mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-06-10 06:24:45 +02:00
Implement dynamic questions with formsets
This commit is contained in:
parent
c0c726e5fa
commit
f54276d82b
10 changed files with 249 additions and 105 deletions
|
@ -1,6 +1,5 @@
|
|||
import logging
|
||||
import random
|
||||
import string
|
||||
from faker import Faker
|
||||
|
||||
from registrar.models import (
|
||||
|
@ -81,28 +80,29 @@ class DomainApplicationFixture:
|
|||
"status": "started",
|
||||
"organization_name": "Example - Finished but not Submitted",
|
||||
},
|
||||
{
|
||||
"status": "started",
|
||||
"organization_name": "Example - Just started",
|
||||
"organization_type": "federal",
|
||||
"federal_agency": None,
|
||||
"federal_type": None,
|
||||
"address_line1": None,
|
||||
"address_line2": None,
|
||||
"city": None,
|
||||
"state_territory": None,
|
||||
"zipcode": None,
|
||||
"urbanization": None,
|
||||
"purpose": None,
|
||||
"security_email": None,
|
||||
"anything_else": None,
|
||||
"is_policy_acknowledged": None,
|
||||
"authorizing_official": None,
|
||||
"submitter": None,
|
||||
"other_contacts": [],
|
||||
"current_websites": [],
|
||||
"alternative_domains": [],
|
||||
},
|
||||
# an example of a more manual application
|
||||
# {
|
||||
# "status": "started",
|
||||
# "organization_name": "Example - Just started",
|
||||
# "organization_type": "federal",
|
||||
# "federal_agency": None,
|
||||
# "federal_type": None,
|
||||
# "address_line1": None,
|
||||
# "address_line2": None,
|
||||
# "city": None,
|
||||
# "state_territory": None,
|
||||
# "zipcode": None,
|
||||
# "urbanization": None,
|
||||
# "purpose": None,
|
||||
# "security_email": None,
|
||||
# "anything_else": None,
|
||||
# "is_policy_acknowledged": None,
|
||||
# "authorizing_official": None,
|
||||
# "submitter": None,
|
||||
# "other_contacts": [],
|
||||
# "current_websites": [],
|
||||
# "alternative_domains": [],
|
||||
# },
|
||||
{
|
||||
"status": "submitted",
|
||||
"organization_name": "Example - Submitted but pending Investigation",
|
||||
|
@ -121,12 +121,12 @@ class DomainApplicationFixture:
|
|||
"last_name": fake.last_name(),
|
||||
"title": fake.job(),
|
||||
"email": fake.ascii_safe_email(),
|
||||
"phone": fake.phone_number(),
|
||||
"phone": "201-555-5555",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def fake_dot_gov(cls):
|
||||
return "".join(random.choices(string.ascii_lowercase, k=16)) + ".gov" # nosec
|
||||
return f"{fake.slug()}.gov"
|
||||
|
||||
@classmethod
|
||||
def _set_non_foreign_key_fields(cls, da: DomainApplication, app: dict):
|
||||
|
@ -199,7 +199,7 @@ class DomainApplicationFixture:
|
|||
if "other_contacts" in app:
|
||||
for contact in app["other_contacts"]:
|
||||
da.other_contacts.add(Contact.objects.get_or_create(**contact)[0])
|
||||
else:
|
||||
elif not da.other_contacts.exists():
|
||||
other_contacts = [
|
||||
Contact.objects.create(**cls.fake_contact())
|
||||
for _ in range(random.randint(0, 3)) # nosec
|
||||
|
@ -211,7 +211,7 @@ class DomainApplicationFixture:
|
|||
da.current_websites.add(
|
||||
Website.objects.get_or_create(website=website)[0]
|
||||
)
|
||||
else:
|
||||
elif not da.current_websites.exists():
|
||||
current_websites = [
|
||||
Website.objects.create(website=fake.uri())
|
||||
for _ in range(random.randint(0, 3)) # nosec
|
||||
|
@ -223,7 +223,7 @@ class DomainApplicationFixture:
|
|||
da.alternative_domains.add(
|
||||
Website.objects.get_or_create(website=domain)[0]
|
||||
)
|
||||
else:
|
||||
elif not da.alternative_domains.exists():
|
||||
alternative_domains = [
|
||||
Website.objects.create(website=cls.fake_dot_gov())
|
||||
for _ in range(random.randint(0, 3)) # nosec
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
from __future__ import annotations # allows forward references in annotations
|
||||
from itertools import zip_longest
|
||||
import logging
|
||||
from phonenumber_field.formfields import PhoneNumberField # type: ignore
|
||||
|
||||
from django import forms
|
||||
from django.core.validators import RegexValidator
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from phonenumber_field.formfields import PhoneNumberField # type: ignore
|
||||
|
||||
from registrar.models import Contact, DomainApplication, Domain
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -57,6 +57,19 @@ class RegistrarForm(forms.Form):
|
|||
} # 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):
|
||||
organization_type = forms.ChoiceField(
|
||||
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):
|
||||
def to_database(self, obj):
|
||||
if not self.is_valid():
|
||||
|
@ -353,12 +429,6 @@ class DotGovDomainForm(RegistrarForm):
|
|||
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
|
||||
def from_database(cls, obj):
|
||||
|
@ -366,23 +436,9 @@ class DotGovDomainForm(RegistrarForm):
|
|||
requested_domain = getattr(obj, "requested_domain", None)
|
||||
if requested_domain is not None:
|
||||
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
|
||||
|
||||
requested_domain = forms.CharField(
|
||||
label="What .gov domain do you want?",
|
||||
)
|
||||
alternative_domain = forms.CharField(
|
||||
required=False,
|
||||
label=(
|
||||
"Are there other domains you’d like if we can’t give you your first "
|
||||
"choice? Entering alternative domains is optional."
|
||||
),
|
||||
)
|
||||
requested_domain = forms.CharField(label="What .gov domain do you want?")
|
||||
|
||||
def clean_requested_domain(self):
|
||||
"""Requested domains need to be legal top-level domains, not subdomains.
|
||||
|
@ -490,25 +546,6 @@ class YourContactForm(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(
|
||||
label="First name / given name",
|
||||
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):
|
||||
security_email = forms.EmailField(
|
||||
required=False,
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -2,8 +2,10 @@ from django.db import models
|
|||
|
||||
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."""
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ from .utility.address_model import AddressModel
|
|||
from .contact import Contact
|
||||
|
||||
|
||||
class UserProfile(TimeStampedModel, Contact, AddressModel):
|
||||
class UserProfile(Contact, TimeStampedModel, AddressModel):
|
||||
|
||||
"""User information, unrelated to their login/auth details."""
|
||||
|
||||
|
|
|
@ -2,8 +2,10 @@ from django.apps import apps
|
|||
from django.core.exceptions import ValidationError
|
||||
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
|
||||
many of them."""
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
<!-- Test page -->
|
||||
{% extends 'application_form.html' %}
|
||||
{% load widget_tweaks static%}
|
||||
|
||||
|
@ -75,8 +74,19 @@
|
|||
<span class="padding-top-05 padding-left-2px">.gov </span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ forms.1.management_form }}
|
||||
<p class="alt_domain_instructions">Are there other domains you’d like if we can’t 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">
|
||||
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
|
||||
</svg><span class="margin-left-05">Add another alternative</span>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
<!-- Test page -->
|
||||
{% extends 'application_form.html' %}
|
||||
{% load widget_tweaks %}
|
||||
{% load static %}
|
||||
|
@ -11,28 +10,25 @@
|
|||
|
||||
<form class="usa-form usa-form--large" id="step__{{steps.current}}" method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
{{ forms.0.management_form }}
|
||||
{% for form in forms.0.forms %}
|
||||
<fieldset class="usa-fieldset">
|
||||
<legend>
|
||||
<h2 class="margin-bottom-05"> Contact 2 </h2>
|
||||
<h2 class="margin-bottom-05">Contact {{ forloop.counter }}</h2>
|
||||
</legend>
|
||||
{% input_with_errors forms.0.first_name %}
|
||||
|
||||
{% input_with_errors forms.0.middle_name %}
|
||||
|
||||
{% input_with_errors forms.0.last_name %}
|
||||
|
||||
{% input_with_errors forms.0.title %}
|
||||
|
||||
{% input_with_errors forms.0.email %}
|
||||
|
||||
{% input_with_errors forms.0.phone add_class="usa-input--medium" %}
|
||||
{% input_with_errors form.first_name %}
|
||||
{% input_with_errors form.middle_name %}
|
||||
{% input_with_errors form.last_name %}
|
||||
{% input_with_errors form.title %}
|
||||
{% input_with_errors form.email %}
|
||||
{% input_with_errors form.phone add_class="usa-input--medium" %}
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
|
||||
<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">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -283,7 +283,7 @@ class DomainApplicationTests(TestWithUser, WebTest):
|
|||
dotgov_page = current_sites_result.follow()
|
||||
dotgov_form = dotgov_page.form
|
||||
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
|
||||
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_form = other_contacts_page.form
|
||||
|
||||
other_contacts_form["other_contacts-first_name"] = "Testy2"
|
||||
other_contacts_form["other_contacts-last_name"] = "Tester2"
|
||||
other_contacts_form["other_contacts-title"] = "Another Tester"
|
||||
other_contacts_form["other_contacts-email"] = "testy2@town.com"
|
||||
other_contacts_form["other_contacts-phone"] = "(201) 555 5557"
|
||||
other_contacts_form["other_contacts-0-first_name"] = "Testy2"
|
||||
other_contacts_form["other_contacts-0-last_name"] = "Tester2"
|
||||
other_contacts_form["other_contacts-0-title"] = "Another Tester"
|
||||
other_contacts_form["other_contacts-0-email"] = "testy2@town.com"
|
||||
other_contacts_form["other_contacts-0-phone"] = "(201) 555 5557"
|
||||
|
||||
# test saving the page
|
||||
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
|
||||
|
|
|
@ -155,6 +155,7 @@ class ApplicationWizard(LoginRequiredMixin, TemplateView):
|
|||
|
||||
@storage.deleter
|
||||
def storage(self):
|
||||
if self.prefix in self.request.session:
|
||||
del self.request.session[self.prefix]
|
||||
self.request.session.modified = True
|
||||
|
||||
|
@ -193,7 +194,9 @@ class ApplicationWizard(LoginRequiredMixin, TemplateView):
|
|||
|
||||
# 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
|
||||
|
@ -380,7 +383,7 @@ class CurrentSites(ApplicationWizard):
|
|||
|
||||
class DotgovDomain(ApplicationWizard):
|
||||
template_name = "application_dotgov_domain.html"
|
||||
forms = [forms.DotGovDomainForm]
|
||||
forms = [forms.DotGovDomainForm, forms.AlternativeDomainFormSet]
|
||||
|
||||
|
||||
class Purpose(ApplicationWizard):
|
||||
|
@ -395,7 +398,7 @@ class YourContact(ApplicationWizard):
|
|||
|
||||
class OtherContacts(ApplicationWizard):
|
||||
template_name = "application_other_contacts.html"
|
||||
forms = [forms.OtherContactsForm]
|
||||
forms = [forms.OtherContactsFormSet]
|
||||
|
||||
|
||||
class SecurityEmail(ApplicationWizard):
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue