diff --git a/src/mypy.ini b/src/mypy.ini index 5f4ec24aa..a151424a3 100644 --- a/src/mypy.ini +++ b/src/mypy.ini @@ -1,7 +1,12 @@ [mypy] plugins = mypy_django_plugin.main +# strict_optional: treat None as compatible with every type? +# `var: int` is equal to `var: int|None` strict_optional = True +# implicit_optional: treat arguments a None default value as implicitly Optional? +# `var: int = None` is equal to `var: Optional[int] = None` +implicit_optional = True [mypy.plugins.django-stubs] django_settings_module = "registrar.config.settings" diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index d3a6d0a46..3bebba5df 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -24,6 +24,7 @@ urlpatterns = [ path("", index.index, name="home"), path("whoami/", whoami.whoami, name="whoami"), path("admin/", admin.site.urls), + path("application//edit/", application_wizard, name="edit-application"), path("health/", health.health), path("edit_profile/", profile.edit_profile, name="edit-profile"), path("openid/", include("djangooidc.urls")), diff --git a/src/registrar/forms/application_wizard.py b/src/registrar/forms/application_wizard.py index 2e16c35e2..5fbdf6a3c 100644 --- a/src/registrar/forms/application_wizard.py +++ b/src/registrar/forms/application_wizard.py @@ -8,63 +8,53 @@ from typing import Union from django import forms from django.shortcuts import render - from django.contrib.auth.mixins import LoginRequiredMixin +from django.urls import resolve from formtools.wizard.views import NamedUrlSessionWizardView # type: ignore -from registrar.models import DomainApplication, Domain +from registrar.models import Contact, DomainApplication, Domain logger = logging.getLogger(__name__) -# Subclass used to remove the default colon suffix from all fields class RegistrarForm(forms.Form): + """Subclass used to remove the default colon suffix from all fields.""" + def __init__(self, *args, **kwargs): kwargs.setdefault("label_suffix", "") super(RegistrarForm, self).__init__(*args, **kwargs) + def to_database(self, obj: DomainApplication | Contact): + """ + Adds this form's cleaned data to `obj` and saves `obj`. + + Does nothing if form is not valid. + """ + if not self.is_valid(): + return + for name, value in self.cleaned_data.items(): + setattr(obj, name, value) + obj.save() + + def from_database(self, obj: DomainApplication | Contact): + """Initializes this form's fields with values gotten from `obj`.""" + for name in self.declared_fields.keys(): + self.initial[name] = getattr(obj, name) # type: ignore + class OrganizationTypeForm(RegistrarForm): organization_type = forms.ChoiceField( required=True, - choices=[ - (DomainApplication.FEDERAL, "Federal: a federal agency"), - ( - DomainApplication.INTERSTATE, - "Interstate: an organization of two or more states", - ), - ( - DomainApplication.STATE_OR_TERRITORY, - ( - "State or Territory: One of the 50 U.S. states, the District of " - "Columbia, American Samoa, Guam, Northern Mariana Islands, " - "Puerto Rico, or the U.S. Virgin Islands" - ), - ), - ( - DomainApplication.TRIBAL, - ( - "Tribal: a tribal government recognized by the federal or " - "state government" - ), - ), - (DomainApplication.COUNTY, "County: a county, parish, or borough"), - (DomainApplication.CITY, "City: a city, town, township, village, etc."), - ( - DomainApplication.SPECIAL_DISTRICT, - "Special District: an independent organization within a single state", - ), - ], + choices=DomainApplication.OrganizationChoices.choices, widget=forms.RadioSelect, ) class OrganizationFederalForm(RegistrarForm): federal_type = forms.ChoiceField( - required=False, - choices=DomainApplication.BRANCH_CHOICES, + choices=DomainApplication.BranchChoices.choices, widget=forms.RadioSelect, ) @@ -77,7 +67,6 @@ class OrganizationElectionForm(RegistrarForm): (False, "No"), ], ), - required=False, ) @@ -236,71 +225,32 @@ class OrganizationContactForm(RegistrarForm): required=False, label="Address line 2", ) - us_state = forms.ChoiceField( - label="State", - choices=[ - ("AL", "Alabama"), - ("AK", "Alaska"), - ("AZ", "Arizona"), - ("AR", "Arkansas"), - ("CA", "California"), - ("CO", "Colorado"), - ("CT", "Connecticut"), - ("DE", "Delaware"), - ("DC", "District of Columbia"), - ("FL", "Florida"), - ("GA", "Georgia"), - ("HI", "Hawaii"), - ("ID", "Idaho"), - ("IL", "Illinois"), - ("IN", "Indiana"), - ("IA", "Iowa"), - ("KS", "Kansas"), - ("KY", "Kentucky"), - ("LA", "Louisiana"), - ("ME", "Maine"), - ("MD", "Maryland"), - ("MA", "Massachusetts"), - ("MI", "Michigan"), - ("MN", "Minnesota"), - ("MS", "Mississippi"), - ("MO", "Missouri"), - ("MT", "Montana"), - ("NE", "Nebraska"), - ("NV", "Nevada"), - ("NH", "New Hampshire"), - ("NJ", "New Jersey"), - ("NM", "New Mexico"), - ("NY", "New York"), - ("NC", "North Carolina"), - ("ND", "North Dakota"), - ("OH", "Ohio"), - ("OK", "Oklahoma"), - ("OR", "Oregon"), - ("PA", "Pennsylvania"), - ("RI", "Rhode Island"), - ("SC", "South Carolina"), - ("SD", "South Dakota"), - ("TN", "Tennessee"), - ("TX", "Texas"), - ("UT", "Utah"), - ("VT", "Vermont"), - ("VA", "Virginia"), - ("WA", "Washington"), - ("WV", "West Virginia"), - ("WI", "Wisconsin"), - ("WY", "Wyoming"), - ("AS", "American Samoa"), - ("GU", "Guam"), - ("MP", "Northern Mariana Islands"), - ("PR", "Puerto Rico"), - ("VI", "Virgin Islands"), - ], + state_territory = forms.ChoiceField( + label="State", choices=DomainApplication.StateTerritoryChoices.choices ) zipcode = forms.CharField(label="ZIP code") class AuthorizingOfficialForm(RegistrarForm): + def to_database(self, obj): + """Adds this form's cleaned data to `obj` and saves `obj`.""" + if not self.is_valid(): + return + contact = getattr(obj, "authorizing_official", None) + if contact is not None: + super().to_database(contact) + else: + contact = Contact() + super().to_database(contact) + obj.authorizing_official = contact + obj.save() + + def from_database(self, obj): + """Initializes this form's fields with values gotten from `obj`.""" + contact = getattr(obj, "authorizing_official", None) + if contact is not None: + super().from_database(contact) + first_name = forms.CharField(label="First name/given name") middle_name = forms.CharField( required=False, @@ -313,6 +263,22 @@ class AuthorizingOfficialForm(RegistrarForm): class CurrentSitesForm(RegistrarForm): + def to_database(self, obj): + """Adds this form's cleaned data to `obj` and saves `obj`.""" + if not self.is_valid(): + return + obj.save() + normalized = Domain.normalize(self.cleaned_data["current_site"], blank=True) + if normalized: + # TODO: ability to update existing records + obj.current_websites.create(website=normalized) + + def from_database(self, obj): + """Initializes this form's fields with values gotten from `obj`.""" + current_website = obj.current_websites.first() + if current_website is not None: + self.initial["current_site"] = current_website.website + current_site = forms.CharField( required=False, label="Enter your organization’s public website, if you have one. For example, " @@ -321,7 +287,42 @@ class CurrentSitesForm(RegistrarForm): class DotGovDomainForm(RegistrarForm): - dotgov_domain = forms.CharField(label="What .gov domain do you want?") + def to_database(self, obj): + """Adds this form's cleaned data to `obj` and saves `obj`.""" + if not self.is_valid(): + return + normalized = Domain.normalize( + self.cleaned_data["requested_domain"], "gov", blank=True + ) + if normalized: + requested_domain = getattr(obj, "requested_domain", None) + if requested_domain is not None: + requested_domain.name = normalized + requested_domain.save() + else: + requested_domain = Domain.objects.create(name=normalized) + obj.requested_domain = requested_domain + 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) + + def from_database(self, obj): + """Initializes this form's fields with values gotten from `obj`.""" + requested_domain = getattr(obj, "requested_domain", None) + if requested_domain is not None: + self.initial["requested_domain"] = requested_domain.sld + + alternative_domain = obj.alternative_domains.first() + if alternative_domain is not None: + self.initial["alternative_domain"] = alternative_domain.sld + + 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 " @@ -330,10 +331,29 @@ class DotGovDomainForm(RegistrarForm): class PurposeForm(RegistrarForm): - purpose_field = forms.CharField(label="Purpose", widget=forms.Textarea()) + purpose = forms.CharField(label="Purpose", widget=forms.Textarea()) class YourContactForm(RegistrarForm): + def to_database(self, obj): + """Adds this form's cleaned data to `obj` and saves `obj`.""" + if not self.is_valid(): + return + contact = getattr(obj, "submitter", None) + if contact is not None: + super().to_database(contact) + else: + contact = Contact() + super().to_database(contact) + obj.submitter = contact + obj.save() + + def from_database(self, obj): + """Initializes this form's fields with values gotten from `obj`.""" + contact = getattr(obj, "submitter", None) + if contact is not None: + super().from_database(contact) + first_name = forms.CharField(label="First name/given name") middle_name = forms.CharField( required=False, @@ -346,6 +366,27 @@ class YourContactForm(RegistrarForm): class OtherContactsForm(RegistrarForm): + def to_database(self, obj): + """Adds this form's cleaned data to `obj` and saves `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) + + def from_database(self, obj): + """Initializes this form's fields with values gotten from `obj`.""" + other_contacts = obj.other_contacts.first() + if other_contacts is not None: + super().from_database(other_contacts) + first_name = forms.CharField(label="First name/given name") middle_name = forms.CharField( required=False, @@ -358,7 +399,7 @@ class OtherContactsForm(RegistrarForm): class SecurityEmailForm(RegistrarForm): - email = forms.EmailField( + security_email = forms.EmailField( required=False, label="Security email", ) @@ -366,77 +407,104 @@ class SecurityEmailForm(RegistrarForm): class AnythingElseForm(RegistrarForm): anything_else = forms.CharField( - required=False, label="Anything else we should know", widget=forms.Textarea() + required=False, + label="Anything else we should know", + widget=forms.Textarea(), ) class RequirementsForm(RegistrarForm): - agree_check = forms.BooleanField( + is_policy_acknowledged = forms.BooleanField( label="I read and agree to the .gov domain requirements." ) -# Empty class for the review page which gets included as part of the form, but does not -# have any form fields itself class ReviewForm(RegistrarForm): + """ + Empty class for the review page. + + It gets included as part of the form, but does not have any form fields itself. + """ + + def to_database(self, _): + """This form has no data. Do nothing.""" + pass + pass -# List of forms in our wizard. Each entry is a tuple of a name and a form -# subclass +class Step: + """Names for each page of the application wizard.""" + + ORGANIZATION_TYPE = "organization_type" + ORGANIZATION_FEDERAL = "organization_federal" + ORGANIZATION_ELECTION = "organization_election" + ORGANIZATION_CONTACT = "organization_contact" + AUTHORIZING_OFFICIAL = "authorizing_official" + CURRENT_SITES = "current_sites" + DOTGOV_DOMAIN = "dotgov_domain" + PURPOSE = "purpose" + YOUR_CONTACT = "your_contact" + OTHER_CONTACTS = "other_contacts" + SECURITY_EMAIL = "security_email" + ANYTHING_ELSE = "anything_else" + REQUIREMENTS = "requirements" + REVIEW = "review" + + +# List of forms in our wizard. +# Each entry is a tuple of a name and a form subclass FORMS = [ - ("organization_type", OrganizationTypeForm), - ("organization_federal", OrganizationFederalForm), - ("organization_election", OrganizationElectionForm), - ("organization_contact", OrganizationContactForm), - ("authorizing_official", AuthorizingOfficialForm), - ("current_sites", CurrentSitesForm), - ("dotgov_domain", DotGovDomainForm), - ("purpose", PurposeForm), - ("your_contact", YourContactForm), - ("other_contacts", OtherContactsForm), - ("security_email", SecurityEmailForm), - ("anything_else", AnythingElseForm), - ("requirements", RequirementsForm), - ("review", ReviewForm), + (Step.ORGANIZATION_TYPE, OrganizationTypeForm), + (Step.ORGANIZATION_FEDERAL, OrganizationFederalForm), + (Step.ORGANIZATION_ELECTION, OrganizationElectionForm), + (Step.ORGANIZATION_CONTACT, OrganizationContactForm), + (Step.AUTHORIZING_OFFICIAL, AuthorizingOfficialForm), + (Step.CURRENT_SITES, CurrentSitesForm), + (Step.DOTGOV_DOMAIN, DotGovDomainForm), + (Step.PURPOSE, PurposeForm), + (Step.YOUR_CONTACT, YourContactForm), + (Step.OTHER_CONTACTS, OtherContactsForm), + (Step.SECURITY_EMAIL, SecurityEmailForm), + (Step.ANYTHING_ELSE, AnythingElseForm), + (Step.REQUIREMENTS, RequirementsForm), + (Step.REVIEW, ReviewForm), ] -# Dict to match up the right template with the right step. Keys here must -# match the first elements of the tuples in FORMS +# Dict to match up the right template with the right step. TEMPLATES = { - "organization_type": "application_org_type.html", - "organization_federal": "application_org_federal.html", - "organization_election": "application_org_election.html", - "organization_contact": "application_org_contact.html", - "authorizing_official": "application_authorizing_official.html", - "current_sites": "application_current_sites.html", - "dotgov_domain": "application_dotgov_domain.html", - "purpose": "application_purpose.html", - "your_contact": "application_your_contact.html", - "other_contacts": "application_other_contacts.html", - "security_email": "application_security_email.html", - "anything_else": "application_anything_else.html", - "requirements": "application_requirements.html", - "review": "application_review.html", + Step.ORGANIZATION_TYPE: "application_org_type.html", + Step.ORGANIZATION_FEDERAL: "application_org_federal.html", + Step.ORGANIZATION_ELECTION: "application_org_election.html", + Step.ORGANIZATION_CONTACT: "application_org_contact.html", + Step.AUTHORIZING_OFFICIAL: "application_authorizing_official.html", + Step.CURRENT_SITES: "application_current_sites.html", + Step.DOTGOV_DOMAIN: "application_dotgov_domain.html", + Step.PURPOSE: "application_purpose.html", + Step.YOUR_CONTACT: "application_your_contact.html", + Step.OTHER_CONTACTS: "application_other_contacts.html", + Step.SECURITY_EMAIL: "application_security_email.html", + Step.ANYTHING_ELSE: "application_anything_else.html", + Step.REQUIREMENTS: "application_requirements.html", + Step.REVIEW: "application_review.html", } -# We need to pass our page titles as context to the templates, indexed -# by the step names +# We need to pass our page titles as context to the templates TITLES = { - "organization_type": "Type of organization", - "organization_federal": "Type of organization — Federal", - "organization_election": "Type of organization — Election board", - "organization_contact": "Organization name and mailing address", - "authorizing_official": "Authorizing official", - "current_sites": "Organization website", - "dotgov_domain": ".gov domain", - "purpose": "Purpose of your domain", - "your_contact": "Your contact information", - "other_contacts": "Other contacts for your domain", - "security_email": "Security email for public use", - "anything_else": "Anything else we should know?", - "requirements": "Requirements for registration and operation of .gov domains", - "review": "Review and submit your domain request", + Step.ORGANIZATION_TYPE: "Type of organization", + Step.ORGANIZATION_FEDERAL: "Type of organization — Federal", + Step.ORGANIZATION_ELECTION: "Type of organization — Election board", + Step.ORGANIZATION_CONTACT: "Organization name and mailing address", + Step.AUTHORIZING_OFFICIAL: "Authorizing official", + Step.CURRENT_SITES: "Organization website", + Step.DOTGOV_DOMAIN: ".gov domain", + Step.PURPOSE: "Purpose of your domain", + Step.YOUR_CONTACT: "Your contact information", + Step.OTHER_CONTACTS: "Other contacts for your domain", + Step.SECURITY_EMAIL: "Security email for public use", + Step.ANYTHING_ELSE: "Anything else we should know?", + Step.REQUIREMENTS: "Requirements for registration and operation of .gov domains", + Step.REVIEW: "Review and submit your domain request", } @@ -456,6 +524,10 @@ class ApplicationWizard(LoginRequiredMixin, NamedUrlSessionWizardView): domain applications. Each form in the sequence has its own URL and the progress through the form is stored in the Django session (thus "NamedUrlSessionWizardView"). + + Caution: due to the redirect performed by using NamedUrlSessionWizardView, + many methods, such as `process_step`, are called TWICE per request. For + this reason, methods in this class need to be idempotent. """ form_list = FORMS @@ -495,42 +567,97 @@ class ApplicationWizard(LoginRequiredMixin, NamedUrlSessionWizardView): context["is_federal"] = self._is_federal() return context - def forms_to_object(self, form_dict: dict) -> DomainApplication: - """Unpack the form responses onto the model object properties.""" + def get_application_object(self) -> DomainApplication: + """ + Attempt to match the current wizard with a DomainApplication. + + Will create an application if none exists. + """ + if "application_id" in self.storage.extra_data: + id = self.storage.extra_data["application_id"] + try: + return DomainApplication.objects.get( + creator=self.request.user, + pk=id, + ) + except DomainApplication.DoesNotExist: + logger.debug("Application id %s did not have a DomainApplication" % id) + application = DomainApplication.objects.create(creator=self.request.user) - - # organization type information - organization_type_data = form_dict["organization_type"].cleaned_data - application.organization_type = organization_type_data["organization_type"] - - # federal branch information may not exist - federal_branch_data = form_dict.get("organization_federal") - if federal_branch_data is not None: - federal_branch_data = federal_branch_data.cleaned_data - application.federal_branch = federal_branch_data["federal_type"] - - # election board information may not exist. - election_board_data = form_dict.get("organization_election") - if election_board_data is not None: - election_board_data = election_board_data.cleaned_data - application.is_election_office = election_board_data["is_election_board"] - - # contact information - contact_data = form_dict["organization_contact"].cleaned_data - application.organization_name = contact_data["organization_name"] - application.street_address = contact_data["address_line1"] - # TODO: add the rest of these fields when they are created in the forms - - # This isn't really the requested_domain field - # but we need something in this field to make the form submittable - requested_site, _ = Domain.objects.get_or_create( - name=contact_data["organization_name"] + ".gov" - ) - application.requested_domain = requested_site + self.storage.extra_data["application_id"] = application.id return application + def forms_to_database( + self, forms: dict = None, form: RegistrarForm = None + ) -> DomainApplication: + """ + Unpack the form responses onto the model object properties. + + Saves the application to the database. + """ + application = self.get_application_object() + + if forms: + itr = forms + elif form: + itr = {"form": form} + else: + raise TypeError("forms and form cannot both be None") + + for form in itr.values(): + if form is not None and hasattr(form, "to_database"): + form.to_database(application) + + return application + + def process_step(self, form): + """ + Hook called on every POST request, if the form is valid. + + Do not manipulate the form data here. + """ + # save progress + self.forms_to_database(form=form) + return self.get_form_step_data(form) + + def get_form(self, step=None, data=None, files=None): + """This method constructs the form for a given step.""" + form = super().get_form(step, data, files) + + # restore from database, but only if a record has already + # been associated with this wizard instance + if "application_id" in self.storage.extra_data: + application = self.get_application_object() + form.from_database(application) + return form + + def post(self, *args, **kwargs): + """This method handles POST requests.""" + step = self.steps.current + # always call super() first, to do important pre-processing + rendered = super().post(*args, **kwargs) + # if user opted to save their progress, + # return them to the page they were already on + button = self.request.POST.get("submit_button", None) + if button == "save": + return self.render_goto_step(step) + # otherwise, proceed as normal + return rendered + + def get(self, *args, **kwargs): + """This method handles GET requests.""" + current_url = resolve(self.request.path_info).url_name + # always call super(), it handles important redirect logic + rendered = super().get(*args, **kwargs) + # if user visited via an "edit" url, associate the id of the + # application they are trying to edit to this wizard instance + if current_url == "edit-application" and "id" in kwargs: + self.storage.extra_data["application_id"] = kwargs["id"] + return rendered + def done(self, form_list, form_dict, **kwargs): - application = self.forms_to_object(form_dict) + """Called when the data for every form is submitted and validated.""" + application = self.forms_to_database(forms=form_dict) application.submit() # change the status to submitted application.save() logger.debug("Application object saved: %s", application.id) diff --git a/src/registrar/migrations/0003_rename_is_election_office_domainapplication_is_election_board_and_more.py b/src/registrar/migrations/0003_rename_is_election_office_domainapplication_is_election_board_and_more.py new file mode 100644 index 000000000..34dec1d5d --- /dev/null +++ b/src/registrar/migrations/0003_rename_is_election_office_domainapplication_is_election_board_and_more.py @@ -0,0 +1,99 @@ +# Generated by Django 4.1.3 on 2022-12-02 21:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0002_domain_host_nameserver_hostip_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="domainapplication", + old_name="is_election_office", + new_name="is_election_board", + ), + migrations.RenameField( + model_name="domainapplication", + old_name="acknowledged_policy", + new_name="is_policy_acknowledged", + ), + migrations.RenameField( + model_name="domainapplication", + old_name="zip_code", + new_name="zipcode", + ), + migrations.RemoveField( + model_name="domainapplication", + name="federal_branch", + ), + migrations.RemoveField( + model_name="domainapplication", + name="street_address", + ), + migrations.RemoveField( + model_name="domainapplication", + name="unit_number", + ), + migrations.RemoveField( + model_name="domainapplication", + name="unit_type", + ), + migrations.AddField( + model_name="domainapplication", + name="address_line1", + field=models.TextField(blank=True, help_text="Address line 1", null=True), + ), + migrations.AddField( + model_name="domainapplication", + name="address_line2", + field=models.CharField( + blank=True, help_text="Address line 2", max_length=15, null=True + ), + ), + migrations.AddField( + model_name="domainapplication", + name="federal_type", + field=models.CharField( + blank=True, + choices=[ + ("executive", "Executive"), + ("judicial", "Judicial"), + ("legislative", "Legislative"), + ], + help_text="Branch of federal government", + max_length=50, + null=True, + ), + ), + migrations.AlterField( + model_name="domainapplication", + name="organization_type", + field=models.CharField( + blank=True, + choices=[ + ("federal", "Federal: a federal agency"), + ("interstate", "Interstate: an organization of two or more states"), + ( + "state_or_territory", + "State or Territory: One of the 50 U.S. states, the District of Columbia, American Samoa, Guam, Northern Mariana Islands, Puerto Rico, or the U.S. Virgin Islands", + ), + ( + "tribal", + "Tribal: a tribal government recognized by the federal or state government", + ), + ("county", "County: a county, parish, or borough"), + ("city", "City: a city, town, township, village, etc."), + ( + "special_district", + "Special District: an independent organization within a single state", + ), + ], + help_text="Type of Organization", + max_length=255, + null=True, + ), + ), + ] diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index e834df5d7..29126c98f 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -1,14 +1,14 @@ import logging import re +from django.apps import apps +from django.core.exceptions import ValidationError from django.db import models from django_fsm import FSMField, transition # type: ignore from epp.mock_epp import domain_info, domain_check from .utility.time_stamped_model import TimeStampedModel -from .domain_application import DomainApplication -from .user import User logger = logging.getLogger(__name__) @@ -93,11 +93,65 @@ class Domain(TimeStampedModel): DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(? bool: + def normalize(cls, domain: str, tld=None, blank=False) -> str: # noqa: C901 + """Return `domain` in form `.`. + + Raises ValueError if string cannot be normalized. + + This does not guarantee the returned string is a valid domain name. + + Set `blank` to True to allow empty strings. + """ + if blank and len(domain.strip()) == 0: + return "" + cleaned = domain.lower() + # starts with https or http + if cleaned.startswith("https://"): + cleaned = cleaned[8:] + if cleaned.startswith("http://"): + cleaned = cleaned[7:] + # has url parts + if "/" in cleaned: + cleaned = cleaned.split("/")[0] + # has query parts + if "?" in cleaned: + cleaned = cleaned.split("?")[0] + # has fragments + if "#" in cleaned: + cleaned = cleaned.split("#")[0] + # replace disallowed chars + re.sub(r"^[^A-Za-z0-9.-]+", "", cleaned) + + parts = cleaned.split(".") + # has subdomains or invalid repetitions + if cleaned.count(".") > 0: + # remove invalid repetitions + while parts[-1] == parts[-2]: + parts.pop() + # remove subdomains + parts = parts[-2:] + hasTLD = len(parts) == 2 + if hasTLD: + # set correct tld + if tld is not None: + parts[-1] = tld + else: + # add tld + if tld is not None: + parts.append(tld) + else: + raise ValueError("You must specify a tld for %s" % domain) + + cleaned = ".".join(parts) + + return cleaned + + @classmethod + def string_could_be_domain(cls, domain: str | None) -> bool: """Return True if the string could be a domain name, otherwise False.""" - if cls.DOMAIN_REGEX.match(domain): - return True - return False + if not isinstance(domain, str): + return False + return bool(cls.DOMAIN_REGEX.match(domain)) @classmethod def available(cls, domain: str) -> bool: @@ -137,16 +191,10 @@ class Domain(TimeStampedModel): # TODO: return an error if registry cannot be contacted return None - def could_be_domain(self) -> bool: - """Could this instance be a domain?""" - # short-circuit if self.website is null/None - if not self.name: - return False - return self.string_could_be_domain(str(self.name)) - @transition(field="is_active", source="*", target=True) def activate(self): """This domain should be made live.""" + DomainApplication = apps.get_model("registrar.DomainApplication") if hasattr(self, "domain_application"): if self.domain_application.status != DomainApplication.APPROVED: raise ValueError("Cannot activate. Application must be approved.") @@ -166,6 +214,34 @@ class Domain(TimeStampedModel): # if there is a feature request to implement this raise Exception("Cannot revoke, contact registry.") + @property + def sld(self): + """Get or set the second level domain string.""" + return self.name.split(".")[0] + + @sld.setter + def sld(self, value: str): + parts = self.name.split(".") + tld = parts[1] if len(parts) > 1 else "" + if Domain.string_could_be_domain(f"{value}.{tld}"): + self.name = f"{value}.{tld}" + else: + raise ValidationError("%s is not a valid second level domain" % value) + + @property + def tld(self): + """Get or set the top level domain string.""" + parts = self.name.split(".") + return parts[1] if len(parts) > 1 else "" + + @tld.setter + def tld(self, value: str): + sld = self.name.split(".")[0] + if Domain.string_could_be_domain(f"{sld}.{value}"): + self.name = f"{sld}.{value}" + else: + raise ValidationError("%s is not a valid top level domain" % value) + def __str__(self) -> str: return self.name @@ -232,6 +308,6 @@ class Domain(TimeStampedModel): # TODO: determine the relationship between this field # and the domain application's `creator` and `submitter` owners = models.ManyToManyField( - User, + "registrar.User", help_text="", ) diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py index 681d7edc8..236403cdb 100644 --- a/src/registrar/models/domain_application.py +++ b/src/registrar/models/domain_application.py @@ -1,14 +1,11 @@ from __future__ import annotations +from typing import TYPE_CHECKING, Union +from django.apps import apps from django.db import models from django_fsm import FSMField, transition # type: ignore from .utility.time_stamped_model import TimeStampedModel -from .contact import Contact -from .user import User -from .website import Website - -from typing import TYPE_CHECKING, Union if TYPE_CHECKING: from ..forms.application_wizard import ApplicationWizard @@ -30,35 +27,86 @@ class DomainApplication(TimeStampedModel): (APPROVED, APPROVED), ] - FEDERAL = "federal" - INTERSTATE = "interstate" - STATE_OR_TERRITORY = "state_or_territory" - TRIBAL = "tribal" - COUNTY = "county" - CITY = "city" - SPECIAL_DISTRICT = "special_district" - ORGANIZATION_CHOICES = [ - (FEDERAL, "a federal agency"), - (INTERSTATE, "an organization of two or more states"), - ( - STATE_OR_TERRITORY, - "one of the 50 U.S. states, the District of " - "Columbia, American Samoa, Guam, Northern Mariana Islands, " - "Puerto Rico, or the U.S. Virgin Islands", - ), - ( - TRIBAL, - "a tribal government recognized by the federal or " "state government", - ), - (COUNTY, "a county, parish, or borough"), - (CITY, "a city, town, township, village, etc."), - (SPECIAL_DISTRICT, "an independent organization within a single state"), - ] + class StateTerritoryChoices(models.TextChoices): + ALABAMA = "AL", "Alabama" + ALASKA = "AK", "Alaska" + ARIZONA = "AZ", "Arizona" + ARKANSAS = "AR", "Arkansas" + CALIFORNIA = "CA", "California" + COLORADO = "CO", "Colorado" + CONNECTICUT = "CT", "Connecticut" + DELAWARE = "DE", "Delaware" + DISTRICT_OF_COLUMBIA = "DC", "District of Columbia" + FLORIDA = "FL", "Florida" + GEORGIA = "GA", "Georgia" + HAWAII = "HI", "Hawaii" + IDAHO = "ID", "Idaho" + ILLINOIS = "IL", "Illinois" + INDIANA = "IN", "Indiana" + IOWA = "IA", "Iowa" + KANSAS = "KS", "Kansas" + KENTUCKY = "KY", "Kentucky" + LOUISIANA = "LA", "Louisiana" + MAINE = "ME", "Maine" + MARYLAND = "MD", "Maryland" + MASSACHUSETTS = "MA", "Massachusetts" + MICHIGAN = "MI", "Michigan" + MINNESOTA = "MN", "Minnesota" + MISSISSIPPI = "MS", "Mississippi" + MISSOURI = "MO", "Missouri" + MONTANA = "MT", "Montana" + NEBRASKA = "NE", "Nebraska" + NEVADA = "NV", "Nevada" + NEW_HAMPSHIRE = "NH", "New Hampshire" + NEW_JERSEY = "NJ", "New Jersey" + NEW_MEXICO = "NM", "New Mexico" + NEW_YORK = "NY", "New York" + NORTH_CAROLINA = "NC", "North Carolina" + NORTH_DAKOTA = "ND", "North Dakota" + OHIO = "OH", "Ohio" + OKLAHOMA = "OK", "Oklahoma" + OREGON = "OR", "Oregon" + PENNSYLVANIA = "PA", "Pennsylvania" + RHODE_ISLAND = "RI", "Rhode Island" + SOUTH_CAROLINA = "SC", "South Carolina" + SOUTH_DAKOTA = "SD", "South Dakota" + TENNESSEE = "TN", "Tennessee" + TEXAS = "TX", "Texas" + UTAH = "UT", "Utah" + VERMONT = "VT", "Vermont" + VIRGINIA = "VA", "Virginia" + WASHINGTON = "WA", "Washington" + WEST_VIRGINIA = "WV", "West Virginia" + WISCONSIN = "WI", "Wisconsin" + WYOMING = "WY", "Wyoming" + AMERICAN_SAMOA = "AS", "American Samoa" + GUAM = "GU", "Guam" + NORTHERN_MARIANA_ISLANDS = "MP", "Northern Mariana Islands" + PUERTO_RICO = "PR", "Puerto Rico" + VIRGIN_ISLANDS = "VI", "Virgin Islands" - EXECUTIVE = "Executive" - JUDICIAL = "Judicial" - LEGISLATIVE = "Legislative" - BRANCH_CHOICES = [(x, x) for x in (EXECUTIVE, JUDICIAL, LEGISLATIVE)] + class OrganizationChoices(models.TextChoices): + FEDERAL = "federal", "Federal: a federal agency" + INTERSTATE = "interstate", "Interstate: an organization of two or more states" + STATE_OR_TERRITORY = "state_or_territory", ( + "State or Territory: One of the 50 U.S. states, the District of " + "Columbia, American Samoa, Guam, Northern Mariana Islands, " + "Puerto Rico, or the U.S. Virgin Islands" + ) + TRIBAL = "tribal", ( + "Tribal: a tribal government recognized by the federal or " + "state government" + ) + COUNTY = "county", "County: a county, parish, or borough" + CITY = "city", "City: a city, town, township, village, etc." + SPECIAL_DISTRICT = "special_district", ( + "Special District: an independent organization within a single state" + ) + + class BranchChoices(models.TextChoices): + EXECUTIVE = "executive", "Executive" + JUDICIAL = "judicial", "Judicial" + LEGISLATIVE = "legislative", "Legislative" # #### Internal fields about the application ##### status = FSMField( @@ -69,10 +117,12 @@ class DomainApplication(TimeStampedModel): # This is the application user who created this application. The contact # information that they gave is in the `submitter` field creator = models.ForeignKey( - User, on_delete=models.PROTECT, related_name="applications_created" + "registrar.User", + on_delete=models.PROTECT, + related_name="applications_created", ) investigator = models.ForeignKey( - User, + "registrar.User", null=True, blank=True, on_delete=models.SET_NULL, @@ -82,21 +132,21 @@ class DomainApplication(TimeStampedModel): # ##### data fields from the initial form ##### organization_type = models.CharField( max_length=255, - choices=ORGANIZATION_CHOICES, + choices=OrganizationChoices.choices, null=True, blank=True, help_text="Type of Organization", ) - federal_branch = models.CharField( + federal_type = models.CharField( max_length=50, - choices=BRANCH_CHOICES, + choices=BranchChoices.choices, null=True, blank=True, help_text="Branch of federal government", ) - is_election_office = models.BooleanField( + is_election_board = models.BooleanField( null=True, blank=True, help_text="Is your ogranization an election office?", @@ -108,22 +158,16 @@ class DomainApplication(TimeStampedModel): help_text="Organization name", db_index=True, ) - street_address = models.TextField( + address_line1 = models.TextField( null=True, blank=True, - help_text="Street Address", + help_text="Address line 1", ) - unit_type = models.CharField( + address_line2 = models.CharField( max_length=15, null=True, blank=True, - help_text="Unit type", - ) - unit_number = models.CharField( - max_length=255, - null=True, - blank=True, - help_text="Unit number", + help_text="Address line 2", ) state_territory = models.CharField( max_length=2, @@ -131,7 +175,7 @@ class DomainApplication(TimeStampedModel): blank=True, help_text="State/Territory", ) - zip_code = models.CharField( + zipcode = models.CharField( max_length=10, null=True, blank=True, @@ -140,7 +184,7 @@ class DomainApplication(TimeStampedModel): ) authorizing_official = models.ForeignKey( - Contact, + "registrar.Contact", null=True, blank=True, related_name="authorizing_official", @@ -149,7 +193,7 @@ class DomainApplication(TimeStampedModel): # "+" means no reverse relation to lookup applications from Website current_websites = models.ManyToManyField( - Website, + "registrar.Website", blank=True, related_name="current+", ) @@ -163,7 +207,7 @@ class DomainApplication(TimeStampedModel): on_delete=models.PROTECT, ) alternative_domains = models.ManyToManyField( - Website, + "registrar.Website", blank=True, related_name="alternatives+", ) @@ -171,7 +215,7 @@ class DomainApplication(TimeStampedModel): # This is the contact information provided by the applicant. The # application user who created it is in the `creator` field. submitter = models.ForeignKey( - Contact, + "registrar.Contact", null=True, blank=True, related_name="submitted_applications", @@ -185,7 +229,7 @@ class DomainApplication(TimeStampedModel): ) other_contacts = models.ManyToManyField( - Contact, + "registrar.Contact", blank=True, related_name="contact_applications", ) @@ -203,7 +247,7 @@ class DomainApplication(TimeStampedModel): help_text="Anything else we should know?", ) - acknowledged_policy = models.BooleanField( + is_policy_acknowledged = models.BooleanField( null=True, blank=True, help_text="Acknowledged .gov acceptable use policy", @@ -226,8 +270,15 @@ class DomainApplication(TimeStampedModel): # can raise more informative exceptions # requested_domain could be None here - if (not self.requested_domain) or (not self.requested_domain.could_be_domain()): - raise ValueError("Requested domain is not a legal domain name.") + if not hasattr(self, "requested_domain"): + raise ValueError("Requested domain is missing.") + + if self.requested_domain is None: + raise ValueError("Requested domain is missing.") + + Domain = apps.get_model("registrar.Domain") + if not Domain.string_could_be_domain(self.requested_domain.name): + raise ValueError("Requested domain is not a valid domain name.") # if no exception was raised, then we don't need to do anything # inside this method, keep the `pass` here to remind us of that @@ -253,10 +304,8 @@ class DomainApplication(TimeStampedModel): @staticmethod def show_organization_federal(wizard: ApplicationWizard) -> bool: """Show this step if the answer to the first question was "federal".""" - return ( - DomainApplication._get_organization_type(wizard) - == DomainApplication.FEDERAL - ) + user_choice = DomainApplication._get_organization_type(wizard) + return user_choice == DomainApplication.OrganizationChoices.FEDERAL @staticmethod def show_organization_election(wizard: ApplicationWizard) -> bool: @@ -264,10 +313,9 @@ class DomainApplication(TimeStampedModel): This shows for answers that aren't "Federal" or "Interstate". """ - type_answer = DomainApplication._get_organization_type(wizard) - if type_answer and type_answer not in ( - DomainApplication.FEDERAL, - DomainApplication.INTERSTATE, - ): - return True - return False + user_choice = DomainApplication._get_organization_type(wizard) + excluded = [ + DomainApplication.OrganizationChoices.FEDERAL, + DomainApplication.OrganizationChoices.INTERSTATE, + ] + return bool(user_choice and user_choice not in excluded) diff --git a/src/registrar/models/host.py b/src/registrar/models/host.py index 23f6c7659..bab968afc 100644 --- a/src/registrar/models/host.py +++ b/src/registrar/models/host.py @@ -1,7 +1,6 @@ from django.db import models from .utility.time_stamped_model import TimeStampedModel -from .domain import Domain class Host(TimeStampedModel): @@ -26,7 +25,7 @@ class Host(TimeStampedModel): ) domain = models.ForeignKey( - Domain, + "registrar.Domain", on_delete=models.PROTECT, related_name="host", # access this Host via the Domain as `domain.host` help_text="Domain to which this host belongs", diff --git a/src/registrar/models/host_ip.py b/src/registrar/models/host_ip.py index 22847b77c..4d646898b 100644 --- a/src/registrar/models/host_ip.py +++ b/src/registrar/models/host_ip.py @@ -2,7 +2,6 @@ from django.db import models from django.core.validators import validate_ipv46_address from .utility.time_stamped_model import TimeStampedModel -from .host import Host class HostIP(TimeStampedModel): @@ -25,7 +24,7 @@ class HostIP(TimeStampedModel): ) host = models.ForeignKey( - Host, + "registrar.Host", on_delete=models.PROTECT, related_name="ip", # access this HostIP via the Host as `host.ip` help_text="Host to which this IP address belongs", diff --git a/src/registrar/models/user_profile.py b/src/registrar/models/user_profile.py index 8d7a7d4b0..fd08c7821 100644 --- a/src/registrar/models/user_profile.py +++ b/src/registrar/models/user_profile.py @@ -4,7 +4,6 @@ from .utility.time_stamped_model import TimeStampedModel from .utility.address_model import AddressModel from .contact import Contact -from .user import User class UserProfile(TimeStampedModel, Contact, AddressModel): @@ -12,7 +11,7 @@ class UserProfile(TimeStampedModel, Contact, AddressModel): """User information, unrelated to their login/auth details.""" user = models.OneToOneField( - User, + "registrar.User", null=True, blank=True, on_delete=models.CASCADE, diff --git a/src/registrar/models/website.py b/src/registrar/models/website.py index a0db7a2a2..5b4efb619 100644 --- a/src/registrar/models/website.py +++ b/src/registrar/models/website.py @@ -1,3 +1,5 @@ +from django.apps import apps +from django.core.exceptions import ValidationError from django.db import models @@ -14,5 +16,35 @@ class Website(models.Model): help_text="", ) + @property + def sld(self): + """Get or set the second level domain string.""" + return self.website.split(".")[0] + + @sld.setter + def sld(self, value: str): + Domain = apps.get_model("registrar.Domain") + parts = self.website.split(".") + tld = parts[1] if len(parts) > 1 else "" + if Domain.string_could_be_domain(f"{value}.{tld}"): + self.website = f"{value}.{tld}" + else: + raise ValidationError("%s is not a valid second level domain" % value) + + @property + def tld(self): + """Get or set the top level domain string.""" + parts = self.website.split(".") + return parts[1] if len(parts) > 1 else "" + + @tld.setter + def tld(self, value: str): + Domain = apps.get_model("registrar.Domain") + sld = self.website.split(".")[0] + if Domain.string_could_be_domain(f"{sld}.{value}"): + self.website = f"{sld}.{value}" + else: + raise ValidationError("%s is not a valid top level domain" % value) + def __str__(self) -> str: return str(self.website) diff --git a/src/registrar/templates/application_dotgov_domain.html b/src/registrar/templates/application_dotgov_domain.html index abe3e774b..d8e46d3cd 100644 --- a/src/registrar/templates/application_dotgov_domain.html +++ b/src/registrar/templates/application_dotgov_domain.html @@ -27,10 +27,10 @@ {{ wizard.management_form }} {% csrf_token %} - {{ wizard.form.dotgov_domain|add_label_class:"usa-label" }} + {{ wizard.form.requested_domain|add_label_class:"usa-label" }}
www. - {{ wizard.form.dotgov_domain|add_class:"usa-input"|attr:"aria-describedby:domain_instructions" }} + {{ wizard.form.requested_domain|add_class:"usa-input"|attr:"aria-describedby:domain_instructions" }} .gov
diff --git a/src/registrar/templates/application_form.html b/src/registrar/templates/application_form.html index 4c28048af..949e4bdff 100644 --- a/src/registrar/templates/application_form.html +++ b/src/registrar/templates/application_form.html @@ -19,14 +19,32 @@ {% endif %}

