diff --git a/src/registrar/forms/application_wizard.py b/src/registrar/forms/application_wizard.py index 1e067ba8a..b5e60c0d1 100644 --- a/src/registrar/forms/application_wizard.py +++ b/src/registrar/forms/application_wizard.py @@ -14,9 +14,12 @@ from api.views import DOMAIN_API_MESSAGES from registrar.models import Contact, DomainApplication, DraftDomain, Domain from registrar.templatetags.url_helpers import public_site_url from registrar.utility import errors +from django.utils.translation import gettext_lazy as _, ngettext logger = logging.getLogger(__name__) +TOTAL_FORM_COUNT = 'TOTAL_FORMS' +INITIAL_FORM_COUNT = 'INITIAL_FORMS' class RegistrarForm(forms.Form): """ @@ -95,7 +98,119 @@ class RegistrarFormSet(forms.BaseFormSet): Hint: Subclass should call `self._to_database(...)`. """ raise NotImplementedError + + def full_clean(self): + """ + Clean all of self.data and populate self._errors and + self._non_form_errors. + """ + logger.info("in full_clean") + self._errors = [] + self._non_form_errors = self.error_class() + empty_forms_count = 0 + if not self.is_bound: # Stop further processing. + return + + logger.info("about to test management form ") + if not self.management_form.is_valid(): + error = forms.ValidationError( + self.error_messages['missing_management_form'], + params={ + 'field_names': ', '.join( + self.management_form.add_prefix(field_name) + for field_name in self.management_form.errors + ), + }, + code='missing_management_form', + ) + self._non_form_errors.append(error) + + logger.info("about to test forms in self.forms") + for i, form in enumerate(self.forms): + logger.info(f"checking form {i}") + # Empty forms are unchanged forms beyond those with initial data. + if not form.has_changed() and i >= self.initial_form_count(): + empty_forms_count += 1 + # Accessing errors calls full_clean() if necessary. + # _should_delete_form() requires cleaned_data. + form_errors = form.errors + if self.can_delete and self._should_delete_form(form): + continue + self._errors.append(form_errors) + logger.info("at the end of for loop processing") + try: + logger.info("about to test validate max and min") + if (self.validate_max and + self.total_form_count() - len(self.deleted_forms) > self.max_num) or \ + self.management_form.cleaned_data[TOTAL_FORM_COUNT] > self.absolute_max: + raise forms.ValidationError(ngettext( + "Please submit at most %d form.", + "Please submit at most %d forms.", self.max_num) % self.max_num, + code='too_many_forms', + ) + logger.info("between validate max and validate min") + if (self.validate_min and + self.total_form_count() - len(self.deleted_forms) - empty_forms_count < self.min_num): + raise forms.ValidationError(ngettext( + "Please submit at least %d form.", + "Please submit at least %d forms.", self.min_num) % self.min_num, + code='too_few_forms') + # Give self.clean() a chance to do cross-form validation. + logger.info("about to call clean on formset") + self.clean() + except forms.ValidationError as e: + logger.info(f"hit an exception {e}") + self._non_form_errors = self.error_class(e.error_list) + + def total_form_count(self): + """Return the total number of forms in this FormSet.""" + logger.info("in total_form_count") + if self.is_bound: + logger.info("is_bound") + # return absolute_max if it is lower than the actual total form + # count in the data; this is DoS protection to prevent clients + # from forcing the server to instantiate arbitrary numbers of + # forms + return min(self.management_form.cleaned_data[TOTAL_FORM_COUNT], self.absolute_max) + else: + initial_forms = self.initial_form_count() + total_forms = max(initial_forms, self.min_num) + self.extra + # Allow all existing related objects/inlines to be displayed, + # but don't allow extra beyond max_num. + if initial_forms > self.max_num >= 0: + total_forms = initial_forms + elif total_forms > self.max_num >= 0: + total_forms = self.max_num + return total_forms + + def initial_form_count(self): + """Return the number of forms that are required in this FormSet.""" + logger.info("in initial_form_count") + if self.is_bound: + return self.management_form.cleaned_data[INITIAL_FORM_COUNT] + else: + # Use the length of the initial data if it's there, 0 otherwise. + initial_forms = len(self.initial) if self.initial else 0 + return initial_forms + + def is_valid(self): + """Return True if every form in self.forms is valid.""" + if not self.is_bound: + return False + # Accessing errors triggers a full clean the first time only. + logger.info("before self.errors") + self.errors + # List comprehension ensures is_valid() is called for all forms. + # Forms due to be deleted shouldn't cause the formset to be invalid. + logger.info("before all isvalid") + forms_valid = all([ + form.is_valid() for form in self.forms + if not (self.can_delete and self._should_delete_form(form)) + ]) + logger.info(f"forms_valid = {forms_valid}") + return forms_valid and not self.non_form_errors() + def test_if_more_than_one_join(self, db_obj, rel, related_name): """Helper for finding whether an object is joined more than once.""" # threshold is the number of related objects that are acceptable @@ -165,6 +280,7 @@ class RegistrarFormSet(forms.BaseFormSet): logger.info(post_data) logger.info(cleaned) + logger.info(db_obj) # matching database object exists, update it if db_obj is not None and cleaned: if should_delete(cleaned): @@ -181,8 +297,12 @@ class RegistrarFormSet(forms.BaseFormSet): # no matching database object, create it # make sure not to create a database object if cleaned has 'delete' attribute - elif db_obj is None and cleaned and not cleaned.get("delete", False): + elif db_obj is None and cleaned and not cleaned.get("DELETE", False): + logger.info(cleaned.get("DELETE",False)) + logger.info("about to pre_create") kwargs = pre_create(db_obj, cleaned) + logger.info("after pre_create") + logger.info(kwargs) getattr(obj, join).create(**kwargs) @classmethod @@ -679,9 +799,14 @@ class OtherContactsForm(RegistrarForm): # return empty object with only 'delete' attribute defined. # this will prevent _to_database from creating an empty # database object - return {"delete": True} + return {"DELETE": True} return self.cleaned_data + + def is_valid(self): + val = super().is_valid() + logger.info(f"othercontactsform validation yields: {val}") + return val class BaseOtherContactsFormSet(RegistrarFormSet): @@ -708,8 +833,15 @@ class BaseOtherContactsFormSet(RegistrarFormSet): def should_delete(self, cleaned): empty = (isinstance(v, str) and (v.strip() == "" or v is None) for v in cleaned.values()) - return all(empty) or self.formset_data_marked_for_deletion + return all(empty) or self.formset_data_marked_for_deletion or cleaned.get("DELETE", False) + def pre_create(self, db_obj, cleaned): + """Code to run before an item in the formset is created in the database.""" + # remove DELETE from cleaned + if "DELETE" in cleaned: + cleaned.pop('DELETE') + return cleaned + def to_database(self, obj: DomainApplication): logger.info("in to_database for BaseOtherContactsFormSet") self._to_database(obj, self.JOIN, self.REVERSE_JOINS, self.should_delete, self.pre_update, self.pre_create) @@ -733,7 +865,10 @@ class BaseOtherContactsFormSet(RegistrarFormSet): number of other contacts when contacts marked for deletion""" if self.formset_data_marked_for_deletion: self.validate_min = False - return super().is_valid() + logger.info("in is_valid()") + val = super().is_valid() + logger.info(f"formset validation yields: {val}") + return val OtherContactsFormSet = forms.formset_factory(