mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-08-04 17:01:56 +02:00
Merge remote-tracking branch 'origin/main' into nl/2848-screenreader-outputs-bundle
This commit is contained in:
commit
722e6e49c8
70 changed files with 2698 additions and 740 deletions
51
.github/pull_request_template.md
vendored
51
.github/pull_request_template.md
vendored
|
@ -1,11 +1,7 @@
|
|||
## Ticket
|
||||
|
||||
<!-- PR title format: `#issue_number: Descriptive name ideally matching ticket name - [sandbox]`-->
|
||||
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
|
||||
|
||||
|
@ -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
|
||||
- [ ] Created/modified automated tests
|
||||
- [ ] Added at least 2 developers as PR reviewers (only 1 will need to approve)
|
||||
- [ ] 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.
|
||||
- [ ] Update documentation in READMEs and/or onboarding guide
|
||||
|
||||
#### Ensured code standards are met (Original Developer)
|
||||
|
||||
- [ ] All new functions and methods are commented using plain language
|
||||
- [ ] Did dependency updates in Pipfile also get changed in requirements.txt?
|
||||
<!-- Mark "- N/A" and check at the end of each check that is not applicable to your PR -->
|
||||
- [ ] If any updated dependencies on Pipfile, also update dependencies in requirements.txt.
|
||||
- [ ] Interactions with external systems are wrapped in try/except
|
||||
- [ ] Error handling exists for unusual or missing values
|
||||
|
||||
#### 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
|
||||
- [ ] 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
|
||||
|
||||
#### Reviewed, tested, and left feedback about the changes
|
||||
|
||||
- [ ] 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
|
||||
- [ ] Made it clear which comments need to be addressed before this work is merged
|
||||
- [ ] 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?
|
||||
- [ ] Verify migrations are valid and do not conflict with existing migrations
|
||||
|
||||
#### 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
|
||||
- [ ] Checked keyboard navigability
|
||||
- [ ] 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 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
|
||||
|
||||
**Note:** Multiple code reviewers can share the checklists above, a second reviewers should not make a duplicate checklist
|
||||
|
||||
### As a designer reviewer, I have
|
||||
|
||||
#### Verified that the changes match the design intention
|
||||
|
||||
- [ ] 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 for landmarks, page heading structure, and links
|
||||
- [ ] Tried to break the intended flow
|
||||
|
||||
#### Validated user-facing changes as a designer
|
||||
|
||||
- [ ] Checked keyboard navigability
|
||||
- [ ] 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)
|
||||
- [ ] Chrome
|
||||
- [ ] Microsoft Edge
|
||||
- [ ] FireFox
|
||||
- [ ] Safari
|
||||
|
||||
- [ ] (Rarely needed) Tested as both an analyst and applicant user
|
||||
|
||||
### References
|
||||
- [Code review best practices](../docs/dev-practices/code_review.md)
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- If this PR makes visible interface changes, an image of the finished interface can help reviewers
|
||||
|
|
|
@ -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
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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`.
|
||||
|
|
31
docs/dev-practices/code_review.md
Normal file
31
docs/dev-practices/code_review.md
Normal 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.
|
|
@ -5,6 +5,11 @@ from django import forms
|
|||
from django.db.models import Value, CharField, Q
|
||||
from django.db.models.functions import Concat, Coalesce
|
||||
from django.http import HttpResponseRedirect
|
||||
from registrar.utility.admin_helpers import (
|
||||
get_action_needed_reason_default_email,
|
||||
get_rejection_reason_default_email,
|
||||
get_field_links_as_list,
|
||||
)
|
||||
from django.conf import settings
|
||||
from django.shortcuts import redirect
|
||||
from django_fsm import get_available_FIELD_transitions, FSMField
|
||||
|
@ -20,11 +25,6 @@ from epplibwrapper.errors import ErrorCode, RegistryError
|
|||
from registrar.models.user_domain_role import UserDomainRole
|
||||
from waffle.admin import FlagAdmin
|
||||
from waffle.models import Sample, Switch
|
||||
from registrar.utility.admin_helpers import (
|
||||
get_all_action_needed_reason_emails,
|
||||
get_action_needed_reason_default_email,
|
||||
get_field_links_as_list,
|
||||
)
|
||||
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
|
||||
from registrar.utility.constants import BranchChoices
|
||||
from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
|
||||
|
@ -190,11 +190,11 @@ class PortfolioInvitationAdminForm(UserChangeForm):
|
|||
model = models.PortfolioInvitation
|
||||
fields = "__all__"
|
||||
widgets = {
|
||||
"portfolio_roles": FilteredSelectMultipleArrayWidget(
|
||||
"portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
|
||||
"roles": FilteredSelectMultipleArrayWidget(
|
||||
"roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices
|
||||
),
|
||||
"portfolio_additional_permissions": FilteredSelectMultipleArrayWidget(
|
||||
"portfolio_additional_permissions",
|
||||
"additional_permissions": FilteredSelectMultipleArrayWidget(
|
||||
"additional_permissions",
|
||||
is_stacked=False,
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
),
|
||||
|
@ -237,6 +237,7 @@ class DomainRequestAdminForm(forms.ModelForm):
|
|||
}
|
||||
labels = {
|
||||
"action_needed_reason_email": "Email",
|
||||
"rejection_reason_email": "Email",
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
@ -1408,8 +1409,8 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
|
|||
list_display = [
|
||||
"email",
|
||||
"portfolio",
|
||||
"portfolio_roles",
|
||||
"portfolio_additional_permissions",
|
||||
"roles",
|
||||
"additional_permissions",
|
||||
"status",
|
||||
]
|
||||
|
||||
|
@ -1750,6 +1751,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
"status_history",
|
||||
"status",
|
||||
"rejection_reason",
|
||||
"rejection_reason_email",
|
||||
"action_needed_reason",
|
||||
"action_needed_reason_email",
|
||||
"investigator",
|
||||
|
@ -1905,25 +1907,11 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
# Get the original domain request from the database.
|
||||
original_obj = models.DomainRequest.objects.get(pk=obj.pk)
|
||||
|
||||
# == Handle action_needed_reason == #
|
||||
|
||||
reason_changed = obj.action_needed_reason != original_obj.action_needed_reason
|
||||
if reason_changed:
|
||||
# Track the fact that we sent out an email
|
||||
request.session["action_needed_email_sent"] = True
|
||||
|
||||
# Set the action_needed_reason_email to the default if nothing exists.
|
||||
# Since this check occurs after save, if the user enters a value then we won't update.
|
||||
|
||||
default_email = get_action_needed_reason_default_email(request, obj, obj.action_needed_reason)
|
||||
if obj.action_needed_reason_email:
|
||||
emails = get_all_action_needed_reason_emails(request, obj)
|
||||
is_custom_email = obj.action_needed_reason_email not in emails.values()
|
||||
if not is_custom_email:
|
||||
obj.action_needed_reason_email = default_email
|
||||
else:
|
||||
obj.action_needed_reason_email = default_email
|
||||
# == Handle action needed and rejected emails == #
|
||||
# Edge case: this logic is handled by javascript, so contexts outside that must be handled
|
||||
obj = self._handle_custom_emails(obj)
|
||||
|
||||
# == Handle allowed emails == #
|
||||
if obj.status in DomainRequest.get_statuses_that_send_emails() and not settings.IS_PRODUCTION:
|
||||
self._check_for_valid_email(request, obj)
|
||||
|
||||
|
@ -1939,6 +1927,15 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
if should_save:
|
||||
return super().save_model(request, obj, form, change)
|
||||
|
||||
def _handle_custom_emails(self, obj):
|
||||
if obj.status == DomainRequest.DomainRequestStatus.ACTION_NEEDED:
|
||||
if obj.action_needed_reason and not obj.action_needed_reason_email:
|
||||
obj.action_needed_reason_email = get_action_needed_reason_default_email(obj, obj.action_needed_reason)
|
||||
elif obj.status == DomainRequest.DomainRequestStatus.REJECTED:
|
||||
if obj.rejection_reason and not obj.rejection_reason_email:
|
||||
obj.rejection_reason_email = get_rejection_reason_default_email(obj, obj.rejection_reason)
|
||||
return obj
|
||||
|
||||
def _check_for_valid_email(self, request, obj):
|
||||
"""Certain emails are whitelisted in non-production environments,
|
||||
so we should display that information using this function.
|
||||
|
@ -2476,7 +2473,10 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
|
|||
generic_org_type.admin_order_field = "domain_info__generic_org_type" # type: ignore
|
||||
|
||||
def federal_agency(self, obj):
|
||||
return obj.domain_info.federal_agency if obj.domain_info else None
|
||||
if obj.domain_info:
|
||||
return obj.domain_info.federal_agency
|
||||
else:
|
||||
return None
|
||||
|
||||
federal_agency.admin_order_field = "domain_info__federal_agency" # type: ignore
|
||||
|
||||
|
|
|
@ -344,69 +344,6 @@ function initializeWidgetOnList(list, parentId) {
|
|||
}
|
||||
}
|
||||
|
||||
/** An IIFE for admin in DjangoAdmin to listen to changes on the domain request
|
||||
* status select and to show/hide the rejection reason
|
||||
*/
|
||||
(function (){
|
||||
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
|
||||
// This is the "action needed reason" field
|
||||
let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason');
|
||||
// This is the "Email" field
|
||||
let actionNeededReasonEmailFormGroup = document.querySelector('.field-action_needed_reason_email')
|
||||
|
||||
if (rejectionReasonFormGroup && actionNeededReasonFormGroup && actionNeededReasonEmailFormGroup) {
|
||||
let statusSelect = document.getElementById('id_status')
|
||||
let isRejected = statusSelect.value == "rejected"
|
||||
let isActionNeeded = statusSelect.value == "action needed"
|
||||
|
||||
// Initial handling of rejectionReasonFormGroup display
|
||||
showOrHideObject(rejectionReasonFormGroup, show=isRejected)
|
||||
showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded)
|
||||
showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
|
||||
|
||||
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
|
||||
statusSelect.addEventListener('change', function() {
|
||||
// Show the rejection reason field if the status is rejected.
|
||||
// Then track if its shown or hidden in our session cache.
|
||||
isRejected = statusSelect.value == "rejected"
|
||||
showOrHideObject(rejectionReasonFormGroup, show=isRejected)
|
||||
addOrRemoveSessionBoolean("showRejectionReason", add=isRejected)
|
||||
|
||||
isActionNeeded = statusSelect.value == "action needed"
|
||||
showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded)
|
||||
showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
|
||||
addOrRemoveSessionBoolean("showActionNeededReason", add=isActionNeeded)
|
||||
});
|
||||
|
||||
// Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage
|
||||
|
||||
// When you navigate using forward/back after changing status but not saving, when you land back on the DA page the
|
||||
// status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide
|
||||
// accurately for this edge case, we use cache and test for the back/forward navigation.
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
list.getEntries().forEach((entry) => {
|
||||
if (entry.type === "back_forward") {
|
||||
let showRejectionReason = sessionStorage.getItem("showRejectionReason") !== null
|
||||
showOrHideObject(rejectionReasonFormGroup, show=showRejectionReason)
|
||||
|
||||
let showActionNeededReason = sessionStorage.getItem("showActionNeededReason") !== null
|
||||
showOrHideObject(actionNeededReasonFormGroup, show=showActionNeededReason)
|
||||
showOrHideObject(actionNeededReasonEmailFormGroup, show=isActionNeeded)
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe({ type: "navigation" });
|
||||
}
|
||||
|
||||
// Adds or removes the display-none class to object depending on the value of boolean show
|
||||
function showOrHideObject(object, show){
|
||||
if (show){
|
||||
object.classList.remove("display-none");
|
||||
}else {
|
||||
object.classList.add("display-none");
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
/** An IIFE for toggling the submit bar on domain request forms
|
||||
*/
|
||||
|
@ -501,86 +438,110 @@ function initializeWidgetOnList(list, parentId) {
|
|||
})();
|
||||
|
||||
|
||||
/** An IIFE that hooks to the show/hide button underneath action needed reason.
|
||||
* This shows the auto generated email on action needed reason.
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const dropdown = document.getElementById("id_action_needed_reason");
|
||||
const textarea = document.getElementById("id_action_needed_reason_email")
|
||||
const domainRequestId = dropdown ? document.getElementById("domain_request_id").value : null
|
||||
const textareaPlaceholder = document.querySelector(".field-action_needed_reason_email__placeholder");
|
||||
const directEditButton = document.querySelector('.field-action_needed_reason_email__edit');
|
||||
const modalTrigger = document.querySelector('.field-action_needed_reason_email__modal-trigger');
|
||||
const modalConfirm = document.getElementById('confirm-edit-email');
|
||||
const formLabel = document.querySelector('label[for="id_action_needed_reason_email"]');
|
||||
let lastSentEmailContent = document.getElementById("last-sent-email-content");
|
||||
const initialDropdownValue = dropdown ? dropdown.value : null;
|
||||
let initialEmailValue;
|
||||
if (textarea)
|
||||
initialEmailValue = textarea.value
|
||||
class CustomizableEmailBase {
|
||||
|
||||
/**
|
||||
* @param {Object} config - must contain the following:
|
||||
* @property {HTMLElement} dropdown - The dropdown element.
|
||||
* @property {HTMLElement} textarea - The textarea element.
|
||||
* @property {HTMLElement} lastSentEmailContent - The last sent email content element.
|
||||
* @property {HTMLElement} textAreaFormGroup - The form group for the textarea.
|
||||
* @property {HTMLElement} dropdownFormGroup - The form group for the dropdown.
|
||||
* @property {HTMLElement} modalConfirm - The confirm button in the modal.
|
||||
* @property {string} apiUrl - The API URL for fetching email content.
|
||||
* @property {string} statusToCheck - The status to check against. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
|
||||
* @property {string} sessionVariableName - The session variable name. Used for show/hide on textAreaFormGroup/dropdownFormGroup.
|
||||
* @property {string} apiErrorMessage - The error message that the ajax call returns.
|
||||
*/
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.dropdown = config.dropdown;
|
||||
this.textarea = config.textarea;
|
||||
this.lastSentEmailContent = config.lastSentEmailContent;
|
||||
this.apiUrl = config.apiUrl;
|
||||
this.apiErrorMessage = config.apiErrorMessage;
|
||||
this.modalConfirm = config.modalConfirm;
|
||||
|
||||
// These fields are hidden/shown on pageload depending on the current status
|
||||
this.textAreaFormGroup = config.textAreaFormGroup;
|
||||
this.dropdownFormGroup = config.dropdownFormGroup;
|
||||
this.statusToCheck = config.statusToCheck;
|
||||
this.sessionVariableName = config.sessionVariableName;
|
||||
|
||||
// Non-configurable variables
|
||||
this.statusSelect = document.getElementById("id_status");
|
||||
this.domainRequestId = this.dropdown ? document.getElementById("domain_request_id").value : null
|
||||
this.initialDropdownValue = this.dropdown ? this.dropdown.value : null;
|
||||
this.initialEmailValue = this.textarea ? this.textarea.value : null;
|
||||
|
||||
// Find other fields near the textarea
|
||||
const parentDiv = this.textarea ? this.textarea.closest(".flex-container") : null;
|
||||
this.directEditButton = parentDiv ? parentDiv.querySelector(".edit-email-button") : null;
|
||||
this.modalTrigger = parentDiv ? parentDiv.querySelector(".edit-button-modal-trigger") : null;
|
||||
|
||||
this.textareaPlaceholder = parentDiv ? parentDiv.querySelector(".custom-email-placeholder") : null;
|
||||
this.formLabel = this.textarea ? document.querySelector(`label[for="${this.textarea.id}"]`) : null;
|
||||
|
||||
this.isEmailAlreadySentConst;
|
||||
if (this.lastSentEmailContent && this.textarea) {
|
||||
this.isEmailAlreadySentConst = this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
// We will use the const to control the modal
|
||||
let isEmailAlreadySentConst;
|
||||
if (lastSentEmailContent)
|
||||
isEmailAlreadySentConst = lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, '');
|
||||
// We will use the function to control the label and help
|
||||
function isEmailAlreadySent() {
|
||||
return lastSentEmailContent.value.replace(/\s+/g, '') === textarea.value.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
if (!dropdown || !textarea || !domainRequestId || !formLabel || !modalConfirm) return;
|
||||
const apiUrl = document.getElementById("get-action-needed-email-for-user-json").value;
|
||||
// Handle showing/hiding the related fields on page load.
|
||||
initializeFormGroups() {
|
||||
let isStatus = this.statusSelect.value == this.statusToCheck;
|
||||
|
||||
function updateUserInterface(reason) {
|
||||
if (!reason) {
|
||||
// No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text
|
||||
formLabel.innerHTML = "Email:";
|
||||
textareaPlaceholder.innerHTML = "Select an action needed reason to see email";
|
||||
showElement(textareaPlaceholder);
|
||||
hideElement(directEditButton);
|
||||
hideElement(modalTrigger);
|
||||
hideElement(textarea);
|
||||
} else if (reason === 'other') {
|
||||
// 'Other' selected, we will set the label to "Email", show the "No email will be sent" placeholder, hide the trigger, textarea, hide the help text
|
||||
formLabel.innerHTML = "Email:";
|
||||
textareaPlaceholder.innerHTML = "No email will be sent";
|
||||
showElement(textareaPlaceholder);
|
||||
hideElement(directEditButton);
|
||||
hideElement(modalTrigger);
|
||||
hideElement(textarea);
|
||||
} else {
|
||||
// A triggering selection is selected, all hands on board:
|
||||
textarea.setAttribute('readonly', true);
|
||||
showElement(textarea);
|
||||
hideElement(textareaPlaceholder);
|
||||
// Initial handling of these groups.
|
||||
this.updateFormGroupVisibility(isStatus);
|
||||
|
||||
if (isEmailAlreadySentConst) {
|
||||
hideElement(directEditButton);
|
||||
showElement(modalTrigger);
|
||||
} else {
|
||||
showElement(directEditButton);
|
||||
hideElement(modalTrigger);
|
||||
}
|
||||
if (isEmailAlreadySent()) {
|
||||
formLabel.innerHTML = "Email sent to creator:";
|
||||
} else {
|
||||
formLabel.innerHTML = "Email:";
|
||||
}
|
||||
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
|
||||
this.statusSelect.addEventListener('change', () => {
|
||||
// Show the action needed field if the status is what we expect.
|
||||
// Then track if its shown or hidden in our session cache.
|
||||
isStatus = this.statusSelect.value == this.statusToCheck;
|
||||
this.updateFormGroupVisibility(isStatus);
|
||||
addOrRemoveSessionBoolean(this.sessionVariableName, isStatus);
|
||||
});
|
||||
|
||||
// Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage
|
||||
// When you navigate using forward/back after changing status but not saving, when you land back on the DA page the
|
||||
// status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide
|
||||
// accurately for this edge case, we use cache and test for the back/forward navigation.
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
list.getEntries().forEach((entry) => {
|
||||
if (entry.type === "back_forward") {
|
||||
let showTextAreaFormGroup = sessionStorage.getItem(this.sessionVariableName) !== null;
|
||||
this.updateFormGroupVisibility(showTextAreaFormGroup);
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe({ type: "navigation" });
|
||||
}
|
||||
|
||||
updateFormGroupVisibility(showFormGroups) {
|
||||
if (showFormGroups) {
|
||||
showElement(this.textAreaFormGroup);
|
||||
showElement(this.dropdownFormGroup);
|
||||
}else {
|
||||
hideElement(this.textAreaFormGroup);
|
||||
hideElement(this.dropdownFormGroup);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize UI
|
||||
updateUserInterface(dropdown.value);
|
||||
|
||||
dropdown.addEventListener("change", function() {
|
||||
const reason = dropdown.value;
|
||||
// Update the UI
|
||||
updateUserInterface(reason);
|
||||
if (reason && reason !== "other") {
|
||||
// If it's not the initial value
|
||||
if (initialDropdownValue !== dropdown.value || initialEmailValue !== textarea.value) {
|
||||
initializeDropdown() {
|
||||
this.dropdown.addEventListener("change", () => {
|
||||
let reason = this.dropdown.value;
|
||||
if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) {
|
||||
let searchParams = new URLSearchParams(
|
||||
{
|
||||
"reason": reason,
|
||||
"domain_request_id": this.domainRequestId,
|
||||
}
|
||||
);
|
||||
// Replace the email content
|
||||
fetch(`${apiUrl}?reason=${reason}&domain_request_id=${domainRequestId}`)
|
||||
fetch(`${this.apiUrl}?${searchParams.toString()}`)
|
||||
.then(response => {
|
||||
return response.json().then(data => data);
|
||||
})
|
||||
|
@ -588,30 +549,213 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
if (data.error) {
|
||||
console.error("Error in AJAX call: " + data.error);
|
||||
}else {
|
||||
textarea.value = data.action_needed_email;
|
||||
this.textarea.value = data.email;
|
||||
}
|
||||
updateUserInterface(reason);
|
||||
this.updateUserInterface(reason);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error action needed email: ", error)
|
||||
console.error(this.apiErrorMessage, error)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initializeModalConfirm() {
|
||||
this.modalConfirm.addEventListener("click", () => {
|
||||
this.textarea.removeAttribute('readonly');
|
||||
this.textarea.focus();
|
||||
hideElement(this.directEditButton);
|
||||
hideElement(this.modalTrigger);
|
||||
});
|
||||
}
|
||||
|
||||
initializeDirectEditButton() {
|
||||
this.directEditButton.addEventListener("click", () => {
|
||||
this.textarea.removeAttribute('readonly');
|
||||
this.textarea.focus();
|
||||
hideElement(this.directEditButton);
|
||||
hideElement(this.modalTrigger);
|
||||
});
|
||||
}
|
||||
|
||||
isEmailAlreadySent() {
|
||||
return this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
updateUserInterface(reason=this.dropdown.value, excluded_reasons=["other"]) {
|
||||
if (!reason) {
|
||||
// No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text
|
||||
this.showPlaceholderNoReason();
|
||||
} else if (excluded_reasons.includes(reason)) {
|
||||
// 'Other' selected, we will set the label to "Email", show the "No email will be sent" placeholder, hide the trigger, textarea, hide the help text
|
||||
this.showPlaceholderOtherReason();
|
||||
} else {
|
||||
this.showReadonlyTextarea();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function that makes overriding the readonly textarea easy
|
||||
showReadonlyTextarea() {
|
||||
// A triggering selection is selected, all hands on board:
|
||||
this.textarea.setAttribute('readonly', true);
|
||||
showElement(this.textarea);
|
||||
hideElement(this.textareaPlaceholder);
|
||||
|
||||
if (this.isEmailAlreadySentConst) {
|
||||
hideElement(this.directEditButton);
|
||||
showElement(this.modalTrigger);
|
||||
} else {
|
||||
showElement(this.directEditButton);
|
||||
hideElement(this.modalTrigger);
|
||||
}
|
||||
|
||||
});
|
||||
if (this.isEmailAlreadySent()) {
|
||||
this.formLabel.innerHTML = "Email sent to creator:";
|
||||
} else {
|
||||
this.formLabel.innerHTML = "Email:";
|
||||
}
|
||||
}
|
||||
|
||||
modalConfirm.addEventListener("click", () => {
|
||||
textarea.removeAttribute('readonly');
|
||||
textarea.focus();
|
||||
hideElement(directEditButton);
|
||||
hideElement(modalTrigger);
|
||||
});
|
||||
directEditButton.addEventListener("click", () => {
|
||||
textarea.removeAttribute('readonly');
|
||||
textarea.focus();
|
||||
hideElement(directEditButton);
|
||||
hideElement(modalTrigger);
|
||||
});
|
||||
// Helper function that makes overriding the placeholder reason easy
|
||||
showPlaceholderNoReason() {
|
||||
this.showPlaceholder("Email:", "Select a reason to see email");
|
||||
}
|
||||
|
||||
// Helper function that makes overriding the placeholder reason easy
|
||||
showPlaceholderOtherReason() {
|
||||
this.showPlaceholder("Email:", "No email will be sent");
|
||||
}
|
||||
|
||||
showPlaceholder(formLabelText, placeholderText) {
|
||||
this.formLabel.innerHTML = formLabelText;
|
||||
this.textareaPlaceholder.innerHTML = placeholderText;
|
||||
showElement(this.textareaPlaceholder);
|
||||
hideElement(this.directEditButton);
|
||||
hideElement(this.modalTrigger);
|
||||
hideElement(this.textarea);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class customActionNeededEmail extends CustomizableEmailBase {
|
||||
constructor() {
|
||||
const emailConfig = {
|
||||
dropdown: document.getElementById("id_action_needed_reason"),
|
||||
textarea: document.getElementById("id_action_needed_reason_email"),
|
||||
lastSentEmailContent: document.getElementById("last-sent-action-needed-email-content"),
|
||||
modalConfirm: document.getElementById("action-needed-reason__confirm-edit-email"),
|
||||
apiUrl: document.getElementById("get-action-needed-email-for-user-json")?.value || null,
|
||||
textAreaFormGroup: document.querySelector('.field-action_needed_reason'),
|
||||
dropdownFormGroup: document.querySelector('.field-action_needed_reason_email'),
|
||||
statusToCheck: "action needed",
|
||||
sessionVariableName: "showActionNeededReason",
|
||||
apiErrorMessage: "Error when attempting to grab action needed email: "
|
||||
}
|
||||
super(emailConfig);
|
||||
}
|
||||
|
||||
loadActionNeededEmail() {
|
||||
// Hide/show the email fields depending on the current status
|
||||
this.initializeFormGroups();
|
||||
// Setup the textarea, edit button, helper text
|
||||
this.updateUserInterface();
|
||||
this.initializeDropdown();
|
||||
this.initializeModalConfirm();
|
||||
this.initializeDirectEditButton();
|
||||
}
|
||||
|
||||
// Overrides the placeholder text when no reason is selected
|
||||
showPlaceholderNoReason() {
|
||||
this.showPlaceholder("Email:", "Select an action needed reason to see email");
|
||||
}
|
||||
|
||||
// Overrides the placeholder text when the reason other is selected
|
||||
showPlaceholderOtherReason() {
|
||||
this.showPlaceholder("Email:", "No email will be sent");
|
||||
}
|
||||
}
|
||||
|
||||
/** An IIFE that hooks to the show/hide button underneath action needed reason.
|
||||
* This shows the auto generated email on action needed reason.
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const domainRequestForm = document.getElementById("domainrequest_form");
|
||||
if (!domainRequestForm) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize UI
|
||||
const customEmail = new customActionNeededEmail();
|
||||
|
||||
// Check that every variable was setup correctly
|
||||
const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key);
|
||||
if (nullItems.length > 0) {
|
||||
console.error(`Failed to load customActionNeededEmail(). Some variables were null: ${nullItems.join(", ")}`)
|
||||
return;
|
||||
}
|
||||
customEmail.loadActionNeededEmail()
|
||||
});
|
||||
|
||||
|
||||
class customRejectedEmail extends CustomizableEmailBase {
|
||||
constructor() {
|
||||
const emailConfig = {
|
||||
dropdown: document.getElementById("id_rejection_reason"),
|
||||
textarea: document.getElementById("id_rejection_reason_email"),
|
||||
lastSentEmailContent: document.getElementById("last-sent-rejection-email-content"),
|
||||
modalConfirm: document.getElementById("rejection-reason__confirm-edit-email"),
|
||||
apiUrl: document.getElementById("get-rejection-email-for-user-json")?.value || null,
|
||||
textAreaFormGroup: document.querySelector('.field-rejection_reason'),
|
||||
dropdownFormGroup: document.querySelector('.field-rejection_reason_email'),
|
||||
statusToCheck: "rejected",
|
||||
sessionVariableName: "showRejectionReason",
|
||||
errorMessage: "Error when attempting to grab rejected email: "
|
||||
};
|
||||
super(emailConfig);
|
||||
}
|
||||
|
||||
loadRejectedEmail() {
|
||||
this.initializeFormGroups();
|
||||
this.updateUserInterface();
|
||||
this.initializeDropdown();
|
||||
this.initializeModalConfirm();
|
||||
this.initializeDirectEditButton();
|
||||
}
|
||||
|
||||
// Overrides the placeholder text when no reason is selected
|
||||
showPlaceholderNoReason() {
|
||||
this.showPlaceholder("Email:", "Select a rejection reason to see email");
|
||||
}
|
||||
|
||||
updateUserInterface(reason=this.dropdown.value, excluded_reasons=[]) {
|
||||
super.updateUserInterface(reason, excluded_reasons);
|
||||
}
|
||||
// Overrides the placeholder text when the reason other is selected
|
||||
// showPlaceholderOtherReason() {
|
||||
// this.showPlaceholder("Email:", "No email will be sent");
|
||||
// }
|
||||
}
|
||||
|
||||
|
||||
/** An IIFE that hooks to the show/hide button underneath rejected reason.
|
||||
* This shows the auto generated email on action needed reason.
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const domainRequestForm = document.getElementById("domainrequest_form");
|
||||
if (!domainRequestForm) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize UI
|
||||
const customEmail = new customRejectedEmail();
|
||||
// Check that every variable was setup correctly
|
||||
const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key);
|
||||
if (nullItems.length > 0) {
|
||||
console.error(`Failed to load customRejectedEmail(). Some variables were null: ${nullItems.join(", ")}`)
|
||||
return;
|
||||
}
|
||||
customEmail.loadRejectedEmail()
|
||||
});
|
||||
|
||||
|
||||
|
|
|
@ -1886,11 +1886,10 @@ class MembersTable extends LoadTableBase {
|
|||
* @param {*} sortBy - the sort column option
|
||||
* @param {*} order - the sort order {asc, desc}
|
||||
* @param {*} scroll - control for the scrollToElement functionality
|
||||
* @param {*} status - control for the status filter
|
||||
* @param {*} searchTerm - the search term
|
||||
* @param {*} portfolio - the portfolio id
|
||||
*/
|
||||
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) {
|
||||
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) {
|
||||
|
||||
// --------- SEARCH
|
||||
let searchParams = new URLSearchParams(
|
||||
|
@ -1898,7 +1897,6 @@ class MembersTable extends LoadTableBase {
|
|||
"page": page,
|
||||
"sort_by": sortBy,
|
||||
"order": order,
|
||||
"status": status,
|
||||
"search_term": searchTerm
|
||||
}
|
||||
);
|
||||
|
@ -1934,11 +1932,40 @@ class MembersTable extends LoadTableBase {
|
|||
const memberList = document.querySelector('.members__table tbody');
|
||||
memberList.innerHTML = '';
|
||||
|
||||
const invited = 'Invited';
|
||||
|
||||
data.members.forEach(member => {
|
||||
// const actionUrl = domain.action_url;
|
||||
const member_name = member.name;
|
||||
const member_email = member.email;
|
||||
const last_active = member.last_active;
|
||||
const member_display = member.member_display;
|
||||
const options = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||
|
||||
// Handle last_active values
|
||||
let last_active = member.last_active;
|
||||
let last_active_formatted = '';
|
||||
let last_active_sort_value = '';
|
||||
|
||||
// Handle 'Invited' or null/empty values differently from valid dates
|
||||
if (last_active && last_active !== invited) {
|
||||
try {
|
||||
// Try to parse the last_active as a valid date
|
||||
last_active = new Date(last_active);
|
||||
if (!isNaN(last_active)) {
|
||||
last_active_formatted = last_active.toLocaleDateString('en-US', options);
|
||||
last_active_sort_value = last_active.getTime(); // For sorting purposes
|
||||
} else {
|
||||
last_active_formatted='Invalid date'
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error parsing date: ${last_active}. Error: ${e}`);
|
||||
last_active_formatted='Invalid date'
|
||||
}
|
||||
} else {
|
||||
// Handle 'Invited' or null
|
||||
last_active = invited;
|
||||
last_active_formatted = invited;
|
||||
last_active_sort_value = invited; // Keep 'Invited' as a sortable string
|
||||
}
|
||||
|
||||
const action_url = member.action_url;
|
||||
const action_label = member.action_label;
|
||||
const svg_icon = member.svg_icon;
|
||||
|
@ -1951,10 +1978,10 @@ class MembersTable extends LoadTableBase {
|
|||
|
||||
row.innerHTML = `
|
||||
<th scope="row" role="rowheader" data-label="member email">
|
||||
${member_email ? member_email : member_name} ${admin_tagHTML}
|
||||
${member_display} ${admin_tagHTML}
|
||||
</th>
|
||||
<td data-sort-value="${last_active}" data-label="last_active">
|
||||
${last_active}
|
||||
<td data-sort-value="${last_active_sort_value}" data-label="last_active">
|
||||
${last_active_formatted}
|
||||
</td>
|
||||
<td>
|
||||
<a href="${action_url}">
|
||||
|
|
|
@ -25,6 +25,8 @@
|
|||
/**
|
||||
* Edits made for dotgov project:
|
||||
* - tooltip exposed to window to be accessible in other js files
|
||||
* - tooltip positioning logic updated to allow position:fixed
|
||||
* - tooltip dynamic content updated to include nested element (for better sizing control)
|
||||
* - modal exposed to window to be accessible in other js files
|
||||
* - fixed bug in createHeaderButton which added newlines to header button tooltips
|
||||
*/
|
||||
|
@ -5938,6 +5940,22 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
|||
return offset;
|
||||
};
|
||||
|
||||
// ---- DOTGOV EDIT (Added section)
|
||||
// DOTGOV: Tooltip positioning logic updated to allow position:fixed
|
||||
const tooltipStyle = window.getComputedStyle(tooltipBody);
|
||||
const tooltipIsFixedPositioned = tooltipStyle.position === 'fixed';
|
||||
const triggerRect = tooltipTrigger.getBoundingClientRect(); //detect if tooltip is set to "fixed" position
|
||||
const targetLeft = tooltipIsFixedPositioned ? triggerRect.left + triggerRect.width/2 + 'px': `50%`
|
||||
const targetTop = tooltipIsFixedPositioned ? triggerRect.top + triggerRect.height/2 + 'px': `50%`
|
||||
if (tooltipIsFixedPositioned) {
|
||||
/* DOTGOV: Add listener to handle scrolling if tooltip position = 'fixed'
|
||||
(so that the tooltip doesn't appear to stick to the screen) */
|
||||
window.addEventListener('scroll', function() {
|
||||
findBestPosition(tooltipBody)
|
||||
});
|
||||
}
|
||||
// ---- END DOTGOV EDIT
|
||||
|
||||
/**
|
||||
* Positions tooltip at the top
|
||||
* @param {HTMLElement} e - this is the tooltip body
|
||||
|
@ -5949,8 +5967,16 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
|||
const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger);
|
||||
const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger);
|
||||
setPositionClass("top");
|
||||
e.style.left = `50%`; // center the element
|
||||
e.style.top = `-${TRIANGLE_SIZE}px`; // consider the pseudo element
|
||||
|
||||
// ---- DOTGOV EDIT
|
||||
// e.style.left = `50%`; // center the element
|
||||
// e.style.top = `-${TRIANGLE_SIZE}px`; // consider the pseudo element
|
||||
|
||||
// DOTGOV: updated logic for position:fixed
|
||||
e.style.left = targetLeft; // center the element
|
||||
e.style.top = tooltipIsFixedPositioned ?`${triggerRect.top-TRIANGLE_SIZE}px`:`-${TRIANGLE_SIZE}px`; // consider the pseudo element
|
||||
// ---- END DOTGOV EDIT
|
||||
|
||||
// apply our margins based on the offset
|
||||
e.style.margin = `-${topMargin}px 0 0 -${leftMargin / 2}px`;
|
||||
};
|
||||
|
@ -5963,7 +5989,17 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
|||
resetPositionStyles(e);
|
||||
const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger);
|
||||
setPositionClass("bottom");
|
||||
e.style.left = `50%`;
|
||||
|
||||
// ---- DOTGOV EDIT
|
||||
// e.style.left = `50%`;
|
||||
|
||||
// DOTGOV: updated logic for position:fixed
|
||||
if (tooltipIsFixedPositioned){
|
||||
e.style.top = triggerRect.bottom+'px';
|
||||
}
|
||||
// ---- END DOTGOV EDIT
|
||||
|
||||
e.style.left = targetLeft;
|
||||
e.style.margin = `${TRIANGLE_SIZE}px 0 0 -${leftMargin / 2}px`;
|
||||
};
|
||||
|
||||
|
@ -5975,8 +6011,16 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
|||
resetPositionStyles(e);
|
||||
const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger);
|
||||
setPositionClass("right");
|
||||
e.style.top = `50%`;
|
||||
e.style.left = `${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`;
|
||||
|
||||
// ---- DOTGOV EDIT
|
||||
// e.style.top = `50%`;
|
||||
// e.style.left = `${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`;
|
||||
|
||||
// DOTGOV: updated logic for position:fixed
|
||||
e.style.top = targetTop;
|
||||
e.style.left = tooltipIsFixedPositioned ? `${triggerRect.right + TRIANGLE_SIZE}px`:`${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`;
|
||||
// ---- END DOTGOV EDIT
|
||||
|
||||
e.style.margin = `-${topMargin / 2}px 0 0 0`;
|
||||
};
|
||||
|
||||
|
@ -5991,8 +6035,16 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
|||
// we have to check for some utility margins
|
||||
const leftMargin = calculateMarginOffset("left", tooltipTrigger.offsetLeft > e.offsetWidth ? tooltipTrigger.offsetLeft - e.offsetWidth : e.offsetWidth, tooltipTrigger);
|
||||
setPositionClass("left");
|
||||
e.style.top = `50%`;
|
||||
e.style.left = `-${TRIANGLE_SIZE}px`;
|
||||
|
||||
// ---- DOTGOV EDIT
|
||||
// e.style.top = `50%`;
|
||||
// e.style.left = `-${TRIANGLE_SIZE}px`;
|
||||
|
||||
// DOTGOV: updated logic for position:fixed
|
||||
e.style.top = targetTop;
|
||||
e.style.left = tooltipIsFixedPositioned ? `${triggerRect.left-TRIANGLE_SIZE}px` : `-${TRIANGLE_SIZE}px`;
|
||||
// ---- END DOTGOV EDIT
|
||||
|
||||
e.style.margin = `-${topMargin / 2}px 0 0 ${tooltipTrigger.offsetLeft > e.offsetWidth ? leftMargin : -leftMargin}px`; // adjust the margin
|
||||
};
|
||||
|
||||
|
@ -6017,6 +6069,7 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
|
|||
if (i < positions.length) {
|
||||
const pos = positions[i];
|
||||
pos(element);
|
||||
|
||||
if (!isElementInViewport(element)) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
tryPositions(i += 1);
|
||||
|
@ -6128,7 +6181,17 @@ const setUpAttributes = tooltipTrigger => {
|
|||
tooltipBody.setAttribute("aria-hidden", "true");
|
||||
|
||||
// place the text in the tooltip
|
||||
tooltipBody.textContent = tooltipContent;
|
||||
|
||||
// -- DOTGOV EDIT
|
||||
// tooltipBody.textContent = tooltipContent;
|
||||
|
||||
// DOTGOV: nest the text element to allow us greater control over width and wrapping behavior
|
||||
tooltipBody.innerHTML = `
|
||||
<span class="usa-tooltip__content">
|
||||
${tooltipContent}
|
||||
</span>`
|
||||
// -- END DOTGOV EDIT
|
||||
|
||||
return {
|
||||
tooltipBody,
|
||||
position,
|
||||
|
|
|
@ -254,6 +254,7 @@ a .usa-icon,
|
|||
// Note: Can be simplified by adding text-secondary to delete anchors in tables
|
||||
button.text-secondary,
|
||||
button.text-secondary:hover,
|
||||
.dotgov-table a.text-secondary {
|
||||
a.text-secondary,
|
||||
a.text-secondary:hover {
|
||||
color: $theme-color-error;
|
||||
}
|
||||
|
|
|
@ -28,3 +28,47 @@
|
|||
#extended-logo .usa-tooltip__body {
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
.domains__table {
|
||||
/*
|
||||
Trick tooltips in the domains table to do 2 things...
|
||||
1 - Shrink itself to a padded viewport window
|
||||
(override width and wrapping properties in key areas to constrain tooltip size)
|
||||
2 - NOT be clipped by the table's scrollable view
|
||||
(Set tooltip position to "fixed", which prevents tooltip from being clipped by parent
|
||||
containers. Fixed-position detection was added to uswds positioning logic to update positioning
|
||||
calculations accordingly.)
|
||||
*/
|
||||
.usa-tooltip__body {
|
||||
white-space: inherit;
|
||||
max-width: fit-content; // prevent adjusted widths from being larger than content
|
||||
position: fixed; // prevents clipping by parent containers
|
||||
}
|
||||
/*
|
||||
Override width adustments in this dynamically added class
|
||||
(this is original to the javascript handler as a way to shrink tooltip contents within the viewport,
|
||||
but is insufficient for our needs. We cannot simply override its properties
|
||||
because the logic surrounding its dynamic appearance in the DOM does not account
|
||||
for parent containers (basically, this class isn't in the DOM when we need it).
|
||||
Intercept .usa-tooltip__content instead and nullify the effects of
|
||||
.usa-tooltip__body--wrap to prevent conflicts)
|
||||
*/
|
||||
.usa-tooltip__body--wrap {
|
||||
min-width: inherit;
|
||||
width: inherit;
|
||||
}
|
||||
/*
|
||||
Add width and wrapping to tooltip content in order to confine it to a smaller viewport window.
|
||||
*/
|
||||
.usa-tooltip__content {
|
||||
width: 50vw;
|
||||
text-wrap: wrap;
|
||||
text-align: center;
|
||||
font-size: inherit; //inherit tooltip fontsize of .93rem
|
||||
max-width: fit-content;
|
||||
@include at-media('desktop') {
|
||||
width: 70vw;
|
||||
}
|
||||
display: block;
|
||||
}
|
||||
}
|
|
@ -31,9 +31,10 @@ from registrar.views.utility.api_views import (
|
|||
get_senior_official_from_federal_agency_json,
|
||||
get_federal_and_portfolio_types_from_federal_agency_json,
|
||||
get_action_needed_email_for_user_json,
|
||||
get_rejection_email_for_user_json,
|
||||
)
|
||||
|
||||
from registrar.views.domain_request import Step
|
||||
from registrar.views.domain_request import Step, PortfolioDomainRequestStep
|
||||
from registrar.views.transfer_user import TransferUserView
|
||||
from registrar.views.utility import always_404
|
||||
from api.views import available, rdap, get_current_federal, get_current_full
|
||||
|
@ -61,6 +62,9 @@ for step, view in [
|
|||
(Step.ADDITIONAL_DETAILS, views.AdditionalDetails),
|
||||
(Step.REQUIREMENTS, views.Requirements),
|
||||
(Step.REVIEW, views.Review),
|
||||
# Portfolio steps
|
||||
(PortfolioDomainRequestStep.REQUESTING_ENTITY, views.RequestingEntity),
|
||||
(PortfolioDomainRequestStep.ADDITIONAL_DETAILS, views.PortfolioAdditionalDetails),
|
||||
]:
|
||||
domain_request_urls.append(path(f"{step}/", view.as_view(), name=step))
|
||||
|
||||
|
@ -82,6 +86,26 @@ urlpatterns = [
|
|||
views.PortfolioMembersView.as_view(),
|
||||
name="members",
|
||||
),
|
||||
path(
|
||||
"member/<int:pk>",
|
||||
views.PortfolioMemberView.as_view(),
|
||||
name="member",
|
||||
),
|
||||
path(
|
||||
"member/<int:pk>/permissions",
|
||||
views.PortfolioMemberEditView.as_view(),
|
||||
name="member-permissions",
|
||||
),
|
||||
path(
|
||||
"invitedmember/<int:pk>",
|
||||
views.PortfolioInvitedMemberView.as_view(),
|
||||
name="invitedmember",
|
||||
),
|
||||
path(
|
||||
"invitedmember/<int:pk>/permissions",
|
||||
views.PortfolioInvitedMemberEditView.as_view(),
|
||||
name="invitedmember-permissions",
|
||||
),
|
||||
# path(
|
||||
# "no-organization-members/",
|
||||
# views.PortfolioNoMembersView.as_view(),
|
||||
|
@ -172,6 +196,11 @@ urlpatterns = [
|
|||
get_action_needed_email_for_user_json,
|
||||
name="get-action-needed-email-for-user-json",
|
||||
),
|
||||
path(
|
||||
"admin/api/get-rejection-email-for-user-json/",
|
||||
get_rejection_email_for_user_json,
|
||||
name="get-rejection-email-for-user-json",
|
||||
),
|
||||
path("admin/", admin.site.urls),
|
||||
path(
|
||||
"reports/export_data_type_user/",
|
||||
|
@ -184,7 +213,12 @@ urlpatterns = [
|
|||
name="export_data_type_requests",
|
||||
),
|
||||
path(
|
||||
"domain-request/<id>/edit/",
|
||||
"reports/export_data_type_requests/",
|
||||
ExportDataTypeRequests.as_view(),
|
||||
name="export_data_type_requests",
|
||||
),
|
||||
path(
|
||||
"domain-request/<int:id>/edit/",
|
||||
views.DomainRequestWizard.as_view(),
|
||||
name=views.DomainRequestWizard.EDIT_URL_NAME,
|
||||
),
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import logging
|
||||
import random
|
||||
from faker import Faker
|
||||
from django.db import transaction
|
||||
|
||||
|
@ -7,7 +8,7 @@ from registrar.fixtures.fixtures_users import UserFixture
|
|||
from registrar.models import User
|
||||
from registrar.models.portfolio import Portfolio
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
|
||||
fake = Faker()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -51,22 +52,24 @@ class UserPortfolioPermissionFixture:
|
|||
|
||||
user_portfolio_permissions_to_create = []
|
||||
for user in users:
|
||||
for portfolio in portfolios:
|
||||
try:
|
||||
if not UserPortfolioPermission.objects.filter(user=user, portfolio=portfolio).exists():
|
||||
user_portfolio_permission = UserPortfolioPermission(
|
||||
user=user,
|
||||
portfolio=portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
user_portfolio_permissions_to_create.append(user_portfolio_permission)
|
||||
else:
|
||||
logger.info(
|
||||
f"Permission exists for user '{user.username}' "
|
||||
f"on portfolio '{portfolio.organization_name}'."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
# Assign a random portfolio to a user
|
||||
portfolio = random.choice(portfolios) # nosec
|
||||
try:
|
||||
if not UserPortfolioPermission.objects.filter(user=user, portfolio=portfolio).exists():
|
||||
user_portfolio_permission = UserPortfolioPermission(
|
||||
user=user,
|
||||
portfolio=portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
|
||||
)
|
||||
user_portfolio_permissions_to_create.append(user_portfolio_permission)
|
||||
else:
|
||||
logger.info(
|
||||
f"Permission exists for user '{user.username}' "
|
||||
f"on portfolio '{portfolio.organization_name}'."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(e)
|
||||
|
||||
# Bulk create permissions
|
||||
cls._bulk_create_permissions(user_portfolio_permissions_to_create)
|
||||
|
|
|
@ -13,4 +13,5 @@ from .domain import (
|
|||
)
|
||||
from .portfolio import (
|
||||
PortfolioOrgAddressForm,
|
||||
PortfolioMemberForm,
|
||||
)
|
||||
|
|
|
@ -4,7 +4,7 @@ import logging
|
|||
from django import forms
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator, MaxLengthValidator
|
||||
from django.forms import formset_factory
|
||||
from registrar.models import DomainRequest
|
||||
from registrar.models import DomainRequest, FederalAgency
|
||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
from registrar.models.suborganization import Suborganization
|
||||
from registrar.models.utility.domain_helper import DomainHelper
|
||||
|
@ -35,7 +35,10 @@ class DomainAddUserForm(forms.Form):
|
|||
email = forms.EmailField(
|
||||
label="Email",
|
||||
max_length=None,
|
||||
error_messages={"invalid": ("Enter your email address in the required format, like name@example.com.")},
|
||||
error_messages={
|
||||
"invalid": ("Enter an email address in the required format, like name@example.com."),
|
||||
"required": ("Enter an email address in the required format, like name@example.com."),
|
||||
},
|
||||
validators=[
|
||||
MaxLengthValidator(
|
||||
320,
|
||||
|
@ -285,7 +288,7 @@ class UserForm(forms.ModelForm):
|
|||
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
|
||||
}
|
||||
self.fields["email"].error_messages = {
|
||||
"required": "Enter your email address in the required format, like name@example.com."
|
||||
"required": "Enter an email address in the required format, like name@example.com."
|
||||
}
|
||||
self.fields["phone"].error_messages["required"] = "Enter your phone number."
|
||||
self.domainInfo = None
|
||||
|
@ -342,7 +345,7 @@ class ContactForm(forms.ModelForm):
|
|||
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
|
||||
}
|
||||
self.fields["email"].error_messages = {
|
||||
"required": "Enter your email address in the required format, like name@example.com."
|
||||
"required": "Enter an email address in the required format, like name@example.com."
|
||||
}
|
||||
self.fields["phone"].error_messages["required"] = "Enter your phone number."
|
||||
self.domainInfo = None
|
||||
|
@ -458,9 +461,12 @@ class DomainOrgNameAddressForm(forms.ModelForm):
|
|||
validators=[
|
||||
RegexValidator(
|
||||
"^[0-9]{5}(?:-[0-9]{4})?$|^$",
|
||||
message="Enter a zip code in the required format, like 12345 or 12345-6789.",
|
||||
message="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
|
||||
)
|
||||
],
|
||||
error_messages={
|
||||
"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
|
||||
},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
@ -529,17 +535,25 @@ class DomainOrgNameAddressForm(forms.ModelForm):
|
|||
|
||||
def save(self, commit=True):
|
||||
"""Override the save() method of the BaseModelForm."""
|
||||
|
||||
if self.has_changed():
|
||||
|
||||
# This action should be blocked by the UI, as the text fields are readonly.
|
||||
# If they get past this point, we forbid it this way.
|
||||
# This could be malicious, so lets reserve information for the backend only.
|
||||
if self.is_federal and not self._field_unchanged("federal_agency"):
|
||||
raise ValueError("federal_agency cannot be modified when the generic_org_type is federal")
|
||||
|
||||
if self.is_federal:
|
||||
if not self._field_unchanged("federal_agency"):
|
||||
raise ValueError("federal_agency cannot be modified when the generic_org_type is federal")
|
||||
|
||||
elif self.is_tribal and not self._field_unchanged("organization_name"):
|
||||
raise ValueError("organization_name cannot be modified when the generic_org_type is tribal")
|
||||
|
||||
super().save()
|
||||
else: # If this error that means Non-Federal Agency is missing
|
||||
non_federal_agency_instance = FederalAgency.get_non_federal_agency()
|
||||
self.instance.federal_agency = non_federal_agency_instance
|
||||
|
||||
return super().save(commit=commit)
|
||||
|
||||
def _field_unchanged(self, field_name) -> bool:
|
||||
"""
|
||||
|
|
|
@ -21,6 +21,13 @@ from registrar.utility.constants import BranchChoices
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RequestingEntityForm(RegistrarForm):
|
||||
organization_name = forms.CharField(
|
||||
label="Organization name",
|
||||
error_messages={"required": "Enter the name of your organization."},
|
||||
)
|
||||
|
||||
|
||||
class OrganizationTypeForm(RegistrarForm):
|
||||
generic_org_type = forms.ChoiceField(
|
||||
# use the long names in the domain request form
|
||||
|
@ -140,10 +147,10 @@ class OrganizationContactForm(RegistrarForm):
|
|||
validators=[
|
||||
RegexValidator(
|
||||
"^[0-9]{5}(?:-[0-9]{4})?$|^$",
|
||||
message="Enter a zip code in the form of 12345 or 12345-6789.",
|
||||
message="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
|
||||
)
|
||||
],
|
||||
error_messages={"required": ("Enter a zip code in the form of 12345 or 12345-6789.")},
|
||||
error_messages={"required": ("Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.")},
|
||||
)
|
||||
urbanization = forms.CharField(
|
||||
required=False,
|
||||
|
@ -610,7 +617,8 @@ class CisaRepresentativeForm(BaseDeletableRegistrarForm):
|
|||
max_length=None,
|
||||
required=False,
|
||||
error_messages={
|
||||
"invalid": ("Enter your representative’s email address in the required format, like name@example.com."),
|
||||
"invalid": ("Enter an email address in the required format, like name@example.com."),
|
||||
"required": ("Enter an email address in the required format, like name@example.com."),
|
||||
},
|
||||
validators=[
|
||||
MaxLengthValidator(
|
||||
|
|
|
@ -4,7 +4,14 @@ import logging
|
|||
from django import forms
|
||||
from django.core.validators import RegexValidator
|
||||
|
||||
from ..models import DomainInformation, Portfolio, SeniorOfficial
|
||||
from registrar.models import (
|
||||
PortfolioInvitation,
|
||||
UserPortfolioPermission,
|
||||
DomainInformation,
|
||||
Portfolio,
|
||||
SeniorOfficial,
|
||||
)
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -17,9 +24,12 @@ class PortfolioOrgAddressForm(forms.ModelForm):
|
|||
validators=[
|
||||
RegexValidator(
|
||||
"^[0-9]{5}(?:-[0-9]{4})?$|^$",
|
||||
message="Enter a zip code in the required format, like 12345 or 12345-6789.",
|
||||
message="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
|
||||
)
|
||||
],
|
||||
error_messages={
|
||||
"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789.",
|
||||
},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
@ -38,6 +48,7 @@ class PortfolioOrgAddressForm(forms.ModelForm):
|
|||
"state_territory": {
|
||||
"required": "Select the state, territory, or military post where your organization is located."
|
||||
},
|
||||
"zipcode": {"required": "Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789."},
|
||||
}
|
||||
widgets = {
|
||||
# We need to set the required attributed for State/territory
|
||||
|
@ -95,3 +106,57 @@ class PortfolioSeniorOfficialForm(forms.ModelForm):
|
|||
cleaned_data = super().clean()
|
||||
cleaned_data.pop("full_name", None)
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class PortfolioMemberForm(forms.ModelForm):
|
||||
"""
|
||||
Form for updating a portfolio member.
|
||||
"""
|
||||
|
||||
roles = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioRoleChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Roles",
|
||||
)
|
||||
|
||||
additional_permissions = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Additional Permissions",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = UserPortfolioPermission
|
||||
fields = [
|
||||
"roles",
|
||||
"additional_permissions",
|
||||
]
|
||||
|
||||
|
||||
class PortfolioInvitedMemberForm(forms.ModelForm):
|
||||
"""
|
||||
Form for updating a portfolio invited member.
|
||||
"""
|
||||
|
||||
roles = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioRoleChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Roles",
|
||||
)
|
||||
|
||||
additional_permissions = forms.MultipleChoiceField(
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
widget=forms.SelectMultiple(attrs={"class": "usa-select"}),
|
||||
required=False,
|
||||
label="Additional Permissions",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PortfolioInvitation
|
||||
fields = [
|
||||
"roles",
|
||||
"additional_permissions",
|
||||
]
|
||||
|
|
|
@ -58,7 +58,7 @@ class UserProfileForm(forms.ModelForm):
|
|||
"required": "Enter your title or role in your organization (e.g., Chief Information Officer)"
|
||||
}
|
||||
self.fields["email"].error_messages = {
|
||||
"required": "Enter your email address in the required format, like name@example.com."
|
||||
"required": "Enter an email address in the required format, like name@example.com."
|
||||
}
|
||||
self.fields["phone"].error_messages["required"] = "Enter your phone number."
|
||||
|
||||
|
|
|
@ -279,11 +279,11 @@ class BaseYesNoForm(RegistrarForm):
|
|||
return initial_value
|
||||
|
||||
|
||||
def request_step_list(request_wizard):
|
||||
def request_step_list(request_wizard, step_enum):
|
||||
"""Dynamically generated list of steps in the form wizard."""
|
||||
step_list = []
|
||||
for step in request_wizard.StepEnum:
|
||||
condition = request_wizard.WIZARD_CONDITIONS.get(step, True)
|
||||
for step in step_enum:
|
||||
condition = request_wizard.wizard_conditions.get(step, True)
|
||||
if callable(condition):
|
||||
condition = condition(request_wizard)
|
||||
if condition:
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
# Generated by Django 4.2.10 on 2024-10-08 18:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registrar", "0132_alter_domaininformation_portfolio_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="domainrequest",
|
||||
name="rejection_reason_email",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="domainrequest",
|
||||
name="rejection_reason",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("domain_purpose", "Purpose requirements not met"),
|
||||
("requestor_not_eligible", "Requestor not eligible to make request"),
|
||||
("org_has_domain", "Org already has a .gov domain"),
|
||||
("contacts_not_verified", "Org contacts couldn't be verified"),
|
||||
("org_not_eligible", "Org not eligible for a .gov domain"),
|
||||
("naming_requirements", "Naming requirements not met"),
|
||||
("other", "Other/Unspecified"),
|
||||
],
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
]
|
|
@ -254,18 +254,18 @@ class DomainRequest(TimeStampedModel):
|
|||
)
|
||||
|
||||
class RejectionReasons(models.TextChoices):
|
||||
DOMAIN_PURPOSE = "purpose_not_met", "Purpose requirements not met"
|
||||
REQUESTOR = "requestor_not_eligible", "Requestor not eligible to make request"
|
||||
SECOND_DOMAIN_REASONING = (
|
||||
DOMAIN_PURPOSE = "domain_purpose", "Purpose requirements not met"
|
||||
REQUESTOR_NOT_ELIGIBLE = "requestor_not_eligible", "Requestor not eligible to make request"
|
||||
ORG_HAS_DOMAIN = (
|
||||
"org_has_domain",
|
||||
"Org already has a .gov domain",
|
||||
)
|
||||
CONTACTS_OR_ORGANIZATION_LEGITIMACY = (
|
||||
CONTACTS_NOT_VERIFIED = (
|
||||
"contacts_not_verified",
|
||||
"Org contacts couldn't be verified",
|
||||
)
|
||||
ORGANIZATION_ELIGIBILITY = "org_not_eligible", "Org not eligible for a .gov domain"
|
||||
NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met"
|
||||
ORG_NOT_ELIGIBLE = "org_not_eligible", "Org not eligible for a .gov domain"
|
||||
NAMING_REQUIREMENTS = "naming_requirements", "Naming requirements not met"
|
||||
OTHER = "other", "Other/Unspecified"
|
||||
|
||||
@classmethod
|
||||
|
@ -300,6 +300,11 @@ class DomainRequest(TimeStampedModel):
|
|||
blank=True,
|
||||
)
|
||||
|
||||
rejection_reason_email = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
action_needed_reason = models.TextField(
|
||||
choices=ActionNeededReasons.choices,
|
||||
null=True,
|
||||
|
@ -635,15 +640,16 @@ class DomainRequest(TimeStampedModel):
|
|||
# Actually updates the organization_type field
|
||||
org_type_helper.create_or_update_organization_type()
|
||||
|
||||
def _cache_status_and_action_needed_reason(self):
|
||||
def _cache_status_and_status_reasons(self):
|
||||
"""Maintains a cache of properties so we can avoid a DB call"""
|
||||
self._cached_action_needed_reason = self.action_needed_reason
|
||||
self._cached_rejection_reason = self.rejection_reason
|
||||
self._cached_status = self.status
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Store original values for caching purposes. Used to compare them on save.
|
||||
self._cache_status_and_action_needed_reason()
|
||||
self._cache_status_and_status_reasons()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Save override for custom properties"""
|
||||
|
@ -655,23 +661,63 @@ class DomainRequest(TimeStampedModel):
|
|||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Handle the action needed email.
|
||||
# An email is sent out when action_needed_reason is changed or added.
|
||||
if self.action_needed_reason and self.status == self.DomainRequestStatus.ACTION_NEEDED:
|
||||
self.sync_action_needed_reason()
|
||||
# Handle custom status emails.
|
||||
# An email is sent out when a, for example, action_needed_reason is changed or added.
|
||||
statuses_that_send_custom_emails = [self.DomainRequestStatus.ACTION_NEEDED, self.DomainRequestStatus.REJECTED]
|
||||
if self.status in statuses_that_send_custom_emails:
|
||||
self.send_custom_status_update_email(self.status)
|
||||
|
||||
# Update the cached values after saving
|
||||
self._cache_status_and_action_needed_reason()
|
||||
self._cache_status_and_status_reasons()
|
||||
|
||||
def sync_action_needed_reason(self):
|
||||
"""Checks if we need to send another action needed email"""
|
||||
was_already_action_needed = self._cached_status == self.DomainRequestStatus.ACTION_NEEDED
|
||||
reason_exists = self._cached_action_needed_reason is not None and self.action_needed_reason is not None
|
||||
reason_changed = self._cached_action_needed_reason != self.action_needed_reason
|
||||
if was_already_action_needed and reason_exists and reason_changed:
|
||||
# We don't send emails out in state "other"
|
||||
if self.action_needed_reason != self.ActionNeededReasons.OTHER:
|
||||
self._send_action_needed_reason_email(email_content=self.action_needed_reason_email)
|
||||
def send_custom_status_update_email(self, status):
|
||||
"""Helper function to send out a second status email when the status remains the same,
|
||||
but the reason has changed."""
|
||||
|
||||
# Currently, we store all this information in three variables.
|
||||
# When adding new reasons, this can be a lot to manage so we store it here
|
||||
# in a centralized location. However, this may need to change if this scales.
|
||||
status_information = {
|
||||
self.DomainRequestStatus.ACTION_NEEDED: {
|
||||
"cached_reason": self._cached_action_needed_reason,
|
||||
"reason": self.action_needed_reason,
|
||||
"email": self.action_needed_reason_email,
|
||||
"excluded_reasons": [DomainRequest.ActionNeededReasons.OTHER],
|
||||
"wrap_email": True,
|
||||
},
|
||||
self.DomainRequestStatus.REJECTED: {
|
||||
"cached_reason": self._cached_rejection_reason,
|
||||
"reason": self.rejection_reason,
|
||||
"email": self.rejection_reason_email,
|
||||
"excluded_reasons": [],
|
||||
# "excluded_reasons": [DomainRequest.RejectionReasons.OTHER],
|
||||
"wrap_email": False,
|
||||
},
|
||||
}
|
||||
status_info = status_information.get(status)
|
||||
|
||||
# Don't send an email if there is nothing to send.
|
||||
if status_info.get("email") is None:
|
||||
logger.warning("send_custom_status_update_email() => Tried sending an empty email.")
|
||||
return
|
||||
|
||||
# We should never send an email if no reason was specified.
|
||||
# Additionally, Don't send out emails for reasons that shouldn't send them.
|
||||
if status_info.get("reason") is None or status_info.get("reason") in status_info.get("excluded_reasons"):
|
||||
logger.warning("send_custom_status_update_email() => Tried sending a status email without a reason.")
|
||||
return
|
||||
|
||||
# Only send out an email if the underlying reason itself changed or if no email was sent previously.
|
||||
if status_info.get("cached_reason") != status_info.get("reason") or status_info.get("cached_reason") is None:
|
||||
bcc_address = settings.DEFAULT_FROM_EMAIL if settings.IS_PRODUCTION else ""
|
||||
self._send_status_update_email(
|
||||
new_status=status,
|
||||
email_template="emails/includes/custom_email.txt",
|
||||
email_template_subject="emails/status_change_subject.txt",
|
||||
bcc_address=bcc_address,
|
||||
custom_email_content=status_info.get("email"),
|
||||
wrap_email=status_information.get("wrap_email"),
|
||||
)
|
||||
|
||||
def sync_yes_no_form_fields(self):
|
||||
"""Some yes/no forms use a db field to track whether it was checked or not.
|
||||
|
@ -901,7 +947,7 @@ class DomainRequest(TimeStampedModel):
|
|||
target=DomainRequestStatus.ACTION_NEEDED,
|
||||
conditions=[domain_is_not_active, investigator_exists_and_is_staff],
|
||||
)
|
||||
def action_needed(self, send_email=True):
|
||||
def action_needed(self):
|
||||
"""Send back an domain request that is under investigation or rejected.
|
||||
|
||||
This action is logged.
|
||||
|
@ -909,43 +955,23 @@ class DomainRequest(TimeStampedModel):
|
|||
This action cleans up the rejection status if moving away from rejected.
|
||||
|
||||
As side effects this will delete the domain and domain_information
|
||||
(will cascade) when they exist."""
|
||||
(will cascade) when they exist.
|
||||
|
||||
Afterwards, we send out an email for action_needed in def save().
|
||||
See the function send_custom_status_update_email.
|
||||
"""
|
||||
|
||||
if self.status == self.DomainRequestStatus.APPROVED:
|
||||
self.delete_and_clean_up_domain("reject_with_prejudice")
|
||||
self.delete_and_clean_up_domain("action_needed")
|
||||
elif self.status == self.DomainRequestStatus.REJECTED:
|
||||
self.rejection_reason = None
|
||||
|
||||
# Check if the tuple is setup correctly, then grab its value.
|
||||
|
||||
literal = DomainRequest.DomainRequestStatus.ACTION_NEEDED
|
||||
# Check if the tuple is setup correctly, then grab its value
|
||||
action_needed = literal if literal is not None else "Action Needed"
|
||||
logger.info(f"A status change occurred. {self} was changed to '{action_needed}'")
|
||||
|
||||
# Send out an email if an action needed reason exists
|
||||
if self.action_needed_reason and self.action_needed_reason != self.ActionNeededReasons.OTHER:
|
||||
email_content = self.action_needed_reason_email
|
||||
self._send_action_needed_reason_email(send_email, email_content)
|
||||
|
||||
def _send_action_needed_reason_email(self, send_email=True, email_content=None):
|
||||
"""Sends out an automatic email for each valid action needed reason provided"""
|
||||
|
||||
email_template_name = "custom_email.txt"
|
||||
email_template_subject_name = f"{self.action_needed_reason}_subject.txt"
|
||||
|
||||
bcc_address = ""
|
||||
if settings.IS_PRODUCTION:
|
||||
bcc_address = settings.DEFAULT_FROM_EMAIL
|
||||
|
||||
self._send_status_update_email(
|
||||
new_status="action needed",
|
||||
email_template=f"emails/action_needed_reasons/{email_template_name}",
|
||||
email_template_subject=f"emails/action_needed_reasons/{email_template_subject_name}",
|
||||
send_email=send_email,
|
||||
bcc_address=bcc_address,
|
||||
custom_email_content=email_content,
|
||||
wrap_email=True,
|
||||
)
|
||||
|
||||
@transition(
|
||||
field="status",
|
||||
source=[
|
||||
|
@ -1039,18 +1065,20 @@ class DomainRequest(TimeStampedModel):
|
|||
def reject(self):
|
||||
"""Reject an domain request that has been submitted.
|
||||
|
||||
This action is logged.
|
||||
|
||||
This action cleans up the action needed status if moving away from action needed.
|
||||
|
||||
As side effects this will delete the domain and domain_information
|
||||
(will cascade), and send an email notification."""
|
||||
(will cascade) when they exist.
|
||||
|
||||
Afterwards, we send out an email for reject in def save().
|
||||
See the function send_custom_status_update_email.
|
||||
"""
|
||||
|
||||
if self.status == self.DomainRequestStatus.APPROVED:
|
||||
self.delete_and_clean_up_domain("reject")
|
||||
|
||||
self._send_status_update_email(
|
||||
"action needed",
|
||||
"emails/status_change_rejected.txt",
|
||||
"emails/status_change_rejected_subject.txt",
|
||||
)
|
||||
|
||||
@transition(
|
||||
field="status",
|
||||
source=[
|
||||
|
|
|
@ -4,6 +4,7 @@ import logging
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django_fsm import FSMField, transition
|
||||
from registrar.models.domain_invitation import DomainInvitation
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore
|
||||
from .utility.time_stamped_model import TimeStampedModel
|
||||
|
@ -38,7 +39,7 @@ class PortfolioInvitation(TimeStampedModel):
|
|||
related_name="portfolios",
|
||||
)
|
||||
|
||||
portfolio_roles = ArrayField(
|
||||
roles = ArrayField(
|
||||
models.CharField(
|
||||
max_length=50,
|
||||
choices=UserPortfolioRoleChoices.choices,
|
||||
|
@ -48,7 +49,7 @@ class PortfolioInvitation(TimeStampedModel):
|
|||
help_text="Select one or more roles.",
|
||||
)
|
||||
|
||||
portfolio_additional_permissions = ArrayField(
|
||||
additional_permissions = ArrayField(
|
||||
models.CharField(
|
||||
max_length=50,
|
||||
choices=UserPortfolioPermissionChoices.choices,
|
||||
|
@ -67,6 +68,31 @@ class PortfolioInvitation(TimeStampedModel):
|
|||
def __str__(self):
|
||||
return f"Invitation for {self.email} on {self.portfolio} is {self.status}"
|
||||
|
||||
def get_managed_domains_count(self):
|
||||
"""Return the count of domain invitations managed by the invited user for this portfolio."""
|
||||
# Filter the UserDomainRole model to get domains where the user has a manager role
|
||||
managed_domains = DomainInvitation.objects.filter(
|
||||
email=self.email, domain__domain_info__portfolio=self.portfolio
|
||||
).count()
|
||||
return managed_domains
|
||||
|
||||
def get_portfolio_permissions(self):
|
||||
"""
|
||||
Retrieve the permissions for the user's portfolio roles from the invite.
|
||||
This is similar logic to _get_portfolio_permissions in user_portfolio_permission
|
||||
"""
|
||||
# Use a set to avoid duplicate permissions
|
||||
portfolio_permissions = set()
|
||||
|
||||
if self.roles:
|
||||
for role in self.roles:
|
||||
portfolio_permissions.update(UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
|
||||
|
||||
if self.additional_permissions:
|
||||
portfolio_permissions.update(self.additional_permissions)
|
||||
|
||||
return list(portfolio_permissions)
|
||||
|
||||
@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
|
||||
def retrieve(self):
|
||||
"""When an invitation is retrieved, create the corresponding permission.
|
||||
|
@ -88,8 +114,8 @@ class PortfolioInvitation(TimeStampedModel):
|
|||
user_portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
portfolio=self.portfolio, user=user
|
||||
)
|
||||
if self.portfolio_roles and len(self.portfolio_roles) > 0:
|
||||
user_portfolio_permission.roles = self.portfolio_roles
|
||||
if self.portfolio_additional_permissions and len(self.portfolio_additional_permissions) > 0:
|
||||
user_portfolio_permission.additional_permissions = self.portfolio_additional_permissions
|
||||
if self.roles and len(self.roles) > 0:
|
||||
user_portfolio_permission.roles = self.roles
|
||||
if self.additional_permissions and len(self.additional_permissions) > 0:
|
||||
user_portfolio_permission.additional_permissions = self.additional_permissions
|
||||
user_portfolio_permission.save()
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from django.db import models
|
||||
from django.forms import ValidationError
|
||||
from registrar.models.user_domain_role import UserDomainRole
|
||||
from registrar.utility.waffle import flag_is_active_for_user
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from .utility.time_stamped_model import TimeStampedModel
|
||||
|
@ -79,6 +80,14 @@ class UserPortfolioPermission(TimeStampedModel):
|
|||
)
|
||||
return readable_roles
|
||||
|
||||
def get_managed_domains_count(self):
|
||||
"""Return the count of domains managed by the user for this portfolio."""
|
||||
# Filter the UserDomainRole model to get domains where the user has a manager role
|
||||
managed_domains = UserDomainRole.objects.filter(
|
||||
user=self.user, role=UserDomainRole.Roles.MANAGER, domain__domain_info__portfolio=self.portfolio
|
||||
).count()
|
||||
return managed_domains
|
||||
|
||||
def _get_portfolio_permissions(self):
|
||||
"""
|
||||
Retrieve the permissions for the user's portfolio roles.
|
||||
|
|
|
@ -10,6 +10,8 @@
|
|||
<input id="has_audit_logs" class="display-none" value="{%if filtered_audit_log_entries %}true{% else %}false{% endif %}"/>
|
||||
{% url 'get-action-needed-email-for-user-json' as url %}
|
||||
<input id="get-action-needed-email-for-user-json" class="display-none" value="{{ url }}" />
|
||||
{% url 'get-rejection-email-for-user-json' as url_2 %}
|
||||
<input id="get-rejection-email-for-user-json" class="display-none" value="{{ url_2 }}" />
|
||||
{% for fieldset in adminform %}
|
||||
{% comment %}
|
||||
TODO: this will eventually need to be changed to something like this
|
||||
|
|
|
@ -137,29 +137,28 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
|
||||
{% block field_other %}
|
||||
{% if field.field.name == "action_needed_reason_email" %}
|
||||
{{ field.field }}
|
||||
|
||||
<div class="margin-top-05 text-faded field-action_needed_reason_email__placeholder">
|
||||
<div class="margin-top-05 text-faded custom-email-placeholder">
|
||||
–
|
||||
</div>
|
||||
|
||||
{{ field.field }}
|
||||
|
||||
<button
|
||||
aria-label="Edit email in textarea"
|
||||
type="button"
|
||||
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text margin-left-1 text-no-underline field-action_needed_reason_email__edit flex-align-self-start"
|
||||
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text margin-left-1 text-no-underline flex-align-self-start edit-email-button"
|
||||
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</button
|
||||
>
|
||||
<a
|
||||
href="#email-already-sent-modal"
|
||||
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1 field-action_needed_reason_email__modal-trigger flex-align-self-start"
|
||||
aria-controls="email-already-sent-modal"
|
||||
href="#action-needed-email-already-sent-modal"
|
||||
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1 edit-button-modal-trigger flex-align-self-start"
|
||||
aria-controls="action-needed-email-already-sent-modal"
|
||||
data-open-modal
|
||||
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</a
|
||||
>
|
||||
<div
|
||||
class="usa-modal"
|
||||
id="email-already-sent-modal"
|
||||
id="action-needed-email-already-sent-modal"
|
||||
aria-labelledby="Are you sure you want to edit this email?"
|
||||
aria-describedby="The creator of this request already received an email"
|
||||
>
|
||||
|
@ -187,8 +186,8 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
<li class="usa-button-group__item">
|
||||
<button
|
||||
type="submit"
|
||||
id="action-needed-reason__confirm-edit-email"
|
||||
class="usa-button"
|
||||
id="confirm-edit-email"
|
||||
data-close-modal
|
||||
>
|
||||
Yes, continue editing
|
||||
|
@ -221,11 +220,99 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
</div>
|
||||
|
||||
{% if original_object.action_needed_reason_email %}
|
||||
<input id="last-sent-email-content" class="display-none" value="{{original_object.action_needed_reason_email}}">
|
||||
<input id="last-sent-action-needed-email-content" class="display-none" value="{{original_object.action_needed_reason_email}}">
|
||||
{% else %}
|
||||
<input id="last-sent-email-content" class="display-none" value="None">
|
||||
<input id="last-sent-action-needed-email-content" class="display-none" value="None">
|
||||
{% endif %}
|
||||
|
||||
{% elif field.field.name == "rejection_reason_email" %}
|
||||
{{ field.field }}
|
||||
|
||||
<div class="margin-top-05 text-faded custom-email-placeholder">
|
||||
–
|
||||
</div>
|
||||
|
||||
<button
|
||||
aria-label="Edit email in textarea"
|
||||
type="button"
|
||||
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text margin-left-1 text-no-underline flex-align-self-start edit-email-button"
|
||||
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</button
|
||||
>
|
||||
<a
|
||||
href="#rejection-reason-email-already-sent-modal"
|
||||
class="usa-button usa-button--unstyled usa-button--dja-link-color usa-button__small-text text-no-underline margin-left-1 edit-button-modal-trigger flex-align-self-start"
|
||||
aria-controls="rejection-reason-email-already-sent-modal"
|
||||
data-open-modal
|
||||
><img src="/public/admin/img/icon-changelink.svg" alt="Change"> Edit email</a
|
||||
>
|
||||
<div
|
||||
class="usa-modal"
|
||||
id="rejection-reason-email-already-sent-modal"
|
||||
aria-labelledby="Are you sure you want to edit this email?"
|
||||
aria-describedby="The creator of this request already received an email"
|
||||
>
|
||||
<div class="usa-modal__content">
|
||||
<div class="usa-modal__main">
|
||||
<h2 class="usa-modal__heading">
|
||||
Are you sure you want to edit this email?
|
||||
</h2>
|
||||
<div class="usa-prose">
|
||||
<p>
|
||||
The creator of this request already received an email for this status/reason:
|
||||
</p>
|
||||
<ul>
|
||||
<li class="font-body-sm">Status: <b>Rejected</b></li>
|
||||
<li class="font-body-sm">Reason: <b>{{ original_object.get_rejection_reason_display }}</b></li>
|
||||
</ul>
|
||||
<p>
|
||||
If you edit this email's text, <b>the system will send another email</b> to
|
||||
the creator after you “save” your changes. If you do not want to send another email, click “cancel” below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="usa-modal__footer">
|
||||
<ul class="usa-button-group">
|
||||
<li class="usa-button-group__item">
|
||||
<button
|
||||
type="submit"
|
||||
id="rejection-reason__confirm-edit-email"
|
||||
class="usa-button"
|
||||
data-close-modal
|
||||
>
|
||||
Yes, continue editing
|
||||
</button>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--unstyled padding-105 text-center"
|
||||
name="_cancel_edit_email"
|
||||
data-close-modal
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-modal__close"
|
||||
aria-label="Close this window"
|
||||
data-close-modal
|
||||
>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if original_object.rejection_reason_email %}
|
||||
<input id="last-sent-rejection-email-content" class="display-none" value="{{original_object.rejection_reason_email}}">
|
||||
{% else %}
|
||||
<input id="last-sent-rejection-email-content" class="display-none" value="None">
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ field.field }}
|
||||
{% endif %}
|
||||
|
|
|
@ -4,11 +4,31 @@
|
|||
{% block title %}Add a domain manager | {% endblock %}
|
||||
|
||||
{% block domain_content %}
|
||||
{% block breadcrumb %}
|
||||
{% url 'domain-users' pk=domain.id as url %}
|
||||
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain manager breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Domain managers</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||
<span>Add a domain manager</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endblock breadcrumb %}
|
||||
<h1>Add a domain manager</h1>
|
||||
|
||||
<p>You can add another user to help manage your domain. They will need to sign
|
||||
in to the .gov registrar with their Login.gov account.
|
||||
{% if has_organization_feature_flag %}
|
||||
<p>
|
||||
You can add another user to help manage your domain. Users can only be a member of one .gov organization,
|
||||
and they'll need to sign in with their Login.gov account.
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
You can add another user to help manage your domain. They will need to sign in to the .gov registrar with
|
||||
their Login.gov account.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="usa-form usa-form--large" method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
<div class="usa-alert usa-alert--info usa-alert--slim">
|
||||
<div class="usa-alert__body">
|
||||
<p class="usa-alert__text ">
|
||||
To manage information for this domain, you must add yourself as a domain manager.
|
||||
You don't have access to manage {{domain.name}}. If you need to make updates, contact one of the listed domain managers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
|
||||
{% input_with_errors form.state_territory %}
|
||||
|
||||
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
|
||||
{% with add_class="usa-input--small" sublabel_text="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789." %}
|
||||
{% input_with_errors form.zipcode %}
|
||||
{% endwith %}
|
||||
|
||||
|
|
|
@ -12,8 +12,10 @@
|
|||
|
||||
<p>Names that <em>uniquely apply to your organization</em> are likely to be approved over names that could also apply to other organizations.
|
||||
{% if not is_federal %}In most instances, this requires including your state’s two-letter abbreviation.{% endif %}</p>
|
||||
|
||||
|
||||
{% if not portfolio %}
|
||||
<p>Requests for your organization’s initials or an abbreviated name might not be approved, but we encourage you to request the name you want.</p>
|
||||
{% endif %}
|
||||
|
||||
<p>Note that <strong>only federal agencies can request generic terms</strong> like
|
||||
vote.gov.</p>
|
||||
|
|
|
@ -12,7 +12,11 @@
|
|||
|
||||
<h1>You’re about to start your .gov domain request.</h1>
|
||||
<p>You don’t have to complete the process in one session. You can save what you enter and come back to it when you’re ready.</p>
|
||||
{% if portfolio %}
|
||||
<p>We’ll use the information you provide to verify your domain request meets our guidelines.</p>
|
||||
{% else %}
|
||||
<p>We’ll use the information you provide to verify your organization’s eligibility for a .gov domain. We’ll also verify that the domain you request meets our guidelines.</p>
|
||||
{% endif %}
|
||||
<h2>Time to complete the form</h2>
|
||||
<p>If you have <a href="{% public_site_url 'domains/before/#information-you%E2%80%99ll-need-to-complete-the-domain-request-form' %}" target="_blank" class="usa-link">all the information you need</a>,
|
||||
completing your domain request might take around 15 minutes.</p>
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
|
||||
{% input_with_errors forms.0.state_territory %}
|
||||
|
||||
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
|
||||
{% with add_class="usa-input--small" sublabel_text="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789." %}
|
||||
{% input_with_errors forms.0.zipcode %}
|
||||
{% endwith %}
|
||||
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
{% extends 'domain_request_form.html' %}
|
||||
{% load field_helpers url_helpers %}
|
||||
|
||||
{% block form_instructions %}
|
||||
<p>🛸🛸🛸🛸 Placeholder content 🛸🛸🛸🛸</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block form_fields %}
|
||||
<fieldset class="usa-fieldset">
|
||||
<legend>
|
||||
<h2>What is the name of your space vessel?</h2>
|
||||
</legend>
|
||||
|
||||
{% input_with_errors forms.0.organization_name %}
|
||||
</fieldset>
|
||||
{% endblock %}
|
|
@ -19,5 +19,9 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block form_fields %}
|
||||
{% include "includes/request_review_steps.html" with is_editable=True %}
|
||||
{% if portfolio %}
|
||||
{% include "includes/portfolio_request_review_steps.html" with is_editable=True %}
|
||||
{% else %}
|
||||
{% include "includes/request_review_steps.html" with is_editable=True %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -11,8 +11,13 @@
|
|||
|
||||
<p>
|
||||
The name of your suborganization will be publicly listed as the domain registrant.
|
||||
This list of suborganizations has been populated the .gov program.
|
||||
If you believe there is an error please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
|
||||
</p>
|
||||
<p>
|
||||
When this field is blank, the domain registrant will be listed as the overarching organization: {{ portfolio }}.
|
||||
</p>
|
||||
<p>
|
||||
If you don’t see your suborganization in the menu or need to edit one of the options,
|
||||
please contact <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.
|
||||
</p>
|
||||
|
||||
{% if has_any_domains_portfolio_permission and has_edit_suborganization_portfolio_permission %}
|
||||
|
|
|
@ -8,8 +8,7 @@
|
|||
|
||||
<p>
|
||||
Domain managers can update all information related to a domain within the
|
||||
.gov registrar, including contact details, senior official, security
|
||||
email, and DNS name servers.
|
||||
.gov registrar, including including security email and DNS name servers.
|
||||
</p>
|
||||
|
||||
<ul class="usa-list">
|
||||
|
@ -17,7 +16,8 @@
|
|||
<li>After adding a domain manager, an email invitation will be sent to that user with
|
||||
instructions on how to set up an account.</li>
|
||||
<li>All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.</li>
|
||||
<li>Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain. Add another domain manager before you remove yourself from this domain.</li>
|
||||
<li>All domain managers will be notified when updates are made to this domain.</li>
|
||||
<li>Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain.</li>
|
||||
</ul>
|
||||
|
||||
{% if domain.permissions %}
|
||||
|
|
|
@ -8,8 +8,8 @@ REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
|
|||
STATUS: Rejected
|
||||
|
||||
----------------------------------------------------------------
|
||||
{% if domain_request.rejection_reason != 'other' %}
|
||||
REJECTION REASON{% endif %}{% if domain_request.rejection_reason == 'purpose_not_met' %}
|
||||
{% if reason != domain_request.RejectionReasons.DOMAIN_PURPOSE.OTHER %}
|
||||
REJECTION REASON{% endif %}{% if reason == domain_request.RejectionReasons.DOMAIN_PURPOSE %}
|
||||
Your domain request was rejected because the purpose you provided did not meet our
|
||||
requirements. You didn’t provide enough information about how you intend to use the
|
||||
domain.
|
||||
|
@ -18,7 +18,7 @@ Learn more about:
|
|||
- Eligibility for a .gov domain <https://get.gov/domains/eligibility>
|
||||
- What you can and can’t do with .gov domains <https://get.gov/domains/requirements/>
|
||||
|
||||
If you have questions or comments, reply to this email.{% elif domain_request.rejection_reason == 'requestor_not_eligible' %}
|
||||
If you have questions or comments, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.REQUESTOR_NOT_ELIGIBLE %}
|
||||
Your domain request was rejected because we don’t believe you’re eligible to request a
|
||||
.gov domain on behalf of {{ domain_request.organization_name }}. You must be a government employee, or be
|
||||
working on behalf of a government organization, to request a .gov domain.
|
||||
|
@ -26,7 +26,7 @@ working on behalf of a government organization, to request a .gov domain.
|
|||
|
||||
DEMONSTRATE ELIGIBILITY
|
||||
If you can provide more information that demonstrates your eligibility, or you want to
|
||||
discuss further, reply to this email.{% elif domain_request.rejection_reason == 'org_has_domain' %}
|
||||
discuss further, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.ORG_HAS_DOMAIN %}
|
||||
Your domain request was rejected because {{ domain_request.organization_name }} has a .gov domain. Our
|
||||
practice is to approve one domain per online service per government organization. We
|
||||
evaluate additional requests on a case-by-case basis. You did not provide sufficient
|
||||
|
@ -35,9 +35,9 @@ justification for an additional domain.
|
|||
Read more about our practice of approving one domain per online service
|
||||
<https://get.gov/domains/before/#one-domain-per-service>.
|
||||
|
||||
If you have questions or comments, reply to this email.{% elif domain_request.rejection_reason == 'contacts_not_verified' %}
|
||||
If you have questions or comments, reply to this email.{% elif reason == 'contacts_not_verified' %}
|
||||
Your domain request was rejected because we could not verify the organizational
|
||||
contacts you provided. If you have questions or comments, reply to this email.{% elif domain_request.rejection_reason == 'org_not_eligible' %}
|
||||
contacts you provided. If you have questions or comments, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.ORG_NOT_ELIGIBLE %}
|
||||
Your domain request was rejected because we determined that {{ domain_request.organization_name }} is not
|
||||
eligible for a .gov domain. .Gov domains are only available to official U.S.-based
|
||||
government organizations.
|
||||
|
@ -46,7 +46,7 @@ Learn more about eligibility for .gov domains
|
|||
<https://get.gov/domains/eligibility/>.
|
||||
|
||||
If you have questions or comments, reply to this email.
|
||||
{% elif domain_request.rejection_reason == 'naming_not_met' %}
|
||||
{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.NAMING_REQUIREMENTS %}
|
||||
Your domain request was rejected because it does not meet our naming requirements.
|
||||
Domains should uniquely identify a government organization and be clear to the
|
||||
general public. Learn more about naming requirements for your type of organization
|
||||
|
@ -55,7 +55,7 @@ general public. Learn more about naming requirements for your type of organizati
|
|||
|
||||
YOU CAN SUBMIT A NEW REQUEST
|
||||
We encourage you to request a domain that meets our requirements. If you have
|
||||
questions or want to discuss potential domain names, reply to this email.{% elif domain_request.rejection_reason == 'other' %}
|
||||
questions or want to discuss potential domain names, reply to this email.{% elif reason == domain_request.RejectionReasons.DOMAIN_PURPOSE.OTHER %}
|
||||
YOU CAN SUBMIT A NEW REQUEST
|
||||
If your organization is eligible for a .gov domain and you meet our other requirements, you can submit a new request.
|
||||
|
||||
|
|
|
@ -34,6 +34,7 @@
|
|||
</ul>
|
||||
</div>
|
||||
<ul class="usa-nav__primary usa-accordion">
|
||||
{% if not hide_domains %}
|
||||
<li class="usa-nav__primary-item">
|
||||
{% if has_any_domains_portfolio_permission %}
|
||||
{% url 'domains' as url %}
|
||||
|
@ -44,13 +45,14 @@
|
|||
Domains
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<!-- <li class="usa-nav__primary-item">
|
||||
<a href="#" class="usa-nav-link">
|
||||
Domain groups
|
||||
</a>
|
||||
</li> -->
|
||||
|
||||
{% if has_organization_requests_flag %}
|
||||
|
||||
{% if has_organization_requests_flag and not hide_requests %}
|
||||
<li class="usa-nav__primary-item">
|
||||
<!-- user has one of the view permissions plus the edit permission, show the dropdown -->
|
||||
{% if has_edit_request_portfolio_permission %}
|
||||
|
@ -91,12 +93,12 @@
|
|||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if has_organization_members_flag and has_view_members_portfolio_permission %}
|
||||
<li class="usa-nav__primary-item">
|
||||
<a href="/members/" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}">
|
||||
Members
|
||||
</a>
|
||||
</li>
|
||||
{% if has_organization_members_flag %}
|
||||
<li class="usa-nav__primary-item">
|
||||
<a href="/members/" class="usa-nav-link {% if path|is_members_subpage %} usa-current{% endif %}">
|
||||
Members
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="usa-nav__primary-item">
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
<h4 class="margin-bottom-0 text-primary">Assigned domains</h4>
|
||||
{% if domain_count > 0 %}
|
||||
<p class="margin-top-0">{{domain_count}}</p>
|
||||
{% else %}
|
||||
<p class="margin-top-0">This member does not manage any domains.{% if manage_button %} To assign this member a domain, click "Manage".{% endif %}</p>
|
||||
{% endif %}
|
26
src/registrar/templates/includes/member_permissions.html
Normal file
26
src/registrar/templates/includes/member_permissions.html
Normal file
|
@ -0,0 +1,26 @@
|
|||
<h4 class="margin-bottom-0 text-primary">Member access</h4>
|
||||
{% if permissions.roles and 'organization_admin' in permissions.roles %}
|
||||
<p class="margin-top-0">Admin access</p>
|
||||
{% elif permissions.roles and 'organization_member' in permissions.roles %}
|
||||
<p class="margin-top-0">Basic access</p>
|
||||
{% else %}
|
||||
<p class="margin-top-0">⎯</p>
|
||||
{% endif %}
|
||||
|
||||
<h4 class="margin-bottom-0 text-primary">Organization domain requests</h4>
|
||||
{% if member_has_edit_request_portfolio_permission %}
|
||||
<p class="margin-top-0">View all requests plus create requests</p>
|
||||
{% elif member_has_view_all_requests_portfolio_permission %}
|
||||
<p class="margin-top-0">View all requests</p>
|
||||
{% else %}
|
||||
<p class="margin-top-0">No access</p>
|
||||
{% endif %}
|
||||
|
||||
<h4 class="margin-bottom-0 text-primary">Organization members</h4>
|
||||
{% if member_has_edit_members_portfolio_permission %}
|
||||
<p class="margin-top-0">View all members plus manage members</p>
|
||||
{% elif member_has_view_members_portfolio_permission %}
|
||||
<p class="margin-top-0">View all members</p>
|
||||
{% else %}
|
||||
<p class="margin-top-0">No access</p>
|
||||
{% endif %}
|
|
@ -4,6 +4,11 @@
|
|||
<div class="usa-modal__main">
|
||||
<h2 class="usa-modal__heading" id="modal-1-heading">
|
||||
{{ modal_heading }}
|
||||
{%if domain_name_modal is not None %}
|
||||
<span class="domain-name-wrap">
|
||||
{{ domain_name_modal }}
|
||||
</span>
|
||||
{%endif%}
|
||||
{% if heading_value is not None %}
|
||||
{# Add a breakpoint #}
|
||||
<div aria-hidden="true"></div>
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
{% endif %}
|
||||
|
||||
{% if step == Step.REQUESTING_ENTITY %}
|
||||
|
||||
{% if domain_request.organization_name %}
|
||||
{% with title=form_titles|get_item:step value=domain_request %}
|
||||
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url address='true' %}
|
||||
|
@ -54,32 +53,8 @@
|
|||
{% endif %}
|
||||
|
||||
{% if step == Step.ADDITIONAL_DETAILS %}
|
||||
{% with title=form_titles|get_item:step %}
|
||||
{% if domain_request.has_additional_details %}
|
||||
{% include "includes/summary_item.html" with title="Additional Details" value=" " heading_level=heading_level editable=is_editable edit_link=domain_request_url %}
|
||||
<h3 class="register-form-review-header">CISA Regional Representative</h3>
|
||||
<ul class="usa-list usa-list--unstyled margin-top-0">
|
||||
{% if domain_request.cisa_representative_first_name %}
|
||||
<li>{{domain_request.cisa_representative_first_name}} {{domain_request.cisa_representative_last_name}}</li>
|
||||
{% if domain_request.cisa_representative_email %}
|
||||
<li>{{domain_request.cisa_representative_email}}</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
No
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<h3 class="register-form-review-header">Anything else</h3>
|
||||
<ul class="usa-list usa-list--unstyled margin-top-0">
|
||||
{% if domain_request.anything_else %}
|
||||
{{domain_request.anything_else}}
|
||||
{% else %}
|
||||
No
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{% include "includes/summary_item.html" with title="Additional Details" value="<span class='text-bold text-secondary-dark'>Incomplete</span>"|safe heading_level=heading_level editable=is_editable edit_link=domain_request_url %}
|
||||
{% endif %}
|
||||
{% with title=form_titles|get_item:step value=domain_request.anything_else|default:"<span class='text-bold text-secondary-dark'>Incomplete</span>"|safe %}
|
||||
{% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=is_editable edit_link=domain_request_url %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
|
|
|
@ -24,7 +24,11 @@
|
|||
{% if sub_header_text %}
|
||||
<h4 class="register-form-review-header">{{ sub_header_text }}</h4>
|
||||
{% endif %}
|
||||
{% if address %}
|
||||
{% if permissions %}
|
||||
{% include "includes/member_permissions.html" with permissions=value %}
|
||||
{% elif domain_mgmt %}
|
||||
{% include "includes/member_domain_management.html" with domain_count=value %}
|
||||
{% elif address %}
|
||||
{% include "includes/organization_address.html" with organization=value %}
|
||||
{% elif contact %}
|
||||
{% if list %}
|
||||
|
@ -122,9 +126,9 @@
|
|||
class="usa-link usa-link--icon font-sans-sm line-height-sans-5"
|
||||
>
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="{% static 'img/sprite.svg' %}#edit"></use>
|
||||
<use xlink:href="{% static 'img/sprite.svg' %}#{% if manage_button %}settings{% elif view_button %}visibility{% else %}edit{% endif %}"></use>
|
||||
</svg>
|
||||
Edit<span class="sr-only"> {{ title }}</span>
|
||||
{% if manage_button %}Manage{% elif view_button %}View{% else %}Edit{% endif %}<span class="sr-only"> {{ title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
{% extends 'domain_request_form.html' %}
|
||||
{% load static field_helpers %}
|
||||
|
||||
{% block form_required_fields_help_text %}
|
||||
{% include "includes/required_fields.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block form_fields %}
|
||||
|
||||
<fieldset class="usa-fieldset margin-top-2">
|
||||
<legend>
|
||||
<h2>Is there anything else you’d like us to know about your domain request?</h2>
|
||||
</legend>
|
||||
</fieldset>
|
||||
|
||||
<div class="margin-top-3" id="anything-else">
|
||||
<p>Provide details below. <abbr class="usa-hint usa-hint--required" title="required">*</abbr></p>
|
||||
{% with attr_maxlength=2000 add_label_class="usa-sr-only" %}
|
||||
{% input_with_errors forms.0.anything_else %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% endblock %}
|
137
src/registrar/templates/portfolio_member.html
Normal file
137
src/registrar/templates/portfolio_member.html
Normal file
|
@ -0,0 +1,137 @@
|
|||
{% extends 'portfolio_base.html' %}
|
||||
{% load static field_helpers%}
|
||||
|
||||
{% block title %}Organization member {% endblock %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block portfolio_content %}
|
||||
<div id="main-content">
|
||||
|
||||
{% url 'members' as url %}
|
||||
<nav class="usa-breadcrumb padding-top-0 margin-bottom-3" aria-label="Portfolio member breadcrumb">
|
||||
<ol class="usa-breadcrumb__list">
|
||||
<li class="usa-breadcrumb__list-item">
|
||||
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Members</span></a>
|
||||
</li>
|
||||
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
|
||||
<span>Manage member</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
|
||||
<h1 class="margin-bottom-3">Manage member</h1>
|
||||
|
||||
<div class="tablet:display-flex tablet:flex-justify">
|
||||
<h2 class="margin-top-0 margin-bottom-3 break-word">
|
||||
{% if member %}
|
||||
{{ member.email }}
|
||||
{% elif portfolio_invitation %}
|
||||
{{ portfolio_invitation.email }}
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% if has_edit_members_portfolio_permission %}
|
||||
{% if member %}
|
||||
<a
|
||||
role="button"
|
||||
href="#"
|
||||
class="display-block usa-button text-secondary usa-button--unstyled text-no-underline margin-bottom-3 line-height-sans-5 visible-mobile-flex"
|
||||
>
|
||||
Remove member
|
||||
</a>
|
||||
{% else %}
|
||||
<a
|
||||
role="button"
|
||||
href="#"
|
||||
class="display-block usa-button text-secondary usa-button--unstyled text-no-underline margin-bottom-3 line-height-sans-5 visible-mobile-flex"
|
||||
>
|
||||
Cancel invitation
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="usa-accordion usa-accordion--more-actions hidden-mobile-flex">
|
||||
<div class="usa-accordion__heading">
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions"
|
||||
aria-expanded="false"
|
||||
aria-controls="more-actions"
|
||||
>
|
||||
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
|
||||
<use xlink:href="/public/img/sprite.svg#more_vert"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="more-actions" class="usa-accordion__content usa-prose shadow-1 left-auto right-0" hidden>
|
||||
<h2>More options</h2>
|
||||
{% if member %}
|
||||
<a
|
||||
role="button"
|
||||
href="#"
|
||||
class="usa-button text-secondary usa-button--unstyled text-no-underline margin-top-2 line-height-sans-5"
|
||||
>
|
||||
Remove member
|
||||
</a>
|
||||
{% else %}
|
||||
<a
|
||||
role="button"
|
||||
href="#"
|
||||
class="usa-button text-secondary usa-button--unstyled text-no-underline margin-top-2 line-height-sans-5"
|
||||
>
|
||||
Cancel invitation
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<address>
|
||||
<strong class="text-primary-dark">Last active:</strong>
|
||||
{% if member and member.last_login %}
|
||||
{{ member.last_login }}
|
||||
{% elif portfolio_invitation %}
|
||||
Invited
|
||||
{% else %}
|
||||
⎯
|
||||
{% endif %}
|
||||
<br />
|
||||
|
||||
<strong class="text-primary-dark">Full name:</strong>
|
||||
{% if member %}
|
||||
{% if member.first_name or member.last_name %}
|
||||
{{ member.get_formatted_name }}
|
||||
{% else %}
|
||||
⎯
|
||||
{% endif %}
|
||||
{% else %}
|
||||
⎯
|
||||
{% endif %}
|
||||
<br />
|
||||
|
||||
<strong class="text-primary-dark">Title or organization role:</strong>
|
||||
{% if member and member.title %}
|
||||
{{ member.title }}
|
||||
{% else %}
|
||||
⎯
|
||||
{% endif %}
|
||||
</address>
|
||||
|
||||
{% if portfolio_permission %}
|
||||
{% include "includes/summary_item.html" with title='Member access and permissions' permissions=True value=portfolio_permission edit_link=edit_url editable=has_edit_members_portfolio_permission %}
|
||||
{% elif portfolio_invitation %}
|
||||
{% include "includes/summary_item.html" with title='Member access and permissions' permissions=True value=portfolio_invitation edit_link=edit_url editable=has_edit_members_portfolio_permission %}
|
||||
{% endif %}
|
||||
|
||||
{% comment %}view_button is passed below as true in all cases. This is because manage_button logic will trump view_button logic; ie. if manage_button is true, view_button will never be looked at{% endcomment %}
|
||||
{% if portfolio_permission %}
|
||||
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=portfolio_permission.get_managed_domains_count edit_link='#' editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
|
||||
{% elif portfolio_invitation %}
|
||||
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=portfolio_invitation.get_managed_domains_count edit_link='#' editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
|
||||
{% else %}
|
||||
{% include "includes/summary_item.html" with title='Domain management' domain_mgmt=True value=0 edit_link='#' editable=True manage_button=has_edit_members_portfolio_permission view_button=True %}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
42
src/registrar/templates/portfolio_member_permissions.html
Normal file
42
src/registrar/templates/portfolio_member_permissions.html
Normal file
|
@ -0,0 +1,42 @@
|
|||
{% extends 'portfolio_base.html' %}
|
||||
{% load static field_helpers%}
|
||||
|
||||
{% block title %}Organization member {% endblock %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
{% block portfolio_content %}
|
||||
<div class="grid-row grid-gap">
|
||||
<div class="tablet:grid-col-9" id="main-content">
|
||||
|
||||
{% block messages %}
|
||||
{% include "includes/form_messages.html" %}
|
||||
{% endblock %}
|
||||
|
||||
<h1>Manage member</h1>
|
||||
|
||||
<p>
|
||||
{% if member %}
|
||||
{{ member.email }}
|
||||
{% elif invitation %}
|
||||
{{ invitation.email }}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<form class="usa-form usa-form--large" method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{% input_with_errors form.roles %}
|
||||
{% input_with_errors form.additional_permissions %}
|
||||
<button
|
||||
type="submit"
|
||||
class="usa-button"
|
||||
>Submit</button>
|
||||
</form>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -18,6 +18,7 @@
|
|||
<div class="mobile:grid-col-12 tablet:grid-col-6">
|
||||
<h1 id="members-header">Members</h1>
|
||||
</div>
|
||||
{% if has_edit_members_portfolio_permission %}
|
||||
<div class="mobile:grid-col-12 tablet:grid-col-6">
|
||||
<p class="float-right-tablet tablet:margin-y-0">
|
||||
<a href="#" class="usa-button"
|
||||
|
@ -26,6 +27,7 @@
|
|||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% include "includes/members_table.html" with portfolio=portfolio %}
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
{% input_with_errors form.address_line2 %}
|
||||
{% input_with_errors form.city %}
|
||||
{% input_with_errors form.state_territory %}
|
||||
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
|
||||
{% with add_class="usa-input--small" sublabel_text="Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789." %}
|
||||
{% input_with_errors form.zipcode %}
|
||||
{% endwith %}
|
||||
<button type="submit" class="usa-button">
|
||||
|
|
|
@ -246,9 +246,7 @@ def is_members_subpage(path):
|
|||
"""Checks if the given page is a subpage of members.
|
||||
Takes a path name, like '/organization/'."""
|
||||
# Since our pages aren't unified under a common path, we need this approach for now.
|
||||
url_names = [
|
||||
"members",
|
||||
]
|
||||
url_names = ["members", "member", "member-permissions", "invitedmember", "invitedmember-permissions"]
|
||||
return get_url_name(path) in url_names
|
||||
|
||||
|
||||
|
|
|
@ -595,7 +595,12 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
|
||||
@less_console_noise_decorator
|
||||
def transition_state_and_send_email(
|
||||
self, domain_request, status, rejection_reason=None, action_needed_reason=None, action_needed_reason_email=None
|
||||
self,
|
||||
domain_request,
|
||||
status,
|
||||
rejection_reason=None,
|
||||
action_needed_reason=None,
|
||||
action_needed_reason_email=None,
|
||||
):
|
||||
"""Helper method for the email test cases."""
|
||||
|
||||
|
@ -687,6 +692,10 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
|
||||
# We use javascript to reset the content of this. It is only automatically set
|
||||
# if the email itself is somehow None.
|
||||
self._reset_action_needed_email(domain_request)
|
||||
|
||||
# Test the email sent out for bad_name
|
||||
bad_name = DomainRequest.ActionNeededReasons.BAD_NAME
|
||||
self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name)
|
||||
|
@ -694,6 +703,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS", 1, EMAIL, bcc_email_address=BCC_EMAIL
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
self._reset_action_needed_email(domain_request)
|
||||
|
||||
# Test the email sent out for eligibility_unclear
|
||||
eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR
|
||||
|
@ -702,6 +712,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", 2, EMAIL, bcc_email_address=BCC_EMAIL
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||
self._reset_action_needed_email(domain_request)
|
||||
|
||||
# Test that a custom email is sent out for questionable_so
|
||||
questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
|
||||
|
@ -710,6 +721,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, _creator.email, bcc_email_address=BCC_EMAIL
|
||||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
|
||||
self._reset_action_needed_email(domain_request)
|
||||
|
||||
# Assert that no other emails are sent on OTHER
|
||||
other = DomainRequest.ActionNeededReasons.OTHER
|
||||
|
@ -717,6 +729,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
|
||||
# Should be unchanged from before
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
|
||||
self._reset_action_needed_email(domain_request)
|
||||
|
||||
# Tests if an analyst can override existing email content
|
||||
questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
|
||||
|
@ -730,6 +743,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
domain_request.refresh_from_db()
|
||||
self.assert_email_is_accurate("custom email content", 4, _creator.email, bcc_email_address=BCC_EMAIL)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 5)
|
||||
self._reset_action_needed_email(domain_request)
|
||||
|
||||
# Tests if a new email gets sent when just the email is changed.
|
||||
# An email should NOT be sent out if we just modify the email content.
|
||||
|
@ -741,6 +755,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
)
|
||||
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 5)
|
||||
self._reset_action_needed_email(domain_request)
|
||||
|
||||
# Set the request back to in review
|
||||
domain_request.in_review()
|
||||
|
@ -757,55 +772,53 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), 6)
|
||||
|
||||
# def test_action_needed_sends_reason_email_prod_bcc(self):
|
||||
# """When an action needed reason is set, an email is sent out and help@get.gov
|
||||
# is BCC'd in production"""
|
||||
# # Ensure there is no user with this email
|
||||
# EMAIL = "mayor@igorville.gov"
|
||||
# BCC_EMAIL = settings.DEFAULT_FROM_EMAIL
|
||||
# User.objects.filter(email=EMAIL).delete()
|
||||
# in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
|
||||
# action_needed = DomainRequest.DomainRequestStatus.ACTION_NEEDED
|
||||
def _reset_action_needed_email(self, domain_request):
|
||||
"""Sets the given action needed email back to none"""
|
||||
domain_request.action_needed_reason_email = None
|
||||
domain_request.save()
|
||||
domain_request.refresh_from_db()
|
||||
|
||||
# # Create a sample domain request
|
||||
# domain_request = completed_domain_request(status=in_review)
|
||||
@override_settings(IS_PRODUCTION=True)
|
||||
@less_console_noise_decorator
|
||||
def test_rejected_sends_reason_email_prod_bcc(self):
|
||||
"""When a rejection reason is set, an email is sent out and help@get.gov
|
||||
is BCC'd in production"""
|
||||
# Create fake creator
|
||||
EMAIL = "meoward.jones@igorville.gov"
|
||||
|
||||
# # Test the email sent out for already_has_domains
|
||||
# already_has_domains = DomainRequest.ActionNeededReasons.ALREADY_HAS_DOMAINS
|
||||
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=already_has_domains)
|
||||
# self.assert_email_is_accurate("ORGANIZATION ALREADY HAS A .GOV DOMAIN", 0, EMAIL, bcc_email_address=BCC_EMAIL)
|
||||
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 1)
|
||||
_creator = User.objects.create(
|
||||
username="MrMeoward",
|
||||
first_name="Meoward",
|
||||
last_name="Jones",
|
||||
email=EMAIL,
|
||||
phone="(555) 123 12345",
|
||||
title="Treat inspector",
|
||||
)
|
||||
|
||||
# # Test the email sent out for bad_name
|
||||
# bad_name = DomainRequest.ActionNeededReasons.BAD_NAME
|
||||
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=bad_name)
|
||||
# self.assert_email_is_accurate(
|
||||
# "DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS", 1, EMAIL, bcc_email_address=BCC_EMAIL
|
||||
# )
|
||||
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 2)
|
||||
BCC_EMAIL = settings.DEFAULT_FROM_EMAIL
|
||||
in_review = DomainRequest.DomainRequestStatus.IN_REVIEW
|
||||
rejected = DomainRequest.DomainRequestStatus.REJECTED
|
||||
|
||||
# # Test the email sent out for eligibility_unclear
|
||||
# eligibility_unclear = DomainRequest.ActionNeededReasons.ELIGIBILITY_UNCLEAR
|
||||
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=eligibility_unclear)
|
||||
# self.assert_email_is_accurate(
|
||||
# "ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", 2, EMAIL, bcc_email_address=BCC_EMAIL
|
||||
# )
|
||||
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
|
||||
# Create a sample domain request
|
||||
domain_request = completed_domain_request(status=in_review, user=_creator)
|
||||
|
||||
# # Test the email sent out for questionable_so
|
||||
# questionable_so = DomainRequest.ActionNeededReasons.QUESTIONABLE_SENIOR_OFFICIAL
|
||||
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=questionable_so)
|
||||
# self.assert_email_is_accurate(
|
||||
# "SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", 3, EMAIL, bcc_email_address=BCC_EMAIL
|
||||
# )
|
||||
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
|
||||
|
||||
# # Assert that no other emails are sent on OTHER
|
||||
# other = DomainRequest.ActionNeededReasons.OTHER
|
||||
# self.transition_state_and_send_email(domain_request, action_needed, action_needed_reason=other)
|
||||
|
||||
# # Should be unchanged from before
|
||||
# self.assertEqual(len(self.mock_client.EMAILS_SENT), 4)
|
||||
expected_emails = {
|
||||
DomainRequest.RejectionReasons.DOMAIN_PURPOSE: "You didn’t provide enough information about how",
|
||||
DomainRequest.RejectionReasons.REQUESTOR_NOT_ELIGIBLE: "You must be a government employee, or be",
|
||||
DomainRequest.RejectionReasons.ORG_HAS_DOMAIN: "practice is to approve one domain",
|
||||
DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED: "we could not verify the organizational",
|
||||
DomainRequest.RejectionReasons.ORG_NOT_ELIGIBLE: ".Gov domains are only available to official U.S.-based",
|
||||
DomainRequest.RejectionReasons.NAMING_REQUIREMENTS: "does not meet our naming requirements",
|
||||
DomainRequest.RejectionReasons.OTHER: "YOU CAN SUBMIT A NEW REQUEST",
|
||||
}
|
||||
for i, (reason, email_content) in enumerate(expected_emails.items()):
|
||||
with self.subTest(reason=reason):
|
||||
self.transition_state_and_send_email(domain_request, status=rejected, rejection_reason=reason)
|
||||
self.assert_email_is_accurate(email_content, i, EMAIL, bcc_email_address=BCC_EMAIL)
|
||||
self.assertEqual(len(self.mock_client.EMAILS_SENT), i + 1)
|
||||
domain_request.rejection_reason_email = None
|
||||
domain_request.save()
|
||||
domain_request.refresh_from_db()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_save_model_sends_submitted_email(self):
|
||||
|
@ -1034,7 +1047,9 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
|
||||
# Reject for reason REQUESTOR and test email including dynamic organization name
|
||||
self.transition_state_and_send_email(
|
||||
domain_request, DomainRequest.DomainRequestStatus.REJECTED, DomainRequest.RejectionReasons.REQUESTOR
|
||||
domain_request,
|
||||
DomainRequest.DomainRequestStatus.REJECTED,
|
||||
DomainRequest.RejectionReasons.REQUESTOR_NOT_ELIGIBLE,
|
||||
)
|
||||
self.assert_email_is_accurate(
|
||||
"Your domain request was rejected because we don’t believe you’re eligible to request a \n.gov "
|
||||
|
@ -1072,7 +1087,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.transition_state_and_send_email(
|
||||
domain_request,
|
||||
DomainRequest.DomainRequestStatus.REJECTED,
|
||||
DomainRequest.RejectionReasons.SECOND_DOMAIN_REASONING,
|
||||
DomainRequest.RejectionReasons.ORG_HAS_DOMAIN,
|
||||
)
|
||||
self.assert_email_is_accurate(
|
||||
"Your domain request was rejected because Testorg has a .gov domain.", 0, _creator.email
|
||||
|
@ -1108,7 +1123,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.transition_state_and_send_email(
|
||||
domain_request,
|
||||
DomainRequest.DomainRequestStatus.REJECTED,
|
||||
DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY,
|
||||
DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
|
||||
)
|
||||
self.assert_email_is_accurate(
|
||||
"Your domain request was rejected because we could not verify the organizational \n"
|
||||
|
@ -1146,7 +1161,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.transition_state_and_send_email(
|
||||
domain_request,
|
||||
DomainRequest.DomainRequestStatus.REJECTED,
|
||||
DomainRequest.RejectionReasons.ORGANIZATION_ELIGIBILITY,
|
||||
DomainRequest.RejectionReasons.ORG_NOT_ELIGIBLE,
|
||||
)
|
||||
self.assert_email_is_accurate(
|
||||
"Your domain request was rejected because we determined that Testorg is not \neligible for "
|
||||
|
@ -1275,7 +1290,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
stack.enter_context(patch.object(messages, "error"))
|
||||
stack.enter_context(patch.object(messages, "warning"))
|
||||
domain_request.status = DomainRequest.DomainRequestStatus.REJECTED
|
||||
domain_request.rejection_reason = DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY
|
||||
domain_request.rejection_reason = DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED
|
||||
|
||||
self.admin.save_model(request, domain_request, None, True)
|
||||
|
||||
|
@ -1621,6 +1636,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
"updated_at",
|
||||
"status",
|
||||
"rejection_reason",
|
||||
"rejection_reason_email",
|
||||
"action_needed_reason",
|
||||
"action_needed_reason_email",
|
||||
"federal_agency",
|
||||
|
@ -1840,7 +1856,7 @@ class TestDomainRequestAdmin(MockEppLib):
|
|||
self.trigger_saving_approved_to_another_state(
|
||||
False,
|
||||
DomainRequest.DomainRequestStatus.REJECTED,
|
||||
DomainRequest.RejectionReasons.CONTACTS_OR_ORGANIZATION_LEGITIMACY,
|
||||
DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
|
||||
)
|
||||
|
||||
def test_side_effects_when_saving_approved_to_ineligible(self):
|
||||
|
|
|
@ -143,8 +143,8 @@ class GetActionNeededEmailForUserJsonTest(TestCase):
|
|||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIn("action_needed_email", data)
|
||||
self.assertIn("ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", data["action_needed_email"])
|
||||
self.assertIn("email", data)
|
||||
self.assertIn("ORGANIZATION MAY NOT MEET ELIGIBILITY REQUIREMENTS", data["email"])
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_action_needed_email_for_user_json_analyst(self):
|
||||
|
@ -160,8 +160,8 @@ class GetActionNeededEmailForUserJsonTest(TestCase):
|
|||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIn("action_needed_email", data)
|
||||
self.assertIn("SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", data["action_needed_email"])
|
||||
self.assertIn("email", data)
|
||||
self.assertIn("SENIOR OFFICIAL DOES NOT MEET ELIGIBILITY REQUIREMENTS", data["email"])
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_action_needed_email_for_user_json_regular(self):
|
||||
|
@ -176,3 +176,71 @@ class GetActionNeededEmailForUserJsonTest(TestCase):
|
|||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
|
||||
class GetRejectionEmailForUserJsonTest(TestCase):
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.superuser = create_superuser()
|
||||
self.analyst_user = create_user()
|
||||
self.agency = FederalAgency.objects.create(agency="Test Agency")
|
||||
self.domain_request = completed_domain_request(
|
||||
federal_agency=self.agency,
|
||||
name="test.gov",
|
||||
status=DomainRequest.DomainRequestStatus.REJECTED,
|
||||
)
|
||||
|
||||
self.api_url = reverse("get-rejection-email-for-user-json")
|
||||
|
||||
def tearDown(self):
|
||||
DomainRequest.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
FederalAgency.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_rejected_email_for_user_json_superuser(self):
|
||||
"""Test that a superuser can fetch the action needed email."""
|
||||
self.client.force_login(self.superuser)
|
||||
|
||||
response = self.client.get(
|
||||
self.api_url,
|
||||
{
|
||||
"reason": DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
|
||||
"domain_request_id": self.domain_request.id,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIn("email", data)
|
||||
self.assertIn("we could not verify the organizational", data["email"])
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_rejected_email_for_user_json_analyst(self):
|
||||
"""Test that an analyst can fetch the action needed email."""
|
||||
self.client.force_login(self.analyst_user)
|
||||
|
||||
response = self.client.get(
|
||||
self.api_url,
|
||||
{
|
||||
"reason": DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
|
||||
"domain_request_id": self.domain_request.id,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIn("email", data)
|
||||
self.assertIn("we could not verify the organizational", data["email"])
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_rejected_email_for_user_json_regular(self):
|
||||
"""Test that a regular user receives a 403 with an error message."""
|
||||
p = "password"
|
||||
self.client.login(username="testuser", password=p)
|
||||
response = self.client.get(
|
||||
self.api_url,
|
||||
{
|
||||
"reason": DomainRequest.RejectionReasons.CONTACTS_NOT_VERIFIED,
|
||||
"domain_request_id": self.domain_request.id,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
|
|
@ -33,7 +33,7 @@ class TestFormValidation(MockEppLib):
|
|||
form = OrganizationContactForm(data={"zipcode": "nah"})
|
||||
self.assertEqual(
|
||||
form.errors["zipcode"],
|
||||
["Enter a zip code in the form of 12345 or 12345-6789."],
|
||||
["Enter a 5-digit or 9-digit zip code, like 12345 or 12345-6789."],
|
||||
)
|
||||
|
||||
def test_org_contact_zip_valid(self):
|
||||
|
|
|
@ -152,12 +152,15 @@ class TestPortfolioInvitations(TestCase):
|
|||
self.invitation, _ = PortfolioInvitation.objects.get_or_create(
|
||||
email=self.email,
|
||||
portfolio=self.portfolio,
|
||||
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||
roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||
additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
DomainInvitation.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
UserPortfolioPermission.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
PortfolioInvitation.objects.all().delete()
|
||||
|
@ -209,8 +212,8 @@ class TestPortfolioInvitations(TestCase):
|
|||
PortfolioInvitation.objects.get_or_create(
|
||||
email=self.email,
|
||||
portfolio=portfolio2,
|
||||
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||
roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||
additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||
)
|
||||
with override_flag("multiple_portfolios", active=True):
|
||||
self.user.check_portfolio_invitations_on_login()
|
||||
|
@ -233,8 +236,8 @@ class TestPortfolioInvitations(TestCase):
|
|||
PortfolioInvitation.objects.get_or_create(
|
||||
email=self.email,
|
||||
portfolio=portfolio2,
|
||||
portfolio_roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||
portfolio_additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||
roles=[self.portfolio_role_base, self.portfolio_role_admin],
|
||||
additional_permissions=[self.portfolio_permission_1, self.portfolio_permission_2],
|
||||
)
|
||||
self.user.check_portfolio_invitations_on_login()
|
||||
self.user.refresh_from_db()
|
||||
|
@ -245,6 +248,52 @@ class TestPortfolioInvitations(TestCase):
|
|||
updated_invitation2, _ = PortfolioInvitation.objects.get_or_create(email=self.email, portfolio=portfolio2)
|
||||
self.assertEqual(updated_invitation2.status, PortfolioInvitation.PortfolioInvitationStatus.INVITED)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_managed_domains_count(self):
|
||||
"""Test that the correct number of domains, which are associated with the portfolio and
|
||||
have invited the email of the portfolio invitation, are returned."""
|
||||
# Add three domains, one which is in the portfolio and email is invited to,
|
||||
# one which is in the portfolio and email is not invited to,
|
||||
# and one which is email is invited to and not in the portfolio.
|
||||
# Arrange
|
||||
# domain_in_portfolio should not be included in the count
|
||||
domain_in_portfolio, _ = Domain.objects.get_or_create(name="domain_in_portfolio.gov", state=Domain.State.READY)
|
||||
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio, portfolio=self.portfolio)
|
||||
# domain_in_portfolio_and_invited should be included in the count
|
||||
domain_in_portfolio_and_invited, _ = Domain.objects.get_or_create(
|
||||
name="domain_in_portfolio_and_invited.gov", state=Domain.State.READY
|
||||
)
|
||||
DomainInformation.objects.get_or_create(
|
||||
creator=self.user, domain=domain_in_portfolio_and_invited, portfolio=self.portfolio
|
||||
)
|
||||
DomainInvitation.objects.get_or_create(email=self.email, domain=domain_in_portfolio_and_invited)
|
||||
# domain_invited should not be included in the count
|
||||
domain_invited, _ = Domain.objects.get_or_create(name="domain_invited.gov", state=Domain.State.READY)
|
||||
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_invited)
|
||||
DomainInvitation.objects.get_or_create(email=self.email, domain=domain_invited)
|
||||
|
||||
# Assert
|
||||
self.assertEqual(self.invitation.get_managed_domains_count(), 1)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_portfolio_permissions(self):
|
||||
"""Test that get_portfolio_permissions returns the expected list of permissions,
|
||||
based on the roles and permissions assigned to the invitation."""
|
||||
# Arrange
|
||||
test_permission_list = set()
|
||||
# add the arrays that are defined in UserPortfolioPermission for member and admin
|
||||
test_permission_list.update(
|
||||
UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [])
|
||||
)
|
||||
test_permission_list.update(
|
||||
UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(UserPortfolioRoleChoices.ORGANIZATION_ADMIN, [])
|
||||
)
|
||||
# add the permissions that are added to the invitation as additional_permissions
|
||||
test_permission_list.update([self.portfolio_permission_1, self.portfolio_permission_2])
|
||||
perm_list = list(test_permission_list)
|
||||
# Verify
|
||||
self.assertEquals(self.invitation.get_portfolio_permissions(), perm_list)
|
||||
|
||||
|
||||
class TestUserPortfolioPermission(TestCase):
|
||||
@less_console_noise_decorator
|
||||
|
@ -314,6 +363,40 @@ class TestUserPortfolioPermission(TestCase):
|
|||
),
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_get_managed_domains_count(self):
|
||||
"""Test that the correct number of managed domains associated with the portfolio
|
||||
are returned."""
|
||||
# Add three domains, one which is in the portfolio and managed by the user,
|
||||
# one which is in the portfolio and not managed by the user,
|
||||
# and one which is managed by the user and not in the portfolio.
|
||||
# Arrange
|
||||
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California")
|
||||
test_user = create_test_user()
|
||||
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
portfolio=portfolio, user=test_user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
# domain_in_portfolio should not be included in the count
|
||||
domain_in_portfolio, _ = Domain.objects.get_or_create(name="domain_in_portfolio.gov", state=Domain.State.READY)
|
||||
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_in_portfolio, portfolio=portfolio)
|
||||
# domain_in_portfolio_and_managed should be included in the count
|
||||
domain_in_portfolio_and_managed, _ = Domain.objects.get_or_create(
|
||||
name="domain_in_portfolio_and_managed.gov", state=Domain.State.READY
|
||||
)
|
||||
DomainInformation.objects.get_or_create(
|
||||
creator=self.user, domain=domain_in_portfolio_and_managed, portfolio=portfolio
|
||||
)
|
||||
UserDomainRole.objects.get_or_create(
|
||||
user=test_user, domain=domain_in_portfolio_and_managed, role=UserDomainRole.Roles.MANAGER
|
||||
)
|
||||
# domain_managed should not be included in the count
|
||||
domain_managed, _ = Domain.objects.get_or_create(name="domain_managed.gov", state=Domain.State.READY)
|
||||
DomainInformation.objects.get_or_create(creator=self.user, domain=domain_managed)
|
||||
UserDomainRole.objects.get_or_create(user=test_user, domain=domain_managed, role=UserDomainRole.Roles.MANAGER)
|
||||
|
||||
# Assert
|
||||
self.assertEqual(portfolio_permission.get_managed_domains_count(), 1)
|
||||
|
||||
|
||||
class TestUser(TestCase):
|
||||
"""Test actions that occur on user login,
|
||||
|
|
|
@ -267,7 +267,6 @@ class TestDomainRequest(TestCase):
|
|||
domain_request.submit()
|
||||
self.assertEqual(domain_request.status, domain_request.DomainRequestStatus.SUBMITTED)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def check_email_sent(
|
||||
self, domain_request, msg, action, expected_count, expected_content=None, expected_email="mayor@igorville.com"
|
||||
):
|
||||
|
@ -278,6 +277,7 @@ class TestDomainRequest(TestCase):
|
|||
# Perform the specified action
|
||||
action_method = getattr(domain_request, action)
|
||||
action_method()
|
||||
domain_request.save()
|
||||
|
||||
# Check if an email was sent
|
||||
sent_emails = [
|
||||
|
@ -337,12 +337,30 @@ class TestDomainRequest(TestCase):
|
|||
domain_request, msg, "withdraw", 1, expected_content="withdrawn", expected_email=user.email
|
||||
)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_reject_sends_email(self):
|
||||
msg = "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")
|
||||
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED, user=user)
|
||||
self.check_email_sent(domain_request, msg, "reject", 1, expected_content="Hi", expected_email=user.email)
|
||||
expected_email = user.email
|
||||
email_allowed, _ = AllowedEmail.objects.get_or_create(email=expected_email)
|
||||
with boto3_mocking.clients.handler_for("sesv2", self.mock_client):
|
||||
domain_request.reject()
|
||||
domain_request.rejection_reason = domain_request.RejectionReasons.CONTACTS_NOT_VERIFIED
|
||||
domain_request.rejection_reason_email = "test"
|
||||
domain_request.save()
|
||||
|
||||
# Check if an email was sent
|
||||
sent_emails = [
|
||||
email
|
||||
for email in MockSESClient.EMAILS_SENT
|
||||
if expected_email in email["kwargs"]["Destination"]["ToAddresses"]
|
||||
]
|
||||
self.assertEqual(len(sent_emails), 1)
|
||||
|
||||
email_content = sent_emails[0]["kwargs"]["Content"]["Simple"]["Body"]["Text"]["Data"]
|
||||
self.assertIn("test", email_content)
|
||||
|
||||
email_allowed.delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_reject_with_prejudice_does_not_send_email(self):
|
||||
|
|
|
@ -340,7 +340,10 @@ class TestDomainDetail(TestDomainOverview):
|
|||
detail_page = self.client.get(f"/domain/{domain.id}")
|
||||
# Check that alert message displays properly
|
||||
self.assertContains(
|
||||
detail_page, "To manage information for this domain, you must add yourself as a domain manager."
|
||||
detail_page,
|
||||
"You don't have access to manage "
|
||||
+ domain.name
|
||||
+ ". If you need to make updates, contact one of the listed domain managers.",
|
||||
)
|
||||
# Check that user does not have option to Edit domain
|
||||
self.assertNotContains(detail_page, "Edit")
|
||||
|
|
|
@ -37,6 +37,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
|
|||
UserDomainRole.objects.all().delete()
|
||||
UserPortfolioPermission.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
super().tearDown()
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from django.urls import reverse
|
||||
|
||||
from registrar.models.portfolio import Portfolio
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.user import User
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
|
@ -38,6 +39,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
phone="8003114567",
|
||||
title="Admin",
|
||||
)
|
||||
cls.email5 = "fifth@example.com"
|
||||
|
||||
# Create Portfolio
|
||||
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
|
||||
|
@ -67,6 +69,23 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
PortfolioInvitation.objects.create(
|
||||
email=cls.email5,
|
||||
portfolio=cls.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
PortfolioInvitation.objects.all().delete()
|
||||
UserPortfolioPermission.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
super().tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
@ -83,14 +102,21 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
self.assertFalse(data["has_previous"])
|
||||
self.assertFalse(data["has_next"])
|
||||
self.assertEqual(data["num_pages"], 1)
|
||||
self.assertEqual(data["total"], 4)
|
||||
self.assertEqual(data["unfiltered_total"], 4)
|
||||
self.assertEqual(data["total"], 5)
|
||||
self.assertEqual(data["unfiltered_total"], 5)
|
||||
|
||||
# Check the number of members
|
||||
self.assertEqual(len(data["members"]), 4)
|
||||
self.assertEqual(len(data["members"]), 5)
|
||||
|
||||
# Check member fields
|
||||
expected_emails = {self.user.email, self.user2.email, self.user3.email, self.user4.email}
|
||||
expected_emails = {
|
||||
self.user.email,
|
||||
self.user2.email,
|
||||
self.user3.email,
|
||||
self.user4.email,
|
||||
self.user4.email,
|
||||
self.email5,
|
||||
}
|
||||
actual_emails = {member["email"] for member in data["members"]}
|
||||
self.assertEqual(expected_emails, actual_emails)
|
||||
|
||||
|
@ -123,8 +149,8 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
self.assertTrue(data["has_next"])
|
||||
self.assertFalse(data["has_previous"])
|
||||
self.assertEqual(data["num_pages"], 2)
|
||||
self.assertEqual(data["total"], 14)
|
||||
self.assertEqual(data["unfiltered_total"], 14)
|
||||
self.assertEqual(data["total"], 15)
|
||||
self.assertEqual(data["unfiltered_total"], 15)
|
||||
|
||||
# Check the number of members on page 1
|
||||
self.assertEqual(len(data["members"]), 10)
|
||||
|
@ -142,7 +168,7 @@ class GetPortfolioMembersJsonTest(TestWithUser, WebTest):
|
|||
self.assertEqual(data["num_pages"], 2)
|
||||
|
||||
# Check the number of members on page 2
|
||||
self.assertEqual(len(data["members"]), 4)
|
||||
self.assertEqual(len(data["members"]), 5)
|
||||
|
||||
def test_search(self):
|
||||
"""Test search functionality for portfolio members."""
|
||||
|
|
|
@ -10,6 +10,7 @@ from registrar.models import (
|
|||
UserDomainRole,
|
||||
User,
|
||||
)
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.user_group import UserGroup
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
|
@ -288,9 +289,9 @@ class TestPortfolio(WebTest):
|
|||
def test_accessible_pages_when_user_does_not_have_role(self):
|
||||
"""Test that admin / memmber roles are associated with the right access"""
|
||||
self.app.set_user(self.user.username)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user, portfolio=self.portfolio, roles=portfolio_roles
|
||||
user=self.user, portfolio=self.portfolio, roles=roles
|
||||
)
|
||||
with override_flag("organization_feature", active=True):
|
||||
# This will redirect the user to the portfolio page.
|
||||
|
@ -398,8 +399,8 @@ class TestPortfolio(WebTest):
|
|||
"""When organization_feature flag is true and user has a portfolio,
|
||||
the portfolio should be set in session."""
|
||||
self.client.force_login(self.user)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
||||
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||
with override_flag("organization_feature", active=True):
|
||||
response = self.client.get(reverse("home"))
|
||||
# Ensure that middleware processes the session
|
||||
|
@ -420,8 +421,8 @@ class TestPortfolio(WebTest):
|
|||
This test also satisfies the condition when multiple_portfolios flag
|
||||
is false and user has a portfolio, so won't add a redundant test for that."""
|
||||
self.client.force_login(self.user)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
||||
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||
response = self.client.get(reverse("home"))
|
||||
# Ensure that middleware processes the session
|
||||
session_middleware = SessionMiddleware(lambda request: None)
|
||||
|
@ -457,8 +458,8 @@ class TestPortfolio(WebTest):
|
|||
"""When multiple_portfolios flag is true and user has a portfolio,
|
||||
the portfolio should be set in session."""
|
||||
self.client.force_login(self.user)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
||||
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||
with override_flag("organization_feature", active=True), override_flag("multiple_portfolios", active=True):
|
||||
response = self.client.get(reverse("home"))
|
||||
# Ensure that middleware processes the session
|
||||
|
@ -817,7 +818,6 @@ class TestPortfolio(WebTest):
|
|||
|
||||
# Verify that view-only settings are sent in the dynamic HTML
|
||||
response = self.client.get(reverse("get_portfolio_members_json") + f"?portfolio={self.portfolio.pk}")
|
||||
print(response.content)
|
||||
self.assertContains(response, '"action_label": "View"')
|
||||
self.assertContains(response, '"svg_icon": "visibility"')
|
||||
|
||||
|
@ -856,6 +856,230 @@ class TestPortfolio(WebTest):
|
|||
# TerminalHelper.colorful_logger(logger.info, TerminalColors.OKCYAN, f"{response.content}")
|
||||
self.assertContains(response, '"is_admin": true')
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
def test_cannot_view_member_page_when_flag_is_off(self):
|
||||
"""Test that user cannot access the member page when waffle flag is off"""
|
||||
|
||||
# Verify that the user cannot access the member page
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("member", kwargs={"pk": 1}), follow=True)
|
||||
# Make sure the page is denied
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_cannot_view_member_page_when_user_has_no_permission(self):
|
||||
"""Test that user cannot access the member page without proper permission"""
|
||||
|
||||
# give user base permissions
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
|
||||
# Verify that the user cannot access the member page
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("member", kwargs={"pk": 1}), follow=True)
|
||||
# Make sure the page is denied
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_can_view_member_page_when_user_has_view_members(self):
|
||||
"""Test that user can access the member page with view_members permission"""
|
||||
|
||||
# Arrange
|
||||
# give user permissions to view members
|
||||
permission_obj, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Verify the page can be accessed
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("member", kwargs={"pk": permission_obj.pk}), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Assert text within the page is correct
|
||||
self.assertContains(response, "First Last")
|
||||
self.assertContains(response, self.user.email)
|
||||
self.assertContains(response, "Basic access")
|
||||
self.assertContains(response, "No access")
|
||||
self.assertContains(response, "View all members")
|
||||
self.assertContains(response, "This member does not manage any domains.")
|
||||
|
||||
# Assert buttons and links within the page are correct
|
||||
self.assertNotContains(response, "usa-button--more-actions") # test that 3 dot is not present
|
||||
self.assertNotContains(response, "sprite.svg#edit") # test that Edit link is not present
|
||||
self.assertNotContains(response, "sprite.svg#settings") # test that Manage link is not present
|
||||
self.assertContains(response, "sprite.svg#visibility") # test that View link is present
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_can_view_member_page_when_user_has_edit_members(self):
|
||||
"""Test that user can access the member page with edit_members permission"""
|
||||
|
||||
# Arrange
|
||||
# give user permissions to view AND manage members
|
||||
permission_obj, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Verify the page can be accessed
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("member", kwargs={"pk": permission_obj.pk}), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Assert text within the page is correct
|
||||
self.assertContains(response, "First Last")
|
||||
self.assertContains(response, self.user.email)
|
||||
self.assertContains(response, "Admin access")
|
||||
self.assertContains(response, "View all requests plus create requests")
|
||||
self.assertContains(response, "View all members plus manage members")
|
||||
self.assertContains(
|
||||
response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
|
||||
)
|
||||
|
||||
# Assert buttons and links within the page are correct
|
||||
self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present
|
||||
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
|
||||
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
|
||||
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
def test_cannot_view_invitedmember_page_when_flag_is_off(self):
|
||||
"""Test that user cannot access the invitedmember page when waffle flag is off"""
|
||||
|
||||
# Verify that the user cannot access the member page
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("invitedmember", kwargs={"pk": 1}), follow=True)
|
||||
# Make sure the page is denied
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_cannot_view_invitedmember_page_when_user_has_no_permission(self):
|
||||
"""Test that user cannot access the invitedmember page without proper permission"""
|
||||
|
||||
# give user base permissions
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
)
|
||||
|
||||
# Verify that the user cannot access the member page
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("invitedmember", kwargs={"pk": 1}), follow=True)
|
||||
# Make sure the page is denied
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_can_view_invitedmember_page_when_user_has_view_members(self):
|
||||
"""Test that user can access the invitedmember page with view_members permission"""
|
||||
|
||||
# Arrange
|
||||
# give user permissions to view members
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
],
|
||||
)
|
||||
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
|
||||
email="info@example.com",
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Verify the page can be accessed
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("invitedmember", kwargs={"pk": portfolio_invitation.pk}), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Assert text within the page is correct
|
||||
self.assertContains(response, "Invited")
|
||||
self.assertContains(response, portfolio_invitation.email)
|
||||
self.assertContains(response, "Basic access")
|
||||
self.assertContains(response, "No access")
|
||||
self.assertContains(response, "View all members")
|
||||
self.assertContains(response, "This member does not manage any domains.")
|
||||
|
||||
# Assert buttons and links within the page are correct
|
||||
self.assertNotContains(response, "usa-button--more-actions") # test that 3 dot is not present
|
||||
self.assertNotContains(response, "sprite.svg#edit") # test that Edit link is not present
|
||||
self.assertNotContains(response, "sprite.svg#settings") # test that Manage link is not present
|
||||
self.assertContains(response, "sprite.svg#visibility") # test that View link is present
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_can_view_invitedmember_page_when_user_has_edit_members(self):
|
||||
"""Test that user can access the invitedmember page with edit_members permission"""
|
||||
|
||||
# Arrange
|
||||
# give user permissions to view AND manage members
|
||||
permission_obj, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
|
||||
email="info@example.com",
|
||||
portfolio=self.portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
# Verify the page can be accessed
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("invitedmember", kwargs={"pk": portfolio_invitation.pk}), follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Assert text within the page is correct
|
||||
self.assertContains(response, "Invited")
|
||||
self.assertContains(response, portfolio_invitation.email)
|
||||
self.assertContains(response, "Admin access")
|
||||
self.assertContains(response, "View all requests plus create requests")
|
||||
self.assertContains(response, "View all members plus manage members")
|
||||
self.assertContains(
|
||||
response, 'This member does not manage any domains. To assign this member a domain, click "Manage"'
|
||||
)
|
||||
|
||||
# Assert buttons and links within the page are correct
|
||||
self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present
|
||||
self.assertContains(response, "sprite.svg#edit") # test that Edit link is present
|
||||
self.assertContains(response, "sprite.svg#settings") # test that Manage link is present
|
||||
self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
def test_portfolio_domain_requests_page_when_user_has_no_permissions(self):
|
||||
|
@ -1015,8 +1239,8 @@ class TestPortfolio(WebTest):
|
|||
def test_portfolio_cache_updates_when_modified(self):
|
||||
"""Test that the portfolio in session updates when the portfolio is modified"""
|
||||
self.client.force_login(self.user)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
||||
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||
|
||||
with override_flag("organization_feature", active=True):
|
||||
# Initial request to set the portfolio in session
|
||||
|
@ -1044,8 +1268,8 @@ class TestPortfolio(WebTest):
|
|||
def test_portfolio_cache_updates_when_flag_disabled_while_logged_in(self):
|
||||
"""Test that the portfolio in session is set to None when the organization_feature flag is disabled"""
|
||||
self.client.force_login(self.user)
|
||||
portfolio_roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=portfolio_roles)
|
||||
roles = [UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
UserPortfolioPermission.objects.get_or_create(user=self.user, portfolio=self.portfolio, roles=roles)
|
||||
|
||||
with override_flag("organization_feature", active=True):
|
||||
# Initial request to set the portfolio in session
|
||||
|
|
|
@ -43,7 +43,7 @@ class DomainRequestTests(TestWithUser, WebTest):
|
|||
super().setUp()
|
||||
self.federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration")
|
||||
self.app.set_user(self.user.username)
|
||||
self.TITLES = DomainRequestWizard.TITLES
|
||||
self.TITLES = DomainRequestWizard.REGULAR_TITLES
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
|
@ -521,7 +521,8 @@ class DomainRequestTests(TestWithUser, WebTest):
|
|||
# And the existence of the modal's data parked and ready for the js init.
|
||||
# The next assert also tests for the passed requested domain context from
|
||||
# the view > domain_request_form > modal
|
||||
self.assertContains(review_page, "You are about to submit a domain request for city.gov")
|
||||
self.assertContains(review_page, "You are about to submit a domain request for")
|
||||
self.assertContains(review_page, "city.gov")
|
||||
|
||||
# final submission results in a redirect to the "finished" URL
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
|
@ -2741,6 +2742,66 @@ class DomainRequestTests(TestWithUser, WebTest):
|
|||
self.assertContains(review_page, "toggle-submit-domain-request")
|
||||
self.assertContains(review_page, "Your request form is incomplete")
|
||||
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_requests", active=True)
|
||||
def test_portfolio_user_missing_edit_permissions(self):
|
||||
"""Tests that a portfolio user without edit request permissions cannot edit or add new requests"""
|
||||
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Test Portfolio")
|
||||
portfolio_perm, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
|
||||
)
|
||||
# This user should be forbidden from creating new domain requests
|
||||
intro_page = self.app.get(reverse("domain-request:"), expect_errors=True)
|
||||
self.assertEqual(intro_page.status_code, 403)
|
||||
|
||||
# This user should also be forbidden from editing existing ones
|
||||
domain_request = completed_domain_request(user=self.user)
|
||||
edit_page = self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk}), expect_errors=True)
|
||||
self.assertEqual(edit_page.status_code, 403)
|
||||
|
||||
# Cleanup
|
||||
portfolio_perm.delete()
|
||||
portfolio.delete()
|
||||
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_requests", active=True)
|
||||
def test_portfolio_user_with_edit_permissions(self):
|
||||
"""Tests that a portfolio user with edit request permissions can edit and add new requests"""
|
||||
portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Test Portfolio")
|
||||
portfolio_perm, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
# This user should be allowed to create new domain requests
|
||||
intro_page = self.app.get(reverse("domain-request:"))
|
||||
self.assertEqual(intro_page.status_code, 200)
|
||||
|
||||
# This user should also be allowed to edit existing ones
|
||||
domain_request = completed_domain_request(user=self.user)
|
||||
edit_page = self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})).follow()
|
||||
self.assertEqual(edit_page.status_code, 200)
|
||||
|
||||
# Cleanup
|
||||
DomainRequest.objects.all().delete()
|
||||
portfolio_perm.delete()
|
||||
portfolio.delete()
|
||||
|
||||
def test_non_creator_access(self):
|
||||
"""Tests that a user cannot edit a domain request they didn't create"""
|
||||
p = "password"
|
||||
other_user = User.objects.create_user(username="other_user", password=p)
|
||||
domain_request = completed_domain_request(user=other_user)
|
||||
|
||||
edit_page = self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk}), expect_errors=True)
|
||||
self.assertEqual(edit_page.status_code, 403)
|
||||
|
||||
def test_creator_access(self):
|
||||
"""Tests that a user can edit a domain request they created"""
|
||||
domain_request = completed_domain_request(user=self.user)
|
||||
|
||||
edit_page = self.app.get(reverse("edit-domain-request", kwargs={"id": domain_request.pk})).follow()
|
||||
self.assertEqual(edit_page.status_code, 200)
|
||||
|
||||
|
||||
class DomainRequestTestDifferentStatuses(TestWithUser, WebTest):
|
||||
def setUp(self):
|
||||
|
@ -2903,7 +2964,7 @@ class DomainRequestTestDifferentStatuses(TestWithUser, WebTest):
|
|||
self.assertNotContains(home_page, "city.gov")
|
||||
|
||||
|
||||
class TestWizardUnlockingSteps(TestWithUser, WebTest):
|
||||
class TestDomainRequestWizard(TestWithUser, WebTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.app.set_user(self.user.username)
|
||||
|
@ -3025,6 +3086,94 @@ class TestWizardUnlockingSteps(TestWithUser, WebTest):
|
|||
else:
|
||||
self.fail(f"Expected a redirect, but got a different response: {response}")
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_requests", active=True)
|
||||
def test_wizard_steps_portfolio(self):
|
||||
"""
|
||||
Tests the behavior of the domain request wizard for portfolios.
|
||||
Ensures that:
|
||||
- The user can access the organization page.
|
||||
- The expected number of steps are locked/unlocked (implicit test for expected steps).
|
||||
- The user lands on the "Requesting entity" page
|
||||
- The user does not see the Domain and Domain requests buttons
|
||||
"""
|
||||
|
||||
# This should unlock 4 steps by default.
|
||||
# Purpose, .gov domain, current websites, and requirements for operating
|
||||
domain_request = completed_domain_request(
|
||||
status=DomainRequest.DomainRequestStatus.STARTED,
|
||||
user=self.user,
|
||||
)
|
||||
domain_request.anything_else = None
|
||||
domain_request.save()
|
||||
|
||||
federal_agency = FederalAgency.objects.get(agency="Non-Federal Agency")
|
||||
# Add a portfolio
|
||||
portfolio = Portfolio.objects.create(
|
||||
creator=self.user,
|
||||
organization_name="test portfolio",
|
||||
federal_agency=federal_agency,
|
||||
)
|
||||
|
||||
user_portfolio_permission = UserPortfolioPermission.objects.create(
|
||||
user=self.user,
|
||||
portfolio=portfolio,
|
||||
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
|
||||
)
|
||||
|
||||
response = self.app.get(f"/domain-request/{domain_request.id}/edit/")
|
||||
# django-webtest does not handle cookie-based sessions well because it keeps
|
||||
# resetting the session key on each new request, thus destroying the concept
|
||||
# of a "session". We are going to do it manually, saving the session ID here
|
||||
# and then setting the cookie on each request.
|
||||
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
|
||||
# Check if the response is a redirect
|
||||
if response.status_code == 302:
|
||||
# Follow the redirect manually
|
||||
try:
|
||||
detail_page = response.follow()
|
||||
|
||||
self.wizard.get_context_data()
|
||||
except Exception as err:
|
||||
# Handle any potential errors while following the redirect
|
||||
self.fail(f"Error following the redirect {err}")
|
||||
|
||||
# Now 'detail_page' contains the response after following the redirect
|
||||
self.assertEqual(detail_page.status_code, 200)
|
||||
|
||||
# Assert that we're on the organization page
|
||||
self.assertContains(detail_page, portfolio.organization_name)
|
||||
|
||||
# We should only see one unlocked step
|
||||
self.assertContains(detail_page, "#check_circle", count=4)
|
||||
|
||||
# One pages should still be locked (additional details)
|
||||
self.assertContains(detail_page, "#lock", 1)
|
||||
|
||||
# The current option should be selected
|
||||
self.assertContains(detail_page, "usa-current", count=1)
|
||||
|
||||
# We default to the requesting entity page
|
||||
expected_url = reverse("domain-request:portfolio_requesting_entity")
|
||||
# This returns the entire url, thus "in"
|
||||
self.assertIn(expected_url, detail_page.request.url)
|
||||
|
||||
# We shouldn't show the "domains" and "domain requests" buttons
|
||||
# on this page.
|
||||
self.assertNotContains(detail_page, "Domains")
|
||||
self.assertNotContains(detail_page, "Domain requests")
|
||||
else:
|
||||
self.fail(f"Expected a redirect, but got a different response: {response}")
|
||||
|
||||
# Data cleanup
|
||||
user_portfolio_permission.delete()
|
||||
portfolio.delete()
|
||||
federal_agency.delete()
|
||||
domain_request.delete()
|
||||
|
||||
|
||||
class TestPortfolioDomainRequestViewonly(TestWithUser, WebTest):
|
||||
|
||||
|
@ -3036,7 +3185,7 @@ class TestPortfolioDomainRequestViewonly(TestWithUser, WebTest):
|
|||
super().setUp()
|
||||
self.federal_agency, _ = FederalAgency.objects.get_or_create(agency="General Services Administration")
|
||||
self.app.set_user(self.user.username)
|
||||
self.TITLES = DomainRequestWizard.TITLES
|
||||
self.TITLES = DomainRequestWizard.REGULAR_TITLES
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
|
|
|
@ -6,36 +6,39 @@ from django.utils.html import escape
|
|||
from registrar.models.utility.generic_helper import value_of_attribute
|
||||
|
||||
|
||||
def get_all_action_needed_reason_emails(request, domain_request):
|
||||
"""Returns a dictionary of every action needed reason and its associated email
|
||||
for this particular domain request."""
|
||||
|
||||
emails = {}
|
||||
for action_needed_reason in domain_request.ActionNeededReasons:
|
||||
# Map the action_needed_reason to its default email
|
||||
emails[action_needed_reason.value] = get_action_needed_reason_default_email(
|
||||
request, domain_request, action_needed_reason.value
|
||||
)
|
||||
|
||||
return emails
|
||||
|
||||
|
||||
def get_action_needed_reason_default_email(request, domain_request, action_needed_reason):
|
||||
def get_action_needed_reason_default_email(domain_request, action_needed_reason):
|
||||
"""Returns the default email associated with the given action needed reason"""
|
||||
if not action_needed_reason or action_needed_reason == DomainRequest.ActionNeededReasons.OTHER:
|
||||
return _get_default_email(
|
||||
domain_request,
|
||||
file_path=f"emails/action_needed_reasons/{action_needed_reason}.txt",
|
||||
reason=action_needed_reason,
|
||||
excluded_reasons=[DomainRequest.ActionNeededReasons.OTHER],
|
||||
)
|
||||
|
||||
|
||||
def get_rejection_reason_default_email(domain_request, rejection_reason):
|
||||
"""Returns the default email associated with the given rejection reason"""
|
||||
return _get_default_email(
|
||||
domain_request,
|
||||
file_path="emails/status_change_rejected.txt",
|
||||
reason=rejection_reason,
|
||||
# excluded_reasons=[DomainRequest.RejectionReasons.OTHER]
|
||||
)
|
||||
|
||||
|
||||
def _get_default_email(domain_request, file_path, reason, excluded_reasons=None):
|
||||
if not reason:
|
||||
return None
|
||||
|
||||
if excluded_reasons and reason in excluded_reasons:
|
||||
return None
|
||||
|
||||
recipient = domain_request.creator
|
||||
# Return the context of the rendered views
|
||||
context = {"domain_request": domain_request, "recipient": recipient}
|
||||
context = {"domain_request": domain_request, "recipient": recipient, "reason": reason}
|
||||
|
||||
# Get the email body
|
||||
template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt"
|
||||
|
||||
email_body_text = get_template(template_path).render(context=context)
|
||||
email_body_text_cleaned = None
|
||||
if email_body_text:
|
||||
email_body_text_cleaned = email_body_text.strip().lstrip("\n")
|
||||
email_body_text = get_template(file_path).render(context=context)
|
||||
email_body_text_cleaned = email_body_text.strip().lstrip("\n") if email_body_text else None
|
||||
|
||||
return email_body_text_cleaned
|
||||
|
||||
|
|
|
@ -74,10 +74,13 @@ class PortfolioDomainRequestStep(StrEnum):
|
|||
appear in the order they are defined. (Order matters.)
|
||||
"""
|
||||
|
||||
# Portfolio
|
||||
REQUESTING_ENTITY = "organization_name"
|
||||
# NOTE: Append portfolio_ when customizing a view for portfolio.
|
||||
# By default, these will redirect to the normal request flow views.
|
||||
# After creating a new view, you will need to add this to urls.py.
|
||||
REQUESTING_ENTITY = "portfolio_requesting_entity"
|
||||
CURRENT_SITES = "current_sites"
|
||||
DOTGOV_DOMAIN = "dotgov_domain"
|
||||
PURPOSE = "purpose"
|
||||
ADDITIONAL_DETAILS = "additional_details"
|
||||
ADDITIONAL_DETAILS = "portfolio_additional_details"
|
||||
REQUIREMENTS = "requirements"
|
||||
REVIEW = "review"
|
||||
|
|
|
@ -23,6 +23,15 @@ class InvalidDomainError(ValueError):
|
|||
pass
|
||||
|
||||
|
||||
class OutsideOrgMemberError(ValueError):
|
||||
"""
|
||||
Error raised when an org member tries adding a user from a different .gov org.
|
||||
To be deleted when users can be members of multiple orgs.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ActionNotAllowed(Exception):
|
||||
"""User accessed an action that is not
|
||||
allowed by the current state"""
|
||||
|
@ -240,7 +249,7 @@ class SecurityEmailError(Exception):
|
|||
"""
|
||||
|
||||
_error_mapping = {
|
||||
SecurityEmailErrorCodes.BAD_DATA: ("Enter an email address in the required format, " "like name@example.com."),
|
||||
SecurityEmailErrorCodes.BAD_DATA: ("Enter an email address in the required format, like name@example.com."),
|
||||
}
|
||||
|
||||
def __init__(self, *args, code=None, **kwargs):
|
||||
|
|
|
@ -21,8 +21,10 @@ from registrar.models import (
|
|||
DomainRequest,
|
||||
DomainInformation,
|
||||
DomainInvitation,
|
||||
PortfolioInvitation,
|
||||
User,
|
||||
UserDomainRole,
|
||||
UserPortfolioPermission,
|
||||
PublicContact,
|
||||
)
|
||||
from registrar.utility.enums import DefaultEmail
|
||||
|
@ -35,9 +37,11 @@ from registrar.utility.errors import (
|
|||
DsDataErrorCodes,
|
||||
SecurityEmailError,
|
||||
SecurityEmailErrorCodes,
|
||||
OutsideOrgMemberError,
|
||||
)
|
||||
from registrar.models.utility.contact_error import ContactError
|
||||
from registrar.views.utility.permission_views import UserDomainRolePermissionDeleteView
|
||||
from registrar.utility.waffle import flag_is_active_for_user
|
||||
|
||||
from ..forms import (
|
||||
SeniorOfficialContactForm,
|
||||
|
@ -778,7 +782,18 @@ class DomainAddUserView(DomainFormBaseView):
|
|||
"""Get an absolute URL for this domain."""
|
||||
return self.request.build_absolute_uri(reverse("domain", kwargs={"pk": self.object.id}))
|
||||
|
||||
def _send_domain_invitation_email(self, email: str, requestor: User, add_success=True):
|
||||
def _is_member_of_different_org(self, email, requestor, requested_user):
|
||||
"""Verifies if an email belongs to a different organization as a member or invited member."""
|
||||
# Check if user is a already member of a different organization than the requestor's org
|
||||
requestor_org = UserPortfolioPermission.objects.filter(user=requestor).first().portfolio
|
||||
existing_org_permission = UserPortfolioPermission.objects.filter(user=requested_user).first()
|
||||
existing_org_invitation = PortfolioInvitation.objects.filter(email=email).first()
|
||||
|
||||
return (existing_org_permission and existing_org_permission.portfolio != requestor_org) or (
|
||||
existing_org_invitation and existing_org_invitation.portfolio != requestor_org
|
||||
)
|
||||
|
||||
def _send_domain_invitation_email(self, email: str, requestor: User, requested_user=None, add_success=True):
|
||||
"""Performs the sending of the domain invitation email,
|
||||
does not make a domain information object
|
||||
email: string- email to send to
|
||||
|
@ -803,6 +818,13 @@ class DomainAddUserView(DomainFormBaseView):
|
|||
)
|
||||
return None
|
||||
|
||||
# Check is user is a member or invited member of a different org from this domain's org
|
||||
if flag_is_active_for_user(requestor, "organization_feature") and self._is_member_of_different_org(
|
||||
email, requestor, requested_user
|
||||
):
|
||||
add_success = False
|
||||
raise OutsideOrgMemberError
|
||||
|
||||
# Check to see if an invite has already been sent
|
||||
try:
|
||||
invite = DomainInvitation.objects.get(email=email, domain=self.object)
|
||||
|
@ -859,16 +881,21 @@ class DomainAddUserView(DomainFormBaseView):
|
|||
Throws EmailSendingError."""
|
||||
requested_email = form.cleaned_data["email"]
|
||||
requestor = self.request.user
|
||||
email_success = False
|
||||
# look up a user with that email
|
||||
try:
|
||||
requested_user = User.objects.get(email=requested_email)
|
||||
except User.DoesNotExist:
|
||||
# no matching user, go make an invitation
|
||||
email_success = True
|
||||
return self._make_invitation(requested_email, requestor)
|
||||
else:
|
||||
# if user already exists then just send an email
|
||||
try:
|
||||
self._send_domain_invitation_email(requested_email, requestor, add_success=False)
|
||||
self._send_domain_invitation_email(
|
||||
requested_email, requestor, requested_user=requested_user, add_success=False
|
||||
)
|
||||
email_success = True
|
||||
except EmailSendingError:
|
||||
logger.warn(
|
||||
"Could not send email invitation (EmailSendingError)",
|
||||
|
@ -876,6 +903,17 @@ class DomainAddUserView(DomainFormBaseView):
|
|||
exc_info=True,
|
||||
)
|
||||
messages.warning(self.request, "Could not send email invitation.")
|
||||
email_success = True
|
||||
except OutsideOrgMemberError:
|
||||
logger.warn(
|
||||
"Could not send email. Can not invite member of a .gov organization to a different organization.",
|
||||
self.object,
|
||||
exc_info=True,
|
||||
)
|
||||
messages.error(
|
||||
self.request,
|
||||
f"{requested_email} is already a member of another .gov organization.",
|
||||
)
|
||||
except Exception:
|
||||
logger.warn(
|
||||
"Could not send email invitation (Other Exception)",
|
||||
|
@ -883,17 +921,17 @@ class DomainAddUserView(DomainFormBaseView):
|
|||
exc_info=True,
|
||||
)
|
||||
messages.warning(self.request, "Could not send email invitation.")
|
||||
if email_success:
|
||||
try:
|
||||
UserDomainRole.objects.create(
|
||||
user=requested_user,
|
||||
domain=self.object,
|
||||
role=UserDomainRole.Roles.MANAGER,
|
||||
)
|
||||
messages.success(self.request, f"Added user {requested_email}.")
|
||||
except IntegrityError:
|
||||
messages.warning(self.request, f"{requested_email} is already a manager for this domain")
|
||||
|
||||
try:
|
||||
UserDomainRole.objects.create(
|
||||
user=requested_user,
|
||||
domain=self.object,
|
||||
role=UserDomainRole.Roles.MANAGER,
|
||||
)
|
||||
except IntegrityError:
|
||||
messages.warning(self.request, f"{requested_email} is already a manager for this domain")
|
||||
else:
|
||||
messages.success(self.request, f"Added user {requested_email}.")
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
|
||||
|
|
|
@ -43,8 +43,8 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
although not without consulting the base implementation, first.
|
||||
"""
|
||||
|
||||
StepEnum: Step = Step # type: ignore
|
||||
template_name = ""
|
||||
is_portfolio = False
|
||||
|
||||
# uniquely namespace the wizard in urls.py
|
||||
# (this is not seen _in_ urls, only for Django's internal naming)
|
||||
|
@ -54,42 +54,140 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
# name for accessing /domain-request/<id>/edit
|
||||
EDIT_URL_NAME = "edit-domain-request"
|
||||
NEW_URL_NAME = "/request/"
|
||||
|
||||
# region: Titles
|
||||
# We need to pass our human-readable step titles as context to the templates.
|
||||
TITLES = {
|
||||
StepEnum.ORGANIZATION_TYPE: _("Type of organization"),
|
||||
StepEnum.TRIBAL_GOVERNMENT: _("Tribal government"),
|
||||
StepEnum.ORGANIZATION_FEDERAL: _("Federal government branch"),
|
||||
StepEnum.ORGANIZATION_ELECTION: _("Election office"),
|
||||
StepEnum.ORGANIZATION_CONTACT: _("Organization"),
|
||||
StepEnum.ABOUT_YOUR_ORGANIZATION: _("About your organization"),
|
||||
StepEnum.SENIOR_OFFICIAL: _("Senior official"),
|
||||
StepEnum.CURRENT_SITES: _("Current websites"),
|
||||
StepEnum.DOTGOV_DOMAIN: _(".gov domain"),
|
||||
StepEnum.PURPOSE: _("Purpose of your domain"),
|
||||
StepEnum.OTHER_CONTACTS: _("Other employees from your organization"),
|
||||
StepEnum.ADDITIONAL_DETAILS: _("Additional details"),
|
||||
StepEnum.REQUIREMENTS: _("Requirements for operating a .gov domain"),
|
||||
StepEnum.REVIEW: _("Review and submit your domain request"),
|
||||
REGULAR_TITLES = {
|
||||
Step.ORGANIZATION_TYPE: _("Type of organization"),
|
||||
Step.TRIBAL_GOVERNMENT: _("Tribal government"),
|
||||
Step.ORGANIZATION_FEDERAL: _("Federal government branch"),
|
||||
Step.ORGANIZATION_ELECTION: _("Election office"),
|
||||
Step.ORGANIZATION_CONTACT: _("Organization"),
|
||||
Step.ABOUT_YOUR_ORGANIZATION: _("About your organization"),
|
||||
Step.SENIOR_OFFICIAL: _("Senior official"),
|
||||
Step.CURRENT_SITES: _("Current websites"),
|
||||
Step.DOTGOV_DOMAIN: _(".gov domain"),
|
||||
Step.PURPOSE: _("Purpose of your domain"),
|
||||
Step.OTHER_CONTACTS: _("Other employees from your organization"),
|
||||
Step.ADDITIONAL_DETAILS: _("Additional details"),
|
||||
Step.REQUIREMENTS: _("Requirements for operating a .gov domain"),
|
||||
Step.REVIEW: _("Review and submit your domain request"),
|
||||
}
|
||||
|
||||
# Titles for the portfolio context
|
||||
PORTFOLIO_TITLES = {
|
||||
PortfolioDomainRequestStep.REQUESTING_ENTITY: _("Requesting entity"),
|
||||
PortfolioDomainRequestStep.CURRENT_SITES: _("Current websites"),
|
||||
PortfolioDomainRequestStep.DOTGOV_DOMAIN: _(".gov domain"),
|
||||
PortfolioDomainRequestStep.PURPOSE: _("Purpose of your domain"),
|
||||
PortfolioDomainRequestStep.ADDITIONAL_DETAILS: _("Additional details"),
|
||||
PortfolioDomainRequestStep.REQUIREMENTS: _("Requirements for operating a .gov domain"),
|
||||
PortfolioDomainRequestStep.REVIEW: _("Review and submit your domain request"),
|
||||
}
|
||||
# endregion
|
||||
|
||||
# region: Wizard conditions
|
||||
# We can use a dictionary with step names and callables that return booleans
|
||||
# to show or hide particular steps based on the state of the process.
|
||||
WIZARD_CONDITIONS = {
|
||||
StepEnum.ORGANIZATION_FEDERAL: lambda w: w.from_model("show_organization_federal", False),
|
||||
StepEnum.TRIBAL_GOVERNMENT: lambda w: w.from_model("show_tribal_government", False),
|
||||
StepEnum.ORGANIZATION_ELECTION: lambda w: w.from_model("show_organization_election", False),
|
||||
StepEnum.ABOUT_YOUR_ORGANIZATION: lambda w: w.from_model("show_about_your_organization", False),
|
||||
REGULAR_WIZARD_CONDITIONS = {
|
||||
Step.ORGANIZATION_FEDERAL: lambda w: w.from_model("show_organization_federal", False),
|
||||
Step.TRIBAL_GOVERNMENT: lambda w: w.from_model("show_tribal_government", False),
|
||||
Step.ORGANIZATION_ELECTION: lambda w: w.from_model("show_organization_election", False),
|
||||
Step.ABOUT_YOUR_ORGANIZATION: lambda w: w.from_model("show_about_your_organization", False),
|
||||
}
|
||||
|
||||
PORTFOLIO_WIZARD_CONDITIONS = {} # type: ignore
|
||||
# endregion
|
||||
|
||||
# region: Unlocking steps
|
||||
# The conditions by which each step is "unlocked" or "locked"
|
||||
REGULAR_UNLOCKING_STEPS = {
|
||||
Step.ORGANIZATION_TYPE: lambda self: self.domain_request.generic_org_type is not None,
|
||||
Step.TRIBAL_GOVERNMENT: lambda self: self.domain_request.tribe_name is not None,
|
||||
Step.ORGANIZATION_FEDERAL: lambda self: self.domain_request.federal_type is not None,
|
||||
Step.ORGANIZATION_ELECTION: lambda self: self.domain_request.is_election_board is not None,
|
||||
Step.ORGANIZATION_CONTACT: lambda self: (
|
||||
self.domain_request.federal_agency is not None
|
||||
or self.domain_request.organization_name is not None
|
||||
or self.domain_request.address_line1 is not None
|
||||
or self.domain_request.city is not None
|
||||
or self.domain_request.state_territory is not None
|
||||
or self.domain_request.zipcode is not None
|
||||
or self.domain_request.urbanization is not None
|
||||
),
|
||||
Step.ABOUT_YOUR_ORGANIZATION: lambda self: self.domain_request.about_your_organization is not None,
|
||||
Step.SENIOR_OFFICIAL: lambda self: self.domain_request.senior_official is not None,
|
||||
Step.CURRENT_SITES: lambda self: (
|
||||
self.domain_request.current_websites.exists() or self.domain_request.requested_domain is not None
|
||||
),
|
||||
Step.DOTGOV_DOMAIN: lambda self: self.domain_request.requested_domain is not None,
|
||||
Step.PURPOSE: lambda self: self.domain_request.purpose is not None,
|
||||
Step.OTHER_CONTACTS: lambda self: (
|
||||
self.domain_request.other_contacts.exists() or self.domain_request.no_other_contacts_rationale is not None
|
||||
),
|
||||
Step.ADDITIONAL_DETAILS: lambda self: (
|
||||
# Additional details is complete as long as "has anything else" and "has cisa rep" are not None
|
||||
(
|
||||
self.domain_request.has_anything_else_text is not None
|
||||
and self.domain_request.has_cisa_representative is not None
|
||||
)
|
||||
),
|
||||
Step.REQUIREMENTS: lambda self: self.domain_request.is_policy_acknowledged is not None,
|
||||
Step.REVIEW: lambda self: self.domain_request.is_policy_acknowledged is not None,
|
||||
}
|
||||
|
||||
PORTFOLIO_UNLOCKING_STEPS = {
|
||||
PortfolioDomainRequestStep.REQUESTING_ENTITY: lambda self: self.domain_request.organization_name is not None,
|
||||
PortfolioDomainRequestStep.CURRENT_SITES: lambda self: (
|
||||
self.domain_request.current_websites.exists() or self.domain_request.requested_domain is not None
|
||||
),
|
||||
PortfolioDomainRequestStep.DOTGOV_DOMAIN: lambda self: self.domain_request.requested_domain is not None,
|
||||
PortfolioDomainRequestStep.PURPOSE: lambda self: self.domain_request.purpose is not None,
|
||||
PortfolioDomainRequestStep.ADDITIONAL_DETAILS: lambda self: self.domain_request.anything_else is not None,
|
||||
PortfolioDomainRequestStep.REQUIREMENTS: lambda self: self.domain_request.is_policy_acknowledged is not None,
|
||||
PortfolioDomainRequestStep.REVIEW: lambda self: self.domain_request.is_policy_acknowledged is not None,
|
||||
}
|
||||
# endregion
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.steps = StepsHelper(self)
|
||||
self.titles = {}
|
||||
self.wizard_conditions = {}
|
||||
self.unlocking_steps = {}
|
||||
self.steps = None
|
||||
# Configure titles, wizard_conditions, unlocking_steps, and steps
|
||||
self.configure_step_options()
|
||||
self._domain_request = None # for caching
|
||||
|
||||
def configure_step_options(self):
|
||||
"""Changes which steps are available to the user based on self.is_portfolio.
|
||||
This may change on the fly, so we need to evaluate it on the fly.
|
||||
|
||||
Using this information, we then set three configuration variables.
|
||||
- self.titles => Returns the page titles for each step
|
||||
- self.wizard_conditions => Conditionally shows / hides certain steps
|
||||
- self.unlocking_steps => Determines what steps are locked/unlocked
|
||||
|
||||
Then, we create self.steps.
|
||||
"""
|
||||
if self.is_portfolio:
|
||||
self.titles = self.PORTFOLIO_TITLES
|
||||
self.wizard_conditions = self.PORTFOLIO_WIZARD_CONDITIONS
|
||||
self.unlocking_steps = self.PORTFOLIO_UNLOCKING_STEPS
|
||||
else:
|
||||
self.titles = self.REGULAR_TITLES
|
||||
self.wizard_conditions = self.REGULAR_WIZARD_CONDITIONS
|
||||
self.unlocking_steps = self.REGULAR_UNLOCKING_STEPS
|
||||
self.steps = StepsHelper(self)
|
||||
|
||||
def has_pk(self):
|
||||
"""Does this wizard know about a DomainRequest database record?"""
|
||||
return "domain_request_id" in self.storage
|
||||
|
||||
def get_step_enum(self):
|
||||
"""Determines which step enum we should use for the wizard"""
|
||||
return PortfolioDomainRequestStep if self.is_portfolio else Step
|
||||
|
||||
@property
|
||||
def prefix(self):
|
||||
"""Namespace the wizard to avoid clashes in session variable names."""
|
||||
|
@ -128,10 +226,16 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
|
||||
# If a user is creating a request, we assume that perms are handled upstream
|
||||
if self.request.user.is_org_user(self.request):
|
||||
portfolio = self.request.session.get("portfolio")
|
||||
self._domain_request = DomainRequest.objects.create(
|
||||
creator=self.request.user,
|
||||
portfolio=self.request.session.get("portfolio"),
|
||||
portfolio=portfolio,
|
||||
)
|
||||
|
||||
# Question for reviewers: we should probably be doing this right?
|
||||
if portfolio and not self._domain_request.generic_org_type:
|
||||
self._domain_request.generic_org_type = portfolio.organization_type
|
||||
self._domain_request.save()
|
||||
else:
|
||||
self._domain_request = DomainRequest.objects.create(creator=self.request.user)
|
||||
|
||||
|
@ -190,6 +294,12 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""This method handles GET requests."""
|
||||
|
||||
if not self.is_portfolio and self.request.user.is_org_user(request):
|
||||
self.is_portfolio = True
|
||||
# Configure titles, wizard_conditions, unlocking_steps, and steps
|
||||
self.configure_step_options()
|
||||
|
||||
current_url = resolve(request.path_info).url_name
|
||||
|
||||
# if user visited via an "edit" url, associate the id of the
|
||||
|
@ -209,7 +319,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
# Clear context so the prop getter won't create a request here.
|
||||
# Creating a request will be handled in the post method for the
|
||||
# intro page.
|
||||
return render(request, "domain_request_intro.html", {})
|
||||
return render(request, "domain_request_intro.html", {"hide_requests": True, "hide_domains": True})
|
||||
else:
|
||||
return self.goto(self.steps.first)
|
||||
|
||||
|
@ -321,56 +431,9 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
return DomainRequest.objects.filter(creator=self.request.user, status__in=check_statuses)
|
||||
|
||||
def db_check_for_unlocking_steps(self):
|
||||
"""Helper for get_context_data
|
||||
|
||||
"""Helper for get_context_data.
|
||||
Queries the DB for a domain request and returns a list of unlocked steps."""
|
||||
|
||||
# The way this works is as follows:
|
||||
# Each step is assigned a true/false value to determine if it is
|
||||
# "unlocked" or not. This dictionary of values is looped through
|
||||
# at the end of this function and any step with a "true" value is
|
||||
# added to a simple array that is returned at the end of this function.
|
||||
# This array is eventually passed to the frontend context (eg. domain_request_sidebar.html),
|
||||
# and is used to determine how steps appear in the side nav.
|
||||
# It is worth noting that any step assigned "false" here will be EXCLUDED
|
||||
# from the list of "unlocked" steps.
|
||||
|
||||
history_dict = {
|
||||
"generic_org_type": self.domain_request.generic_org_type is not None,
|
||||
"tribal_government": self.domain_request.tribe_name is not None,
|
||||
"organization_federal": self.domain_request.federal_type is not None,
|
||||
"organization_election": self.domain_request.is_election_board is not None,
|
||||
"organization_contact": (
|
||||
self.domain_request.federal_agency is not None
|
||||
or self.domain_request.organization_name is not None
|
||||
or self.domain_request.address_line1 is not None
|
||||
or self.domain_request.city is not None
|
||||
or self.domain_request.state_territory is not None
|
||||
or self.domain_request.zipcode is not None
|
||||
or self.domain_request.urbanization is not None
|
||||
),
|
||||
"about_your_organization": self.domain_request.about_your_organization is not None,
|
||||
"senior_official": self.domain_request.senior_official is not None,
|
||||
"current_sites": (
|
||||
self.domain_request.current_websites.exists() or self.domain_request.requested_domain is not None
|
||||
),
|
||||
"dotgov_domain": self.domain_request.requested_domain is not None,
|
||||
"purpose": self.domain_request.purpose is not None,
|
||||
"other_contacts": (
|
||||
self.domain_request.other_contacts.exists()
|
||||
or self.domain_request.no_other_contacts_rationale is not None
|
||||
),
|
||||
"additional_details": (
|
||||
# Additional details is complete as long as "has anything else" and "has cisa rep" are not None
|
||||
(
|
||||
self.domain_request.has_anything_else_text is not None
|
||||
and self.domain_request.has_cisa_representative is not None
|
||||
)
|
||||
),
|
||||
"requirements": self.domain_request.is_policy_acknowledged is not None,
|
||||
"review": self.domain_request.is_policy_acknowledged is not None,
|
||||
}
|
||||
return [key for key, value in history_dict.items() if value]
|
||||
return [key for key, is_unlocked_checker in self.unlocking_steps.items() if is_unlocked_checker(self)]
|
||||
|
||||
def get_context_data(self):
|
||||
"""Define context for access on all wizard pages."""
|
||||
|
@ -380,17 +443,22 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
requested_domain_name = self.domain_request.requested_domain.name
|
||||
|
||||
context_stuff = {}
|
||||
if DomainRequest._form_complete(self.domain_request, self.request):
|
||||
|
||||
# Note: we will want to consolidate the non_org_steps_complete check into the same check that
|
||||
# org_steps_complete is using at some point.
|
||||
non_org_steps_complete = DomainRequest._form_complete(self.domain_request, self.request)
|
||||
org_steps_complete = len(self.db_check_for_unlocking_steps()) == len(self.steps)
|
||||
if (not self.is_portfolio and non_org_steps_complete) or (self.is_portfolio and org_steps_complete):
|
||||
modal_button = '<button type="submit" ' 'class="usa-button" ' ">Submit request</button>"
|
||||
context_stuff = {
|
||||
"not_form": False,
|
||||
"form_titles": self.TITLES,
|
||||
"form_titles": self.titles,
|
||||
"steps": self.steps,
|
||||
"visited": self.storage.get("step_history", []),
|
||||
"is_federal": self.domain_request.is_federal(),
|
||||
"modal_button": modal_button,
|
||||
"modal_heading": "You are about to submit a domain request for "
|
||||
+ str(self.domain_request.requested_domain),
|
||||
"modal_heading": "You are about to submit a domain request for ",
|
||||
"domain_name_modal": str(self.domain_request.requested_domain),
|
||||
"modal_description": "Once you submit this request, you won’t be able to edit it until we review it.\
|
||||
You’ll only be able to withdraw your request.",
|
||||
"review_form_is_complete": True,
|
||||
|
@ -401,7 +469,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
modal_button = '<button type="button" class="usa-button" data-close-modal>Return to request</button>'
|
||||
context_stuff = {
|
||||
"not_form": True,
|
||||
"form_titles": self.TITLES,
|
||||
"form_titles": self.titles,
|
||||
"steps": self.steps,
|
||||
"visited": self.storage.get("step_history", []),
|
||||
"is_federal": self.domain_request.is_federal(),
|
||||
|
@ -413,14 +481,19 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
"user": self.request.user,
|
||||
"requested_domain__name": requested_domain_name,
|
||||
}
|
||||
|
||||
# Hides the requests and domains buttons in the navbar
|
||||
context_stuff["hide_requests"] = self.is_portfolio
|
||||
context_stuff["hide_domains"] = self.is_portfolio
|
||||
|
||||
return context_stuff
|
||||
|
||||
def get_step_list(self) -> list:
|
||||
"""Dynamically generated list of steps in the form wizard."""
|
||||
return request_step_list(self)
|
||||
return request_step_list(self, self.get_step_enum())
|
||||
|
||||
def goto(self, step):
|
||||
if step == "generic_org_type":
|
||||
if step == "generic_org_type" or step == "portfolio_requesting_entity":
|
||||
# We need to avoid creating a new domain request if the user
|
||||
# clicks the back button
|
||||
self.request.session["new_request"] = False
|
||||
|
@ -443,21 +516,21 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
|
||||
def post(self, request, *args, **kwargs) -> HttpResponse:
|
||||
"""This method handles POST requests."""
|
||||
if not self.is_portfolio and self.request.user.is_org_user(request): # type: ignore
|
||||
self.is_portfolio = True
|
||||
# Configure titles, wizard_conditions, unlocking_steps, and steps
|
||||
self.configure_step_options()
|
||||
|
||||
# which button did the user press?
|
||||
button: str = request.POST.get("submit_button", "")
|
||||
# If a user hits the new request url directly
|
||||
|
||||
if "new_request" not in request.session:
|
||||
request.session["new_request"] = True
|
||||
|
||||
# if user has acknowledged the intro message
|
||||
if button == "intro_acknowledge":
|
||||
if request.path_info == self.NEW_URL_NAME:
|
||||
|
||||
if self.request.session["new_request"] is True:
|
||||
# This will trigger the domain_request getter into creating a new DomainRequest
|
||||
del self.storage
|
||||
|
||||
return self.goto(self.steps.first)
|
||||
# Split into a function: C901 'DomainRequestWizard.post' is too complex (11)
|
||||
self.handle_intro_acknowledge(request)
|
||||
|
||||
# if accessing this class directly, redirect to the first step
|
||||
if self.__class__ == DomainRequestWizard:
|
||||
|
@ -488,6 +561,14 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
# otherwise, proceed as normal
|
||||
return self.goto_next_step()
|
||||
|
||||
def handle_intro_acknowledge(self, request):
|
||||
"""If we are starting a new request, clear storage
|
||||
and redirect to the first step"""
|
||||
if request.path_info == self.NEW_URL_NAME:
|
||||
if self.request.session["new_request"] is True:
|
||||
del self.storage
|
||||
return self.goto(self.steps.first)
|
||||
|
||||
def save(self, forms: list):
|
||||
"""
|
||||
Unpack the form responses onto the model object properties.
|
||||
|
@ -501,24 +582,22 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
|
|||
|
||||
# TODO - this is a WIP until the domain request experience for portfolios is complete
|
||||
class PortfolioDomainRequestWizard(DomainRequestWizard):
|
||||
StepEnum: PortfolioDomainRequestStep = PortfolioDomainRequestStep # type: ignore
|
||||
|
||||
TITLES = {
|
||||
StepEnum.REQUESTING_ENTITY: _("Requesting entity"),
|
||||
StepEnum.CURRENT_SITES: _("Current websites"),
|
||||
StepEnum.DOTGOV_DOMAIN: _(".gov domain"),
|
||||
StepEnum.PURPOSE: _("Purpose of your domain"),
|
||||
StepEnum.ADDITIONAL_DETAILS: _("Additional details"),
|
||||
StepEnum.REQUIREMENTS: _("Requirements for operating a .gov domain"),
|
||||
# Step.REVIEW: _("Review and submit your domain request"),
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.steps = StepsHelper(self)
|
||||
self._domain_request = None # for caching
|
||||
is_portfolio = True
|
||||
|
||||
|
||||
# Portfolio pages
|
||||
class RequestingEntity(DomainRequestWizard):
|
||||
template_name = "domain_request_requesting_entity.html"
|
||||
forms = [forms.RequestingEntityForm]
|
||||
|
||||
|
||||
class PortfolioAdditionalDetails(DomainRequestWizard):
|
||||
template_name = "portfolio_domain_request_additional_details.html"
|
||||
|
||||
forms = [forms.AnythingElseForm]
|
||||
|
||||
|
||||
# Non-portfolio pages
|
||||
class OrganizationType(DomainRequestWizard):
|
||||
template_name = "domain_request_org_type.html"
|
||||
forms = [forms.OrganizationTypeForm]
|
||||
|
@ -698,7 +777,7 @@ class Review(DomainRequestWizard):
|
|||
if DomainRequest._form_complete(self.domain_request, self.request) is False:
|
||||
logger.warning("User arrived at review page with an incomplete form.")
|
||||
context = super().get_context_data()
|
||||
context["Step"] = Step.__members__
|
||||
context["Step"] = self.get_step_enum().__members__
|
||||
context["domain_request"] = self.domain_request
|
||||
return context
|
||||
|
||||
|
@ -899,9 +978,9 @@ class PortfolioDomainRequestStatusViewOnly(DomainRequestPortfolioViewonlyView):
|
|||
# Create a temp wizard object to grab the step list
|
||||
wizard = PortfolioDomainRequestWizard()
|
||||
wizard.request = self.request
|
||||
context["Step"] = wizard.StepEnum.__members__
|
||||
context["steps"] = request_step_list(wizard)
|
||||
context["form_titles"] = wizard.TITLES
|
||||
context["Step"] = PortfolioDomainRequestStep.__members__
|
||||
context["steps"] = request_step_list(wizard, PortfolioDomainRequestStep)
|
||||
context["form_titles"] = wizard.titles
|
||||
return context
|
||||
|
||||
|
||||
|
|
|
@ -1,45 +1,41 @@
|
|||
from django.http import JsonResponse
|
||||
from django.core.paginator import Paginator
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.db.models import Value, F, CharField, TextField, Q, Case, When
|
||||
from django.db.models.functions import Concat, Coalesce
|
||||
from django.urls import reverse
|
||||
from django.db.models.functions import Cast
|
||||
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.user import User
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
|
||||
|
||||
@login_required
|
||||
def get_portfolio_members_json(request):
|
||||
"""Given the current request,
|
||||
get all members that are associated with the given portfolio"""
|
||||
"""Fetch members (permissions and invitations) for the given portfolio."""
|
||||
|
||||
portfolio = request.GET.get("portfolio")
|
||||
member_ids = get_member_ids_from_request(request, portfolio)
|
||||
objects = User.objects.filter(id__in=member_ids)
|
||||
|
||||
admin_ids = UserPortfolioPermission.objects.filter(
|
||||
portfolio=portfolio,
|
||||
roles__overlap=[
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
|
||||
],
|
||||
).values_list("user__id", flat=True)
|
||||
portfolio_invitation_emails = PortfolioInvitation.objects.filter(portfolio=portfolio).values_list(
|
||||
"email", flat=True
|
||||
)
|
||||
# Two initial querysets which will be combined
|
||||
permissions = initial_permissions_search(portfolio)
|
||||
invitations = initial_invitations_search(portfolio)
|
||||
|
||||
unfiltered_total = objects.count()
|
||||
# Get total across both querysets before applying filters
|
||||
unfiltered_total = permissions.count() + invitations.count()
|
||||
|
||||
objects = apply_search(objects, request)
|
||||
# objects = apply_status_filter(objects, request)
|
||||
permissions = apply_search_term(permissions, request)
|
||||
invitations = apply_search_term(invitations, request)
|
||||
|
||||
# Union the two querysets
|
||||
objects = permissions.union(invitations)
|
||||
objects = apply_sorting(objects, request)
|
||||
|
||||
paginator = Paginator(objects, 10)
|
||||
page_number = request.GET.get("page", 1)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
members = [
|
||||
serialize_members(request, portfolio, member, request.user, admin_ids, portfolio_invitation_emails)
|
||||
for member in page_obj.object_list
|
||||
]
|
||||
|
||||
members = [serialize_members(request, portfolio, item, request.user) for item in page_obj.object_list]
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
|
@ -54,71 +50,121 @@ def get_portfolio_members_json(request):
|
|||
)
|
||||
|
||||
|
||||
def get_member_ids_from_request(request, portfolio):
|
||||
"""Given the current request,
|
||||
get all members that are associated with the given portfolio"""
|
||||
member_ids = []
|
||||
if portfolio:
|
||||
member_ids = UserPortfolioPermission.objects.filter(portfolio=portfolio).values_list("user__id", flat=True)
|
||||
return member_ids
|
||||
def initial_permissions_search(portfolio):
|
||||
"""Perform initial search for permissions before applying any filters."""
|
||||
permissions = UserPortfolioPermission.objects.filter(portfolio=portfolio)
|
||||
permissions = (
|
||||
permissions.select_related("user")
|
||||
.annotate(
|
||||
first_name=F("user__first_name"),
|
||||
last_name=F("user__last_name"),
|
||||
email_display=F("user__email"),
|
||||
last_active=Cast(F("user__last_login"), output_field=TextField()), # Cast last_login to text
|
||||
additional_permissions_display=F("additional_permissions"),
|
||||
member_display=Case(
|
||||
# If email is present and not blank, use email
|
||||
When(Q(user__email__isnull=False) & ~Q(user__email=""), then=F("user__email")),
|
||||
# If first name or last name is present, use concatenation of first_name + " " + last_name
|
||||
When(
|
||||
Q(user__first_name__isnull=False) | Q(user__last_name__isnull=False),
|
||||
then=Concat(
|
||||
Coalesce(F("user__first_name"), Value("")),
|
||||
Value(" "),
|
||||
Coalesce(F("user__last_name"), Value("")),
|
||||
),
|
||||
),
|
||||
# If neither, use an empty string
|
||||
default=Value(""),
|
||||
output_field=CharField(),
|
||||
),
|
||||
source=Value("permission", output_field=CharField()),
|
||||
)
|
||||
.values(
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email_display",
|
||||
"last_active",
|
||||
"roles",
|
||||
"additional_permissions_display",
|
||||
"member_display",
|
||||
"source",
|
||||
)
|
||||
)
|
||||
return permissions
|
||||
|
||||
|
||||
def apply_search(queryset, request):
|
||||
search_term = request.GET.get("search_term")
|
||||
def initial_invitations_search(portfolio):
|
||||
"""Perform initial invitations search before applying any filters."""
|
||||
invitations = PortfolioInvitation.objects.filter(portfolio=portfolio)
|
||||
invitations = invitations.annotate(
|
||||
first_name=Value(None, output_field=CharField()),
|
||||
last_name=Value(None, output_field=CharField()),
|
||||
email_display=F("email"),
|
||||
last_active=Value("Invited", output_field=TextField()),
|
||||
additional_permissions_display=F("additional_permissions"),
|
||||
member_display=F("email"),
|
||||
source=Value("invitation", output_field=CharField()),
|
||||
).values(
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email_display",
|
||||
"last_active",
|
||||
"roles",
|
||||
"additional_permissions_display",
|
||||
"member_display",
|
||||
"source",
|
||||
)
|
||||
return invitations
|
||||
|
||||
|
||||
def apply_search_term(queryset, request):
|
||||
"""Apply search term to the queryset."""
|
||||
search_term = request.GET.get("search_term", "").lower()
|
||||
if search_term:
|
||||
queryset = queryset.filter(
|
||||
Q(username__icontains=search_term)
|
||||
| Q(first_name__icontains=search_term)
|
||||
Q(first_name__icontains=search_term)
|
||||
| Q(last_name__icontains=search_term)
|
||||
| Q(email__icontains=search_term)
|
||||
| Q(email_display__icontains=search_term)
|
||||
)
|
||||
return queryset
|
||||
|
||||
|
||||
def apply_sorting(queryset, request):
|
||||
"""Apply sorting to the queryset."""
|
||||
sort_by = request.GET.get("sort_by", "id") # Default to 'id'
|
||||
order = request.GET.get("order", "asc") # Default to 'asc'
|
||||
|
||||
# Adjust sort_by to match the annotated fields in the unioned queryset
|
||||
if sort_by == "member":
|
||||
sort_by = ["email", "first_name", "middle_name", "last_name"]
|
||||
else:
|
||||
sort_by = [sort_by]
|
||||
|
||||
sort_by = "member_display"
|
||||
if order == "desc":
|
||||
sort_by = [f"-{field}" for field in sort_by]
|
||||
|
||||
return queryset.order_by(*sort_by)
|
||||
queryset = queryset.order_by(F(sort_by).desc())
|
||||
else:
|
||||
queryset = queryset.order_by(sort_by)
|
||||
return queryset
|
||||
|
||||
|
||||
def serialize_members(request, portfolio, member, user, admin_ids, portfolio_invitation_emails):
|
||||
# ------- VIEW ONLY
|
||||
# If not view_only (the user has permissions to edit/manage users), show the gear icon with "Manage" link.
|
||||
# If view_only (the user only has view user permissions), show the "View" link (no gear icon).
|
||||
# We check on user_group_permision to account for the upcoming "Manage portfolio" button on admin.
|
||||
user_can_edit_other_users = False
|
||||
for user_group_permission in ["registrar.full_access_permission", "registrar.change_user"]:
|
||||
if user.has_perm(user_group_permission):
|
||||
user_can_edit_other_users = True
|
||||
break
|
||||
def serialize_members(request, portfolio, item, user):
|
||||
# Check if the user can edit other users
|
||||
user_can_edit_other_users = any(
|
||||
user.has_perm(perm) for perm in ["registrar.full_access_permission", "registrar.change_user"]
|
||||
)
|
||||
|
||||
view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users
|
||||
|
||||
# ------- USER STATUSES
|
||||
is_invited = member.email in portfolio_invitation_emails
|
||||
last_active = "Invited" if is_invited else "Unknown"
|
||||
if member.last_login:
|
||||
last_active = member.last_login.strftime("%b. %d, %Y")
|
||||
is_admin = member.id in admin_ids
|
||||
is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or [])
|
||||
action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]})
|
||||
|
||||
# ------- SERIALIZE
|
||||
# Serialize member data
|
||||
member_json = {
|
||||
"id": member.id,
|
||||
"name": member.get_formatted_name(),
|
||||
"email": member.email,
|
||||
"id": item.get("id", ""),
|
||||
"name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])),
|
||||
"email": item.get("email_display", ""),
|
||||
"member_display": item.get("member_display", ""),
|
||||
"is_admin": is_admin,
|
||||
"last_active": last_active,
|
||||
"action_url": "#", # reverse("members", kwargs={"pk": member.id}), # TODO: Future ticket?
|
||||
"last_active": item.get("last_active", ""),
|
||||
"action_url": action_url,
|
||||
"action_label": ("View" if view_only else "Manage"),
|
||||
"svg_icon": ("visibility" if view_only else "settings"),
|
||||
}
|
||||
|
|
|
@ -3,20 +3,30 @@ from django.http import Http404
|
|||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.contrib import messages
|
||||
from registrar.forms.portfolio import PortfolioOrgAddressForm, PortfolioSeniorOfficialForm
|
||||
from registrar.forms.portfolio import (
|
||||
PortfolioInvitedMemberForm,
|
||||
PortfolioMemberForm,
|
||||
PortfolioOrgAddressForm,
|
||||
PortfolioSeniorOfficialForm,
|
||||
)
|
||||
from registrar.models import Portfolio, User
|
||||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from registrar.views.utility.permission_views import (
|
||||
PortfolioDomainRequestsPermissionView,
|
||||
PortfolioDomainsPermissionView,
|
||||
PortfolioBasePermissionView,
|
||||
NoPortfolioDomainsPermissionView,
|
||||
PortfolioInvitedMemberEditPermissionView,
|
||||
PortfolioInvitedMemberPermissionView,
|
||||
PortfolioMemberEditPermissionView,
|
||||
PortfolioMemberPermissionView,
|
||||
PortfolioMembersPermissionView,
|
||||
)
|
||||
from django.views.generic import View
|
||||
from django.views.generic.edit import FormMixin
|
||||
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -51,6 +61,155 @@ class PortfolioMembersView(PortfolioMembersPermissionView, View):
|
|||
return render(request, "portfolio_members.html")
|
||||
|
||||
|
||||
class PortfolioMemberView(PortfolioMemberPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member.html"
|
||||
|
||||
def get(self, request, pk):
|
||||
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
|
||||
member = portfolio_permission.user
|
||||
|
||||
# We have to explicitely name these with member_ otherwise we'll have conflicts with context preprocessors
|
||||
member_has_view_all_requests_portfolio_permission = member.has_view_all_requests_portfolio_permission(
|
||||
portfolio_permission.portfolio
|
||||
)
|
||||
member_has_edit_request_portfolio_permission = member.has_edit_request_portfolio_permission(
|
||||
portfolio_permission.portfolio
|
||||
)
|
||||
member_has_view_members_portfolio_permission = member.has_view_members_portfolio_permission(
|
||||
portfolio_permission.portfolio
|
||||
)
|
||||
member_has_edit_members_portfolio_permission = member.has_edit_members_portfolio_permission(
|
||||
portfolio_permission.portfolio
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"edit_url": reverse("member-permissions", args=[pk]),
|
||||
"portfolio_permission": portfolio_permission,
|
||||
"member": member,
|
||||
"member_has_view_all_requests_portfolio_permission": member_has_view_all_requests_portfolio_permission,
|
||||
"member_has_edit_request_portfolio_permission": member_has_edit_request_portfolio_permission,
|
||||
"member_has_view_members_portfolio_permission": member_has_view_members_portfolio_permission,
|
||||
"member_has_edit_members_portfolio_permission": member_has_edit_members_portfolio_permission,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member_permissions.html"
|
||||
form_class = PortfolioMemberForm
|
||||
|
||||
def get(self, request, pk):
|
||||
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
|
||||
user = portfolio_permission.user
|
||||
|
||||
form = self.form_class(instance=portfolio_permission)
|
||||
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"form": form,
|
||||
"member": user,
|
||||
},
|
||||
)
|
||||
|
||||
def post(self, request, pk):
|
||||
portfolio_permission = get_object_or_404(UserPortfolioPermission, pk=pk)
|
||||
user = portfolio_permission.user
|
||||
|
||||
form = self.form_class(request.POST, instance=portfolio_permission)
|
||||
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("member", pk=pk)
|
||||
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"form": form,
|
||||
"member": user, # Pass the user object again to the template
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class PortfolioInvitedMemberView(PortfolioInvitedMemberPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member.html"
|
||||
# form_class = PortfolioInvitedMemberForm
|
||||
|
||||
def get(self, request, pk):
|
||||
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
||||
# form = self.form_class(instance=portfolio_invitation)
|
||||
|
||||
# We have to explicitely name these with member_ otherwise we'll have conflicts with context preprocessors
|
||||
member_has_view_all_requests_portfolio_permission = (
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in portfolio_invitation.get_portfolio_permissions()
|
||||
)
|
||||
member_has_edit_request_portfolio_permission = (
|
||||
UserPortfolioPermissionChoices.EDIT_REQUESTS in portfolio_invitation.get_portfolio_permissions()
|
||||
)
|
||||
member_has_view_members_portfolio_permission = (
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS in portfolio_invitation.get_portfolio_permissions()
|
||||
)
|
||||
member_has_edit_members_portfolio_permission = (
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS in portfolio_invitation.get_portfolio_permissions()
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"edit_url": reverse("invitedmember-permissions", args=[pk]),
|
||||
"portfolio_invitation": portfolio_invitation,
|
||||
"member_has_view_all_requests_portfolio_permission": member_has_view_all_requests_portfolio_permission,
|
||||
"member_has_edit_request_portfolio_permission": member_has_edit_request_portfolio_permission,
|
||||
"member_has_view_members_portfolio_permission": member_has_view_members_portfolio_permission,
|
||||
"member_has_edit_members_portfolio_permission": member_has_edit_members_portfolio_permission,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class PortfolioInvitedMemberEditView(PortfolioInvitedMemberEditPermissionView, View):
|
||||
|
||||
template_name = "portfolio_member_permissions.html"
|
||||
form_class = PortfolioInvitedMemberForm
|
||||
|
||||
def get(self, request, pk):
|
||||
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
||||
form = self.form_class(instance=portfolio_invitation)
|
||||
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"form": form,
|
||||
"invitation": portfolio_invitation,
|
||||
},
|
||||
)
|
||||
|
||||
def post(self, request, pk):
|
||||
portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk)
|
||||
form = self.form_class(request.POST, instance=portfolio_invitation)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("invitedmember", pk=pk)
|
||||
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"form": form,
|
||||
"invitation": portfolio_invitation, # Pass the user object again to the template
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View):
|
||||
"""Some users have access to the underlying portfolio, but not any domains.
|
||||
This is a custom view which explains that to the user - and denotes who to contact.
|
||||
|
|
|
@ -4,7 +4,7 @@ from django.forms.models import model_to_dict
|
|||
from registrar.models import FederalAgency, SeniorOfficial, DomainRequest
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from registrar.utility.admin_helpers import get_all_action_needed_reason_emails
|
||||
from registrar.utility.admin_helpers import get_action_needed_reason_default_email, get_rejection_reason_default_email
|
||||
from registrar.models.portfolio import Portfolio
|
||||
from registrar.utility.constants import BranchChoices
|
||||
|
||||
|
@ -88,5 +88,30 @@ def get_action_needed_email_for_user_json(request):
|
|||
return JsonResponse({"error": "No domain_request_id specified"}, status=404)
|
||||
|
||||
domain_request = DomainRequest.objects.filter(id=domain_request_id).first()
|
||||
emails = get_all_action_needed_reason_emails(request, domain_request)
|
||||
return JsonResponse({"action_needed_email": emails.get(reason)}, status=200)
|
||||
|
||||
email = get_action_needed_reason_default_email(domain_request, reason)
|
||||
return JsonResponse({"email": email}, status=200)
|
||||
|
||||
|
||||
@login_required
|
||||
@staff_member_required
|
||||
def get_rejection_email_for_user_json(request):
|
||||
"""Returns a default rejection email for a given user"""
|
||||
|
||||
# This API is only accessible to admins and analysts
|
||||
superuser_perm = request.user.has_perm("registrar.full_access_permission")
|
||||
analyst_perm = request.user.has_perm("registrar.analyst_access_permission")
|
||||
if not request.user.is_authenticated or not any([analyst_perm, superuser_perm]):
|
||||
return JsonResponse({"error": "You do not have access to this resource"}, status=403)
|
||||
|
||||
reason = request.GET.get("reason")
|
||||
domain_request_id = request.GET.get("domain_request_id")
|
||||
if not reason:
|
||||
return JsonResponse({"error": "No reason specified"}, status=404)
|
||||
|
||||
if not domain_request_id:
|
||||
return JsonResponse({"error": "No domain_request_id specified"}, status=404)
|
||||
|
||||
domain_request = DomainRequest.objects.filter(id=domain_request_id).first()
|
||||
email = get_rejection_reason_default_email(domain_request, reason)
|
||||
return JsonResponse({"email": email}, status=200)
|
||||
|
|
|
@ -384,10 +384,32 @@ class DomainRequestWizardPermission(PermissionsLoginMixin):
|
|||
The user is in self.request.user
|
||||
"""
|
||||
|
||||
if not self.request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
# The user has an ineligible flag
|
||||
if self.request.user.is_restricted():
|
||||
return False
|
||||
|
||||
# If the user is an org user and doesn't have add/edit perms, forbid this
|
||||
if self.request.user.is_org_user(self.request):
|
||||
portfolio = self.request.session.get("portfolio")
|
||||
if not self.request.user.has_edit_request_portfolio_permission(portfolio):
|
||||
return False
|
||||
|
||||
# user needs to be the creator of the domain request to edit it.
|
||||
id = self.kwargs.get("id") if hasattr(self, "kwargs") else None
|
||||
if not id:
|
||||
domain_request_wizard = self.request.session.get("wizard_domain_request")
|
||||
if domain_request_wizard:
|
||||
id = domain_request_wizard.get("domain_request_id")
|
||||
|
||||
# If no id is provided, we can assume that the user is starting a new request.
|
||||
# If one IS provided, check that they are the original creator of it.
|
||||
if id:
|
||||
if not DomainRequest.objects.filter(creator=self.request.user, id=id).exists():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
@ -490,7 +512,81 @@ class PortfolioMembersPermission(PortfolioBasePermission):
|
|||
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||
|
||||
portfolio = self.request.session.get("portfolio")
|
||||
if not self.request.user.has_view_members_portfolio_permission(portfolio):
|
||||
if not self.request.user.has_view_members_portfolio_permission(
|
||||
portfolio
|
||||
) and not self.request.user.has_edit_members_portfolio_permission(portfolio):
|
||||
return False
|
||||
|
||||
return super().has_permission()
|
||||
|
||||
|
||||
class PortfolioMemberPermission(PortfolioBasePermission):
|
||||
"""Permission mixin that allows access to portfolio member pages if user
|
||||
has access, otherwise 403"""
|
||||
|
||||
def has_permission(self):
|
||||
"""Check if this user has access to members for this portfolio.
|
||||
|
||||
The user is in self.request.user and the portfolio can be looked
|
||||
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||
|
||||
portfolio = self.request.session.get("portfolio")
|
||||
if not self.request.user.has_view_members_portfolio_permission(
|
||||
portfolio
|
||||
) and not self.request.user.has_edit_members_portfolio_permission(portfolio):
|
||||
return False
|
||||
|
||||
return super().has_permission()
|
||||
|
||||
|
||||
class PortfolioMemberEditPermission(PortfolioBasePermission):
|
||||
"""Permission mixin that allows access to portfolio member pages if user
|
||||
has access to edit, otherwise 403"""
|
||||
|
||||
def has_permission(self):
|
||||
"""Check if this user has access to members for this portfolio.
|
||||
|
||||
The user is in self.request.user and the portfolio can be looked
|
||||
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||
|
||||
portfolio = self.request.session.get("portfolio")
|
||||
if not self.request.user.has_edit_members_portfolio_permission(portfolio):
|
||||
return False
|
||||
|
||||
return super().has_permission()
|
||||
|
||||
|
||||
class PortfolioInvitedMemberPermission(PortfolioBasePermission):
|
||||
"""Permission mixin that allows access to portfolio invited member pages if user
|
||||
has access, otherwise 403"""
|
||||
|
||||
def has_permission(self):
|
||||
"""Check if this user has access to members for this portfolio.
|
||||
|
||||
The user is in self.request.user and the portfolio can be looked
|
||||
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||
|
||||
portfolio = self.request.session.get("portfolio")
|
||||
if not self.request.user.has_view_members_portfolio_permission(
|
||||
portfolio
|
||||
) and not self.request.user.has_edit_members_portfolio_permission(portfolio):
|
||||
return False
|
||||
|
||||
return super().has_permission()
|
||||
|
||||
|
||||
class PortfolioInvitedMemberEditPermission(PortfolioBasePermission):
|
||||
"""Permission mixin that allows access to portfolio invited member pages if user
|
||||
has access to edit, otherwise 403"""
|
||||
|
||||
def has_permission(self):
|
||||
"""Check if this user has access to members for this portfolio.
|
||||
|
||||
The user is in self.request.user and the portfolio can be looked
|
||||
up from the portfolio's primary key in self.kwargs["pk"]"""
|
||||
|
||||
portfolio = self.request.session.get("portfolio")
|
||||
if not self.request.user.has_edit_members_portfolio_permission(portfolio):
|
||||
return False
|
||||
|
||||
return super().has_permission()
|
||||
|
|
|
@ -15,10 +15,14 @@ from .mixins import (
|
|||
DomainRequestWizardPermission,
|
||||
PortfolioDomainRequestsPermission,
|
||||
PortfolioDomainsPermission,
|
||||
PortfolioInvitedMemberEditPermission,
|
||||
PortfolioInvitedMemberPermission,
|
||||
PortfolioMemberEditPermission,
|
||||
UserDeleteDomainRolePermission,
|
||||
UserProfilePermission,
|
||||
PortfolioBasePermission,
|
||||
PortfolioMembersPermission,
|
||||
PortfolioMemberPermission,
|
||||
DomainRequestPortfolioViewonlyPermission,
|
||||
)
|
||||
import logging
|
||||
|
@ -253,7 +257,41 @@ class PortfolioDomainRequestsPermissionView(PortfolioDomainRequestsPermission, P
|
|||
|
||||
|
||||
class PortfolioMembersPermissionView(PortfolioMembersPermission, PortfolioBasePermissionView, abc.ABC):
|
||||
"""Abstract base view for portfolio domain request views that enforces permissions.
|
||||
"""Abstract base view for portfolio members views that enforces permissions.
|
||||
|
||||
This abstract view cannot be instantiated. Actual views must specify
|
||||
`template_name`.
|
||||
"""
|
||||
|
||||
|
||||
class PortfolioMemberPermissionView(PortfolioMemberPermission, PortfolioBasePermissionView, abc.ABC):
|
||||
"""Abstract base view for portfolio member views that enforces permissions.
|
||||
|
||||
This abstract view cannot be instantiated. Actual views must specify
|
||||
`template_name`.
|
||||
"""
|
||||
|
||||
|
||||
class PortfolioMemberEditPermissionView(PortfolioMemberEditPermission, PortfolioBasePermissionView, abc.ABC):
|
||||
"""Abstract base view for portfolio member edit views that enforces permissions.
|
||||
|
||||
This abstract view cannot be instantiated. Actual views must specify
|
||||
`template_name`.
|
||||
"""
|
||||
|
||||
|
||||
class PortfolioInvitedMemberPermissionView(PortfolioInvitedMemberPermission, PortfolioBasePermissionView, abc.ABC):
|
||||
"""Abstract base view for portfolio member views that enforces permissions.
|
||||
|
||||
This abstract view cannot be instantiated. Actual views must specify
|
||||
`template_name`.
|
||||
"""
|
||||
|
||||
|
||||
class PortfolioInvitedMemberEditPermissionView(
|
||||
PortfolioInvitedMemberEditPermission, PortfolioBasePermissionView, abc.ABC
|
||||
):
|
||||
"""Abstract base view for portfolio member edit views that enforces permissions.
|
||||
|
||||
This abstract view cannot be instantiated. Actual views must specify
|
||||
`template_name`.
|
||||
|
|
|
@ -71,6 +71,7 @@
|
|||
10038 OUTOFSCOPE http://app:8080/domain_requests/
|
||||
10038 OUTOFSCOPE http://app:8080/domains/
|
||||
10038 OUTOFSCOPE http://app:8080/organization/
|
||||
10038 OUTOFSCOPE http://app:8080/permissions
|
||||
10038 OUTOFSCOPE http://app:8080/suborganization/
|
||||
10038 OUTOFSCOPE http://app:8080/transfer/
|
||||
# This URL always returns 404, so include it as well.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue