Skip to content

Closes: #392 - Validate semantic version fields#455

Merged
arthanson merged 47 commits into
featurefrom
392-validate-semver-fields
Apr 30, 2026
Merged

Closes: #392 - Validate semantic version fields#455
arthanson merged 47 commits into
featurefrom
392-validate-semver-fields

Conversation

@bctiemann
Copy link
Copy Markdown
Contributor

@bctiemann bctiemann commented Apr 8, 2026

Closes: #392

Summary

  • Adds a _validate_semver validator (backed by packaging.version.Version) to three version-string fields:
    • CustomObjectType.version — the schema version of the COT itself
    • CustomObjectTypeField.deprecated_since — version in which the field was marked deprecated
    • CustomObjectTypeField.scheduled_removal — version in which the field is planned to be removed
  • All three fields remain blank=True; the validator is a no-op for empty strings
  • Validation uses PEP 440 (packaging.version), which is already a project dependency and accepts standard semver strings (1.0.0, 2.3.4, etc.) as well as Python-style pre/post-release suffixes (1.0.0a1, 1.0.0.post1)
  • Includes a Django migration (0007) recording the field alterations

Changed files

File Change
models.py Import packaging.version.{Version,InvalidVersion}; add _validate_semver; wire onto three CharField definitions
migrations/0007_alter_customobjecttype_version_and_more.py Generated migration for the three AlterField operations
tests/test_models.py SemverValidationTestCase — 9 tests covering blank (valid), valid PEP 440 strings, and invalid strings for all three fields

Part of the portable schema format cluster

PR Issue Description
#449 #385 Schema document format
#450 #386 Exporter
#452 #387 Comparator / diff engine
#453 #389 Upgrade executor
#454 #390 Schema preview & apply API endpoints
#455 #392 Version field semver validation

@bctiemann bctiemann changed the base branch from main to 390-schema-api-endpoints April 8, 2026 01:06
@bctiemann bctiemann requested review from arthanson and jnovinger April 9, 2026 11:58
Copy link
Copy Markdown
Contributor

@arthanson arthanson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocked by #449

@bctiemann
Copy link
Copy Markdown
Contributor Author

No longer blocked.

@bctiemann bctiemann requested a review from arthanson April 28, 2026 17:08
@arthanson arthanson changed the base branch from 390-schema-api-endpoints to feature April 28, 2026 18:04
bctiemann and others added 12 commits April 29, 2026 18:47
Resolve add/add and content conflicts:
- schema/{comparator,exporter}.py: use constants.TABLE_MODEL_RE (no local re import)
- schema/exporter.py: match removed_fields by slug (not name)
- schema/executor.py: raise RuntimeError instead of assert
- tests/schema/test_executor.py: include test_alter_field_type
- api/views.py: keep both ETagMixin shim and status import
- tests/test_api.py: keep both Pep440APIValidationTestCase and SchemaIdReadOnlyTest
- tests/test_models.py: keep feature's PluginConfig error-handling tests,
  SemverValidationTestCase, and CrossCOTStubSearchIndexRegressionTestCase

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…omparator.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_meta.get_field() triggers Django's lazy _relation_tree, which calls
apps.get_models(), which re-enters our get_models() override, which
calls get_model() for every COT — infinite recursion that eventually
crashes with ContentType.DoesNotExist.

Replace with a set built from _meta.local_fields and
_meta.local_many_to_many, which are plain lists populated at model
class-creation time and never trigger the relation tree.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_after_model_generation iterated both active and tombstoned fields
(_trashed_field_objects) and called model._meta.get_field(field_name) to
check presence. For tombstoned fields absent from the model, get_field()
cannot find the name in _forward_fields_map and falls through to
fields_map, which lazily computes _relation_tree. _relation_tree calls
apps.get_models(), re-entering our override, which calls get_model() for
every COT -- including the one currently being generated (not yet cached)
-- causing unbounded recursion.

Replace the get_field() call with a present_fields dict built from
model._meta.local_fields and model._meta.local_many_to_many, the same
safe pattern used in register_custom_object_search_index.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two complementary fixes:

1. Add _app_ready flag to gate get_models() dynamic generation.
   Other apps' ready() calls (e.g. dcim.ready()) trigger
   Device._meta._relation_tree → apps.get_models() before our own
   ready() has run.  At that point _model_cache is empty, so get_model()
   tries to regenerate every COT from scratch -- including ContentType
   DB lookups for MULTIOBJECT fields that may fail.  get_models() now
   returns only static models until ready() completes and sets
   _app_ready = True.  ready() then calls apps.clear_cache() so the
   next _relation_tree access recomputes with the full COT model set.

2. Catch ContentType.DoesNotExist in _fetch_and_generate_field_attrs.
   If a MULTIOBJECT field's related_object_type_id references a
   ContentType that no longer exists (stale data, manual cleanup, etc.),
   log a warning and skip the field rather than crashing the entire
   model generation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…bject_type

get_serializer_class iterated all DB fields without checking whether
they were actually generated onto the model.  Fields skipped during
model generation (broken/null related_object_type_id) caused
get_serializer_field() to crash on field.related_object_type.model_class()
when related_object_type is None.

Fix: build model_field_names from local_fields/local_many_to_many once
and gate both loops in get_serializer_class against it, mirroring the
same pattern used in _after_model_generation.

Also improve the warning message in _fetch_and_generate_field_attrs to
distinguish between a null FK (never set) vs a missing ContentType row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… methods

ObjectFieldType and MultiObjectFieldType call ContentType.objects.get()
in get_model_field, get_form_field, get_filterform_field, and
get_serializer_field.  All callers already catch NotImplementedError and
skip the field, but DoesNotExist (raised when related_object_type_id is
NULL or the row was deleted) was not caught, crashing every view that
iterated COT fields.

Add _get_related_content_type() to the FieldType base class. It raises
NotImplementedError for a null FK or missing ContentType row instead of
letting DoesNotExist propagate.  Replace all seven
ContentType.objects.get(pk=field.related_object_type_id) calls in
ObjectFieldType and MultiObjectFieldType with this helper so that every
caller gets a consistent, handled skip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both ObjectFieldType and MultiObjectFieldType get_serializer_field access
field.related_object_type.model_class() directly without first validating
the FK.  When related_object_type_id is NULL this crashes with
AttributeError instead of the NotImplementedError that callers expect.

Call _get_related_content_type() at the top of both methods to perform
the validation before accessing the FK object.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eration

_fetch_and_generate_field_attrs only caught ContentType.DoesNotExist, but
_get_related_content_type now raises NotImplementedError for null FKs too.
Widen the except to catch both so model generation skips invalid fields
gracefully regardless of which exception surfaces.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
These messages fire for test/stale data with null related_object_type_id.
They are not actionable by operators and appeared multiple times at
startup (model generation runs with skip_object_fields=True for stub
models too).  DEBUG keeps them available for diagnostics without
cluttering production logs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- models.py: ContentType.DoesNotExist in _fetch_and_generate_field_attrs
  was dead code — _get_related_content_type() converts it to
  NotImplementedError before it can escape get_model_field().
  Simplify except clause to just NotImplementedError.

- field_types.py: after_model_generation() and create_m2m_table() in
  MultiObjectFieldType still had raw ContentType.objects.get() calls
  that were missed during the centralisation refactor (they use
  'instance' not 'field', making them invisible to find-and-replace).
  Replace both with self._get_related_content_type(instance) for
  consistency and safety.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ch index

Tests that would have caught the three bugs fixed earlier this session:
- register_custom_object_search_index() with skip_object_fields stub model
- get_model() skips OBJECT field with NULL related_object_type_id
- get_serializer_class() handles field absent from model (Meta.fields/attrs guard)
- ObjectFieldType/MultiObjectFieldType.get_model_field() raises NotImplementedError for null FK

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@arthanson arthanson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to fix the merge conflicts

The test exercises a method on CustomObjectType, not the reindex job or
its triggers.  SearchReindexTestCase is the wrong home for it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@bctiemann
Copy link
Copy Markdown
Contributor Author

Additional fixes bundled into this PR

While integrating this branch with feature, a cluster of startup and request-time crashes surfaced. They are fixed in the 11 commits added on top of the original semver-validation work. This note records what broke, why it only showed up now, and what each fix does.


How the bugs entered feature

Two PRs merged on April 20 introduced the conditions that caused the crashes:

PR #474Skip absent fields when registering search index for stub models
Added model._meta.get_field(field.name) inside register_custom_object_search_index() to safely skip fields absent from a stub model. The intent was correct, but _meta.get_field() for a name that is not yet in _forward_fields_map falls through to fields_map, which triggers Django's lazy _relation_tree computation. _relation_tree calls apps.get_models(), which re-enters our custom get_models() override, which calls get_model() for every COT — producing unbounded recursion.

PR #467Add thread-safe clear_cache suppressor
Restructured _after_model_generation by wrapping it inside _suppress_clear_cache(). The suppressor only prevents apps.clear_cache() from firing; it does not protect against apps.get_models() re-entry. _after_model_generation already called model._meta.get_field() for tombstoned fields before this PR, so the same recursion path existed there as well.

