This commit is contained in:
Rachid Mrad 2024-07-01 12:51:54 -04:00
commit a95168de3c
No known key found for this signature in database
39 changed files with 1003 additions and 442 deletions

View file

@ -0,0 +1,47 @@
# 26. Django Waffle library for Feature Flags
Date: 2024-07-06
## Status
Approved
## Context
We release finished code twice weekly, allowing features to reach users quickly. However, several upcoming features require a series of changes that will need to be done over a few sprints and should only be displayed to users once we are all done. Thus, users would see half-finished features if we followed our standard process.
At the same time, some of these features should only be turned on for users upon request (and likely during user research). We would want a way for our CISA users to turn this feature on and off for people without requiring a lengthy process or code changes.
This brought us to finding solutions that could fix one or both of these problems.
## Considered Options
**Option 1:** Environment variables
The environment allows developers to set a true or false value to the given variable, allowing implementation over multiple sprints when new features are encapsulated with this variable. The feature shows when the variable is on (true); otherwise, it remains hidden. Environment variables are also innate to Django, making them free to use; on top of that, we already use them for other things in our code.
The downside is that you would need to go to cloud.gov or use the cf CLI to see the current settings on a sandbox. This is very technical, meaning only developers would really be able to see what features were set, and we would be the only ones able to adjust them. It would also be easy to accidentally have the feature on or off without noticing. This also would not solve the problem of turning features on and off quickly for a given user group.
**Option 2:** Feature branches
Like environment variables, using feature branches would be free and allow us to iterate on developing big features over multiple sprints. We would make a feature branch that developers working on that feature would push and pull from to iterate on. This quickly brings us to the downsides of this approach.
Using feature branches, we do not solve the problem of being able to turn features on and off quickly for a user group. More importantly, by working in a separate branch for more than a sprint, we easily risk having out-of-sync migrations and merge conflicts that would slow development time and cause frustration. Out-of-sync migrations can also cause technical issues on sandboxes, further contributing to development frustration.
**Option 3:** Feature flags
Feature flags are free, allowing us to implement features over multiple sprints, and some libraries can apply features based on UserGroups while even more come with an interface for non-developers to control turning feature flags on and off. Going with this decision would also entail picking the correct library or product.
**Option 3a:** Feature flags with Waffle
The Waffle feature flag library is a highly recommended Django library for handling large features. It has clear documentation on turning on feature flags for user groups, which is one of the main problems it attempts to solve. It also provides "Samples" that can turn on flags for a certain percentage of users and "Switches" that can be used to turn features on and off holistically. The reviews from those who used it were highly favorable, some even mentioning how it beat out competitors like Gargoyl. It's also compatible with Django admin, providing a quick way to add the view of the flags in Django admin so any user with admin access can modify flags for their sandbox.
The repo has had new releases every year since its the creation and looks to be well maintained, with many issues on the repo referring to new feature requests.
**Option 3b:** Feature flags with Gargoyl
Gargoyl is another feature-flag library with Django, but it is no longer maintained, and reviews say it wasn't as easy to work with as Waffle. Using it would require forking the library, and many outstanding issues indicate bugs that need fixing. The mixed reviews from those who have done this and the less robust documentation were immediately huge cons to using this as an option.
**Option 3c:** Paid feature flag system with GitHub integration- LaunchDarkly
LaunchDarkly is a Fedramped solution with excellent reviews for controlling feature flags straight from GitHub to promote any team member easily controlling feature flags. However, the big con to this was that it would be a paid solution and would take time to procure, thus slowing down our ability to start on these significant features. We shouldn't consider LaunchDarkly because taking time to procure it would negatively affect our timeline, even if the budget was eventually approved.
## Decision
Option 3a, feature flags with the Django Waffle library
## Consequences
We are now reliant on the Waffle library for feature flags. As with any library, we would need to fork it if it ever became non-maintained with critical bugs. This doesn't seem likely in the near future, but if it occurred, we could complete the forking and fix any bug within a sprint without drastically impacting our timeline.

View file

@ -0,0 +1,65 @@
# Terminal Helper Functions
`terminal_helper.py` contains utility functions to assist with common terminal and script operations.
This file documents what they do and provides guidance on their usage.
## TerminalColors
`TerminalColors` provides ANSI color codes as variables to style terminal output.
## ScriptDataHelper
### bulk_update_fields
`bulk_update_fields` performs a memory-efficient bulk update on a Django model in batches using a Paginator.
## TerminalHelper
### log_script_run_summary
`log_script_run_summary` logs a summary of a script run, including counts of updated, skipped, and failed records.
### print_conditional
`print_conditional` conditionally logs a statement at a specified severity if a condition is met.
### prompt_for_execution
`prompt_for_execution` prompts the user to inspect a string and confirm if they wish to proceed. Returns True if proceeding, False if skipping, or exits the script.
### query_yes_no
`query_yes_no` prompts the user with a yes/no question and returns True for "yes" or False for "no".
### query_yes_no_exit
`query_yes_no_exit` is similar to `query_yes_no` but includes an "exit" option to terminate the script.
## PopulateScriptTemplate
`PopulateScriptTemplate` is an abstract base class that provides a template for creating generic populate scripts. It handles logging and bulk updating for repetitive scripts that update a few fields.
### **Disclaimer**
This template is intended as a shorthand for simple scripts. It is not recommended for complex operations. See `transfer_federal_agency.py` for a straightforward example of how to use this template.
### Step-by-step usage guide
To create a script using `PopulateScriptTemplate`:
1. Create a new class that inherits from `PopulateScriptTemplate`
2. Implement the `update_record` method to define how each record should be updated
3. Optionally, override the configuration variables and helper methods as needed
4. Call `mass_update_records` within `handle` and run the script
#### Template explanation
The main method provided by `PopulateScriptTemplate` is `mass_update_records`. This method loops through each valid object (specified by `filter_conditions`) and updates the fields defined in `fields_to_update` using the `update_record` method.
Before updating, `mass_update_records` prompts the user to confirm the proposed changes. If the user does not proceed, the script will exit.
After processing the records, `mass_update_records` performs a bulk update on the specified fields using `ScriptDataHelper.bulk_update_fields` and logs a summary of the script run using `TerminalHelper.log_script_run_summary`.
#### Config options
The class provides the following optional configuration variables:
- `prompt_title`: The header displayed by `prompt_for_execution` when the script starts (default: "Do you wish to proceed?")
- `display_run_summary_items_as_str`: If True, runs `str(item)` on each item when printing the run summary for prettier output (default: False)
- `run_summary_header`: The header for the script run summary printed after the script finishes (default: None)
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)

View file

@ -697,3 +697,58 @@ Example: `cf ssh getgov-za`
| | Parameter | Description |
|:-:|:-------------------------- |:----------------------------------------------------------------------------|
| 1 | **debug** | Increases logging detail. Defaults to False. |
## Transfer federal agency script
The transfer federal agency script adds the "federal_type" field on each associated DomainRequest, and uses that to populate the "federal_type" field on each FederalAgency.
**Important:** When running this script, note that data generated by our fixtures will be inaccurate (since we assign random data to them). Use real data on this script.
Do note that there is a check on record uniqueness. If two or more records do NOT have the same value for federal_type for any given federal agency, then the record is skipped. This protects against fixtures data when loaded with real data.
### 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 transfer_federal_agency_type```
### Running locally
#### Step 1: Running the script
```docker-compose exec app ./manage.py transfer_federal_agency_type```
## Email current metadata report
### 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 email_current_metadata_report --emailTo {desired email address}```
### Running locally
#### Step 1: Running the script
```docker-compose exec app ./manage.py email_current_metadata_report --emailTo {desired email address}```
##### Parameters
| | Parameter | Description |
|:-:|:-------------------------- |:-----------------------------------------------------------------------------------|
| 1 | **emailTo** | Specifies where the email will be emailed. Defaults to help@get.gov on production. |

View file

