Merge branch 'main' into ag/2616-populate-suborg-and-portfolio-script

This commit is contained in:
zandercymatics 2024-09-10 14:25:59 -06:00
commit fbabd2029c
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
38 changed files with 1301 additions and 147 deletions

View file

@ -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

View file

@ -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

View 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);

View file

@ -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() {

View file

@ -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;
}

View file

@ -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"]

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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}

View file

@ -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)}
""",

View file

@ -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)}

View 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)

View file

@ -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}"""

View file

@ -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,
),
),
]

View file

@ -6,7 +6,7 @@ from django.db.models import Q
from django.http import HttpRequest
from registrar.models import DomainInformation, UserDomainRole
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices
from .domain_invitation import DomainInvitation
from .portfolio_invitation import PortfolioInvitation
@ -64,32 +64,6 @@ class User(AbstractUser):
# after they login.
FIXTURE_USER = "fixture_user", "Created by fixtures"
PORTFOLIO_ROLE_PERMISSIONS = {
UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
UserPortfolioPermissionChoices.VIEW_MEMBER,
UserPortfolioPermissionChoices.EDIT_MEMBER,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.EDIT_REQUESTS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
UserPortfolioPermissionChoices.EDIT_PORTFOLIO,
# Domain: field specific permissions
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION,
],
UserPortfolioRoleChoices.ORGANIZATION_ADMIN_READ_ONLY: [
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS,
UserPortfolioPermissionChoices.VIEW_MEMBER,
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS,
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
# Domain: field specific permissions
UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION,
],
UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [
UserPortfolioPermissionChoices.VIEW_PORTFOLIO,
],
}
# #### Constants for choice fields ####
RESTRICTED = "restricted"
STATUS_CHOICES = ((RESTRICTED, RESTRICTED),)
@ -230,10 +204,40 @@ class User(AbstractUser):
) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)
def has_domain_requests_portfolio_permission(self, portfolio):
# BEGIN
# Note code below is to add organization_request feature
request = HttpRequest()
request.user = self
has_organization_requests_flag = flag_is_active(request, "organization_requests")
if not has_organization_requests_flag:
return False
# END
return self._has_portfolio_permission(
portfolio, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS
) or self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS)
def has_view_members_portfolio_permission(self, portfolio):
# BEGIN
# Note code below is to add organization_request feature
request = HttpRequest()
request.user = self
has_organization_members_flag = flag_is_active(request, "organization_members")
if not has_organization_members_flag:
return False
# END
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_MEMBERS)
def has_edit_members_portfolio_permission(self, portfolio):
# BEGIN
# Note code below is to add organization_request feature
request = HttpRequest()
request.user = self
has_organization_members_flag = flag_is_active(request, "organization_members")
if not has_organization_members_flag:
return False
# END
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_MEMBERS)
def has_view_all_domains_permission(self, portfolio):
"""Determines if the current user can view all available domains in a given portfolio"""
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)

View file

@ -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

View file

@ -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"

View file

@ -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>

View 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>
&rsaquo; <a href="{% url 'admin:app_list' 'registrar' %}">{% trans 'Registrar' %}</a>
&rsaquo; <a href="{% url 'admin:registrar_user_changelist' %}">{% trans 'Users' %}</a>
&rsaquo; <a href="{% url 'admin:registrar_user_change' current_user.pk %}">{{ current_user.first_name }} {{ current_user.last_name }}</a>
&rsaquo; {% 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:">&nbsp;</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 %}

View file

@ -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 %}
<div class="module aligned padding-3">
<h2>Associated requests and domains</h2>

View file

@ -46,11 +46,11 @@
Domains
</a>
</li>
<li class="usa-nav__primary-item">
<!-- <li class="usa-nav__primary-item">
<a href="#" class="usa-nav-link">
Domain groups
</a>
</li>
</li> -->
{% if has_domain_requests_portfolio_permission %}
<li class="usa-nav__primary-item">
@ -60,11 +60,13 @@
</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 -->

View file

@ -62,7 +62,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
@ -2208,3 +2209,222 @@ class TestPortfolioAdmin(TestCase):
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.")

View file

@ -1534,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
@ -1555,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)

View file

@ -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.

View file

