Conversation
Every scalar field type (text, longtext, integer, decimal, boolean, date, datetime, url, json, select, multiselect, coordinates) hardcoded blank=True on its generated model field regardless of CustomObjectTypeField.required, and had no explicit serializer field of its own, so DRF's ModelSerializer auto-built one from the model field and always inferred required=False. required was therefore enforced only by the UI form, which builds its own field with required=field.required directly. get_model_field() now sets blank=not field.required for each scalar type (null stays True unconditionally - toggling required on an existing field must not risk an ALTER TABLE SET NOT NULL against rows that already have a NULL value, and this plugin already commits to that contract elsewhere, per test_branching.py's test_field_required_toggle_merge). Each scalar type now also implements get_serializer_field(), passing required=field.required explicitly instead of relying on DRF's auto-inference (which considers a nullable column non-required regardless of blank). Coordinates' two backing columns are returned as a dict, mirroring get_model_field()'s existing multi-column convention; serializers.py's field-building loop now merges a dict return into attrs and exempts coordinates fields from the single-column model_field_names guard, since neither of its backing columns is literally named after the field. URL's title column and multiselect/select's non-required cases are unaffected: title stays always-optional by design, and every field's existing default-omission behavior on create is preserved (the serializer still doesn't set an explicit default, so a model-level default still applies when the key is simply absent from the payload).
…field methods Each of the 12 newly added get_serializer_field() overrides imported rest_framework.serializers locally; consolidated into one top-level import.
|
@claude /review and limit response to actionable issues. |
|
Claude finished @bctiemann's task in 4m 41s —— View job Code Review
The fix is correct and the approach (fixing 1. API test never exercises required
|
…field SelectFieldType/MultiSelectFieldType.get_serializer_field() read choices via field.choices, which re-queries field.choice_set on every request (serializer generation is rebuilt per-request, unlike the model). Read them off the already-generated model field's own .choices instead, matching the zero-query pattern get_display_value() already uses for the same reason.
- Remove allow_blank from SelectFieldType.get_serializer_field(). DRF's ChoiceField.to_internal_value() special-cases "" to bypass choice validation entirely when allow_blank=True, letting a non-required select field silently accept and store "" even though it isn't a defined choice. allow_null already covers "no selection". - Fix the get_serializer_class() exception fallback for coordinates fields: it was assigning the fallback JSONField under field.name, which isn't a real backing column for this type (only _latitude/_longitude are). Assign it under both real column names instead. - Add req_text/req_longtext to RequiredScalarFieldAPITest, closing a gap where the only required text-type field in the fixture (name) was always satisfied by every test's payload, so text/longtext enforcement was never actually exercised at the REST layer.
|
Addressed all three in f846402:
|
jnovinger
left a comment
There was a problem hiding this comment.
test_field_required_toggle_merge's docstring (test_branching.py:2991-2993) still says field_types.py "hardcodes null=True, blank=True" and that required is form-layer only. Both changed here. The test still passes because it only asserts is_nullable.
blank=False (added for #700) is checked by full_clean() against every field on the object, not just the one being edited - so an existing row already left blank on this field would fail on its very next save (form submission, API PATCH, branch merge) for a completely unrelated edit, once the field is marked required. Add a pre-flight check to CustomObjectTypeField.clean(), mirroring the existing non-unique-values check right above it: scan for any existing row whose value for this field is empty, per Django's own blank/empty_values semantics, and reject the toggle if found. Multi-column types (coordinates) check each half independently; a url field's title column is excluded, since it stays blank=True unconditionally. Also fixes test_field_required_toggle_merge's docstring, which described the pre-#700 behavior (null=True, blank=True unconditionally; required enforced at the form layer only) - both changed in #700, though the test's own assertion (DB column stays nullable) still holds.
|
Addressed both in 11ad686:
|
jnovinger
left a comment
There was a problem hiding this comment.
Object and multiobject fields still hardcode blank=True (field_types.py:932, :937, :982, :1595), so required on those two types stays REST-only. The scalar types now enforce at both layers, which leaves the same "Required" checkbox meaning two different things depending on field type.
#700 is written against the scalar types, so this may be deliberate scope. Worth a follow-up issue if so.
Toggling a polymorphic multiobject field optional->required raised AttributeError: get_model_field() returns a bare PolymorphicM2MDescriptor for this type, not a real Django Field, so it has no .blank attribute. Toggling a polymorphic object field raised FieldError: its dict return includes a GenericForeignKey entry alongside two real backing columns - a real Field, with .blank=False by default, but not itself a directly queryable column (values_list() can't resolve it). required_columns now treats anything without a usable .blank, and any GenericForeignKey specifically, as always blank=True - i.e. not checkable here, matching how ObjectFieldType/MultiObjectFieldType's own concrete model fields already hardcode blank=True regardless of required. Plain object/multiobject fields were already unaffected (their model field(s) already had a real, correctly-excluded blank=True); this only fixes the two polymorphic variants, which previously 500'd on any required toggle. Empirically verified all four relationship-field variants (plain object, plain multiobject, polymorphic object, polymorphic multiobject) now toggle cleanly; added test_required_toggle_does_not_crash_for_relationship_fields covering all four.
|
Both addressed:
|
Closes: #700
Summary
Every scalar field type (
text,longtext,integer,decimal,boolean,date,datetime,url,json,select,multiselect,coordinates) hardcodedblank=Trueon its generated model field regardless ofCustomObjectTypeField.required, and had no explicitget_serializer_field()of its own — so DRF'sModelSerializerauto-built a serializer field from the model field and always inferredrequired=False.requiredwas therefore enforced only by the UI form, which constructs its own form field withrequired=field.requireddirectly, independent of the model field.get_model_field()now setsblank=not field.requiredfor every affected type.nullstaysTrueunconditionally — this plugin already commits to "togglingrequiredmust never risk anALTER TABLE ... SET NOT NULL" (seetest_branching.py::test_field_required_toggle_merge), and DRF's ownrequiredauto-inference treats a nullable column as non-required regardless ofblankanyway, so leavingnull=Trueand fixingblankalone wouldn't have been enough even if DB-level enforcement were in scope.get_serializer_field()explicitly, passingrequired=field.required(mirroring the patternObjectFieldType/MultiObjectFieldTypealready used correctly).CoordinatesFieldType.get_serializer_field()returns a dict (one entry per backing column —<name>_latitude/<name>_longitude), mirroringget_model_field()'s existing multi-column convention.serializers.py's field-building loop now merges a dict return intoattrsand exempts coordinates fields from the single-columnmodel_field_namesguard (neither backing column is literally named after the field itself).URLFieldType's title column is unaffected by this — it stays always-optional by design (see the class docstring), only the URL column itself followsrequired.default=of its own, so a model-level default still applies when a key is simply absent from the payload (for a non-required field).Test plan
RequiredFieldEnforcementTestCase(new,test_field_types.py) — model-layerblankcorrectly followsrequiredfor all 9 plain scalar types, select/multiselect, both coordinates halves, and confirms the URL title column stays optional regardless.RequiredScalarFieldAPITest(new,test_api.py) — REST-layer:POSTomitting required fields returns 400 with per-field errors;POSTwith all required fields (or only the optional one omitted) returns 201;OPTIONSreportsrequired: true/falsecorrectly, including the URL title column.test_branching.py::test_field_required_toggle_merge(existing, unmodified) — confirms togglingrequiredstill does not produce a DB-levelNOT NULLconstraint.netbox_custom_objectssuite (1284 tests) run against a freshly built database — 17 pre-existing failures observed, matching the already-established baseline exactly (netbox_branching'sObjectChangeapp_labelRuntimeErrorin.delete()/rename code paths under a non-branching test config), confirmed unrelated to this change.ruff checkpasses on all changed files.