@ -1,7 +1,8 @@
from datetime import date
import logging
import copy
import json
from django.template.loader import get_template
from django import forms
from django.db.models import Value, CharField, Q
from django.db.models.functions import Concat, Coalesce
@ -13,7 +14,6 @@ from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import Group
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from dateutil.relativedelta import relativedelta # type: ignore
from epplibwrapper.errors import ErrorCode, RegistryError
from registrar.models.user_domain_role import UserDomainRole
from waffle.admin import FlagAdmin
@ -1689,6 +1689,10 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return super().save_model(request, obj, form, change)
# == Handle non-status changes == #
# Change this in #1901. Add a check on "not self.action_needed_reason_email"
if obj.action_needed_reason:
self._handle_action_needed_reason_email(obj)
should_save = True
# Get the original domain request from the database.
original_obj = models.DomainRequest.objects.get(pk=obj.pk)
@ -1697,14 +1701,17 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return super().save_model(request, obj, form, change)
# == Handle status changes == #
# Run some checks on the current object for invalid status changes
obj, should_save = self._handle_status_change(request, obj, original_obj)
# We should only save if we don't display any errors in the step above.
# We should only save if we don't display any errors in the steps above.
if should_save:
return super().save_model(request, obj, form, change)
def _handle_action_needed_reason_email(self, obj):
text = self._get_action_needed_reason_default_email_text(obj, obj.action_needed_reason)
obj.action_needed_reason_email = text.get("email_body_text")
def _handle_status_change(self, request, obj, original_obj):
"""
Checks for various conditions when a status change is triggered.
@ -1898,10 +1905,45 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Initialize extra_context and add filtered entries
extra_context = extra_context or {}
extra_context["filtered_audit_log_entries"] = filtered_audit_log_entries
extra_context["action_needed_reason_emails"] = self.get_all_action_needed_reason_emails_as_json(obj)
# Call the superclass method with updated extra_context
return super().change_view(request, object_id, form_url, extra_context)
def get_all_action_needed_reason_emails_as_json(self, domain_request):
"""Returns a json dictionary of every action needed reason and its associated email
for this particular domain request."""
emails = {}
for action_needed_reason in domain_request.ActionNeededReasons:
enum_value = action_needed_reason.value
# Change this in #1901. Just add a check for the current value.
emails[enum_value] = self._get_action_needed_reason_default_email_text(domain_request, enum_value)
return json.dumps(emails)
def _get_action_needed_reason_default_email_text(self, domain_request, action_needed_reason: str):
"""Returns the default email associated with the given action needed reason"""
if action_needed_reason is None or action_needed_reason == domain_request.ActionNeededReasons.OTHER:
return {
"subject_text": None,
"email_body_text": None,
}
# Get the email body
template_path = f"emails/action_needed_reasons/{action_needed_reason}.txt"
template = get_template(template_path)
# Get the email subject
template_subject_path = f"emails/action_needed_reasons/{action_needed_reason}_subject.txt"
subject_template = get_template(template_subject_path)
# Return the content of the rendered views
context = {"domain_request": domain_request}
return {
"subject_text": subject_template.render(context=context),
"email_body_text": template.render(context=context),
}
def process_log_entry(self, log_entry):
"""Process a log entry and return filtered entry dictionary if applicable."""
changes = log_entry.changes
@ -2195,25 +2237,12 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
extra_context["state_help_message"] = Domain.State.get_admin_help_text(domain.state)
extra_context["domain_state"] = domain.get_state_display()
# Pass in what the an extended expiration date would be for the expiration date modal
self._set_expiration_date_context(domain, extra_context)
extra_context["curr_exp_date"] = (
domain.expiration_date if domain.expiration_date is not None else self._get_current_date()
)
return super().changeform_view(request, object_id, form_url, extra_context)
def _set_expiration_date_context(self, domain, extra_context):
"""Given a domain, calculate the an extended expiration date
from the current registry expiration date."""
years_to_extend_by = self._get_calculated_years_for_exp_date(domain)
try:
curr_exp_date = domain.registry_expiration_date
except KeyError:
# No expiration date was found. Return none.
extra_context["extended_expiration_date"] = None
else:
new_date = curr_exp_date + relativedelta(years=years_to_extend_by)
extra_context["extended_expiration_date"] = new_date
def response_change(self, request, obj):
# Create dictionary of action functions
ACTION_FUNCTIONS = {
@ -2241,11 +2270,9 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
self.message_user(request, "Object is not of type Domain.", messages.ERROR)
return None
years = self._get_calculated_years_for_exp_date(obj)
# Renew the domain.
try:
obj.renew_domain(length=years)
obj.renew_domain()
self.message_user(
request,
"Successfully extended the expiration date.",
@ -2270,37 +2297,6 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return HttpResponseRedirect(".")
def _get_calculated_years_for_exp_date(self, obj, extension_period: int = 1):
"""Given the current date, an extension period, and a registry_expiration_date
on the domain object, calculate the number of years needed to extend the
current expiration date by the extension period.
"""
# Get the date we want to update to
desired_date = self._get_current_date() + relativedelta(years=extension_period)
# Grab the current expiration date
try:
exp_date = obj.registry_expiration_date
except KeyError:
# if no expiration date from registry, set it to today
logger.warning("current expiration date not set; setting to today")
exp_date = self._get_current_date()
# If the expiration date is super old (2020, for example), we need to
# "catch up" to the current year, so we add the difference.
# If both years match, then lets just proceed as normal.
calculated_exp_date = exp_date + relativedelta(years=extension_period)
year_difference = desired_date.year - exp_date.year
years = extension_period
if desired_date > calculated_exp_date:
# Max probably isn't needed here (no code flow), but it guards against negative and 0.
# In both of those cases, we just want to extend by the extension_period.
years = max(extension_period, year_difference)
return years
# Workaround for unit tests, as we cannot mock date directly.
# it is immutable. Rather than dealing with a convoluted workaround,
# lets wrap this in a function.

View file

@ -8,6 +8,25 @@
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// Helper functions.
/**
* Hide element
*
*/
const hideElement = (element) => {
if (element && !element.classList.contains("display-none"))
element.classList.add('display-none');
};
/**
* Show element
*
*/
const showElement = (element) => {
if (element && element.classList.contains("display-none"))
element.classList.remove('display-none');
};
/** Either sets attribute target="_blank" to a given element, or removes it */
function openInNewTab(el, removeAttribute = false){
if(removeAttribute){
@ -57,6 +76,7 @@ function openInNewTab(el, removeAttribute = false){
createPhantomModalFormButtons();
})();
/** An IIFE for DomainRequest to hook a modal to a dropdown option.
* This intentionally does not interact with createPhantomModalFormButtons()
*/
@ -406,15 +426,27 @@ function initializeWidgetOnList(list, parentId) {
let statusSelect = document.getElementById('id_status');
function moveStatusChangelog(actionNeededReasonFormGroup, statusSelect) {
if (!actionNeededReasonFormGroup || !statusSelect) {
return;
}
let flexContainer = actionNeededReasonFormGroup.querySelector('.flex-container');
let statusChangelog = document.getElementById('dja-status-changelog');
// On action needed, show the email that will be sent out
let showReasonEmailContainer = document.querySelector("#action_needed_reason_email_readonly")
// Prepopulate values on page load.
if (statusSelect.value === "action needed") {
flexContainer.parentNode.insertBefore(statusChangelog, flexContainer.nextSibling);
showElement(showReasonEmailContainer);
} else {
// Move the changelog back to its original location
let statusFlexContainer = statusSelect.closest('.flex-container');
statusFlexContainer.parentNode.insertBefore(statusChangelog, statusFlexContainer.nextSibling);
hideElement(showReasonEmailContainer);
}
}
// Call the function on page load
@ -518,3 +550,60 @@ function initializeWidgetOnList(list, parentId) {
handleShowMoreButton(toggleButton, descriptionDiv)
}
})();
/** An IIFE that hooks up to the "show email" button.
* which shows the auto generated email on action needed reason.
*/
(function () {
let actionNeededReasonDropdown = document.querySelector("#id_action_needed_reason");
let actionNeededEmail = document.querySelector("#action_needed_reason_email_view_more");
if(actionNeededReasonDropdown && actionNeededEmail && container) {
// Add a change listener to the action needed reason dropdown
handleChangeActionNeededEmail(actionNeededReasonDropdown, actionNeededEmail);
}
function handleChangeActionNeededEmail(actionNeededReasonDropdown, actionNeededEmail) {
actionNeededReasonDropdown.addEventListener("change", function() {
let reason = actionNeededReasonDropdown.value;
// If a reason isn't specified, no email will be sent.
// You also cannot save the model in this state.
// This flow occurs if you switch back to the empty picker state.
if(!reason) {
showNoEmailMessage(actionNeededEmail);
return;
}
let actionNeededEmails = JSON.parse(document.getElementById('action-needed-emails-data').textContent)
let emailData = actionNeededEmails[reason];
if (emailData) {
let emailBody = emailData.email_body_text
if (emailBody) {
actionNeededEmail.value = emailBody
showActionNeededEmail(actionNeededEmail);
}else {
showNoEmailMessage(actionNeededEmail);
}
}else {
showNoEmailMessage(actionNeededEmail);
}
});
}
// Show the text field. Hide the "no email" message.
function showActionNeededEmail(actionNeededEmail){
let noEmailMessage = document.getElementById("no-email-message");
showElement(actionNeededEmail);
hideElement(noEmailMessage);
}
// Hide the text field. Show the "no email" message.
function showNoEmailMessage(actionNeededEmail) {
let noEmailMessage = document.getElementById("no-email-message");
hideElement(actionNeededEmail);
showElement(noEmailMessage);
}
})();

View file

@ -786,3 +786,44 @@ div.dja__model-description{
.usa-button--dja-link-color {
color: var(--link-fg);
}
.dja-readonly-textarea-container {
textarea {
width: 100%;
min-width: 610px;
resize: none;
cursor: auto;
&::-webkit-scrollbar {
background-color: transparent;
border: none;
width: 12px;
}
// Style the scroll bar handle
&::-webkit-scrollbar-thumb {
background-color: var(--body-fg);
border-radius: 99px;
background-clip: content-box;
border: 3px solid transparent;
}
}
}
.max-full {
width: 100% !important;
}
.thin-border {
background-color: var(--selected-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
label {
padding-top: 0 !important;
}
}
.display-none {
// Many elements in django admin try to override this, so we need !important.
display: none !important;
}

View file

@ -10,6 +10,8 @@ from registrar.models.utility.domain_helper import DomainHelper
class UserProfileForm(forms.ModelForm):
"""Form for updating user profile."""
redirect = forms.CharField(widget=forms.HiddenInput(), required=False)
class Meta:
model = Contact
fields = ["first_name", "middle_name", "last_name", "title", "email", "phone"]

View file

@ -1,7 +1,6 @@
"""Generates current-metadata.csv then uploads to S3 + sends email"""
import logging
import os
import pyzipper
from datetime import datetime
@ -9,7 +8,7 @@ from datetime import datetime
from django.core.management import BaseCommand
from django.conf import settings
from registrar.utility import csv_export
from registrar.utility.s3_bucket import S3ClientHelper
from io import StringIO
from ...utility.email import send_templated_email
@ -17,89 +16,101 @@ logger = logging.getLogger(__name__)
class Command(BaseCommand):
"""Emails a encrypted zip file containing a csv of our domains and domain requests"""
help = (
"Generates and uploads a domain-metadata.csv file to our S3 bucket "
"which is based off of all existing Domains."
)
current_date = datetime.now().strftime("%m%d%Y")
def add_arguments(self, parser):
"""Add our two filename arguments."""
parser.add_argument("--directory", default="migrationdata", help="Desired directory")
parser.add_argument(
"--checkpath",
default=True,
help="Flag that determines if we do a check for os.path.exists. Used for test cases",
"--emailTo",
default=settings.DEFAULT_FROM_EMAIL,
help="Defines where we should email this report",
)
def handle(self, **options):
"""Grabs the directory then creates domain-metadata.csv in that directory"""
file_name = "domain-metadata.csv"
# Ensures a slash is added
directory = os.path.join(options.get("directory"), "")
check_path = options.get("checkpath")
zip_filename = f"domain-metadata-{self.current_date}.zip"
email_to = options.get("emailTo")
# Don't email to DEFAULT_FROM_EMAIL when not prod.
if not settings.IS_PRODUCTION and email_to == settings.DEFAULT_FROM_EMAIL:
raise ValueError(
"The --emailTo arg must be specified in non-prod environments, "
"and the arg must not equal the DEFAULT_FROM_EMAIL value (aka: help@get.gov)."
)
logger.info("Generating report...")
try:
self.email_current_metadata_report(directory, file_name, check_path)
self.email_current_metadata_report(zip_filename, email_to)
except Exception as err:
# TODO - #1317: Notify operations when auto report generation fails
raise err
else:
logger.info(f"Success! Created {file_name} and successfully sent out an email!")
logger.info(f"Success! Created {zip_filename} and successfully sent out an email!")
def email_current_metadata_report(self, directory, file_name, check_path):
"""Creates a current-metadata.csv file under the specified directory,
then uploads it to a AWS S3 bucket. This is done for resiliency
reasons in the event our application goes down and/or the email
cannot send -- we'll still be able to grab info from the S3
instance"""
s3_client = S3ClientHelper()
file_path = os.path.join(directory, file_name)
def email_current_metadata_report(self, zip_filename, email_to):
"""Emails a password protected zip containing domain-metadata and domain-request-metadata"""
reports = {
"Domain report": {
"report_filename": f"domain-metadata-{self.current_date}.csv",
"report_function": csv_export.export_data_type_to_csv,
},
"Domain request report": {
"report_filename": f"domain-request-metadata-{self.current_date}.csv",
"report_function": csv_export.DomainRequestExport.export_full_domain_request_report,
},
}
# Generate a file locally for upload
with open(file_path, "w") as file:
csv_export.export_data_type_to_csv(file)
# Set the password equal to our content in SECRET_ENCRYPT_METADATA.
# For local development, this will be "devpwd" unless otherwise set.
# Uncomment these lines if you want to use this:
# override = settings.SECRET_ENCRYPT_METADATA is None and not settings.IS_PRODUCTION
# password = "devpwd" if override else settings.SECRET_ENCRYPT_METADATA
password = settings.SECRET_ENCRYPT_METADATA
if not password:
raise ValueError("No password was specified for this zip file.")
if check_path and not os.path.exists(file_path):
raise FileNotFoundError(f"Could not find newly created file at '{file_path}'")
s3_client.upload_file(file_path, file_name)
# Set zip file name
current_date = datetime.now().strftime("%m%d%Y")
current_filename = f"domain-metadata-{current_date}.zip"
# Pre-set zip file name
encrypted_metadata_output = current_filename
# Set context for the subject
current_date_str = datetime.now().strftime("%Y-%m-%d")
# Encrypt the metadata
encrypted_metadata_in_bytes = self._encrypt_metadata(
s3_client.get_file(file_name), encrypted_metadata_output, str.encode(settings.SECRET_ENCRYPT_METADATA)
)
encrypted_zip_in_bytes = self.get_encrypted_zip(zip_filename, reports, password)
# Send the metadata file that is zipped
send_templated_email(
template_name="emails/metadata_body.txt",
subject_template_name="emails/metadata_subject.txt",
to_address=settings.DEFAULT_FROM_EMAIL,
context={"current_date_str": current_date_str},
attachment_file=encrypted_metadata_in_bytes,
to_address=email_to,
context={"current_date_str": datetime.now().strftime("%Y-%m-%d")},
attachment_file=encrypted_zip_in_bytes,
)
def _encrypt_metadata(self, input_file, output_file, password):
def get_encrypted_zip(self, zip_filename, reports, password):
"""Helper function for encrypting the attachment file"""
current_date = datetime.now().strftime("%m%d%Y")
current_filename = f"domain-metadata-{current_date}.csv"
# Using ZIP_DEFLATED bc it's a more common compression method supported by most zip utilities and faster
# We could also use compression=pyzipper.ZIP_LZMA if we are looking for smaller file size
with pyzipper.AESZipFile(
output_file, "w", compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES
zip_filename, "w", compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES
) as f_out:
f_out.setpassword(password)
f_out.writestr(current_filename, input_file)
with open(output_file, "rb") as file_data:
f_out.setpassword(str.encode(password))
for report_name, report in reports.items():
logger.info(f"Generating {report_name}")
report_content = self.write_and_return_report(report["report_function"])
f_out.writestr(report["report_filename"], report_content)
# Get the final report for emailing purposes
with open(zip_filename, "rb") as file_data:
attachment_in_bytes = file_data.read()
return attachment_in_bytes
def write_and_return_report(self, report_function):
"""Writes a report to a StringIO object given a report_function and returns the string."""
report_bytes = StringIO()
report_function(report_bytes)
# Rewind the buffer to the beginning after writing
report_bytes.seek(0)
return report_bytes.read()

View file

@ -12,12 +12,11 @@ class Command(BaseCommand, PopulateScriptTemplate):
def handle(self, **kwargs):
"""Loops through each valid User object and updates its verification_type value"""
filter_condition = {"verification_type__isnull": True}
self.mass_populate_field(User, filter_condition, ["verification_type"])
self.mass_update_records(User, filter_condition, ["verification_type"])
def populate_field(self, field_to_update):
def update_record(self, record: User):
"""Defines how we update the verification_type field"""
field_to_update.set_user_verification_type()
record.set_user_verification_type()
logger.info(
f"{TerminalColors.OKCYAN}Updating {field_to_update} => "
f"{field_to_update.verification_type}{TerminalColors.OKCYAN}"
f"{TerminalColors.OKCYAN}Updating {record} => " f"{record.verification_type}{TerminalColors.OKCYAN}"
)

View file

@ -0,0 +1,94 @@
import logging
from django.core.management import BaseCommand
from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors
from registrar.models import FederalAgency, DomainInformation
from registrar.utility.constants import BranchChoices
logger = logging.getLogger(__name__)
class Command(BaseCommand, PopulateScriptTemplate):
"""
This command uses the PopulateScriptTemplate,
which provides reusable logging and bulk updating functions for mass-updating fields.
"""
help = "Loops through each valid User object and updates its verification_type value"
prompt_title = "Do you wish to update all Federal Agencies?"
def handle(self, **kwargs):
"""Loops through each valid User object and updates the value of its verification_type field"""
# These are federal agencies that we don't have any data on.
# Independent agencies are considered "EXECUTIVE" here.
self.missing_records = {
"Christopher Columbus Fellowship Foundation": BranchChoices.EXECUTIVE,
"Commission for the Preservation of America's Heritage Abroad": BranchChoices.EXECUTIVE,
"Commission of Fine Arts": BranchChoices.EXECUTIVE,
"Committee for Purchase From People Who Are Blind or Severely Disabled": BranchChoices.EXECUTIVE,
"DC Court Services and Offender Supervision Agency": BranchChoices.EXECUTIVE,
"DC Pre-trial Services": BranchChoices.EXECUTIVE,
"Department of Agriculture": BranchChoices.EXECUTIVE,
"Dwight D. Eisenhower Memorial Commission": BranchChoices.LEGISLATIVE,
"Farm Credit System Insurance Corporation": BranchChoices.EXECUTIVE,
"Federal Financial Institutions Examination Council": BranchChoices.EXECUTIVE,
"Federal Judiciary": BranchChoices.JUDICIAL,
"Institute of Peace": BranchChoices.EXECUTIVE,
"International Boundary and Water Commission: United States and Mexico": BranchChoices.EXECUTIVE,
"International Boundary Commission: United States and Canada": BranchChoices.EXECUTIVE,
"International Joint Commission: United States and Canada": BranchChoices.EXECUTIVE,
"Legislative Branch": BranchChoices.LEGISLATIVE,
"National Foundation on the Arts and the Humanities": BranchChoices.EXECUTIVE,
"Nuclear Safety Oversight Committee": BranchChoices.EXECUTIVE,
"Office of Compliance": BranchChoices.LEGISLATIVE,
"Overseas Private Investment Corporation": BranchChoices.EXECUTIVE,
"Public Defender Service for the District of Columbia": BranchChoices.EXECUTIVE,
"The Executive Office of the President": BranchChoices.EXECUTIVE,
"U.S. Access Board": BranchChoices.EXECUTIVE,
"U.S. Agency for Global Media": BranchChoices.EXECUTIVE,
"U.S. China Economic and Security Review Commission": BranchChoices.LEGISLATIVE,
"U.S. Interagency Council on Homelessness": BranchChoices.EXECUTIVE,
"U.S. International Trade Commission": BranchChoices.EXECUTIVE,
"U.S. Postal Service": BranchChoices.EXECUTIVE,
"U.S. Trade and Development Agency": BranchChoices.EXECUTIVE,
"Udall Foundation": BranchChoices.EXECUTIVE,
"United States Arctic Research Commission": BranchChoices.EXECUTIVE,
"Utah Reclamation Mitigation and Conservation Commission": BranchChoices.EXECUTIVE,
"Vietnam Education Foundation": BranchChoices.EXECUTIVE,
"Woodrow Wilson International Center for Scholars": BranchChoices.EXECUTIVE,
"World War I Centennial Commission": BranchChoices.EXECUTIVE,
}
# Get all existing domain requests. Select_related allows us to skip doing db queries.
self.all_domain_infos = DomainInformation.objects.select_related("federal_agency")
self.mass_update_records(
FederalAgency, filter_conditions={"agency__isnull": False}, fields_to_update=["federal_type"]
)
def update_record(self, record: FederalAgency):
"""Defines how we update the federal_type field on each record."""
request = self.all_domain_infos.filter(federal_agency__agency=record.agency).first()
if request:
record.federal_type = request.federal_type
elif not request and record.agency in self.missing_records:
record.federal_type = self.missing_records.get(record.agency)
logger.info(f"{TerminalColors.OKCYAN}Updating {str(record)} => {record.federal_type}{TerminalColors.ENDC}")
def should_skip_record(self, record) -> bool: # noqa
"""Defines the conditions in which we should skip updating a record."""
requests = self.all_domain_infos.filter(federal_agency__agency=record.agency, federal_type__isnull=False)
# Check if all federal_type values are the same. Skip the record otherwise.
distinct_federal_types = requests.values("federal_type").distinct()
should_skip = distinct_federal_types.count() != 1
if should_skip and record.agency not in self.missing_records:
logger.info(
f"{TerminalColors.YELLOW}Skipping update for {str(record)} => count is "
f"{distinct_federal_types.count()} and records are {distinct_federal_types}{TerminalColors.ENDC}"
)
elif record.agency in self.missing_records:
logger.info(
f"{TerminalColors.MAGENTA}Missing data on {str(record)} - "
f"swapping to manual mapping{TerminalColors.ENDC}"
)
should_skip = False
return should_skip

View file

@ -61,56 +61,96 @@ class ScriptDataHelper:
class PopulateScriptTemplate(ABC):
"""
Contains an ABC for generic populate scripts
Contains an ABC for generic populate scripts.
This template provides reusable logging and bulk updating functions for
mass-updating fields.
"""
def mass_populate_field(self, sender, filter_conditions, fields_to_update):
"""Loops through each valid "sender" object - specified by filter_conditions - and
updates fields defined by fields_to_update using populate_function.
# Optional script-global config variables. For the most part, you can leave these untouched.
# Defines what prompt_for_execution displays as its header when you first start the script
prompt_title: str = "Do you wish to proceed?"
You must define populate_field before you can use this function.
# The header when printing the script run summary (after the script finishes)
run_summary_header = None
@abstractmethod
def update_record(self, record):
"""Defines how we update each field. Must be defined before using mass_update_records."""
raise NotImplementedError
def mass_update_records(self, object_class, filter_conditions, fields_to_update, debug=True):
"""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.
"""
objects = sender.objects.filter(**filter_conditions)
records = object_class.objects.filter(**filter_conditions)
readable_class_name = self.get_class_name(object_class)
# 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 {sender} objects to change: {len(objects)}
Number of {readable_class_name} objects to change: {len(records)}
These fields will be updated on each record: {fields_to_update}
""",
prompt_title="Do you wish to patch this data?",
prompt_title=self.prompt_title,
)
logger.info("Updating...")
to_update: List[sender] = []
failed_to_update: List[sender] = []
for updated_object in objects:
to_update: List[object_class] = []
to_skip: List[object_class] = []
failed_to_update: List[object_class] = []
for record in records:
try:
self.populate_field(updated_object)
to_update.append(updated_object)
if not self.should_skip_record(record):
self.update_record(record)
to_update.append(record)
else:
to_skip.append(record)
except Exception as err:
failed_to_update.append(updated_object)
fail_message = self.get_failure_message(record)
failed_to_update.append(record)
logger.error(err)
logger.error(f"{TerminalColors.FAIL}" f"Failed to update {updated_object}" f"{TerminalColors.ENDC}")
logger.error(fail_message)
# Do a bulk update on the first_ready field
ScriptDataHelper.bulk_update_fields(sender, to_update, fields_to_update)
# Do a bulk update on the desired field
ScriptDataHelper.bulk_update_fields(object_class, to_update, fields_to_update)
# Log what happened
TerminalHelper.log_script_run_summary(to_update, failed_to_update, skipped=[], debug=True)
TerminalHelper.log_script_run_summary(
to_update,
failed_to_update,
to_skip,
debug=debug,
log_header=self.run_summary_header,
display_as_str=True,
)
@abstractmethod
def populate_field(self, field_to_update):
"""Defines how we update each field. Must be defined before using mass_populate_field."""
raise NotImplementedError
def get_class_name(self, sender) -> str:
"""Returns the class name that we want to display for the terminal prompt.
Example: DomainRequest => "Domain Request"
"""
return sender._meta.verbose_name if getattr(sender, "_meta") else sender
def get_failure_message(self, record) -> str:
"""Returns the message that we will display if a record fails to update"""
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."""
# By default - don't skip
return False
class TerminalHelper:
@staticmethod
def log_script_run_summary(to_update, failed_to_update, skipped, debug: bool, log_header=None):
def log_script_run_summary(
to_update, failed_to_update, skipped, debug: bool, log_header=None, display_as_str=False
):
"""Prints success, failed, and skipped counts, as well as
all affected objects."""
update_success_count = len(to_update)
@ -121,20 +161,24 @@ class TerminalHelper:
log_header = "============= FINISHED ==============="
# Prepare debug messages
debug_messages = {
"success": (f"{TerminalColors.OKCYAN}Updated: {to_update}{TerminalColors.ENDC}\n"),
"skipped": (f"{TerminalColors.YELLOW}Skipped: {skipped}{TerminalColors.ENDC}\n"),
"failed": (f"{TerminalColors.FAIL}Failed: {failed_to_update}{TerminalColors.ENDC}\n"),
}
if debug:
updated_display = [str(u) for u in to_update] if display_as_str else to_update
skipped_display = [str(s) for s in skipped] if display_as_str else skipped
failed_display = [str(f) for f in failed_to_update] if display_as_str else failed_to_update
debug_messages = {
"success": (f"{TerminalColors.OKCYAN}Updated: {updated_display}{TerminalColors.ENDC}\n"),
"skipped": (f"{TerminalColors.YELLOW}Skipped: {skipped_display}{TerminalColors.ENDC}\n"),
"failed": (f"{TerminalColors.FAIL}Failed: {failed_display}{TerminalColors.ENDC}\n"),
}
# Print out a list of everything that was changed, if we have any changes to log.
# Otherwise, don't print anything.
TerminalHelper.print_conditional(
debug,
f"{debug_messages.get('success') if update_success_count > 0 else ''}"
f"{debug_messages.get('skipped') if update_skipped_count > 0 else ''}"
f"{debug_messages.get('failed') if update_failed_count > 0 else ''}",
)
# Print out a list of everything that was changed, if we have any changes to log.
# Otherwise, don't print anything.
TerminalHelper.print_conditional(
debug,
f"{debug_messages.get('success') if update_success_count > 0 else ''}"
f"{debug_messages.get('skipped') if update_skipped_count > 0 else ''}"
f"{debug_messages.get('failed') if update_failed_count > 0 else ''}",
)
if update_failed_count == 0 and update_skipped_count == 0:
logger.info(

View file

@ -0,0 +1,18 @@
# Generated by Django 4.2.10 on 2024-06-26 14:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registrar", "0106_create_groups_v14"),
]
operations = [
migrations.AddField(
model_name="domainrequest",
name="action_needed_reason_email",
field=models.TextField(blank=True, null=True),
),
]

