mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-24 19:48:36 +02:00
570 lines
22 KiB
Python
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"))
|