Implement dynamic questions with formsets

This commit is contained in:
Seamus Johnston 2023-01-12 09:07:07 -05:00
parent c0c726e5fa
commit f54276d82b
No known key found for this signature in database
GPG key ID: 2F21225985069105
10 changed files with 249 additions and 105 deletions

View file

@ -1,6 +1,5 @@
import logging import logging
import random import random
import string
from faker import Faker from faker import Faker
from registrar.models import ( from registrar.models import (
@ -81,28 +80,29 @@ class DomainApplicationFixture:
"status": "started", "status": "started",
"organization_name": "Example - Finished but not Submitted", "organization_name": "Example - Finished but not Submitted",
}, },
{ # an example of a more manual application
"status": "started", # {
"organization_name": "Example - Just started", # "status": "started",
"organization_type": "federal", # "organization_name": "Example - Just started",
"federal_agency": None, # "organization_type": "federal",
"federal_type": None, # "federal_agency": None,
"address_line1": None, # "federal_type": None,
"address_line2": None, # "address_line1": None,
"city": None, # "address_line2": None,
"state_territory": None, # "city": None,
"zipcode": None, # "state_territory": None,
"urbanization": None, # "zipcode": None,
"purpose": None, # "urbanization": None,
"security_email": None, # "purpose": None,
"anything_else": None, # "security_email": None,
"is_policy_acknowledged": None, # "anything_else": None,
"authorizing_official": None, # "is_policy_acknowledged": None,
"submitter": None, # "authorizing_official": None,
"other_contacts": [], # "submitter": None,
"current_websites": [], # "other_contacts": [],
"alternative_domains": [], # "current_websites": [],
}, # "alternative_domains": [],
# },
{ {
"status": "submitted", "status": "submitted",
"organization_name": "Example - Submitted but pending Investigation", "organization_name": "Example - Submitted but pending Investigation",
@ -121,12 +121,12 @@ class DomainApplicationFixture:
"last_name": fake.last_name(), "last_name": fake.last_name(),
"title": fake.job(), "title": fake.job(),
"email": fake.ascii_safe_email(), "email": fake.ascii_safe_email(),
"phone": fake.phone_number(), "phone": "201-555-5555",
} }
@classmethod @classmethod
def fake_dot_gov(cls): def fake_dot_gov(cls):
return "".join(random.choices(string.ascii_lowercase, k=16)) + ".gov" # nosec return f"{fake.slug()}.gov"
@classmethod @classmethod
def _set_non_foreign_key_fields(cls, da: DomainApplication, app: dict): def _set_non_foreign_key_fields(cls, da: DomainApplication, app: dict):
@ -199,7 +199,7 @@ class DomainApplicationFixture:
if "other_contacts" in app: if "other_contacts" in app:
for contact in app["other_contacts"]: for contact in app["other_contacts"]:
da.other_contacts.add(Contact.objects.get_or_create(**contact)[0]) da.other_contacts.add(Contact.objects.get_or_create(**contact)[0])
else: elif not da.other_contacts.exists():
other_contacts = [ other_contacts = [
Contact.objects.create(**cls.fake_contact()) Contact.objects.create(**cls.fake_contact())
for _ in range(random.randint(0, 3)) # nosec for _ in range(random.randint(0, 3)) # nosec
@ -211,7 +211,7 @@ class DomainApplicationFixture:
da.current_websites.add( da.current_websites.add(
Website.objects.get_or_create(website=website)[0] Website.objects.get_or_create(website=website)[0]
) )
else: elif not da.current_websites.exists():
current_websites = [ current_websites = [
Website.objects.create(website=fake.uri()) Website.objects.create(website=fake.uri())
for _ in range(random.randint(0, 3)) # nosec for _ in range(random.randint(0, 3)) # nosec
@ -223,7 +223,7 @@ class DomainApplicationFixture:
da.alternative_domains.add( da.alternative_domains.add(
Website.objects.get_or_create(website=domain)[0] Website.objects.get_or_create(website=domain)[0]
) )
else: elif not da.alternative_domains.exists():
alternative_domains = [ alternative_domains = [
Website.objects.create(website=cls.fake_dot_gov()) Website.objects.create(website=cls.fake_dot_gov())
for _ in range(random.randint(0, 3)) # nosec for _ in range(random.randint(0, 3)) # nosec

View file

@ -1,12 +1,12 @@
from __future__ import annotations # allows forward references in annotations from __future__ import annotations # allows forward references in annotations
from itertools import zip_longest
import logging import logging
from phonenumber_field.formfields import PhoneNumberField # type: ignore
from django import forms from django import forms
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from phonenumber_field.formfields import PhoneNumberField # type: ignore
from registrar.models import Contact, DomainApplication, Domain from registrar.models import Contact, DomainApplication, Domain
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -57,6 +57,19 @@ class RegistrarForm(forms.Form):
} # type: ignore } # type: ignore
class RegistrarFormSet(forms.BaseFormSet):
"""
As with RegistrarForm, a common set of methods and configuration.
Subclass this class to create new formsets.
"""
def __init__(self, *args, **kwargs):
# save a reference to an application object
self.application = kwargs.pop("application", None)
super(RegistrarFormSet, self).__init__(*args, **kwargs)
class OrganizationTypeForm(RegistrarForm): class OrganizationTypeForm(RegistrarForm):
organization_type = forms.ChoiceField( organization_type = forms.ChoiceField(
required=True, required=True,
@ -335,6 +348,69 @@ class CurrentSitesForm(RegistrarForm):
) )
class AlternativeDomainForm(RegistrarForm):
alternative_domain = forms.CharField(
required=False,
label="Alternative domain",
)
class BaseAlternativeDomainFormSet(RegistrarFormSet):
def to_database(self, obj: DomainApplication):
if not self.is_valid():
return
obj.save()
query = obj.alternative_domains.order_by("created_at").all() # order matters
# the use of `zip` pairs the forms in the formset with the
# related objects gotten from the database -- there should always be
# at least as many forms as database entries: extra forms means new
# entries, but fewer forms is _not_ the correct way to delete items
# (likely a client-side error or an attempt at data tampering)
for db_obj, post_data in zip_longest(query, self.forms, fillvalue=None):
cleaned = post_data.cleaned_data if post_data is not None else {}
domain = cleaned.get("alternative_domain", None)
# matching database object exists, update it
if db_obj is not None and isinstance(domain, str):
entry_was_erased = domain.strip() == ""
if entry_was_erased:
db_obj.delete()
continue
try:
normalized = Domain.normalize(domain, "gov", blank=True)
except ValueError as e:
logger.debug(e)
continue
db_obj.website = normalized
db_obj.save()
# no matching database object, create it
elif db_obj is None and domain is not None:
try:
normalized = Domain.normalize(domain, "gov", blank=True)
except ValueError as e:
logger.debug(e)
continue
obj.alternative_domains.create(website=normalized)
@classmethod
def from_database(cls, obj):
query = obj.alternative_domains.order_by("created_at").all() # order matters
return [{"alternative_domain": domain.sld} for domain in query]
AlternativeDomainFormSet = forms.formset_factory(
AlternativeDomainForm,
extra=1,
absolute_max=1500,
formset=BaseAlternativeDomainFormSet,
)
class DotGovDomainForm(RegistrarForm): class DotGovDomainForm(RegistrarForm):
def to_database(self, obj): def to_database(self, obj):
if not self.is_valid(): if not self.is_valid():
@ -353,12 +429,6 @@ class DotGovDomainForm(RegistrarForm):
obj.save() obj.save()
obj.save() obj.save()
normalized = Domain.normalize(
self.cleaned_data["alternative_domain"], "gov", blank=True
)
if normalized:
# TODO: ability to update existing records
obj.alternative_domains.create(website=normalized)
@classmethod @classmethod
def from_database(cls, obj): def from_database(cls, obj):
@ -366,23 +436,9 @@ class DotGovDomainForm(RegistrarForm):
requested_domain = getattr(obj, "requested_domain", None) requested_domain = getattr(obj, "requested_domain", None)
if requested_domain is not None: if requested_domain is not None:
values["requested_domain"] = requested_domain.sld values["requested_domain"] = requested_domain.sld
alternative_domain = obj.alternative_domains.first()
if alternative_domain is not None:
values["alternative_domain"] = alternative_domain.sld
return values return values
requested_domain = forms.CharField( requested_domain = forms.CharField(label="What .gov domain do you want?")
label="What .gov domain do you want?",
)
alternative_domain = forms.CharField(
required=False,
label=(
"Are there other domains youd like if we cant give you your first "
"choice? Entering alternative domains is optional."
),
)
def clean_requested_domain(self): def clean_requested_domain(self):
"""Requested domains need to be legal top-level domains, not subdomains. """Requested domains need to be legal top-level domains, not subdomains.
@ -490,25 +546,6 @@ class YourContactForm(RegistrarForm):
class OtherContactsForm(RegistrarForm): class OtherContactsForm(RegistrarForm):
def to_database(self, obj):
if not self.is_valid():
return
obj.save()
# TODO: ability to handle multiple contacts
contact = obj.other_contacts.filter(email=self.cleaned_data["email"]).first()
if contact is not None:
super().to_database(contact)
else:
contact = Contact()
super().to_database(contact)
obj.other_contacts.add(contact)
@classmethod
def from_database(cls, obj):
other_contacts = obj.other_contacts.first()
return super().from_database(other_contacts)
first_name = forms.CharField( first_name = forms.CharField(
label="First name / given name", label="First name / given name",
label_suffix=REQUIRED_SUFFIX, label_suffix=REQUIRED_SUFFIX,
@ -557,6 +594,52 @@ class OtherContactsForm(RegistrarForm):
) )
class BaseOtherContactsFormSet(RegistrarFormSet):
def to_database(self, obj):
if not self.is_valid():
return
obj.save()
query = obj.other_contacts.order_by("created_at").all()
# the use of `zip` pairs the forms in the formset with the
# related objects gotten from the database -- there should always be
# at least as many forms as database entries: extra forms means new
# entries, but fewer forms is _not_ the correct way to delete items
# (likely a client-side error or an attempt at data tampering)
for db_obj, post_data in zip_longest(query, self.forms, fillvalue=None):
cleaned = post_data.cleaned_data if post_data is not None else {}
# matching database object exists, update it
if db_obj is not None and cleaned:
empty = (isinstance(v, str) and not v.strip() for v in cleaned.values())
erased = all(empty)
if erased:
db_obj.delete()
continue
for key, value in cleaned.items():
setattr(db_obj, key, value)
db_obj.save()
# no matching database object, create it
elif db_obj is None and cleaned:
obj.other_contacts.create(**cleaned)
@classmethod
def from_database(cls, obj):
return obj.other_contacts.order_by("created_at").values() # order matters
OtherContactsFormSet = forms.formset_factory(
OtherContactsForm,
extra=1,
absolute_max=1500,
formset=BaseOtherContactsFormSet,
)
class SecurityEmailForm(RegistrarForm): class SecurityEmailForm(RegistrarForm):
security_email = forms.EmailField( security_email = forms.EmailField(
required=False, required=False,

View file

@ -0,0 +1,48 @@
# Generated by Django 4.1.5 on 2023-01-13 01:54
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
("registrar", "0007_domainapplication_more_organization_information_and_more"),
]
operations = [
migrations.RemoveField(
model_name="userprofile",
name="created_at",
),
migrations.RemoveField(
model_name="userprofile",
name="updated_at",
),
migrations.AddField(
model_name="contact",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="contact",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name="website",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="website",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
]

View file

@ -2,8 +2,10 @@ from django.db import models
from phonenumber_field.modelfields import PhoneNumberField # type: ignore from phonenumber_field.modelfields import PhoneNumberField # type: ignore
from .utility.time_stamped_model import TimeStampedModel
class Contact(models.Model):
class Contact(TimeStampedModel):
"""Contact information follows a similar pattern for each contact.""" """Contact information follows a similar pattern for each contact."""

View file

@ -6,7 +6,7 @@ from .utility.address_model import AddressModel
from .contact import Contact from .contact import Contact
class UserProfile(TimeStampedModel, Contact, AddressModel): class UserProfile(Contact, TimeStampedModel, AddressModel):
"""User information, unrelated to their login/auth details.""" """User information, unrelated to their login/auth details."""

View file

@ -2,8 +2,10 @@ from django.apps import apps
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from .utility.time_stamped_model import TimeStampedModel
class Website(models.Model):
class Website(TimeStampedModel):
"""Keep domain names in their own table so that applications can refer to """Keep domain names in their own table so that applications can refer to
many of them.""" many of them."""

View file

@ -1,4 +1,3 @@
<!-- Test page -->
{% extends 'application_form.html' %} {% extends 'application_form.html' %}
{% load widget_tweaks static%} {% load widget_tweaks static%}
@ -75,8 +74,19 @@
<span class="padding-top-05 padding-left-2px">.gov </span> <span class="padding-top-05 padding-left-2px">.gov </span>
</div> </div>
{% endif %} {% endif %}
{{ forms.1.management_form }}
<p class="alt_domain_instructions">Are there other domains youd like if we cant give you your first choice? Entering alternative domains is optional.</p>
<button type="button" class="usa-button usa-button--unstyled"> {% for form in forms.1 %}
{{ form.alternative_domain|add_label_class:"usa-label" }}
<div class="display-flex flex-align-center">
<span class="padding-top-05 padding-right-2px">www.</span>
{{ form.alternative_domain|add_class:"usa-input"|attr:"aria-describedby:alt_domain_instructions" }}
<span class="padding-top-05 padding-left-2px">.gov </span>
</div>
{% endfor %}
<button type="submit" name="submit_button" value="save" class="usa-button usa-button--unstyled">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use> <use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add another alternative</span> </svg><span class="margin-left-05">Add another alternative</span>

View file

@ -1,4 +1,3 @@
<!-- Test page -->
{% extends 'application_form.html' %} {% extends 'application_form.html' %}
{% load widget_tweaks %} {% load widget_tweaks %}
{% load static %} {% load static %}
@ -11,28 +10,25 @@
<form class="usa-form usa-form--large" id="step__{{steps.current}}" method="post" novalidate> <form class="usa-form usa-form--large" id="step__{{steps.current}}" method="post" novalidate>
{% csrf_token %} {% csrf_token %}
{{ forms.0.management_form }}
<fieldset class="usa-fieldset"> {% for form in forms.0.forms %}
<legend> <fieldset class="usa-fieldset">
<h2 class="margin-bottom-05"> Contact 2 </h2> <legend>
</legend> <h2 class="margin-bottom-05">Contact {{ forloop.counter }}</h2>
{% input_with_errors forms.0.first_name %} </legend>
{% input_with_errors form.first_name %}
{% input_with_errors forms.0.middle_name %} {% input_with_errors form.middle_name %}
{% input_with_errors form.last_name %}
{% input_with_errors forms.0.last_name %} {% input_with_errors form.title %}
{% input_with_errors form.email %}
{% input_with_errors forms.0.title %} {% input_with_errors form.phone add_class="usa-input--medium" %}
</fieldset>
{% input_with_errors forms.0.email %} {% endfor %}
{% input_with_errors forms.0.phone add_class="usa-input--medium" %}
</fieldset>
<div> <div>
<button type="button" class="usa-button usa-button--unstyled"> <button type="submit" name="submit_button" value="save" class="usa-button usa-button--unstyled">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static '/img/sprite.svg'%}#add_circle"></use> <use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add another contact</span> </svg><span class="margin-left-05">Add another contact</span>
</button> </button>
</div> </div>

View file

@ -283,7 +283,7 @@ class DomainApplicationTests(TestWithUser, WebTest):
dotgov_page = current_sites_result.follow() dotgov_page = current_sites_result.follow()
dotgov_form = dotgov_page.form dotgov_form = dotgov_page.form
dotgov_form["dotgov_domain-requested_domain"] = "city" dotgov_form["dotgov_domain-requested_domain"] = "city"
dotgov_form["dotgov_domain-alternative_domain"] = "city1" dotgov_form["dotgov_domain-0-alternative_domain"] = "city1"
# test saving the page # test saving the page
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
@ -367,11 +367,11 @@ class DomainApplicationTests(TestWithUser, WebTest):
other_contacts_page = your_contact_result.follow() other_contacts_page = your_contact_result.follow()
other_contacts_form = other_contacts_page.form other_contacts_form = other_contacts_page.form
other_contacts_form["other_contacts-first_name"] = "Testy2" other_contacts_form["other_contacts-0-first_name"] = "Testy2"
other_contacts_form["other_contacts-last_name"] = "Tester2" other_contacts_form["other_contacts-0-last_name"] = "Tester2"
other_contacts_form["other_contacts-title"] = "Another Tester" other_contacts_form["other_contacts-0-title"] = "Another Tester"
other_contacts_form["other_contacts-email"] = "testy2@town.com" other_contacts_form["other_contacts-0-email"] = "testy2@town.com"
other_contacts_form["other_contacts-phone"] = "(201) 555 5557" other_contacts_form["other_contacts-0-phone"] = "(201) 555 5557"
# test saving the page # test saving the page
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)

View file

@ -155,8 +155,9 @@ class ApplicationWizard(LoginRequiredMixin, TemplateView):
@storage.deleter @storage.deleter
def storage(self): def storage(self):
del self.request.session[self.prefix] if self.prefix in self.request.session:
self.request.session.modified = True del self.request.session[self.prefix]
self.request.session.modified = True
def done(self): def done(self):
"""Called when the user clicks the submit button, if all forms are valid.""" """Called when the user clicks the submit button, if all forms are valid."""
@ -193,7 +194,9 @@ class ApplicationWizard(LoginRequiredMixin, TemplateView):
# if user visited via an "edit" url, associate the id of the # if user visited via an "edit" url, associate the id of the
# application they are trying to edit to this wizard instance # 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: if current_url == self.EDIT_URL_NAME and "id" in kwargs:
del self.storage
self.storage["application_id"] = kwargs["id"] self.storage["application_id"] = kwargs["id"]
# if accessing this class directly, redirect to the first step # if accessing this class directly, redirect to the first step
@ -380,7 +383,7 @@ class CurrentSites(ApplicationWizard):
class DotgovDomain(ApplicationWizard): class DotgovDomain(ApplicationWizard):
template_name = "application_dotgov_domain.html" template_name = "application_dotgov_domain.html"
forms = [forms.DotGovDomainForm] forms = [forms.DotGovDomainForm, forms.AlternativeDomainFormSet]
class Purpose(ApplicationWizard): class Purpose(ApplicationWizard):
@ -395,7 +398,7 @@ class YourContact(ApplicationWizard):
class OtherContacts(ApplicationWizard): class OtherContacts(ApplicationWizard):
template_name = "application_other_contacts.html" template_name = "application_other_contacts.html"
forms = [forms.OtherContactsForm] forms = [forms.OtherContactsFormSet]
class SecurityEmail(ApplicationWizard): class SecurityEmail(ApplicationWizard):