From 9b7d41e6999a04f0dea90bfe8d42a3729318b5cd Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Mon, 21 Sep 2026 18:17:13 -0400 Subject: [PATCH 1/3] Fixes: #719 - Enforce object/multiobject required at the model layer ObjectFieldType/MultiObjectFieldType.get_model_field() hardcoded blank=True unconditionally on their generated model field(s), so "required" was enforced only at the REST serializer layer for these two types -- unlike every scalar type (fixed in #700), which also enforces it at the model layer via full_clean(). Tie blank to field.required for the plain (non-polymorphic) case, mirroring #700's scalar-type fix: - ObjectFieldType: the direct ForeignKey branch and the LazyForeignKey branch (_make_lazy_cot_fk, shared by the self-referential and cross-COT cases) both now pass blank=not field.required. FK fields are validated by Django's clean_fields(), so full_clean() now correctly rejects an unset required object field, closing the gap for real. - MultiObjectFieldType: the CustomManyToManyField also now passes blank=not field.required. This has NO effect on full_clean() -- Django's clean_fields() only iterates _meta.fields, which explicitly excludes M2M fields (there's no way to validate M2M state before a row exists anyway, since through-rows need an existing pk on both sides). It does matter to the required-toggle pre-flight check in CustomObjectTypeField.clean(), though: that check queries existing data directly via values_list() rather than relying on per-field full_clean() validation, so it now correctly rejects toggling a multiobject field to required while an existing row has no related objects -- previously untested and unreachable, since blank was always True. Scoped to the plain (non-polymorphic) case only, per the issue: polymorphic object fields (GenericForeignKey) and polymorphic multiobject fields (PolymorphicM2MDescriptor) have no real, directly-queryable backing field to attach blank to, and the existing required-toggle pre-flight check already treats both as unaffected -- no parallel mechanism is designed for them here. Verified empirically: a plain object field's FK now raises ValidationError from full_clean() when unset and required; a plain multiobject field's required-toggle pre-flight check now correctly rejects an existing blank row (confirmed the M2M values_list() query resolves as expected), while full_clean() itself still cannot and does not check it. Adds RequiredFieldEnforcementTestCase coverage for both field types in test_field_types.py, and required-toggle pre-flight tests for plain object/multiobject in test_models.py (splitting the old "does not crash" test to cover only the polymorphic cases, which remain unaffected). --- netbox_custom_objects/field_types.py | 24 ++++- .../tests/test_field_types.py | 45 +++++++++ netbox_custom_objects/tests/test_models.py | 98 +++++++++++++++---- 3 files changed, 145 insertions(+), 22 deletions(-) diff --git a/netbox_custom_objects/field_types.py b/netbox_custom_objects/field_types.py index 8c5bcb7e..c1da1476 100644 --- a/netbox_custom_objects/field_types.py +++ b/netbox_custom_objects/field_types.py @@ -221,10 +221,12 @@ def _make_lazy_cot_fk(cot, field, on_delete, **field_kwargs): field.custom_object_type.id ).lower() related_name = f"{table_model_name}_{field.name}_set" + # blank ties to required, same as the direct (non-lazy) ForeignKey branch in + # ObjectFieldType.get_model_field() -- see the comment there. return LazyForeignKey( model_name, null=True, - blank=True, + blank=not field.required, on_delete=on_delete, related_name=related_name, **field_kwargs @@ -1004,8 +1006,13 @@ def get_model_field(self, field, **kwargs): else: table_model_name = field.custom_object_type.get_table_model_name(field.custom_object_type.id).lower() related_name = f"{table_model_name}_{field.name}_set" + # blank ties to required (mirrors the scalar-type fix in #700) so a required + # object field is rejected by full_clean() when unset, same as every scalar + # type; null stays True regardless -- it's a DB-level concern, not a + # user-facing one, and required is enforced at the app layer only. f = models.ForeignKey( - model, null=True, blank=True, on_delete=on_delete, related_name=related_name, **field_kwargs + model, null=True, blank=not field.required, on_delete=on_delete, related_name=related_name, + **field_kwargs ) return f @@ -1613,12 +1620,21 @@ def get_model_field(self, field, **kwargs): m2m_related_name = "+" m2m_related_query_name = "+" - # For self-referential fields, use 'self' as the target + # blank ties to required, mirroring ObjectFieldType's FK above. Note this has + # no effect on full_clean() -- Django's clean_fields() only iterates + # _meta.fields, which explicitly excludes M2M fields (there's no way to + # validate M2M state before the row exists anyway, since through-rows need + # an existing pk on both sides). It does matter to the required-toggle + # pre-flight check in CustomObjectTypeField.clean(), though: that check + # queries existing data directly via values_list() rather than relying on + # per-field full_clean() validation, so it can and does correctly reject + # toggling this field to required while an existing row has no related + # objects. m2m_field = CustomManyToManyField( to="self" if is_self_referential else model_string, through=through, through_fields=("source", "target"), - blank=True, + blank=not field.required, related_name=m2m_related_name, related_query_name=m2m_related_query_name, **field_kwargs diff --git a/netbox_custom_objects/tests/test_field_types.py b/netbox_custom_objects/tests/test_field_types.py index 8dee7d18..e7de36e5 100644 --- a/netbox_custom_objects/tests/test_field_types.py +++ b/netbox_custom_objects/tests/test_field_types.py @@ -101,6 +101,51 @@ def test_url_title_stays_optional_regardless_of_required(self): self.assertFalse(model._meta.get_field('url_req').blank) self.assertTrue(model._meta.get_field('url_req_title').blank) + def test_object_field_blank_matches_required(self): + """#719: a plain (non-polymorphic) object field's FK follows required, + same as every scalar type -- and full_clean() actually enforces it, since + FK fields (unlike M2M) are validated by Django's clean_fields().""" + site_ot = ObjectType.objects.get(app_label='dcim', model='site') + required = self.create_custom_object_type_field( + self.custom_object_type, name='site_req', type='object', + related_object_type=site_ot, required=True, + ) + optional = self.create_custom_object_type_field( + self.custom_object_type, name='site_opt', type='object', + related_object_type=site_ot, required=False, + ) + model = self.custom_object_type.get_model() + self.assertFalse(model._meta.get_field(required.name).blank) + self.assertTrue(model._meta.get_field(optional.name).blank) + self.assertTrue(model._meta.get_field(required.name).null) + + instance = model(name='obj') + with self.assertRaises(ValidationError): + instance.full_clean() + + def test_multiobject_field_blank_matches_required_but_full_clean_cannot_check_it(self): + """#719: a plain multiobject field's M2M also follows required, for + declarative correctness and because the required-toggle pre-flight check + (CustomObjectTypeField.clean()) relies on it -- but Django's clean_fields() + never validates M2M fields at all (they aren't in _meta.fields), so + full_clean() cannot and does not enforce this at the model layer. required + stays enforced at the REST/UI layer for multiobject, same as before #719.""" + site_ot = ObjectType.objects.get(app_label='dcim', model='site') + required = self.create_custom_object_type_field( + self.custom_object_type, name='sites_req', type='multiobject', + related_object_type=site_ot, required=True, + ) + optional = self.create_custom_object_type_field( + self.custom_object_type, name='sites_opt', type='multiobject', + related_object_type=site_ot, required=False, + ) + model = self.custom_object_type.get_model() + self.assertFalse(model._meta.get_field(required.name).blank) + self.assertTrue(model._meta.get_field(optional.name).blank) + + instance = model.objects.create(name='obj') + instance.full_clean() # does not raise -- M2M is outside clean_fields()'s reach + class TextFieldTypeTestCase(FieldTypeTestCase): """Test cases for text field type.""" diff --git a/netbox_custom_objects/tests/test_models.py b/netbox_custom_objects/tests/test_models.py index 227f8298..9da67cbb 100644 --- a/netbox_custom_objects/tests/test_models.py +++ b/netbox_custom_objects/tests/test_models.py @@ -779,27 +779,21 @@ def test_required_toggle_rejected_for_coordinates_with_one_half_blank(self): with self.assertRaises(ValidationError): field.full_clean() - def test_required_toggle_does_not_crash_for_relationship_fields(self): - """Object/multiobject fields (plain and polymorphic) must not crash on toggle. - - Their model field(s) either hardcode blank=True unconditionally (plain - object/multiobject) or are not a real, directly queryable Django Field at - all (a polymorphic object field's GenericForeignKey entry; a polymorphic - multiobject field's PolymorphicM2MDescriptor) - none of them are checked - by the required-toggle pre-flight check, but they must be skipped - cleanly rather than raising AttributeError/FieldError. + def test_required_toggle_does_not_crash_for_polymorphic_relationship_fields(self): + """Polymorphic object/multiobject fields must not crash on toggle. + + Their model field(s) are not a real, directly queryable Django Field at all + (a polymorphic object field's GenericForeignKey entry; a polymorphic + multiobject field's PolymorphicM2MDescriptor, which has no .blank attribute) + -- neither is checked by the required-toggle pre-flight check, but they must + be skipped cleanly rather than raising AttributeError/FieldError. (Plain + object/multiobject fields, by contrast, now ARE checked -- see + test_required_toggle_rejected_when_existing_object_row_is_blank et al -- since + #719 tied their blank to field.required same as every scalar type.) """ device_ot = self.get_device_object_type() site_ot = self.get_site_object_type() - plain_object = self.create_custom_object_type_field( - self.custom_object_type, name="dev", type="object", - related_object_type=device_ot, required=False, - ) - plain_multiobject = self.create_custom_object_type_field( - self.custom_object_type, name="devs", type="multiobject", - related_object_type=device_ot, required=False, - ) poly_object = self.create_polymorphic_field( self.custom_object_type, related_object_types=[device_ot, site_ot], name="poly_dev", type="object", required=False, @@ -809,12 +803,80 @@ def test_required_toggle_does_not_crash_for_relationship_fields(self): name="poly_devs", type="multiobject", required=False, ) - for field in (plain_object, plain_multiobject, poly_object, poly_multiobject): + for field in (poly_object, poly_multiobject): with self.subTest(field=field.name): field = CustomObjectTypeField.objects.get(pk=field.pk) field.required = True field.full_clean() # must not raise + def test_required_toggle_rejected_when_existing_object_row_is_blank(self): + """A plain object field's FK is now checked by the required-toggle pre-flight + check (#719: blank ties to field.required, same as every scalar type).""" + site_ot = self.get_site_object_type() + field = self.create_custom_object_type_field( + self.custom_object_type, name="site", type="object", + related_object_type=site_ot, required=False, + ) + model = self.custom_object_type.get_model() + model.objects.create() + + field = CustomObjectTypeField.objects.get(pk=field.pk) + field.required = True + with self.assertRaises(ValidationError): + field.full_clean() + + def test_required_toggle_allowed_when_no_blank_object_rows(self): + """Toggling a plain object field to required succeeds when every existing + row already has a value.""" + site_ot = self.get_site_object_type() + site = Site.objects.create(name="Req Toggle Site", slug="req-toggle-site") + field = self.create_custom_object_type_field( + self.custom_object_type, name="site", type="object", + related_object_type=site_ot, required=False, + ) + model = self.custom_object_type.get_model() + model.objects.create(site=site) + + field = CustomObjectTypeField.objects.get(pk=field.pk) + field.required = True + field.full_clean() # must not raise + + def test_required_toggle_rejected_when_existing_multiobject_row_is_blank(self): + """A plain multiobject field's M2M is also checked by the required-toggle + pre-flight check (#719): even though full_clean() itself can never validate + an M2M field (Django's clean_fields() excludes M2M fields entirely), the + pre-flight check queries existing data directly via values_list(), which + still correctly catches a row with no related objects.""" + site_ot = self.get_site_object_type() + field = self.create_custom_object_type_field( + self.custom_object_type, name="sites", type="multiobject", + related_object_type=site_ot, required=False, + ) + model = self.custom_object_type.get_model() + model.objects.create() + + field = CustomObjectTypeField.objects.get(pk=field.pk) + field.required = True + with self.assertRaises(ValidationError): + field.full_clean() + + def test_required_toggle_allowed_when_no_blank_multiobject_rows(self): + """Toggling a plain multiobject field to required succeeds when every + existing row already has at least one related object.""" + site_ot = self.get_site_object_type() + site = Site.objects.create(name="Req Toggle Site M2M", slug="req-toggle-site-m2m") + field = self.create_custom_object_type_field( + self.custom_object_type, name="sites", type="multiobject", + related_object_type=site_ot, required=False, + ) + model = self.custom_object_type.get_model() + instance = model.objects.create() + getattr(instance, "sites").set([site]) + + field = CustomObjectTypeField.objects.get(pk=field.pk) + field.required = True + field.full_clean() # must not raise + def test_custom_object_type_field_unique_name_per_type(self): """Test that field names must be unique within a custom object type.""" self.create_custom_object_type_field( From f1f15c90f9e918d28499a4d38a0106c4b0739707 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Mon, 21 Sep 2026 18:59:39 -0400 Subject: [PATCH 2/3] Address automated review findings on PR #720 1. models.py:2989-2991 had a stale comment claiming plain object/multiobject fields "hardcode blank=True regardless of required" -- true before this PR, no longer true after it. Reworded to say only the polymorphic cases remain excluded from the required-toggle pre-flight check. 2. Investigated the concern that values_list() across the M2M field might compile to an INNER JOIN, silently excluding blank rows from the required-toggle pre-flight check and making test_required_toggle_rejected_when_existing_multiobject_row_is_blank pass for the wrong reason. Confirmed empirically (via a throwaway script, not committed) that Django generates a LEFT OUTER JOIN here -- a blank row comes back as (None,), not omitted -- so the existing test already passes for the right reason. Added test_required_toggle_rejected_when_one_of_several_multiobject_rows_is_blank as permanent regression coverage: a blank row alongside a filled one must still be caught, which would fail under an INNER JOIN. --- netbox_custom_objects/models.py | 11 ++++++----- netbox_custom_objects/tests/test_models.py | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index b3a14098..609fc133 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -2978,17 +2978,18 @@ def clean(self): model_field = FIELD_TYPE_CLASS[self.type]().get_model_field(self) columns = model_field if isinstance(model_field, dict) else {self.name: model_field} # Only columns actually made non-blank by this field's required flag - # (e.g. a url field's title column always stays blank=True). A + # (e.g. a url field's title column always stays blank=True). A plain + # object field's FK and a plain multiobject field's M2M both follow + # required (#719) and so ARE checkable here. A *polymorphic* # relationship field's "column" may not be a real, directly queryable - # Django Field at all: a polymorphic multiobject's is a + # Django Field at all, though: a polymorphic multiobject's is a # PolymorphicM2MDescriptor (no .blank attribute), and a polymorphic # object field's dict includes a GenericForeignKey entry alongside its # two real backing columns - a real Field with .blank=False by default, # but not itself a queryable column (values_list() can't resolve it). # Treat anything without a usable .blank, and GFKs specifically, as - # always blank=True, i.e. not checkable here - matching - # ObjectFieldType/MultiObjectFieldType's own concrete model fields, - # which hardcode blank=True regardless of required. + # always blank=True, i.e. not checkable here - #719 leaves both + # polymorphic cases unaffected, pending a parallel mechanism for them. required_columns = { name: f for name, f in columns.items() if not isinstance(f, GenericForeignKey) and not getattr(f, 'blank', True) diff --git a/netbox_custom_objects/tests/test_models.py b/netbox_custom_objects/tests/test_models.py index 9da67cbb..4b6591ab 100644 --- a/netbox_custom_objects/tests/test_models.py +++ b/netbox_custom_objects/tests/test_models.py @@ -860,6 +860,27 @@ def test_required_toggle_rejected_when_existing_multiobject_row_is_blank(self): with self.assertRaises(ValidationError): field.full_clean() + def test_required_toggle_rejected_when_one_of_several_multiobject_rows_is_blank(self): + """The pre-flight check's values_list() query must use a LEFT OUTER JOIN, + not an INNER JOIN, across the M2M -- otherwise a blank row would be + silently excluded from the result rather than surfaced as (None,), and a + blank row sitting alongside filled ones would slip through undetected.""" + site_ot = self.get_site_object_type() + site = Site.objects.create(name="Req Toggle Site Mixed", slug="req-toggle-site-mixed") + field = self.create_custom_object_type_field( + self.custom_object_type, name="sites", type="multiobject", + related_object_type=site_ot, required=False, + ) + model = self.custom_object_type.get_model() + filled = model.objects.create() + getattr(filled, "sites").set([site]) + model.objects.create() # blank, alongside the filled row above + + field = CustomObjectTypeField.objects.get(pk=field.pk) + field.required = True + with self.assertRaises(ValidationError): + field.full_clean() + def test_required_toggle_allowed_when_no_blank_multiobject_rows(self): """Toggling a plain multiobject field to required succeeds when every existing row already has at least one related object.""" From 6cc07cf569e448b657a802847c7ad65823e24e7d Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Mon, 21 Sep 2026 19:45:52 -0400 Subject: [PATCH 3/3] Trim verbose comments/docstrings and drop issue-number references No behavior change -- condenses comments and docstrings added for this PR down to their essential why, and removes inline #-issue references per project comment conventions. --- netbox_custom_objects/field_types.py | 22 +++++++----------- netbox_custom_objects/models.py | 23 +++++++++---------- .../tests/test_field_types.py | 16 ++++++------- netbox_custom_objects/tests/test_models.py | 18 +++++++-------- 4 files changed, 34 insertions(+), 45 deletions(-) diff --git a/netbox_custom_objects/field_types.py b/netbox_custom_objects/field_types.py index c1da1476..3b9d256b 100644 --- a/netbox_custom_objects/field_types.py +++ b/netbox_custom_objects/field_types.py @@ -1006,10 +1006,9 @@ def get_model_field(self, field, **kwargs): else: table_model_name = field.custom_object_type.get_table_model_name(field.custom_object_type.id).lower() related_name = f"{table_model_name}_{field.name}_set" - # blank ties to required (mirrors the scalar-type fix in #700) so a required - # object field is rejected by full_clean() when unset, same as every scalar - # type; null stays True regardless -- it's a DB-level concern, not a - # user-facing one, and required is enforced at the app layer only. + # blank ties to required, so full_clean() rejects an unset required + # object field like any scalar type; null stays True regardless (a + # DB-level concern, not a user-facing one). f = models.ForeignKey( model, null=True, blank=not field.required, on_delete=on_delete, related_name=related_name, **field_kwargs @@ -1620,16 +1619,11 @@ def get_model_field(self, field, **kwargs): m2m_related_name = "+" m2m_related_query_name = "+" - # blank ties to required, mirroring ObjectFieldType's FK above. Note this has - # no effect on full_clean() -- Django's clean_fields() only iterates - # _meta.fields, which explicitly excludes M2M fields (there's no way to - # validate M2M state before the row exists anyway, since through-rows need - # an existing pk on both sides). It does matter to the required-toggle - # pre-flight check in CustomObjectTypeField.clean(), though: that check - # queries existing data directly via values_list() rather than relying on - # per-field full_clean() validation, so it can and does correctly reject - # toggling this field to required while an existing row has no related - # objects. + # blank ties to required, mirroring the FK above. Has no effect on + # full_clean() (Django's clean_fields() excludes M2M fields entirely), + # but does matter to the required-toggle pre-flight check in + # CustomObjectTypeField.clean(), which queries existing data directly + # rather than relying on full_clean(). m2m_field = CustomManyToManyField( to="self" if is_self_referential else model_string, through=through, diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index 609fc133..58a839a6 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -2978,18 +2978,17 @@ def clean(self): model_field = FIELD_TYPE_CLASS[self.type]().get_model_field(self) columns = model_field if isinstance(model_field, dict) else {self.name: model_field} # Only columns actually made non-blank by this field's required flag - # (e.g. a url field's title column always stays blank=True). A plain - # object field's FK and a plain multiobject field's M2M both follow - # required (#719) and so ARE checkable here. A *polymorphic* - # relationship field's "column" may not be a real, directly queryable - # Django Field at all, though: a polymorphic multiobject's is a - # PolymorphicM2MDescriptor (no .blank attribute), and a polymorphic - # object field's dict includes a GenericForeignKey entry alongside its - # two real backing columns - a real Field with .blank=False by default, - # but not itself a queryable column (values_list() can't resolve it). - # Treat anything without a usable .blank, and GFKs specifically, as - # always blank=True, i.e. not checkable here - #719 leaves both - # polymorphic cases unaffected, pending a parallel mechanism for them. + # (e.g. a url field's title column always stays blank=True). Plain + # object/multiobject fields follow required and so ARE checkable + # here. A *polymorphic* relationship field's "column" may not be a + # real, directly queryable Django Field at all, though: a + # polymorphic multiobject's is a PolymorphicM2MDescriptor (no + # .blank attribute), and a polymorphic object field's dict includes + # a GenericForeignKey entry alongside its two real backing columns + # - a real Field with .blank=False by default, but not itself a + # queryable column (values_list() can't resolve it). Treat anything + # without a usable .blank, and GFKs specifically, as always + # blank=True, i.e. not checkable here. required_columns = { name: f for name, f in columns.items() if not isinstance(f, GenericForeignKey) and not getattr(f, 'blank', True) diff --git a/netbox_custom_objects/tests/test_field_types.py b/netbox_custom_objects/tests/test_field_types.py index e7de36e5..f4983e7d 100644 --- a/netbox_custom_objects/tests/test_field_types.py +++ b/netbox_custom_objects/tests/test_field_types.py @@ -102,9 +102,9 @@ def test_url_title_stays_optional_regardless_of_required(self): self.assertTrue(model._meta.get_field('url_req_title').blank) def test_object_field_blank_matches_required(self): - """#719: a plain (non-polymorphic) object field's FK follows required, - same as every scalar type -- and full_clean() actually enforces it, since - FK fields (unlike M2M) are validated by Django's clean_fields().""" + """A plain object field's FK follows required, like every scalar type -- + and full_clean() enforces it, since FK fields are validated by + Django's clean_fields().""" site_ot = ObjectType.objects.get(app_label='dcim', model='site') required = self.create_custom_object_type_field( self.custom_object_type, name='site_req', type='object', @@ -124,12 +124,10 @@ def test_object_field_blank_matches_required(self): instance.full_clean() def test_multiobject_field_blank_matches_required_but_full_clean_cannot_check_it(self): - """#719: a plain multiobject field's M2M also follows required, for - declarative correctness and because the required-toggle pre-flight check - (CustomObjectTypeField.clean()) relies on it -- but Django's clean_fields() - never validates M2M fields at all (they aren't in _meta.fields), so - full_clean() cannot and does not enforce this at the model layer. required - stays enforced at the REST/UI layer for multiobject, same as before #719.""" + """A plain multiobject field's M2M also follows required (the + required-toggle pre-flight check relies on it), but full_clean() can't + enforce it -- Django's clean_fields() excludes M2M fields entirely, so + required stays enforced at the REST/UI layer for multiobject.""" site_ot = ObjectType.objects.get(app_label='dcim', model='site') required = self.create_custom_object_type_field( self.custom_object_type, name='sites_req', type='multiobject', diff --git a/netbox_custom_objects/tests/test_models.py b/netbox_custom_objects/tests/test_models.py index 4b6591ab..83000deb 100644 --- a/netbox_custom_objects/tests/test_models.py +++ b/netbox_custom_objects/tests/test_models.py @@ -786,10 +786,9 @@ def test_required_toggle_does_not_crash_for_polymorphic_relationship_fields(self (a polymorphic object field's GenericForeignKey entry; a polymorphic multiobject field's PolymorphicM2MDescriptor, which has no .blank attribute) -- neither is checked by the required-toggle pre-flight check, but they must - be skipped cleanly rather than raising AttributeError/FieldError. (Plain - object/multiobject fields, by contrast, now ARE checked -- see - test_required_toggle_rejected_when_existing_object_row_is_blank et al -- since - #719 tied their blank to field.required same as every scalar type.) + be skipped cleanly rather than raising AttributeError/FieldError. Plain + object/multiobject fields, by contrast, ARE checked (see + test_required_toggle_rejected_when_existing_object_row_is_blank et al). """ device_ot = self.get_device_object_type() site_ot = self.get_site_object_type() @@ -810,8 +809,8 @@ def test_required_toggle_does_not_crash_for_polymorphic_relationship_fields(self field.full_clean() # must not raise def test_required_toggle_rejected_when_existing_object_row_is_blank(self): - """A plain object field's FK is now checked by the required-toggle pre-flight - check (#719: blank ties to field.required, same as every scalar type).""" + """A plain object field's FK is checked by the required-toggle + pre-flight check, same as every scalar type.""" site_ot = self.get_site_object_type() field = self.create_custom_object_type_field( self.custom_object_type, name="site", type="object", @@ -843,10 +842,9 @@ def test_required_toggle_allowed_when_no_blank_object_rows(self): def test_required_toggle_rejected_when_existing_multiobject_row_is_blank(self): """A plain multiobject field's M2M is also checked by the required-toggle - pre-flight check (#719): even though full_clean() itself can never validate - an M2M field (Django's clean_fields() excludes M2M fields entirely), the - pre-flight check queries existing data directly via values_list(), which - still correctly catches a row with no related objects.""" + pre-flight check: even though full_clean() can never validate an M2M + field, the pre-flight check queries existing data directly via + values_list(), which still correctly catches a blank row.""" site_ot = self.get_site_object_type() field = self.create_custom_object_type_field( self.custom_object_type, name="sites", type="multiobject",