Closes: #637 - Fix model-registry errors when get_model() runs outside ready()'s two-pass loop - #664
Closes: #637 - Fix model-registry errors when get_model() runs outside ready()'s two-pass loop#664bctiemann wants to merge 4 commits into
Conversation
…-pass loop Every other dynamic-model entry point (ready(), PluginConfig.get_model/ get_models) already guards against generating COT models before migrations have run, via should_skip_dynamic_model_creation(). CustomObjectType.get_model() itself -- the one method third-party plugins call directly on a COT instance -- had no such guard. A module-level `CustomObjectType.objects.get(name=...).get_model()` call in a sibling plugin (the pattern shown in docs/index.md) fires during Django app loading regardless of which manage.py command is running. During `migrate`, should_skip_dynamic_model_creation() causes ready() to skip its two-pass cross-COT FK resolution entirely, so only the one directly-requested COT gets registered. If that COT has a cross-COT Object field, its LazyForeignKey target is left as an unresolved string reference, which Django's system checks flag as fields.E300/E307, aborting the upgrade. Fix: get_model() now also consults should_skip_dynamic_model_creation() and falls back to skip_object_fields=True when it's set, so no dangling reference is ever created. The resulting degraded model is never cached (existing behavior for skip_object_fields=True), so a real request after startup regenerates it in full via ready()'s normal path. should_skip_dynamic_model_creation() gains an include_test_skip parameter (default True, preserving existing behavior for ready()/PluginConfig.get_model/ get_models) so this new check in get_model() can pass include_test_skip=False -- without it, "test" being unconditionally in sys.argv during `manage.py test` would force every test's get_model() call into the degraded path. Closes: #637
… flag should_skip_dynamic_model_creation(include_test_skip=True) was a single method whose behavior changed based on a boolean parameter -- awkward to read at call sites and easy to get backwards. Split it instead: - _dynamic_model_creation_unsafe(): the actual thing get_model() needs to know (is it unsafe to query the DB for a dynamic model right now? mid-migration, migrate/makemigrations/collectstatic, or this app's own migrations incomplete). Deliberately excludes "test". - should_skip_dynamic_model_creation(): restored to its original no-arg signature/behavior, now just _dynamic_model_creation_unsafe() OR "test" in sys.argv. Every existing caller (ready(), PluginConfig.get_model/ get_models, navigation) and every existing test that patches it by name is unaffected. get_model() now calls _dynamic_model_creation_unsafe() directly.
|
@claude /review and limit response to actionable issues. |
|
Claude finished @bctiemann's task in 2m 58s —— View job PR #664 Review
OverviewThe fix is correct and well-motivated. Splitting Actionable Issues1. with self.assertRaises(Exception):
model._meta.get_field('forwarder_profile')
2. if apps.get_app_config(APP_LABEL)._dynamic_model_creation_unsafe():
skip_object_fields = True
branch_id = self._active_branch_id()
with self._global_lock:
if self.is_model_cached(...) and not no_cache:
...
return model # ← returns cached full model regardlessWhen there's a cache hit, # inside the cache-miss path, before calling generate_model()
if apps.get_app_config(APP_LABEL)._dynamic_model_creation_unsafe():
skip_object_fields = TrueNot a blocker, but worth the minor restructure for clarity. Fix this → 3. Minor: PR description mentions The description says:
The actual implementation uses two separate functions instead. The PR description is stale — not a code issue but may confuse future SummaryThe core logic is sound. Issue 1 is worth fixing before merge. Issue 2 is a small optimization. Issue 3 is cosmetic. |
…hit path - test_models.py: assertRaises(Exception) -> assertRaises(FieldDoesNotExist), the specific exception _meta.get_field() actually raises. - models.py: move the _dynamic_model_creation_unsafe() check past the cache-hit return path, so a cached get_model() call doesn't pay for the check (ContextVar read + sys.argv scan) when it's never going to matter.
|
Addressed items 1 and 2. |
arthanson
left a comment
There was a problem hiding this comment.
From Claude:
The PR's guard drops all Object/Multi-object fields when it fires, but only COT-targeting non-polymorphic ones can dangle.
Consequence: migration 0003_ensure_fk_constraints calls get_model() inside a migrate process, so it now finds no object fields and creates no FK constraints - silently (one logger.error). Core-model targets (dcim.Site etc.) worked there before; nothing downstream backfills a missing constraint, so affected COTs lose DB-level on_delete_behavior enforcement. Reachable on installs still at plugin ≤0.4.0 that have COTs.
Fix: gate on "target is another COT" instead of "is an object field"
Secondary: the degradation isn't logged, and _migrations_checked caches True for the process lifetime, so a worker started mid-upgrade serves field-less models until restart.
Here is what is came up for as a fix:
One file for the behavior: netbox_custom_objects/models.py
- A new predicate on CustomObjectTypeField (next to related_object_type_label, which already open-codes this pattern at models.py:2605, as does :3049):
@property
def targets_custom_object_type(self):
"""
True when this field points at another COT's dynamic model, i.e. its FK is a
LazyForeignKey that only resolves once the target is in the app registry.
Polymorphic fields use a GFK and need no resolution, so they are excluded.
"""
if self.is_polymorphic or not self.related_object_type_id:
return False
try:
object_type = ContentType.objects.get_for_id(self.related_object_type_id)
except ContentType.DoesNotExist:
return False
return (
object_type.app_label == APP_LABEL
and extract_cot_id_from_model_name(object_type.model) is not None
)
ContentType and extract_cot_id_from_model_name are already imported there. get_for_id() is the process-cached lookup, so this doesn't add a query per field the way _get_related_content_type() (field_types.py:293) would. The extract_cot_id_from_model_name half matters: an Object field targeting netbox_custom_objects.customobjecttype shares the app label but isn't a dynamic model, and field_types.py:780 treats it differently.
- _fetch_and_generate_field_attrs() gains a second, narrower mode - the existing skip_object_fields=True semantics are untouched, because field_types.py:1446 and views.py:922 rely on them to build stub models that break FK recursion:
for field in fields:
- if skip_object_fields:
- if field.type in [CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT]:
+ if field.type in [CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT]:
+ if skip_object_fields:
+ continue
+ # Only a COT target produces a LazyForeignKey that needs ready()'s
+ # second pass; core-model targets and polymorphic GFKs resolve on
+ # their own, so they stay even in the unsafe window (#637).
+ if skip_cot_object_fields and field.targets_custom_object_type:
continue
- get_model() sets the narrow flag instead of overwriting the caller's - three small hunks: the guard becomes skip_cot_object_fields = apps.get_app_config(APP_LABEL)._dynamic_model_creation_unsafe(), that flag is passed through to _fetch_and_generate_field_attrs(), and the cache condition becomes if not (skip_object_fields or skip_cot_object_fields) so a narrowed model still never gets cached.
Closes: #637
Summary
A COT with a cross-COT Object-type field, when
.get_model()is called directly on the model instance (the pattern shown indocs/index.md-- a module-levelCustomObjectType.objects.get(...).get_model()) while it's unsafe to touch the DB (e.g. duringmanage.py migrate), left the field'sLazyForeignKeytarget unresolved. Django's system checks then raisedfields.E300/fields.E307for that field, aborting the upgrade.Root cause: every other dynamic-model entry point (
ready(),PluginConfig.get_model()/get_models()) already guards against generating COT models before migrations have completed.CustomObjectType.get_model()-- the one method third-party plugins call directly -- didn't check this at all, so it could register a COT model outside ofready()'s two-pass cross-COT FK resolution, leaving a dangling reference that nothing else ever re-resolves during thatmigrateprocess.Changes
CustomObjectType.get_model()now checks a new_dynamic_model_creation_unsafe()predicate and forcesskip_object_fields=Truewhen it's set, omitting the cross-COT field entirely rather than leaving a dangling lazy reference. The resulting degraded model is never cached (existing behavior forskip_object_fields=True), so a real request after startup regenerates it in full viaready()'s normal two-pass path in the actual (separate) worker process._dynamic_model_creation_unsafe()is the actual DB-readiness check (mid-migration,migrate/makemigrations/collectstatic, or this app's own migrations incomplete) -- deliberately excluding"test", sinceget_model()is the method tests use directly and the test database is always fully migrated by the time a test body runs.should_skip_dynamic_model_creation()-- used byready(),PluginConfig.get_model()/get_models(), andnavigation-- keeps its existing public signature and behavior, now expressed as_dynamic_model_creation_unsafe()OR"test"insys.argv.Testing
CrossCOTGetModelOutsideReadyTestCaseintest_models.py:test_get_model_omits_cross_cot_field_when_unsafe-- the field is omitted, and the degraded model isn't cached.test_get_model_system_checks_pass_after_migrate_time_call-- reproduces the exactfields.E300/E307symptom and confirms it's gone.test_get_model_regenerates_in_full_on_next_normal_call-- once the target COT is registered again (mirroring a real restart'sready()pass), a subsequent call fully resolves the field.test_get_model_unaffected_during_ordinary_test_run-- ordinary test-suiteget_model()calls stay fully hydrated.test_dynamic_model_creation_unsafe_excludes_testinPluginConfigGetModelTestCase.main(Django 6.0.7) andfeature(Django 6.1), plus the broadertest_models/test_api/test_filtersets/test_polymorphic_fields/test_field_types/test_viewssuites.