mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-20 09:46:06 +02:00
Merge branch 'main' into za/2671-show-portfolios-on-user-table
This commit is contained in:
commit
765a706f4d
65 changed files with 1609 additions and 264 deletions
|
@ -63,3 +63,4 @@ The class also provides helper methods:
|
|||
- `get_class_name`: Returns a display-friendly class name for the terminal prompt
|
||||
- `get_failure_message`: Returns the message to display if a record fails to update
|
||||
- `should_skip_record`: Defines the condition for skipping a record (by default, no records are skipped)
|
||||
- `custom_filter`: Allows for additional filters that cannot be expressed using django queryset field lookups
|
|
@ -817,6 +817,28 @@ Example: `cf ssh getgov-za`
|
|||
|:-:|:-------------------------- |:-----------------------------------------------------------------------------------|
|
||||
| 1 | **federal_cio_csv_path** | Specifies where the federal CIO csv is |
|
||||
|
||||
## Update First Ready Values
|
||||
This section outlines how to run the populate_first_ready script
|
||||
|
||||
### Running on sandboxes
|
||||
|
||||
#### Step 1: Login to CloudFoundry
|
||||
```cf login -a api.fr.cloud.gov --sso```
|
||||
|
||||
#### Step 2: SSH into your environment
|
||||
```cf ssh getgov-{space}```
|
||||
|
||||
Example: `cf ssh getgov-za`
|
||||
|
||||
#### Step 3: Create a shell instance
|
||||
```/tmp/lifecycle/shell```
|
||||
|
||||
#### Step 4: Running the script
|
||||
```./manage.py update_first_ready```
|
||||
|
||||
### Running locally
|
||||
```docker-compose exec app ./manage.py update_first_ready```
|
||||
|
||||
## Populate Domain Request Dates
|
||||
This section outlines how to run the populate_domain_request_dates script
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ from django.conf import settings
|
|||
from django.shortcuts import redirect
|
||||
from django_fsm import get_available_FIELD_transitions, FSMField
|
||||
from registrar.models.domain_information import DomainInformation
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from waffle.decorators import flag_is_active
|
||||
from django.contrib import admin, messages
|
||||
|
@ -2970,11 +2971,7 @@ class PortfolioAdmin(ListHeaderAdmin):
|
|||
fieldsets = [
|
||||
# created_on is the created_at field, and portfolio_type is f"{organization_type} - {federal_type}"
|
||||
(None, {"fields": ["portfolio_type", "organization_name", "creator", "created_on", "notes"]}),
|
||||
# TODO - uncomment in #2521
|
||||
# ("Portfolio members", {
|
||||
# "classes": ("collapse", "closed"),
|
||||
# "fields": ["administrators", "members"]}
|
||||
# ),
|
||||
("Portfolio members", {"fields": ["display_admins", "display_members"]}),
|
||||
("Portfolio domains", {"fields": ["domains", "domain_requests"]}),
|
||||
("Type of organization", {"fields": ["organization_type", "federal_type"]}),
|
||||
(
|
||||
|
@ -3022,15 +3019,118 @@ class PortfolioAdmin(ListHeaderAdmin):
|
|||
readonly_fields = [
|
||||
# This is the created_at field
|
||||
"created_on",
|
||||
# Custom fields such as these must be defined as readonly.
|
||||
# Django admin doesn't allow methods to be directly listed in fieldsets. We can
|
||||
# display the custom methods display_admins amd display_members in the admin form if
|
||||
# they are readonly.
|
||||
"federal_type",
|
||||
"domains",
|
||||
"domain_requests",
|
||||
"suborganizations",
|
||||
"portfolio_type",
|
||||
"display_admins",
|
||||
"display_members",
|
||||
"creator",
|
||||
]
|
||||
|
||||
def get_admin_users(self, obj):
|
||||
# Filter UserPortfolioPermission objects related to the portfolio
|
||||
admin_permissions = UserPortfolioPermission.objects.filter(
|
||||
portfolio=obj, roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
# Get the user objects associated with these permissions
|
||||
admin_users = User.objects.filter(portfolio_permissions__in=admin_permissions)
|
||||
|
||||
return admin_users
|
||||
|
||||
def get_non_admin_users(self, obj):
|
||||
# Filter UserPortfolioPermission objects related to the portfolio that do NOT have the "Admin" role
|
||||
non_admin_permissions = UserPortfolioPermission.objects.filter(portfolio=obj).exclude(
|
||||
roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
# Get the user objects associated with these permissions
|
||||
non_admin_users = User.objects.filter(portfolio_permissions__in=non_admin_permissions)
|
||||
|
||||
return non_admin_users
|
||||
|
||||
def display_admins(self, obj):
|
||||
"""Get joined users who are Admin, unpack and return an HTML block.
|
||||
|
||||
'DJA readonly can't handle querysets, so we need to unpack and return html here.
|
||||
Alternatively, we could return querysets in context but that would limit where this
|
||||
data would display in a custom change form without extensive template customization.
|
||||
|
||||
Will be used in the field_readonly block"""
|
||||
admins = self.get_admin_users(obj)
|
||||
if not admins:
|
||||
return format_html("<p>No admins found.</p>")
|
||||
|
||||
admin_details = ""
|
||||
for portfolio_admin in admins:
|
||||
change_url = reverse("admin:registrar_user_change", args=[portfolio_admin.pk])
|
||||
admin_details += "<address class='margin-bottom-2 dja-address-contact-list'>"
|
||||
admin_details += f'<a href="{change_url}">{escape(portfolio_admin)}</a><br>'
|
||||
admin_details += f"{escape(portfolio_admin.title)}<br>"
|
||||
admin_details += f"{escape(portfolio_admin.email)}"
|
||||
admin_details += "<div class='admin-icon-group admin-icon-group__clipboard-link'>"
|
||||
admin_details += f"<input aria-hidden='true' class='display-none' value='{escape(portfolio_admin.email)}'>"
|
||||
admin_details += (
|
||||
"<button class='usa-button usa-button--unstyled padding-right-1 usa-button--icon padding-left-05"
|
||||
+ "button--clipboard copy-to-clipboard text-no-underline' type='button'>"
|
||||
)
|
||||
admin_details += "<svg class='usa-icon'>"
|
||||
admin_details += "<use aria-hidden='true' xlink:href='/public/img/sprite.svg#content_copy'></use>"
|
||||
admin_details += "</svg>"
|
||||
admin_details += "Copy"
|
||||
admin_details += "</button>"
|
||||
admin_details += "</div><br>"
|
||||
admin_details += f"{escape(portfolio_admin.phone)}"
|
||||
admin_details += "</address>"
|
||||
return format_html(admin_details)
|
||||
|
||||
display_admins.short_description = "Administrators" # type: ignore
|
||||
|
||||
def display_members(self, obj):
|
||||
"""Get joined users who have roles/perms that are not Admin, unpack and return an HTML block.
|
||||
|
||||
DJA readonly can't handle querysets, so we need to unpack and return html here.
|
||||
Alternatively, we could return querysets in context but that would limit where this
|
||||
data would display in a custom change form without extensive template customization.
|
||||
|
||||
Will be used in the after_help_text block."""
|
||||
members = self.get_non_admin_users(obj)
|
||||
if not members:
|
||||
return ""
|
||||
|
||||
member_details = (
|
||||
"<table><thead><tr><th>Name</th><th>Title</th><th>Email</th>"
|
||||
+ "<th>Phone</th><th>Roles</th></tr></thead><tbody>"
|
||||
)
|
||||
for member in members:
|
||||
full_name = member.get_formatted_name()
|
||||
member_details += "<tr>"
|
||||
member_details += f"<td>{escape(full_name)}</td>"
|
||||
member_details += f"<td>{escape(member.title)}</td>"
|
||||
member_details += f"<td>{escape(member.email)}</td>"
|
||||
member_details += f"<td>{escape(member.phone)}</td>"
|
||||
member_details += "<td>"
|
||||
for role in member.portfolio_role_summary(obj):
|
||||
member_details += f"<span class='usa-tag'>{escape(role)}</span> "
|
||||
member_details += "</td></tr>"
|
||||
member_details += "</tbody></table>"
|
||||
return format_html(member_details)
|
||||
|
||||
display_members.short_description = "Members" # type: ignore
|
||||
|
||||
def display_members_summary(self, obj):
|
||||
"""Will be passed as context and used in the field_readonly block."""
|
||||
members = self.get_non_admin_users(obj)
|
||||
if not members:
|
||||
return {}
|
||||
|
||||
return self.get_field_links_as_list(members, "user", separator=", ")
|
||||
|
||||
def federal_type(self, obj: models.Portfolio):
|
||||
"""Returns the federal_type field"""
|
||||
return BranchChoices.get_branch_label(obj.federal_type) if obj.federal_type else "-"
|
||||
|
@ -3090,7 +3190,7 @@ class PortfolioAdmin(ListHeaderAdmin):
|
|||
]
|
||||
|
||||
def get_field_links_as_list(
|
||||
self, queryset, model_name, attribute_name=None, link_info_attribute=None, seperator=None
|
||||
self, queryset, model_name, attribute_name=None, link_info_attribute=None, separator=None
|
||||
):
|
||||
"""
|
||||
Generate HTML links for items in a queryset, using a specified attribute for link text.
|
||||
|
@ -3122,14 +3222,14 @@ class PortfolioAdmin(ListHeaderAdmin):
|
|||
if link_info_attribute:
|
||||
link += f" ({self.value_of_attribute(item, link_info_attribute)})"
|
||||
|
||||
if seperator:
|
||||
if separator:
|
||||
links.append(link)
|
||||
else:
|
||||
links.append(f"<li>{link}</li>")
|
||||
|
||||
# If no seperator is specified, just return an unordered list.
|
||||
if seperator:
|
||||
return format_html(seperator.join(links)) if links else "-"
|
||||
# If no separator is specified, just return an unordered list.
|
||||
if separator:
|
||||
return format_html(separator.join(links)) if links else "-"
|
||||
else:
|
||||
links = "".join(links)
|
||||
return format_html(f'<ul class="add-list-reset">{links}</ul>') if links else "-"
|
||||
|
@ -3172,8 +3272,12 @@ class PortfolioAdmin(ListHeaderAdmin):
|
|||
return readonly_fields
|
||||
|
||||
def change_view(self, request, object_id, form_url="", extra_context=None):
|
||||
"""Add related suborganizations and domain groups"""
|
||||
extra_context = {"skip_additional_contact_info": True}
|
||||
"""Add related suborganizations and domain groups.
|
||||
Add the summary for the portfolio members field (list of members that link to change_forms)."""
|
||||
obj = self.get_object(request, object_id)
|
||||
extra_context = extra_context or {}
|
||||
extra_context["skip_additional_contact_info"] = True
|
||||
extra_context["display_members_summary"] = self.display_members_summary(obj)
|
||||
return super().change_view(request, object_id, form_url, extra_context)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
|
|
14
src/registrar/assets/js/get-gov-admin-extra.js
Normal file
14
src/registrar/assets/js/get-gov-admin-extra.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
// Use Django's jQuery with Select2 to make the user select on the user transfer view a combobox
|
||||
(function($) {
|
||||
$(document).ready(function() {
|
||||
if ($) {
|
||||
$("#selected_user").select2({
|
||||
width: 'resolve',
|
||||
placeholder: 'Select a user',
|
||||
allowClear: true
|
||||
});
|
||||
} else {
|
||||
console.error('jQuery is not available');
|
||||
}
|
||||
});
|
||||
})(window.jQuery);
|
|
@ -172,6 +172,7 @@ function addOrRemoveSessionBoolean(name, add){
|
|||
** To perform data operations on this - we need to use jQuery rather than vanilla js.
|
||||
*/
|
||||
(function (){
|
||||
if (document.getElementById("id_investigator") && django && django.jQuery) {
|
||||
let selector = django.jQuery("#id_investigator")
|
||||
let assignSelfButton = document.querySelector("#investigator__assign_self");
|
||||
if (!selector || !assignSelfButton) {
|
||||
|
@ -203,9 +204,7 @@ function addOrRemoveSessionBoolean(name, add){
|
|||
// The parent container has display type flex.
|
||||
assignSelfButton.parentElement.style.display = this.value === currentUserId ? "none" : "flex";
|
||||
});
|
||||
|
||||
|
||||
|
||||
}
|
||||
})();
|
||||
|
||||
/** An IIFE for pages in DjangoAdmin that use a clipboard button
|
||||
|
@ -215,7 +214,6 @@ function addOrRemoveSessionBoolean(name, add){
|
|||
function copyToClipboardAndChangeIcon(button) {
|
||||
// Assuming the input is the previous sibling of the button
|
||||
let input = button.previousElementSibling;
|
||||
let userId = input.getAttribute("user-id")
|
||||
// Copy input value to clipboard
|
||||
if (input) {
|
||||
navigator.clipboard.writeText(input.value).then(function() {
|
||||
|
|
|
@ -1220,7 +1220,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : '';
|
||||
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
|
||||
const actionUrl = domain.action_url;
|
||||
const suborganization = domain.suborganization ? domain.suborganization : '';
|
||||
const suborganization = domain.domain_info__sub_organization ? domain.domain_info__sub_organization : '⎯';
|
||||
|
||||
const row = document.createElement('tr');
|
||||
|
||||
|
@ -1229,7 +1229,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
if (!noPortfolioFlag) {
|
||||
markupForSuborganizationRow = `
|
||||
<td>
|
||||
<span class="${suborganization ? 'ellipsis ellipsis--30 vertical-align-middle' : ''}" aria-label="${suborganization}" title="${suborganization}">${suborganization}</span>
|
||||
<span class="text-wrap" aria-label="${domain.suborganization ? suborganization : 'No suborganization'}">${suborganization}</span>
|
||||
</td>
|
||||
`
|
||||
}
|
||||
|
@ -1910,7 +1910,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
|
||||
let editableFormGroup = button.parentElement.parentElement.parentElement;
|
||||
if (editableFormGroup){
|
||||
let readonlyField = editableFormGroup.querySelector(".input-with-edit-button__readonly-field")
|
||||
let readonlyField = editableFormGroup.querySelector(".toggleable_input__readonly-field")
|
||||
let inputField = document.getElementById(`id_${fieldName}`);
|
||||
if (!inputField || !readonlyField) {
|
||||
return;
|
||||
|
@ -1936,8 +1936,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
// Keep the path before '#' and replace the part after '#' with 'invalid'
|
||||
const newHref = parts[0] + '#error';
|
||||
svg.setAttribute('xlink:href', newHref);
|
||||
fullNameField.classList.add("input-with-edit-button__error")
|
||||
label = fullNameField.querySelector(".input-with-edit-button__readonly-field")
|
||||
fullNameField.classList.add("toggleable_input__error")
|
||||
label = fullNameField.querySelector(".toggleable_input__readonly-field")
|
||||
label.innerHTML = "Unknown";
|
||||
}
|
||||
}
|
||||
|
@ -2043,7 +2043,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
// Due to the nature of how uswds works, this is slightly hacky.
|
||||
|
||||
// Use a MutationObserver to watch for changes in the dropdown list
|
||||
const dropdownList = document.querySelector(`#${input.id}--list`);
|
||||
const dropdownList = comboBox.querySelector(`#${input.id}--list`);
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
if (mutation.type === "childList") {
|
||||
|
@ -2111,7 +2111,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
if (!initialValue){
|
||||
blankOption.classList.add("usa-combo-box__list-option--selected")
|
||||
}
|
||||
blankOption.textContent = "---------";
|
||||
blankOption.textContent = "⎯";
|
||||
|
||||
dropdownList.insertBefore(blankOption, dropdownList.firstChild);
|
||||
blankOption.addEventListener("click", (e) => {
|
||||
|
|
|
@ -126,7 +126,7 @@ html[data-theme="light"] {
|
|||
body.dashboard,
|
||||
body.change-list,
|
||||
body.change-form,
|
||||
.analytics {
|
||||
.custom-admin-template, dt {
|
||||
color: var(--body-fg);
|
||||
}
|
||||
.usa-table td {
|
||||
|
@ -155,7 +155,7 @@ html[data-theme="dark"] {
|
|||
body.dashboard,
|
||||
body.change-list,
|
||||
body.change-form,
|
||||
.analytics {
|
||||
.custom-admin-template, dt {
|
||||
color: var(--body-fg);
|
||||
}
|
||||
.usa-table td {
|
||||
|
@ -370,14 +370,60 @@ input.admin-confirm-button {
|
|||
list-style-type: none;
|
||||
line-height: normal;
|
||||
}
|
||||
.button {
|
||||
}
|
||||
|
||||
// This block resolves some of the issues we're seeing on buttons due to css
|
||||
// conflicts between DJ and USWDS
|
||||
a.button,
|
||||
.usa-button--dja {
|
||||
display: inline-block;
|
||||
padding: 10px 8px;
|
||||
line-height: normal;
|
||||
}
|
||||
a.button:active, a.button:focus {
|
||||
padding: 10px 15px;
|
||||
font-size: 14px;
|
||||
line-height: 16.1px;
|
||||
font-kerning: auto;
|
||||
font-family: inherit;
|
||||
font-weight: normal;
|
||||
}
|
||||
.button svg,
|
||||
.button span,
|
||||
.usa-button--dja svg,
|
||||
.usa-button--dja span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary) {
|
||||
background: var(--button-bg);
|
||||
}
|
||||
.usa-button--dja span {
|
||||
font-size: 14px;
|
||||
}
|
||||
.usa-button--dja:not(.usa-button--unstyled, .usa-button--outline, .usa-modal__close, .usa-button--secondary):hover {
|
||||
background: var(--button-hover-bg);
|
||||
}
|
||||
a.button:active, a.button:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
.usa-modal {
|
||||
font-family: inherit;
|
||||
}
|
||||
input[type=submit].button--dja-toolbar {
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 0.8125rem;
|
||||
padding: 4px 8px;
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
background: var(--body-bg);
|
||||
box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset;
|
||||
cursor: pointer;
|
||||
color: var(--body-fg);
|
||||
}
|
||||
input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-toolbar:hover {
|
||||
border-color: var(--body-quiet-color);
|
||||
}
|
||||
// Targets the DJA buttom with a nested icon
|
||||
button .usa-icon,
|
||||
.button .usa-icon,
|
||||
.button--clipboard .usa-icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.module--custom {
|
||||
|
@ -471,13 +517,6 @@ address.dja-address-contact-list {
|
|||
color: var(--link-fg);
|
||||
}
|
||||
|
||||
// Targets the DJA buttom with a nested icon
|
||||
button .usa-icon,
|
||||
.button .usa-icon,
|
||||
.button--clipboard .usa-icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.errors span.select2-selection {
|
||||
border: 1px solid var(--error-fg) !important;
|
||||
}
|
||||
|
@ -738,7 +777,7 @@ div.dja__model-description{
|
|||
|
||||
li {
|
||||
list-style-type: disc;
|
||||
font-family: Source Sans Pro Web,Helvetica Neue,Helvetica,Roboto,Arial,sans-serif;
|
||||
font-family: family('sans');
|
||||
}
|
||||
|
||||
a, a:link, a:visited {
|
||||
|
@ -878,3 +917,16 @@ ul.add-list-reset {
|
|||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
// Fix the combobox when deployed outside admin (eg user transfer)
|
||||
.submit-row .select2,
|
||||
.submit-row .select2 span {
|
||||
margin-top: 0;
|
||||
}
|
||||
.transfer-user-selector .select2-selection__placeholder {
|
||||
color: #3d4551!important;
|
||||
}
|
||||
|
||||
.dl-dja dt {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
|
|
@ -33,16 +33,19 @@ body {
|
|||
}
|
||||
|
||||
#wrapper.dashboard--portfolio {
|
||||
background-color: color('gray-1');
|
||||
padding-top: units(4)!important;
|
||||
}
|
||||
|
||||
#wrapper.dashboard--grey-1 {
|
||||
background-color: color('gray-1');
|
||||
}
|
||||
|
||||
.section--outlined {
|
||||
|
||||
.section-outlined {
|
||||
background-color: color('white');
|
||||
border: 1px solid color('base-lighter');
|
||||
border-radius: 4px;
|
||||
padding: 0 units(2) units(3);
|
||||
padding: 0 units(4) units(3) units(2);
|
||||
margin-top: units(3);
|
||||
|
||||
&.margin-top-0 {
|
||||
|
@ -72,9 +75,13 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
.section--outlined__header--no-portfolio {
|
||||
.section--outlined__search,
|
||||
.section--outlined__utility-button {
|
||||
.section-outlined--border-base-light {
|
||||
border: 1px solid color('base-light');
|
||||
}
|
||||
|
||||
.section-outlined__header--no-portfolio {
|
||||
.section-outlined__search,
|
||||
.section-outlined__utility-button {
|
||||
margin-top: units(2);
|
||||
}
|
||||
|
||||
|
@ -82,11 +89,11 @@ body {
|
|||
display: flex;
|
||||
column-gap: units(3);
|
||||
|
||||
.section--outlined__search,
|
||||
.section--outlined__utility-button {
|
||||
.section-outlined__search,
|
||||
.section-outlined__utility-button {
|
||||
margin-top: 0;
|
||||
}
|
||||
.section--outlined__search {
|
||||
.section-outlined__search {
|
||||
flex-grow: 4;
|
||||
// Align right
|
||||
max-width: 383px;
|
||||
|
@ -192,3 +199,7 @@ abbr[title] {
|
|||
max-width: 50ch;
|
||||
}
|
||||
}
|
||||
|
||||
.margin-right-neg-4px {
|
||||
margin-right: -4px;
|
||||
}
|
||||
|
|
|
@ -124,10 +124,6 @@ a.withdraw:active {
|
|||
background-color: color('error-darker');
|
||||
}
|
||||
|
||||
.usa-button--unstyled .usa-icon {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
a.usa-button--unstyled:visited {
|
||||
color: color('primary');
|
||||
}
|
||||
|
@ -162,14 +158,14 @@ a.usa-button--unstyled:visited {
|
|||
}
|
||||
}
|
||||
|
||||
.input-with-edit-button {
|
||||
.toggleable_input {
|
||||
svg.usa-icon {
|
||||
width: 1.5em !important;
|
||||
height: 1.5em !important;
|
||||
color: #{$dhs-green};
|
||||
position: absolute;
|
||||
}
|
||||
&.input-with-edit-button__error {
|
||||
&.toggleable_input__error {
|
||||
svg.usa-icon {
|
||||
color: #{$dhs-red};
|
||||
}
|
||||
|
@ -205,12 +201,32 @@ a.usa-button--unstyled:visited {
|
|||
}
|
||||
}
|
||||
|
||||
.dotgov-table a,
|
||||
.usa-link--icon,
|
||||
.usa-button--with-icon {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
color: color('primary');
|
||||
column-gap: units(.5);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
.dotgov-table a,
|
||||
.usa-link--icon {
|
||||
&:visited {
|
||||
color: color('primary');
|
||||
}
|
||||
}
|
||||
|
||||
a .usa-icon,
|
||||
.usa-button--with-icon .usa-icon {
|
||||
height: 1.3em;
|
||||
width: 1.3em;
|
||||
}
|
||||
|
||||
.usa-icon.usa-icon--big {
|
||||
margin: 0;
|
||||
height: 1.5em;
|
||||
width: 1.5em;
|
||||
}
|
||||
|
||||
.margin-right-neg-4px {
|
||||
margin-right: -4px;
|
||||
}
|
|
@ -1,18 +0,0 @@
|
|||
@use "uswds-core" as *;
|
||||
|
||||
.dotgov-table a,
|
||||
.usa-link--icon {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
color: color('primary');
|
||||
|
||||
&:visited {
|
||||
color: color('primary');
|
||||
}
|
||||
.usa-icon {
|
||||
// align icon with x height
|
||||
margin-top: units(0.5);
|
||||
margin-right: units(0.5);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
@use "uswds-core" as *;
|
||||
|
||||
td,
|
||||
th {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.dotgov-table--stacked {
|
||||
td, th {
|
||||
padding: units(1) units(2) units(2px) 0;
|
||||
|
@ -12,7 +17,7 @@
|
|||
|
||||
tr {
|
||||
border-bottom: none;
|
||||
border-top: 2px solid color('base-light');
|
||||
border-top: 2px solid color('base-lighter');
|
||||
margin-top: units(2);
|
||||
|
||||
&:first-child {
|
||||
|
@ -39,10 +44,6 @@
|
|||
.dotgov-table {
|
||||
width: 100%;
|
||||
|
||||
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
|
||||
right: auto;
|
||||
}
|
||||
|
||||
tbody th {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
@ -56,7 +57,7 @@
|
|||
}
|
||||
|
||||
td, th {
|
||||
border-bottom: 1px solid color('base-light');
|
||||
border-bottom: 1px solid color('base-lighter');
|
||||
}
|
||||
|
||||
thead th {
|
||||
|
@ -72,11 +73,17 @@
|
|||
|
||||
td, th,
|
||||
.usa-tabel th{
|
||||
padding: units(2) units(2) units(2) 0;
|
||||
padding: units(2) units(4) units(2) 0;
|
||||
}
|
||||
|
||||
thead tr:first-child th:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
@include at-media(tablet-lg) {
|
||||
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
|
||||
right: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
--- Custom Styles ---------------------------------*/
|
||||
@forward "base";
|
||||
@forward "typography";
|
||||
@forward "links";
|
||||
@forward "lists";
|
||||
@forward "accordions";
|
||||
@forward "buttons";
|
||||
|
|
|
@ -357,13 +357,18 @@ CSP_FORM_ACTION = allowed_sources
|
|||
# and inline with a nonce, as well as allowing connections back to their domain.
|
||||
# Note: If needed, we can embed chart.js instead of using the CDN
|
||||
CSP_DEFAULT_SRC = ("'self'",)
|
||||
CSP_STYLE_SRC = ["'self'", "https://www.ssa.gov/accessibility/andi/andi.css"]
|
||||
CSP_STYLE_SRC = [
|
||||
"'self'",
|
||||
"https://www.ssa.gov/accessibility/andi/andi.css",
|
||||
"https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css",
|
||||
]
|
||||
CSP_SCRIPT_SRC_ELEM = [
|
||||
"'self'",
|
||||
"https://www.googletagmanager.com/",
|
||||
"https://cdn.jsdelivr.net/npm/chart.js",
|
||||
"https://www.ssa.gov",
|
||||
"https://ajax.googleapis.com",
|
||||
"https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js",
|
||||
]
|
||||
CSP_CONNECT_SRC = ["'self'", "https://www.google-analytics.com/", "https://www.ssa.gov/accessibility/andi/andi.js"]
|
||||
CSP_INCLUDE_NONCE_IN = ["script-src-elem", "style-src"]
|
||||
|
|
|
@ -24,6 +24,7 @@ from registrar.views.report_views import (
|
|||
|
||||
from registrar.views.domain_request import Step
|
||||
from registrar.views.domain_requests_json import get_domain_requests_json
|
||||
from registrar.views.transfer_user import TransferUserView
|
||||
from registrar.views.utility.api_views import (
|
||||
get_senior_official_from_federal_agency_json,
|
||||
get_federal_and_portfolio_types_from_federal_agency_json,
|
||||
|
@ -137,6 +138,7 @@ urlpatterns = [
|
|||
AnalyticsView.as_view(),
|
||||
name="analytics",
|
||||
),
|
||||
path("admin/registrar/user/<int:user_id>/transfer/", TransferUserView.as_view(), name="transfer_user"),
|
||||
path(
|
||||
"admin/api/get-senior-official-from-federal-agency-json/",
|
||||
get_senior_official_from_federal_agency_json,
|
||||
|
|
|
@ -60,6 +60,17 @@ def add_has_profile_feature_flag_to_context(request):
|
|||
|
||||
def portfolio_permissions(request):
|
||||
"""Make portfolio permissions for the request user available in global context"""
|
||||
context = {
|
||||
"has_base_portfolio_permission": False,
|
||||
"has_domains_portfolio_permission": False,
|
||||
"has_domain_requests_portfolio_permission": False,
|
||||
"has_view_members_portfolio_permission": False,
|
||||
"has_edit_members_portfolio_permission": False,
|
||||
"has_view_suborganization": False,
|
||||
"has_edit_suborganization": False,
|
||||
"portfolio": None,
|
||||
"has_organization_feature_flag": False,
|
||||
}
|
||||
try:
|
||||
portfolio = request.session.get("portfolio")
|
||||
if portfolio:
|
||||
|
@ -69,29 +80,15 @@ def portfolio_permissions(request):
|
|||
"has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(
|
||||
portfolio
|
||||
),
|
||||
"has_view_members_portfolio_permission": request.user.has_view_members_portfolio_permission(portfolio),
|
||||
"has_edit_members_portfolio_permission": request.user.has_edit_members_portfolio_permission(portfolio),
|
||||
"has_view_suborganization": request.user.has_view_suborganization(portfolio),
|
||||
"has_edit_suborganization": request.user.has_edit_suborganization(portfolio),
|
||||
"portfolio": portfolio,
|
||||
"has_organization_feature_flag": True,
|
||||
}
|
||||
return {
|
||||
"has_base_portfolio_permission": False,
|
||||
"has_domains_portfolio_permission": False,
|
||||
"has_domain_requests_portfolio_permission": False,
|
||||
"has_view_suborganization": False,
|
||||
"has_edit_suborganization": False,
|
||||
"portfolio": None,
|
||||
"has_organization_feature_flag": False,
|
||||
}
|
||||
return context
|
||||
|
||||
except AttributeError:
|
||||
# Handles cases where request.user might not exist
|
||||
return {
|
||||
"has_base_portfolio_permission": False,
|
||||
"has_domains_portfolio_permission": False,
|
||||
"has_domain_requests_portfolio_permission": False,
|
||||
"has_view_suborganization": False,
|
||||
"has_edit_suborganization": False,
|
||||
"portfolio": None,
|
||||
"has_organization_feature_flag": False,
|
||||
}
|
||||
return context
|
||||
|
|
|
@ -417,7 +417,7 @@ class SeniorOfficialContactForm(ContactForm):
|
|||
# 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.
|
||||
raise ValueError("Senior Official cannot be modified for federal or tribal domains.")
|
||||
raise ValueError("Senior official cannot be modified for federal or tribal domains.")
|
||||
elif db_so.has_more_than_one_join("information_senior_official"):
|
||||
# Handle the case where the domain information object is available and the SO Contact
|
||||
# has more than one joined object.
|
||||
|
|
|
@ -21,7 +21,7 @@ class Command(BaseCommand):
|
|||
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect="""
|
||||
prompt_message="""
|
||||
This script will delete all rows from the following tables:
|
||||
* Contact
|
||||
* Domain
|
||||
|
|
|
@ -130,7 +130,7 @@ class Command(BaseCommand):
|
|||
"""Asks if the user wants to proceed with this action"""
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
==Extension Amount==
|
||||
Period: {extension_amount} year(s)
|
||||
|
||||
|
|
|
@ -64,7 +64,7 @@ class Command(BaseCommand):
|
|||
# Will sys.exit() when prompt is "n"
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
==Master data file==
|
||||
domain_additional_filename: {org_args.domain_additional_filename}
|
||||
|
||||
|
@ -84,7 +84,7 @@ class Command(BaseCommand):
|
|||
# Will sys.exit() when prompt is "n"
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
==Master data file==
|
||||
domain_additional_filename: {org_args.domain_additional_filename}
|
||||
|
||||
|
|
|
@ -27,7 +27,7 @@ class Command(BaseCommand):
|
|||
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
==Proposed Changes==
|
||||
CSV: {federal_cio_csv_path}
|
||||
|
||||
|
|
|
@ -651,7 +651,7 @@ class Command(BaseCommand):
|
|||
title = "Do you wish to load additional data for TransitionDomains?"
|
||||
proceed = TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
!!! ENSURE THAT ALL FILENAMES ARE CORRECT BEFORE PROCEEDING
|
||||
==Master data file==
|
||||
domain_additional_filename: {domain_additional_filename}
|
||||
|
|
|
@ -91,7 +91,7 @@ class Command(BaseCommand):
|
|||
# Code execution will stop here if the user prompts "N"
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
==Proposed Changes==
|
||||
Number of DomainInformation objects to change: {len(human_readable_domain_names)}
|
||||
The following DomainInformation objects will be modified: {human_readable_domain_names}
|
||||
|
@ -148,7 +148,7 @@ class Command(BaseCommand):
|
|||
# Code execution will stop here if the user prompts "N"
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
==File location==
|
||||
current-full.csv filepath: {file_path}
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@ class Command(BaseCommand):
|
|||
# Code execution will stop here if the user prompts "N"
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
==Proposed Changes==
|
||||
Number of Domain objects to change: {len(domains)}
|
||||
""",
|
||||
|
|
|
@ -54,7 +54,7 @@ class Command(BaseCommand):
|
|||
# Code execution will stop here if the user prompts "N"
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
==Proposed Changes==
|
||||
Number of DomainRequest objects to change: {len(domain_requests)}
|
||||
|
||||
|
@ -72,7 +72,7 @@ class Command(BaseCommand):
|
|||
# Code execution will stop here if the user prompts "N"
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
prompt_message=f"""
|
||||
==Proposed Changes==
|
||||
Number of DomainInformation objects to change: {len(domain_infos)}
|
||||
|
||||
|
|
38
src/registrar/management/commands/update_first_ready.py
Normal file
38
src/registrar/management/commands/update_first_ready.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
import logging
|
||||
from django.core.management import BaseCommand
|
||||
from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors
|
||||
from registrar.models import Domain, TransitionDomain
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand, PopulateScriptTemplate):
|
||||
help = "Loops through each domain object and populates the last_status_update and first_submitted_date"
|
||||
|
||||
def handle(self, **kwargs):
|
||||
"""Loops through each valid Domain object and updates it's first_ready value if it is out of sync"""
|
||||
filter_conditions = {"state__in": [Domain.State.READY, Domain.State.ON_HOLD, Domain.State.DELETED]}
|
||||
self.mass_update_records(Domain, filter_conditions, ["first_ready"], verbose=True)
|
||||
|
||||
def update_record(self, record: Domain):
|
||||
"""Defines how we update the first_ready field"""
|
||||
# update the first_ready value based on the creation date.
|
||||
record.first_ready = record.created_at.date()
|
||||
|
||||
logger.info(
|
||||
f"{TerminalColors.OKCYAN}Updating {record} => first_ready: " f"{record.first_ready}{TerminalColors.ENDC}"
|
||||
)
|
||||
|
||||
# check if a transition domain object for this domain name exists,
|
||||
# or if so whether its first_ready value matches its created_at date
|
||||
def custom_filter(self, records):
|
||||
to_include_pks = []
|
||||
for record in records:
|
||||
if (
|
||||
TransitionDomain.objects.filter(domain_name=record.name).exists()
|
||||
and record.first_ready != record.created_at.date()
|
||||
): # noqa
|
||||
to_include_pks.append(record.pk)
|
||||
|
||||
return records.filter(pk__in=to_include_pks)
|
|
@ -2,9 +2,12 @@ import logging
|
|||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Model
|
||||
from django.db.models.manager import BaseManager
|
||||
from typing import List
|
||||
from registrar.utility.enums import LogCode
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
@ -76,27 +79,60 @@ class PopulateScriptTemplate(ABC):
|
|||
|
||||
@abstractmethod
|
||||
def update_record(self, record):
|
||||
"""Defines how we update each field. Must be defined before using mass_update_records."""
|
||||
"""Defines how we update each field.
|
||||
|
||||
raises:
|
||||
NotImplementedError: If not defined before calling mass_update_records.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def mass_update_records(self, object_class, filter_conditions, fields_to_update, debug=True):
|
||||
def mass_update_records(self, object_class, filter_conditions, fields_to_update, debug=True, verbose=False):
|
||||
"""Loops through each valid "object_class" object - specified by filter_conditions - and
|
||||
updates fields defined by fields_to_update using update_record.
|
||||
|
||||
You must define update_record before you can use this function.
|
||||
Parameters:
|
||||
object_class: The Django model class that you want to perform the bulk update on.
|
||||
This should be the actual class, not a string of the class name.
|
||||
|
||||
filter_conditions: dictionary of valid Django Queryset filter conditions
|
||||
(e.g. {'verification_type__isnull'=True}).
|
||||
|
||||
fields_to_update: List of strings specifying which fields to update.
|
||||
(e.g. ["first_ready_date", "last_submitted_date"])
|
||||
|
||||
debug: Whether to log script run summary in debug mode.
|
||||
Default: True.
|
||||
|
||||
verbose: Whether to print a detailed run summary *before* run confirmation.
|
||||
Default: False.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If you do not define update_record before using this function.
|
||||
TypeError: If custom_filter is not Callable.
|
||||
"""
|
||||
|
||||
records = object_class.objects.filter(**filter_conditions) if filter_conditions else object_class.objects.all()
|
||||
|
||||
# apply custom filter
|
||||
records = self.custom_filter(records)
|
||||
|
||||
readable_class_name = self.get_class_name(object_class)
|
||||
|
||||
# for use in the execution prompt.
|
||||
proposed_changes = f"""==Proposed Changes==
|
||||
Number of {readable_class_name} objects to change: {len(records)}
|
||||
These fields will be updated on each record: {fields_to_update}
|
||||
"""
|
||||
|
||||
if verbose:
|
||||
proposed_changes = f"""{proposed_changes}
|
||||
These records will be updated: {list(records.all())}
|
||||
"""
|
||||
|
||||
# Code execution will stop here if the user prompts "N"
|
||||
TerminalHelper.prompt_for_execution(
|
||||
system_exit_on_terminate=True,
|
||||
info_to_inspect=f"""
|
||||
==Proposed Changes==
|
||||
Number of {readable_class_name} objects to change: {len(records)}
|
||||
These fields will be updated on each record: {fields_to_update}
|
||||
""",
|
||||
prompt_message=proposed_changes,
|
||||
prompt_title=self.prompt_title,
|
||||
)
|
||||
logger.info("Updating...")
|
||||
|
@ -141,10 +177,17 @@ class PopulateScriptTemplate(ABC):
|
|||
return f"{TerminalColors.FAIL}" f"Failed to update {record}" f"{TerminalColors.ENDC}"
|
||||
|
||||
def should_skip_record(self, record) -> bool: # noqa
|
||||
"""Defines the condition in which we should skip updating a record. Override as needed."""
|
||||
"""Defines the condition in which we should skip updating a record. Override as needed.
|
||||
The difference between this and custom_filter is that records matching these conditions
|
||||
*will* be included in the run but will be skipped (and logged as such)."""
|
||||
# By default - don't skip
|
||||
return False
|
||||
|
||||
def custom_filter(self, records: BaseManager[Model]) -> BaseManager[Model]:
|
||||
"""Override to define filters that can't be represented by django queryset field lookups.
|
||||
Applied to individual records *after* filter_conditions. True means"""
|
||||
return records
|
||||
|
||||
|
||||
class TerminalHelper:
|
||||
@staticmethod
|
||||
|
@ -220,6 +263,9 @@ class TerminalHelper:
|
|||
an answer is required of the user).
|
||||
|
||||
The "answer" return value is True for "yes" or False for "no".
|
||||
|
||||
Raises:
|
||||
ValueError: When "default" is not "yes", "no", or None.
|
||||
"""
|
||||
valid = {"yes": True, "y": True, "ye": True, "no": False, "n": False}
|
||||
if default is None:
|
||||
|
@ -244,6 +290,7 @@ class TerminalHelper:
|
|||
@staticmethod
|
||||
def query_yes_no_exit(question: str, default="yes"):
|
||||
"""Ask a yes/no question via raw_input() and return their answer.
|
||||
Allows for answer "e" to exit.
|
||||
|
||||
"question" is a string that is presented to the user.
|
||||
"default" is the presumed answer if the user just hits <Enter>.
|
||||
|
@ -251,6 +298,9 @@ class TerminalHelper:
|
|||
an answer is required of the user).
|
||||
|
||||
The "answer" return value is True for "yes" or False for "no".
|
||||
|
||||
Raises:
|
||||
ValueError: When "default" is not "yes", "no", or None.
|
||||
"""
|
||||
valid = {
|
||||
"yes": True,
|
||||
|
@ -317,9 +367,8 @@ class TerminalHelper:
|
|||
case _:
|
||||
logger.info(print_statement)
|
||||
|
||||
# TODO - "info_to_inspect" should be refactored to "prompt_message"
|
||||
@staticmethod
|
||||
def prompt_for_execution(system_exit_on_terminate: bool, info_to_inspect: str, prompt_title: str) -> bool:
|
||||
def prompt_for_execution(system_exit_on_terminate: bool, prompt_message: str, prompt_title: str) -> bool:
|
||||
"""Create to reduce code complexity.
|
||||
Prompts the user to inspect the given string
|
||||
and asks if they wish to proceed.
|
||||
|
@ -340,7 +389,7 @@ class TerminalHelper:
|
|||
=====================================================
|
||||
*** IMPORTANT: VERIFY THE FOLLOWING LOOKS CORRECT ***
|
||||
|
||||
{info_to_inspect}
|
||||
{prompt_message}
|
||||
{TerminalColors.FAIL}
|
||||
Proceed? (Y = proceed, N = {action_description_for_selecting_no})
|
||||
{TerminalColors.ENDC}"""
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
# Generated by Django 4.2.10 on 2024-09-04 21:29
|
||||
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("registrar", "0122_create_groups_v16"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="portfolioinvitation",
|
||||
name="portfolio_additional_permissions",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[
|
||||
("view_all_domains", "View all domains and domain reports"),
|
||||
("view_managed_domains", "View managed domains"),
|
||||
("view_members", "View members"),
|
||||
("edit_members", "Create and edit members"),
|
||||
("view_all_requests", "View all requests"),
|
||||
("view_created_requests", "View created requests"),
|
||||
("edit_requests", "Create and edit requests"),
|
||||
("view_portfolio", "View organization"),
|
||||
("edit_portfolio", "Edit organization"),
|
||||
("view_suborganization", "View suborganization"),
|
||||
("edit_suborganization", "Edit suborganization"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
blank=True,
|
||||
help_text="Select one or more additional permissions.",
|
||||
null=True,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="userportfoliopermission",
|
||||
name="additional_permissions",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.CharField(
|
||||
choices=[
|
||||
("view_all_domains", "View all domains and domain reports"),
|
||||
("view_managed_domains", "View managed domains"),
|
||||
("view_members", "View members"),
|
||||
("edit_members", "Create and edit members"),
|
||||
("view_all_requests", "View all requests"),
|
||||
("view_created_requests", "View created requests"),
|
||||
("edit_requests", "Create and edit requests"),
|
||||
("view_portfolio", "View organization"),
|
||||
("edit_portfolio", "Edit organization"),
|
||||
("view_suborganization", "View suborganization"),
|
||||
("edit_suborganization", "Edit suborganization"),
|
||||
],
|
||||
max_length=50,
|
||||
),
|
||||
blank=True,
|
||||
help_text="Select one or more additional permissions.",
|
||||
null=True,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -16,8 +16,8 @@ class UserPortfolioPermission(TimeStampedModel):
|
|||
PORTFOLIO_ROLE_PERMISSIONS = {
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBER,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBER,
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.EDIT_MEMBERS,
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
||||
UserPortfolioPermissionChoices.EDIT_REQUESTS,
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
|
@ -28,7 +28,7 @@ class UserPortfolioPermission(TimeStampedModel):
|
|||
],
|
||||
UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBER,
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
# Domain: field specific permissions
|
||||
|
|
|
@ -17,8 +17,8 @@ class UserPortfolioPermissionChoices(models.TextChoices):
|
|||
VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports"
|
||||
VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains"
|
||||
|
||||
VIEW_MEMBER = "view_member", "View members"
|
||||
EDIT_MEMBER = "edit_member", "Create and edit members"
|
||||
VIEW_MEMBERS = "view_members", "View members"
|
||||
EDIT_MEMBERS = "edit_members", "Create and edit members"
|
||||
|
||||
VIEW_ALL_REQUESTS = "view_all_requests", "View all requests"
|
||||
VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests"
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
{% block content %}
|
||||
|
||||
<div id="content-main" class="analytics">
|
||||
<div id="content-main" class="custom-admin-template">
|
||||
|
||||
<div class="grid-row grid-gap-2">
|
||||
<div class="tablet:grid-col-6 margin-top-2">
|
||||
|
@ -29,28 +29,28 @@
|
|||
<div class="padding-top-2 padding-x-2">
|
||||
<ul class="usa-button-group wrapped-button-group">
|
||||
<li class="usa-button-group__item">
|
||||
<a href="{% url 'export_data_type' %}" class="button text-no-wrap" role="button">
|
||||
<a href="{% url 'export_data_type' %}" class="usa-button usa-button--dja text-no-wrap" role="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg><span class="margin-left-05">All domain metadata</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<a href="{% url 'export_data_full' %}" class="button text-no-wrap" role="button">
|
||||
<a href="{% url 'export_data_full' %}" class="usa-button usa-button--dja text-no-wrap" role="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg><span class="margin-left-05">Current full</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<a href="{% url 'export_data_federal' %}" class="button text-no-wrap" role="button">
|
||||
<a href="{% url 'export_data_federal' %}" class="usa-button usa-button--dja text-no-wrap" role="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg><span class="margin-left-05">Current federal</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<a href="{% url 'export_data_domain_requests_full' %}" class="button text-no-wrap" role="button">
|
||||
<a href="{% url 'export_data_domain_requests_full' %}" class="usa-button usa-button--dja text-no-wrap" role="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg><span class="margin-left-05">All domain requests metadata</span>
|
||||
|
@ -84,35 +84,35 @@
|
|||
</div>
|
||||
<ul class="usa-button-group">
|
||||
<li class="usa-button-group__item">
|
||||
<button class="button exportLink" data-export-url="{% url 'export_domains_growth' %}" type="button">
|
||||
<button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_domains_growth' %}" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg><span class="margin-left-05">Domain growth</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<button class="button exportLink" data-export-url="{% url 'export_requests_growth' %}" type="button">
|
||||
<button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_requests_growth' %}" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg><span class="margin-left-05">Request growth</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<button class="button exportLink" data-export-url="{% url 'export_managed_domains' %}" type="button">
|
||||
<button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_managed_domains' %}" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg><span class="margin-left-05">Managed domains</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<button class="button exportLink" data-export-url="{% url 'export_unmanaged_domains' %}" type="button">
|
||||
<button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_unmanaged_domains' %}" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg><span class="margin-left-05">Unmanaged domains</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="usa-button-group__item">
|
||||
<button class="button exportLink usa-button--secondary" data-export-url="{% url 'analytics' %}" type="button">
|
||||
<button class="usa-button usa-button--dja exportLink usa-button--secondary" data-export-url="{% url 'analytics' %}" type="button">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#assessment"></use>
|
||||
</svg><span class="margin-left-05">Update charts</span>
|
||||
|
|
|
@ -17,7 +17,7 @@ Template for an input field with a clipboard
|
|||
>
|
||||
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
||||
</svg>
|
||||
<span>Copy</span>
|
||||
Copy
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -25,7 +25,7 @@ Template for an input field with a clipboard
|
|||
<div class="admin-icon-group admin-icon-group__clipboard-link">
|
||||
<input aria-hidden="true" class="display-none" value="{{ field.email }}" />
|
||||
<button
|
||||
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard text-no-underline"
|
||||
class="usa-button usa-button--unstyled padding-right-1 usa-button--icon button--clipboard copy-to-clipboard text-no-underline padding-left-05"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
|
@ -33,7 +33,7 @@ Template for an input field with a clipboard
|
|||
>
|
||||
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
|
||||
</svg>
|
||||
<span class="padding-left-05">Copy</span>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
260
src/registrar/templates/admin/transfer_user.html
Normal file
260
src/registrar/templates/admin/transfer_user.html
Normal file
|
@ -0,0 +1,260 @@
|
|||
{% extends 'admin/base_site.html' %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block content_title %}<h1>Transfer user</h1>{% endblock %}
|
||||
|
||||
{% block extrastyle %}
|
||||
|
||||
{{ block.super }}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
<!-- Making the user select a combobox: -->
|
||||
<!-- Load Django Admin's base JavaScript. This is NEEDED because select2 relies on it. -->
|
||||
<script src="{% static 'admin/js/vendor/jquery/jquery.min.js' %}"></script>
|
||||
|
||||
<!-- Include Select2 JavaScript. Since this view technically falls outside of admin, this is needed. -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
<script type="application/javascript" src="{% static 'js/get-gov-admin-extra.js' %}" defer></script>
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||
› <a href="{% url 'admin:app_list' 'registrar' %}">{% trans 'Registrar' %}</a>
|
||||
› <a href="{% url 'admin:registrar_user_changelist' %}">{% trans 'Users' %}</a>
|
||||
› <a href="{% url 'admin:registrar_user_change' current_user.pk %}">{{ current_user.first_name }} {{ current_user.last_name }}</a>
|
||||
› {% trans 'Transfer User' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main" class="custom-admin-template">
|
||||
|
||||
<div class="module padding-4 display-flex flex-row flex-justify submit-row">
|
||||
|
||||
<div class="desktop:flex-align-center">
|
||||
<form class="transfer-user-selector" method="GET" action="{% url 'transfer_user' current_user.pk %}">
|
||||
<label for="selected_user" class="text-middle">Select user to transfer data from:</label>
|
||||
<select name="selected_user" id="selected_user" class="admin-combobox margin-top-0" onchange="this.form.submit()">
|
||||
<option value="">Select a user</option>
|
||||
{% for user in other_users %}
|
||||
<option value="{{ user.pk }}" {% if selected_user and user.pk == selected_user.pk %}selected{% endif %}>
|
||||
{{ user.first_name }} {{ user.last_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="submit" value="Select and preview" class="button--dja-toolbar">
|
||||
</form>
|
||||
</div>
|
||||
<div class="desktop:flex-align-center">
|
||||
{% if selected_user %}
|
||||
<a class="usa-button usa-button--dja" href="#transfer-and-delete" aria-controls="transfer-and-delete" data-open-modal>
|
||||
Transfer and delete user
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-row grid-gap-2">
|
||||
|
||||
<div class="tablet:grid-col-6 margin-top-2">
|
||||
<div class="module height-full">
|
||||
<h2>User to transfer data from</h2>
|
||||
<div class="padding-top-2 padding-x-2">
|
||||
{% if selected_user %}
|
||||
<dl class="dl-dja">
|
||||
<dt>Username:</dt>
|
||||
<dd>{{ selected_user.username }}</dd>
|
||||
<dt>Created at:</dt>
|
||||
<dd>{{ selected_user.created_at }}</dd>
|
||||
<dt>Last login:</dt>
|
||||
<dd>{{ selected_user.last_login }}</dd>
|
||||
<dt>First name:</dt>
|
||||
<dd>{{ selected_user.first_name }}</dd>
|
||||
<dt>Middle name:</dt>
|
||||
<dd>{{ selected_user.middle_name }}</dd>
|
||||
<dt>Last name:</dt>
|
||||
<dd>{{ selected_user.last_name }}</dd>
|
||||
<dt>Title:</dt>
|
||||
<dd>{{ selected_user.title }}</dd>
|
||||
<dt>Email:</dt>
|
||||
<dd>{{ selected_user.email }}</dd>
|
||||
<dt>Phone:</dt>
|
||||
<dd>{{ selected_user.phone }}</dd>
|
||||
<h3 class="font-heading-md">Data that will get transferred:</h3>
|
||||
<dt>Domains:</dt>
|
||||
<dd>
|
||||
{% if selected_user_domains %}
|
||||
<ul>
|
||||
{% for domain in selected_user_domains %}
|
||||
<li>{{ domain }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Domain requests:</dt>
|
||||
<dd>
|
||||
{% if selected_user_domain_requests %}
|
||||
<ul>
|
||||
{% for request in selected_user_domain_requests %}
|
||||
<li>{{ request }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Portfolios:</dt>
|
||||
<dd>
|
||||
{% if selected_user_portfolios %}
|
||||
<ul>
|
||||
{% for portfolio in selected_user_portfolios %}
|
||||
<li>{{ portfolio.portfolio }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
{% else %}
|
||||
<p>No user selected yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tablet:grid-col-6 margin-top-2">
|
||||
<div class="module height-full">
|
||||
<h2>User to receive data</h2>
|
||||
<div class="padding-top-2 padding-x-2">
|
||||
<dl class="dl-dja">
|
||||
<dt>Username:</dt>
|
||||
<dd>{{ current_user.username }}</dd>
|
||||
<dt>Created at:</dt>
|
||||
<dd>{{ current_user.created_at }}</dd>
|
||||
<dt>Last login:</dt>
|
||||
<dd>{{ current_user.last_login }}</dd>
|
||||
<dt>First name:</dt>
|
||||
<dd>{{ current_user.first_name }}</dd>
|
||||
<dt>Middle name:</dt>
|
||||
<dd>{{ current_user.middle_name }}</dd>
|
||||
<dt>Last name:</dt>
|
||||
<dd>{{ current_user.last_name }}</dd>
|
||||
<dt>Title:</dt>
|
||||
<dd>{{ current_user.title }}</dd>
|
||||
<dt>Email:</dt>
|
||||
<dd>{{ current_user.email }}</dd>
|
||||
<dt>Phone:</dt>
|
||||
<dd>{{ current_user.phone }}</dd>
|
||||
<h3 class="font-heading-md" aria-label="Data that will added to:"> </h3>
|
||||
<dt>Domains:</dt>
|
||||
<dd>
|
||||
{% if current_user_domains %}
|
||||
<ul>
|
||||
{% for domain in current_user_domains %}
|
||||
<li>{{ domain }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Domain requests:</dt>
|
||||
<dd>
|
||||
{% if current_user_domain_requests %}
|
||||
<ul>
|
||||
{% for request in current_user_domain_requests %}
|
||||
<li>{{ request }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Portfolios:</dt>
|
||||
<dd>
|
||||
{% if current_user_portfolios %}
|
||||
<ul>
|
||||
{% for portfolio in current_user_portfolios %}
|
||||
<li>{{ portfolio.portfolio }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
None
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="usa-modal"
|
||||
id="transfer-and-delete"
|
||||
aria-labelledby="This action will delete {{ selected_user }}"
|
||||
aria-describedby="This action will delete {{ selected_user }}"
|
||||
>
|
||||
<div class="usa-modal__content">
|
||||
<div class="usa-modal__main">
|
||||
<h2 class="usa-modal__heading" id="transfer-and-delete-heading">
|
||||
Are you sure you want to transfer data and delete this user?
|
||||
</h2>
|
||||
<div class="usa-prose">
|
||||
{% if selected_user != logged_in_user %}
|
||||
<p>Username: <b>{{ selected_user.username }}</b><br>
|
||||
Name: <b>{{ selected_user.first_name }} {{ selected_user.last_name }}</b><br>
|
||||
Email: <b>{{ selected_user.email }}</b></p>
|
||||
<p>This action cannot be undone.</p>
|
||||
{% else %}
|
||||
<p>Don't do it!</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="usa-modal__footer">
|
||||
<ul class="usa-button-group">
|
||||
{% if selected_user != logged_in_user %}
|
||||
<li class="usa-button-group__item">
|
||||
<form method="POST" action="{% url 'transfer_user' current_user.pk %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="selected_user" value="{{ selected_user.pk }}">
|
||||
<input type="submit" class="usa-button usa-button--dja" value="Yes, transfer and delete user">
|
||||
</form>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="usa-button-group__item">
|
||||
<button
|
||||
type="button"
|
||||
class="usa-button usa-button--unstyled padding-105 text-center"
|
||||
name="_cancel_domain_request_ineligible"
|
||||
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>
|
||||
{% endblock %}
|
|
@ -137,6 +137,16 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
{% endfor %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% elif field.field.name == "display_admins" %}
|
||||
<div class="readonly">{{ field.contents|safe }}</div>
|
||||
{% elif field.field.name == "display_members" %}
|
||||
<div class="readonly">
|
||||
{% if display_members_summary %}
|
||||
{{ display_members_summary }}
|
||||
{% else %}
|
||||
<p>No additional members found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="readonly">{{ field.contents }}</div>
|
||||
{% endif %}
|
||||
|
@ -330,6 +340,13 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
|
|||
</details>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% elif field.field.name == "display_members" and field.contents %}
|
||||
<details class="margin-top-1 dja-detail-table" aria-role="button" open>
|
||||
<summary class="padding-1 padding-left-0 dja-details-summary">Details</summary>
|
||||
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
|
||||
{{ field.contents|safe }}
|
||||
</div>
|
||||
</details>
|
||||
{% elif field.field.name == "state_territory" and original_object|model_name_lowercase != 'portfolio' %}
|
||||
<div class="flex-container margin-top-2">
|
||||
<span>
|
||||
|
|
|
@ -17,8 +17,7 @@
|
|||
This is a placeholder for now.
|
||||
|
||||
Disclaimer:
|
||||
When extending the fieldset view - *make a new one* that extends from detail_table_fieldset.
|
||||
For instance, "portfolio_fieldset.html".
|
||||
When extending the fieldset view consider whether you need to make a new one that extends from detail_table_fieldset.
|
||||
detail_table_fieldset is used on multiple admin pages, so a change there can have unintended consequences.
|
||||
{% endcomment %}
|
||||
{% include "django/admin/includes/detail_table_fieldset.html" with original_object=original %}
|
||||
|
|
|
@ -1,6 +1,21 @@
|
|||
{% extends 'django/admin/email_clipboard_change_form.html' %}
|
||||
{% load i18n static %}
|
||||
|
||||
|
||||
{% block field_sets %}
|
||||
<div class="display-flex flex-row flex-justify submit-row">
|
||||
<div class="desktop:flex-align-self-end">
|
||||
<a href="{% url 'transfer_user' original.pk %}" class="button">
|
||||
Transfer data from old account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for fieldset in adminform %}
|
||||
{% include "django/admin/includes/domain_fieldset.html" with state_help_message=state_help_message %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
{% block after_related_objects %}
|
||||
{% if portfolios %}
|
||||
<div class="module aligned padding-3">
|
||||
|
|
|
@ -7,7 +7,9 @@ for now we just carry the attribute to both the parent element and the select.
|
|||
|
||||
<div class="usa-combo-box"
|
||||
{% for name, value in widget.attrs.items %}
|
||||
{% if name != 'id' %}
|
||||
{{ name }}="{{ value }}"
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
>
|
||||
{% include "django/forms/widgets/select.html" %}
|
||||
|
|
|
@ -63,10 +63,10 @@
|
|||
|
||||
<div class="grid-row margin-top-1">
|
||||
<div class="grid-col">
|
||||
<button type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record">
|
||||
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon float-right-tablet delete-record">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
|
||||
</svg><span class="margin-left-05">Delete</span>
|
||||
</svg>Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -74,10 +74,10 @@
|
|||
</fieldset>
|
||||
{% endfor %}
|
||||
|
||||
<button type="button" class="usa-button usa-button--unstyled display-block margin-bottom-2" id="add-form">
|
||||
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon margin-bottom-2" id="add-form">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
|
||||
</svg><span class="margin-left-05">Add new record</span>
|
||||
</svg>Add new record
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
|
|
@ -52,20 +52,20 @@
|
|||
{% endwith %}
|
||||
</div>
|
||||
<div class="tablet:grid-col-2">
|
||||
<button type="button" class="usa-button usa-button--unstyled display-block delete-record margin-bottom-075">
|
||||
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon delete-record margin-bottom-075">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#delete"></use>
|
||||
</svg><span class="margin-left-05">Delete</span>
|
||||
</svg>Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<button type="button" class="usa-button usa-button--unstyled display-block" id="add-form">
|
||||
<button type="button" class="usa-button usa-button--unstyled usa-button--with-icon" id="add-form">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
|
||||
</svg><span class="margin-left-05">Add another name server</span>
|
||||
</svg>Add another name server
|
||||
</button>
|
||||
|
||||
{% comment %} Work around USWDS' button margins to add some spacing between the submit and the 'add more'
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
|
||||
{% input_with_errors form.state_territory %}
|
||||
|
||||
{% with add_class="usa-input--small" %}
|
||||
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
|
||||
{% input_with_errors form.zipcode %}
|
||||
{% endwith %}
|
||||
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
|
||||
{% input_with_errors forms.0.state_territory %}
|
||||
|
||||
{% with add_class="usa-input--small" %}
|
||||
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
|
||||
{% input_with_errors forms.0.zipcode %}
|
||||
{% endwith %}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{% extends "domain_base.html" %}
|
||||
{% load static field_helpers%}
|
||||
|
||||
{% block title %}Suborganization{% endblock %}
|
||||
{% block title %}Suborganization{% if suborganization_name %} | suborganization_name{% endif %} | {% endblock %}
|
||||
|
||||
{% block domain_content %}
|
||||
{# this is right after the messages block in the parent template #}
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
</ul>
|
||||
|
||||
{% if domain.permissions %}
|
||||
<section class="section--outlined">
|
||||
<section class="section-outlined">
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
|
||||
<h2 class> Domain managers </h2>
|
||||
<caption class="sr-only">Domain managers</caption>
|
||||
|
@ -112,7 +112,7 @@
|
|||
</section>
|
||||
|
||||
{% if domain.invitations.exists %}
|
||||
<section class="section--outlined">
|
||||
<section class="section-outlined">
|
||||
<h2>Invitations</h2>
|
||||
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
|
||||
<caption class="sr-only">Domain invitations</caption>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||
{% url 'get_domain_requests_json' as url %}
|
||||
<span id="get_domain_requests_json_url" class="display-none">{{url}}</span>
|
||||
<section class="section--outlined domain-requests" id="domain-requests">
|
||||
<section class="section-outlined domain-requests{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domain-requests">
|
||||
<div class="grid-row">
|
||||
{% if not has_domain_requests_portfolio_permission %}
|
||||
<div class="mobile:grid-col-12 desktop:grid-col-6">
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
|
||||
{% url 'get_domains_json' as url %}
|
||||
<span id="get_domains_json_url" class="display-none">{{url}}</span>
|
||||
<section class="section--outlined domains{% if not portfolio %} margin-top-0{% endif %}" id="domains">
|
||||
<div class="section--outlined__header margin-bottom-3 {% if not portfolio %} section--outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
|
||||
<section class="section-outlined domains margin-top-0{% if portfolio %} section-outlined--border-base-light{% endif %}" id="domains">
|
||||
<div class="section-outlined__header margin-bottom-3 {% if not portfolio %} section-outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
|
||||
{% if not portfolio %}
|
||||
<h2 id="domains-header" class="display-inline-block">Domains</h2>
|
||||
<span class="display-none" id="no-portfolio-js-flag"></span>
|
||||
|
@ -14,7 +14,7 @@
|
|||
<!-- Embedding the portfolio value in a data attribute -->
|
||||
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
|
||||
{% endif %}
|
||||
<div class="section--outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
|
||||
<div class="section-outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
|
||||
<section aria-label="Domains search component" class="margin-top-2">
|
||||
<form class="usa-search usa-search--small" method="POST" role="search">
|
||||
{% csrf_token %}
|
||||
|
@ -43,10 +43,10 @@
|
|||
</section>
|
||||
</div>
|
||||
{% if user_domain_count and user_domain_count > 0 %}
|
||||
<div class="section--outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
|
||||
<section aria-label="Domains report component" class="mobile-lg:margin-top-205">
|
||||
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled" role="button">
|
||||
<svg class="usa-icon usa-icon--big margin-right-neg-4px" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
|
||||
<section aria-label="Domains report component" class="margin-top-205">
|
||||
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled usa-button--with-icon" role="button">
|
||||
<svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
|
||||
</svg>Export as CSV
|
||||
</a>
|
||||
|
@ -158,7 +158,7 @@
|
|||
<th data-sortable="expiration_date" scope="col" role="columnheader">Expires</th>
|
||||
<th data-sortable="state_display" scope="col" role="columnheader">Status</th>
|
||||
{% if portfolio and has_view_suborganization %}
|
||||
<th data-sortable="suborganization" scope="col" role="columnheader">Suborganization</th>
|
||||
<th data-sortable="domain_info__sub_organization" scope="col" role="columnheader">Suborganization</th>
|
||||
{% endif %}
|
||||
<th
|
||||
scope="col"
|
||||
|
|
|
@ -12,46 +12,6 @@
|
|||
<button type="button" class="usa-nav__close">
|
||||
<img src="{%static 'img/usa-icons/close.svg'%}" role="img" alt="Close" />
|
||||
</button>
|
||||
<ul class="usa-nav__primary usa-accordion">
|
||||
<li class="usa-nav__primary-item">
|
||||
{% if has_domains_portfolio_permission %}
|
||||
{% url 'domains' as url %}
|
||||
{%else %}
|
||||
{% url 'no-portfolio-domains' as url %}
|
||||
{% endif %}
|
||||
<a href="{{ url }}" class="usa-nav-link{% if 'domain'|in_path:request.path %} usa-current{% endif %}">
|
||||
Domains
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-nav__primary-item">
|
||||
<a href="#" class="usa-nav-link">
|
||||
Domain groups
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% if has_domain_requests_portfolio_permission %}
|
||||
<li class="usa-nav__primary-item">
|
||||
{% url 'domain-requests' as url %}
|
||||
<a href="{{ url }}" class="usa-nav-link{% if 'request'|in_path:request.path %} usa-current{% endif %}">
|
||||
Domain requests
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="usa-nav__primary-item">
|
||||
<a href="#" class="usa-nav-link">
|
||||
Members
|
||||
</a>
|
||||
</li>
|
||||
<li class="usa-nav__primary-item">
|
||||
{% url 'organization' as url %}
|
||||
<!-- Move the padding from the a to the span so that the descenders do not get cut off -->
|
||||
<a href="{{ url }}" class="usa-nav-link padding-y-0">
|
||||
<span class="ellipsis ellipsis--23 ellipsis--desktop-50 padding-y-1 desktop:padding-y-2">
|
||||
{{ portfolio.organization_name }}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="usa-nav__secondary">
|
||||
<ul class="usa-nav__secondary-links">
|
||||
<li class="usa-nav__secondary-item">
|
||||
|
@ -75,6 +35,48 @@
|
|||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ul class="usa-nav__primary usa-accordion">
|
||||
<li class="usa-nav__primary-item">
|
||||
{% if has_domains_portfolio_permission %}
|
||||
{% url 'domains' as url %}
|
||||
{%else %}
|
||||
{% url 'no-portfolio-domains' as url %}
|
||||
{% endif %}
|
||||
<a href="{{ url }}" class="usa-nav-link{% if 'domain'|in_path:request.path %} usa-current{% endif %}">
|
||||
Domains
|
||||
</a>
|
||||
</li>
|
||||
<!-- <li class="usa-nav__primary-item">
|
||||
<a href="#" class="usa-nav-link">
|
||||
Domain groups
|
||||
</a>
|
||||
</li> -->
|
||||
|
||||
{% if has_domain_requests_portfolio_permission %}
|
||||
<li class="usa-nav__primary-item">
|
||||
{% url 'domain-requests' as url %}
|
||||
<a href="{{ url }}" class="usa-nav-link{% if 'request'|in_path:request.path %} usa-current{% endif %}">
|
||||
Domain requests
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if has_view_members_portfolio_permission %}
|
||||
<li class="usa-nav__primary-item">
|
||||
<a href="#" class="usa-nav-link">
|
||||
Members
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="usa-nav__primary-item">
|
||||
{% url 'organization' as url %}
|
||||
<!-- Move the padding from the a to the span so that the descenders do not get cut off -->
|
||||
<a href="{{ url }}" class="usa-nav-link padding-y-0 {% if request.path == '/organization/' %} usa-current{% endif %}">
|
||||
<span class="ellipsis ellipsis--23 ellipsis--desktop-50 padding-y-1 desktop:padding-y-2">
|
||||
{{ portfolio.organization_name }}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
{% include "includes/form_errors.html" with form=form %}
|
||||
{% endif %}
|
||||
|
||||
<h1>Senior Official</h1>
|
||||
<h1>Senior official</h1>
|
||||
|
||||
<p>
|
||||
Your senior official is a person within your organization who can authorize domain requests.
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{% load static field_helpers url_helpers custom_filters %}
|
||||
|
||||
<div id="{{field.name}}__edit-button-readonly" class="margin-top-2 margin-bottom-1 input-with-edit-button {% if not field.value and field.field.required %}input-with-edit-button__error{% endif %}">
|
||||
<div id="{{field.name}}__edit-button-readonly" class="margin-top-2 margin-bottom-1 toggleable_input {% if not field.value and field.field.required %}toggleable_input__error{% endif %}">
|
||||
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
|
||||
{% if field.value or not field.field.required %}
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#check_circle"></use>
|
||||
|
@ -8,7 +8,7 @@
|
|||
<use xlink:href="{%static 'img/sprite.svg'%}#error"></use>
|
||||
{%endif %}
|
||||
</svg>
|
||||
<div class="display-inline padding-left-05 margin-left-3 input-with-edit-button__readonly-field {% if not field.field.required %}text-base{% endif %}">
|
||||
<div class="display-inline padding-left-05 margin-left-3 toggleable_input__readonly-field {% if not field.field.required %}text-base{% endif %}">
|
||||
{% if field.name != "phone" %}
|
||||
{{ field.value }}
|
||||
{% else %}
|
||||
|
|
|
@ -5,9 +5,10 @@
|
|||
{% block title %} Domains | {% endblock %}
|
||||
|
||||
{% block portfolio_content %}
|
||||
<div id="main-content">
|
||||
<h1 id="domains-header">Domains</h1>
|
||||
<section class="section--outlined">
|
||||
<div class="section--outlined__header margin-bottom-3">
|
||||
<section class="section-outlined">
|
||||
<div class="section-outlined__header margin-bottom-3">
|
||||
<h2 id="domains-header" class="display-inline-block">You aren’t managing any domains.</h2>
|
||||
{% if portfolio_administrators %}
|
||||
<p>If you believe you should have access to a domain, reach out to your organization’s administrators.</p>
|
||||
|
@ -27,4 +28,5 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block wrapper %}
|
||||
<div id="wrapper" class="dashboard--portfolio">
|
||||
<div id="wrapper" class="{% block wrapper_class %}dashboard--portfolio{% endblock %}">
|
||||
{% block content %}
|
||||
|
||||
<main id="main-content" class="grid-container">
|
||||
<main class="grid-container">
|
||||
{% if user.is_authenticated %}
|
||||
{# the entire logged in page goes here #}
|
||||
|
||||
|
@ -26,10 +26,8 @@
|
|||
{% endif %}
|
||||
</main>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% endblock content%}
|
||||
<div role="complementary">{% block complementary %}{% endblock %}</div>
|
||||
|
||||
{% block content_bottom %}{% endblock %}
|
||||
</div>
|
||||
{% endblock wrapper %}
|
||||
|
|
|
@ -4,7 +4,13 @@
|
|||
|
||||
{% block title %} Domains | {% endblock %}
|
||||
|
||||
{% block wrapper_class %}
|
||||
{{ block.super }} dashboard--grey-1
|
||||
{% endblock %}
|
||||
|
||||
{% block portfolio_content %}
|
||||
<div id="main-content">
|
||||
<h1 id="domains-header">Domains</h1>
|
||||
{% include "includes/domains_table.html" with portfolio=portfolio user_domain_count=user_domain_count %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{% extends 'portfolio_base.html' %}
|
||||
{% load static field_helpers%}
|
||||
|
||||
{% block title %}Organization mailing address | {{ portfolio.name }} | {% endblock %}
|
||||
{% block title %}Organization mailing address | {{ portfolio.name }}{% endblock %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
|
@ -17,7 +17,7 @@
|
|||
{% include 'portfolio_organization_sidebar.html' %}
|
||||
</div>
|
||||
|
||||
<div class="tablet:grid-col-9">
|
||||
<div class="tablet:grid-col-9" id="main-content">
|
||||
|
||||
<h1>Organization</h1>
|
||||
|
||||
|
@ -41,7 +41,7 @@
|
|||
{% input_with_errors form.address_line2 %}
|
||||
{% input_with_errors form.city %}
|
||||
{% input_with_errors form.state_territory %}
|
||||
{% with add_class="usa-input--small" %}
|
||||
{% with add_class="usa-input--small" sublabel_text="Enter a zip code in the required format, like 12345 or 12345-6789." %}
|
||||
{% input_with_errors form.zipcode %}
|
||||
{% endwith %}
|
||||
<button type="submit" class="usa-button">
|
||||
|
|
|
@ -4,7 +4,12 @@
|
|||
|
||||
{% block title %} Domain requests | {% endblock %}
|
||||
|
||||
{% block wrapper_class %}
|
||||
{{ block.super }} dashboard--grey-1
|
||||
{% endblock %}
|
||||
|
||||
{% block portfolio_content %}
|
||||
<div id="main-content">
|
||||
<h1 id="domain-requests-header">Domain requests</h1>
|
||||
|
||||
{% comment %}
|
||||
|
@ -20,4 +25,5 @@
|
|||
</p>
|
||||
|
||||
{% include "includes/domain_requests_table.html" with portfolio=portfolio %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{% extends 'portfolio_base.html' %}
|
||||
{% load static field_helpers%}
|
||||
|
||||
{% block title %}Senior Official | {{ portfolio.name }} | {% endblock %}
|
||||
{% block title %}Senior official | {{ portfolio.name }}{% endblock %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
|
@ -17,7 +17,7 @@
|
|||
{% include 'portfolio_organization_sidebar.html' %}
|
||||
</div>
|
||||
|
||||
<div class="tablet:grid-col-9">
|
||||
<div class="tablet:grid-col-9" id="main-content">
|
||||
{% include "includes/senior_official.html" with can_edit=False %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -48,6 +48,8 @@ from registrar.models import (
|
|||
from registrar.models.portfolio_invitation import PortfolioInvitation
|
||||
from registrar.models.senior_official import SeniorOfficial
|
||||
from registrar.models.user_domain_role import UserDomainRole
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
|
||||
from registrar.models.verified_by_staff import VerifiedByStaff
|
||||
from .common import (
|
||||
MockDbForSharedTests,
|
||||
|
@ -63,7 +65,8 @@ from .common import (
|
|||
)
|
||||
from django.contrib.sessions.backends.db import SessionStore
|
||||
from django.contrib.auth import get_user_model
|
||||
from unittest.mock import patch, Mock
|
||||
from unittest.mock import ANY, patch, Mock
|
||||
from django_webtest import WebTest # type: ignore
|
||||
|
||||
import logging
|
||||
|
||||
|
@ -2084,6 +2087,7 @@ class TestPortfolioAdmin(TestCase):
|
|||
DomainRequest.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
User.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_created_on_display(self):
|
||||
|
@ -2135,3 +2139,310 @@ class TestPortfolioAdmin(TestCase):
|
|||
|
||||
domain_requests = self.admin.domain_requests(self.portfolio)
|
||||
self.assertIn("2 domain requests", domain_requests)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_portfolio_members_display(self):
|
||||
"""Tests the custom portfolio members field, admin and member sections"""
|
||||
admin_user_1 = User.objects.create(
|
||||
username="testuser1",
|
||||
first_name="Gerald",
|
||||
last_name="Meoward",
|
||||
title="Captain",
|
||||
email="meaoward@gov.gov",
|
||||
)
|
||||
|
||||
UserPortfolioPermission.objects.all().create(
|
||||
user=admin_user_1, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
admin_user_2 = User.objects.create(
|
||||
username="testuser2",
|
||||
first_name="Arnold",
|
||||
last_name="Poopy",
|
||||
title="Major",
|
||||
email="poopy@gov.gov",
|
||||
)
|
||||
|
||||
UserPortfolioPermission.objects.all().create(
|
||||
user=admin_user_2, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
admin_user_3 = User.objects.create(
|
||||
username="testuser3",
|
||||
first_name="Mad",
|
||||
last_name="Max",
|
||||
title="Road warrior",
|
||||
email="madmax@gov.gov",
|
||||
)
|
||||
|
||||
UserPortfolioPermission.objects.all().create(
|
||||
user=admin_user_3, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER]
|
||||
)
|
||||
|
||||
admin_user_4 = User.objects.create(
|
||||
username="testuser4",
|
||||
first_name="Agent",
|
||||
last_name="Smith",
|
||||
title="Program",
|
||||
email="thematrix@gov.gov",
|
||||
)
|
||||
|
||||
UserPortfolioPermission.objects.all().create(
|
||||
user=admin_user_4,
|
||||
portfolio=self.portfolio,
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
UserPortfolioPermissionChoices.EDIT_REQUESTS,
|
||||
],
|
||||
)
|
||||
|
||||
display_admins = self.admin.display_admins(self.portfolio)
|
||||
|
||||
self.assertIn(
|
||||
f'<a href="/admin/registrar/user/{admin_user_1.pk}/change/">Gerald Meoward meaoward@gov.gov</a>',
|
||||
display_admins,
|
||||
)
|
||||
self.assertIn("Captain", display_admins)
|
||||
self.assertIn(
|
||||
f'<a href="/admin/registrar/user/{admin_user_2.pk}/change/">Arnold Poopy poopy@gov.gov</a>', display_admins
|
||||
)
|
||||
self.assertIn("Major", display_admins)
|
||||
|
||||
display_members_summary = self.admin.display_members_summary(self.portfolio)
|
||||
|
||||
self.assertIn(
|
||||
f'<a href="/admin/registrar/user/{admin_user_3.pk}/change/">Mad Max madmax@gov.gov</a>',
|
||||
display_members_summary,
|
||||
)
|
||||
self.assertIn(
|
||||
f'<a href="/admin/registrar/user/{admin_user_4.pk}/change/">Agent Smith thematrix@gov.gov</a>',
|
||||
display_members_summary,
|
||||
)
|
||||
|
||||
display_members = self.admin.display_members(self.portfolio)
|
||||
|
||||
self.assertIn("Mad Max", display_members)
|
||||
self.assertIn("<span class='usa-tag'>Member</span>", display_members)
|
||||
self.assertIn("Road warrior", display_members)
|
||||
self.assertIn("Agent Smith", display_members)
|
||||
self.assertIn("<span class='usa-tag'>Domain requestor</span>", display_members)
|
||||
self.assertIn("Program", display_members)
|
||||
|
||||
|
||||
class TestTransferUser(WebTest):
|
||||
"""User transfer custom admin page"""
|
||||
|
||||
# csrf checks do not work well with WebTest.
|
||||
# We disable them here.
|
||||
csrf_checks = False
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.site = AdminSite()
|
||||
cls.superuser = create_superuser()
|
||||
cls.admin = PortfolioAdmin(model=Portfolio, admin_site=cls.site)
|
||||
cls.factory = RequestFactory()
|
||||
|
||||
def setUp(self):
|
||||
self.app.set_user(self.superuser)
|
||||
self.user1, _ = User.objects.get_or_create(
|
||||
username="madmax", first_name="Max", last_name="Rokatanski", title="Road warrior"
|
||||
)
|
||||
self.user2, _ = User.objects.get_or_create(
|
||||
username="furiosa", first_name="Furiosa", last_name="Jabassa", title="Imperator"
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
Suborganization.objects.all().delete()
|
||||
DomainInformation.objects.all().delete()
|
||||
DomainRequest.objects.all().delete()
|
||||
Domain.objects.all().delete()
|
||||
Portfolio.objects.all().delete()
|
||||
UserDomainRole.objects.all().delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_shows_current_and_selected_user_information(self):
|
||||
"""Assert we pull the current user info and display it on the transfer page"""
|
||||
completed_domain_request(user=self.user1, name="wasteland.gov")
|
||||
domain_request = completed_domain_request(
|
||||
user=self.user1, name="citadel.gov", status=DomainRequest.DomainRequestStatus.SUBMITTED
|
||||
)
|
||||
domain_request.status = DomainRequest.DomainRequestStatus.APPROVED
|
||||
domain_request.save()
|
||||
portfolio1 = Portfolio.objects.create(organization_name="Hotel California", creator=self.user2)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user1, portfolio=portfolio1, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
portfolio2 = Portfolio.objects.create(organization_name="Tokyo Hotel", creator=self.user2)
|
||||
UserPortfolioPermission.objects.create(
|
||||
user=self.user2, portfolio=portfolio2, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
|
||||
self.assertContains(user_transfer_page, "madmax")
|
||||
self.assertContains(user_transfer_page, "Max")
|
||||
self.assertContains(user_transfer_page, "Rokatanski")
|
||||
self.assertContains(user_transfer_page, "Road warrior")
|
||||
self.assertContains(user_transfer_page, "wasteland.gov")
|
||||
self.assertContains(user_transfer_page, "citadel.gov")
|
||||
self.assertContains(user_transfer_page, "Hotel California")
|
||||
|
||||
select_form = user_transfer_page.forms[0]
|
||||
select_form["selected_user"] = str(self.user2.id)
|
||||
preview_result = select_form.submit()
|
||||
|
||||
self.assertContains(preview_result, "furiosa")
|
||||
self.assertContains(preview_result, "Furiosa")
|
||||
self.assertContains(preview_result, "Jabassa")
|
||||
self.assertContains(preview_result, "Imperator")
|
||||
self.assertContains(preview_result, "Tokyo Hotel")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_transfers_user_portfolio_roles(self):
|
||||
"""Assert that a portfolio user role gets transferred"""
|
||||
portfolio = Portfolio.objects.create(organization_name="Hotel California", creator=self.user2)
|
||||
user_portfolio_permission = UserPortfolioPermission.objects.create(
|
||||
user=self.user2, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
|
||||
)
|
||||
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
|
||||
submit_form = user_transfer_page.forms[1]
|
||||
submit_form["selected_user"] = self.user2.pk
|
||||
submit_form.submit()
|
||||
|
||||
user_portfolio_permission.refresh_from_db()
|
||||
|
||||
self.assertEquals(user_portfolio_permission.user, self.user1)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_transfers_domain_request_creator_and_investigator(self):
|
||||
"""Assert that domain request fields get transferred"""
|
||||
domain_request = completed_domain_request(user=self.user2, name="wasteland.gov", investigator=self.user2)
|
||||
|
||||
self.assertEquals(domain_request.creator, self.user2)
|
||||
self.assertEquals(domain_request.investigator, self.user2)
|
||||
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
submit_form = user_transfer_page.forms[1]
|
||||
submit_form["selected_user"] = self.user2.pk
|
||||
submit_form.submit()
|
||||
domain_request.refresh_from_db()
|
||||
|
||||
self.assertEquals(domain_request.creator, self.user1)
|
||||
self.assertEquals(domain_request.investigator, self.user1)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_transfers_domain_information_creator(self):
|
||||
"""Assert that domain fields get transferred"""
|
||||
domain_information, _ = DomainInformation.objects.get_or_create(creator=self.user2)
|
||||
|
||||
self.assertEquals(domain_information.creator, self.user2)
|
||||
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
submit_form = user_transfer_page.forms[1]
|
||||
submit_form["selected_user"] = self.user2.pk
|
||||
submit_form.submit()
|
||||
domain_information.refresh_from_db()
|
||||
|
||||
self.assertEquals(domain_information.creator, self.user1)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_transfers_domain_role(self):
|
||||
"""Assert that user domain role get transferred"""
|
||||
domain_1, _ = Domain.objects.get_or_create(name="chrome.gov", state=Domain.State.READY)
|
||||
domain_2, _ = Domain.objects.get_or_create(name="v8.gov", state=Domain.State.READY)
|
||||
user_domain_role1, _ = UserDomainRole.objects.get_or_create(
|
||||
user=self.user2, domain=domain_1, role=UserDomainRole.Roles.MANAGER
|
||||
)
|
||||
user_domain_role2, _ = UserDomainRole.objects.get_or_create(
|
||||
user=self.user2, domain=domain_2, role=UserDomainRole.Roles.MANAGER
|
||||
)
|
||||
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
submit_form = user_transfer_page.forms[1]
|
||||
submit_form["selected_user"] = self.user2.pk
|
||||
submit_form.submit()
|
||||
user_domain_role1.refresh_from_db()
|
||||
user_domain_role2.refresh_from_db()
|
||||
|
||||
self.assertEquals(user_domain_role1.user, self.user1)
|
||||
self.assertEquals(user_domain_role2.user, self.user1)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_transfers_verified_by_staff_requestor(self):
|
||||
"""Assert that verified by staff creator gets transferred"""
|
||||
vip, _ = VerifiedByStaff.objects.get_or_create(requestor=self.user2, email="immortan.joe@citadel.com")
|
||||
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
submit_form = user_transfer_page.forms[1]
|
||||
submit_form["selected_user"] = self.user2.pk
|
||||
submit_form.submit()
|
||||
vip.refresh_from_db()
|
||||
|
||||
self.assertEquals(vip.requestor, self.user1)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_deletes_old_user(self):
|
||||
"""Assert that the slected user gets deleted"""
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
submit_form = user_transfer_page.forms[1]
|
||||
submit_form["selected_user"] = self.user2.pk
|
||||
submit_form.submit()
|
||||
# Refresh user2 from the database and check if it still exists
|
||||
with self.assertRaises(User.DoesNotExist):
|
||||
self.user2.refresh_from_db()
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_throws_transfer_and_delete_success_messages(self):
|
||||
"""Test that success messages for data transfer and user deletion are displayed."""
|
||||
# Ensure the setup for VerifiedByStaff
|
||||
VerifiedByStaff.objects.get_or_create(requestor=self.user2, email="immortan.joe@citadel.com")
|
||||
|
||||
# Access the transfer user page
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
|
||||
with patch("django.contrib.messages.success") as mock_success_message:
|
||||
|
||||
# Fill the form with the selected user and submit
|
||||
submit_form = user_transfer_page.forms[1]
|
||||
submit_form["selected_user"] = self.user2.pk
|
||||
after_submit = submit_form.submit().follow()
|
||||
|
||||
self.assertContains(after_submit, "<h1>Change user</h1>")
|
||||
|
||||
mock_success_message.assert_any_call(
|
||||
ANY,
|
||||
(
|
||||
"Data transferred successfully for the following objects: ['Changed requestor "
|
||||
+ 'from "Furiosa Jabassa " to "Max Rokatanski " on immortan.joe@citadel.com\']'
|
||||
),
|
||||
)
|
||||
|
||||
mock_success_message.assert_any_call(ANY, f"Deleted {self.user2} {self.user2.username}")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_throws_error_message(self):
|
||||
"""Test that an error message is thrown if the transfer fails."""
|
||||
with patch(
|
||||
"registrar.views.TransferUserView.transfer_user_fields_and_log", side_effect=Exception("Simulated Error")
|
||||
):
|
||||
with patch("django.contrib.messages.error") as mock_error:
|
||||
# Access the transfer user page
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
|
||||
# Fill the form with the selected user and submit
|
||||
submit_form = user_transfer_page.forms[1]
|
||||
submit_form["selected_user"] = self.user2.pk
|
||||
submit_form.submit().follow()
|
||||
|
||||
# Assert that the error message was called with the correct argument
|
||||
mock_error.assert_called_once_with(ANY, "An error occurred during the transfer: Simulated Error")
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_transfer_user_modal(self):
|
||||
"""Assert modal on page"""
|
||||
user_transfer_page = self.app.get(reverse("transfer_user", args=[self.user1.pk]))
|
||||
self.assertContains(user_transfer_page, "This action cannot be undone.")
|
||||
|
|
|
@ -1311,6 +1311,7 @@ class TestUser(TestCase):
|
|||
self.domain, _ = Domain.objects.get_or_create(name="igorville.gov")
|
||||
self.user, _ = User.objects.get_or_create(email=self.email)
|
||||
self.factory = RequestFactory()
|
||||
self.portfolio = Portfolio.objects.create(organization_name="Test Portfolio", creator=self.user)
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
|
@ -1325,6 +1326,65 @@ class TestUser(TestCase):
|
|||
User.objects.all().delete()
|
||||
UserDomainRole.objects.all().delete()
|
||||
|
||||
@patch.object(User, "has_edit_suborganization", return_value=True)
|
||||
def test_portfolio_role_summary_admin(self, mock_edit_suborganization):
|
||||
# Test if the user is recognized as an Admin
|
||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Admin"])
|
||||
|
||||
@patch.multiple(
|
||||
User,
|
||||
has_view_all_domains_permission=lambda self, portfolio: True,
|
||||
has_domain_requests_portfolio_permission=lambda self, portfolio: True,
|
||||
has_edit_requests=lambda self, portfolio: True,
|
||||
)
|
||||
def test_portfolio_role_summary_view_only_admin_and_domain_requestor(self):
|
||||
# Test if the user has both 'View-only admin' and 'Domain requestor' roles
|
||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["View-only admin", "Domain requestor"])
|
||||
|
||||
@patch.multiple(
|
||||
User,
|
||||
has_view_all_domains_permission=lambda self, portfolio: True,
|
||||
has_domain_requests_portfolio_permission=lambda self, portfolio: True,
|
||||
)
|
||||
def test_portfolio_role_summary_view_only_admin(self):
|
||||
# Test if the user is recognized as a View-only admin
|
||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["View-only admin"])
|
||||
|
||||
@patch.multiple(
|
||||
User,
|
||||
has_base_portfolio_permission=lambda self, portfolio: True,
|
||||
has_edit_requests=lambda self, portfolio: True,
|
||||
has_domains_portfolio_permission=lambda self, portfolio: True,
|
||||
)
|
||||
def test_portfolio_role_summary_member_domain_requestor_domain_manager(self):
|
||||
# Test if the user has 'Member', 'Domain requestor', and 'Domain manager' roles
|
||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor", "Domain manager"])
|
||||
|
||||
@patch.multiple(
|
||||
User, has_base_portfolio_permission=lambda self, portfolio: True, has_edit_requests=lambda self, portfolio: True
|
||||
)
|
||||
def test_portfolio_role_summary_member_domain_requestor(self):
|
||||
# Test if the user has 'Member' and 'Domain requestor' roles
|
||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain requestor"])
|
||||
|
||||
@patch.multiple(
|
||||
User,
|
||||
has_base_portfolio_permission=lambda self, portfolio: True,
|
||||
has_domains_portfolio_permission=lambda self, portfolio: True,
|
||||
)
|
||||
def test_portfolio_role_summary_member_domain_manager(self):
|
||||
# Test if the user has 'Member' and 'Domain manager' roles
|
||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Domain manager"])
|
||||
|
||||
@patch.multiple(User, has_base_portfolio_permission=lambda self, portfolio: True)
|
||||
def test_portfolio_role_summary_member(self):
|
||||
# Test if the user is recognized as a Member
|
||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), ["Member"])
|
||||
|
||||
def test_portfolio_role_summary_empty(self):
|
||||
# Test if the user has no roles
|
||||
self.assertEqual(self.user.portfolio_role_summary(self.portfolio), [])
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_check_transition_domains_without_domains_on_login(self):
|
||||
"""A user's on_each_login callback does not check transition domains.
|
||||
|
@ -1474,6 +1534,7 @@ class TestUser(TestCase):
|
|||
self.assertFalse(self.user.has_contact_info())
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_requests", active=True)
|
||||
def test_has_portfolio_permission(self):
|
||||
"""
|
||||
0. Returns False when user does not have a permission
|
||||
|
@ -1495,7 +1556,10 @@ class TestUser(TestCase):
|
|||
portfolio_permission, _ = UserPortfolioPermission.objects.get_or_create(
|
||||
portfolio=portfolio,
|
||||
user=self.user,
|
||||
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS],
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
|
||||
],
|
||||
)
|
||||
|
||||
user_can_view_all_domains = self.user.has_domains_portfolio_permission(portfolio)
|
||||
|
|
|
@ -25,6 +25,7 @@ SAMPLE_KWARGS = {
|
|||
"domain": "whitehouse.gov",
|
||||
"user_pk": "1",
|
||||
"portfolio_id": "1",
|
||||
"user_id": "1",
|
||||
}
|
||||
|
||||
# Our test suite will ignore some namespaces.
|
||||
|
|
|
@ -1153,7 +1153,7 @@ class TestDomainSeniorOfficial(TestDomainOverview):
|
|||
def test_domain_senior_official(self):
|
||||
"""Can load domain's senior official page."""
|
||||
page = self.client.get(reverse("domain-senior-official", kwargs={"pk": self.domain.id}))
|
||||
self.assertContains(page, "Senior official", count=3)
|
||||
self.assertContains(page, "Senior official", count=4)
|
||||
|
||||
@less_console_noise_decorator
|
||||
def test_domain_senior_official_content(self):
|
||||
|
|
|
@ -230,6 +230,7 @@ class TestPortfolio(WebTest):
|
|||
self.assertContains(response, 'for="id_city"')
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_requests", active=True)
|
||||
def test_accessible_pages_when_user_does_not_have_permission(self):
|
||||
"""Tests which pages are accessible when user does not have portfolio permissions"""
|
||||
self.app.set_user(self.user.username)
|
||||
|
@ -280,6 +281,7 @@ class TestPortfolio(WebTest):
|
|||
self.assertEquals(domain_request_page.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_requests", active=True)
|
||||
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)
|
||||
|
@ -532,3 +534,99 @@ class TestPortfolio(WebTest):
|
|||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Domain name")
|
||||
permission.delete()
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_requests", active=False)
|
||||
def test_organization_requests_waffle_flag_off_hides_nav_link_and_restricts_permission(self):
|
||||
"""Setting the organization_requests waffle off hides the nav link and restricts access to the requests page"""
|
||||
self.app.set_user(self.user.username)
|
||||
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS,
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
||||
UserPortfolioPermissionChoices.EDIT_REQUESTS,
|
||||
],
|
||||
)
|
||||
|
||||
home = self.app.get(reverse("home")).follow()
|
||||
|
||||
self.assertContains(home, "Hotel California")
|
||||
self.assertNotContains(home, "Domain requests")
|
||||
|
||||
domain_requests = self.app.get(reverse("domain-requests"), expect_errors=True)
|
||||
self.assertEqual(domain_requests.status_code, 403)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_requests", active=True)
|
||||
def test_organization_requests_waffle_flag_on_shows_nav_link_and_allows_permission(self):
|
||||
"""Setting the organization_requests waffle on shows the nav link and allows access to the requests page"""
|
||||
self.app.set_user(self.user.username)
|
||||
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS,
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
||||
UserPortfolioPermissionChoices.EDIT_REQUESTS,
|
||||
],
|
||||
)
|
||||
|
||||
home = self.app.get(reverse("home")).follow()
|
||||
|
||||
self.assertContains(home, "Hotel California")
|
||||
self.assertContains(home, "Domain requests")
|
||||
|
||||
domain_requests = self.app.get(reverse("domain-requests"))
|
||||
self.assertEqual(domain_requests.status_code, 200)
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=False)
|
||||
def test_organization_members_waffle_flag_off_hides_nav_link(self):
|
||||
"""Setting the organization_members waffle off hides the nav link"""
|
||||
self.app.set_user(self.user.username)
|
||||
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS,
|
||||
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
|
||||
UserPortfolioPermissionChoices.EDIT_REQUESTS,
|
||||
],
|
||||
)
|
||||
|
||||
home = self.app.get(reverse("home")).follow()
|
||||
|
||||
self.assertContains(home, "Hotel California")
|
||||
self.assertNotContains(home, "Members")
|
||||
|
||||
@less_console_noise_decorator
|
||||
@override_flag("organization_feature", active=True)
|
||||
@override_flag("organization_members", active=True)
|
||||
def test_organization_members_waffle_flag_on_shows_nav_link(self):
|
||||
"""Setting the organization_members waffle on shows the nav link"""
|
||||
self.app.set_user(self.user.username)
|
||||
|
||||
UserPortfolioPermission.objects.get_or_create(
|
||||
user=self.user,
|
||||
portfolio=self.portfolio,
|
||||
additional_permissions=[
|
||||
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
|
||||
UserPortfolioPermissionChoices.VIEW_MEMBERS,
|
||||
],
|
||||
)
|
||||
|
||||
home = self.app.get(reverse("home")).follow()
|
||||
|
||||
self.assertContains(home, "Hotel California")
|
||||
self.assertContains(home, "Members")
|
||||
|
|
|
@ -19,3 +19,4 @@ from .user_profile import UserProfileView, FinishProfileSetupView
|
|||
from .health import *
|
||||
from .index import *
|
||||
from .portfolios import *
|
||||
from .transfer_user import TransferUserView
|
||||
|
|
|
@ -133,5 +133,5 @@ def serialize_domain(domain, user):
|
|||
"action_url": reverse("domain", kwargs={"pk": domain.id}),
|
||||
"action_label": ("View" if view_only else "Manage"),
|
||||
"svg_icon": ("visibility" if view_only else "settings"),
|
||||
"suborganization": suborganization_name,
|
||||
"domain_info__sub_organization": suborganization_name,
|
||||
}
|
||||
|
|
172
src/registrar/views/transfer_user.py
Normal file
172
src/registrar/views/transfer_user.py
Normal file
|
@ -0,0 +1,172 @@
|
|||
import logging
|
||||
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.views import View
|
||||
from registrar.models.domain import Domain
|
||||
from registrar.models.domain_information import DomainInformation
|
||||
from registrar.models.domain_request import DomainRequest
|
||||
from registrar.models.portfolio import Portfolio
|
||||
from registrar.models.user import User
|
||||
from django.contrib.admin import site
|
||||
from django.contrib import messages
|
||||
|
||||
from registrar.models.user_domain_role import UserDomainRole
|
||||
from registrar.models.user_portfolio_permission import UserPortfolioPermission
|
||||
from registrar.models.verified_by_staff import VerifiedByStaff
|
||||
from typing import Any, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TransferUserView(View):
|
||||
"""Transfer user methods that set up the transfer_user template and handle the forms on it."""
|
||||
|
||||
JOINS = [
|
||||
(DomainRequest, "creator"),
|
||||
(DomainInformation, "creator"),
|
||||
(Portfolio, "creator"),
|
||||
(DomainRequest, "investigator"),
|
||||
(UserDomainRole, "user"),
|
||||
(VerifiedByStaff, "requestor"),
|
||||
(UserPortfolioPermission, "user"),
|
||||
]
|
||||
|
||||
# Future-proofing in case joined fields get added on the user model side
|
||||
# This was tested in the first portfolio model iteration and works
|
||||
USER_FIELDS: List[Any] = []
|
||||
|
||||
def get(self, request, user_id):
|
||||
"""current_user referes to the 'source' user where the button that redirects to this view was clicked.
|
||||
other_users exclude current_user and populate a dropdown, selected_user is the selection in the dropdown.
|
||||
|
||||
This also querries the relevant domains and domain requests, and the admin context needed for the sidenav."""
|
||||
|
||||
current_user = get_object_or_404(User, pk=user_id)
|
||||
other_users = User.objects.exclude(pk=user_id).order_by(
|
||||
"first_name", "last_name"
|
||||
) # Exclude the current user from the dropdown
|
||||
|
||||
# Get the default admin site context, needed for the sidenav
|
||||
admin_context = site.each_context(request)
|
||||
|
||||
context = {
|
||||
"current_user": current_user,
|
||||
"other_users": other_users,
|
||||
"logged_in_user": request.user,
|
||||
**admin_context, # Include the admin context
|
||||
"current_user_domains": self.get_domains(current_user),
|
||||
"current_user_domain_requests": self.get_domain_requests(current_user),
|
||||
"current_user_portfolios": self.get_portfolios(current_user),
|
||||
}
|
||||
|
||||
selected_user_id = request.GET.get("selected_user")
|
||||
if selected_user_id:
|
||||
selected_user = get_object_or_404(User, pk=selected_user_id)
|
||||
context["selected_user"] = selected_user
|
||||
context["selected_user_domains"] = self.get_domains(selected_user)
|
||||
context["selected_user_domain_requests"] = self.get_domain_requests(selected_user)
|
||||
context["selected_user_portfolios"] = self.get_portfolios(selected_user)
|
||||
|
||||
return render(request, "admin/transfer_user.html", context)
|
||||
|
||||
def post(self, request, user_id):
|
||||
"""This handles the transfer from selected_user to current_user then deletes selected_user.
|
||||
|
||||
NOTE: We have a ticket to refactor this into a more solid lookup for related fields in #2645"""
|
||||
|
||||
current_user = get_object_or_404(User, pk=user_id)
|
||||
selected_user_id = request.POST.get("selected_user")
|
||||
selected_user = get_object_or_404(User, pk=selected_user_id)
|
||||
|
||||
try:
|
||||
change_logs = []
|
||||
|
||||
# Transfer specific fields
|
||||
self.transfer_user_fields_and_log(selected_user, current_user, change_logs)
|
||||
|
||||
# Perform the updates and log the changes
|
||||
for model_class, field_name in self.JOINS:
|
||||
self.update_joins_and_log(model_class, field_name, selected_user, current_user, change_logs)
|
||||
|
||||
# Success message if any related objects were updated
|
||||
if change_logs:
|
||||
success_message = f"Data transferred successfully for the following objects: {change_logs}"
|
||||
messages.success(request, success_message)
|
||||
|
||||
selected_user.delete()
|
||||
messages.success(request, f"Deleted {selected_user} {selected_user.username}")
|
||||
|
||||
except Exception as e:
|
||||
messages.error(request, f"An error occurred during the transfer: {e}")
|
||||
|
||||
return redirect("admin:registrar_user_change", object_id=user_id)
|
||||
|
||||
@classmethod
|
||||
def update_joins_and_log(cls, model_class, field_name, selected_user, current_user, change_logs):
|
||||
"""
|
||||
Helper function to update the user join fields for a given model and log the changes.
|
||||
"""
|
||||
|
||||
filter_kwargs = {field_name: selected_user}
|
||||
updated_objects = model_class.objects.filter(**filter_kwargs)
|
||||
|
||||
for obj in updated_objects:
|
||||
# Check for duplicate UserDomainRole before updating
|
||||
if model_class == UserDomainRole:
|
||||
if model_class.objects.filter(user=current_user, domain=obj.domain).exists():
|
||||
continue # Skip the update to avoid a duplicate
|
||||
|
||||
# Update the field on the object and save it
|
||||
setattr(obj, field_name, current_user)
|
||||
obj.save()
|
||||
|
||||
# Log the change
|
||||
cls.log_change(obj, field_name, selected_user, current_user, change_logs)
|
||||
|
||||
@classmethod
|
||||
def transfer_user_fields_and_log(cls, selected_user, current_user, change_logs):
|
||||
"""
|
||||
Transfers portfolio fields from the selected_user to the current_user.
|
||||
Logs the changes for each transferred field.
|
||||
"""
|
||||
for field in cls.USER_FIELDS:
|
||||
field_value = getattr(selected_user, field, None)
|
||||
|
||||
if field_value:
|
||||
setattr(current_user, field, field_value)
|
||||
cls.log_change(current_user, field, field_value, field_value, change_logs)
|
||||
|
||||
current_user.save()
|
||||
|
||||
@classmethod
|
||||
def log_change(cls, obj, field_name, field_value, new_value, change_logs):
|
||||
"""Logs the change for a specific field on an object"""
|
||||
log_entry = f'Changed {field_name} from "{field_value}" to "{new_value}" on {obj}'
|
||||
|
||||
logger.info(log_entry)
|
||||
|
||||
# Collect the related object for the success message
|
||||
change_logs.append(log_entry)
|
||||
|
||||
@classmethod
|
||||
def get_domains(cls, user):
|
||||
"""A simplified version of domains_json"""
|
||||
user_domain_roles = UserDomainRole.objects.filter(user=user)
|
||||
domain_ids = user_domain_roles.values_list("domain_id", flat=True)
|
||||
domains = Domain.objects.filter(id__in=domain_ids)
|
||||
|
||||
return domains
|
||||
|
||||
@classmethod
|
||||
def get_domain_requests(cls, user):
|
||||
"""A simplified version of domain_requests_json"""
|
||||
domain_requests = DomainRequest.objects.filter(creator=user)
|
||||
|
||||
return domain_requests
|
||||
|
||||
@classmethod
|
||||
def get_portfolios(cls, user):
|
||||
"""Get portfolios"""
|
||||
portfolios = UserPortfolioPermission.objects.filter(user=user)
|
||||
|
||||
return portfolios
|
|
@ -7,5 +7,6 @@ from .permission_views import (
|
|||
DomainRequestPermissionWithdrawView,
|
||||
DomainInvitationPermissionDeleteView,
|
||||
DomainRequestWizardPermissionView,
|
||||
PortfolioMembersPermission,
|
||||
)
|
||||
from .api_views import get_senior_official_from_federal_agency_json
|
||||
|
|
|
@ -454,3 +454,20 @@ class PortfolioDomainRequestsPermission(PortfolioBasePermission):
|
|||
return False
|
||||
|
||||
return super().has_permission()
|
||||
|
||||
|
||||
class PortfolioMembersPermission(PortfolioBasePermission):
|
||||
"""Permission mixin that allows access to portfolio members 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):
|
||||
return False
|
||||
|
||||
return super().has_permission()
|
||||
|
|
|
@ -18,6 +18,7 @@ from .mixins import (
|
|||
UserDeleteDomainRolePermission,
|
||||
UserProfilePermission,
|
||||
PortfolioBasePermission,
|
||||
PortfolioMembersPermission,
|
||||
)
|
||||
import logging
|
||||
|
||||
|
@ -229,3 +230,11 @@ class PortfolioDomainRequestsPermissionView(PortfolioDomainRequestsPermission, P
|
|||
This abstract view cannot be instantiated. Actual views must specify
|
||||
`template_name`.
|
||||
"""
|
||||
|
||||
|
||||
class PortfolioMembersPermissionView(PortfolioMembersPermission, PortfolioBasePermissionView, abc.ABC):
|
||||
"""Abstract base view for portfolio domain request views that enforces permissions.
|
||||
|
||||
This abstract view cannot be instantiated. Actual views must specify
|
||||
`template_name`.
|
||||
"""
|
||||
|
|
|
@ -72,6 +72,7 @@
|
|||
10038 OUTOFSCOPE http://app:8080/domains/
|
||||
10038 OUTOFSCOPE http://app:8080/organization/
|
||||
10038 OUTOFSCOPE http://app:8080/suborganization/
|
||||
10038 OUTOFSCOPE http://app:8080/transfer/
|
||||
# This URL always returns 404, so include it as well.
|
||||
10038 OUTOFSCOPE http://app:8080/todo
|
||||
# OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue