def test_field() -> None: """Ensure that fields can be created within a form.""" field = FieldFactory.build( form=FormFactory(), label=None, field_type=SingleLineTextField.name, ) # Ensure that the field has no machine name until it gets saved to the # database. assert field.name == "" # Ensure the field has a friendly string representation assert str(field) == "New App Field" # Ensure that a machine name is generated for the field on save. field.label = "Test Field" field.save() assert field.name == "test_field" # Ensure that updating the field label does not change the name, # which should remain stable. field.label = "Updated Test Field" field.save() assert field.name == "test_field" # Ensure the field's friendly string representation reflects the label assert str(field) == "Updated Test Field" # Ensure that a Django form field instance can be produced from the field. assert isinstance(field.as_form_field(field_values={}), forms.Field) # Ensure that a Django model field instance can be produced from the field. assert isinstance(field.as_model_field(), models.Field)
def test_noop_modifier_attribute() -> None: """Ensure that a nonexistent attribute in a modifier is a noop. If a FieldModifier modifies an attribute that does not exist on the field and has no explicit handler on the field type, its only effect should be that it appears in the "_modifiers" dict on the rendered form field. """ form = FormFactory(label="Orphan Field Modifier") # Define a field that has a modifier that tries to change a nonexistent attribute. field = FieldFactory( form=form, label="Are we testing?", name="test_field", field_type=YesNoRadioField.name, required=True, ) modifier = field.modifiers.create( attribute="noop_modifier", expression=f"empty({field.name})", ) django_form = form.as_django_form() # The only effect an unhandled modifier should have is to be present in the # modifiers dict. assert modifier.attribute in django_form.fields[field.name]._modifiers
def test_initial_values() -> None: """Ensure initial values are respected for django forms.""" form = FormFactory(label="Initial Value Form") # Define a field that has a modifier that tries to change a nonexistent attribute. field = FieldFactory( form=form, label="How many?", name="test_field", field_type=IntegerField.name, required=True, initial=0, ) django_form = form.as_django_form() assert not django_form.is_bound assert django_form.initial.get(field.name) == field.initial django_form = form.as_django_form(data={}) assert django_form.is_bound assert django_form.initial.get(field.name) == field.initial django_form = form.as_django_form(initial={field.name: 123}) assert not django_form.is_bound assert django_form.initial.get(field.name) == 123 django_form = form.as_django_form(data={}, initial={field.name: 123}) assert django_form.is_bound assert django_form.initial.get(field.name) == 123
def test_file_upload() -> None: """Ensure that file uploads are handled correctly.""" form = FormFactory(label="File Upload Form") # Define a field that has a modifier that tries to change a nonexistent attribute. file_field = FieldFactory( form=form, label="Upload a file", name="file", field_type=FileUploadField.name, required=False, ) # Upload a file. uploaded_file = SimpleUploadedFile( "random-file.txt", b"Hello, world!", content_type="text/plain", ) # Generate a Django form. django_form = form.as_django_form(files={file_field.name: uploaded_file}) # The form should be valid, and saving it should produce a record. assert django_form.is_valid(), ( django_form.errors, django_form.data, django_form.initial, ) record = django_form.save() assert isinstance(record.file, File) # Setting the field to False should result in a null value when cleaned. django_form = form.as_django_form(files={file_field.name: False}, instance=record) assert django_form.is_valid(), (django_form.errors, django_form.data) record = django_form.save() assert record.file._file is None
def test_record_queries(django_assert_num_queries) -> None: """Ensure that a minimal number of queries is required to fetch records.""" forms_count = 3 fields_per_form_count = len(FIELD_TYPES) records_per_form_count = 1 forms = FormFactory.create_batch(forms_count) for form in forms: AppField.objects.bulk_create( FieldFactory.build(form=form, name=f"{field_type}_field", field_type=field_type, _order=0) for field_type in FIELD_TYPES.keys()) record = AppRecord.objects.create(form=form) for field in form.fields.all(): setattr(record, field.name, None) record.save() # There should be `forms_count` forms in the database. assert AppForm.objects.count() == forms_count # There should be `forms_count * fields_per_form_count` fields in the database (one field per type in FIELD_TYPES). assert AppField.objects.count() == forms_count * fields_per_form_count # There should be `forms_count * records_per_form_count` records in the database. assert AppRecord.objects.count() == forms_count * records_per_form_count # There should be `forms_count * fields_per_form_count` records in the database (one record per form, one field per type in FIELD_TYPES). assert AppRecordAttribute.objects.count( ) == forms_count * fields_per_form_count # By default, the records QuerySet should only require these queries do # perform all of the core responsibilities of the library from the # perspective of Record instances (which is what most implementations will # be interacting with most of the time in e.g. views): # # 1. One to fetch the list of records; this should include a prefetch of its form. # 2. One to fetch the list of the fields on the record's form. # 3. One to fetch the list of field modifiers fields on the record's form. # 4. One to fetch the list of fieldsets for each record's form. # 5. One to fetch the list of attributes for all of the records. # with django_assert_num_queries(5): records = cast(List[BaseRecord], list(AppRecord.objects.all())) # Fetching the data from any of the records should not require any # additional queries. with django_assert_num_queries(0): for record in records: assert record._data != {} # Validating an existing record against its Django form with no changes # should require only the queries necessary to fetch the form structure. with django_assert_num_queries(2): record = records[0] django_form = record.form.as_django_form(data={}, instance=record) assert django_form.is_valid(), django_form.errors # Updating a record using a Django form should require these queries: # # * A SAVEPOINT query before saving the model. # * Two queries to fetch the form structure for the record. # * A single query to update the Record itself. # * A single bulk_update query to update the attributes that have changed. # * A RELEASE SAVEPOINT query after all of the queries have been executed. # with django_assert_num_queries(6): record = records[1] new_record_values = { f"{SingleLineTextField.name}_field": "new_value", f"{MultiLineTextField.name}_field": "another\nnew\nvalue", } django_form = record.form.as_django_form(instance=record, data=new_record_values) assert django_form.is_valid(), django_form.errors updated_record = django_form.save() for attr, value in new_record_values.items(): assert getattr(updated_record, attr) == value
def test_form_lifecycle() -> None: """Ensure that changing form values can change the form structure.""" form = FormFactory(label="Bridgekeeper") name_field = FieldFactory( form=form, label="What... is your name?", name="name", field_type=SingleLineTextField.name, required=True, ) # Define a field that is only visible and required if the name field is not # empty. quest_field = FieldFactory( form=form, label="What... is your quest?", name="quest", field_type=MultiLineTextField.name, required=True, ) quest_field.modifiers.create( attribute="hidden", expression=f"empty({name_field.name})", ) # Define a field that is only visible and required if the name field is not # empty. favorite_color_field = FieldFactory( form=form, label="What... is your favorite color?", name="favorite_color", field_type=SingleChoiceSelectField.name, form_field_options={ "choices": ( ("blue", "Blue"), ("yellow", "Yellow"), ), }, required=True, ) favorite_color_field.modifiers.create( attribute="hidden", expression="empty(quest)", ) favorite_color_field.modifiers.create( attribute="help_text", expression="'Auuugh!' if favorite_color == 'yellow' else ''", ) # Initially, the form should have three fields. Only the first field should # be visible and required. # # Since the first field is required, the form should not be valid since we # haven't provided a value for it. field_values = {} django_form = form.as_django_form(data=field_values) form_fields = django_form.fields assert form_fields["name"].required assert isinstance(form_fields["name"].widget, TextInput) assert not form_fields["quest"].required assert isinstance(form_fields["quest"].widget, HiddenInput) assert not form_fields["favorite_color"].required assert isinstance(form_fields["favorite_color"].widget, HiddenInput) assert not django_form.is_valid() assert "name" in django_form.errors # Filling out the first field should cause the second field to become # visible and required. field_values = { **field_values, "name": "Sir Lancelot of Camelot", } django_form = form.as_django_form(field_values) form_fields = django_form.fields assert form_fields["name"].required assert isinstance(form_fields["name"].widget, TextInput) assert form_fields["quest"].required assert isinstance(form_fields["quest"].widget, Textarea) assert not form_fields["favorite_color"].required assert isinstance(form_fields["favorite_color"].widget, HiddenInput) assert not django_form.is_valid() assert "quest" in django_form.errors # Filling out the second field should expose the last field. field_values = { **field_values, "quest": "To seek the Holy Grail.", } django_form = form.as_django_form(field_values) form_fields = django_form.fields assert form_fields["name"].required assert isinstance(form_fields["name"].widget, TextInput) assert form_fields["quest"].required assert isinstance(form_fields["quest"].widget, Textarea) assert form_fields["favorite_color"].required assert isinstance(form_fields["favorite_color"].widget, Select) assert not django_form.is_valid() assert "favorite_color" in django_form.errors # Filling out the last field with the "wrong" answer should change its help text. field_values = { **field_values, "favorite_color": "yellow", } django_form = form.as_django_form(field_values) form_fields = django_form.fields assert form_fields["name"].required assert isinstance(form_fields["name"].widget, TextInput) assert form_fields["quest"].required assert isinstance(form_fields["quest"].widget, Textarea) assert form_fields["favorite_color"].required assert form_fields["favorite_color"].help_text == "Auuugh!" assert isinstance(form_fields["favorite_color"].widget, Select) # A completely filled-out form should be valid. assert django_form.is_valid(), ( django_form.errors, django_form.data, django_form.instance, ) # "Saving" the form with commit=False should produce a record instance with # a data property that matches the cleaned form submission, but not # actually persist anything to the database. record_count = AppRecord.objects.count() unpersisted_record = django_form.save(commit=False) cleaned_record_data = django_form.cleaned_data assert { **unpersisted_record._data, "uuid": None, "app_form": unpersisted_record.app_form, } == cleaned_record_data assert AppRecord.objects.count() == record_count # Saving the form with commit=True should produce the same result as # commit=False, but actually persist the changes to the database. persisted_record = django_form.save(commit=True) assert { **persisted_record._data, "uuid": None, "app_form": persisted_record.app_form, } == cleaned_record_data assert AppRecord.objects.count() == record_count + 1 # Recreating the form from the persisted record should produce a valid, # unchanged form. Calling save() on the form should noop. same_form = persisted_record.as_django_form(field_values) assert same_form.initial == { **persisted_record._data, "id": persisted_record.id, "uuid": persisted_record.uuid, "app_form": persisted_record.app_form, } assert same_form.is_valid(), same_form.errors assert not same_form.has_changed(), same_form.changed_data same_record = same_form.save() assert persisted_record is same_record
def test_fieldset() -> None: """Ensure that Django fieldsets can be produced for a Form.""" form = FormFactory(label="Fieldsets Test") first_name_field = FieldFactory( form=form, label="First name", name="first_name", field_type=SingleLineTextField.name, ) last_name_field = FieldFactory( form=form, label="Last name", name="last_name", field_type=SingleLineTextField.name, ) birth_date_field = FieldFactory( form=form, label="Birth date", name="birth_date", field_type=DateTimeField.name, ) avatar_field = FieldFactory(form=form, label="Avatar", name="avatar", field_type=FileUploadField.name) bio_field = FieldFactory(form=form, label="Bio", name="bio", field_type=MultiLineTextField.name) # A form with no fieldsets should return an empty list for as_django_fieldsets(). assert form.as_django_fieldsets() == [] # Create a fieldset for collecting basic info. It should have no header or description. basic_fieldset = form.fieldsets.create() # The fieldset should have a friendly name that includes its ID in __str__. assert str(basic_fieldset.pk) in str(basic_fieldset) # First and last name should appear on the same line within the fieldset. basic_fieldset.items.create(field=first_name_field, vertical_order=0, horizontal_order=0) basic_fieldset.items.create(field=last_name_field, vertical_order=0, horizontal_order=1) # Birth date should appear on its own line. Horizontal and vertical order # should act as a (weight as opposed to an index), so higher numbers in # either field should not result in gaps or empty elements in the rendered # fieldsets. basic_fieldset.items.create(field=birth_date_field, vertical_order=10, horizontal_order=10) # Create a fieldset for collecting profile info. It should have a header, # description, and CSS class names that will be split on spaces. profile_fieldset = form.fieldsets.create(name="Profile", description="Profile info", classes="profile collapse") profile_fieldset.items.create(field=avatar_field, vertical_order=0, horizontal_order=0) # Trying to put a field in the same slot as another field should raise an error. with pytest.raises(IntegrityError): profile_fieldset.items.create(field=bio_field, vertical_order=0, horizontal_order=0) fieldset_item = profile_fieldset.items.create(field=bio_field, vertical_order=1, horizontal_order=1) # The fieldset should have a friendly name that includes its ID in __str__. assert str(fieldset_item.pk) in str(fieldset_item) assert form.as_django_fieldsets() == [ # The basic fieldset should come first and have no heading, classes, or description. ( None, { "classes": (), "description": None, "fields": ( # The first and last name fields should be grouped together. ("first_name", "last_name"), # The birth date field should be on its own line, # unwrapped, with no gaps or empty elements from the higher # vertical and horizontal order numbers. "birth_date", ), }, ), # The profile fieldset should come last and have appropriate metadata values. ( "Profile", { "classes": ("profile", "collapse"), "description": "Profile info", "fields": ("avatar", "bio"), }, ), ]
def test_field_modifier() -> None: """Ensure that field modifiers can be created for a field. Tests validation logic for expression validity. """ form = FormFactory() field_1 = FieldFactory( form=form, name="field_1", label="Test Field 1", field_type=SingleLineTextField.name, ) field_2 = FieldFactory( form=form, name="field_2", label="Test Field 2", field_type=SingleLineTextField.name, ) modifier = field_2.modifiers.create(attribute="required", expression="True") # If the modifier has no field, defer validation until save modifier.field = None modifier.clean() assert not modifier._validated modifier.field = field_2 modifier.save() assert modifier._validated # Saving after validating should not result in a call to clean() modifier.save() assert modifier._validated # Ensure the field modifier has a friendly string representation assert str(modifier) == "App Field Modifier (required = True)" # Field modifiers that reference fields that don't exist should raise a # ValidationError. with pytest.raises(ValidationError) as ex: modifier.expression = "does_not_exist == 1" modifier.clean() error_message = str(ex.value.messages[0]) # The validation message should tell the user what's wrong, and include the # name of the invalid variable as well as a list of valid ones. assert "no field with that name exists" in error_message assert "does_not_exist" in error_message assert ", ".join([field_1.name, field_2.name]) in error_message # Field modifiers that reference functions that don't exist should raise a # ValidationError. with pytest.raises(ValidationError) as ex: modifier.expression = "does_not_exist()" modifier.clean() error_message = str(ex.value.messages[0]) # The validation message should tell the user what's wrong, and include the # name of the invalid function as well as a list of valid ones. assert "that function does not exist" in error_message assert "does_not_exist" in error_message assert ", ".join(FormEvaluator.FUNCTIONS.keys()) in error_message # Field modifiers that encounter other errors (like TypeErrors for # comparisons) should raise a ValidationError. with pytest.raises(ValidationError) as ex: modifier.expression = "'string' > 1" modifier.clean() error_message = str(ex.value.messages[0]) # The validation message should tell the user what's wrong, and include the # exception message. assert "'>' not supported between instances of 'str' and 'int'" in str( ex.value.messages) assert "expression is invalid" in error_message