View file

@ -7,6 +7,7 @@ from django.conf import settings
from django.db import models
from django_fsm import FSMField, transition # type: ignore
from django.utils import timezone
from waffle import flag_is_active
from registrar.models.domain import Domain
from registrar.models.federal_agency import FederalAgency
from registrar.models.utility.generic_helper import CreateOrUpdateOrganizationTypeHelper
@ -294,6 +295,11 @@ class DomainRequest(TimeStampedModel):
blank=True,
)
action_needed_reason_email = models.TextField(
null=True,
blank=True,
)
federal_agency = models.ForeignKey(
"registrar.FederalAgency",
on_delete=models.PROTECT,
@ -668,34 +674,50 @@ class DomainRequest(TimeStampedModel):
def _send_status_update_email(
self, new_status, email_template, email_template_subject, send_email=True, bcc_address="", wrap_email=False
):
"""Send a status update email to the submitter.
"""Send a status update email to the creator.
The email goes to the email address that the submitter gave as their
contact information. If there is not submitter information, then do
The email goes to the email address that the creator gave as their
contact information. If there is not creator information, then do
nothing.
If the waffle flag "profile_feature" is active, then this email will be sent to the
domain request creator rather than the submitter
send_email: bool -> Used to bypass the send_templated_email function, in the event
we just want to log that an email would have been sent, rather than actually sending one.
wrap_email: bool -> Wraps emails using `wrap_text_and_preserve_paragraphs` if any given
paragraph exceeds our desired max length (for prettier display).
"""
if self.submitter is None or self.submitter.email is None:
logger.warning(f"Cannot send {new_status} email, no submitter email address.")
recipient = self.creator if flag_is_active(None, "profile_feature") else self.submitter
if recipient is None or recipient.email is None:
logger.warning(
f"Cannot send {new_status} email, no creator email address for domain request with pk: {self.pk}."
f" Name: {self.requested_domain.name}"
if self.requested_domain
else ""
)
return None
if not send_email:
logger.info(f"Email was not sent. Would send {new_status} email: {self.submitter.email}")
logger.info(f"Email was not sent. Would send {new_status} email to: {recipient.email}")
return None
try:
send_templated_email(
email_template,
email_template_subject,
self.submitter.email,
context={"domain_request": self},
recipient.email,
context={
"domain_request": self,
# This is the user that we refer to in the email
"recipient": recipient,
},
bcc_address=bcc_address,
wrap_email=wrap_email,
)
logger.info(f"The {new_status} email sent to: {self.submitter.email}")
logger.info(f"The {new_status} email sent to: {recipient.email}")
except EmailSendingError:
logger.warning("Failed to send confirmation email", exc_info=True)
@ -1165,19 +1187,21 @@ class DomainRequest(TimeStampedModel):
def _is_policy_acknowledgement_complete(self):
return self.is_policy_acknowledged is not None
def _is_general_form_complete(self):
def _is_general_form_complete(self, request):
has_profile_feature_flag = flag_is_active(request, "profile_feature")
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()
# NOTE: This flag leaves submitter as empty (request wont submit) hence preset to True
and (self._is_submitter_complete() if not has_profile_feature_flag else True)
and self._is_other_contacts_complete()
and self._is_additional_details_complete()
and self._is_policy_acknowledgement_complete()
)
def _form_complete(self):
def _form_complete(self, request):
match self.generic_org_type:
case DomainRequest.OrganizationChoices.FEDERAL:
is_complete = self._is_federal_complete()
@ -1198,8 +1222,6 @@ class DomainRequest(TimeStampedModel):
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():
if not is_complete or not self._is_general_form_complete(request):
return False
return True

View file

@ -45,6 +45,7 @@ class CheckUserProfileMiddleware:
self.setup_page = reverse("finish-user-profile-setup")
self.profile_page = reverse("user-profile")
self.logout_page = reverse("logout")
self.regular_excluded_pages = [
self.setup_page,
self.logout_page,
@ -56,6 +57,14 @@ class CheckUserProfileMiddleware:
"/admin",
]
self.excluded_pages = {
self.setup_page: self.regular_excluded_pages,
self.profile_page: self.other_excluded_pages,
}
def _get_excluded_pages(self, page):
return self.excluded_pages.get(page, [])
def __call__(self, request):
response = self.get_response(request)
return response
@ -72,16 +81,16 @@ class CheckUserProfileMiddleware:
return None
if request.user.is_authenticated:
profile_page = self.profile_page
if request.user.verification_type == User.VerificationTypeChoices.REGULAR:
profile_page = self.setup_page
if hasattr(request.user, "finished_setup") and not request.user.finished_setup:
if request.user.verification_type == User.VerificationTypeChoices.REGULAR:
return self._handle_regular_user_setup_not_finished(request)
else:
return self._handle_other_user_setup_not_finished(request)
return self._handle_user_setup_not_finished(request, profile_page)
# Continue processing the view
return None
def _handle_regular_user_setup_not_finished(self, request):
def _handle_user_setup_not_finished(self, request, profile_page):
"""Redirects the given user to the finish setup page.
We set the "redirect" query param equal to where the user wants to go.
@ -97,7 +106,7 @@ class CheckUserProfileMiddleware:
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.regular_excluded_pages):
if not any(request.path.startswith(page) for page in self._get_excluded_pages(profile_page)):
# Preserve the original query parameters, and coerce them into a dict
query_params = parse_qs(request.META["QUERY_STRING"])
@ -107,23 +116,13 @@ class CheckUserProfileMiddleware:
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
new_setup_page = replace_url_queryparams(profile_page, query_params) if query_params else profile_page
return HttpResponseRedirect(new_setup_page)
else:
# Process the view as normal
return None
def _handle_other_user_setup_not_finished(self, request):
"""Redirects the given user to the profile page to finish setup."""
# Don't redirect on excluded pages (such as the setup page itself)
if not any(request.path.startswith(page) for page in self.other_excluded_pages):
return HttpResponseRedirect(self.profile_page)
else:
# Process the view as normal
return None
class CheckPortfolioMiddleware:
"""

View file

@ -69,7 +69,7 @@
</h2>
<div class="usa-prose">
<p>
This will extend the expiration date by one year.
This will extend the expiration date by one year from today.
{# Acts as a <br> #}
<div class="display-inline"></div>
This action cannot be undone.
@ -78,7 +78,7 @@
Domain: <b>{{ original.name }}</b>
{# Acts as a <br> #}
<div class="display-inline"></div>
New expiration date: <b>{{ extended_expiration_date }}</b>
Current expiration date: <b>{{ curr_exp_date }}</b>
{{test}}
</p>
</div>

View file

@ -5,7 +5,9 @@
{% block field_sets %}
{# Create an invisible <a> tag so that we can use a click event to toggle the modal. #}
<a id="invisible-ineligible-modal-toggler" class="display-none" href="#toggle-set-ineligible" aria-controls="toggle-set-ineligible" data-open-modal></a>
{# Store the current object id so we can access it easier #}
<input id="domain_request_id" class="display-none" value="{{original.id}}" />
<input id="has_audit_logs" class="display-none" value="{%if filtered_audit_log_entries %}true{% else %}false{% endif %}"/>
{% for fieldset in adminform %}
{% comment %}
TODO: this will eventually need to be changed to something like this

View file

@ -62,17 +62,18 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endwith %}
</div>
{% else %}
<div class="readonly">{{ field.contents }}</div>
<div class="readonly">{{ field.contents }}</div>
{% endif %}
{% endwith %}
{% endblock field_readonly %}
{% block after_help_text %}
{% if field.field.name == "status" and filtered_audit_log_entries %}
{% if field.field.name == "status" %}
<div class="flex-container" id="dja-status-changelog">
<label aria-label="Status changelog"></label>
<div>
<div class="usa-table-container--scrollable collapse--dgsimple collapsed" tabindex="0">
{% if filtered_audit_log_entries %}
<table class="usa-table usa-table--borderless">
<thead>
<tr>
@ -105,7 +106,34 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endfor %}
</tbody>
</table>
{% else %}
<p>No changelog to display.</p>
{% endif %}
{% comment %}
Store the action needed reason emails in a json-based dictionary.
This allows us to change the action_needed_reason_email field dynamically, depending on value.
The alternative to this is an API endpoint.
Given that we have a limited number of emails, doing it this way makes sense.
{% endcomment %}
{% if action_needed_reason_emails %}
<script id="action-needed-emails-data" type="application/json">
{{ action_needed_reason_emails|safe }}
</script>
{% endif %}
<div id="action_needed_reason_email_readonly" class="dja-readonly-textarea-container padding-1 margin-top-2 thin-border display-none">
<label class="max-full" for="action_needed_reason_email_view_more">
<strong>Auto-generated email (sent to submitter)</strong>
</label>
<textarea id="action_needed_reason_email_view_more" cols="40" rows="20" class="{% if not original_object.action_needed_reason %}display-none{% endif %}" readonly>
{{ original_object.action_needed_reason_email }}
</textarea>
<p id="no-email-message" class="{% if original_object.action_needed_reason %}display-none{% endif %}">No email will be sent.</p>
</div>
</div>
<button type="button" class="collapse-toggle--dgsimple usa-button usa-button--unstyled margin-top-2 margin-bottom-1 margin-left-1">
<span>Show details</span>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">

View file

@ -18,7 +18,7 @@
completing your domain request might take around 15 minutes.</p>
{% if has_profile_feature_flag %}
<h2>How well reach you</h2>
<p>While reviewing your domain request, we may need to reach out with questions. Well also email you when we complete our review If the contact information below is not correct, visit <a href="{% url 'user-profile' %}?return_to_request=True" class="usa-link">your profile</a> to make updates.</p>
<p>While reviewing your domain request, we may need to reach out with questions. Well also email you when we complete our review If the contact information below is not correct, visit <a href="{% url 'user-profile' %}?redirect=domain-request:" class="usa-link">your profile</a> to make updates.</p>
{% include "includes/profile_information.html" with user=user%}
{% endif %}

View file

@ -1,5 +1,5 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi, {{ domain_request.submitter.first_name }}.
Hi, {{ recipient.first_name }}.
We've identified an action that youll need to complete before we continue reviewing your .gov domain request.

View file

@ -1,5 +1,5 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi, {{ domain_request.submitter.first_name }}.
Hi, {{ recipient.first_name }}.
We've identified an action that youll need to complete before we continue reviewing your .gov domain request.

View file

@ -1,5 +1,5 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi, {{ domain_request.submitter.first_name }}.
Hi, {{ recipient.first_name }}.
We've identified an action that youll need to complete before we continue reviewing your .gov domain request.

View file

@ -1,5 +1,5 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi, {{ domain_request.submitter.first_name }}.
Hi, {{ recipient.first_name }}.
We've identified an action that youll need to complete before we continue reviewing your .gov domain request.

View file

@ -1,5 +1,5 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi, {{ domain_request.submitter.first_name }}.
Hi, {{ recipient.first_name }}.
Your .gov domain request has been withdrawn and will not be reviewed by our team.

View file

@ -43,7 +43,7 @@ Purpose of your domain:
{{ domain_request.purpose }}
Your contact information:
{% spaceless %}{% include "emails/includes/contact.txt" with contact=domain_request.submitter %}{% endspaceless %}
{% spaceless %}{% include "emails/includes/contact.txt" with contact=recipient %}{% endspaceless %}
Other employees from your organization:{% for other in domain_request.other_contacts.all %}
{% spaceless %}{% include "emails/includes/contact.txt" with contact=other %}{% endspaceless %}

View file

@ -1,5 +1,5 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi, {{ domain_request.submitter.first_name }}.
Hi, {{ recipient.first_name }}.
Congratulations! Your .gov domain request has been approved.

View file

@ -1,5 +1,5 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi, {{ domain_request.submitter.first_name }}.
Hi, {{ recipient.first_name }}.
Your .gov domain request has been rejected.

View file

@ -1,5 +1,5 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi, {{ domain_request.submitter.first_name }}.
Hi, {{ recipient.first_name }}.
We received your .gov domain request.

View file

@ -5,7 +5,7 @@
{# Disable the redirect #}
{% block logo %}
{% include "includes/gov_extended_logo.html" with logo_clickable=confirm_changes %}
{% include "includes/gov_extended_logo.html" with logo_clickable=user_finished_setup %}
{% endblock %}
{# Add the new form #}
@ -16,5 +16,5 @@
{% endblock content_bottom %}
{% block footer %}
{% include "includes/footer.html" with show_manage_your_domains=confirm_changes %}
{% include "includes/footer.html" with show_manage_your_domains=user_finished_setup %}
{% endblock footer %}

View file

@ -33,6 +33,8 @@
Your contact information
</legend>
<input type="hidden" name="redirect" value="{{ form.initial.redirect }}">
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable usa-form-editable--no-border padding-top-2" %}
{% input_with_errors form.full_name %}
{% endwith %}
@ -78,7 +80,7 @@
<button type="submit" name="contact_setup_save_button" class="usa-button ">
Save
</button>
{% if confirm_changes and going_to_specific_page %}
{% if user_finished_setup and going_to_specific_page %}
<button type="submit" name="contact_setup_submit_button" class="usa-button usa-button--outline">
{{redirect_button_text }}
</button>

View file

@ -19,6 +19,9 @@
<form class="usa-form usa-form--large" method="post" novalidate>
{% csrf_token %}
{# Include the hidden 'redirect' field #}
<input type="hidden" name="redirect" value="{{ form.initial.redirect }}">
{% input_with_errors form.first_name %}
{% input_with_errors form.middle_name %}

View file

@ -25,19 +25,13 @@ Edit your User Profile |
{% include "includes/form_errors.html" with form=form %}
{% if show_back_button %}
<a href="{% if not return_to_request %}{% url 'home' %}{% else %}{% url 'domain-request:' %}{% endif %}" class="breadcrumb__back">
<a href="{% url form.initial.redirect %}" class="breadcrumb__back">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
</svg>
{% if not return_to_request %}
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
{{ profile_back_button_text }}
</p>
{% else %}
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
Go back to your domain request
</p>
{% endif %}
</a>
{% endif %}

View file

@ -858,6 +858,8 @@ def completed_domain_request( # noqa
is_election_board=False,
organization_type=None,
federal_agency=None,
federal_type=None,
action_needed_reason=None,
):
"""A completed domain request."""
if not user:
@ -922,6 +924,12 @@ def completed_domain_request( # noqa
if organization_type:
domain_request_kwargs["organization_type"] = organization_type
if federal_type:
domain_request_kwargs["federal_type"] = federal_type
if action_needed_reason:
domain_request_kwargs["action_needed_reason"] = action_needed_reason
domain_request, _ = DomainRequest.objects.get_or_create(**domain_request_kwargs)
if has_other_contacts:

View file

@ -374,9 +374,9 @@ class TestDomainAdmin(MockEppLib, WebTest):
# Create a ready domain with a preset expiration date
domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk]))
# load expiration date into cache and registrar with below command
domain.registry_expiration_date
# Make sure the ex date is what we expect it to be
domain_ex_date = Domain.objects.get(id=domain.id).expiration_date
self.assertEqual(domain_ex_date, date(2023, 5, 25))
@ -400,7 +400,6 @@ class TestDomainAdmin(MockEppLib, WebTest):
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Extend expiration date")
self.assertContains(response, "New expiration date: <b>May 25, 2025</b>")
# Ensure the message we recieve is in line with what we expect
expected_message = "Successfully extended the expiration date."
@ -519,70 +518,10 @@ class TestDomainAdmin(MockEppLib, WebTest):
# Follow the response
response = response.follow()
# This value is based off of the current year - the expiration date.
# We "freeze" time to 2024, so 2024 - 2023 will always result in an
# "extension" of 2, as that will be one year of extension from that date.
extension_length = 2
# Assert that it is calling the function with the right extension length.
# Assert that it is calling the function with the default extension length.
# We only need to test the value that EPP sends, as we can assume the other
# test cases cover the "renew" function.
renew_mock.assert_has_calls([call(length=extension_length)], any_order=False)
# We should not make duplicate calls
self.assertEqual(renew_mock.call_count, 1)
# Assert that everything on the page looks correct
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Extend expiration date")
# Ensure the message we recieve is in line with what we expect
expected_message = "Successfully extended the expiration date."
expected_call = call(
# The WGSI request doesn't need to be tested
ANY,
messages.INFO,
expected_message,
extra_tags="",
fail_silently=False,
)
mock_add_message.assert_has_calls([expected_call], 1)
@patch("registrar.admin.DomainAdmin._get_current_date", return_value=date(2023, 1, 1))
def test_extend_expiration_date_button_date_matches_epp(self, mock_date_today):
"""
Tests if extend_expiration_date button sends the right epp command
when the current year matches the expiration date
"""
# Create a ready domain with a preset expiration date
domain, _ = Domain.objects.get_or_create(name="fake.gov", state=Domain.State.READY)
response = self.app.get(reverse("admin:registrar_domain_change", args=[domain.pk]))
# Make sure that the page is loading as expected
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain.name)
self.assertContains(response, "Extend expiration date")
# Grab the form to submit
form = response.forms["domain_form"]
with patch("django.contrib.messages.add_message") as mock_add_message:
with patch("registrar.models.Domain.renew_domain") as renew_mock:
# Submit the form
response = form.submit("_extend_expiration_date")
# Follow the response
response = response.follow()
extension_length = 1
# Assert that it is calling the function with the right extension length.
# We only need to test the value that EPP sends, as we can assume the other
# test cases cover the "renew" function.
renew_mock.assert_has_calls([call(length=extension_length)], any_order=False)
renew_mock.assert_has_calls([call()], any_order=False)
# We should not make duplicate calls
self.assertEqual(renew_mock.call_count, 1)
@ -944,7 +883,7 @@ class TestDomainRequestAdminForm(TestCase):
self.assertIn("rejection_reason", form.errors)
rejection_reason = form.errors.get("rejection_reason")
self.assertEqual(rejection_reason, ["A rejection reason is required."])
self.assertEqual(rejection_reason, ["A reason is required for this status."])
def test_form_choices_when_no_instance(self):
with less_console_noise():
@ -1596,6 +1535,24 @@ class TestDomainRequestAdmin(MockEppLib):
self.transition_state_and_send_email(domain_request, DomainRequest.DomainRequestStatus.SUBMITTED)
self.assertEqual(len(self.mock_client.EMAILS_SENT), 3)
@less_console_noise_decorator
def test_model_displays_action_needed_email(self):
"""Tests if the action needed email is visible for Domain Requests"""
_domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.ACTION_NEEDED,
action_needed_reason=DomainRequest.ActionNeededReasons.BAD_NAME,
)
p = "userpass"
self.client.login(username="staffuser", password=p)
response = self.client.get(
"/admin/registrar/domainrequest/{}/change/".format(_domain_request.pk),
follow=True,
)
self.assertContains(response, "DOMAIN NAME DOES NOT MEET .GOV REQUIREMENTS")
@override_settings(IS_PRODUCTION=True)
def test_save_model_sends_submitted_email_with_bcc_on_prod(self):
"""When transitioning to submitted from started or withdrawn on a domain request,
@ -1911,7 +1868,7 @@ class TestDomainRequestAdmin(MockEppLib):
messages.error.assert_called_once_with(
request,
"A rejection reason is required.",
"A reason is required for this status.",
)
domain_request.refresh_from_db()
@ -2161,15 +2118,15 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertContains(response, "testy@town.com", count=2)
expected_ao_fields = [
# Field, expected value
("title", "Chief Tester"),
("phone", "(555) 555 5555"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_ao_fields)
self.assertContains(response, "Chief Tester")
self.assertContains(response, "Testy Tester", count=10)
self.assertContains(response, "Testy Tester")
# == Test the other_employees field == #
self.assertContains(response, "testy2@town.com", count=2)
self.assertContains(response, "testy2@town.com")
expected_other_employees_fields = [
# Field, expected value
("title", "Another Tester"),
@ -2290,6 +2247,7 @@ class TestDomainRequestAdmin(MockEppLib):
"status",
"rejection_reason",
"action_needed_reason",
"action_needed_reason_email",
"federal_agency",
"portfolio",
"creator",

View file

@ -2,6 +2,7 @@ import copy
from datetime import date, datetime, time
from django.core.management import call_command
from django.test import TestCase, override_settings
from registrar.utility.constants import BranchChoices
from django.utils import timezone
from django.utils.module_loading import import_string
import logging
@ -1112,3 +1113,115 @@ class TestImportTables(TestCase):
# Check that logger.error was called with the correct message
mock_logger.error.assert_called_once_with("Zip file tmp/exported_tables.zip does not exist.")
class TestTransferFederalAgencyType(TestCase):
"""Tests for the transfer_federal_agency_type script"""
def setUp(self):
"""Creates a fake domain object"""
super().setUp()
self.amtrak, _ = FederalAgency.objects.get_or_create(agency="AMTRAK")
self.legislative_branch, _ = FederalAgency.objects.get_or_create(agency="Legislative Branch")
self.library_of_congress, _ = FederalAgency.objects.get_or_create(agency="Library of Congress")
self.gov_admin, _ = FederalAgency.objects.get_or_create(agency="gov Administration")
self.domain_request_1 = completed_domain_request(
name="testgov.gov",
federal_agency=self.amtrak,
federal_type=BranchChoices.EXECUTIVE,
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
)
self.domain_request_2 = completed_domain_request(
name="cheesefactory.gov",
federal_agency=self.legislative_branch,
federal_type=BranchChoices.LEGISLATIVE,
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
)
self.domain_request_3 = completed_domain_request(
name="meowardslaw.gov",
federal_agency=self.library_of_congress,
federal_type=BranchChoices.JUDICIAL,
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
)
# Duplicate fields with invalid data - we expect to skip updating these
self.domain_request_4 = completed_domain_request(
name="baddata.gov",
federal_agency=self.gov_admin,
federal_type=BranchChoices.EXECUTIVE,
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
)
self.domain_request_5 = completed_domain_request(
name="worsedata.gov",
federal_agency=self.gov_admin,
federal_type=BranchChoices.JUDICIAL,
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
)
self.domain_request_1.approve()
self.domain_request_2.approve()
self.domain_request_3.approve()
self.domain_request_4.approve()
self.domain_request_5.approve()
def tearDown(self):
"""Deletes all DB objects related to migrations"""
super().tearDown()
# Delete domains and related information
Domain.objects.all().delete()
DomainInformation.objects.all().delete()
DomainRequest.objects.all().delete()
User.objects.all().delete()
Contact.objects.all().delete()
Website.objects.all().delete()
FederalAgency.objects.all().delete()
def run_transfer_federal_agency_type(self):
"""
This method executes the transfer_federal_agency_type command.
The 'call_command' function from Django's management framework is then used to
execute the populate_first_ready command with the specified arguments.
"""
with less_console_noise():
with patch(
"registrar.management.commands.utility.terminal_helper.TerminalHelper.query_yes_no_exit", # noqa
return_value=True,
):
call_command("transfer_federal_agency_type")
@less_console_noise_decorator
def test_transfer_federal_agency_type_script(self):
"""
Tests that the transfer_federal_agency_type script updates what we expect, and skips what we expect
"""
# Before proceeding, make sure we don't have any data contamination
tested_agencies = [
self.amtrak,
self.legislative_branch,
self.library_of_congress,
self.gov_admin,
]
for agency in tested_agencies:
self.assertEqual(agency.federal_type, None)
# Run the script
self.run_transfer_federal_agency_type()
# Refresh the local db instance to reflect changes
self.amtrak.refresh_from_db()
self.legislative_branch.refresh_from_db()
self.library_of_congress.refresh_from_db()
self.gov_admin.refresh_from_db()
# Test the values that we expect to be updated
self.assertEqual(self.amtrak.federal_type, BranchChoices.EXECUTIVE)
self.assertEqual(self.legislative_branch.federal_type, BranchChoices.LEGISLATIVE)
self.assertEqual(self.library_of_congress.federal_type, BranchChoices.JUDICIAL)
# We don't expect this field to be updated (as it has duplicate data)
self.assertEqual(self.gov_admin.federal_type, None)

View file

@ -3,6 +3,8 @@ from django.db.utils import IntegrityError
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import RequestFactory
from registrar.models import (
Contact,
DomainRequest,
@ -23,6 +25,7 @@ from registrar.utility.constants import BranchChoices
from .common import MockSESClient, less_console_noise, completed_domain_request, set_domain_request_investigators
from django_fsm import TransitionNotAllowed
from waffle.testutils import override_flag
# Test comment for push -- will remove
@ -31,29 +34,44 @@ from django_fsm import TransitionNotAllowed
@boto3_mocking.patching
class TestDomainRequest(TestCase):
def setUp(self):
self.dummy_user, _ = Contact.objects.get_or_create(
email="mayor@igorville.com", first_name="Hello", last_name="World"
)
self.dummy_user_2, _ = User.objects.get_or_create(
username="intern@igorville.com", email="intern@igorville.com", first_name="Lava", last_name="World"
)
self.started_domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.STARTED, name="started.gov"
status=DomainRequest.DomainRequestStatus.STARTED,
name="started.gov",
)
self.submitted_domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.SUBMITTED, name="submitted.gov"
status=DomainRequest.DomainRequestStatus.SUBMITTED,
name="submitted.gov",
)
self.in_review_domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.IN_REVIEW, name="in-review.gov"
status=DomainRequest.DomainRequestStatus.IN_REVIEW,
name="in-review.gov",
)
self.action_needed_domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.ACTION_NEEDED, name="action-needed.gov"
status=DomainRequest.DomainRequestStatus.ACTION_NEEDED,
name="action-needed.gov",
)
self.approved_domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.APPROVED, name="approved.gov"
status=DomainRequest.DomainRequestStatus.APPROVED,
name="approved.gov",
)
self.withdrawn_domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.WITHDRAWN, name="withdrawn.gov"
status=DomainRequest.DomainRequestStatus.WITHDRAWN,
name="withdrawn.gov",
)
self.rejected_domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.REJECTED, name="rejected.gov"
status=DomainRequest.DomainRequestStatus.REJECTED,
name="rejected.gov",
)
self.ineligible_domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.INELIGIBLE, name="ineligible.gov"
status=DomainRequest.DomainRequestStatus.INELIGIBLE,
name="ineligible.gov",
)
# Store all domain request statuses in a variable for ease of use
@ -197,7 +215,9 @@ class TestDomainRequest(TestCase):
domain_request.submit()
self.assertEqual(domain_request.status, domain_request.DomainRequestStatus.SUBMITTED)
def check_email_sent(self, domain_request, msg, action, expected_count):
def check_email_sent(
self, domain_request, msg, action, expected_count, expected_content=None, expected_email="mayor@igorville.com"
):
"""Check if an email was sent after performing an action."""
with self.subTest(msg=msg, action=action):
@ -211,19 +231,35 @@ class TestDomainRequest(TestCase):
sent_emails = [
email
for email in MockSESClient.EMAILS_SENT
if "mayor@igorville.gov" in email["kwargs"]["Destination"]["ToAddresses"]
if expected_email in email["kwargs"]["Destination"]["ToAddresses"]
]
self.assertEqual(len(sent_emails), expected_count)
if expected_content:
email_content = sent_emails[0]["kwargs"]["Content"]["Simple"]["Body"]["Text"]["Data"]
self.assertIn(expected_content, email_content)
@override_flag("profile_feature", active=False)
def test_submit_from_started_sends_email(self):
msg = "Create a domain request and submit it and see if email was sent."
domain_request = completed_domain_request()
self.check_email_sent(domain_request, msg, "submit", 1)
domain_request = completed_domain_request(submitter=self.dummy_user, user=self.dummy_user_2)
self.check_email_sent(domain_request, msg, "submit", 1, expected_content="Hello")
@override_flag("profile_feature", active=True)
def test_submit_from_started_sends_email_to_creator(self):
"""Tests if, when the profile feature flag is on, we send an email to the creator"""
msg = "Create a domain request and submit it and see if email was sent when the feature flag is on."
domain_request = completed_domain_request(submitter=self.dummy_user, user=self.dummy_user_2)
self.check_email_sent(
domain_request, msg, "submit", 1, expected_content="Lava", expected_email="intern@igorville.com"
)
def test_submit_from_withdrawn_sends_email(self):
msg = "Create a withdrawn domain request and submit it and see if email was sent."
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.WITHDRAWN)
self.check_email_sent(domain_request, msg, "submit", 1)
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.WITHDRAWN, submitter=self.dummy_user
)
self.check_email_sent(domain_request, msg, "submit", 1, expected_content="Hello")
def test_submit_from_action_needed_does_not_send_email(self):
msg = "Create a domain request with ACTION_NEEDED status and submit it, check if email was not sent."
@ -237,18 +273,24 @@ class TestDomainRequest(TestCase):
def test_approve_sends_email(self):
msg = "Create a domain request and approve it and see if email was sent."
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
self.check_email_sent(domain_request, msg, "approve", 1)
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.IN_REVIEW, submitter=self.dummy_user
)
self.check_email_sent(domain_request, msg, "approve", 1, expected_content="Hello")
def test_withdraw_sends_email(self):
msg = "Create a domain request and withdraw it and see if email was sent."
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
self.check_email_sent(domain_request, msg, "withdraw", 1)
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.IN_REVIEW, submitter=self.dummy_user
)
self.check_email_sent(domain_request, msg, "withdraw", 1, expected_content="Hello")
def test_reject_sends_email(self):
msg = "Create a domain request and reject it and see if email was sent."
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.APPROVED)
self.check_email_sent(domain_request, msg, "reject", 1)
domain_request = completed_domain_request(
status=DomainRequest.DomainRequestStatus.APPROVED, submitter=self.dummy_user
)
self.check_email_sent(domain_request, msg, "reject", 1, expected_content="Hello")
def test_reject_with_prejudice_does_not_send_email(self):
msg = "Create a domain request and reject it with prejudice and see if email was sent."
@ -1610,6 +1652,7 @@ class TestDomainInformationCustomSave(TestCase):
class TestDomainRequestIncomplete(TestCase):
def setUp(self):
super().setUp()
self.factory = RequestFactory()
username = "test_user"
first_name = "First"
last_name = "Last"
@ -2013,7 +2056,10 @@ class TestDomainRequestIncomplete(TestCase):
self.assertFalse(self.domain_request._is_policy_acknowledgement_complete())
def test_form_complete(self):
self.assertTrue(self.domain_request._form_complete())
request = self.factory.get("/")
request.user = self.user
self.assertTrue(self.domain_request._form_complete(request))
self.domain_request.generic_org_type = None
self.domain_request.save()
self.assertFalse(self.domain_request._form_complete())
self.assertFalse(self.domain_request._form_complete(request))

