diff --git a/docs/developer/management_script_helpers.md b/docs/developer/management_script_helpers.md index 104e4dc13..a43bb16aa 100644 --- a/docs/developer/management_script_helpers.md +++ b/docs/developer/management_script_helpers.md @@ -62,4 +62,5 @@ The class provides the following optional configuration variables: 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) \ No newline at end of file +- `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 \ No newline at end of file diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md index c261e7a06..6c42cb727 100644 --- a/docs/operations/data_migration.md +++ b/docs/operations/data_migration.md @@ -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 diff --git a/src/registrar/assets/js/get-gov-admin-extra.js b/src/registrar/assets/js/get-gov-admin-extra.js new file mode 100644 index 000000000..14059267b --- /dev/null +++ b/src/registrar/assets/js/get-gov-admin-extra.js @@ -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); diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index b24e946dc..27ff1470b 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -172,40 +172,39 @@ function addOrRemoveSessionBoolean(name, add){ ** To perform data operations on this - we need to use jQuery rather than vanilla js. */ (function (){ - let selector = django.jQuery("#id_investigator") - let assignSelfButton = document.querySelector("#investigator__assign_self"); - if (!selector || !assignSelfButton) { - return; - } - - let currentUserId = assignSelfButton.getAttribute("data-user-id"); - let currentUserName = assignSelfButton.getAttribute("data-user-name"); - if (!currentUserId || !currentUserName){ - console.error("Could not assign current user: no values found.") - return; - } - - // Hook a click listener to the "Assign to me" button. - // Logic borrowed from here: https://select2.org/programmatic-control/add-select-clear-items#create-if-not-exists - assignSelfButton.addEventListener("click", function() { - if (selector.find(`option[value='${currentUserId}']`).length) { - // Select the value that is associated with the current user. - selector.val(currentUserId).trigger("change"); - } else { - // Create a DOM Option that matches the desired user. Then append it and select it. - let userOption = new Option(currentUserName, currentUserId, true, true); - selector.append(userOption).trigger("change"); + if (document.getElementById("id_investigator") && django && django.jQuery) { + let selector = django.jQuery("#id_investigator") + let assignSelfButton = document.querySelector("#investigator__assign_self"); + if (!selector || !assignSelfButton) { + return; } - }); - // Listen to any change events, and hide the parent container if investigator has a value. - selector.on('change', function() { - // The parent container has display type flex. - assignSelfButton.parentElement.style.display = this.value === currentUserId ? "none" : "flex"; - }); - - + let currentUserId = assignSelfButton.getAttribute("data-user-id"); + let currentUserName = assignSelfButton.getAttribute("data-user-name"); + if (!currentUserId || !currentUserName){ + console.error("Could not assign current user: no values found.") + return; + } + // Hook a click listener to the "Assign to me" button. + // Logic borrowed from here: https://select2.org/programmatic-control/add-select-clear-items#create-if-not-exists + assignSelfButton.addEventListener("click", function() { + if (selector.find(`option[value='${currentUserId}']`).length) { + // Select the value that is associated with the current user. + selector.val(currentUserId).trigger("change"); + } else { + // Create a DOM Option that matches the desired user. Then append it and select it. + let userOption = new Option(currentUserName, currentUserId, true, true); + selector.append(userOption).trigger("change"); + } + }); + + // Listen to any change events, and hide the parent container if investigator has a value. + selector.on('change', function() { + // 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() { diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index e2377e07c..ef1a810ac 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -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 { @@ -166,7 +166,7 @@ html[data-theme="dark"] { // Remove when dark mode successfully applies to Django delete page. .delete-confirmation .content a:not(.button) { color: color('primary'); - } + } } @@ -370,14 +370,60 @@ input.admin-confirm-button { list-style-type: none; line-height: normal; } - .button { - display: inline-block; - padding: 10px 8px; - line-height: normal; - } - a.button:active, a.button:focus { - text-decoration: none; - } +} + +// 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 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; +} diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 73aecad7a..7965424bc 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -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"] diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 19fa99809..17be3c2bb 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -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//transfer/", TransferUserView.as_view(), name="transfer_user"), path( "admin/api/get-senior-official-from-federal-agency-json/", get_senior_official_from_federal_agency_json, diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index ea04dca80..2ac22b2e0 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -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 diff --git a/src/registrar/management/commands/clean_tables.py b/src/registrar/management/commands/clean_tables.py index 5d4439d95..66b3e772f 100644 --- a/src/registrar/management/commands/clean_tables.py +++ b/src/registrar/management/commands/clean_tables.py @@ -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 diff --git a/src/registrar/management/commands/extend_expiration_dates.py b/src/registrar/management/commands/extend_expiration_dates.py index cefc38b9e..ac083da1d 100644 --- a/src/registrar/management/commands/extend_expiration_dates.py +++ b/src/registrar/management/commands/extend_expiration_dates.py @@ -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) diff --git a/src/registrar/management/commands/load_organization_data.py b/src/registrar/management/commands/load_organization_data.py index 122795400..35cc248ee 100644 --- a/src/registrar/management/commands/load_organization_data.py +++ b/src/registrar/management/commands/load_organization_data.py @@ -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} diff --git a/src/registrar/management/commands/load_senior_official_table.py b/src/registrar/management/commands/load_senior_official_table.py index 43f61d57a..cdbc607bf 100644 --- a/src/registrar/management/commands/load_senior_official_table.py +++ b/src/registrar/management/commands/load_senior_official_table.py @@ -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} diff --git a/src/registrar/management/commands/load_transition_domain.py b/src/registrar/management/commands/load_transition_domain.py index 4132096c8..c2dd66f55 100644 --- a/src/registrar/management/commands/load_transition_domain.py +++ b/src/registrar/management/commands/load_transition_domain.py @@ -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} diff --git a/src/registrar/management/commands/patch_federal_agency_info.py b/src/registrar/management/commands/patch_federal_agency_info.py index b286f1516..51a98ffaa 100644 --- a/src/registrar/management/commands/patch_federal_agency_info.py +++ b/src/registrar/management/commands/patch_federal_agency_info.py @@ -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} diff --git a/src/registrar/management/commands/populate_first_ready.py b/src/registrar/management/commands/populate_first_ready.py index 9636476c2..04468029a 100644 --- a/src/registrar/management/commands/populate_first_ready.py +++ b/src/registrar/management/commands/populate_first_ready.py @@ -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)} """, diff --git a/src/registrar/management/commands/populate_organization_type.py b/src/registrar/management/commands/populate_organization_type.py index a7dd98b24..60d179cb8 100644 --- a/src/registrar/management/commands/populate_organization_type.py +++ b/src/registrar/management/commands/populate_organization_type.py @@ -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)} diff --git a/src/registrar/management/commands/update_first_ready.py b/src/registrar/management/commands/update_first_ready.py new file mode 100644 index 000000000..0a4ea10a7 --- /dev/null +++ b/src/registrar/management/commands/update_first_ready.py @@ -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) diff --git a/src/registrar/management/commands/utility/terminal_helper.py b/src/registrar/management/commands/utility/terminal_helper.py index b9e11be5d..fa7cde683 100644 --- a/src/registrar/management/commands/utility/terminal_helper.py +++ b/src/registrar/management/commands/utility/terminal_helper.py @@ -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 . @@ -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}""" diff --git a/src/registrar/migrations/0123_alter_portfolioinvitation_portfolio_additional_permissions_and_more.py b/src/registrar/migrations/0123_alter_portfolioinvitation_portfolio_additional_permissions_and_more.py new file mode 100644 index 000000000..c14a70ab0 --- /dev/null +++ b/src/registrar/migrations/0123_alter_portfolioinvitation_portfolio_additional_permissions_and_more.py @@ -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, + ), + ), + ] diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 0ab6bc750..5c3d93424 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -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) diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index bf1c3e566..0c2487df3 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -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 diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 86aaa5e16..7afd32603 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -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" diff --git a/src/registrar/templates/admin/analytics.html b/src/registrar/templates/admin/analytics.html index 13db3b60a..7c1a09c78 100644 --- a/src/registrar/templates/admin/analytics.html +++ b/src/registrar/templates/admin/analytics.html @@ -5,7 +5,7 @@ {% block content %} -
+
@@ -29,28 +29,28 @@ +
+
+ +
+
+{% endblock %} diff --git a/src/registrar/templates/django/admin/user_change_form.html b/src/registrar/templates/django/admin/user_change_form.html index 005d67aec..c0ddd8caf 100644 --- a/src/registrar/templates/django/admin/user_change_form.html +++ b/src/registrar/templates/django/admin/user_change_form.html @@ -1,6 +1,21 @@ {% extends 'django/admin/email_clipboard_change_form.html' %} {% load i18n static %} + +{% block field_sets %} + + + {% for fieldset in adminform %} + {% include "django/admin/includes/domain_fieldset.html" with state_help_message=state_help_message %} + {% endfor %} +{% endblock %} + {% block after_related_objects %}

Associated requests and domains

diff --git a/src/registrar/templates/includes/header_extended.html b/src/registrar/templates/includes/header_extended.html index d00c57e38..5084b1a3d 100644 --- a/src/registrar/templates/includes/header_extended.html +++ b/src/registrar/templates/includes/header_extended.html @@ -46,11 +46,11 @@ Domains -
  • + {% if has_domain_requests_portfolio_permission %}
  • @@ -60,11 +60,13 @@
  • {% endif %} + {% if has_view_members_portfolio_permission %}
  • Members
  • + {% endif %}
  • {% url 'organization' as url %} diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 93e611c1a..25d7e5fd2 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -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("Domain requestor", 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, "

    Change user

    ") + + 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.") diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index f2e9a4bfa..fd42caee0 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -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) diff --git a/src/registrar/tests/test_url_auth.py b/src/registrar/tests/test_url_auth.py index 3a045498a..284ec7638 100644 --- a/src/registrar/tests/test_url_auth.py +++ b/src/registrar/tests/test_url_auth.py @@ -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. diff --git a/src/registrar/tests/test_views_domains_json.py b/src/registrar/tests/test_views_domains_json.py index 70ae23b43..07799104b 100644 --- a/src/registrar/tests/test_views_domains_json.py +++ b/src/registrar/tests/test_views_domains_json.py @@ -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) diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index c5d1a9830..807c66cf7 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -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") diff --git a/src/registrar/views/__init__.py b/src/registrar/views/__init__.py index f6e87dd07..2b830d958 100644 --- a/src/registrar/views/__init__.py +++ b/src/registrar/views/__init__.py @@ -19,3 +19,4 @@ from .user_profile import UserProfileView, FinishProfileSetupView from .health import * from .index import * from .portfolios import * +from .transfer_user import TransferUserView diff --git a/src/registrar/views/domains_json.py b/src/registrar/views/domains_json.py index e72aac5fb..b26e92802 100644 --- a/src/registrar/views/domains_json.py +++ b/src/registrar/views/domains_json.py @@ -50,11 +50,15 @@ def get_domain_ids_from_request(request): """ portfolio = request.GET.get("portfolio") if portfolio: - domain_infos = DomainInformation.objects.filter(portfolio=portfolio) - return domain_infos.values_list("domain_id", flat=True) - else: - user_domain_roles = UserDomainRole.objects.filter(user=request.user) - return user_domain_roles.values_list("domain_id", flat=True) + 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) def apply_search(queryset, request): diff --git a/src/registrar/views/transfer_user.py b/src/registrar/views/transfer_user.py new file mode 100644 index 000000000..ac51cd20b --- /dev/null +++ b/src/registrar/views/transfer_user.py @@ -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 diff --git a/src/registrar/views/utility/__init__.py b/src/registrar/views/utility/__init__.py index 7219f4358..7e4e19085 100644 --- a/src/registrar/views/utility/__init__.py +++ b/src/registrar/views/utility/__init__.py @@ -7,5 +7,6 @@ from .permission_views import ( DomainRequestPermissionWithdrawView, DomainInvitationPermissionDeleteView, DomainRequestWizardPermissionView, + PortfolioMembersPermission, ) from .api_views import get_senior_official_from_federal_agency_json diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 6f0745f41..190d80981 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -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() diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index 0ff7d1676..e7031cf0d 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -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`. + """ diff --git a/src/zap.conf b/src/zap.conf index c97897aeb..dd9ae1565 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -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