{{form_titles|get_item:wizard.steps.current}}

{% block form_content %} + + +
{% if wizard.steps.next %} - + {% else %} - + {% endif %} - +
+ {% endblock %} diff --git a/src/registrar/templates/application_org_election.html b/src/registrar/templates/application_org_election.html index ed30d3efe..877bdb81b 100644 --- a/src/registrar/templates/application_org_election.html +++ b/src/registrar/templates/application_org_election.html @@ -13,8 +13,9 @@

Is your organization an election office?

{% radio_buttons_by_value wizard.form.is_election_board as choices %} - {% include "includes/radio_button.html" with choice=choices|get_item:True %} - {% include "includes/radio_button.html" with choice=choices|get_item:False %} + {% for choice in choices.values %} + {% include "includes/radio_button.html" with choice=choice tile="true" %} + {% endfor %} {{ block.super }} diff --git a/src/registrar/templates/application_org_federal.html b/src/registrar/templates/application_org_federal.html index 59daf5d7c..b4da3b2f0 100644 --- a/src/registrar/templates/application_org_federal.html +++ b/src/registrar/templates/application_org_federal.html @@ -12,10 +12,10 @@

Which federal branch is your organization in?

- {% radio_buttons_by_value wizard.form.federal_type as federal_choices %} - {% include "includes/radio_button.html" with choice=federal_choices.Executive%} - {% include "includes/radio_button.html" with choice=federal_choices.Judicial%} - {% include "includes/radio_button.html" with choice=federal_choices.Legislative%} + {% radio_buttons_by_value wizard.form.federal_type as choices %} + {% for choice in choices.values %} + {% include "includes/radio_button.html" with choice=choice tile="true" %} + {% endfor %} {{ block.super }} diff --git a/src/registrar/templates/application_org_type.html b/src/registrar/templates/application_org_type.html index 089f0b329..188a7bb40 100644 --- a/src/registrar/templates/application_org_type.html +++ b/src/registrar/templates/application_org_type.html @@ -16,13 +16,9 @@