View file

@ -531,8 +531,11 @@ class FinishUserProfileTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
def _submit_form_webtest(self, form, follow=False):
page = form.submit()
def _submit_form_webtest(self, form, follow=False, name=None):
if name:
page = form.submit(name=name)
else:
page = form.submit()
self._set_session_cookie()
return page.follow() if follow else page
@ -606,6 +609,15 @@ class FinishUserProfileTests(TestWithUser, WebTest):
self.assertEqual(completed_setup_page.status_code, 200)
finish_setup_form = completed_setup_page.form
# Submit the form using the specific submit button to execute the redirect
completed_setup_page = self._submit_form_webtest(
finish_setup_form, follow=True, name="contact_setup_submit_button"
)
self.assertEqual(completed_setup_page.status_code, 200)
# Assert that we are still on the
# Assert that we're on the domain request page
self.assertNotContains(completed_setup_page, "Finish setting up your profile")
self.assertNotContains(completed_setup_page, "What contact information should we use to reach you?")
@ -822,7 +834,7 @@ class UserProfileTests(TestWithUser, WebTest):
"""tests user profile when profile_feature is on,
and when they are redirected from the domain request page"""
with override_flag("profile_feature", active=True):
response = self.client.get("/user-profile?return_to_request=True")
response = self.client.get("/user-profile?redirect=domain-request:")
self.assertContains(response, "Your profile")
self.assertContains(response, "Go back to your domain request")
self.assertNotContains(response, "Back to manage your domains")