@ -1,9 +1,13 @@
from registrar.models import UserDomainRole, Domain, DomainInformation, Portfolio
from django.urls import reverse
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from .test_views import TestWithUser
from django_webtest import WebTest # type: ignore
from django.utils.dateparse import parse_date
from api.tests.common import less_console_noise_decorator
from waffle.testutils import override_flag
class GetDomainsJsonTest(TestWithUser, WebTest):
@ -31,6 +35,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
def tearDown(self):
UserDomainRole.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
DomainInformation.objects.all().delete()
Portfolio.objects.all().delete()
super().tearDown()
@ -115,8 +120,104 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
self.assertEqual(svg_icon_expected, svg_icons[i])
@less_console_noise_decorator
def test_get_domains_json_with_portfolio(self):
"""Test that an authenticated user gets the list of 2 domains for portfolio."""
@override_flag("organization_feature", active=True)
def test_get_domains_json_with_portfolio_view_managed_domains(self):
"""Test that an authenticated user gets the list of 1 domain for portfolio. The 1 domain
is the domain that they manage within the portfolio."""
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS],
)
response = self.app.get(reverse("get_domains_json"), {"portfolio": self.portfolio.id})
self.assertEqual(response.status_code, 200)
data = response.json
# Check pagination info
self.assertEqual(data["page"], 1)
self.assertFalse(data["has_next"])
self.assertFalse(data["has_previous"])
self.assertEqual(data["num_pages"], 1)
# Check the number of domains
self.assertEqual(len(data["domains"]), 1)
# Expected domains
expected_domains = [self.domain3]
# Extract fields from response
domain_ids = [domain["id"] for domain in data["domains"]]
names = [domain["name"] for domain in data["domains"]]
expiration_dates = [domain["expiration_date"] for domain in data["domains"]]
states = [domain["state"] for domain in data["domains"]]
state_displays = [domain["state_display"] for domain in data["domains"]]
get_state_help_texts = [domain["get_state_help_text"] for domain in data["domains"]]
action_urls = [domain["action_url"] for domain in data["domains"]]
action_labels = [domain["action_label"] for domain in data["domains"]]
svg_icons = [domain["svg_icon"] for domain in data["domains"]]
# Check fields for each domain
for i, expected_domain in enumerate(expected_domains):
self.assertEqual(expected_domain.id, domain_ids[i])
self.assertEqual(expected_domain.name, names[i])
self.assertEqual(expected_domain.expiration_date, expiration_dates[i])
self.assertEqual(expected_domain.state, states[i])
# Parsing the expiration date from string to date
parsed_expiration_date = parse_date(expiration_dates[i])
expected_domain.expiration_date = parsed_expiration_date
# Check state_display and get_state_help_text
self.assertEqual(expected_domain.state_display(), state_displays[i])
self.assertEqual(expected_domain.get_state_help_text(), get_state_help_texts[i])
self.assertEqual(reverse("domain", kwargs={"pk": expected_domain.id}), action_urls[i])
# Check action_label
user_domain_role_exists = UserDomainRole.objects.filter(
domain_id=expected_domains[i].id, user=self.user
).exists()
action_label_expected = (
"View"
if not user_domain_role_exists
or expected_domains[i].state
in [
Domain.State.DELETED,
Domain.State.ON_HOLD,
]
else "Manage"
)
self.assertEqual(action_label_expected, action_labels[i])
# Check svg_icon
svg_icon_expected = (
"visibility"
if not user_domain_role_exists
or expected_domains[i].state
in [
Domain.State.DELETED,
Domain.State.ON_HOLD,
]
else "settings"
)
self.assertEqual(svg_icon_expected, svg_icons[i])
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_get_domains_json_with_portfolio_view_all_domains(self):
"""Test that an authenticated user gets the list of 2 domains for portfolio. One is a domain which
they manage within the portfolio. The other is a domain which they don't manage within the
portfolio."""
UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS],
)
response = self.app.get(reverse("get_domains_json"), {"portfolio": self.portfolio.id})
self.assertEqual(response.status_code, 200)

View file

@ -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")

View file

@ -19,3 +19,4 @@ from .user_profile import UserProfileView, FinishProfileSetupView
from .health import *
from .index import *
from .portfolios import *
from .transfer_user import TransferUserView

View file

@ -50,9 +50,13 @@ def get_domain_ids_from_request(request):
"""
portfolio = request.GET.get("portfolio")
if portfolio:
if request.user.is_org_user(request) and request.user.has_view_all_domains_permission(portfolio):
domain_infos = DomainInformation.objects.filter(portfolio=portfolio)
return domain_infos.values_list("domain_id", flat=True)
else:
domain_info_ids = DomainInformation.objects.filter(portfolio=portfolio).values_list("domain_id", flat=True)
user_domain_roles = UserDomainRole.objects.filter(user=request.user).values_list("domain_id", flat=True)
return domain_info_ids.intersection(user_domain_roles)
user_domain_roles = UserDomainRole.objects.filter(user=request.user)
return user_domain_roles.values_list("domain_id", flat=True)

View 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

View file

@ -7,5 +7,6 @@ from .permission_views import (
DomainRequestPermissionWithdrawView,
DomainInvitationPermissionDeleteView,
DomainRequestWizardPermissionView,
PortfolioMembersPermission,
)
from .api_views import get_senior_official_from_federal_agency_json

View file

@ -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()

View file

@ -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`.
"""

View file

@ -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