Each PR passed its own tests and worked correctly in isolation. The combination exposed the recursion because both register_custom_object_search_index (new call from #474) and _after_model_generation (restructured by #467) could now trigger _relation_tree during the same model registration window. Merging both into feature, and then merging feature into this branch, was when the crashes first appeared.


Root cause

Any call to model._meta.get_field() or model._meta.get_fields() during model registration can trigger Django's lazy _relation_tree cached property. That computation calls apps.get_models(), which re-enters our override, which calls get_model() for every COT. If get_model() is already executing (we are mid-registration), this is infinite recursion.

The safe alternatives are model._meta.local_fields and model._meta.local_many_to_many — plain lists populated at class-creation time that never trigger _relation_tree.


Fixes (in order of application)

Commit What it fixes
0c9ada6 Replace _meta.get_field() in register_custom_object_search_index() with local_fields / local_many_to_many set membership check
9617203 Same replacement in _after_model_generation() for tombstoned fields in _trashed_field_objects
409efe6 Add _app_ready flag to get_models(): skip all dynamic model generation until ready() has fully completed. Prevents dcim.ready()Device._meta.get_fields()_relation_tree → our get_models() from attempting COT model generation before the plugin is initialised
052a0b1 Centralise null/missing related_object_type_id handling into FieldType._get_related_content_type(), which converts ContentType.DoesNotExist and null-FK into NotImplementedError. Add model_field_names guard in get_serializer_class() so Meta.fields and serializer attrs stay consistent (DRF raises on mismatch)
f7f39f5 Replace all remaining raw ContentType.objects.get(pk=field.related_object_type_id) calls in field_types.py with self._get_related_content_type(field) so every caller gets a consistent NotImplementedError
fd4c006 Add _get_related_content_type() guard at the top of both get_serializer_field() methods so a null/missing FK raises before field.related_object_type.model_class() is called
ed1418a Widen the except clause in _fetch_and_generate_field_attrs() from ContentType.DoesNotExist to NotImplementedError to match what _get_related_content_type() now raises
9cb440e Downgrade the "skipping field" log messages from WARNING to DEBUG; they were appearing twice at startup because get_model() was being called twice per COT on the stub path
a2b3191 Remove now-dead ContentType.DoesNotExist from the except tuple (unreachable after centralisation); fix two raw ContentType.objects.get() calls in MultiObjectFieldType that used the instance parameter and were missed by the earlier search-and-replace
bcf7f10 Regression tests: get_model() with null FK, get_serializer_class() with skipped field, register_custom_object_search_index() with stub model, ObjectFieldType/MultiObjectFieldType.get_model_field() raises NotImplementedError for null FK
d48ca78 Move the search-index regression test from SearchReindexTestCase to CustomObjectTypeTestCase where it belongs

bctiemann and others added 4 commits April 29, 2026 20:38
- SchemaApplyView: enforce add+change permissions on CustomObjectType
  before calling apply_document(); any authenticated token-write user
  could previously trigger DDL operations without model-level permission
- LinkedObjectsView: remove no_cache=True from get_model() — the cache
  exists for exactly this scenario (same COT referenced across multiple
  fields in one request)
- models.py: move logger to module level instead of importing logging
  inside the except block on every skipped field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…import

- test_comparator.py: update import from netbox_custom_objects.exporter
  to netbox_custom_objects.schema.exporter (exporter was moved to the
  schema sub-package in an earlier PR)
- views.py: remove redundant deferred import of CustomObjectType inside
  get_form_kwargs() — already imported at module level on line 28

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SchemaApplyView now requires add+change permissions on CustomObjectType.
Grant them via ObjectPermission in setUp() so all apply tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@arthanson arthanson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jsonschema is listed in pyproject.toml dependencies (hard requirement), but api/views.py wraps its import in a try/except and silently no-ops schema validation if it fails (_HAS_JSONSCHEMA = False).

If it's required, the ImportError path is dead code and the silent-pass fallback should be replaced with a startup error. If it's optional, it shouldn't be in dependencies. Either way, the OSError/json.JSONDecodeError case (missing/corrupt schema file) is a separate concern and should be handled explicitly regardless.

@arthanson arthanson merged commit 7ca27f3 into feature Apr 30, 2026
7 checks passed
bctiemann added a commit that referenced this pull request Apr 30, 2026
Merge feature (PRs #453/#454/#455) into 31-polymorphic-object-fields and
fix all conflicts. Fix all items from the latest review (#4201168389):

HIGH
- views.py custom_save: set polymorphic GFK attributes on the instance before
  the first save() call, eliminating the double-save / double ObjectChange.

MEDIUM
- field_types.py create_polymorphic_m2m_table: accept schema_editor from caller
  instead of opening a nested with connection.schema_editor() context, which
  flushes deferred SQL prematurely on PostgreSQL.
- models.py _original_is_polymorphic: use self.is_polymorphic instead of
  self.__dict__.get() to avoid False when field is deferred.
- serializers.py PolymorphicObjectSerializerField: catch ValueError/TypeError on
  ContentType.objects.get(pk=...) for non-integer content_type_id input.
- serializers.py get_serializer_class: derive _poly_obj_fields/_poly_m2m_fields
  from the already-evaluated model_fields queryset (no extra DB queries).
- field_types.py MultiObjectFieldType.get_serializer_field: add
  allow_null=not field.required to the ListField for polymorphic multiobject
  fields so PATCH with null on non-required fields returns 200 not 400.

Migration path
- Rename 0009_polymorphic_object_fields to 0010_polymorphic_object_fields and
  update its dependency to 0009_alter_customobjecttype_version.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Validate Custom Object Type "version" field

2 participants