View file

@ -101,7 +101,7 @@ class FSMDomainRequestError(Exception):
FSMErrorCodes.NO_INVESTIGATOR: ("Investigator is required for this status."),
FSMErrorCodes.INVESTIGATOR_NOT_STAFF: ("Investigator is not a staff user."),
FSMErrorCodes.INVESTIGATOR_NOT_SUBMITTER: ("Only the assigned investigator can make this change."),
FSMErrorCodes.NO_REJECTION_REASON: ("A rejection reason is required."),
FSMErrorCodes.NO_REJECTION_REASON: ("A reason is required for this status."),
FSMErrorCodes.NO_ACTION_NEEDED_REASON: ("A reason is required for this status."),
}

View file

@ -383,7 +383,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView):
has_profile_flag = flag_is_active(self.request, "profile_feature")
context_stuff = {}
if DomainRequest._form_complete(self.domain_request):
if DomainRequest._form_complete(self.domain_request, self.request):
modal_button = '<button type="submit" ' 'class="usa-button" ' ">Submit request</button>"
context_stuff = {
"not_form": False,
@ -695,7 +695,7 @@ class Review(DomainRequestWizard):
forms = [] # type: ignore
def get_context_data(self):
if DomainRequest._form_complete(self.domain_request) is False:
if DomainRequest._form_complete(self.domain_request, self.request) is False:
logger.warning("User arrived at review page with an incomplete form.")
context = super().get_context_data()
context["Step"] = Step.__members__

View file

@ -2,13 +2,10 @@
"""
from enum import Enum
import logging
from urllib.parse import parse_qs, unquote
from urllib.parse import quote
from django.contrib import messages
from django.http import QueryDict
from django.views.generic.edit import FormMixin
from registrar.forms.user_profile import UserProfileForm, FinishSetupProfileForm
from django.urls import NoReverseMatch, reverse
@ -20,9 +17,6 @@ from registrar.models.utility.generic_helper import replace_url_queryparams
from registrar.views.utility.permission_views import UserProfilePermissionView
from waffle.decorators import flag_is_active, waffle_flag
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_protect
logger = logging.getLogger(__name__)
@ -34,13 +28,18 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
model = Contact
template_name = "profile.html"
form_class = UserProfileForm
base_view_name = "user-profile"
def get(self, request, *args, **kwargs):
"""Handle get requests by getting user's contact object and setting object
and form to context before rendering."""
self._refresh_session_and_object(request)
form = self.form_class(instance=self.object)
context = self.get_context_data(object=self.object, form=form)
self.object = self.get_object()
# Get the redirect parameter from the query string
redirect = request.GET.get("redirect", "home")
form = self.form_class(instance=self.object, initial={"redirect": redirect})
context = self.get_context_data(object=self.object, form=form, redirect=redirect)
if (
hasattr(self.user, "finished_setup")
@ -49,22 +48,10 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
):
context["show_confirmation_modal"] = True
return_to_request = request.GET.get("return_to_request")
if return_to_request:
context["return_to_request"] = True
return self.render_to_response(context)
def _refresh_session_and_object(self, request):
"""Sets the current session to self.session and the current object to self.object"""
self.session = request.session
self.object = self.get_object()
@waffle_flag("profile_feature") # type: ignore
def dispatch(self, request, *args, **kwargs): # type: ignore
# Store the original queryparams to persist them
query_params = request.META["QUERY_STRING"]
request.session["query_params"] = query_params
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
@ -73,10 +60,14 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
# The text for the back button on this page
context["profile_back_button_text"] = "Go to manage your domains"
context["show_back_button"] = False
# Set the profile_back_button_text based on the redirect parameter
if kwargs.get("redirect") == "domain-request:":
context["profile_back_button_text"] = "Go back to your domain request"
else:
context["profile_back_button_text"] = "Go to manage your domains"
# Show back button conditional on user having finished setup
context["show_back_button"] = False
if hasattr(self.user, "finished_setup") and self.user.finished_setup:
context["user_finished_setup"] = True
context["show_back_button"] = True
@ -84,21 +75,29 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
return context
def get_success_url(self):
"""Redirect to the user's profile page."""
"""Redirect to the user's profile page with updated query parameters."""
query_params = {}
if "query_params" in self.session:
params = unquote(self.session["query_params"])
query_params = parse_qs(params)
# Get the redirect parameter from the form submission
redirect_param = self.request.POST.get("redirect", None)
# Preserve queryparams and add them back to the url
base_url = reverse("user-profile")
new_redirect = replace_url_queryparams(base_url, query_params, convert_list_to_csv=True)
return new_redirect
# Initialize QueryDict with existing query parameters from current request
query_params = QueryDict(mutable=True)
query_params.update(self.request.GET)
# Update query parameters with the 'redirect' value from form submission
if redirect_param and redirect_param != "home":
query_params["redirect"] = redirect_param
# Generate the URL with updated query parameters
base_url = reverse(self.base_view_name)
# Generate the full url from the given query params
full_url = replace_url_queryparams(base_url, query_params)
return full_url
def post(self, request, *args, **kwargs):
"""Handle post requests (form submissions)"""
self._refresh_session_and_object(request)
self.object = self.get_object()
form = self.form_class(request.POST, instance=self.object)
if form.is_valid():
@ -133,139 +132,53 @@ class FinishProfileSetupView(UserProfileView):
"""This view forces the user into providing additional details that
we may have missed from Login.gov"""
class RedirectType(Enum):
"""
Enums for each type of redirection. Enforces behaviour on `get_redirect_url()`.
- HOME: We want to redirect to reverse("home")
- BACK_TO_SELF: We want to redirect back to this page
- TO_SPECIFIC_PAGE: We want to redirect to the page specified in the queryparam "redirect"
- COMPLETE_SETUP: Indicates that we want to navigate BACK_TO_SELF, but the subsequent
redirect after the next POST should be either HOME or TO_SPECIFIC_PAGE
"""
HOME = "home"
TO_SPECIFIC_PAGE = "domain_request"
BACK_TO_SELF = "back_to_self"
COMPLETE_SETUP = "complete_setup"
@classmethod
def get_all_redirect_types(cls) -> list[str]:
"""Returns the value of every redirect type defined in this enum."""
return [r.value for r in cls]
template_name = "finish_profile_setup.html"
form_class = FinishSetupProfileForm
model = Contact
all_redirect_types = RedirectType.get_all_redirect_types()
redirect_type: RedirectType
base_view_name = "finish-user-profile-setup"
def get_context_data(self, **kwargs):
"""Extend get_context_data to include has_profile_feature_flag"""
context = super().get_context_data(**kwargs)
# Hide the back button by default
# Show back button conditional on user having finished setup
context["show_back_button"] = False
if self.redirect_type == self.RedirectType.COMPLETE_SETUP:
context["confirm_changes"] = True
if "redirect_viewname" not in self.session:
if hasattr(self.user, "finished_setup") and self.user.finished_setup:
if kwargs.get("redirect") == "home":
context["show_back_button"] = True
else:
context["going_to_specific_page"] = True
context["redirect_button_text"] = "Continue to your request"
return context
@method_decorator(csrf_protect)
def dispatch(self, request, *args, **kwargs):
"""
Handles dispatching of the view, applying CSRF protection and checking the 'profile_feature' flag.
This method sets the redirect type based on the 'redirect' query parameter,
defaulting to BACK_TO_SELF if not provided.
It updates the session with the redirect view name if the redirect type is TO_SPECIFIC_PAGE.
Returns:
HttpResponse: The response generated by the parent class's dispatch method.
"""
# Update redirect type based on the query parameter if present
default_redirect_value = self.RedirectType.BACK_TO_SELF.value
redirect_value = request.GET.get("redirect", default_redirect_value)
if redirect_value in self.all_redirect_types:
# If the redirect value is a preexisting value in our enum, set it to that.
self.redirect_type = self.RedirectType(redirect_value)
else:
# If the redirect type is undefined, then we assume that we are specifying a particular page to redirect to.
self.redirect_type = self.RedirectType.TO_SPECIFIC_PAGE
# Store the page that we want to redirect to for later use
request.session["redirect_viewname"] = str(redirect_value)
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
"""Form submission posts to this view."""
self._refresh_session_and_object(request)
self.object = self.get_object()
form = self.form_class(request.POST, instance=self.object)
# Get the current form and validate it
if form.is_valid():
self.redirect_page = False
if "contact_setup_save_button" in request.POST:
# Logic for when the 'Save' button is clicked
self.redirect_type = self.RedirectType.COMPLETE_SETUP
# Logic for when the 'Save' button is clicked, which indicates
# user should stay on this page
self.redirect_page = False
elif "contact_setup_submit_button" in request.POST:
specific_redirect = "redirect_viewname" in self.session
self.redirect_type = self.RedirectType.TO_SPECIFIC_PAGE if specific_redirect else self.RedirectType.HOME
# Logic for when the other button is clicked, which indicates
# the user should be taken to the redirect page
self.redirect_page = True
return self.form_valid(form)
else:
return self.form_invalid(form)
def get_success_url(self):
"""Redirect to the nameservers page for the domain."""
return self.get_redirect_url()
def get_redirect_url(self):
"""
Returns a URL string based on the current value of self.redirect_type.
Depending on self.redirect_type, constructs a base URL and appends a
'redirect' query parameter. Handles different redirection types such as
HOME, BACK_TO_SELF, COMPLETE_SETUP, and TO_SPECIFIC_PAGE.
Returns:
str: The full URL with the appropriate query parameters.
"""
# These redirect types redirect to the same page
self_redirect = [self.RedirectType.BACK_TO_SELF, self.RedirectType.COMPLETE_SETUP]
# Maps the redirect type to a URL
base_url = ""
"""Redirect to the redirect page, or redirect to the current page"""
try:
if self.redirect_type in self_redirect:
base_url = reverse("finish-user-profile-setup")
elif self.redirect_type == self.RedirectType.TO_SPECIFIC_PAGE:
# We only allow this session value to use viewnames,
# because this restricts what can be redirected to.
desired_view = self.session["redirect_viewname"]
self.session.pop("redirect_viewname")
base_url = reverse(desired_view)
else:
base_url = reverse("home")
# Get the redirect parameter from the form submission
redirect_param = self.request.POST.get("redirect", None)
if self.redirect_page and redirect_param:
return reverse(redirect_param)
except NoReverseMatch as err:
logger.error(f"get_redirect_url -> Could not find the specified page. Err: {err}")
query_params = {}
# Quote cleans up the value so that it can be used in a url
if self.redirect_type and self.redirect_type.value:
query_params["redirect"] = quote(self.redirect_type.value)
# Generate the full url from the given query params
full_url = replace_url_queryparams(base_url, query_params)
return full_url
return super().get_success_url()