diff --git a/docs/developer/README.md b/docs/developer/README.md index 860140a96..7519da7a9 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -405,3 +405,9 @@ This function is triggered by the post_save event on the User model, designed to 1. For New Users: Upon the creation of a new user, it checks for an existing `Contact` by email. If no matching contact is found, it creates a new Contact using the user's details from Login.gov. If a matching contact is found, it associates this contact with the user. In cases where multiple contacts with the same email exist, it logs a warning and associates the first contact found. 2. For Existing Users: For users logging in subsequent times, the function ensures that any updates from Login.gov are applied to the associated User record. However, it does not alter any existing Contact records. + +## Disable email sending (toggling the disable_email_sending flag) +1. On the app, navigate to `\admin`. +2. Under models, click `Waffle flags`. +3. Click the `disable_email_sending` record. This should exist by default, if not - create one with that name. +4. (Important) Set the field `everyone` to `Yes`. This field overrides all other settings \ No newline at end of file diff --git a/docs/operations/import_export.md b/docs/operations/import_export.md index 7c3ee1159..7ddfd5d3b 100644 --- a/docs/operations/import_export.md +++ b/docs/operations/import_export.md @@ -1,18 +1,29 @@ # Export / Import Tables -A means is provided to export and import individual tables from +A means is provided to export and import tables from one environment to another. This allows for replication of production data in a development environment. Import and export -are provided through the django admin interface, through a modified -library, django-import-export. Each supported model has an Import -and an Export button on the list view. +are provided through a modified library, django-import-export. +Simple scripts are provided as detailed below. ### Export -When exporting models from the source environment, make sure that -no filters are selected. This will ensure that all rows of the model -are exported. Due to database dependencies, the following models -need to be exported: +To export from the source environment, run the following command from src directory: +manage.py export_tables + +Connect to the source sandbox and run the command: +cf ssh {source-app} +/tmp/lifecycle/shell +./manage.py export_tables + +example exporting from getgov-stable: +cf ssh getgov-stable +/tmp/lifecycle/shell +./manage.py export_tables + +This exports a file, exported_tables.zip, to the tmp directory + +For reference, the zip file will contain the following tables in csv form: * User * Contact @@ -25,6 +36,20 @@ need to be exported: * Host * HostIP +After exporting the file from the target environment, scp the exported_tables.zip +file from the target environment to local. Run the below commands from local. + +Get passcode by running: +cf ssh-code + +scp file from source app to local file: +scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app {source-app} --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip {local_file_path} +when prompted, supply the passcode retrieved in the 'cf ssh-code' command + +example copying from stable to local cwd: +scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-stable --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 ssh.fr.cloud.gov:app/tmp/exported_tables.zip . + + ### Import When importing into the target environment, if the target environment @@ -34,7 +59,18 @@ that there are no database conflicts on import. #### Preparing Target Environment -Delete all rows from tables in the following order through django admin: +In order to delete all rows from the appropriate tables, run the following +command: +cf ssh {target-app} +/tmp/lifecycle/shell +./manage.py clean_tables + +example cleaning getgov-backup: +cf ssh getgov-backup +/tmp/lifecycle/backup +./manage.py clean_tables + +For reference, this deletes all rows from the following tables: * DomainInformation * DomainRequest @@ -48,10 +84,34 @@ Delete all rows from tables in the following order through django admin: #### Importing into Target Environment -Once target environment is prepared, files can be imported in the following -order: +Once target environment is prepared, files can be imported. -* User (After importing User table, you need to delete all rows from Contact table before importing Contacts) +To scp the exported_tables.zip file from local to the sandbox, run the following: + +Get passcode by running: +cf ssh-code + +scp file from local to target app: +scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app {target-app} --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 {local_file_path} ssh.fr.cloud.gov:app/tmp/exported_tables.zip +when prompted, supply the passcode retrieved in the 'cf ssh-code' command + +example copy of local file in tmp to getgov-backup: +scp -P 2222 -o User=cf:$(cf curl /v3/apps/$(cf app getgov-backup --guid)/processes | jq -r '.resources[] | select(.type=="web") | .guid')/0 tmp/exported_tables.zip ssh.fr.cloud.gov:app/tmp/exported_tables.zip + + +Then connect to a shell in the target environment, and run the following import command: +cf ssh {target-app} +/tmp/lifecycle/shell +./manage.py import_tables + +example cleaning getgov-backup: +cf ssh getgov-backup +/tmp/lifecycle/backup +./manage.py import_tables + +For reference, this imports tables in the following order: + +* User * Contact * Domain * Host diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 2d6559570..f782f7c62 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2245,9 +2245,46 @@ class DraftDomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): return response -class PublicContactAdmin(ListHeaderAdmin): +class PublicContactResource(resources.ModelResource): + """defines how each field in the referenced model should be mapped to the corresponding fields in the + import/export file""" + + class Meta: + model = models.PublicContact + + def import_row(self, row, instance_loader, using_transactions=True, dry_run=False, raise_errors=None, **kwargs): + """Override kwargs skip_epp_save and set to True""" + kwargs["skip_epp_save"] = True + return super().import_row( + row, + instance_loader, + using_transactions=using_transactions, + dry_run=dry_run, + raise_errors=raise_errors, + **kwargs, + ) + + def save_instance(self, instance, is_create, using_transactions=True, dry_run=False): + """Override save_instance setting skip_epp_save to True""" + self.before_save_instance(instance, using_transactions, dry_run) + if self._meta.use_bulk: + if is_create: + self.create_instances.append(instance) + else: + self.update_instances.append(instance) + elif not using_transactions and dry_run: + # we don't have transactions and we want to do a dry_run + pass + else: + instance.save(skip_epp_save=True) + self.after_save_instance(instance, using_transactions, dry_run) + + +class PublicContactAdmin(ListHeaderAdmin, ImportExportModelAdmin): """Custom PublicContact admin class.""" + resource_classes = [PublicContactResource] + change_form_template = "django/admin/email_clipboard_change_form.html" autocomplete_fields = ["domain"] @@ -2305,6 +2342,8 @@ class UserGroupAdmin(AuditedAdmin): class WaffleFlagAdmin(FlagAdmin): + """Custom admin implementation of django-waffle's Flag class""" + class Meta: """Contains meta information about this class""" @@ -2338,6 +2377,6 @@ admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin) # Register our custom waffle implementations admin.site.register(models.WaffleFlag, WaffleFlagAdmin) -# Unregister Sample and Switch from the waffle library -admin.site.unregister(Sample) +# Unregister Switch and Sample from the waffle library admin.site.unregister(Switch) +admin.site.unregister(Sample) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index c33a31aa2..4a6d98522 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -1248,3 +1248,125 @@ document.addEventListener('DOMContentLoaded', function() { loadDomainRequests(1); } }); + + + +/** + * An IIFE that hooks up the edit buttons on the finish-user-setup page + */ +(function finishUserSetupListener() { + + function getInputField(fieldName){ + return document.querySelector(`#id_${fieldName}`) + } + + // Shows the hidden input field and hides the readonly one + function showInputFieldHideReadonlyField(fieldName, button) { + let inputField = getInputField(fieldName) + let readonlyField = document.querySelector(`#${fieldName}__edit-button-readonly`) + + readonlyField.classList.toggle('display-none'); + inputField.classList.toggle('display-none'); + + // Toggle the bold style on the grid row + let gridRow = button.closest(".grid-col-2").closest(".grid-row") + if (gridRow){ + gridRow.classList.toggle("bold-usa-label") + } + } + + function handleFullNameField(fieldName = "full_name") { + // Remove the display-none class from the nearest parent div + let nameFieldset = document.querySelector("#profile-name-group"); + if (nameFieldset){ + nameFieldset.classList.remove("display-none"); + } + + // Hide the "full_name" field + let inputField = getInputField(fieldName); + if (inputField) { + inputFieldParentDiv = inputField.closest("div"); + if (inputFieldParentDiv) { + inputFieldParentDiv.classList.add("display-none"); + } + } + } + + function handleEditButtonClick(fieldName, button){ + button.addEventListener('click', function() { + // Lock the edit button while this operation occurs + button.disabled = true + + if (fieldName == "full_name"){ + handleFullNameField(); + }else { + showInputFieldHideReadonlyField(fieldName, button); + } + + // Hide the button itself + button.classList.add("display-none"); + + // Unlock after it completes + button.disabled = false + }); + } + + function setupListener(){ + document.querySelectorAll('[id$="__edit-button"]').forEach(function(button) { + // Get the "{field_name}" and "edit-button" + let fieldIdParts = button.id.split("__") + if (fieldIdParts && fieldIdParts.length > 0){ + let fieldName = fieldIdParts[0] + + // When the edit button is clicked, show the input field under it + handleEditButtonClick(fieldName, button); + } + }); + } + + function showInputOnErrorFields(){ + document.addEventListener('DOMContentLoaded', function() { + // Get all input elements within the form + let form = document.querySelector("#finish-profile-setup-form"); + let inputs = form ? form.querySelectorAll("input") : null; + if (!inputs) { + return null; + } + + let fullNameButtonClicked = false + inputs.forEach(function(input) { + let fieldName = input.name; + let errorMessage = document.querySelector(`#id_${fieldName}__error-message`); + + // If no error message is found, do nothing + if (!fieldName || !errorMessage) { + return null; + } + + let editButton = document.querySelector(`#${fieldName}__edit-button`); + if (editButton){ + // Show the input field of the field that errored out + editButton.click(); + } + + // If either the full_name field errors out, + // or if any of its associated fields do - show all name related fields. + let nameFields = ["first_name", "middle_name", "last_name"]; + if (nameFields.includes(fieldName) && !fullNameButtonClicked){ + // Click the full name button if any of its related fields error out + fullNameButton = document.querySelector("#full_name__edit-button"); + if (fullNameButton) { + fullNameButton.click(); + fullNameButtonClicked = true; + } + } + }); + }); + }; + + // Hookup all edit buttons to the `handleEditButtonClick` function + setupListener(); + + // Show the input fields if an error exists + showInputOnErrorFields(); +})(); diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index b08739072..63ce9882c 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -163,6 +163,7 @@ html[data-theme="dark"] { } } + #branding h1 a:link, #branding h1 a:visited { color: var(--primary-fg); } @@ -194,6 +195,18 @@ div#content > h2 { } } +.change-form { + .usa-table--striped tbody tr:nth-child(odd) td, + .usa-table--striped tbody tr:nth-child(odd) th, + .usa-table td, + .usa-table th { + background-color: transparent; + } + .usa-table td { + border-bottom: 1px solid var(--hairline-color); + } +} + #nav-sidebar { padding-top: 20px; } diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index dc115d69e..e88d75f4e 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -1,4 +1,5 @@ @use "uswds-core" as *; +@use "cisa_colors" as *; /* Styles for making visible to screen reader / AT users only. */ .sr-only { @@ -169,3 +170,44 @@ abbr[title] { .cursor-pointer { cursor: pointer; } + +.input-with-edit-button { + svg.usa-icon { + width: 1.5em !important; + height: 1.5em !important; + color: #{$dhs-green}; + position: absolute; + } + &.input-with-edit-button__error { + svg.usa-icon { + color: #{$dhs-red}; + } + div.readonly-field { + color: #{$dhs-red}; + } + } +} + +// We need to deviate from some default USWDS styles here +// in this particular case, so we have to override this. +.usa-form .usa-button.readonly-edit-button { + margin-top: 0px !important; + padding-top: 0px !important; + svg { + width: 1.25em !important; + height: 1.25em !important; + } +} + +// Define some styles for the .gov header/logo +.usa-logo button { + color: #{$dhs-dark-gray-85}; + font-weight: 700; + font-family: family('sans'); + font-size: 1.6rem; + line-height: 1.1; +} + +.usa-logo button.usa-button--unstyled.disabled-button:hover{ + color: #{$dhs-dark-gray-85}; +} diff --git a/src/registrar/assets/sass/_theme/_buttons.scss b/src/registrar/assets/sass/_theme/_buttons.scss index 1f5047503..4024a6f53 100644 --- a/src/registrar/assets/sass/_theme/_buttons.scss +++ b/src/registrar/assets/sass/_theme/_buttons.scss @@ -1,4 +1,5 @@ @use "uswds-core" as *; +@use "cisa_colors" as *; /* Make "placeholder" links visually obvious */ a[href$="todo"]::after { @@ -7,11 +8,16 @@ a[href$="todo"]::after { content: " [link TBD]"; font-style: italic; } - + +a.usa-link.usa-link--always-blue { + color: #{$dhs-blue}; +} + a.breadcrumb__back { display:flex; align-items: center; margin-bottom: units(2.5); + color: #{$dhs-blue}; &:visited { color: color('primary'); } diff --git a/src/registrar/assets/sass/_theme/_cisa_colors.scss b/src/registrar/assets/sass/_theme/_cisa_colors.scss index 7466a3490..23ecf7989 100644 --- a/src/registrar/assets/sass/_theme/_cisa_colors.scss +++ b/src/registrar/assets/sass/_theme/_cisa_colors.scss @@ -46,6 +46,7 @@ $dhs-gray-10: #fcfdfd; /*--- Dark Gray ---*/ $dhs-dark-gray-90: #040404; +$dhs-dark-gray-85: #1b1b1b; $dhs-dark-gray-80: #19191a; $dhs-dark-gray-70: #2f2f30; $dhs-dark-gray-60: #444547; diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss index 058a9f6c8..c025bdb29 100644 --- a/src/registrar/assets/sass/_theme/_forms.scss +++ b/src/registrar/assets/sass/_theme/_forms.scss @@ -1,4 +1,5 @@ @use "uswds-core" as *; +@use "cisa_colors" as *; .usa-form .usa-button { margin-top: units(3); @@ -26,6 +27,34 @@ } } +.usa-form-editable { + border-top: 2px #{$dhs-dark-gray-15} solid; + + .bold-usa-label label.usa-label{ + font-weight: bold; + } + + &.bold-usa-label label.usa-label{ + font-weight: bold; + } + + &.usa-form-editable--no-border { + border-top: None; + margin-top: 0px !important; + } + +} + +.usa-form-editable > .usa-form-group:first-of-type { + margin-top: unset; +} + +@media (min-width: 35em) { + .usa-form--largest { + max-width: 35rem; + } +} + .usa-form-group--unstyled-error { margin-left: 0; padding-left: 0; @@ -52,4 +81,4 @@ legend.float-left-tablet + button.float-right-tablet { background-color: var(--body-fg); color: var(--close-button-hover-bg); } -} \ No newline at end of file +} diff --git a/src/registrar/assets/sass/_theme/_tooltips.scss b/src/registrar/assets/sass/_theme/_tooltips.scss index 04c6f3cda..3ab630dc0 100644 --- a/src/registrar/assets/sass/_theme/_tooltips.scss +++ b/src/registrar/assets/sass/_theme/_tooltips.scss @@ -24,3 +24,7 @@ text-align: center !important; } } + +#extended-logo .usa-tooltip__body { + font-weight: 400 !important; +} diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 9f31ffc2c..851f3550c 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -162,7 +162,7 @@ MIDDLEWARE = [ # django-cors-headers: listen to cors responses "corsheaders.middleware.CorsMiddleware", # custom middleware to stop caching from CloudFront - "registrar.no_cache_middleware.NoCacheMiddleware", + "registrar.registrar_middleware.NoCacheMiddleware", # serve static assets in production "whitenoise.middleware.WhiteNoiseMiddleware", # provide security enhancements to the request/response cycle @@ -188,6 +188,7 @@ MIDDLEWARE = [ "auditlog.middleware.AuditlogMiddleware", # Used for waffle feature flags "waffle.middleware.WaffleMiddleware", + "registrar.registrar_middleware.CheckUserProfileMiddleware", ] # application object used by Django’s built-in servers (e.g. `runserver`) @@ -326,7 +327,7 @@ SERVER_EMAIL = "root@get.gov" # endregion # region: Waffle feature flags-----------------------------------------------------------### -# If Waffle encounters a reference to a flag that is not in the database, should Waffle create the flag? +# If Waffle encounters a reference to a flag that is not in the database, create the flag automagically. WAFFLE_CREATE_MISSING_FLAGS = True # The model that will be used to keep track of flags. Extends AbstractUserFlag. diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 1279e0fd8..bf13b950e 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -180,6 +180,11 @@ urlpatterns = [ views.DomainAddUserView.as_view(), name="domain-users-add", ), + path( + "finish-profile-setup", + views.FinishProfileSetupView.as_view(), + name="finish-user-profile-setup", + ), path( "user-profile", views.UserProfileView.as_view(), diff --git a/src/registrar/forms/user_profile.py b/src/registrar/forms/user_profile.py index 11b5cc069..557e34e0d 100644 --- a/src/registrar/forms/user_profile.py +++ b/src/registrar/forms/user_profile.py @@ -60,4 +60,35 @@ class UserProfileForm(forms.ModelForm): } self.fields["phone"].error_messages["required"] = "Enter your phone number." + if self.instance and self.instance.phone: + self.fields["phone"].initial = self.instance.phone.as_national + DomainHelper.disable_field(self.fields["email"], disable_required=True) + + +class FinishSetupProfileForm(UserProfileForm): + """Form for updating user profile.""" + + full_name = forms.CharField(required=True, label="Full name") + + def clean(self): + cleaned_data = super().clean() + # Remove the full name property + if "full_name" in cleaned_data: + # Delete the full name element as its purely decorative. + # We include it as a normal Charfield for all the advantages + # and utility that it brings, but we're playing pretend. + del cleaned_data["full_name"] + return cleaned_data + + def __init__(self, *args, **kwargs): + """Override the inerited __init__ method to update the fields.""" + + super().__init__(*args, **kwargs) + + # Set custom form label for email + self.fields["email"].label = "Organization email" + self.fields["title"].label = "Title or role in your organization" + + # Define the "full_name" value + self.fields["full_name"].initial = self.instance.get_formatted_name() diff --git a/src/registrar/management/commands/clean_tables.py b/src/registrar/management/commands/clean_tables.py new file mode 100644 index 000000000..fa37c214d --- /dev/null +++ b/src/registrar/management/commands/clean_tables.py @@ -0,0 +1,68 @@ +import logging +from django.conf import settings +from django.core.management import BaseCommand +from django.apps import apps +from django.db import transaction + +from registrar.management.commands.utility.terminal_helper import TerminalHelper + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Clean tables in database to prepare for import." + + def handle(self, **options): + """Delete all rows from a list of tables""" + + if settings.IS_PRODUCTION: + logger.error("clean_tables cannot be run in production") + return + + TerminalHelper.prompt_for_execution( + system_exit_on_terminate=True, + info_to_inspect=""" + This script will delete all rows from the following tables: + * Contact + * Domain + * DomainInformation + * DomainRequest + * DraftDomain + * Host + * HostIp + * PublicContact + * User + * Website + """, + prompt_title="Do you wish to proceed with these changes?", + ) + + table_names = [ + "DomainInformation", + "DomainRequest", + "PublicContact", + "Domain", + "User", + "Contact", + "Website", + "DraftDomain", + "HostIp", + "Host", + ] + + for table_name in table_names: + self.clean_table(table_name) + + def clean_table(self, table_name): + """Delete all rows in the given table""" + try: + # Get the model class dynamically + model = apps.get_model("registrar", table_name) + # Use a transaction to ensure database integrity + with transaction.atomic(): + model.objects.all().delete() + logger.info(f"Successfully cleaned table {table_name}") + except LookupError: + logger.error(f"Model for table {table_name} not found.") + except Exception as e: + logger.error(f"Error cleaning table {table_name}: {e}") diff --git a/src/registrar/management/commands/export_tables.py b/src/registrar/management/commands/export_tables.py new file mode 100644 index 000000000..f927129fe --- /dev/null +++ b/src/registrar/management/commands/export_tables.py @@ -0,0 +1,64 @@ +import logging +import os +import pyzipper +from django.core.management import BaseCommand +import registrar.admin + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Exports tables in csv format to zip file in tmp directory." + + def handle(self, **options): + """Generates CSV files for specified tables and creates a zip archive""" + table_names = [ + "User", + "Contact", + "Domain", + "DomainRequest", + "DomainInformation", + "UserDomainRole", + "DraftDomain", + "Website", + "HostIp", + "Host", + "PublicContact", + ] + + # Ensure the tmp directory exists + os.makedirs("tmp", exist_ok=True) + + for table_name in table_names: + self.export_table(table_name) + + # Create a zip file containing all the CSV files + zip_filename = "tmp/exported_tables.zip" + with pyzipper.AESZipFile(zip_filename, "w", compression=pyzipper.ZIP_DEFLATED) as zipf: + for table_name in table_names: + csv_filename = f"tmp/{table_name}.csv" + if os.path.exists(csv_filename): + zipf.write(csv_filename, os.path.basename(csv_filename)) + logger.info(f"Added {csv_filename} to zip archive {zip_filename}") + + # Remove the CSV files after adding them to the zip file + for table_name in table_names: + csv_filename = f"tmp/{table_name}.csv" + if os.path.exists(csv_filename): + os.remove(csv_filename) + logger.info(f"Removed temporary file {csv_filename}") + + def export_table(self, table_name): + """Export a given table to a csv file in the tmp directory""" + resourcename = f"{table_name}Resource" + try: + resourceclass = getattr(registrar.admin, resourcename) + dataset = resourceclass().export() + filename = f"tmp/{table_name}.csv" + with open(filename, "w") as outputfile: + outputfile.write(dataset.csv) + logger.info(f"Successfully exported {table_name} to {filename}") + except AttributeError: + logger.error(f"Resource class {resourcename} not found in registrar.admin") + except Exception as e: + logger.error(f"Failed to export {table_name}: {e}") diff --git a/src/registrar/management/commands/import_tables.py b/src/registrar/management/commands/import_tables.py new file mode 100644 index 000000000..3594d3215 --- /dev/null +++ b/src/registrar/management/commands/import_tables.py @@ -0,0 +1,104 @@ +import logging +import os +import pyzipper +import tablib +from django.apps import apps +from django.conf import settings +from django.db import transaction +from django.core.management import BaseCommand +import registrar.admin + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Imports tables from a zip file, exported_tables.zip, containing CSV files in the tmp directory." + + def handle(self, **options): + """Extracts CSV files from a zip archive and imports them into the respective tables""" + + if settings.IS_PRODUCTION: + logger.error("import_tables cannot be run in production") + return + + table_names = [ + "User", + "Contact", + "Domain", + "Host", + "HostIp", + "DraftDomain", + "Website", + "DomainRequest", + "DomainInformation", + "UserDomainRole", + "PublicContact", + ] + + # Ensure the tmp directory exists + os.makedirs("tmp", exist_ok=True) + + # Unzip the file + zip_filename = "tmp/exported_tables.zip" + if not os.path.exists(zip_filename): + logger.error(f"Zip file {zip_filename} does not exist.") + return + + with pyzipper.AESZipFile(zip_filename, "r") as zipf: + zipf.extractall("tmp") + logger.info(f"Extracted zip file {zip_filename} into tmp directory") + + # Import each CSV file + for table_name in table_names: + self.import_table(table_name) + + def import_table(self, table_name): + """Import data from a CSV file into the given table""" + + resourcename = f"{table_name}Resource" + csv_filename = f"tmp/{table_name}.csv" + try: + if not os.path.exists(csv_filename): + logger.error(f"CSV file {csv_filename} not found.") + return + + # if table_name is Contact, clean the table first + # User table is loaded before Contact, and signals create + # rows in Contact table which break the import, so need + # to be cleaned again before running import on Contact table + if table_name == "Contact": + self.clean_table(table_name) + + resourceclass = getattr(registrar.admin, resourcename) + resource_instance = resourceclass() + with open(csv_filename, "r") as csvfile: + dataset = tablib.Dataset().load(csvfile.read(), format="csv") + result = resource_instance.import_data(dataset, dry_run=False, skip_epp_save=True) + + if result.has_errors(): + logger.error(f"Errors occurred while importing {csv_filename}: {result.row_errors()}") + else: + logger.info(f"Successfully imported {csv_filename} into {table_name}") + + except AttributeError: + logger.error(f"Resource class {resourcename} not found in registrar.admin") + except Exception as e: + logger.error(f"Failed to import {csv_filename}: {e}") + finally: + if os.path.exists(csv_filename): + os.remove(csv_filename) + logger.info(f"Removed temporary file {csv_filename}") + + def clean_table(self, table_name): + """Delete all rows in the given table""" + try: + # Get the model class dynamically + model = apps.get_model("registrar", table_name) + # Use a transaction to ensure database integrity + with transaction.atomic(): + model.objects.all().delete() + logger.info(f"Successfully cleaned table {table_name}") + except LookupError: + logger.error(f"Model for table {table_name} not found.") + except Exception as e: + logger.error(f"Error cleaning table {table_name}: {e}") diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index bd82ad0b7..22325a3ee 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -938,3 +938,131 @@ class DomainRequest(TimeStampedModel): for field in opts.many_to_many: data[field.name] = field.value_from_object(self) return data + + def _is_federal_complete(self): + # Federal -> "Federal government branch" page can't be empty + Federal Agency selection can't be None + return not (self.federal_type is None or self.federal_agency is None) + + def _is_interstate_complete(self): + # Interstate -> "About your organization" page can't be empty + return self.about_your_organization is not None + + def _is_state_or_territory_complete(self): + # State -> ""Election office" page can't be empty + return self.is_election_board is not None + + def _is_tribal_complete(self): + # Tribal -> "Tribal name" and "Election office" page can't be empty + return self.tribe_name is not None and self.is_election_board is not None + + def _is_county_complete(self): + # County -> "Election office" page can't be empty + return self.is_election_board is not None + + def _is_city_complete(self): + # City -> "Election office" page can't be empty + return self.is_election_board is not None + + def _is_special_district_complete(self): + # Special District -> "Election office" and "About your organization" page can't be empty + return self.is_election_board is not None and self.about_your_organization is not None + + def _is_organization_name_and_address_complete(self): + return not ( + self.organization_name is None + and self.address_line1 is None + and self.city is None + and self.state_territory is None + and self.zipcode is None + ) + + def _is_authorizing_official_complete(self): + return self.authorizing_official is not None + + def _is_requested_domain_complete(self): + return self.requested_domain is not None + + def _is_purpose_complete(self): + return self.purpose is not None + + def _is_submitter_complete(self): + return self.submitter is not None + + def _has_other_contacts_and_filled(self): + # Other Contacts Radio button is Yes and if all required fields are filled + return ( + self.has_other_contacts() + and self.other_contacts.filter( + first_name__isnull=False, + last_name__isnull=False, + title__isnull=False, + email__isnull=False, + phone__isnull=False, + ).exists() + ) + + def _has_no_other_contacts_gives_rationale(self): + # Other Contacts Radio button is No and a rationale is provided + return self.has_other_contacts() is False and self.no_other_contacts_rationale is not None + + def _is_other_contacts_complete(self): + if self._has_other_contacts_and_filled() or self._has_no_other_contacts_gives_rationale(): + return True + return False + + def _cisa_rep_and_email_check(self): + # Has a CISA rep + email is NOT empty or NOT an empty string OR doesn't have CISA rep + return ( + self.has_cisa_representative is True + and self.cisa_representative_email is not None + and self.cisa_representative_email != "" + ) or self.has_cisa_representative is False + + def _anything_else_radio_button_and_text_field_check(self): + # Anything else boolean is True + filled text field and it's not an empty string OR the boolean is No + return ( + self.has_anything_else_text is True and self.anything_else is not None and self.anything_else != "" + ) or self.has_anything_else_text is False + + def _is_additional_details_complete(self): + return self._cisa_rep_and_email_check() and self._anything_else_radio_button_and_text_field_check() + + def _is_policy_acknowledgement_complete(self): + return self.is_policy_acknowledged is not None + + def _is_general_form_complete(self): + return ( + self._is_organization_name_and_address_complete() + and self._is_authorizing_official_complete() + and self._is_requested_domain_complete() + and self._is_purpose_complete() + and self._is_submitter_complete() + and self._is_other_contacts_complete() + and self._is_additional_details_complete() + and self._is_policy_acknowledgement_complete() + ) + + def _form_complete(self): + match self.generic_org_type: + case DomainRequest.OrganizationChoices.FEDERAL: + is_complete = self._is_federal_complete() + case DomainRequest.OrganizationChoices.INTERSTATE: + is_complete = self._is_interstate_complete() + case DomainRequest.OrganizationChoices.STATE_OR_TERRITORY: + is_complete = self._is_state_or_territory_complete() + case DomainRequest.OrganizationChoices.TRIBAL: + is_complete = self._is_tribal_complete() + case DomainRequest.OrganizationChoices.COUNTY: + is_complete = self._is_county_complete() + case DomainRequest.OrganizationChoices.CITY: + is_complete = self._is_city_complete() + case DomainRequest.OrganizationChoices.SPECIAL_DISTRICT: + is_complete = self._is_special_district_complete() + case _: + # NOTE: Shouldn't happen, this is only if somehow they didn't choose an org type + is_complete = False + + if not is_complete or not self._is_general_form_complete(): + return False + + return True diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index ce14c0a69..005037925 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -98,6 +98,24 @@ class User(AbstractUser): help_text="The means through which this user was verified", ) + @property + def finished_setup(self): + """ + Tracks if the user finished their profile setup or not. This is so + we can globally enforce that new users provide additional account information before proceeding. + """ + + # Change this to self once the user and contact objects are merged. + # For now, since they are linked, lets test on the underlying contact object. + user_info = self.contact # noqa + user_values = [ + user_info.first_name, + user_info.last_name, + user_info.title, + user_info.phone, + ] + return None not in user_values + def __str__(self): # this info is pulled from Login.gov if self.first_name or self.last_name: diff --git a/src/registrar/no_cache_middleware.py b/src/registrar/no_cache_middleware.py deleted file mode 100644 index 5edfca20e..000000000 --- a/src/registrar/no_cache_middleware.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Middleware to add Cache-control: no-cache to every response. - -Used to force Cloudfront caching to leave us alone while we develop -better caching responses. -""" - - -class NoCacheMiddleware: - """Middleware to add a single header to every response.""" - - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - response = self.get_response(request) - response["Cache-Control"] = "no-cache" - return response diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py new file mode 100644 index 000000000..f9921513b --- /dev/null +++ b/src/registrar/registrar_middleware.py @@ -0,0 +1,100 @@ +""" +Contains middleware used in settings.py +""" + +from urllib.parse import parse_qs +from django.urls import reverse +from django.http import HttpResponseRedirect +from waffle.decorators import flag_is_active + +from registrar.models.utility.generic_helper import replace_url_queryparams + + +class NoCacheMiddleware: + """ + Middleware to add Cache-control: no-cache to every response. + + Used to force Cloudfront caching to leave us alone while we develop + better caching responses. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + response["Cache-Control"] = "no-cache" + return response + + +class CheckUserProfileMiddleware: + """ + Checks if the current user has finished_setup = False. + If they do, redirect them to the setup page regardless of where they are in + the application. + """ + + def __init__(self, get_response): + self.get_response = get_response + + self.setup_page = reverse("finish-user-profile-setup") + self.logout_page = reverse("logout") + self.excluded_pages = [ + self.setup_page, + self.logout_page, + ] + + def __call__(self, request): + response = self.get_response(request) + return response + + def process_view(self, request, view_func, view_args, view_kwargs): + """Runs pre-processing logic for each view. Checks for the + finished_setup flag on the current user. If they haven't done so, + then we redirect them to the finish setup page.""" + # Check that the user is "opted-in" to the profile feature flag + has_profile_feature_flag = flag_is_active(request, "profile_feature") + + # If they aren't, skip this check entirely + if not has_profile_feature_flag: + return None + + if request.user.is_authenticated: + if hasattr(request.user, "finished_setup") and not request.user.finished_setup: + return self._handle_setup_not_finished(request) + + # Continue processing the view + return None + + def _handle_setup_not_finished(self, request): + """Redirects the given user to the finish setup page. + + We set the "redirect" query param equal to where the user wants to go. + + If the user wants to go to '/request/', then we set that + information in the query param. + + Otherwise, we assume they want to go to the home page. + """ + + # In some cases, we don't want to redirect to home. This handles that. + # Can easily be generalized if need be, but for now lets keep this easy to read. + custom_redirect = "domain-request:" if request.path == "/request/" else None + + # Don't redirect on excluded pages (such as the setup page itself) + if not any(request.path.startswith(page) for page in self.excluded_pages): + + # Preserve the original query parameters, and coerce them into a dict + query_params = parse_qs(request.META["QUERY_STRING"]) + + # Set the redirect value to our redirect location + if custom_redirect is not None: + query_params["redirect"] = custom_redirect + + # Add our new query param, while preserving old ones + new_setup_page = replace_url_queryparams(self.setup_page, query_params) if query_params else self.setup_page + + return HttpResponseRedirect(new_setup_page) + else: + # Process the view as normal + return None diff --git a/src/registrar/templates/base.html b/src/registrar/templates/base.html index 958813029..fc49c19ec 100644 --- a/src/registrar/templates/base.html +++ b/src/registrar/templates/base.html @@ -139,11 +139,7 @@
Federally-recognized tribe
{% endif %} @@ -47,7 +47,7 @@ {% if step == Step.ORGANIZATION_FEDERAL %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.get_federal_type_display|default:"Incomplete" %} + {% with title=form_titles|get_item:step value=domain_request.get_federal_type_display|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -66,7 +66,7 @@ {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url address='true' %} {% endwith %} {% else %} - {% with title=form_titles|get_item:step value='Incomplete' %} + {% with title=form_titles|get_item:step value="Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -74,7 +74,7 @@ {% if step == Step.ABOUT_YOUR_ORGANIZATION %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.about_your_organization|default:"Incomplete" %} + {% with title=form_titles|get_item:step value=domain_request.about_your_organization|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -86,7 +86,7 @@ {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url contact='true' %} {% endwith %} {% else %} - {% with title=form_titles|get_item:step value="Incomplete" %} + {% with title=form_titles|get_item:step value="Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -107,7 +107,7 @@ {% if step == Step.DOTGOV_DOMAIN %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.requested_domain.name|default:"Incomplete" %} + {% with title=form_titles|get_item:step value=domain_request.requested_domain.name|default:"Incomplete"|safe%} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} @@ -123,7 +123,7 @@ {% if step == Step.PURPOSE %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.purpose|default:"Incomplete" %} + {% with title=form_titles|get_item:step value=domain_request.purpose|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -135,7 +135,7 @@ {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url contact='true' %} {% endwith %} {% else %} - {% with title=form_titles|get_item:step value="Incomplete" %} + {% with title=form_titles|get_item:step value="Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -148,7 +148,7 @@ {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url contact='true' list='true' %} {% endwith %} {% else %} - {% with title=form_titles|get_item:step value=domain_request.no_other_contacts_rationale|default:"Incomplete" %} + {% with title=form_titles|get_item:step value=domain_request.no_other_contacts_rationale|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -157,7 +157,7 @@ {% if step == Step.ADDITIONAL_DETAILS %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.requested_domain.name|default:"Incomplete" %} + {% with title=form_titles|get_item:step value=domain_request.requested_domain.name|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title sub_header_text='CISA regional representative' value=domain_request.cisa_representative_email heading_level=heading_level editable=True edit_link=domain_request_url custom_text_for_value_none='No' %} {% endwith %} diff --git a/src/registrar/templates/finish_profile_setup.html b/src/registrar/templates/finish_profile_setup.html new file mode 100644 index 000000000..f8070551b --- /dev/null +++ b/src/registrar/templates/finish_profile_setup.html @@ -0,0 +1,20 @@ +{% extends "profile.html" %} + +{% load static form_helpers url_helpers field_helpers %} +{% block title %} Finish setting up your profile | {% endblock %} + +{# Disable the redirect #} +{% block logo %} + {% include "includes/gov_extended_logo.html" with logo_clickable=confirm_changes %} +{% endblock %} + +{# Add the new form #} +{% block content_bottom %} + {% include "includes/finish_profile_form.html" with form=form %} + + +{% endblock content_bottom %} + +{% block footer %} + {% include "includes/footer.html" with show_manage_your_domains=confirm_changes %} +{% endblock footer %} diff --git a/src/registrar/templates/includes/finish_profile_form.html b/src/registrar/templates/includes/finish_profile_form.html new file mode 100644 index 000000000..a40534b48 --- /dev/null +++ b/src/registrar/templates/includes/finish_profile_form.html @@ -0,0 +1,89 @@ +{% extends 'includes/profile_form.html' %} + +{% load static url_helpers %} +{% load field_helpers %} + +{% block profile_header %} ++ We require + that you maintain accurate contact information. + The details you provide will only be used to support the administration of .gov and won’t be made public. +
+ ++ Review the details below and update any required information. + Note that editing this information won’t affect your Login.gov account information. +
+ +{# We use a var called 'remove_margin_top' rather than 'add_margin_top' because this is more useful as a default #} +{% include "includes/required_fields.html" with remove_margin_top=True %} + +{% endblock profile_blurb %} + +{% block profile_form %} + + +{% endblock profile_form %} diff --git a/src/registrar/templates/includes/footer.html b/src/registrar/templates/includes/footer.html index 42bd4ddcf..fdf91a64e 100644 --- a/src/registrar/templates/includes/footer.html +++ b/src/registrar/templates/includes/footer.html @@ -26,10 +26,12 @@ >