Skip to content

Fix: Attachments on Custom Objects — load order and removed-setting warning (#110)#111

Open
Kani999 wants to merge 9 commits into
mainfrom
110-unable-to-attach-files-to-custom-object
Open

Fix: Attachments on Custom Objects — load order and removed-setting warning (#110)#111
Kani999 wants to merge 9 commits into
mainfrom
110-unable-to-attach-files-to-custom-object

Conversation

@Kani999

@Kani999 Kani999 commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Fixes #110.

The reporter saw no Attachments UI on Custom Object detail pages, and no custom object types in the object-type picker. There turned out to be two independent causes, either of which alone produces that report — so fixing only the obvious one would have left them broken.

Cause 1 — a removed setting, silently ignored

Their config used apps, which was merged into scope_filter in v7.1.0 (607d5e1, Feb 2025). They are on 11.2.3. NetBox's PluginConfig.validate() only fills in missing keys, so apps sat unread in PLUGINS_CONFIG while scope_filter fell back to its default — which covers dcim but not netbox_custom_objects. Their DCIM attachments kept working, which made the config look honoured and pointed suspicion at custom objects.

Measured with their exact config:

with apps: [...] with scope_filter: [...]
validate_object_type(<custom object>) False True
custom types offered in the picker 0 55

That alone explains the missing picker entries.

Cause 2 — a load-order bug (breaks even a correct config)

template_extensions is built at module import, i.e. inside this plugin's ready(). netbox_custom_objects withholds its dynamically generated models until its own ready() has finished:

# netbox_custom_objects/__init__.py — AppConfig.get_models()
if not _app_ready:
    return          # yields no dynamic models yet

Django runs ready() in PLUGINS order, so whenever netbox_attachments is listed first, discovery found zero models and registered no panel at all — whatever scope_filter said. Reproduced on a box with a correct config:

[INFO][netbox_attachments.template_content] Discovered 0 custom object models   <- at ready()
discover_custom_object_models() -> 96 models                                     <- at runtime

It also meant object types created after startup never got a panel until a restart.

Fix: stop enumerating custom objects at startup. They are now served by a single globally registered extension (models = None) that resolves the object at render time, when the models reliably exist. NetBox supports this directly — _get_registered_content() renders key-None extensions for every object alongside model-specific ones.

What's in here

Commit
Feat: System check warning for apps / allowed_models / mode
Refactor: Extract custom_object_identifier(); use Django's label_lower
Fix: Serve custom object panels at render time
Docs: Removed-settings table, Custom Objects section, 11.3.0
Chore: ruff format the package; de-brittle one test

Beyond the two causes, review found that display_setting never worked for custom objects: scope_filter keys off the type name (netbox_custom_objects.cotab2_asset) while the display path keyed off the internal, PK-derived model name (netbox_custom_objects.table134model) — one object, two identifiers. Pre-existing, but this PR's docs newly recommend display_setting for custom objects, so it's fixed here rather than left as a false promise.

Deliberately not done: additional_tab and the top button

The reporter asked for the gray Attachments button. That isn't reachable, and not because of this plugin. The released Custom Objects customobject.html overrides {% block tabs %} (hardcoding Primary/Journal/Changelog) and overrides {% block controls %} without {% plugin_buttons %}. It renders only:

{% plugin_left_page %}   {% plugin_right_page %}   {% plugin_full_width_page %}

So no plugin can add a tab or a top button there. The existing additional_tabfull_width_page fallback is therefore correct and is kept; the docs now explain why instead of just stating it. Lifting this needs an upstream change in netbox-custom-objects. ({% plugin_extra_tabs %} exists only on unreleased feature branches.)

Verification

Unit tests stub their collaborators, so the fix was also exercised against live NetBox 4.6.4 + Custom Objects 0.6.0 (96 dynamic types):

  • Panel renders on a real custom object page (/plugins/custom-objects/cotab2-asset/1/), exactly once, with working Add/Link controls — previously nothing rendered.
  • Per-type applied_scope: "model" scoping renders for the in-scope type and stays hidden for an out-of-scope one.
  • Duplicate-panel guard holds with all 96 dynamic models in the registry (simulating netbox_custom_objects loading first): zero per-model extensions bound to them, exactly one global panel.
  • Regression: dcim keeps its 49 extensions and its Attachments tab; no stray panel on device pages.
  • Permission gating intact (unprivileged user: no panel).
  • manage.py check clean on a correct config; fires W001 on the reporter's.
  • 89 tests pass; every commit passes its own suite in isolation.

Notes for review

  • make format was booby-trapped. It reformatted views.py/tables.py/two migrations that had drifted, which broke test_views_py_assignment_list_prefetches_tags — it asserted on the literal source text of a queryset chain. make format and make test could not both pass. The package is now format-clean and that test parses the chain with ast (as the default_columns test in the same module already does) instead of matching formatting.
  • Follow-up worth an issue: two mechanisms now decide "does this object get a panel, and where" — startup enumeration and render-time resolution. The is_custom_object_model → continue guard exists only to stop them colliding, and the display_setting bug was a symptom of them diverging. Letting the global extension serve all panel-mode models, keeping startup registration only for additional_tab (which genuinely needs register_model_view before the URLconf freezes), would delete that whole risk class.
  • Left alone (pre-existing): with netbox_custom_objects in scope_filter under applied_scope: "app", the plugin's own metadata models (CustomObjectType, CustomObjectTypeField, CustomObjectObjectType) are also in scope, so CustomObjectType gets an Attachments tab. Attaching a spec doc to a type definition may be legitimate; attaching to a field definition looks like a mistake. Worth a deliberate decision, out of scope here.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Attachment panels now support custom objects, including custom object types created after startup.
    • Custom object panels automatically use configured display positions, with fallback behavior where needed.
  • Bug Fixes
    • Fixed attachment panels appearing late or being missed due to plugin and model readiness ordering.
  • Configuration
    • Added startup warnings for removed configuration settings and guidance on their replacements.
    • Updated display setting identifiers for standard and custom objects.
  • Documentation
    • Expanded configuration guidance and documented custom object display limitations.

Jan Krupa added 5 commits July 17, 2026 10:12
- Add a Django system check for `apps`, `allowed_models` and `mode`, which
  were replaced by `scope_filter` and `applied_scope` in v7.1.0 (607d5e1).
- NetBox's PluginConfig.validate() only fills in *missing* keys, so a stale
  setting raises nothing: it sits unread in PLUGINS_CONFIG while its
  replacement silently falls back to a default. That default covers dcim,
  ipam and four more apps, so part of the config keeps working and the rest
  does not -- which reads as a plugin bug rather than a config one (#110).
- Hints name the replacement and spell out that neither is a plain rename:
  `apps` and `allowed_models` were merged into one list, and `mode` changed
  its values as well as its name.
- Register via a new ready() on the PluginConfig, mirroring the idiom in
  netbox_custom_objects/checks.py.
- Pull the "netbox_custom_objects.<type name>" identifier out into a reusable
  helper. Custom object models are named after their backing table's primary
  key (table134model), which is opaque and differs between installs, so
  settings key off the Custom Object Type's name instead.
- Only validate_object_type resolved that identifier, inline; the display
  path needs the same key and had been deriving its own. One helper gives
  the two a single definition to share.
- Use Django's model._meta.label_lower for the standard-model identifier.
  NetBox keys its template-extension registry on label_lower, so building
  that string by hand invites drift from the value it must match.
- No behaviour change: both identifiers resolve exactly as before.
Attachments never appeared on Custom Object detail pages when
netbox_attachments was listed before netbox_custom_objects in PLUGINS.

template_extensions is built at module import, i.e. inside this plugin's
ready(). netbox_custom_objects withholds its dynamically generated models
until its own ready() has finished (`if not _app_ready: return` in its
AppConfig.get_models()), and Django runs ready() in PLUGINS order -- so
discovery found zero models and registered no panel at all, whatever
scope_filter said. A correct config was not enough to work around it.

- Replace discover_custom_object_models() and the import-time enumeration
  with one globally registered extension (models = None) that resolves the
  object at render time, when the models reliably exist.
- Skip custom objects in the startup loop: when netbox_custom_objects loads
  first its models *are* in apps.get_models(), and a per-model extension
  would then render a second panel beside the global one.
- Register the panel before the try block; it needs no models and no
  database, so it should not be lost to an OperationalError below.
- Key display_setting by custom_object_identifier(), so the identifier
  documented for scope_filter also drives per-type display overrides. It
  previously keyed off table{pk}model, so the documented key never matched.
  Resolved only when an override exists, since it costs a query.
- Guard objects without _meta: NetBox offers global extensions whatever is
  in context, and it need not be a model instance.

Side effects: PLUGINS ordering no longer matters, this plugin no longer
touches the database during app loading, and Custom Object Types created
after startup get a panel without restarting NetBox.
- Add a "Removed settings" table mapping `apps`, `allowed_models` and `mode`
  to their replacements, and note the new startup warning.
- Replace the terse "Custom Objects Limitation" note with a section that
  explains what does and does not work, and why. The Custom Objects detail
  template hardcodes its tab bar and its button row and exposes only the
  left/right/full-width panel hooks, so `additional_tab` and
  `create_add_button` cannot work there -- both are properties of that
  template, not of this plugin, and need an upstream change to lift.
- Document that display_setting keys custom objects by type name, matching
  scope_filter, and that this had no effect before 11.3.0.
- Bump to 11.3.0.
- `make format` reformatted views.py, tables.py and two migrations, which
  had drifted from ruff's style; the package is now format-clean, so the
  command no longer produces unrelated diffs alongside real work.
- Reformatting views.py broke test_views_py_assignment_list_prefetches_tags,
  which asserted on the literal source text of the queryset chain. Joining
  `.prefetch_related(` onto the previous line changed nothing about what the
  code does, but the substring stopped matching -- so `make format` and
  `make test` could not both pass.
- Parse the chain with ast instead, as the default_columns test in the same
  module already does, and assert on the calls and their arguments. The test
  still fails if select_related/prefetch_related lose their guarantee; it no
  longer fails on where the formatter breaks a line.
@Kani999 Kani999 linked an issue Jul 17, 2026 that may be closed by this pull request
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 961e4ade-2578-43ed-8105-1542c8df3345

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch 110-unable-to-attach-files-to-custom-object

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
netbox_attachments/checks.py (1)

12-12: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Avoid shadowing the Python builtin Warning.

As highlighted by static analysis, importing Warning directly shadows the Python built-in exception. Consider aliasing it to prevent linter warnings and avoid potential confusion.

♻️ Proposed fix to alias the import
-from django.core.checks import Warning, register
+from django.core.checks import Warning as CheckWarning, register

Ensure that you also update the instantiation on line 49 from Warning( to CheckWarning(.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@netbox_attachments/checks.py` at line 12, Alias the django.core.checks
Warning import as CheckWarning in checks.py, then update the warning
instantiation in the checks registration logic from Warning( to CheckWarning(.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@netbox_attachments/checks.py`:
- Line 12: Alias the django.core.checks Warning import as CheckWarning in
checks.py, then update the warning instantiation in the checks registration
logic from Warning( to CheckWarning(.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: e0f49648-a074-4776-b138-5b2cc5cb91ce

📥 Commits

Reviewing files that changed from the base of the PR and between 691fce1 and 6856856.

📒 Files selected for processing (15)
  • CHANGELOG.md
  • docs/configuration.md
  • netbox_attachments/__init__.py
  • netbox_attachments/checks.py
  • netbox_attachments/migrations/0012_netboxattachment_owner.py
  • netbox_attachments/migrations/0013_alter_netboxattachmentassignment_custom_field_data.py
  • netbox_attachments/tables.py
  • netbox_attachments/template_content.py
  • netbox_attachments/tests/test_checks.py
  • netbox_attachments/tests/test_configuration.py
  • netbox_attachments/tests/test_new_features.py
  • netbox_attachments/tests/test_template_extensions.py
  • netbox_attachments/utils.py
  • netbox_attachments/version.py
  • netbox_attachments/views.py

Jan Krupa added 4 commits July 17, 2026 12:57
- Check IDs were derived from REMOVED_SETTINGS dict order via enumerate(),
  so editing the table would silently renumber W001-W003 -- IDs users pin
  in SILENCED_SYSTEM_CHECKS and the CHANGELOG advertises. Each setting now
  carries its ID explicitly; a regression test asserts a mode-only config
  still yields W003, which the positional scheme got wrong.
- The message claimed the replacement setting "is being used ... with its
  default value" even when a half-migrated config sets both keys, telling
  the user their configured scope_filter was not in effect when it was.
  The wording now reflects whether the replacement is configured.
- The identical merged-settings hint for 'apps' and 'allowed_models' is one
  _MERGED_HINT constant instead of two literals to keep in sync.
- The hint's docs link is pinned to this release's tag instead of the
  main branch, so it describes the settings the warned install actually
  has and survives a docs reorganization.
- render_custom_object_panel checked hasattr(obj, "_meta") but everything
  downstream reads type(obj)._meta. A model CLASS in context passes the old
  guard (classes have _meta) and then crashes on the metaclass -- ModelBase
  has no _meta -- which NetBox surfaces as an exception box on the page.
  Reproduced with obj=Device (the class); core templates pass instances,
  but the extension is global, so any third-party template could hit it.
- Checking the type also covers None and non-model values in one condition.
- The scope check (validate_object_type under applied_scope="model") and
  the display-key resolution could each issue the same CustomObjectType
  query during one custom object page render, and the render path runs
  per template hook -- up to four identical queries per page in a
  model-scoped config with display_setting overrides.
- Cached with lru_cache keyed on the dynamic model class:
  netbox_custom_objects regenerates that class whenever the type is saved,
  so a rename produces a new class object and therefore a fresh cache
  entry. A queryset .update() bypasses the regeneration signal and would
  serve a stale name until restart -- the same tradeoff the CO plugin's
  own model cache makes.
- Only custom object classes enter the cache (the public wrapper filters
  first), so the install's standard models cannot churn the LRU.
- Add resolve_display_preference_for_model(): the single model -> effective
  display position answer. The startup loop and the render-time panel both
  call it, so display-key selection (type name vs label_lower), the
  query-avoidance optimization, and the additional_tab -> full_width_page
  fallback for custom objects have exactly one definition. Previously the
  key dance lived only in the render path, and the loop dropped the
  is_custom_object flag entirely -- leaving the documented no-tab/no-button
  guarantee resting on loop statement order alone. A test now pins the
  fallback at the resolver layer; consolidation of the two registration
  mechanisms is tracked in issue #112 and referenced at the guard.
- Remove get_display_preference(): zero production callers, and for custom
  objects it answered with the wrong fallback under the wrong key. Its two
  tests now exercise resolve_effective_display_preference directly.
- Extract _render_or_empty() for the try/render/log/return-'' tail that
  existed in three drifting copies (panel, button, custom object panel).
- Finish the label_lower sweep this branch started: the dedup key, the
  registry-facing app_model_name, and both keys in
  get_enabled_object_type_queryset now use model._meta.label_lower instead
  of hand-rolled f-strings.
- Move the _meta fake to a shared tests/conftest.py factory; adding
  label_lower earlier in this branch required synchronized edits to two
  per-file copies, which is the failure mode this removes.
@Kani999

Kani999 commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

Post-review remediation

A 10-angle review of this branch surfaced 13 findings (no merge blockers). Four follow-up commits address them:

Commit Findings addressed
Fix: Stable IDs and accurate wording for removed-setting warnings Positional W-ids that would silently renumber under SILENCED_SYSTEM_CHECKS; "default value" claim wrong for half-migrated configs; duplicated hint literal; docs link now pinned to the release tag
Fix: Guard the class the panel reads, not the context object A model class in context crashed through to NetBox's exception box (ModelBase has no _meta) — reproduced live, now declined
Perf: Cache the CustomObjectType name lookup per generated class Up to 4 identical queries per custom-object page render in model-scoped configs; cache key = the dynamic class, which the CO plugin regenerates on every type save
Refactor: One display resolver for the startup loop and render path Display-key logic split across layers; diverged tests-only get_display_preference removed; triplicated render tails; four leftover hand-rolled label_lower spellings; shared test-fake factory

The two-mechanism consolidation the review flagged at altitude is deliberately not done here — filed as #112 and referenced from the guard comment it will eventually delete.

Verified live after the changes: panel renders exactly once on a custom object page, mode-only config yields W003 (the positional scheme returned W001), identifier cache shows 0 queries on the second lookup, device pages unaffected. 93 standalone tests pass; ruff format/check clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EyeuydpZKGU8pba5oswYMo

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.

Unable to attach files to custom object

1 participant