What kind of government organization do you represent?

{{ wizard.form.organization_type.errors }} - {% include "includes/radio_button.html" with choice=choices.federal tile="true" %} - {% include "includes/radio_button.html" with choice=choices.interstate tile="true" %} - {% include "includes/radio_button.html" with choice=choices.state_or_territory tile="true" %} - {% include "includes/radio_button.html" with choice=choices.tribal tile="true" %} - {% include "includes/radio_button.html" with choice=choices.county tile="true" %} - {% include "includes/radio_button.html" with choice=choices.city tile="true" %} - {% include "includes/radio_button.html" with choice=choices.special_district tile="true" %} + {% for choice in choices.values %} + {% include "includes/radio_button.html" with choice=choice tile="true" %} + {% endfor %} {{ block.super }} diff --git a/src/registrar/templates/application_purpose.html b/src/registrar/templates/application_purpose.html index b890ae0e1..50819de37 100644 --- a/src/registrar/templates/application_purpose.html +++ b/src/registrar/templates/application_purpose.html @@ -12,8 +12,8 @@ {% csrf_token %}
- {{ wizard.form.purpose_field|add_label_class:"usa-label usa-sr-only" }} - {{ wizard.form.purpose_field|add_class:"usa-textarea usa-character-count__field"|attr:"aria-describedby:instructions"|attr:"maxlength=500" }} + {{ wizard.form.purpose|add_label_class:"usa-label usa-sr-only" }} + {{ wizard.form.purpose|add_class:"usa-textarea usa-character-count__field"|attr:"aria-describedby:instructions"|attr:"maxlength=500" }} You can enter up to 500 characters
diff --git a/src/registrar/templates/application_requirements.html b/src/registrar/templates/application_requirements.html index f5f41a877..7f5d23631 100644 --- a/src/registrar/templates/application_requirements.html +++ b/src/registrar/templates/application_requirements.html @@ -60,8 +60,8 @@ {% csrf_token %}
- {{ wizard.form.agree_check|add_class:"usa-checkbox__input"}} - {{ wizard.form.agree_check|add_label_class:"usa-checkbox__label" }} + {{ wizard.form.is_policy_acknowledged|add_class:"usa-checkbox__input"}} + {{ wizard.form.is_policy_acknowledged|add_label_class:"usa-checkbox__label" }}
diff --git a/src/registrar/templates/application_security_email.html b/src/registrar/templates/application_security_email.html index 86410f489..540952a63 100644 --- a/src/registrar/templates/application_security_email.html +++ b/src/registrar/templates/application_security_email.html @@ -11,8 +11,8 @@ {{ wizard.management_form }} {% csrf_token %} - {{ wizard.form.email|add_label_class:"usa-label" }} - {{ wizard.form.email|add_class:"usa-input"|attr:"aria-describedby:instructions" }} + {{ wizard.form.security_email|add_label_class:"usa-label" }} + {{ wizard.form.security_email|add_class:"usa-input"|attr:"aria-describedby:instructions" }} {{ block.super }} diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index 2010d87a2..ac5414c0b 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -32,7 +32,11 @@ {% for application in domain_applications %} - {{ application.requested_domain.name }} + + + {{ application.requested_domain.name|default:"New domain request" }} + + {{ application.status }} {% endfor %} diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 535ab9360..87a6b8df0 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -27,22 +27,21 @@ class TestDomainApplication(TestCase): application = DomainApplication.objects.create( creator=user, investigator=user, - organization_type=DomainApplication.FEDERAL, - federal_branch=DomainApplication.EXECUTIVE, - is_election_office=False, + organization_type=DomainApplication.OrganizationChoices.FEDERAL, + federal_type=DomainApplication.BranchChoices.EXECUTIVE, + is_election_board=False, organization_name="Test", - street_address="100 Main St.", - unit_type="APT", - unit_number="1A", + address_line1="100 Main St.", + address_line2="APT 1A", state_territory="CA", - zip_code="12345-6789", + zipcode="12345-6789", authorizing_official=contact, requested_domain=domain, submitter=contact, purpose="Igorville rules!", security_email="security@igorville.gov", anything_else="All of Igorville loves the dotgov program.", - acknowledged_policy=True, + is_policy_acknowledged=True, ) application.current_websites.add(com_website) application.alternative_domains.add(gov_website) diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index dc08086a4..0f44a5bb0 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -1,3 +1,5 @@ +from unittest import skip + from django.conf import settings from django.test import Client, TestCase from django.urls import reverse @@ -5,7 +7,7 @@ from django.contrib.auth import get_user_model from django_webtest import WebTest # type: ignore -from registrar.models import DomainApplication, Domain +from registrar.models import DomainApplication, Domain, Contact, Website from registrar.forms.application_wizard import TITLES from .common import less_console_noise @@ -87,9 +89,9 @@ class LoggedInTests(TestWithUser): ) -class FormTests(TestWithUser, WebTest): +class DomainApplicationTests(TestWithUser, WebTest): - """Webtests for forms to test filling and submitting.""" + """Webtests for domain application to test filling and submitting.""" # Doesn't work with CSRF checking # hypothesis is that CSRF_USE_SESSIONS is incompatible with WebTest @@ -100,7 +102,7 @@ class FormTests(TestWithUser, WebTest): self.app.set_user(self.user.username) def tearDown(self): - # delete any applications we made so that users can be deleted\ + # delete any applications we made so that users can be deleted DomainApplication.objects.all().delete() super().tearDown() @@ -116,6 +118,10 @@ class FormTests(TestWithUser, WebTest): As we add additional form pages, we need to include them here to make this test work. """ + num_pages_tested = 0 + SKIPPED_PAGES = 1 # elections + num_pages = len(TITLES) - SKIPPED_PAGES + type_page = self.app.get(reverse("application")).follow() # django-webtest does not handle cookie-based sessions well because it keeps # resetting the session key on each new request, thus destroying the concept @@ -127,7 +133,16 @@ class FormTests(TestWithUser, WebTest): type_form = type_page.form type_form["organization_type-organization_type"] = "federal" - # set the session ID before .submit() + # test saving the page + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + result = type_page.form.submit("submit_button", value="save") + # should remain on the same page + self.assertEquals(result["Location"], "/register/organization_type/") + # should see results in db + application = DomainApplication.objects.get() # there's only one + self.assertEquals(application.organization_type, "federal") + + # test next button self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) type_result = type_page.form.submit() @@ -135,20 +150,31 @@ class FormTests(TestWithUser, WebTest): # the application self.assertEquals(type_result.status_code, 302) self.assertEquals(type_result["Location"], "/register/organization_federal/") + num_pages_tested += 1 # ---- FEDERAL BRANCH PAGE ---- # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) federal_page = type_result.follow() federal_form = federal_page.form - federal_form["organization_federal-federal_type"] = "Executive" + federal_form["organization_federal-federal_type"] = "executive" - # set the session ID before .submit() + # test saving the page + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + result = federal_page.form.submit("submit_button", value="save") + # should remain on the same page + self.assertEquals(result["Location"], "/register/organization_federal/") + # should see results in db + application = DomainApplication.objects.get() # there's only one + self.assertEquals(application.federal_type, "executive") + + # test next button self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) federal_result = federal_form.submit() self.assertEquals(federal_result.status_code, 302) self.assertEquals(federal_result["Location"], "/register/organization_contact/") + num_pages_tested += 1 # ---- ORG CONTACT PAGE ---- # Follow the redirect to the next form page @@ -156,9 +182,22 @@ class FormTests(TestWithUser, WebTest): org_contact_form = org_contact_page.form org_contact_form["organization_contact-organization_name"] = "Testorg" org_contact_form["organization_contact-address_line1"] = "address 1" - org_contact_form["organization_contact-us_state"] = "NY" + org_contact_form["organization_contact-state_territory"] = "NY" org_contact_form["organization_contact-zipcode"] = "10002" + # test saving the page + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + result = org_contact_page.form.submit("submit_button", value="save") + # should remain on the same page + self.assertEquals(result["Location"], "/register/organization_contact/") + # should see results in db + application = DomainApplication.objects.get() # there's only one + self.assertEquals(application.organization_name, "Testorg") + self.assertEquals(application.address_line1, "address 1") + self.assertEquals(application.state_territory, "NY") + self.assertEquals(application.zipcode, "10002") + + # test next button self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) org_contact_result = org_contact_form.submit() @@ -166,6 +205,8 @@ class FormTests(TestWithUser, WebTest): self.assertEquals( org_contact_result["Location"], "/register/authorizing_official/" ) + num_pages_tested += 1 + # ---- AUTHORIZING OFFICIAL PAGE ---- # Follow the redirect to the next form page ao_page = org_contact_result.follow() @@ -176,11 +217,26 @@ class FormTests(TestWithUser, WebTest): ao_form["authorizing_official-email"] = "testy@town.com" ao_form["authorizing_official-phone"] = "(555) 555 5555" + # test saving the page + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + result = ao_page.form.submit("submit_button", value="save") + # should remain on the same page + self.assertEquals(result["Location"], "/register/authorizing_official/") + # should see results in db + application = DomainApplication.objects.get() # there's only one + self.assertEquals(application.authorizing_official.first_name, "Testy") + self.assertEquals(application.authorizing_official.last_name, "Tester") + self.assertEquals(application.authorizing_official.title, "Chief Tester") + self.assertEquals(application.authorizing_official.email, "testy@town.com") + self.assertEquals(application.authorizing_official.phone, "(555) 555 5555") + + # test next button self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) ao_result = ao_form.submit() self.assertEquals(ao_result.status_code, 302) self.assertEquals(ao_result["Location"], "/register/current_sites/") + num_pages_tested += 1 # ---- CURRENT SITES PAGE ---- # Follow the redirect to the next form page @@ -188,35 +244,73 @@ class FormTests(TestWithUser, WebTest): current_sites_form = current_sites_page.form current_sites_form["current_sites-current_site"] = "www.city.com" + # test saving the page + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + result = current_sites_page.form.submit("submit_button", value="save") + # should remain on the same page + self.assertEquals(result["Location"], "/register/current_sites/") + # should see results in db + application = DomainApplication.objects.get() # there's only one + self.assertEquals( + application.current_websites.filter(website="city.com").count(), 1 + ) + + # test next button self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) current_sites_result = current_sites_form.submit() self.assertEquals(current_sites_result.status_code, 302) self.assertEquals(current_sites_result["Location"], "/register/dotgov_domain/") + num_pages_tested += 1 # ---- DOTGOV DOMAIN PAGE ---- # Follow the redirect to the next form page dotgov_page = current_sites_result.follow() dotgov_form = dotgov_page.form - dotgov_form["dotgov_domain-dotgov_domain"] = "city" + dotgov_form["dotgov_domain-requested_domain"] = "city" + dotgov_form["dotgov_domain-alternative_domain"] = "city1" + + # test saving the page + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + result = dotgov_page.form.submit("submit_button", value="save") + # should remain on the same page + self.assertEquals(result["Location"], "/register/dotgov_domain/") + # should see results in db + application = DomainApplication.objects.get() # there's only one + self.assertEquals(application.requested_domain.name, "city.gov") + self.assertEquals( + application.alternative_domains.filter(website="city1.gov").count(), 1 + ) self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) dotgov_result = dotgov_form.submit() self.assertEquals(dotgov_result.status_code, 302) self.assertEquals(dotgov_result["Location"], "/register/purpose/") + num_pages_tested += 1 - # ---- PURPOSE DOMAIN PAGE ---- + # ---- PURPOSE PAGE ---- # Follow the redirect to the next form page purpose_page = dotgov_result.follow() purpose_form = purpose_page.form - purpose_form["purpose-purpose_field"] = "Purpose of the site" + purpose_form["purpose-purpose"] = "Purpose of the site" + # test saving the page + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + result = purpose_page.form.submit("submit_button", value="save") + # should remain on the same page + self.assertEquals(result["Location"], "/register/purpose/") + # should see results in db + application = DomainApplication.objects.get() # there's only one + self.assertEquals(application.purpose, "Purpose of the site") + + # test next button self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) purpose_result = purpose_form.submit() self.assertEquals(purpose_result.status_code, 302) self.assertEquals(purpose_result["Location"], "/register/your_contact/") + num_pages_tested += 1 # ---- YOUR CONTACT INFO PAGE ---- # Follow the redirect to the next form page @@ -229,11 +323,26 @@ class FormTests(TestWithUser, WebTest): your_contact_form["your_contact-email"] = "testy-admin@town.com" your_contact_form["your_contact-phone"] = "(555) 555 5556" + # test saving the page + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + result = your_contact_page.form.submit("submit_button", value="save") + # should remain on the same page + self.assertEquals(result["Location"], "/register/your_contact/") + # should see results in db + application = DomainApplication.objects.get() # there's only one + self.assertEquals(application.submitter.first_name, "Testy you") + self.assertEquals(application.submitter.last_name, "Tester you") + self.assertEquals(application.submitter.title, "Admin Tester") + self.assertEquals(application.submitter.email, "testy-admin@town.com") + self.assertEquals(application.submitter.phone, "(555) 555 5556") + + # test next button self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) your_contact_result = your_contact_form.submit() self.assertEquals(your_contact_result.status_code, 302) self.assertEquals(your_contact_result["Location"], "/register/other_contacts/") + num_pages_tested += 1 # ---- OTHER CONTACTS PAGE ---- # Follow the redirect to the next form page @@ -246,6 +355,25 @@ class FormTests(TestWithUser, WebTest): other_contacts_form["other_contacts-email"] = "testy2@town.com" other_contacts_form["other_contacts-phone"] = "(555) 555 5557" + # test saving the page + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + result = other_contacts_page.form.submit("submit_button", value="save") + # should remain on the same page + self.assertEquals(result["Location"], "/register/other_contacts/") + # should see results in db + application = DomainApplication.objects.get() # there's only one + self.assertEquals( + application.other_contacts.filter( + first_name="Testy2", + last_name="Tester2", + title="Another Tester", + email="testy2@town.com", + phone="(555) 555 5557", + ).count(), + 1, + ) + + # test next button self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) other_contacts_result = other_contacts_form.submit() @@ -253,19 +381,31 @@ class FormTests(TestWithUser, WebTest): self.assertEquals( other_contacts_result["Location"], "/register/security_email/" ) + num_pages_tested += 1 # ---- SECURITY EMAIL PAGE ---- # Follow the redirect to the next form page security_email_page = other_contacts_result.follow() security_email_form = security_email_page.form - security_email_form["security_email-email"] = "security@city.com" + security_email_form["security_email-security_email"] = "security@city.com" + # test saving the page + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + result = security_email_page.form.submit("submit_button", value="save") + # should remain on the same page + self.assertEquals(result["Location"], "/register/security_email/") + # should see results in db + application = DomainApplication.objects.get() # there's only one + self.assertEquals(application.security_email, "security@city.com") + + # test next button self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) security_email_result = security_email_form.submit() self.assertEquals(security_email_result.status_code, 302) self.assertEquals(security_email_result["Location"], "/register/anything_else/") + num_pages_tested += 1 # ---- ANYTHING ELSE PAGE ---- # Follow the redirect to the next form page @@ -274,37 +414,65 @@ class FormTests(TestWithUser, WebTest): anything_else_form["anything_else-anything_else"] = "No" + # test saving the page + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + result = anything_else_page.form.submit("submit_button", value="save") + # should remain on the same page + self.assertEquals(result["Location"], "/register/anything_else/") + # should see results in db + application = DomainApplication.objects.get() # there's only one + self.assertEquals(application.anything_else, "No") + + # test next button self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) anything_else_result = anything_else_form.submit() self.assertEquals(anything_else_result.status_code, 302) self.assertEquals(anything_else_result["Location"], "/register/requirements/") + num_pages_tested += 1 # ---- REQUIREMENTS PAGE ---- # Follow the redirect to the next form page requirements_page = anything_else_result.follow() requirements_form = requirements_page.form - requirements_form["requirements-agree_check"] = True + requirements_form["requirements-is_policy_acknowledged"] = True + # test saving the page + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + result = requirements_page.form.submit("submit_button", value="save") + # should remain on the same page + self.assertEquals(result["Location"], "/register/requirements/") + # should see results in db + application = DomainApplication.objects.get() # there's only one + self.assertEquals(application.is_policy_acknowledged, True) + + # test next button self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) requirements_result = requirements_form.submit() self.assertEquals(requirements_result.status_code, 302) self.assertEquals(requirements_result["Location"], "/register/review/") + num_pages_tested += 1 # ---- REVIEW AND FINSIHED PAGES ---- # Follow the redirect to the next form page review_page = requirements_result.follow() review_form = review_page.form - # final submission results in a redirect to the "finished" URL + # test saving the page + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + result = review_page.form.submit("submit_button", value="save") + # should remain on the same page + self.assertEquals(result["Location"], "/register/review/") + # final submission results in a redirect to the "finished" URL self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) review_result = review_form.submit() self.assertEquals(review_result.status_code, 302) self.assertEquals(review_result["Location"], "/register/finished/") + num_pages_tested += 1 # following this redirect is a GET request, so include the cookie # here too. @@ -313,6 +481,9 @@ class FormTests(TestWithUser, WebTest): final_result = review_result.follow() self.assertContains(final_result, "Thank you for your domain request") + # check that any new pages are added to this test + self.assertEqual(num_pages, num_pages_tested) + def test_application_form_conditional_federal(self): """Federal branch question is shown for federal organizations.""" type_page = self.app.get(reverse("application")).follow() @@ -380,8 +551,7 @@ class FormTests(TestWithUser, WebTest): self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) type_result = type_form.submit() - # the post request should return a redirect to the federal branch - # question + # the post request should return a redirect to the elections question self.assertEquals(type_result.status_code, 302) self.assertEquals(type_result["Location"], "/register/organization_election/") @@ -406,3 +576,132 @@ class FormTests(TestWithUser, WebTest): self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) contact_page = election_result.follow() self.assertNotContains(contact_page, "Top level federal agency") + + + @skip("WIP") + def test_application_edit_restore(self): + """ + Test that a previously saved application is available at the /edit endpoint. + """ + ao, _ = Contact.objects.get_or_create( + first_name="Testy", + last_name="Tester", + title="Chief Tester", + email="testy@town.com", + phone="(555) 555 5555", + ) + domain, _ = Domain.objects.get_or_create(name="city.gov") + alt, _ = Website.objects.get_or_create(website="city1.gov") + current, _ = Website.objects.get_or_create(website="city.com") + you, _ = Contact.objects.get_or_create( + first_name="Testy you", + last_name="Tester you", + title="Admin Tester", + email="testy-admin@town.com", + phone="(555) 555 5556", + ) + other, _ = Contact.objects.get_or_create( + first_name="Testy2", + last_name="Tester2", + title="Another Tester", + email="testy2@town.com", + phone="(555) 555 5557", + ) + application, _ = DomainApplication.objects.get_or_create( + organization_type="federal", + federal_type="executive", + purpose="Purpose of the site", + security_email="security@city.com", + anything_else="No", + is_policy_acknowledged=True, + organization_name="Testorg", + address_line1="address 1", + state_territory="NY", + zipcode="10002", + authorizing_official=ao, + requested_domain=domain, + submitter=you, + creator=self.user, + ) + application.other_contacts.add(other) + application.current_websites.add(current) + application.alternative_domains.add(alt) + + # prime the form by visiting /edit + url = reverse("edit-application", kwargs={"id": application.pk}) + response = self.client.get(url) + + # TODO: this is a sketch of each page in the wizard which needs to be tested + # Django does not have tools sufficient for real end to end integration testing + # (for example, USWDS moves radio buttons off screen and replaces them with + # CSS styled "fakes" -- Django cannot determine if those are visually correct) + # -- the best that can/should be done here is to ensure the correct values + # are being passed to the templating engine + + url = reverse("application_step", kwargs={"step": "organization_type"}) + response = self.client.get(url, follow=True) + self.assertContains(response, "") + # choices = response.context['wizard']['form']['organization_type'].subwidgets + # radio = [ x for x in choices if x.data["value"] == "federal" ][0] + # checked = radio.data["selected"] + # self.assertTrue(checked) + + # url = reverse("application_step", kwargs={"step": "organization_federal"}) + # self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # page = self.app.get(url) + # self.assertNotContains(page, "VALUE") + + # url = reverse("application_step", kwargs={"step": "organization_contact"}) + # self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # page = self.app.get(url) + # self.assertNotContains(page, "VALUE") + + # url = reverse("application_step", kwargs={"step": "authorizing_official"}) + # self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # page = self.app.get(url) + # self.assertNotContains(page, "VALUE") + + # url = reverse("application_step", kwargs={"step": "current_sites"}) + # self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # page = self.app.get(url) + # self.assertNotContains(page, "VALUE") + + # url = reverse("application_step", kwargs={"step": "dotgov_domain"}) + # self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # page = self.app.get(url) + # self.assertNotContains(page, "VALUE") + + # url = reverse("application_step", kwargs={"step": "purpose"}) + # self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # page = self.app.get(url) + # self.assertNotContains(page, "VALUE") + + # url = reverse("application_step", kwargs={"step": "your_contact"}) + # self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # page = self.app.get(url) + # self.assertNotContains(page, "VALUE") + + # url = reverse("application_step", kwargs={"step": "other_contacts"}) + # self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # page = self.app.get(url) + # self.assertNotContains(page, "VALUE") + + # url = reverse("application_step", kwargs={"step": "other_contacts"}) + # self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # page = self.app.get(url) + # self.assertNotContains(page, "VALUE") + + # url = reverse("application_step", kwargs={"step": "security_email"}) + # self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # page = self.app.get(url) + # self.assertNotContains(page, "VALUE") + + # url = reverse("application_step", kwargs={"step": "anything_else"}) + # self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # page = self.app.get(url) + # self.assertNotContains(page, "VALUE") + + # url = reverse("application_step", kwargs={"step": "requirements"}) + # self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + # page = self.app.get(url) + # self.assertNotContains(page, "VALUE")