diff --git a/src/Pipfile b/src/Pipfile index 10be9752e..4475c0886 100644 --- a/src/Pipfile +++ b/src/Pipfile @@ -21,6 +21,7 @@ django-widget-tweaks = "*" cachetools = "*" requests = "*" django-fsm = "*" +django-phonenumber-field = {extras = ["phonenumberslite"], version = "*"} [dev-packages] django-debug-toolbar = "*" diff --git a/src/Pipfile.lock b/src/Pipfile.lock index 2a531a89b..05e062c70 100644 --- a/src/Pipfile.lock +++ b/src/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "1668475ce39851bd84ff7be330afe9766f6823cf9095980ba3b220ced3a284f4" + "sha256": "b75e38d4e723e06a194cb607ba3003ed7a9f0460f23d036d9cd18214341d3e77" }, "pipfile-spec": 6, "requires": {}, @@ -24,11 +24,11 @@ }, "cachetools": { "hashes": [ - "sha256:6a94c6402995a99c3970cc7e4884bb60b4a8639938157eeed436098bf9831757", - "sha256:f9f17d2aec496a9aa6b76f53e3b614c965223c061982d434d160f930c698a9db" + "sha256:5991bc0e08a1319bb618d3195ca5b6bc76646a49c21d55962977197b301cc1fe", + "sha256:8462eebf3a6c15d25430a8c27c56ac61340b2ecf60c9ce57afc2b97e450e47da" ], "index": "pypi", - "version": "==5.2.0" + "version": "==5.2.1" }, "certifi": { "hashes": [ @@ -120,7 +120,7 @@ "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845", "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f" ], - "markers": "python_full_version >= '3.6.0'", + "markers": "python_version >= '3.6'", "version": "==2.1.1" }, "cryptography": { @@ -221,6 +221,17 @@ "index": "pypi", "version": "==2.8.1" }, + "django-phonenumber-field": { + "extras": [ + "phonenumberslite" + ], + "hashes": [ + "sha256:9edad2b2602af25f2aefc73c4cf53eaf7abf9e17d73c1c4372bd3052bebb26f9", + "sha256:de3e47b986b4959949762c16fd8fe26b3e462ef3e5531ed00950bd20c698576a" + ], + "index": "pypi", + "version": "==7.0.2" + }, "django-widget-tweaks": { "hashes": [ "sha256:9bfc5c705684754a83cc81da328b39ad1b80f32bd0f4340e2a810cbab4b0c00e", @@ -242,11 +253,11 @@ }, "faker": { "hashes": [ - "sha256:2d5443724f640ce07658ca8ca8bbd40d26b58914e63eec6549727869aa67e2cc", - "sha256:c2a2ff9dd8dfd991109b517ab98d5cb465e857acb45f6b643a0e284a9eb2cc76" + "sha256:4a8bc3cec832dde1928f8ce0817452bdadf63863d9e4d8307817247a38e51523", + "sha256:e15becbddc3a69a342e03ca6810caab7299e28e48106ae113a07f65c627d6fd7" ], "index": "pypi", - "version": "==15.3.4" + "version": "==16.1.0" }, "furl": { "hashes": [ @@ -357,11 +368,18 @@ }, "packaging": { "hashes": [ - "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3", - "sha256:957e2148ba0e1a3b282772e791ef1d8083648bc131c8ab0c1feba110ce1146c3" + "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2", + "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97" ], "markers": "python_version >= '3.7'", - "version": "==22.0" + "version": "==23.0" + }, + "phonenumberslite": { + "hashes": [ + "sha256:44cbb13581122164cd8a83b40f12db854277e8a5f9c6e22bd8dc2d8aa98e3260", + "sha256:469eb263160e243aa02fff643502698f99e77bcb6478e9aaa7115838006be122" + ], + "version": "==8.13.4" }, "psycopg2-binary": { "hashes": [ @@ -581,7 +599,7 @@ "sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30", "sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693" ], - "markers": "python_full_version >= '3.6.0'", + "markers": "python_version >= '3.6'", "version": "==4.11.1" }, "black": { @@ -925,7 +943,7 @@ "sha256:7500c9625927c8ec60f54377d590f67b30c8e70ef4b8894214ac6e4cad233d2a", "sha256:780a4082c5fbc0fde6a2fcfe5e26e6efc1e8f425730863c04085769781f51eba" ], - "markers": "python_full_version >= '3.7.0'", + "markers": "python_version >= '3.7'", "version": "==2.1.2" }, "webob": { diff --git a/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss b/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss index 7d80d5f76..158309f99 100644 --- a/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss +++ b/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss @@ -100,3 +100,9 @@ footer { //Workaround because USWDS units jump from 10 to 15 margin-top: units(10) + units(2); } + +abbr[title] { + // workaround for underlining abbr element + border-bottom: none; + text-decoration: none; +} diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 1869ecb58..49c5a25d5 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -90,6 +90,8 @@ INSTALLED_APPS = [ "widget_tweaks", # library for Finite State Machine statuses "django_fsm", + # library for phone numbers + "phonenumber_field", # let's be sure to install our own application! "registrar", # Our internal API application @@ -181,6 +183,8 @@ TEMPLATES = [ }, ] +MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage" + # IS_DEMO_SITE controls whether or not we show our big red "TEST SITE" banner # underneath the "this is a real government website" banner. IS_DEMO_SITE = True @@ -296,6 +300,9 @@ USE_L10N = True # make datetimes timezone-aware by default USE_TZ = True +# setting for phonenumber library +PHONENUMBER_DEFAULT_REGION = "US" + # endregion # region: Logging-----------------------------------------------------------### diff --git a/src/registrar/forms/application_wizard.py b/src/registrar/forms/application_wizard.py index 9185a26a4..28118ed53 100644 --- a/src/registrar/forms/application_wizard.py +++ b/src/registrar/forms/application_wizard.py @@ -2,11 +2,22 @@ from __future__ import annotations # allows forward references in annotations import logging 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__) +# no sec because this use of mark_safe does not introduce a cross-site scripting +# vulnerability because there is no untrusted content inside. It is +# only being used to pass a specific HTML entity into a template. +REQUIRED_SUFFIX = mark_safe( # nosec + ' *' +) + class RegistrarForm(forms.Form): """ @@ -20,6 +31,8 @@ class RegistrarForm(forms.Form): def __init__(self, *args, **kwargs): kwargs.setdefault("label_suffix", "") + # save a reference to an application object + self.application = kwargs.pop("application", None) super(RegistrarForm, self).__init__(*args, **kwargs) def to_database(self, obj: DomainApplication | Contact): @@ -49,6 +62,7 @@ class OrganizationTypeForm(RegistrarForm): required=True, choices=DomainApplication.OrganizationChoices.choices, widget=forms.RadioSelect, + error_messages={"required": "This question is required."}, ) @@ -56,46 +70,95 @@ class OrganizationFederalForm(RegistrarForm): federal_type = forms.ChoiceField( choices=DomainApplication.BranchChoices.choices, widget=forms.RadioSelect, + error_messages={"required": "This question is required."}, ) class OrganizationElectionForm(RegistrarForm): - is_election_board = forms.BooleanField( + is_election_board = forms.NullBooleanField( widget=forms.RadioSelect( choices=[ (True, "Yes"), (False, "No"), ], ), + required=False, # use field validation to require an answer ) + def clean_is_election_board(self): + """This box must be checked to proceed but offer a clear error.""" + # already converted to a boolean + is_election_board = self.cleaned_data["is_election_board"] + if is_election_board is None: + raise forms.ValidationError( + "Please select Yes or No.", + code="required", + ) + return is_election_board + class OrganizationContactForm(RegistrarForm): # for federal agencies we also want to know the top-level agency. federal_agency = forms.ChoiceField( label="Federal agency", # not required because this field won't be filled out unless - # it is a federal agency. + # it is a federal agency. Use clean to check programatically + # if it has been filled in when required. required=False, - choices=DomainApplication.AGENCY_CHOICES, + choices=[("", "--Select--")] + DomainApplication.AGENCY_CHOICES, + label_suffix=REQUIRED_SUFFIX, + ) + organization_name = forms.CharField( + label="Organization Name", label_suffix=REQUIRED_SUFFIX + ) + address_line1 = forms.CharField( + label="Street address", + label_suffix=REQUIRED_SUFFIX, ) - organization_name = forms.CharField(label="Organization Name") - address_line1 = forms.CharField(label="Street address") address_line2 = forms.CharField( required=False, label="Street address line 2", ) - city = forms.CharField(label="City") + city = forms.CharField(label="City", label_suffix=REQUIRED_SUFFIX) state_territory = forms.ChoiceField( label="State, territory, or military post", choices=[("", "--Select--")] + DomainApplication.StateTerritoryChoices.choices, + label_suffix=REQUIRED_SUFFIX, + ) + zipcode = forms.CharField( + label="ZIP code", + label_suffix=REQUIRED_SUFFIX, + validators=[ + RegexValidator( + "^[0-9]{5}(?:-[0-9]{4})?$|^$", + message="Please enter a ZIP code in the form 12345 or 12345-6789", + ) + ], ) - zipcode = forms.CharField(label="ZIP code") urbanization = forms.CharField( required=False, label="Urbanization (Puerto Rico only)", ) + def clean_federal_agency(self): + """Require something to be selected when this is a federal agency.""" + federal_agency = self.cleaned_data.get("federal_agency", None) + # need the application object to know if this is federal + if self.application is None: + # hmm, no saved application object?, default require the agency + if not federal_agency: + # no answer was selected + raise forms.ValidationError( + "Please select your federal agency.", code="required" + ) + if self.application.is_federal: + if not federal_agency: + # no answer was selected + raise forms.ValidationError( + "Please select your federal agency.", code="required" + ) + return federal_agency + class AuthorizingOfficialForm(RegistrarForm): def to_database(self, obj): @@ -115,15 +178,31 @@ class AuthorizingOfficialForm(RegistrarForm): contact = getattr(obj, "authorizing_official", None) return super().from_database(contact) - first_name = forms.CharField(label="First name/given name") + first_name = forms.CharField( + label="First name/given name", + label_suffix=REQUIRED_SUFFIX, + ) middle_name = forms.CharField( required=False, - label="Middle name (optional)", + label="Middle name", + ) + last_name = forms.CharField( + label="Last name/family name", + label_suffix=REQUIRED_SUFFIX, + ) + title = forms.CharField( + label="Title or role in your organization", + label_suffix=REQUIRED_SUFFIX, + ) + email = forms.EmailField( + label="Email", + label_suffix=REQUIRED_SUFFIX, + error_messages={"invalid": "Please enter a valid email address."}, + ) + phone = PhoneNumberField( + label="Phone", + label_suffix=REQUIRED_SUFFIX, ) - last_name = forms.CharField(label="Last name/family name") - title = forms.CharField(label="Title or role in your organization") - email = forms.EmailField(label="Email") - phone = forms.CharField(label="Phone") class CurrentSitesForm(RegistrarForm): @@ -150,6 +229,27 @@ class CurrentSitesForm(RegistrarForm): "www.city.com.", ) + def clean_current_site(self): + """This field should be a legal domain name.""" + inputted_site = self.cleaned_data["current_site"] + if not inputted_site: + # empty string is fine + return inputted_site + + # something has been inputted + + if inputted_site.startswith("http://") or inputted_site.startswith("https://"): + # strip of the protocol that the pasted from their web browser + inputted_site = inputted_site.split("//", 1)[1] + + if Domain.string_could_be_domain(inputted_site): + return inputted_site + else: + # string could not be a domain + raise forms.ValidationError( + "Please enter a valid domain name", code="invalid" + ) + class DotGovDomainForm(RegistrarForm): def to_database(self, obj): @@ -189,16 +289,51 @@ class DotGovDomainForm(RegistrarForm): return values - requested_domain = forms.CharField(label="What .gov domain do you want?") + 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.", ) + def clean_requested_domain(self): + """Requested domains need to be legal top-level domains, not subdomains. + + If they end with `.gov`, then we can reasonably take that off. If they have + any other dots in them, raise an error. + """ + requested = self.cleaned_data["requested_domain"] + if not requested: + # none or empty string + raise forms.ValidationError( + "Please enter the .gov domain that you are requesting.", code="invalid" + ) + if requested.endswith(".gov"): + requested = requested[:-4] + if "." in requested: + raise forms.ValidationError( + "Please enter a domain without any periods.", + code="invalid", + ) + if not Domain.string_could_be_domain(requested + ".gov"): + raise forms.ValidationError( + "Please enter a valid domain name using only letters, " + "numbers, and hyphens", + code="invalid", + ) + return requested + class PurposeForm(RegistrarForm): - purpose = forms.CharField(label="Purpose", widget=forms.Textarea()) + purpose = forms.CharField( + label="Purpose", + widget=forms.Textarea(), + error_messages={ + "required": "Please enter some information about the purpose of your domain" + }, + ) class YourContactForm(RegistrarForm): @@ -219,15 +354,31 @@ class YourContactForm(RegistrarForm): contact = getattr(obj, "submitter", None) return super().from_database(contact) - first_name = forms.CharField(label="First name/given name") + first_name = forms.CharField( + label="First name/given name", + label_suffix=REQUIRED_SUFFIX, + ) middle_name = forms.CharField( required=False, - label="Middle name (optional)", + label="Middle name", + ) + last_name = forms.CharField( + label="Last name/family name", + label_suffix=REQUIRED_SUFFIX, + ) + title = forms.CharField( + label="Title or role in your organization", + label_suffix=REQUIRED_SUFFIX, + ) + email = forms.EmailField( + label="Email", + label_suffix=REQUIRED_SUFFIX, + error_messages={"invalid": "Please enter a valid email address."}, + ) + phone = PhoneNumberField( + label="Phone", + label_suffix=REQUIRED_SUFFIX, ) - last_name = forms.CharField(label="Last name/family name") - title = forms.CharField(label="Title or role in your organization") - email = forms.EmailField(label="Email") - phone = forms.CharField(label="Phone") class OtherContactsForm(RegistrarForm): @@ -250,21 +401,38 @@ class OtherContactsForm(RegistrarForm): other_contacts = obj.other_contacts.first() return super().from_database(other_contacts) - first_name = forms.CharField(label="First name/given name") + first_name = forms.CharField( + label="First name/given name", + label_suffix=REQUIRED_SUFFIX, + ) middle_name = forms.CharField( required=False, - label="Middle name (optional)", + label="Middle name", + ) + last_name = forms.CharField( + label="Last name/family name", + label_suffix=REQUIRED_SUFFIX, + ) + title = forms.CharField( + label="Title or role in your organization", + label_suffix=REQUIRED_SUFFIX, + ) + email = forms.EmailField( + label="Email", + label_suffix=REQUIRED_SUFFIX, + error_messages={"invalid": "Please enter a valid email address."}, + ) + phone = PhoneNumberField( + label="Phone", + label_suffix=REQUIRED_SUFFIX, ) - last_name = forms.CharField(label="Last name/family name") - title = forms.CharField(label="Title or role in your organization") - email = forms.EmailField(label="Email") - phone = forms.CharField(label="Phone") class SecurityEmailForm(RegistrarForm): security_email = forms.EmailField( required=False, label="Security email", + error_messages={"invalid": "Please enter a valid email address."}, ) @@ -281,5 +449,17 @@ class RequirementsForm(RegistrarForm): label=( "I read and agree to the requirements for registering " "and operating .gov domains." - ) + ), + required=False, # use field validation to enforce this ) + + def clean_is_policy_acknowledged(self): + """This box must be checked to proceed but offer a clear error.""" + # already converted to a boolean + is_acknowledged = self.cleaned_data["is_policy_acknowledged"] + if not is_acknowledged: + raise forms.ValidationError( + "You must read and agree to the .gov domain requirements to proceed.", + code="invalid", + ) + return is_acknowledged diff --git a/src/registrar/migrations/0006_alter_contact_phone.py b/src/registrar/migrations/0006_alter_contact_phone.py new file mode 100644 index 000000000..c971a13d1 --- /dev/null +++ b/src/registrar/migrations/0006_alter_contact_phone.py @@ -0,0 +1,26 @@ +# Generated by Django 4.1.4 on 2022-12-14 20:48 + +from django.db import migrations +import phonenumber_field.modelfields # type: ignore + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0005_domainapplication_city_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="contact", + name="phone", + field=phonenumber_field.modelfields.PhoneNumberField( + blank=True, + db_index=True, + help_text="Phone", + max_length=128, + null=True, + region=None, + ), + ), + ] diff --git a/src/registrar/models/contact.py b/src/registrar/models/contact.py index 6368a0101..0d3a7c389 100644 --- a/src/registrar/models/contact.py +++ b/src/registrar/models/contact.py @@ -1,5 +1,7 @@ from django.db import models +from phonenumber_field.modelfields import PhoneNumberField # type: ignore + class Contact(models.Model): @@ -33,7 +35,7 @@ class Contact(models.Model): help_text="Email", db_index=True, ) - phone = models.TextField( + phone = PhoneNumberField( null=True, blank=True, help_text="Phone", diff --git a/src/registrar/templates/application_authorizing_official.html b/src/registrar/templates/application_authorizing_official.html index 0f786c868..5fee4bc4b 100644 --- a/src/registrar/templates/application_authorizing_official.html +++ b/src/registrar/templates/application_authorizing_official.html @@ -2,6 +2,7 @@ {% extends 'application_form.html' %} {% load widget_tweaks %} {% load static %} +{% load field_helpers %} {% block form_content %} @@ -18,32 +19,27 @@
We’ll contact your authorizing official to let them know that you made this request and to double check that they approve it.
-All fields are required unless they are marked optional.
+{% include "includes/required_fields.html" %} -