diff --git a/docs/developer/database-access.md b/docs/developer/database-access.md index 859ef2fd6..f77261bbb 100644 --- a/docs/developer/database-access.md +++ b/docs/developer/database-access.md @@ -56,6 +56,13 @@ cf ssh getgov-ENVIRONMENT ./manage.py dumpdata ``` +## Access certain table in the database +1. `cf connect-to-service getgov-ENVIRONMENT getgov-ENVIRONMENT-database` gets you into whichever environments database you'd like +2. `\c [table name here that starts cgaws...etc];` connects to the [cgaws...etc] table +3. `\dt` retrieves information about that table and displays it +4. Make sure the table you are looking for exists. For this example, we are looking for `django_migrations` +5. Run `SELECT * FROM django_migrations;` to see everything that's in it! + ## Dropping and re-creating the database For your sandbox environment, it might be necessary to start the database over from scratch. diff --git a/docs/developer/migration-troubleshooting.md b/docs/developer/migration-troubleshooting.md index b90c02ae3..22a02503d 100644 --- a/docs/developer/migration-troubleshooting.md +++ b/docs/developer/migration-troubleshooting.md @@ -121,3 +121,19 @@ https://cisa-corp.slack.com/archives/C05BGB4L5NF/p1697810600723069 2. `./manage.py migrate model_name_here file_name_WITH_create` (run the last data creation migration AND ONLY THAT ONE) 3. `./manage.py migrate --fake model_name_here most_recent_file_name` (fake migrate the last migration in the migration list) 4. `./manage.py load` (rerun fixtures) + +### Scenario 9: Inconsistent Migration History +If you see `django.db.migrations.exceptions.InconsistentMigrationHistory` error, or when you run `./manage.py showmigrations` it looks like: + +[x] 0056_example_migration +[ ] 0057_other_migration +[x] 0058_some_other_migration + +1. Go to [database-access.md](../database-access.md#access-certain-table-in-the-database) to see the commands on how to access a certain table in the database. +2. In this case, we want to remove the migration "history" from the `django_migrations` table +3. Once you are in the `cgaws...` table, select the `django_migrations` table with the command `SELECT * FROM django_migrations;` +4. Find the id of the "history" you want to delete. This will be the one in the far left column. For this example, let's pretend the id is 101. +5. Run `DELETE FROM django_migrations WHERE id=101;` where 101 is an example id as seen above. +6. Go to your shell and run `./manage.py showmigrations` to make sure your migrations are now back to the right state. Most likely you will see several unapplied migrations. +7. If you still have unapplied migrations, run `./manage.py migrate`. If an error occurs saying one has already been applied, fake that particular migration `./manage.py migrate --fake model_name_here migration_number` and then run the normal `./manage.py migrate` command to then apply those migrations that come after the one that threw the error. + diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md index b7e413c05..0846208de 100644 --- a/docs/operations/data_migration.md +++ b/docs/operations/data_migration.md @@ -586,3 +586,59 @@ Example: `cf ssh getgov-za` | | Parameter | Description | |:-:|:-------------------------- |:----------------------------------------------------------------------------| | 1 | **debug** | Increases logging detail. Defaults to False. | + + +## Populate Organization type +This section outlines how to run the `populate_organization_type` script. +The script is used to update the organization_type field on DomainRequest and DomainInformation when it is None. +That data are synthesized from the generic_org_type field and the is_election_board field by concatenating " - Elections" on the end of generic_org_type string if is_elections_board is True. + +### Running on sandboxes + +#### Step 1: Login to CloudFoundry +```cf login -a api.fr.cloud.gov --sso``` + +#### Step 2: Get the domain_election_board file +The latest domain_election_board csv can be found [here](https://drive.google.com/file/d/1aDeCqwHmBnXBl2arvoFCN0INoZmsEGsQ/view). +After downloading this file, place it in `src/migrationdata` + +#### Step 2: Upload the domain_election_board file to your sandbox +Follow [Step 1: Transfer data to sandboxes](#step-1-transfer-data-to-sandboxes) and [Step 2: Transfer uploaded files to the getgov directory](#step-2-transfer-uploaded-files-to-the-getgov-directory) from the [Set Up Migrations on Sandbox](#set-up-migrations-on-sandbox) portion of this doc. + +#### 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 populate_organization_type {domain_election_board_filename}``` + +- The domain_election_board_filename file must adhere to this format: + - example.gov\ + example2.gov\ + example3.gov + +Example: +`./manage.py populate_organization_type migrationdata/election-domains.csv` + +### Running locally + +#### Step 1: Get the domain_election_board file +The latest domain_election_board csv can be found [here](https://drive.google.com/file/d/1aDeCqwHmBnXBl2arvoFCN0INoZmsEGsQ/view). +After downloading this file, place it in `src/migrationdata` + + +#### Step 2: Running the script +```docker-compose exec app ./manage.py populate_organization_type {domain_election_board_filename}``` + +Example (assuming that this is being ran from src/): +`docker-compose exec app ./manage.py populate_organization_type migrationdata/election-domains.csv` + + +### Required parameters +| | Parameter | Description | +|:-:|:------------------------------------|:-------------------------------------------------------------------| +| 1 | **domain_election_board_filename** | A file containing every domain that is an election office. diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 5f6c71f45..974eb1995 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -663,6 +663,7 @@ class ContactAdmin(ListHeaderAdmin): list_display = [ "contact", "email", + "user_exists", ] # this ordering effects the ordering of results # in autocomplete_fields for user @@ -679,6 +680,13 @@ class ContactAdmin(ListHeaderAdmin): change_form_template = "django/admin/email_clipboard_change_form.html" + def user_exists(self, obj): + """Check if the Contact has a related User""" + return "Yes" if obj.user is not None else "No" + + user_exists.short_description = "Is user" # type: ignore + user_exists.admin_order_field = "user" # type: ignore + # We name the custom prop 'contact' because linter # is not allowing a short_description attr on it # This gets around the linter limitation, for now. @@ -1445,12 +1453,36 @@ class DomainRequestAdmin(ListHeaderAdmin): """ Override changelist_view to set the selected value of status filter. """ + # there are two conditions which should set the default selected filter: + # 1 - there are no query parameters in the request and the request is the + # initial request for this view + # 2 - there are no query parameters in the request and the referring url is + # the change view for a domain request + should_apply_default_filter = False # use http_referer in order to distinguish between request as a link from another page # and request as a removal of all filters http_referer = request.META.get("HTTP_REFERER", "") # if there are no query parameters in the request - # and the request is the initial request for this view - if not bool(request.GET) and request.path not in http_referer: + if not bool(request.GET): + # if the request is the initial request for this view + if request.path not in http_referer: + should_apply_default_filter = True + # elif the request is a referral from changelist view or from + # domain request change view + elif request.path in http_referer: + # find the index to determine the referring url after the path + index = http_referer.find(request.path) + # Check if there is a character following the path in http_referer + next_char_index = index + len(request.path) + if index + next_char_index < len(http_referer): + next_char = http_referer[next_char_index] + + # Check if the next character is a digit, if so, this indicates + # a change view for domain request + if next_char.isdigit(): + should_apply_default_filter = True + + if should_apply_default_filter: # modify the GET of the request to set the selected filter modified_get = copy.deepcopy(request.GET) modified_get["status__in"] = "submitted,in review,action needed" @@ -1487,10 +1519,11 @@ class DomainInformationInline(admin.StackedInline): We had issues inheriting from both StackedInline and the source DomainInformationAdmin since these classes conflict, so we'll just pull what we need - from DomainInformationAdmin""" + from DomainInformationAdmin + """ form = DomainInformationInlineForm - + template = "django/admin/includes/domain_info_inline_stacked.html" model = models.DomainInformation fieldsets = copy.deepcopy(DomainInformationAdmin.fieldsets) @@ -1500,10 +1533,8 @@ class DomainInformationInline(admin.StackedInline): del fieldsets[index] break + readonly_fields = DomainInformationAdmin.readonly_fields analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields - # For each filter_horizontal, init in admin js extendFilterHorizontalWidgets - # to activate the edit/delete/view buttons - filter_horizontal = ("other_contacts",) autocomplete_fields = [ "creator", @@ -1669,11 +1700,15 @@ class DomainAdmin(ListHeaderAdmin): if extra_context is None: extra_context = {} - # Pass in what the an extended expiration date would be for the expiration date modal if object_id is not None: domain = Domain.objects.get(pk=object_id) - years_to_extend_by = self._get_calculated_years_for_exp_date(domain) + # Used in the custom contact view + if domain is not None and hasattr(domain, "domain_info"): + extra_context["original_object"] = domain.domain_info + + # Pass in what the an extended expiration date would be for the expiration date modal + years_to_extend_by = self._get_calculated_years_for_exp_date(domain) try: curr_exp_date = domain.registry_expiration_date except KeyError: diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 69f916b7f..3c1b1099f 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -589,7 +589,7 @@ function hideDeletedForms() { let isDotgovDomain = document.querySelector(".dotgov-domain-form"); // The Nameservers formset features 2 required and 11 optionals if (isNameserversForm) { - cloneIndex = 2; + // cloneIndex = 2; formLabel = "Name server"; // DNSSEC: DS Data } else if (isDsDataForm) { @@ -789,6 +789,43 @@ function hideDeletedForms() { HookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null) })(); +/** + * An IIFE that disables the delete buttons on nameserver forms on page load if < 3 forms + * + */ +(function nameserversFormListener() { + let isNameserversForm = document.querySelector(".nameservers-form"); + if (isNameserversForm) { + let forms = document.querySelectorAll(".repeatable-form"); + if (forms.length < 3) { + // Hide the delete buttons on the 2 nameservers + forms.forEach((form) => { + Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { + deleteButton.setAttribute("disabled", "true"); + }); + }); + } + } +})(); + +/** + * An IIFE that disables the delete buttons on nameserver forms on page load if < 3 forms + * + */ +(function nameserversFormListener() { + let isNameserversForm = document.querySelector(".nameservers-form"); + if (isNameserversForm) { + let forms = document.querySelectorAll(".repeatable-form"); + if (forms.length < 3) { + // Hide the delete buttons on the 2 nameservers + forms.forEach((form) => { + Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { + deleteButton.setAttribute("disabled", "true"); + }); + }); + } + } +})(); /** * An IIFE that listens to the yes/no radio buttons on the CISA representatives form and toggles form field visibility accordingly diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index 4b69dc8e3..f5717d067 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -495,6 +495,8 @@ address.dja-address-contact-list { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + font-size: medium; + padding-top: 3px !important; } } @@ -505,6 +507,7 @@ address.dja-address-contact-list { @media screen and (min-width:768px) { .visible-768 { display: block; + padding-top: 0; } } @@ -593,6 +596,7 @@ address.dja-address-contact-list { right: auto; left: 4px; height: 100%; + top: -1px; } button { font-size: unset !important; diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 7b0ac2956..da1462bdb 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -83,25 +83,34 @@ class DomainNameserverForm(forms.Form): # after clean_fields. it is used to determine form level errors. # is_valid is typically called from view during a post cleaned_data = super().clean() + self.clean_empty_strings(cleaned_data) + server = cleaned_data.get("server", "") - # remove ANY spaces in the server field - server = server.replace(" ", "") - # lowercase the server - server = server.lower() + server = server.replace(" ", "").lower() cleaned_data["server"] = server - ip = cleaned_data.get("ip", None) - # remove ANY spaces in the ip field + + ip = cleaned_data.get("ip", "") ip = ip.replace(" ", "") cleaned_data["ip"] = ip + domain = cleaned_data.get("domain", "") ip_list = self.extract_ip_list(ip) - # validate if the form has a server or an ip + # Capture the server_value + server_value = self.cleaned_data.get("server") + + # Validate if the form has a server or an ip if (ip and ip_list) or server: self.validate_nameserver_ip_combo(domain, server, ip_list) + # Re-set the server value: + # add_error which is called on validate_nameserver_ip_combo will clean-up (delete) any invalid data. + # We need that data because we need to know the total server entries (even if invalid) in the formset + # clean method where we determine whether a blank first and/or second entry should throw a required error. + self.cleaned_data["server"] = server_value + return cleaned_data def clean_empty_strings(self, cleaned_data): @@ -149,6 +158,19 @@ class BaseNameserverFormset(forms.BaseFormSet): """ Check for duplicate entries in the formset. """ + + # Check if there are at least two valid servers + valid_servers_count = sum( + 1 for form in self.forms if form.cleaned_data.get("server") and form.cleaned_data.get("server").strip() + ) + if valid_servers_count >= 2: + # If there are, remove the "At least two name servers are required" error from each form + # This will allow for successful submissions when the first or second entries are blanked + # but there are enough entries total + for form in self.forms: + if form.errors.get("server") == ["At least two name servers are required."]: + form.errors.pop("server") + if any(self.errors): # Don't bother validating the formset unless each form is valid on its own return @@ -156,10 +178,13 @@ class BaseNameserverFormset(forms.BaseFormSet): data = [] duplicates = [] - for form in self.forms: + for index, form in enumerate(self.forms): if form.cleaned_data: value = form.cleaned_data["server"] - if value in data: + # We need to make sure not to trigger the duplicate error in case the first and second nameservers + # are empty. If there are enough records in the formset, that error is an unecessary blocker. + # If there aren't, the required error will block the submit. + if value in data and not (form.cleaned_data.get("server", "").strip() == "" and index == 1): form.add_error( "server", NameserverError(code=nsErrorCodes.DUPLICATE_HOST, nameserver=value), diff --git a/src/registrar/management/commands/populate_organization_type.py b/src/registrar/management/commands/populate_organization_type.py new file mode 100644 index 000000000..a7dd98b24 --- /dev/null +++ b/src/registrar/management/commands/populate_organization_type.py @@ -0,0 +1,237 @@ +import argparse +import logging +import os +from typing import List +from django.core.management import BaseCommand +from registrar.management.commands.utility.terminal_helper import TerminalColors, TerminalHelper, ScriptDataHelper +from registrar.models import DomainInformation, DomainRequest +from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = ( + "Loops through each valid DomainInformation and DomainRequest object and updates its organization_type value. " + "A valid DomainInformation/DomainRequest in this sense is one that has the value None for organization_type. " + "In other words, we populate the organization_type field if it is not already populated." + ) + + def __init__(self): + super().__init__() + # Get lists for DomainRequest + self.request_to_update: List[DomainRequest] = [] + self.request_failed_to_update: List[DomainRequest] = [] + self.request_skipped: List[DomainRequest] = [] + + # Get lists for DomainInformation + self.di_to_update: List[DomainInformation] = [] + self.di_failed_to_update: List[DomainInformation] = [] + self.di_skipped: List[DomainInformation] = [] + + # Define a global variable for all domains with election offices + self.domains_with_election_boards_set = set() + + def add_arguments(self, parser): + """Adds command line arguments""" + parser.add_argument( + "domain_election_board_filename", + help=("A file that contains" " all the domains that are election offices."), + ) + + def handle(self, domain_election_board_filename, **kwargs): + """Loops through each valid Domain object and updates its first_created value""" + + # Check if the provided file path is valid + if not os.path.isfile(domain_election_board_filename): + raise argparse.ArgumentTypeError(f"Invalid file path '{domain_election_board_filename}'") + + # Read the election office csv + self.read_election_board_file(domain_election_board_filename) + + domain_requests = DomainRequest.objects.filter(organization_type__isnull=True) + + # 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 DomainRequest objects to change: {len(domain_requests)} + + Organization_type data will be added for all of these fields. + """, + prompt_title="Do you wish to process DomainRequest?", + ) + logger.info("Updating DomainRequest(s)...") + + self.update_domain_requests(domain_requests) + + # We should actually be targeting all fields with no value for organization type, + # but do have a value for generic_org_type. This is because there is data that we can infer. + domain_infos = DomainInformation.objects.filter(organization_type__isnull=True) + # 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 DomainInformation objects to change: {len(domain_infos)} + + Organization_type data will be added for all of these fields. + """, + prompt_title="Do you wish to process DomainInformation?", + ) + logger.info("Updating DomainInformation(s)...") + + self.update_domain_informations(domain_infos) + + def read_election_board_file(self, domain_election_board_filename): + """ + Reads the election board file and adds each parsed domain to self.domains_with_election_boards_set. + As previously implied, this file contains information about Domains which have election boards. + + The file must adhere to this format: + ``` + domain1.gov + domain2.gov + domain3.gov + ``` + (and so on) + """ + with open(domain_election_board_filename, "r") as file: + for line in file: + # Remove any leading/trailing whitespace + domain = line.strip() + if domain not in self.domains_with_election_boards_set: + self.domains_with_election_boards_set.add(domain) + + def update_domain_requests(self, domain_requests): + """ + Updates the organization_type for a list of DomainRequest objects using the `sync_organization_type` function. + Results are then logged. + + This function updates the following variables: + - self.request_to_update list is appended to if the field was updated successfully. + - self.request_skipped list is appended to if the field has `None` for `request.generic_org_type`. + - self.request_failed_to_update list is appended to if an exception is caught during update. + """ + for request in domain_requests: + try: + if request.generic_org_type is not None: + domain_name = None + if request.requested_domain is not None and request.requested_domain.name is not None: + domain_name = request.requested_domain.name + + request_is_approved = request.status == DomainRequest.DomainRequestStatus.APPROVED + if request_is_approved and domain_name is not None and not request.is_election_board: + request.is_election_board = domain_name in self.domains_with_election_boards_set + + self.sync_organization_type(DomainRequest, request) + self.request_to_update.append(request) + logger.info(f"Updating {request} => {request.organization_type}") + else: + self.request_skipped.append(request) + logger.warning(f"Skipped updating {request}. No generic_org_type was found.") + except Exception as err: + self.request_failed_to_update.append(request) + logger.error(err) + logger.error(f"{TerminalColors.FAIL}" f"Failed to update {request}" f"{TerminalColors.ENDC}") + + # Do a bulk update on the organization_type field + ScriptDataHelper.bulk_update_fields( + DomainRequest, self.request_to_update, ["organization_type", "is_election_board", "generic_org_type"] + ) + + # Log what happened + log_header = "============= FINISHED UPDATE FOR DOMAINREQUEST ===============" + TerminalHelper.log_script_run_summary( + self.request_to_update, self.request_failed_to_update, self.request_skipped, True, log_header + ) + + update_skipped_count = len(self.request_to_update) + if update_skipped_count > 0: + logger.warning( + f"""{TerminalColors.MAGENTA} + Note: Entries are skipped when generic_org_type is None + {TerminalColors.ENDC} + """ + ) + + def update_domain_informations(self, domain_informations): + """ + Updates the organization_type for a list of DomainInformation objects + and updates is_election_board if the domain is in the provided csv. + Results are then logged. + + This function updates the following variables: + - self.di_to_update list is appended to if the field was updated successfully. + - self.di_skipped list is appended to if the field has `None` for `request.generic_org_type`. + - self.di_failed_to_update list is appended to if an exception is caught during update. + """ + for info in domain_informations: + try: + if info.generic_org_type is not None: + domain_name = info.domain.name + + if not info.is_election_board: + info.is_election_board = domain_name in self.domains_with_election_boards_set + + self.sync_organization_type(DomainInformation, info) + + self.di_to_update.append(info) + logger.info(f"Updating {info} => {info.organization_type}") + else: + self.di_skipped.append(info) + logger.warning(f"Skipped updating {info}. No generic_org_type was found.") + except Exception as err: + self.di_failed_to_update.append(info) + logger.error(err) + logger.error(f"{TerminalColors.FAIL}" f"Failed to update {info}" f"{TerminalColors.ENDC}") + + # Do a bulk update on the organization_type field + ScriptDataHelper.bulk_update_fields( + DomainInformation, self.di_to_update, ["organization_type", "is_election_board", "generic_org_type"] + ) + + # Log what happened + log_header = "============= FINISHED UPDATE FOR DOMAININFORMATION ===============" + TerminalHelper.log_script_run_summary( + self.di_to_update, self.di_failed_to_update, self.di_skipped, True, log_header + ) + + update_skipped_count = len(self.di_skipped) + if update_skipped_count > 0: + logger.warning( + f"""{TerminalColors.MAGENTA} + Note: Entries are skipped when generic_org_type is None + {TerminalColors.ENDC} + """ + ) + + def sync_organization_type(self, sender, instance): + """ + Updates the organization_type (without saving) to match + the is_election_board and generic_organization_type fields. + """ + + # Define mappings between generic org and election org. + # These have to be defined here, as you'd get a cyclical import error + # otherwise. + + # For any given organization type, return the "_ELECTION" enum equivalent. + # For example: STATE_OR_TERRITORY => STATE_OR_TERRITORY_ELECTION + generic_org_map = DomainRequest.OrgChoicesElectionOffice.get_org_generic_to_org_election() + + # For any given "_election" variant, return the base org type. + # For example: STATE_OR_TERRITORY_ELECTION => STATE_OR_TERRITORY + election_org_map = DomainRequest.OrgChoicesElectionOffice.get_org_election_to_org_generic() + + # Manages the "organization_type" variable and keeps in sync with + # "is_election_board" and "generic_organization_type" + org_type_helper = CreateOrUpdateOrganizationTypeHelper( + sender=sender, + instance=instance, + generic_org_to_org_map=generic_org_map, + election_org_to_generic_org_map=election_org_map, + ) + + org_type_helper.create_or_update_organization_type(force_update=True) diff --git a/src/registrar/management/commands/utility/terminal_helper.py b/src/registrar/management/commands/utility/terminal_helper.py index 49ab89b9a..b54209750 100644 --- a/src/registrar/management/commands/utility/terminal_helper.py +++ b/src/registrar/management/commands/utility/terminal_helper.py @@ -49,6 +49,7 @@ class ScriptDataHelper: Usage: bulk_update_fields(Domain, page.object_list, ["first_ready"]) """ + logger.info(f"{TerminalColors.YELLOW} Bulk updating fields... {TerminalColors.ENDC}") # Create a Paginator object. Bulk_update on the full dataset # is too memory intensive for our current app config, so we can chunk this data instead. paginator = Paginator(update_list, batch_size) @@ -59,13 +60,16 @@ class ScriptDataHelper: class TerminalHelper: @staticmethod - def log_script_run_summary(to_update, failed_to_update, skipped, debug: bool): + def log_script_run_summary(to_update, failed_to_update, skipped, debug: bool, log_header=None): """Prints success, failed, and skipped counts, as well as all affected objects.""" update_success_count = len(to_update) update_failed_count = len(failed_to_update) update_skipped_count = len(skipped) + if log_header is None: + log_header = "============= FINISHED ===============" + # Prepare debug messages debug_messages = { "success": (f"{TerminalColors.OKCYAN}Updated: {to_update}{TerminalColors.ENDC}\n"), @@ -85,7 +89,7 @@ class TerminalHelper: if update_failed_count == 0 and update_skipped_count == 0: logger.info( f"""{TerminalColors.OKGREEN} - ============= FINISHED =============== + {log_header} Updated {update_success_count} entries {TerminalColors.ENDC} """ @@ -93,7 +97,7 @@ class TerminalHelper: elif update_failed_count == 0: logger.warning( f"""{TerminalColors.YELLOW} - ============= FINISHED =============== + {log_header} Updated {update_success_count} entries ----- SOME DATA WAS INVALID (NEEDS MANUAL PATCHING) ----- Skipped updating {update_skipped_count} entries @@ -103,7 +107,7 @@ class TerminalHelper: else: logger.error( f"""{TerminalColors.FAIL} - ============= FINISHED =============== + {log_header} Updated {update_success_count} entries ----- UPDATE FAILED ----- Failed to update {update_failed_count} entries, diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index ec2a4ca22..c61f1e2a2 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -245,14 +245,17 @@ class DomainInformation(TimeStampedModel): except Exception: return "" - def save(self, *args, **kwargs): - """Save override for custom properties""" + def sync_organization_type(self): + """ + Updates the organization_type (without saving) to match + the is_election_board and generic_organization_type fields. + """ # Define mappings between generic org and election org. # These have to be defined here, as you'd get a cyclical import error # otherwise. - # For any given organization type, return the "_election" variant. + # For any given organization type, return the "_ELECTION" enum equivalent. # For example: STATE_OR_TERRITORY => STATE_OR_TERRITORY_ELECTION generic_org_map = DomainRequest.OrgChoicesElectionOffice.get_org_generic_to_org_election() @@ -271,6 +274,12 @@ class DomainInformation(TimeStampedModel): # Actually updates the organization_type field org_type_helper.create_or_update_organization_type() + + return self + + def save(self, *args, **kwargs): + """Save override for custom properties""" + self.sync_organization_type() super().save(*args, **kwargs) @classmethod diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 02324ce61..1e8091c44 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -675,14 +675,16 @@ class DomainRequest(TimeStampedModel): help_text="Notes about this request", ) - def save(self, *args, **kwargs): - """Save override for custom properties""" - + def sync_organization_type(self): + """ + Updates the organization_type (without saving) to match + the is_election_board and generic_organization_type fields. + """ # Define mappings between generic org and election org. # These have to be defined here, as you'd get a cyclical import error # otherwise. - # For any given organization type, return the "_election" variant. + # For any given organization type, return the "_ELECTION" enum equivalent. # For example: STATE_OR_TERRITORY => STATE_OR_TERRITORY_ELECTION generic_org_map = self.OrgChoicesElectionOffice.get_org_generic_to_org_election() @@ -701,6 +703,10 @@ class DomainRequest(TimeStampedModel): # Actually updates the organization_type field org_type_helper.create_or_update_organization_type() + + def save(self, *args, **kwargs): + """Save override for custom properties""" + self.sync_organization_type() super().save(*args, **kwargs) def __str__(self): diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py index 32f767ede..892298967 100644 --- a/src/registrar/models/utility/generic_helper.py +++ b/src/registrar/models/utility/generic_helper.py @@ -49,7 +49,7 @@ class CreateOrUpdateOrganizationTypeHelper: self.generic_org_to_org_map = generic_org_to_org_map self.election_org_to_generic_org_map = election_org_to_generic_org_map - def create_or_update_organization_type(self): + def create_or_update_organization_type(self, force_update=False): """The organization_type field on DomainRequest and DomainInformation is consituted from the generic_org_type and is_election_board fields. To keep the organization_type field up to date, we need to update it before save based off of those field @@ -59,6 +59,14 @@ class CreateOrUpdateOrganizationTypeHelper: one of the excluded types (FEDERAL, INTERSTATE, SCHOOL_DISTRICT), the organization_type is set to a corresponding election variant. Otherwise, it directly mirrors the generic_org_type value. + + args: + force_update (bool): If an existing instance has no values to change, + try to update the organization_type field (or related fields) anyway. + This is done by invoking the new instance handler. + + Use to force org type to be updated to the correct value even + if no other changes were made (including is_election). """ # A new record is added with organization_type not defined. @@ -67,7 +75,7 @@ class CreateOrUpdateOrganizationTypeHelper: if is_new_instance: self._handle_new_instance() else: - self._handle_existing_instance() + self._handle_existing_instance(force_update) return self.instance @@ -92,7 +100,7 @@ class CreateOrUpdateOrganizationTypeHelper: # Update the field self._update_fields(organization_type_needs_update, generic_org_type_needs_update) - def _handle_existing_instance(self): + def _handle_existing_instance(self, force_update_when_no_are_changes_found=False): # == Init variables == # # Instance is already in the database, fetch its current state current_instance = self.sender.objects.get(id=self.instance.id) @@ -109,17 +117,19 @@ class CreateOrUpdateOrganizationTypeHelper: # This will not happen in normal flow as it is not possible otherwise. raise ValueError("Cannot update organization_type and generic_org_type simultaneously.") elif not organization_type_changed and (not generic_org_type_changed and not is_election_board_changed): - # No values to update - do nothing - return None - # == Program flow will halt here if there is no reason to update == # + # No changes found + if force_update_when_no_are_changes_found: + # If we want to force an update anyway, we can treat this record like + # its a new one in that we check for "None" values rather than changes. + self._handle_new_instance() + else: + # == Update the linked values == # + # Find out which field needs updating + organization_type_needs_update = generic_org_type_changed or is_election_board_changed + generic_org_type_needs_update = organization_type_changed - # == Update the linked values == # - # Find out which field needs updating - organization_type_needs_update = generic_org_type_changed or is_election_board_changed - generic_org_type_needs_update = organization_type_changed - - # Update the field - self._update_fields(organization_type_needs_update, generic_org_type_needs_update) + # Update the field + self._update_fields(organization_type_needs_update, generic_org_type_needs_update) def _update_fields(self, organization_type_needs_update, generic_org_type_needs_update): """ diff --git a/src/registrar/templates/admin/stacked.html b/src/registrar/templates/admin/stacked.html new file mode 100644 index 000000000..5ca9324df --- /dev/null +++ b/src/registrar/templates/admin/stacked.html @@ -0,0 +1,52 @@ +{% load i18n admin_urls %} +{% load i18n static %} + +{% comment %} +This is copied from Djangos implementation of this template, with added "blocks" +It is not inherently customizable on its own, so we can modify this instead. +https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/edit_inline/stacked.html +{% endcomment %} + +
- Requested domain: {{ original.requested_domain.name }} +
+ Requested domain: {{ original.requested_domain.name }}
{{ block.super }} diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index f346ee155..fb7303352 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -5,7 +5,7 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endcomment %} {% block field_readonly %} - {% with all_contacts=original.other_contacts.all %} + {% with all_contacts=original_object.other_contacts.all %} {% if field.field.name == "other_contacts" %} {% if all_contacts.count > 2 %}