manage.get.gov/src/registrar/views/application.py
2023-12-15 11:43:20 -05:00

570 lines
22 KiB
Python

import logging
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render
from django.urls import resolve, reverse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView
from django.contrib import messages
from registrar.forms import application_wizard as forms
from registrar.models import DomainApplication
from registrar.utility import StrEnum
from registrar.views.utility import StepsHelper
from .utility import DomainApplicationPermissionView, ApplicationWizardPermissionView
logger = logging.getLogger(__name__)
class Step(StrEnum):
"""
Names for each page of the application wizard.
As with Django's own `TextChoices` class, steps will
appear in the order they are defined. (Order matters.)
"""
ORGANIZATION_TYPE = "organization_type"
TRIBAL_GOVERNMENT = "tribal_government"
ORGANIZATION_FEDERAL = "organization_federal"
ORGANIZATION_ELECTION = "organization_election"
ORGANIZATION_CONTACT = "organization_contact"
ABOUT_YOUR_ORGANIZATION = "about_your_organization"
AUTHORIZING_OFFICIAL = "authorizing_official"
CURRENT_SITES = "current_sites"
DOTGOV_DOMAIN = "dotgov_domain"
PURPOSE = "purpose"
YOUR_CONTACT = "your_contact"
OTHER_CONTACTS = "other_contacts"
NO_OTHER_CONTACTS = "no_other_contacts"
ANYTHING_ELSE = "anything_else"
REQUIREMENTS = "requirements"
REVIEW = "review"
class ApplicationWizard(ApplicationWizardPermissionView, TemplateView):
"""
A common set of methods and configuration.
The registrar's domain application is several pages of "steps".
Together, these steps constitute a "wizard".
This base class sets up a shared state (stored in the user's session)
between pages of the application and provides common methods for
processing form data.
Views for each step should inherit from this base class.
Any method not marked as internal can be overridden in a subclass,
although not without consulting the base implementation, first.
"""
template_name = ""
# uniquely namespace the wizard in urls.py
# (this is not seen _in_ urls, only for Django's internal naming)
# NB: this is included here for reference. Do not change it without
# also changing the many places it is hardcoded in the HTML templates
URL_NAMESPACE = "application"
# name for accessing /application/<id>/edit
EDIT_URL_NAME = "edit-application"
NEW_URL_NAME = "/register/"
# We need to pass our human-readable step titles as context to the templates.
TITLES = {
Step.ORGANIZATION_TYPE: _("Type of organization"),
Step.TRIBAL_GOVERNMENT: _("Tribal government"),
Step.ORGANIZATION_FEDERAL: _("Federal government branch"),
Step.ORGANIZATION_ELECTION: _("Election office"),
Step.ORGANIZATION_CONTACT: _("Organization name and mailing address"),
Step.ABOUT_YOUR_ORGANIZATION: _("About your organization"),
Step.AUTHORIZING_OFFICIAL: _("Authorizing official"),
Step.CURRENT_SITES: _("Current website for your organization"),
Step.DOTGOV_DOMAIN: _(".gov domain"),
Step.PURPOSE: _("Purpose of your domain"),
Step.YOUR_CONTACT: _("Your contact information"),
Step.OTHER_CONTACTS: _("Other employees from your organization"),
Step.NO_OTHER_CONTACTS: _("No other employees from your organization?"),
Step.ANYTHING_ELSE: _("Anything else?"),
Step.REQUIREMENTS: _("Requirements for operating .gov domains"),
Step.REVIEW: _("Review and submit your domain request"),
}
# We can use a dictionary with step names and callables that return booleans
# to show or hide particular steps based on the state of the process.
WIZARD_CONDITIONS = {
Step.ORGANIZATION_FEDERAL: lambda w: w.from_model("show_organization_federal", False),
Step.TRIBAL_GOVERNMENT: lambda w: w.from_model("show_tribal_government", False),
Step.ORGANIZATION_ELECTION: lambda w: w.from_model("show_organization_election", False),
Step.ABOUT_YOUR_ORGANIZATION: lambda w: w.from_model("show_about_your_organization", False),
Step.NO_OTHER_CONTACTS: lambda w: w.from_model("show_no_other_contacts_rationale", False),
}
def __init__(self):
super().__init__()
self.steps = StepsHelper(self)
self._application = None # for caching
def has_pk(self):
"""Does this wizard know about a DomainApplication database record?"""
return "application_id" in self.storage
@property
def prefix(self):
"""Namespace the wizard to avoid clashes in session variable names."""
# this is a string literal but can be made dynamic if we'd like
# users to have multiple applications open for editing simultaneously
return "wizard_application"
@property
def application(self) -> DomainApplication:
"""
Attempt to match the current wizard with a DomainApplication.
Will create an application if none exists.
"""
if self._application:
return self._application
if self.has_pk():
id = self.storage["application_id"]
try:
self._application = DomainApplication.objects.get(
creator=self.request.user, # type: ignore
pk=id,
)
return self._application
except DomainApplication.DoesNotExist:
logger.debug("Application id %s did not have a DomainApplication" % id)
self._application = DomainApplication.objects.create(
creator=self.request.user, # type: ignore
)
self.storage["application_id"] = self._application.id
return self._application
@property
def storage(self):
# marking session as modified on every access
# so that updates to nested keys are always saved
self.request.session.modified = True
return self.request.session.setdefault(self.prefix, {})
@storage.setter
def storage(self, value):
self.request.session[self.prefix] = value
self.request.session.modified = True
@storage.deleter
def storage(self):
if self.prefix in self.request.session:
del self.request.session[self.prefix]
self.request.session.modified = True
def done(self):
"""Called when the user clicks the submit button, if all forms are valid."""
self.application.submit() # change the status to submitted
self.application.save()
logger.debug("Application object saved: %s", self.application.id)
return redirect(reverse(f"{self.URL_NAMESPACE}:finished"))
def from_model(self, attribute: str, default, *args, **kwargs):
"""
Get a attribute from the database model, if it exists.
If it is a callable, call it with any given `args` and `kwargs`.
This method exists so that we can avoid needlessly creating a record
in the database before the wizard has been saved.
"""
if self.has_pk():
if hasattr(self.application, attribute):
attr = getattr(self.application, attribute)
if callable(attr):
return attr(*args, **kwargs)
else:
return attr
else:
raise AttributeError("Model has no attribute %s" % str(attribute))
else:
return default
def get(self, request, *args, **kwargs):
"""This method handles GET requests."""
current_url = resolve(request.path_info).url_name
# if user visited via an "edit" url, associate the id of the
# application they are trying to edit to this wizard instance
# and remove any prior wizard data from their session
if current_url == self.EDIT_URL_NAME and "id" in kwargs:
del self.storage
self.storage["application_id"] = kwargs["id"]
# if accessing this class directly, redirect to the first step
# in other words, if `ApplicationWizard` is called as view
# directly by some redirect or url handler, we'll send users
# to the first step in the processes; subclasses will NOT
# be redirected. The purpose of this is to allow code to
# send users "to the application wizard" without needing to
# know which view is first in the list of steps.
if self.__class__ == ApplicationWizard:
# if starting a new application, clear the storage
if request.path_info == self.NEW_URL_NAME:
del self.storage
return self.goto(self.steps.first)
self.steps.current = current_url
context = self.get_context_data()
context["forms"] = self.get_forms()
# if pending requests exist and user does not have approved domains,
# present message that domain application cannot be submitted
pending_requests = self.pending_requests()
if len(pending_requests) > 0:
message_header = "You cannot submit this request yet"
message_content = (
f"<h4 class='usa-alert__heading'>{message_header}</h4> "
"<p class='margin-bottom-0'>New domain requests cannot be submitted until we have finished "
f"reviewing your pending request: <strong>{pending_requests[0].requested_domain}</strong>. "
"You can continue to fill out this request and save it as a draft to be submitted later. "
f"<a class='usa-link' href='{reverse('home')}'>View your pending requests.</a></p>"
)
context["pending_requests_message"] = mark_safe(message_content) # nosec
context["pending_requests_exist"] = len(pending_requests) > 0
return render(request, self.template_name, context)
def get_all_forms(self, **kwargs) -> list:
"""
Calls `get_forms` for all steps and returns a flat list.
All arguments (**kwargs) are passed directly to `get_forms`.
"""
nested = (self.get_forms(step=step, **kwargs) for step in self.steps)
flattened = [form for lst in nested for form in lst]
return flattened
def get_forms(self, step=None, use_post=False, use_db=False, files=None):
"""
This method constructs the forms for a given step.
The form's initial data will always be gotten from the database,
via the form's `from_database` classmethod.
The form's bound data will be gotten from POST if `use_post` is True,
and from the database if `use_db` is True (provided that record exists).
An empty form will be provided if neither of those are true.
"""
kwargs = {
"files": files,
"prefix": self.steps.current,
"application": self.application, # this is a property, not an object
}
if step is None:
forms = self.forms
else:
url = reverse(f"{self.URL_NAMESPACE}:{step}")
forms = resolve(url).func.view_class.forms
instantiated = []
for form in forms:
data = form.from_database(self.application) if self.has_pk() else None
if use_post:
instantiated.append(form(self.request.POST, **kwargs))
elif use_db:
instantiated.append(form(data, **kwargs))
else:
instantiated.append(form(initial=data, **kwargs))
return instantiated
def pending_requests(self):
"""return an array of pending requests if user has pending requests
and no approved requests"""
if self.approved_applications_exist() or self.approved_domains_exist():
return []
else:
return self.pending_applications()
def approved_applications_exist(self):
"""Checks if user is creator of applications with ApplicationStatus.APPROVED status"""
approved_application_count = DomainApplication.objects.filter(
creator=self.request.user, status=DomainApplication.ApplicationStatus.APPROVED
).count()
return approved_application_count > 0
def approved_domains_exist(self):
"""Checks if user has permissions on approved domains
This additional check is necessary to account for domains which were migrated
and do not have an application"""
return self.request.user.permissions.count() > 0
def pending_applications(self):
"""Returns a List of user's applications with one of the following states:
ApplicationStatus.SUBMITTED, ApplicationStatus.IN_REVIEW, ApplicationStatus.ACTION_NEEDED"""
# if the current application has ApplicationStatus.ACTION_NEEDED status, this check should not be performed
if self.application.status == DomainApplication.ApplicationStatus.ACTION_NEEDED:
return []
check_statuses = [
DomainApplication.ApplicationStatus.SUBMITTED,
DomainApplication.ApplicationStatus.IN_REVIEW,
DomainApplication.ApplicationStatus.ACTION_NEEDED,
]
return DomainApplication.objects.filter(creator=self.request.user, status__in=check_statuses)
def get_context_data(self):
"""Define context for access on all wizard pages."""
# Build the submit button that we'll pass to the modal.
modal_button = '<button type="submit" ' 'class="usa-button" ' ">Submit request</button>"
# Concatenate the modal header that we'll pass to the modal.
if self.application.requested_domain:
modal_heading = "You are about to submit a domain request for " + str(self.application.requested_domain)
else:
modal_heading = "You are about to submit an incomplete request"
return {
"form_titles": self.TITLES,
"steps": self.steps,
# Add information about which steps should be unlocked
"visited": self.storage.get("step_history", []),
"is_federal": self.application.is_federal(),
"modal_button": modal_button,
"modal_heading": modal_heading,
}
def get_step_list(self) -> list:
"""Dynamically generated list of steps in the form wizard."""
step_list = []
for step in Step:
condition = self.WIZARD_CONDITIONS.get(step, True)
if callable(condition):
condition = condition(self)
if condition:
step_list.append(step)
return step_list
def goto(self, step):
self.steps.current = step
return redirect(reverse(f"{self.URL_NAMESPACE}:{step}"))
def goto_next_step(self):
"""Redirects to the next step."""
next = self.steps.next
if next:
self.steps.current = next
return self.goto(next)
else:
raise Http404()
def is_valid(self, forms: list) -> bool:
"""Returns True if all forms in the wizard are valid."""
are_valid = (form.is_valid() for form in forms)
return all(are_valid)
def post(self, request, *args, **kwargs) -> HttpResponse:
"""This method handles POST requests."""
# if accessing this class directly, redirect to the first step
if self.__class__ == ApplicationWizard:
return self.goto(self.steps.first)
# which button did the user press?
button: str = request.POST.get("submit_button", "")
forms = self.get_forms(use_post=True)
if self.is_valid(forms):
# always save progress
self.save(forms)
else:
context = self.get_context_data()
context["forms"] = forms
return render(request, self.template_name, context)
# if user opted to save their progress,
# return them to the page they were already on
if button == "save":
messages.success(request, "Your progress has been saved!")
return self.goto(self.steps.current)
# if user opted to save progress and return,
# return them to the home page
if button == "save_and_return":
return HttpResponseRedirect(reverse("home"))
# otherwise, proceed as normal
return self.goto_next_step()
def save(self, forms: list):
"""
Unpack the form responses onto the model object properties.
Saves the application to the database.
"""
for form in forms:
if form is not None and hasattr(form, "to_database"):
form.to_database(self.application)
class OrganizationType(ApplicationWizard):
template_name = "application_org_type.html"
forms = [forms.OrganizationTypeForm]
class TribalGovernment(ApplicationWizard):
template_name = "application_tribal_government.html"
forms = [forms.TribalGovernmentForm]
class OrganizationFederal(ApplicationWizard):
template_name = "application_org_federal.html"
forms = [forms.OrganizationFederalForm]
class OrganizationElection(ApplicationWizard):
template_name = "application_org_election.html"
forms = [forms.OrganizationElectionForm]
class OrganizationContact(ApplicationWizard):
template_name = "application_org_contact.html"
forms = [forms.OrganizationContactForm]
class AboutYourOrganization(ApplicationWizard):
template_name = "application_about_your_organization.html"
forms = [forms.AboutYourOrganizationForm]
class AuthorizingOfficial(ApplicationWizard):
template_name = "application_authorizing_official.html"
forms = [forms.AuthorizingOfficialForm]
def get_context_data(self):
context = super().get_context_data()
context["organization_type"] = self.application.organization_type
context["federal_type"] = self.application.federal_type
return context
class CurrentSites(ApplicationWizard):
template_name = "application_current_sites.html"
forms = [forms.CurrentSitesFormSet]
class DotgovDomain(ApplicationWizard):
template_name = "application_dotgov_domain.html"
forms = [forms.DotGovDomainForm, forms.AlternativeDomainFormSet]
def get_context_data(self):
context = super().get_context_data()
context["organization_type"] = self.application.organization_type
context["federal_type"] = self.application.federal_type
return context
class Purpose(ApplicationWizard):
template_name = "application_purpose.html"
forms = [forms.PurposeForm]
class YourContact(ApplicationWizard):
template_name = "application_your_contact.html"
forms = [forms.YourContactForm]
class OtherContacts(ApplicationWizard):
template_name = "application_other_contacts.html"
forms = [forms.OtherContactsFormSet]
class NoOtherContacts(ApplicationWizard):
template_name = "application_no_other_contacts.html"
forms = [forms.NoOtherContactsForm]
class AnythingElse(ApplicationWizard):
template_name = "application_anything_else.html"
forms = [forms.AnythingElseForm]
class Requirements(ApplicationWizard):
template_name = "application_requirements.html"
forms = [forms.RequirementsForm]
class Review(ApplicationWizard):
template_name = "application_review.html"
forms = [] # type: ignore
def get_context_data(self):
context = super().get_context_data()
context["Step"] = Step.__members__
context["application"] = self.application
return context
def goto_next_step(self):
return self.done()
# TODO: validate before saving, show errors
# Extra info:
#
# Formtools used saved POST data to revalidate each form as
# the user had entered it. This implementation (in this file) discards
# that data and tries to instantiate the forms from the database
# in order to perform validation.
#
# This must be possible in Django (after all, that is how ModelForms work),
# but is presently not working: the form claims it is invalid,
# even when careful checking via breakpoint() shows that the form
# object contains valid data.
#
# forms = self.get_all_forms()
# if self.is_valid(forms):
# return self.done()
# else:
# # TODO: errors to let users know why this isn't working
# return self.goto(self.steps.current)
class Finished(ApplicationWizard):
template_name = "application_done.html"
forms = [] # type: ignore
def get(self, request, *args, **kwargs):
context = self.get_context_data()
context["application_id"] = self.application.id
# clean up this wizard session, because we are done with it
del self.storage
return render(self.request, self.template_name, context)
class ApplicationStatus(DomainApplicationPermissionView):
template_name = "application_status.html"
class ApplicationWithdrawConfirmation(DomainApplicationPermissionView):
"""This page will ask user to confirm if they want to withdraw
The DomainApplicationPermissionView restricts access so that only the
`creator` of the application may withdraw it.
"""
template_name = "application_withdraw_confirmation.html"
class ApplicationWithdrawn(DomainApplicationPermissionView):
# this view renders no template
template_name = ""
def get(self, *args, **kwargs):
"""View class that does the actual withdrawing.
If user click on withdraw confirm button, this view updates the status
to withdraw and send back to homepage.
"""
application = DomainApplication.objects.get(id=self.kwargs["pk"])
application.withdraw()
application.save()
return HttpResponseRedirect(reverse("home"))