diff --git a/CHANGELOG.md b/CHANGELOG.md index 574a428..853a2bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,57 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.0] - 2026-09-16 + +### Removed + +- **Combined "Custom Objects" tab and its settings** (`combined_models`, + `combined_label`, `combined_weight`). `netbox-custom-objects` 0.7.0 ships the + same tab natively (`netbox_custom_objects/related_tabs/`, weight 2000, no + configuration, live on every referenced object and on Custom Object pages). + Our copy registered under the same view name and had silently become a + no-op next to it. Leftover `combined_*` keys in `PLUGINS_CONFIG` now only + produce a startup warning. +- **Template override of `netbox_custom_objects/customobject.html`** and the + `template_override` module. Upstream's 0.7.0 template renders registered + model-view tabs itself via `{% plugin_extra_tabs %}`, so the typed tabs + reach Custom Object detail pages without shadowing the template. +- **`custom_object_tab_tags` templatetag library.** Upstream 0.7.0 ships a + library with the *same module name*; Django lets the later app in + `INSTALLED_APPS` win, so ours shadowed upstream's and broke + `{% custom_objects_tab_link %}` on every Custom Object page. Deleted. +- `TODO.md` (combined-tab backlog only). + +### Changed + +- **Requires `netbox-custom-objects` ≥ 0.7.0.** `PluginConfig.ready()` probes + for the upstream `related_tabs` package and raises `ImproperlyConfigured` + with an upgrade hint when it is missing. Installs on 0.6.x must stay on + plugin 2.6.x. +- **Typed tabs on Custom Object pages are served by a slug dispatcher.** The + shared `//custom-objects-/` route now resolves the host + Custom Object Type from the URL per request and dispatches to the view + registered for exactly that model, instead of being bound to whichever host + model registered first. +- `typed_models` remains opt-in (default `[]`); with the combined tab gone the + plugin is inactive until it is set. + +### Fixed + +- **Wrong host object / never-active tab on Custom Object pages** when one + Custom Object Type referenced several other Custom Object Types. The shared + route was bound to the first host model's view, so pages of the other host + types loaded the object with the same pk from the wrong table, and the + `ViewTab` placed in the context never matched the page's own registry entry. + Fixed by the dispatcher above; the `(label, weight)` comparison workaround + from 2.6.0 is no longer needed. + +### Upgrade notes + +1. `pip install -U 'netboxlabs-netbox-custom-objects>=0.7.0' netbox-custom-objects-tab` +2. Remove `combined_models` / `combined_label` / `combined_weight` from `PLUGINS_CONFIG`. +3. Restart NetBox. + ## [2.6.1] - 2026-09-14 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 3d4874c..5697781 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,233 +20,160 @@ ruff check netbox_custom_objects_tab/ ruff format netbox_custom_objects_tab/ ``` -Always run both before committing Python changes. +Always run both before committing Python changes. Tests: `pytest tests/ -v` (offline, NetBox +is mocked in `tests/conftest.py`; no DB, no `/opt/netbox`). ## Purpose -Adds **two tab modes** to NetBox object detail pages (Device, Site, Rack, etc.): +Adds **one tab per Custom Object Type** ("typed tabs") to NetBox object detail pages (Device, +Site, Rack, …, third-party plugin models, and Custom Object detail pages themselves). Each tab is +a full-featured type-specific list view: same columns, filters, search, bulk actions, +edit/delete, Add buttons and configure-table as the native `/plugins/custom-objects//` page. -1. **Combined tab** — a single "Custom Objects" tab showing all Custom Object instances - from any Custom Object Type that reference the parent object. Supports pagination, - text search, type/tag filters, column sorting, and per-user column preferences. +The **combined "Custom Objects" tab is NOT this plugin's job any more.** Since +`netbox-custom-objects` 0.7.0 it is built into upstream +(`netbox_custom_objects/related_tabs/`, registry name `custom_objects`, path `custom-objects`, +weight 2000, label "Custom Objects", no config, registered on every public model, live badge with +`hide_if_empty`). Plugin ≤ 2.6 shipped its own combined tab; 3.0.0 removed it. Do not re-add it. -2. **Typed tabs** (per-type) — each Custom Object Type gets its own tab with a - **full-featured** type-specific list view: same columns, filters, search, bulk actions, - edit/delete, and configure table as the native `/plugins/custom-objects//` page. +## Requirements / Gate -Both modes coexist. Config variables control which models get which behavior. +- NetBox 4.5.2 – 4.7.99 (`min_version` / `max_version` in `PluginConfig`). +- `netbox-custom-objects` **≥ 0.7.0**, enforced in `ready()` by importing + `netbox_custom_objects.related_tabs.registry` (feature probe, not a version string). Missing → + `ImproperlyConfigured`. Also checks `apps.is_installed("netbox_custom_objects")` first, because + NetBox silently skips a plugin whose `max_version` is below the running release. +- Leftover `combined_models` / `combined_label` / `combined_weight` in `PLUGINS_CONFIG` → one + `logger.warning`, otherwise ignored. ## Architecture -**NO models, NO migrations, NO API, NO forms, NO navigation menu.** +**NO models, NO migrations, NO API, NO forms, NO navigation menu, NO templates overriding upstream, +NO templatetags.** | File | Role | |------|------| -| `netbox_custom_objects_tab/__init__.py` | `PluginConfig`; calls `template_override.install()` then `views.register_tabs()` in `ready()` | -| `netbox_custom_objects_tab/template_override.py` | Prepends our `templates/` dir to `engine.dirs` so CO detail template override is found first | -| `netbox_custom_objects_tab/views/__init__.py` | `register_tabs()` + `_resolve_model_labels()` + `_inject_co_urls()` | -| `netbox_custom_objects_tab/views/combined.py` | Combined-tab view factory + helpers | -| `netbox_custom_objects_tab/views/typed.py` | Per-type tab view factory + dynamic table/filterset builders | +| `netbox_custom_objects_tab/__init__.py` | `PluginConfig`; gate checks, then `views.register_tabs()` in `ready()` | +| `netbox_custom_objects_tab/views/__init__.py` | `register_tabs()`, `_resolve_model_labels()`, `_inject_co_urls()` + `_make_co_dispatcher()`, `_deduplicate_registry()` | +| `netbox_custom_objects_tab/views/typed.py` | Per-type tab view factory, dynamic table builder, Q-filter builder, Add-link builder, `_get_base_template()` | | `netbox_custom_objects_tab/urls.py` | Empty `urlpatterns` (required by NetBox plugin loader) | -| `templates/.../combined/tab.html` | Combined tab full page (extends base_template) | -| `templates/.../combined/tab_partial.html` | Combined tab HTMX zone (no extends) | -| `templates/.../typed/tab.html` | Typed tab full page (extends base_template, mirrors `generic/object_list.html`) | -| `templates/netbox_custom_objects/customobject.html` | Override of CO detail template — adds `{% model_view_tabs object %}` to the hardcoded tabs block | +| `templates/netbox_custom_objects_tab/typed/tab.html` | Typed tab full page (extends `base_template`, mirrors `generic/object_list.html`); HTMX requests get NetBox's `htmx/table.html` | +| `tools/` | Standalone `manage.py shell <` scripts: demo data and the 2.4.0 polymorphic smoke test (not packaged) | -## Config Design +## Config ```python -# __init__.py default_settings default_settings = { - "typed_models": [], # per-type tabs (opt-in, empty by default) - "combined_models": [ # combined tab (current behavior) - "dcim.*", "ipam.*", "virtualization.*", "tenancy.*", - ], - "combined_label": "Custom Objects", - "combined_weight": 2000, - "typed_weight": 2100, # all typed tabs share this weight + "typed_models": [], # opt-in; "app.model" or "app.*"; "netbox_custom_objects.*" = CO→CO tabs + "typed_weight": 2100, # upstream combined tab is 2000 → typed tabs render right after it } ``` -Both `typed_models` and `combined_models` accept the same label formats: - -| Format | Behaviour | -|--------|-----------| -| `dcim.device` | Registers for that single model | -| `dcim.*` | Registers for **every model** in the `dcim` app | - -A model can appear in both lists and get both tab styles. - -**Third-party plugin models are fully supported:** -```python -'combined_models': ['dcim.*', 'ipam.*', 'inventory_monitor.*'] -``` - ## How Custom Objects Link to NetBox Objects -The `netbox_custom_objects` plugin uses **direct ForeignKey / M2M** relationships, -not GenericForeignKey. Each Custom Object Type generates a real Django model with -its own database table. +`netbox_custom_objects` uses **direct FK / M2M** relationships (plus GFK-style +`_content_type_id`/`_object_id` and a through model for polymorphic fields), not a +single GenericForeignKey. Each Custom Object Type generates a real Django model (`TableModel`). -To find all custom objects referencing a Device (pk=42): -1. Get ContentType for Device -2. `CustomObjectTypeField.objects.filter(related_object_type=content_type)` — finds all - fields in any Custom Object Type that point to Device -3. For each field: `field.custom_object_type.get_model()` — gets the dynamic model class -4. `TYPE_OBJECT` (ForeignKey): `model.objects.filter({field.name}_id=42)` -5. `TYPE_MULTIOBJECT` (M2M): `model.objects.filter({field.name}=42)` +`register_typed_tabs()` pre-fetches all `CustomObjectTypeField` rows of type OBJECT/MULTIOBJECT +(non-poly via `related_object_type`, poly via `related_object_types` M2M), groups them by +`(host_content_type_id, custom_object_type_pk)` → `field_infos = [(name, type, label, is_poly, +through_model_name)]`, and registers one view per pair with `register_model_view(model, +name=f"custom_objects_{slug}", path=f"custom-objects-{slug}")`. -Reference: `netbox_custom_objects/template_content.py::CustomObjectLink.left_page()` +`_build_q_for_field()` turns one field_info into a `Q`; the view ORs them and applies +`.distinct()`. An empty `Q()` means "unresolvable" and must be skipped (`filter(Q())` = all rows). -## Key Import Paths (NetBox 4.5.x / 4.6.x / 4.7.x) +Upstream reference for the same shapes: `netbox_custom_objects/related_tabs/views/combined.py::reference_q`. + +## Key Import Paths (NetBox 4.5.x / 4.6.x / 4.7.x, netbox-custom-objects 0.7.x) ```python -from utilities.views import ViewTab, register_model_view -from utilities.paginator import EnhancedPaginator, get_paginate_count -from netbox_custom_objects.models import CustomObjectTypeField -from extras.choices import CustomFieldTypeChoices, CustomFieldUIVisibleChoices -from netbox.plugins import get_plugin_config -from utilities.htmx import htmx_partial +from utilities.views import ViewTab, register_model_view, get_default_template +from netbox_custom_objects.models import CustomObjectTypeField, CustomObjectType, CustomObject from netbox_custom_objects.tables import CustomObjectTable -from netbox_custom_objects import field_types +from netbox_custom_objects import field_types # FIELD_TYPE_CLASS from netbox_custom_objects.filtersets import get_filterset_class -from netbox.forms import NetBoxModelFilterSetForm -from netbox.forms.mixins import SavedFiltersMixin -from utilities.forms.fields import TagFilterField +from netbox_custom_objects.dynamic_forms import build_filterset_form_class +from extras.choices import CustomFieldTypeChoices, CustomFieldUIVisibleChoices +from netbox.plugins import get_plugin_config +from netbox.registry import registry ``` -## Combined Tab — Pagination & Filtering Design - -- **`_get_linked_custom_objects(instance)`** — returns a Python `list` of `(obj, field)` tuples - by querying across multiple dynamic model tables. A single queryset is not possible. - Each queryset uses `.prefetch_related('tags')` so tag data is batch-fetched. -- **`_filter_linked_objects(linked, q)`** — filters that list in Python; case-insensitive - match against `str(obj)`, `str(field.custom_object_type)`, `str(field)`. -- **`available_tags`** — collected from `linked_all` (unfiltered), deduplicated by slug. -- **Tag filter** — applied after the type filter by checking tag slugs (cache hit, no query). -- **`EnhancedPaginator`** — paginates the filtered list. -- **`htmx_partial(request)`** — returns partial template for HTMX requests. -- Badge count uses `.count()` (DB-side `COUNT(*)`) per field — no full rows fetched. - -## Typed Tab — Architecture - -The typed tab reuses components from `netbox_custom_objects`: - -| What | Import path | -|------|-------------| -| `CustomObjectTable` | `netbox_custom_objects.tables.CustomObjectTable` — base table with pk, id, actions, tags | -| `FIELD_TYPE_CLASS` | `netbox_custom_objects.field_types.FIELD_TYPE_CLASS` — column + filter generation | -| `get_filterset_class()` | `netbox_custom_objects.filtersets.get_filterset_class` — dynamic filterset | -| Bulk action template tags | `netbox_custom_objects.templatetags.custom_object_buttons` | - -Key functions in `views/typed.py`: - -- **`_build_typed_table_class(cot, model)`** — dynamically creates a table class replicating - `CustomObjectTableMixin.get_table()` logic from `netbox_custom_objects`. -- **`_build_filterset_form(cot, model)`** — dynamically creates a filter form replicating - `CustomObjectListView.get_filterset_form()`. -- **`_count_for_type(cot, field_infos)`** — returns a badge callable (COUNT-only). -- **`_make_typed_tab_view(model, cot, field_infos, weight)`** — view factory. The `get()` - method builds a base queryset (union of field filters + `.distinct()`), applies filterset, - builds table, calls `table.configure(request)`, and returns the typed template. -- **`register_typed_tabs(models, weight)`** — pre-fetches all fields, groups by - `(content_type, custom_object_type)`, registers one view per pair. - -HTMX for typed tabs: the view returns `htmx/table.html` (NetBox standard) for HTMX requests. -No custom partial needed — `table.configure(request)` handles pagination and ordering. - -## CO→CO Tab Support (`netbox_custom_objects.*`) - -Setting `netbox_custom_objects.*` in `combined_models` or `typed_models` enables tabs on -Custom Object detail pages themselves (e.g. Type A has a FK to Type B → Type B's detail page -shows a tab of Type A instances). - -Three non-obvious problems had to be solved: - -1. **Model resolution** — dynamic per-type models (e.g. `Table28Model`) are not returned by - `apps.get_app_config().get_models()` unless already registered. `_resolve_model_labels()` - special-cases `netbox_custom_objects.*` to read `CustomObject` subclasses from - `app_config.get_models()` (safe after `netbox_custom_objects.ready()` has run). - **Never call `get_model()` here** — it re-registers journal/changelog views on cache miss. - -2. **URL patterns** — `netbox_custom_objects` serves all CO detail pages via one generic - `CustomObjectView` and never calls `get_model_urls()` for dynamic models. Our tab views - are registered in `registry['views']` but have no corresponding URL patterns, so - `get_action_url()` throws `NoReverseMatch` (silently skipped by the template tag). - `_inject_co_urls()` appends patterns like - `//custom-objects-{slug}/` to - `netbox_custom_objects.urls.urlpatterns` at `ready()` time. - -3. **Template** — `netbox_custom_objects/customobject.html` has a hardcoded `{% block tabs %}` - with no `{% model_view_tabs object %}` call. `template_override.install()` prepends our - `templates/` directory to `engine.dirs` so our copy of the template (with the call added) - is found first by the filesystem loader. - -## Permission Checks in Template - -Combined tab uses inline `` buttons with `can_change`/`can_delete` filters (see combined templates). -Typed tab uses `CustomObjectActionsColumn` from `netbox_custom_objects.tables` which handles -permissions internally via `get_permission_for_model()`. - -- Do **not** add bulk-edit or bulk-delete buttons to the **combined** tab — it shows objects - from multiple different Custom Object Types, so bulk editing across types is meaningless. -- Typed tabs **do** support bulk actions since all objects are the same type. +## CO→CO Tabs (`netbox_custom_objects.*` in `typed_models`) + +Type A has a field → Type B ⇒ Type B's detail page shows a "Type A" tab. + +1. **Model resolution** — dynamic models are read from + `apps.get_app_config("netbox_custom_objects").get_models()` filtered to `CustomObject` + subclasses (safe after upstream's `ready()`). **Never call `CustomObjectType.get_model()` in + `ready()`** — a cache miss re-registers journal/changelog views → duplicate tabs. +2. **URL patterns** — upstream serves all CO detail pages via one generic `CustomObjectView` and + never calls `get_model_urls()` for dynamic models, so our registry entries have no routes. + `_inject_co_urls()` appends `//custom-objects-/` named + `customobject_custom_objects_` to `netbox_custom_objects.urls.urlpatterns` at `ready()`. +3. **Dispatcher** — one slug's action may be registered on several host CO models (Type A → + Type B *and* Type C). The route is bound to `_make_co_dispatcher(action_name)`, which per + request does `CustomObjectType(slug).get_model()._meta.model_name` → registry lookup → + `view_cls.as_view()(request, custom_object_type=…, pk=…)`. This loads the right host model and + puts *that model's* `ViewTab` in the context. (≤ 2.6 bound the route to the first model's view + class: wrong object for other hosts, and never-active tab — the 2.6.0 `(label, weight)` hack.) +4. **Rendering** — upstream 0.7.0 `customobject.html` calls `{% plugin_extra_tabs object %}` + (upstream's own tag in `netbox_custom_objects/templatetags/custom_object_tab_tags.py`). It + renders every registry tab except exact names `journal`, `changelog`, `contacts`, + `custom_objects`, drops tabs whose URL doesn't reverse, and marks active by identity + `context["tab"] == view.tab`. Our names `custom_objects_` pass the filter; the dispatcher + makes the identity check succeed. ## Gotchas -- `register_model_view` must run inside `AppConfig.ready()` — not at module level -- `hide_if_empty=True` on ViewTab requires the badge callable to return `None` (not `0`) - when the count is zero -- Template must `{% extends base_template %}` where `base_template` is set in view context - as `f"{app_label}/{model_name}.html"` -- `CustomObjectTypeField.related_object_type` is a FK to `core.ObjectType` (proxy of ContentType) -- Each model needs its own View subclass (factory pattern) for distinct registry entries -- `inc/paginator.html` uses `page.smart_pages` — always use `EnhancedPaginator` -- Combined tab template is split: `combined/tab.html` (full page) and `combined/tab_partial.html` - (HTMX zone). Typed tab uses NetBox's `htmx/table.html` directly. -- `table.htmx_url` must be set on the instance to shadow `@cached_property` (avoids reverse - error for dynamic models) -- Typed tabs use `custom-objects-{slug}` path prefix — avoids collisions with built-in paths -- Multiple fields of same type → union querysets with `.distinct()` -- Tabs registered at `ready()` — new Custom Object Types need a restart (applies both to typed tabs on native models and to `netbox_custom_objects.*` tabs on Custom Object pages) -- **Do NOT defer typed-tab registration to `request_started` or any post-`ready()` signal.** NetBox's `get_model_urls(app, model)` snapshots `registry['views']` when the model's `urls.py` is first imported (which happens lazily on the first `resolve()` call). Anything added to the registry after that has no URL pattern. Combined tabs work because they're registered in `ready()` synchronously; typed tabs MUST be registered the same way. PR #4 / commit `5bf09c3` deferred typed-tab registration to silence DB-access startup warnings — that change broke typed tabs entirely and was reverted in 2.3.0. The DB-access warning is acceptable; broken URL routing is not. -- `netbox_custom_objects.*` wildcard is special-cased in `_resolve_model_labels()` — dynamic models are discovered via `app_config.get_models()` filtered to `CustomObject` subclasses. **Do NOT call `get_model()` here** — each cache-miss call re-registers journal/changelog tab views, producing duplicate tabs -- `base_template` for CO model instances must be `netbox_custom_objects/customobject.html` — the per-model template (e.g. `netbox_custom_objects/table28model.html`) does not exist -- Tab view `get()` must accept `**kwargs` — CO detail URLs pass `custom_object_type` slug as an extra kwarg alongside `pk` -- `netbox_custom_objects/customobject.html` has a **hardcoded** `{% block tabs %}` (Journal + Changelog only) with no `{% model_view_tabs object %}` call. We override it via `template_override.py` + a copy of the template with the call added. The override must be in `engine.dirs` (filesystem loader) not just `app_directories`, because our app comes after `netbox_custom_objects` in `INSTALLED_APPS` -- `netbox_custom_objects` uses a single generic URL view (`CustomObjectView`) for all CO detail pages — it never calls `get_model_urls()` for dynamic models. `_inject_co_urls()` appends our tab URL patterns to `netbox_custom_objects.urls.urlpatterns` at `ready()` time (safe: Django loads URL conf lazily on first request) -- `SavedFiltersMixin` lives at `netbox.forms.mixins`, not `extras.forms.mixins` -- **Journal/Changelog tabs on CO pages must be hardcoded `
  • ` blocks, not rendered via `{% model_view_tabs %}`.** Upstream's `CustomObjectJournalView`/`CustomObjectChangeLogView` (`views.py:1321, 1393`) set `tab="journal"`/`"changelog"` as a **string** in the template context, while `model_view_tabs` (`utilities/templatetags/tabs.py:53`) computes `is_active = active_tab == tab` where `tab` is a `ViewTab` object — comparison always False → no `active` class. NetBox auto-registers Journal/Changelog views for ChangeLoggedModel subclasses in `netbox/models/features.py:737-742`, so `model_view_tabs` *will* render them, just never as active. Workaround: hardcode `
  • ` for Journal/Changelog (string comparison works), and use our `{% plugin_extra_tabs %}` tag (`templatetags/custom_object_tab_tags.py`) instead of `model_view_tabs` to render combined/typed tabs while filtering out journal/changelog (otherwise duplicate inert tabs appear). Fix landed for issue #15. -- **`context["tab"]` on CO detail pages may be a plain `str`** (`"journal"`, `"changelog"`, - `"contacts"`, `"configcontext"` — set by upstream's hand-rolled views). `plugin_extra_tabs` must - read `label`/`weight` off it with `getattr`, never attribute access (issue #19, regression in 2.6.0). - NetBox (≥ 4.3) also auto-registers a `contacts` ViewTab (`netbox/models/features.py`, next to - journal/changelog), so `contacts` is in `_HARDCODED_TAB_NAMES` too — otherwise a duplicate tab renders. +- `register_model_view` must run inside `AppConfig.ready()` — not at module level. +- **Do NOT defer typed-tab registration** to `request_started` or any post-`ready()` signal. + NetBox's `get_model_urls(app, model)` snapshots `registry['views']` when the model's `urls.py` + is first imported. PR #4 / commit `5bf09c3` deferred it to silence DB-access startup warnings + and broke all typed tabs (reverted in 2.3.0). The warning is acceptable. +- `hide_if_empty=True` requires the badge callable to return `None` (not `0`) when empty. +- `base_template` for CO instances is `netbox_custom_objects/customobject.html`; per-model + templates (`table28model.html`) don't exist. For native models use `get_default_template()` + (falls back to `generic/object.html` for models without a detail template, e.g. `ipam.vrf`). +- Tab view `get()` must accept `**kwargs` — CO routes pass `custom_object_type`. +- `table.htmx_url` must be set on the instance to shadow `@cached_property` (avoids reverse error + for dynamic models). +- Upstream views set `context["tab"]` to a plain **str** (`"journal"`, `"changelog"`, `"contacts"`, + `"configcontext"`) — never assume it's a `ViewTab`. +- **Never ship a templatetag module named `custom_object_tab_tags`** (or any name upstream uses). + Django's `get_installed_libraries()` lets the later `INSTALLED_APPS` entry win; 2.6.x shadowed + upstream's library and would have broken `{% custom_objects_tab_link %}` on 0.7.0. +- Tabs are registered at `ready()` — a new Custom Object Type needs a NetBox restart. +- Toolbar permissions are checked against the **base** `customobject` model + (`netbox_custom_objects.add_customobject` etc.), not the dynamic subclass. +- Known upstream issue (documented 2.3.0): Create via the typed-tab Add button and then per-row + Delete on the new row in the same flow can raise `ValueError` in `CustomObjectDeleteView` + (model class identity drift). Refresh between the two, or use Bulk Delete. ## Critical Reference Files +Upstream editable checkout (also what the venv imports): `/opt/netbox-custom-objects/netbox_custom_objects/` + | File | Purpose | |------|---------| -| `/opt/netbox/venv/lib/python3.12/site-packages/netbox_custom_objects/template_content.py` | Query pattern to replicate | -| `/opt/netbox/venv/lib/python3.12/site-packages/netbox_custom_objects/models.py` | `CustomObjectTypeField` model structure | -| `/opt/netbox/venv/lib/python3.12/site-packages/netbox_custom_objects/views.py` | `CustomObjectTableMixin.get_table()` + `get_filterset_form()` | -| `/opt/netbox/venv/lib/python3.12/site-packages/netbox_custom_objects/tables.py` | `CustomObjectTable`, `CustomObjectActionsColumn` | -| `/opt/netbox/venv/lib/python3.12/site-packages/netbox_custom_objects/filtersets.py` | `get_filterset_class()` | -| `/opt/netbox/venv/lib/python3.12/site-packages/netbox_custom_objects/field_types.py` | `FIELD_TYPE_CLASS` dict | -| `/opt/netbox/netbox/utilities/views.py` | `register_model_view` + `ViewTab` API | -| `/opt/netbox/netbox/utilities/paginator.py` | `EnhancedPaginator` + `get_paginate_count` | -| `/opt/netbox/netbox/templates/htmx/table.html` | HTMX table template used by typed tabs | -| `/opt/netbox/netbox/templates/generic/object_list.html` | Full list view layout pattern | +| `related_tabs/registry.py`, `related_tabs/views/combined.py` | Upstream combined tab + `reference_q()`; the pattern our Q builder mirrors | +| `templatetags/custom_object_tab_tags.py` | Upstream `plugin_extra_tabs` (renders our typed tabs on CO pages) | +| `templates/netbox_custom_objects/customobject.html` | CO detail template (tabs block) | +| `models.py` | `CustomObjectTypeField`, `CustomObjectType.get_model()` | +| `views.py` | `CustomObjectTableMixin.get_table()` — table-building logic we replicate | +| `tables.py`, `filtersets.py`, `field_types.py`, `dynamic_forms.py` | Reused building blocks | +| `/opt/netbox/netbox/utilities/views.py` | `register_model_view` + `ViewTab` | +| `/opt/netbox/netbox/templates/htmx/table.html`, `generic/object_list.html` | Templates the typed tab mirrors | ## Verification Steps -1. Activate venv and install: `pip install -e /opt/custom_objects_additional_tab_plugin/` -2. Add to NetBox config, restart -3. Combined tab: navigate to Device detail → "Custom Objects" tab appears with badge -4. Typed tab: with `typed_models: ['dcim.*']`, per-type tabs appear (e.g. "Link - ISISs") -5. Typed tab: type-specific columns, filters sidebar, bulk actions, configure table all work -6. HTMX: pagination and sorting update in-place (no full reload) -7. Bulk actions: select rows → bulk edit/delete work, return URL correct -8. Per-row edit/delete: action buttons work, return URL preserves tab -9. Remove all objects of one type → typed tab disappears -10. Combined tab unchanged when typed tabs enabled +1. `pip install -e /opt/custom_objects_additional_tab_plugin/` into the NetBox venv; restart. +2. `typed_models: ['dcim.*', 'netbox_custom_objects.*']` → Device detail shows upstream's + "Custom Objects" tab (weight 2000) followed by one tab per referencing type; no duplicates. +3. Typed tab: type-specific columns, filter sidebar, bulk actions, Add buttons, configure table. +4. HTMX: pagination and sorting update in place. +5. CO detail page (`/plugins/custom-objects///`): typed tabs render, the open one is + `active`, upstream's `/custom-objects/`, `/journal/`, `/changelog/`, `/contacts/` still 200. +6. Remove all objects of one type → its tab disappears. +7. `manage.py check` clean (no `netbox_custom_objects.W002`). diff --git a/README.md b/README.md index 49ca8b4..6d94bb2 100644 --- a/README.md +++ b/README.md @@ -6,53 +6,44 @@ [![NetBox](https://img.shields.io/badge/NetBox-4.5.x_|_4.6.x_|_4.7.x-blue)](https://github.com/netbox-community/netbox) [![License](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE) -A NetBox 4.5.x / 4.6.x / 4.7.x plugin that adds **Custom Objects** tabs to object detail pages, -showing Custom Object instances from the `netbox_custom_objects` plugin that reference -those objects via OBJECT or MULTIOBJECT fields. Works on standard NetBox models (Device, -Site, Rack, …), third-party plugin models, and Custom Object detail pages themselves -(CO→CO relationships). - -Two tab modes are available: - -- **Combined tab** — a single tab showing all Custom Object Types in one table, with - pagination, text search, column sorting, type/tag filtering, and HTMX partial updates. -- **Typed tabs** — each Custom Object Type gets its own tab with a full-featured list view - (type-specific columns, filterset sidebar, bulk actions, configure table) matching the - native Custom Objects list page. - -## Screenshot - -![Custom Objects tab showing 3 linked objects with type filter dropdown](https://raw.githubusercontent.com/CESNET/netbox-custom-objects-tab/master/docs/screenshot.png) +A NetBox 4.5.x / 4.6.x / 4.7.x plugin that adds **one tab per Custom Object Type** to object +detail pages. Each tab is a full-featured list of the Custom Object instances (from the +[`netbox-custom-objects`](https://github.com/netboxlabs/netbox-custom-objects) plugin) that +reference the viewed object via an OBJECT or MULTIOBJECT field — with type-specific columns, +filter sidebar, bulk actions, pre-filled Add buttons and per-user table configuration, matching +the native `/plugins/custom-objects//` list page. + +Works on standard NetBox models (Device, Site, Rack, …), third-party plugin models, and Custom +Object detail pages themselves (CO→CO relationships). + +> **Looking for the combined "Custom Objects" tab?** Since `netbox-custom-objects` 0.7.0 it is +> built into the upstream plugin (one tab listing every referencing Custom Object, any type, with +> search / type / tag filters). It needs no configuration and appears on every referenced object. +> This plugin only adds the per-type tabs; versions ≤ 2.6 shipped a combined tab of their own, +> which was removed in 3.0.0. ## Requirements - NetBox 4.5.2 – 4.7.99 -- `netbox_custom_objects` plugin **≥ 0.6.0** installed and configured - (**≥ 0.6.1 on NetBox 4.7** — 0.6.0 declares `max_version` 4.6.99 and is skipped by NetBox 4.7; - 0.5.x installs must stay on plugin 2.4.1) +- `netbox-custom-objects` **≥ 0.7.0** installed and loaded (`netboxlabs-netbox-custom-objects` on PyPI) ## Compatibility -| Plugin version | NetBox version | `netbox_custom_objects` version | -|----------------|----------------|------------------------------------------------------------------------| -| 2.6.x | 4.5.2+ / 4.6.x / 4.7.x | **≥ 0.6.0 required** (≥ 0.6.1 on NetBox 4.7) | -| 2.5.x | 4.5.2+ / 4.6.x | **≥ 0.6.0 required** | -| 2.4.x | 4.5.2+ / 4.6.x | **≥ 0.5.1 required** | -| 2.3.x | 4.5.4+ / 4.6.x | ≥ 0.4.6 (≥ 0.5.0 on 4.6) | -| 2.2.x | 4.5.4+ / 4.6.x | ≥ 0.4.6 (≥ 0.5.0 on 4.6) | -| 2.1.x | 4.5.4+ | ≥ 0.4.6 | -| 2.0.x | 4.5.x | ≥ 0.4.6 | -| 1.0.x | 4.5.x | ≥ 0.4.4 | - -Plugin 2.5.x **enforces** the 0.6.0 minimum at startup: `PluginConfig.ready()` -probes for the upstream `coordinates` field type (introduced in 0.6.0) -and raises `ImproperlyConfigured` with an upgrade message pointing at -`>=0.6.0` if the installed upstream is older. The check is behaviour-based -(looks for the feature, not a version string) so it stays correct across forks -and pre-release tags. The hard floor exists because the plugin ships a copy of -the 0.6.0 `customobject.html` detail template (Contacts and Config Context -tabs, owner display, coordinates rendering), which reverses URLs that do not -exist on 0.5.x. +| Plugin version | NetBox version | `netbox_custom_objects` version | +|----------------|------------------------|----------------------------------------------| +| 3.0.x | 4.5.2+ / 4.6.x / 4.7.x | **≥ 0.7.0 required** | +| 2.6.x | 4.5.2+ / 4.6.x / 4.7.x | ≥ 0.6.0 (≥ 0.6.1 on NetBox 4.7), < 0.7.0 | +| 2.5.x | 4.5.2+ / 4.6.x | ≥ 0.6.0 | +| 2.4.x | 4.5.2+ / 4.6.x | ≥ 0.5.1 | +| 2.0.x – 2.3.x | 4.5.x / 4.6.x | ≥ 0.4.6 | +| 1.0.x | 4.5.x | ≥ 0.4.4 | + +The 0.7.0 minimum is **enforced at startup**: `PluginConfig.ready()` probes for the upstream +`related_tabs` package (new in 0.7.0) and raises `ImproperlyConfigured` with an upgrade hint +if it is missing. The check is behaviour-based (looks for the feature, not a version string) +so it stays correct across forks and pre-release tags. 0.7.0 is required because its Custom +Object detail template renders registered model-view tabs, which is how the per-type tabs reach +Custom Object pages; older releases needed a template override that 3.0.0 no longer ships. ## Installation @@ -69,57 +60,44 @@ PLUGINS = [ 'netbox_custom_objects_tab', ] -# Optional — defaults shown below PLUGINS_CONFIG = { 'netbox_custom_objects_tab': { - 'combined_models': ['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*'], - 'combined_label': 'Custom Objects', - 'combined_weight': 2000, - 'typed_models': [], # opt-in: e.g. ['dcim.*'] - 'typed_weight': 2100, + 'typed_models': ['dcim.*'], # required — the plugin does nothing until set + 'typed_weight': 2100, # optional, default shown } } ``` Restart NetBox. No database migrations required. +### Upgrading from 2.x + +1. `pip install -U 'netboxlabs-netbox-custom-objects>=0.7.0' netbox-custom-objects-tab` +2. Remove `combined_models`, `combined_label` and `combined_weight` from `PLUGINS_CONFIG` + (a leftover key only logs a warning at startup; it has no effect). +3. Restart NetBox. The combined tab keeps working — it is now served by `netbox-custom-objects`. + ## Configuration | Setting | Default | Description | |---------|---------|-------------| -| `combined_models` | `['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*']` | Models that get the combined "Custom Objects" tab. Accepts `app_label.model_name` or `app_label.*` wildcards. | -| `combined_label` | `'Custom Objects'` | Text displayed on the combined tab. | -| `combined_weight` | `2000` | Tab position for the combined tab; lower = further left. | -| `typed_models` | `[]` | Models that get per-type tabs (opt-in, empty by default). Same format as `combined_models`. | -| `typed_weight` | `2100` | Tab position for all typed tabs. | - -A model can appear in both `combined_models` and `typed_models` to get both tab styles. +| `typed_models` | `[]` | Models that get one tab per Custom Object Type referencing them. Accepts `app_label.model_name` or `app_label.*` wildcards. Empty = plugin inactive. | +| `typed_weight` | `2100` | Tab position for all typed tabs; lower = further left. Upstream's combined tab sits at 2000, so typed tabs follow it by default. | ### Examples ```python -# Combined tab only (default) -'combined_models': ['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*'] - -# Per-type tabs for dcim models +# Per-type tabs on every dcim model 'typed_models': ['dcim.*'] -# Both modes for dcim, combined only for others -'combined_models': ['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*'], -'typed_models': ['dcim.*'], - # Only specific models -'combined_models': ['dcim.device', 'dcim.site', 'ipam.prefix'] +'typed_models': ['dcim.device', 'dcim.site', 'ipam.prefix'] # Third-party plugin models work identically -'combined_models': ['dcim.*', 'ipam.*', 'inventory_monitor.*'] +'typed_models': ['dcim.*', 'ipam.*', 'inventory_monitor.*'] -# Tabs on Custom Object detail pages (CO → CO relationships) -'typed_models': ['netbox_custom_objects.*'] - -# Combined tab on Custom Object pages + typed tabs on Device pages -'combined_models': ['dcim.*', 'netbox_custom_objects.*'], -'typed_models': ['dcim.*', 'netbox_custom_objects.*'], +# Tabs on Custom Object detail pages too (CO → CO relationships) +'typed_models': ['dcim.*', 'netbox_custom_objects.*'] ``` Third-party plugin models are fully supported — Django treats plugin apps and built-in apps @@ -127,209 +105,53 @@ the same way in the app registry. Add the plugin's app label and restart NetBox #### Tabs on Custom Object detail pages -Setting `netbox_custom_objects.*` in `combined_models` or `typed_models` enables tabs on -Custom Object detail pages themselves. This is useful when one Custom Object Type has a -field referencing another Custom Object Type — the referenced object will show a tab listing -all objects that link to it. +Setting `netbox_custom_objects.*` in `typed_models` enables tabs on Custom Object detail pages +themselves. When Custom Object Type A has a field referencing Type B, every Type B instance gets +a "Type A" tab listing the Type A objects that link to it. -Because Custom Object model classes are generated dynamically (one per type, on-demand), -**a NetBox restart is required whenever a new Custom Object Type is added** — the same -requirement that applies to all typed tabs. +Because Custom Object model classes are generated dynamically (one per type), +**a NetBox restart is required whenever a new Custom Object Type is added** — this applies to +all typed tabs, on native models and on Custom Object pages alike. -The tab is hidden automatically (`hide_if_empty=True`) when no custom objects reference -the object being viewed, so it only appears when relevant. +Tabs are hidden automatically (`hide_if_empty=True`) when nothing references the viewed object. ## Features -### Pagination -Results are paginated using NetBox's standard `EnhancedPaginator`. The page size respects -the user's personal NetBox preference and can be overridden with `?per_page=N` in the URL. -Page controls appear at the top and bottom of the table. - -### Text search -A search box in the card header filters results by: -- Custom Object instance display name -- Custom Object Type name -- Field label - -Filtering uses the `?q=` query parameter and is applied before pagination. - -### Type filter -A dropdown (shown when 2 or more Custom Object Types are present) lets you narrow -results to a single type. Uses the `?type=` query parameter. The dropdown -auto-submits on selection and is populated from the types actually present in the -current result set. - -### Tag filter -A dropdown (shown when at least one linked Custom Object has a tag) lets you narrow -results to objects with a specific tag. Uses the `?tag=` query parameter. The -dropdown auto-submits on selection and is populated from the tags present across the -full result set. Tag data is pre-fetched in bulk so there is no N+1 query cost. - -### Column sorting -Clicking the **Type**, **Object**, or **Field** column header sorts the table -in-memory. A second click on the same header reverses the direction. The active -column shows an up/down arrow icon. Sort state is preserved when the search form -is submitted. - -### HTMX / Partial updates -Pagination clicks, column sort clicks, search form submissions, type-dropdown changes, -and tag-dropdown changes all update the table zone in-place using HTMX — no full page -reload. The URL is updated via `pushState` so links stay shareable and the browser back -button returns to the previous filter/page state. - -### Value column -Each row includes a **Value** column showing the actual field value on the Custom -Object instance: -- **Object** fields: a link to the related object. -- **Multi-Object** fields: comma-separated links to the related objects, truncated - at 3 with an ellipsis when more are present. - -### Configure Table -A **Configure Table** button in the card header opens a NetBox modal that lets -authenticated users show, hide, and reorder the table columns (Type, Object, Value, -Field, Tags). Preferences are stored per-user in `UserConfig` and respected on every -subsequent page load, including HTMX partial updates. The Actions column is always -visible and cannot be hidden. - -### Action buttons -Each row has right-aligned action buttons, shown only when the user has the relevant permission: - -- **Edit** (pencil icon) — links to the Custom Object instance's edit page. Shown when the user has `change` permission on the object. -- **Delete** (trash icon) — links to the Custom Object instance's delete confirmation page. Shown when the user has `delete` permission on the object. - -Users without either permission see no action buttons in the row. After completing either -action, NetBox redirects back to the Custom Objects tab on the same parent object. - -### Efficient badge counts -The tab badge (shown in the tab bar on every detail page) is computed with a -`COUNT(*)` query per field — no object rows are fetched. Full object rows are only -loaded when the tab itself is opened. This keeps detail page loads fast even when -thousands of custom objects reference an object. +Each typed tab reuses the building blocks of the native Custom Objects list view from +`netbox-custom-objects`, so it looks and behaves like `/plugins/custom-objects//` +pre-filtered to the current object: + +- **Type-specific columns** — every visible field of the Custom Object Type, rendered by the + upstream field-type renderers (links, badges, coordinates, …), plus ID, tags and actions. +- **Filter sidebar** — the upstream dynamic filterset and filter form for that type, including + polymorphic fields and the owner filter. +- **Quick search, sorting, pagination** — NetBox's standard table controls, updated in place via + HTMX (`htmx/table.html`), so paging and sorting never reload the page. +- **Configure Table** — per-user column selection and ordering, stored in `UserConfig`. +- **Add buttons** — one per referencing field, opening the upstream create form with the + reference to the current object pre-filled; returns to the tab afterwards. +- **Bulk edit / bulk delete** — row checkboxes and the upstream bulk views, with the return URL + pointing back to the tab. Available because all rows in a typed tab share one type. +- **Per-row Edit / Delete** — via upstream's `CustomObjectActionsColumn`, permission-aware. +- **Cheap badge counts** — the tab badge is a single `COUNT(*)` with `.distinct()`; a row that + references the object through several fields is counted once. Full rows load only when the + tab is opened. +- **Permission-aware** — rows are restricted with `.restrict(user, "view")`; Add / bulk buttons + honour `netbox_custom_objects.{add,change,delete}_customobject`. ## How It Works -When a Custom Object Type has a field of type **Object** or **Multi-Object** pointing to -a NetBox model (e.g. Device), any Custom Object instances with that field set will appear -in the "Custom Objects" tab on the referenced object's detail page. - -The tab displays: - -| Column | Content | -|--------|---------| -| **Type** | Custom Object Type name (sortable); links to the type detail page when the user has view permission | -| **Object** | Link to the Custom Object instance (sortable) | -| **Value** | The value stored in the linking field — a link for Object fields, comma-separated links for Multi-Object fields | -| **Field** | The field that holds the reference (sortable) | -| **Tags** | Colored tag badges assigned to the Custom Object instance; `—` when none | -| *(actions)* | Edit and Delete buttons, each shown only when the user has the corresponding permission | - -## Known Issues - -### Upstream Delete bug on `netbox-custom-objects == 0.5.0` (fixed in 0.5.1) - -**Affected versions:** `netbox-custom-objects == 0.5.0` only. -**Fixed in:** `netbox-custom-objects` `main` (PR -[#501](https://github.com/netboxlabs/netbox-custom-objects/pull/501), -merged 2026-05-11) and the forthcoming `0.5.1` release. -**Not affected:** `0.4.x` (no polymorphic through-models) and any build -that contains PR #501. - -Deleting a Custom Object instance through the NetBox UI on a 0.5.0 -install can raise a `ValueError` inside -`netbox_custom_objects.CustomObjectDeleteView`: - -``` -ValueError: Cannot query "": Must be "TableModel" instance. -``` - -(at `netbox_custom_objects/views.py:977`, inside `_get_dependent_objects`, -called by Django's `Collector.collect()`). The same crash also occurs from -the bulk-delete view (`CustomObjectBulkDeleteView`) because NetBox's -generic `BulkDeleteView.post()` iterates the queryset and calls `obj.delete()` -per row — the same code path. **Bulk Delete is NOT a workaround** -(earlier versions of this README claimed it was; that was incorrect). - -#### Recommended fix — upgrade upstream - -The cleanest resolution is to upgrade `netbox-custom-objects` to a -build that contains PR #501. As of writing (2026-05-13) no `0.5.1` -release tag exists yet, so the options are: - -```bash -# Option A: install from upstream main (contains PR #501) -pip install --upgrade --force-reinstall \ - git+https://github.com/netboxlabs/netbox-custom-objects.git@main - -# Option B: wait for the 0.5.1 release tag and pin to it -pip install --upgrade 'netbox-custom-objects>=0.5.1' -``` - -Then restart NetBox. The entire delete-bug class disappears regardless -of this plugin's state — no plugin-side change required. - -Several adjacent fixes also landed in upstream `main` post-0.5.0 and -will ship with `0.5.1`: PR #504 (cross-COT FK fields after restart), -PR #505 (stale through-model FK path_infos on COT regeneration), and -PR #510 (self-referential FK isinstance check). Upgrading once closes -the whole family. - -#### Workarounds if you cannot upgrade yet - -1. **`manage.py shell` direct delete** (recommended for one-off rows). - A freshly-spawned shell process initialises the model cache exactly - once, so the class identity is consistent throughout the session and - the collector's identity-check succeeds: - ```bash - /opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py shell <<'PY' - from netbox_custom_objects.models import CustomObjectType - cot = CustomObjectType.objects.get(slug="") - cot.get_model().objects.filter(pk=).delete() - PY - ``` -2. **Refresh the typed-tab list page** between Create and per-row Delete. - This worked reliably for non-polymorphic fields on earlier versions - and still often works on 0.5.0, but it is no longer guaranteed — - polymorphic-MultiObject rows can drift the model cache mid-flow. -3. **Restart NetBox.** Clears `_model_cache` outright. Reliable but - heavyweight; use when shell access isn't available. - -#### Why polymorphic fields amplify the bug on 0.5.0 - -`netbox-custom-objects` 0.5.0 introduced `is_polymorphic=True` Object / -MultiObject fields. Each polymorphic Object field adds a -`GenericForeignKey` descriptor and each polymorphic MultiObject field -adds a per-field through model. Django's collector traverses every -related model when collecting deletion dependencies, so each extra -related-model is another opportunity to hit a stale class generation in -`CustomObjectType._model_cache`. Plugin 2.4.0's discovery code walks -those same descriptors to find inbound links (the original goal of -2.4.0), which warms the cache enough that the upstream drift becomes -deterministic rather than intermittent. - -#### Root cause (for the curious) - -Each Custom Object Type backs a dynamically-generated Django model -(`TableModel`), and the class registry can rebuild between requests -(or during a request that touches `get_model(no_cache=True)`). Django's -`Collector` then sees the queryset's model class on one side and a -related-field descriptor's `.to` pointing at a *different copy of the -same class name* on the other — its identity check raises `ValueError`. -PR #501 fixes the symptom by overriding -`CustomObjectDeleteView._get_dependent_objects` to filter through-table -entries out of the collector's dependency walk before the identity check -runs. This plugin does not override delete or model caching and cannot -patch the bug from its own code. - -#### Cosmetic follow-up on patched builds - -On builds that already contain PR #501, the delete-success toast for -some dynamic models renders as `"Deleted None"` — the -patched view reads `str(obj)` *after* the row's deletion, so the -dynamic model's primary field returns `None`. Models whose `__str__` -captures the display value before delete are unaffected. This is a -cosmetic, post-fix upstream issue; it does not affect the delete -itself. +At startup (`PluginConfig.ready()`), the plugin reads every `CustomObjectTypeField` of type +Object / Multi-Object (polymorphic ones included), groups them by +(referenced model, Custom Object Type), and registers one `ViewTab` view per pair on each model +listed in `typed_models` using NetBox's `register_model_view`. Each view builds a base queryset +(`Q` per referencing field, OR-ed, `.distinct()`), applies the upstream filterset, and renders a +dynamically built `CustomObjectTable` subclass. + +On Custom Object detail pages, which upstream serves through a single generic URL, the plugin +appends one `//custom-objects-/` route per referencing type to the upstream +URLconf. The route resolves the host type from the slug per request and dispatches to the view +registered for exactly that model, so the correct object loads and the tab highlights as active. ## Support diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 015a6c7..0000000 --- a/TODO.md +++ /dev/null @@ -1,20 +0,0 @@ -# TODO — netbox_custom_objects_tab backlog - -## Truncation count for MULTIOBJECT values - -Currently MULTIOBJECT values truncate at 3 items with a bare `…`. - -**Goal:** show `obj1, obj2, obj3 and N more` where N is the actual remaining count. - -**Implementation notes:** -- `_get_field_value` already fetches up to MAX+1 items to detect overflow. -- To compute N, either: - - Add a `.count()` call on the queryset before slicing, or - - Fetch all items into a list and slice in Python (simpler, acceptable for typical M2M sizes). -- Pass the full count alongside the truncated list in the row tuple, then use it in the template. -- Now the 3dots are not visible enought. Maybve a number of assigned object with some link to filter these objects would be suitable? -- When 3dots are visible, will the q (serach) still find object which are not displayed? (not displayed is does seem bad to me) - ---- - -## Add Updated Screenshot diff --git a/netbox_custom_objects_tab/__init__.py b/netbox_custom_objects_tab/__init__.py index dac5ee6..09f1be1 100644 --- a/netbox_custom_objects_tab/__init__.py +++ b/netbox_custom_objects_tab/__init__.py @@ -1,12 +1,17 @@ +import logging from importlib.metadata import version from netbox.plugins import PluginConfig +logger = logging.getLogger("netbox_custom_objects_tab") + +_REMOVED_SETTINGS = ("combined_models", "combined_label", "combined_weight") + class NetBoxCustomObjectsTabConfig(PluginConfig): name = "netbox_custom_objects_tab" - verbose_name = "Custom Objects Tab" - description = 'Adds a "Custom Objects" tab to NetBox object detail pages' + verbose_name = "Custom Object Type Tabs" + description = "Adds one tab per Custom Object Type to NetBox object detail pages" version = version("netbox-custom-objects-tab") author = "Jan Krupa" author_email = "jan.krupa@cesnet.cz" @@ -14,62 +19,55 @@ class NetBoxCustomObjectsTabConfig(PluginConfig): min_version = "4.5.2" max_version = "4.7.99" default_settings = { - # Per-type tabs: each Custom Object Type gets its own tab (opt-in, empty by default). + # Models that get one tab per Custom Object Type referencing them (opt-in). + # Accepts "app_label.model" or "app_label.*"; "netbox_custom_objects.*" enables + # tabs on Custom Object detail pages themselves (CO -> CO references). "typed_models": [], - # Combined tab: single "Custom Objects" tab showing all types (current behavior). - "combined_models": [ - "dcim.*", - "ipam.*", - "virtualization.*", - "tenancy.*", - ], - # Label shown on the combined tab; override in PLUGINS_CONFIG. - "combined_label": "Custom Objects", - # Tab sort weight for the combined tab. - "combined_weight": 2000, - # Tab sort weight for all typed tabs. + # Tab sort weight shared by all typed tabs. netbox-custom-objects' own combined + # "Custom Objects" tab sits at 2000, so typed tabs render right after it. "typed_weight": 2100, } def ready(self): super().ready() - # Hard gate: require netbox-custom-objects >= 0.6.0. We probe behaviour - # (the `coordinates` field type added in 0.6.0) rather than parsing a - # version string, because forks and pre-release tags can carry any - # version label but either have or lack the feature we actually use. - # Our customobject.html override is a copy of the 0.6.0 stock template - # (Contacts/Config Context tabs, coordinates rendering, owner header), - # which reverses URLs that don't exist on 0.5.x — hence the hard floor. - # Raising ImproperlyConfigured here aborts NetBox startup with a clean, - # named error in the logs — preferable to letting a half-loaded plugin - # NoReverseMatch mid-request. from django.apps import apps + from django.conf import settings from django.core.exceptions import ImproperlyConfigured - # netbox_custom_objects must actually be loaded, not merely installed. - # NetBox skips a plugin whose max_version is below the running release - # (netbox-custom-objects 0.6.0 caps at 4.6.99, so NetBox 4.7 drops it - # with only a warning); importing its models then fails with an opaque - # "isn't in INSTALLED_APPS" RuntimeError at startup. + # netbox_custom_objects must actually be loaded, not merely installed: NetBox + # skips a plugin whose max_version is below the running release, and importing + # its models then fails with an opaque "isn't in INSTALLED_APPS" RuntimeError. if not apps.is_installed("netbox_custom_objects"): raise ImproperlyConfigured( "netbox-custom-objects-tab requires the netbox_custom_objects plugin to be loaded. " - "On NetBox 4.7+ that needs netbox-custom-objects>=0.6.1 (0.6.0 declares max_version 4.6.99 " - "and is skipped by NetBox). Upgrade with: pip install -U 'netbox-custom-objects>=0.6.1'" + "Install/upgrade with: pip install -U 'netboxlabs-netbox-custom-objects>=0.7.0'" ) - from netbox_custom_objects.choices import CustomObjectFieldTypeChoices - - if not hasattr(CustomObjectFieldTypeChoices, "TYPE_COORDINATES"): + # Hard gate: netbox-custom-objects >= 0.7.0. We probe for the related_tabs + # package (new in 0.7.0) rather than parsing a version string, because forks + # and pre-release tags can carry any label. 0.7.0 matters because (a) it ships + # the combined "Custom Objects" tab this plugin used to provide, and (b) its + # customobject.html calls {% plugin_extra_tabs %}, which is how our typed tabs + # reach Custom Object detail pages without a template override. + try: + import netbox_custom_objects.related_tabs.registry # noqa: F401 + except ImportError as exc: raise ImproperlyConfigured( - "netbox-custom-objects-tab 2.5+ requires netbox-custom-objects>=0.6.0. " - "Upgrade with: pip install -U 'netbox-custom-objects>=0.6.0'" + "netbox-custom-objects-tab 3.0+ requires netbox-custom-objects>=0.7.0. " + "Upgrade with: pip install -U 'netboxlabs-netbox-custom-objects>=0.7.0'" + ) from exc + + stale = [k for k in _REMOVED_SETTINGS if k in settings.PLUGINS_CONFIG.get(self.name, {})] + if stale: + logger.warning( + "netbox_custom_objects_tab: %s ignored — the combined 'Custom Objects' tab is built into " + "netbox-custom-objects >= 0.7.0; remove these keys from PLUGINS_CONFIG", + ", ".join(stale), ) - from . import template_override, views + from . import views - template_override.install() views.register_tabs() diff --git a/netbox_custom_objects_tab/template_override.py b/netbox_custom_objects_tab/template_override.py deleted file mode 100644 index 7470cf2..0000000 --- a/netbox_custom_objects_tab/template_override.py +++ /dev/null @@ -1,33 +0,0 @@ -""" -Prepend our templates directory to Django's filesystem loader search path. - -netbox_custom_objects/customobject.html has a hardcoded {% block tabs %} that -does not call {% model_view_tabs object %}. We ship an override that adds the -call. Because our app comes after netbox_custom_objects in INSTALLED_APPS, the -app_directories loader would find the original first. Prepending to engine.dirs -ensures our override is found first by the filesystem loader, before any templates -are cached. -""" - -import logging -import os - -logger = logging.getLogger("netbox_custom_objects_tab") - -_TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates") - - -def install(): - try: - from django.template import engines - - engine = engines["django"].engine - - # engine.dirs is the filesystem loader's search path. - # Prepending here ensures our override is found before the original in - # netbox_custom_objects (which comes earlier in INSTALLED_APPS). - if _TEMPLATES_DIR not in engine.dirs: - engine.dirs = [_TEMPLATES_DIR] + list(engine.dirs) - logger.debug("prepended templates dir to engine.dirs") - except Exception: - logger.exception("netbox_custom_objects_tab: could not install template override") diff --git a/netbox_custom_objects_tab/templates/netbox_custom_objects/customobject.html b/netbox_custom_objects_tab/templates/netbox_custom_objects/customobject.html deleted file mode 100644 index b0a8d64..0000000 --- a/netbox_custom_objects_tab/templates/netbox_custom_objects/customobject.html +++ /dev/null @@ -1,212 +0,0 @@ -{% extends 'generic/object.html' %} -{% load static %} -{% load custom_object_buttons %} -{% load custom_links %} -{% load helpers %} -{% load perms %} -{% load plugins %} -{% load render_table from django_tables2 %} -{% load tabs %} -{% load i18n %} -{% load custom_object_utils %} -{% load custom_object_tab_tags %} - -{% block breadcrumbs %} -
  • - -{% endblock breadcrumbs %} - -{% block object_identifier %} -{{ object|meta:"app_label" }}.{{ object.custom_object_type.slug }}:{{ object.pk }} -{% if object.slug %}({{ object.slug }}){% endif %} -{% endblock object_identifier %} - -{% block title %}{{ object }}{% endblock title %} - -{% block subtitle %} -
    - {% if object.owner %} - {{ object.owner|linkify }} - · - {% endif %} - {% trans "Created" %} {{ object.created|isodatetime:"minutes" }} - {% if object.last_updated %} - · - {% trans "Updated" %} {{ object.last_updated|isodatetime:"minutes" }} - {% endif %} -
    -{% endblock subtitle %} - -{% block controls %} -
    - {% block control-buttons %} - {% block extra_controls %}{% endblock %} - {# Default buttons #} - {% if perms.extras.add_bookmark and object.bookmarks %} - {% custom_object_bookmark_button object %} - {% endif %} - {% if perms.extras.add_subscription and object.subscriptions %} - {% custom_object_subscribe_button object %} - {% endif %} - {% if request.user|can_add:object %} - {% custom_object_clone_button object %} - {% endif %} - {% if request.user|can_change:object %} - {% custom_object_edit_button object %} - {% endif %} - {% if request.user|can_delete:object %} - {% custom_object_delete_button object %} - {% endif %} - {% endblock %} -
    - - {# Custom links #} -
    -
    - {% block custom-links %} - {% custom_links object %} - {% endblock custom-links %} -
    -
    -{% endblock controls %} - -{% block tabs %} - -{% endblock tabs %} - -{% block content %} -
    -
    -
    - - - - - - - - - - {% for group_name, group_fields in field_groups.items %} - {% if group_name %} - - - - {% endif %} - {% for field in group_fields %} - {% with is_visible_in_ui=object|get_field_is_ui_visible:field %} - {% if field.is_single_value and is_visible_in_ui %} - - - - - {% endif %} - {% endwith %} - {% endfor %} - {% endfor %} -
    {% trans "Type" %} - {{ object.custom_object_type|linkify:"display_name" }} -
    {% trans "Last activity" %} - {{ latest_change.time|isodatetime|placeholder }} - {% if latest_change %} -
    {{ latest_change.time|timesince }} {% trans "ago" %}
    - {% endif %} -
    {{ group_name }}
    {{ field }} - {% if field.description %} - - {% endif %} - - {% if field.type == 'coordinates' %} - {% with coords=object|get_field_value:field %} - {% if coords %} - {{ coords }} - {% with map_url=object|get_coordinate_map_url:field %} - {% if map_url %} - - {% trans "Map" %} - - {% endif %} - {% endwith %} - {% else %} - {{ ''|placeholder }} - {% endif %} - {% endwith %} - {% else %} - {% customfield_value field object|get_field_value:field %} - {% endif %} -
    -
    - {% plugin_left_page object %} -
    -
    - {% include 'inc/panels/tags.html' %} - {% plugin_right_page object %} - {% for group_name, group_fields in field_groups.items %} - {% for field in group_fields %} - {% if field.many %} - {% with field_values=object|get_child_relations:field is_visible_in_ui=object|get_field_is_ui_visible:field %} - {% if is_visible_in_ui %} -
    -

    - {% if group_name %}{{ group_name }}: {% endif %}{{ field }} -

    - - {% if field.is_polymorphic %} - - - - - - - {% endif %} - {% for relation in field_values.all %} - - {% if field.is_polymorphic %} - - {% endif %} - - - {% endfor %} -
    {% trans "Type" %}{% trans "Object" %}
    {{ relation|meta:"verbose_name" }}{{ relation|linkify }}
    -
    - {% endif %} - {% endwith %} - {% endif %} - {% endfor %} - {% endfor %} -
    -
    -
    -
    - {% plugin_full_width_page object %} -
    -
    -{% endblock %} diff --git a/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab.html b/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab.html deleted file mode 100644 index 32ebfe6..0000000 --- a/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab.html +++ /dev/null @@ -1,110 +0,0 @@ -{% extends base_template %} -{% load i18n custom_object_buttons perms helpers %} -{% block content %} -
    -
    -
    - {# --- header with search form (never swapped by HTMX) --- #} -
    -

    {% trans "Custom Objects" %}

    -
    -
    - - - {% if q or type_slug or tag_slug or owner_id %} - - - - {% endif %} -
    - {# Type dropdown — only shown when there are 2+ types #} - {% if available_types|length > 1 %} - - {% endif %} - {# Tag dropdown — only shown when any object has tags #} - {% if available_tags %} - - {% endif %} - {# Owner dropdown — only shown when any object has an owner #} - {% if available_owners %} - - {% endif %} - {# Preserve sort state and per_page when submitting the search form #} - {% if sort %}{% endif %} - {% if sort_dir and sort_dir != 'asc' %}{% endif %} - {% if request.GET.per_page %}{% endif %} -
    - {% if request.user.is_authenticated %} - - {% endif %} -
    - {# --- table zone (swapped by HTMX) --- #} - {% include 'netbox_custom_objects_tab/combined/tab_partial.html' %} -
    -
    -
    - {% block modals %} - {{ block.super }} - {% table_config_form tab_table %} - {% endblock modals %} -{% endblock content %} diff --git a/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab_partial.html b/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab_partial.html deleted file mode 100644 index 07881c0..0000000 --- a/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab_partial.html +++ /dev/null @@ -1,138 +0,0 @@ -{% load i18n perms %} -
    - {# --- top paginator --- #} - {% include 'inc/paginator.html' with paginator=paginator page=page_obj placement='top' htmx=True table=htmx_table %} - {# --- table or empty state --- #} - {% if page_rows %} -
    - - - - {% if 'type' in selected_columns %} - - {% endif %} - {% if 'object' in selected_columns %} - - {% endif %} - {% if 'value' in selected_columns %} - - {% endif %} - {% if 'owner' in selected_columns %} - - {% endif %} - {% if 'field' in selected_columns %} - - {% endif %} - {% if 'tags' in selected_columns %} - - {% endif %} - - - - - {% for obj, field, value in page_rows %} - - {% if 'type' in selected_columns %} - - {% endif %} - {% if 'object' in selected_columns %} - - {% endif %} - {% if 'value' in selected_columns %} - - {% endif %} - {% if 'owner' in selected_columns %}{% endif %} - {% if 'field' in selected_columns %}{% endif %} - {% if 'tags' in selected_columns %} - - {% endif %} - - - {% endfor %} - -
    - - {% trans "Type" %} - {% if sort_headers.type.icon %}{% endif %} - - - - {% trans "Object" %} - {% if sort_headers.object.icon %}{% endif %} - - {% trans "Value" %} - - {% trans "Owner" %} - {% if sort_headers.owner.icon %}{% endif %} - - - - {% trans "Field" %} - {% if sort_headers.field.icon %}{% endif %} - - {% trans "Tags" %}
    - {% if request.user|can_view:field.custom_object_type %} - {{ field.custom_object_type }} - {% else %} - {{ field.custom_object_type }} - {% endif %} - - {{ obj }} - - {% if field.type == 'object' %} - {% if value %} - {{ value }} - {% else %} - — - {% endif %} - {% elif field.type == 'multiobject' %} - {% if value %} - {% for related_obj in value|slice:":3" %} - {{ related_obj }} - {% if not forloop.last %},{% endif %} - {% endfor %} - {% if value|length > 3 %}…{% endif %} - {% else %} - — - {% endif %} - {% else %} - — - {% endif %} - {{ obj.owner|linkify|placeholder }}{{ field }} - {% for t in obj.tags.all %} - {% tag t %} - {% if not forloop.last %}{% endif %} - {% empty %} - — - {% endfor %} - - {% if request.user|can_change:obj %} - - Edit - - {% endif %} - {% if request.user|can_delete:obj %} - - Delete - - {% endif %} -
    -
    - {% else %} -
    - {% if q or type_slug or tag_slug or owner_id %} - {% trans "No custom objects match your filters." %} - {% else %} - {% trans "No custom objects are linked to this object." %} - {% endif %} -
    - {% endif %} - {# --- bottom paginator --- #} - {% include 'inc/paginator.html' with paginator=paginator page=page_obj placement='bottom' htmx=True table=htmx_table %} -
    diff --git a/netbox_custom_objects_tab/templatetags/__init__.py b/netbox_custom_objects_tab/templatetags/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/netbox_custom_objects_tab/templatetags/custom_object_tab_tags.py b/netbox_custom_objects_tab/templatetags/custom_object_tab_tags.py deleted file mode 100644 index 4707c3f..0000000 --- a/netbox_custom_objects_tab/templatetags/custom_object_tab_tags.py +++ /dev/null @@ -1,70 +0,0 @@ -from django import template -from django.urls.exceptions import NoReverseMatch -from django.utils.module_loading import import_string -from netbox.registry import registry -from utilities.views import get_action_url - -__all__ = ("plugin_extra_tabs",) - -register = template.Library() - -# NetBox auto-registers ObjectContactsView/ObjectJournalView/ObjectChangeLogView -# for every model that supports them (see netbox/models/features.py). On Custom -# Object detail pages we render those tabs as hardcoded
  • blocks instead, -# because upstream's CustomObjectContactsView/JournalView/ChangeLogView put the -# string "contacts"/"journal"/"changelog" in the template context as the -# active-tab marker, which `model_view_tabs` cannot match against its ViewTab -# object. Filtering them out here prevents duplicate, never-active tabs. -_HARDCODED_TAB_NAMES = frozenset({"contacts", "journal", "changelog"}) - - -@register.inclusion_tag("tabs/model_view_tabs.html", takes_context=True) -def plugin_extra_tabs(context, instance): - """ - Render registered model-view tabs for `instance`, excluding tabs that the - Custom Object detail template already renders by hand (Journal, Changelog). - """ - app_label = instance._meta.app_label - model_name = instance._meta.model_name - user = context["request"].user - tabs = [] - - try: - views = registry["views"][app_label][model_name] - except KeyError: - views = [] - - active_tab = context.get("tab") - # Upstream's CO journal/changelog/contacts/configcontext views put a plain str in - # context["tab"]; only a ViewTab has label/weight (issue #19). - active_key = (getattr(active_tab, "label", None), getattr(active_tab, "weight", None)) - - for config in views: - if config["name"] in _HARDCODED_TAB_NAMES: - continue - view = import_string(config["view"]) if type(config["view"]) is str else config["view"] - if tab := getattr(view, "tab", None): - if tab.permission and not user.has_perm(tab.permission): - continue - if attrs := tab.render(instance): - try: - url = get_action_url(instance, action=config["name"], kwargs={"pk": instance.pk}) - except NoReverseMatch: - continue - tabs.append( - { - "name": config["name"], - "url": url, - "label": attrs["label"], - "badge": attrs["badge"], - "weight": attrs["weight"], - # Identity check first; fall back to (label, weight) because the - # generic CO-page URL (see views._inject_co_urls) is bound to the - # first model's view class, whose ViewTab instance differs from the - # registry entry for the page's actual model. - "is_active": active_tab == tab or active_key == (tab.label, tab.weight), - } - ) - - tabs = sorted(tabs, key=lambda x: x["weight"]) - return {"tabs": tabs} diff --git a/netbox_custom_objects_tab/views/__init__.py b/netbox_custom_objects_tab/views/__init__.py index c2346f7..6783e7a 100644 --- a/netbox_custom_objects_tab/views/__init__.py +++ b/netbox_custom_objects_tab/views/__init__.py @@ -1,11 +1,11 @@ import logging from django.apps import apps +from django.http import Http404 +from django.shortcuts import get_object_or_404 from netbox.plugins import get_plugin_config -from ._co_common import _CUSTOM_OBJECTS_APP -from .combined import register_combined_tabs -from .typed import register_typed_tabs +from .typed import _CUSTOM_OBJECTS_APP, register_typed_tabs logger = logging.getLogger("netbox_custom_objects_tab") @@ -85,6 +85,35 @@ def _resolve_model_labels(labels): return result +def _make_co_dispatcher(action_name): + """ + Return a view function serving ``///`` for + every Custom Object host type. + + One typed-tab action (``custom_objects_``) may be registered on several host + CO models (Type A referencing both Type B and Type C). The URL pattern is shared, so + it cannot be bound to one model's view class: we resolve the host model from the + slug per request and dispatch to the view registered for exactly that model. That + also puts the host's own ``ViewTab`` in the template context, which is what + upstream's ``{% plugin_extra_tabs %}`` compares against to mark the tab active. + """ + from netbox.registry import registry + + def dispatch(request, custom_object_type, pk, **kwargs): + from netbox_custom_objects.models import CustomObjectType + + cot = get_object_or_404(CustomObjectType, slug=custom_object_type) + model_name = cot.get_model()._meta.model_name + entries = registry["views"].get(_CUSTOM_OBJECTS_APP, {}).get(model_name, []) + view_cls = next((e["view"] for e in entries if e["name"] == action_name), None) + if view_cls is None: + raise Http404(f"No '{action_name}' tab registered for {custom_object_type}") + return view_cls.as_view()(request, custom_object_type=custom_object_type, pk=pk, **kwargs) + + dispatch.__name__ = f"{action_name}_co_dispatch" + return dispatch + + def _inject_co_urls(): """ Inject URL patterns for our tab views into netbox_custom_objects.urls. @@ -97,8 +126,7 @@ def _inject_co_urls(): The URL names follow CustomObject._get_viewname(): ``plugins:netbox_custom_objects:customobject_{action}`` - which means we need names like ``customobject_custom_objects`` and - ``customobject_custom_objects_{slug}`` inside netbox_custom_objects.urls. + so each typed tab gets ``customobject_custom_objects_{slug}``. """ try: import netbox_custom_objects.urls as co_urls @@ -107,27 +135,22 @@ def _inject_co_urls(): except ImportError: return - co_app = _CUSTOM_OBJECTS_APP - # Collect all tab view classes our plugin registered for CO dynamic models - # from the global registry, keyed by their action name. - co_views_by_name = {} # action_name -> view_class - for model_name, view_entries in registry["views"].get(co_app, {}).items(): + # Action names of the typed-tab views we registered on CO dynamic models. + action_names = set() + for model_name, view_entries in registry["views"].get(_CUSTOM_OBJECTS_APP, {}).items(): if not model_name.startswith("table"): continue for entry in view_entries: - name = entry["name"] - view_cls = entry["view"] - # Only inject views we registered (combined / typed tab views) - if name.startswith("custom_objects") and name not in co_views_by_name: - co_views_by_name[name] = (entry["path"], view_cls) + if entry["name"].startswith("custom_objects_"): + action_names.add((entry["name"], entry["path"])) existing_names = {p.name for p in co_urls.urlpatterns if hasattr(p, "name") and p.name} - for action_name, (url_path_str, view_cls) in co_views_by_name.items(): + for action_name, url_path_str in sorted(action_names): url_name = f"customobject_{action_name}" if url_name in existing_names: continue full_path = f"//{url_path_str}/" - co_urls.urlpatterns.append(url_path(full_path, view_cls.as_view(), name=url_name)) + co_urls.urlpatterns.append(url_path(full_path, _make_co_dispatcher(action_name), name=url_name)) logger.debug("injected URL pattern '%s'", url_name) @@ -164,7 +187,7 @@ def _deduplicate_registry(): def register_tabs(): """ - Read plugin config and register both combined and typed tabs. + Read plugin config and register the typed (per Custom Object Type) tabs. Called from AppConfig.ready(). All registration must happen synchronously here: NetBox builds each model's @@ -176,33 +199,25 @@ def register_tabs(): Earlier versions deferred typed-tab registration to the first HTTP request (commit 5bf09c3, PR #4) to silence DB-access warnings from Django and - netbox_branching. That broke typed-tab URL routing entirely — the Add-button - feature in 2.3.0 was never reachable on a deployment. See 2.3.0 release notes. + netbox_branching. That broke typed-tab URL routing entirely. See 2.3.0 notes. The ``OperationalError`` / ``ProgrammingError`` safety net inside ``register_typed_tabs`` covers the ``manage.py migrate`` / fresh-DB case. """ try: - combined_labels = get_plugin_config("netbox_custom_objects_tab", "combined_models") - combined_label = get_plugin_config("netbox_custom_objects_tab", "combined_label") - combined_weight = get_plugin_config("netbox_custom_objects_tab", "combined_weight") typed_labels = get_plugin_config("netbox_custom_objects_tab", "typed_models") typed_weight = get_plugin_config("netbox_custom_objects_tab", "typed_weight") except Exception: logger.exception("Could not read netbox_custom_objects_tab plugin config") return - combined_models = [] - if combined_labels: - combined_models = _resolve_model_labels(combined_labels) - register_combined_tabs(combined_models, combined_label, combined_weight) + if not typed_labels: + return - typed_models = [] - if typed_labels: - typed_models = _resolve_model_labels(typed_labels) - register_typed_tabs(typed_models, typed_weight) + typed_models = _resolve_model_labels(typed_labels) + register_typed_tabs(typed_models, typed_weight) - if any(m._meta.app_label == _CUSTOM_OBJECTS_APP for m in combined_models + typed_models): + if any(m._meta.app_label == _CUSTOM_OBJECTS_APP for m in typed_models): _inject_co_urls() _deduplicate_registry() diff --git a/netbox_custom_objects_tab/views/_co_common.py b/netbox_custom_objects_tab/views/_co_common.py deleted file mode 100644 index 5b0a61c..0000000 --- a/netbox_custom_objects_tab/views/_co_common.py +++ /dev/null @@ -1,15 +0,0 @@ -from utilities.views import get_default_template - -_CUSTOM_OBJECTS_APP = "netbox_custom_objects" -# Dynamic CO models use a single shared detail template; per-model templates don't exist. -_CO_BASE_TEMPLATE = "netbox_custom_objects/customobject.html" - - -def _get_base_template(instance): - """Return the correct base_template for an object's detail page.""" - if instance._meta.app_label == _CUSTOM_OBJECTS_APP: - return _CO_BASE_TEMPLATE - # Not every model has an "{app}/{model}.html" detail template (e.g. ipam/vrf.html - # and dcim/macaddress.html don't exist). get_default_template falls back to - # generic/object.html — the same resolution NetBox's Journal/Changelog tabs use. - return get_default_template(instance._meta.model) diff --git a/netbox_custom_objects_tab/views/combined.py b/netbox_custom_objects_tab/views/combined.py deleted file mode 100644 index 78e9c92..0000000 --- a/netbox_custom_objects_tab/views/combined.py +++ /dev/null @@ -1,396 +0,0 @@ -import logging -from types import SimpleNamespace -from urllib.parse import urlencode - -import django_tables2 as tables2 -from django.apps import apps -from django.contrib.contenttypes.models import ContentType -from django.core.paginator import InvalidPage -from django.shortcuts import get_object_or_404, render -from django.utils.translation import gettext_lazy as _ -from django.views.generic import View -from extras.choices import CustomFieldTypeChoices -from netbox.tables import BaseTable -from netbox_custom_objects.models import CustomObjectTypeField -from utilities.htmx import htmx_partial -from utilities.paginator import EnhancedPaginator, get_paginate_count -from utilities.views import ViewTab, register_model_view - -from ._co_common import _CUSTOM_OBJECTS_APP, _get_base_template - -logger = logging.getLogger("netbox_custom_objects_tab") - - -class CustomObjectsTabTable(BaseTable): - """Lightweight table class used only for column-preference machinery.""" - - type = tables2.Column(verbose_name=_("Type"), orderable=False) - object = tables2.Column(verbose_name=_("Object"), orderable=False) - value = tables2.Column(verbose_name=_("Value"), orderable=False) - owner = tables2.Column(verbose_name=_("Owner"), orderable=False) - field = tables2.Column(verbose_name=_("Field"), orderable=False) - tags = tables2.Column(verbose_name=_("Tags"), orderable=False) - actions = tables2.Column(verbose_name="", orderable=False) - - exempt_columns = ("actions",) - - class Meta(BaseTable.Meta): - fields = ("type", "object", "value", "owner", "field", "tags", "actions") - default_columns = ("type", "object", "value", "owner", "field", "tags", "actions") - - -# Maximum number of related objects to show in the Value column for MULTIOBJECT fields. -# One extra is fetched to detect truncation without a COUNT query. -_MAX_MULTIOBJECT_DISPLAY = 3 - - -def _iter_linked_fields(instance): - """ - Yield (field, model, filter_kwargs) for every CO field referencing instance. - - Handles both non-polymorphic fields (single related_object_type FK) and - polymorphic fields (related_object_types M2M + is_polymorphic, introduced - in netbox-custom-objects 0.5.0). Mirrors the query shape in upstream's - CustomObjectLink.left_page so behaviour stays consistent with the - upstream "Custom Objects linking to this object" card. - """ - content_type = ContentType.objects.get_for_model(instance._meta.model) - type_choices = [CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT] - - # is_polymorphic=False keeps the two querysets disjoint — a row with - # related_object_type set AND is_polymorphic=True (a legacy misconfig: - # is_polymorphic is immutable upstream but related_object_type isn't - # nulled when toggled) would otherwise be yielded twice. - non_poly = CustomObjectTypeField.objects.filter( - related_object_type=content_type, - is_polymorphic=False, - type__in=type_choices, - ).select_related("custom_object_type") - - poly = CustomObjectTypeField.objects.filter( - related_object_types=content_type, - is_polymorphic=True, - type__in=type_choices, - ).select_related("custom_object_type") - - for field in list(non_poly) + list(poly): - try: - model = field.custom_object_type.get_model() - except Exception: - logger.exception("Could not get model for CustomObjectType %s", field.custom_object_type_id) - continue - - if field.type == CustomFieldTypeChoices.TYPE_OBJECT: - if field.is_polymorphic: - yield ( - field, - model, - { - f"{field.name}_content_type_id": content_type.id, - f"{field.name}_object_id": instance.pk, - }, - ) - else: - yield field, model, {f"{field.name}_id": instance.pk} - elif field.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - if field.is_polymorphic: - try: - through = apps.get_model(_CUSTOM_OBJECTS_APP, field.through_model_name) - except LookupError: - logger.exception( - "Could not resolve through model %r for polymorphic field %s", - field.through_model_name, - field.pk, - ) - continue - source_ids = through.objects.filter( - content_type_id=content_type.id, - object_id=instance.pk, - ).values("source_id") - yield field, model, {"pk__in": source_ids} - else: - yield field, model, {field.name: instance.pk} - - -def _get_linked_custom_objects(instance): - """ - Return list of (custom_object_instance, CustomObjectTypeField) tuples for all - custom objects that reference this instance via OBJECT or MULTIOBJECT fields. - """ - results = [] - for field, model, filter_kwargs in _iter_linked_fields(instance): - # select_related("owner"): OwnerMixin is on the CustomObject base class - # (netbox-custom-objects 0.6.0+), so every dynamic model has the FK. - for obj in model.objects.filter(**filter_kwargs).select_related("owner").prefetch_related("tags"): - results.append((obj, field)) - return results - - -def _count_linked_custom_objects(instance): - """ - Badge callable for ViewTab. - Uses COUNT(*) per queryset — avoids fetching full object rows on every detail page. - Returns None (not 0) when count is zero so hide_if_empty=True works correctly. - """ - total = 0 - for _field, model, filter_kwargs in _iter_linked_fields(instance): - total += model.objects.filter(**filter_kwargs).count() - return total if total > 0 else None - - -def _filter_linked_objects(linked, q): - """ - Case-insensitive substring search across the object display name, - custom object type name, and field label. - """ - q = q.strip().lower() - if not q: - return linked - return [ - (obj, field) - for obj, field in linked - if q in str(obj).lower() or q in str(field.custom_object_type).lower() or q in str(field).lower() - ] - - -def _get_field_value(obj, field): - """ - Return the value stored in `field` on `obj`, for display in the Value column. - - TYPE_OBJECT → the related model instance (or None if unset) - TYPE_MULTIOBJECT → list of related instances, up to _MAX_MULTIOBJECT_DISPLAY+1 - (the extra item lets the template detect truncation without a - separate COUNT query) - """ - if field.type == CustomFieldTypeChoices.TYPE_OBJECT: - return getattr(obj, field.name, None) - elif field.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - qs = getattr(obj, field.name, None) - if qs is None: - return [] - return list(qs.all()[: _MAX_MULTIOBJECT_DISPLAY + 1]) - return None - - -# Sort key lambdas keyed by the ?sort= query parameter value. -_SORT_KEYS = { - "type": lambda t: str(t[1].custom_object_type).lower(), - "object": lambda t: str(t[0]).lower(), - "owner": lambda t: str(t[0].owner or "").lower(), - "field": lambda t: str(t[1]).lower(), -} - - -def _sort_header(sort_base, col, current_sort, current_dir): - """ - Build the URL and directional icon for a sortable column header. - - Returns a dict with keys: - url – the href value for the tag - icon – MDI icon name (arrow-up / arrow-down) when this column is active, - or None when it is not the active sort column - """ - if current_sort == col: - next_dir = "desc" if current_dir == "asc" else "asc" - icon = "arrow-up" if current_dir == "asc" else "arrow-down" - else: - next_dir = "asc" - icon = None - - qs = f"{sort_base}&sort={col}&dir={next_dir}" if sort_base else f"sort={col}&dir={next_dir}" - return {"url": f"?{qs}", "icon": icon} - - -def _make_tab_view(model_class, label="Custom Objects", weight=2000): - """ - Factory that returns a unique View subclass for model_class. - Each model needs its own class so that NetBox's view registry stores - separate entries and URL names do not collide. - """ - - class _TabView(View): - tab = ViewTab( - label=label, - badge=_count_linked_custom_objects, - weight=weight, - hide_if_empty=True, - ) - - def get(self, request, pk, **kwargs): - actual_model = model_class - co_slug = kwargs.get("custom_object_type") - if co_slug and model_class._meta.app_label == _CUSTOM_OBJECTS_APP: - from netbox_custom_objects.models import CustomObjectType - - cot = get_object_or_404(CustomObjectType, slug=co_slug) - actual_model = cot.get_model() - try: - qs = actual_model.objects.restrict(request.user, "view") - except AttributeError: - qs = actual_model.objects.all() - - instance = get_object_or_404(qs, pk=pk) - linked_all = _get_linked_custom_objects(instance) - - # Build table object for column-preference machinery (no data, just column config) - tab_table = CustomObjectsTabTable([], empty_text="") - visible_cols = None - if request.user.is_authenticated and (userconfig := getattr(request.user, "config", None)): - visible_cols = userconfig.get(f"tables.{tab_table.name}.columns") - if visible_cols is None: - visible_cols = list(CustomObjectsTabTable.Meta.default_columns) - tab_table._set_columns(visible_cols) - selected_columns = {col for col, _ in tab_table.selected_columns} | set(tab_table.exempt_columns) - - # Collect unique types for the dropdown (always from the unfiltered list) - seen_type_pks = set() - available_types = [] - for _obj, field in linked_all: - cot = field.custom_object_type - if cot.pk not in seen_type_pks: - seen_type_pks.add(cot.pk) - available_types.append(cot) - available_types.sort(key=lambda t: str(t)) - - # Read filter/sort params - q = request.GET.get("q", "") - type_slug = request.GET.get("type", "") - tag_slug = request.GET.get("tag", "").strip() - owner_id = request.GET.get("owner", "").strip() - sort_col = request.GET.get("sort", "") - sort_dir = request.GET.get("dir", "asc") - per_page = request.GET.get("per_page", "") - - # Collect unique tags for the dropdown (always from the unfiltered list) - seen_tag_slugs = set() - available_tags = [] - for _obj, _field in linked_all: - for t in _obj.tags.all(): - if t.slug not in seen_tag_slugs: - seen_tag_slugs.add(t.slug) - available_tags.append(t) - available_tags.sort(key=lambda t: t.name.lower()) - - # Collect unique owners for the dropdown (always from the unfiltered list) - seen_owner_pks = set() - available_owners = [] - for _obj, _field in linked_all: - if (owner := _obj.owner) and owner.pk not in seen_owner_pks: - seen_owner_pks.add(owner.pk) - available_owners.append(owner) - available_owners.sort(key=lambda o: str(o).lower()) - - # Apply filters - linked = _filter_linked_objects(linked_all, q) - if type_slug: - linked = [(obj, field) for obj, field in linked if field.custom_object_type.slug == type_slug] - if tag_slug: - linked = [(obj, field) for obj, field in linked if tag_slug in {t.slug for t in obj.tags.all()}] - if owner_id: - linked = [(obj, field) for obj, field in linked if str(obj.owner_id or "") == owner_id] - - # In-memory sort (applied after filters, before pagination) - if sort_col in _SORT_KEYS: - linked.sort(key=_SORT_KEYS[sort_col], reverse=(sort_dir == "desc")) - - # Pagination - paginator = EnhancedPaginator(linked, get_paginate_count(request)) - try: - page = paginator.page(int(request.GET.get("page", 1))) - except (InvalidPage, ValueError): - page = paginator.page(1) - - # Resolve field values for just the current page (avoids N+1 on full list) - page_rows = [(obj, field, _get_field_value(obj, field)) for obj, field in page.object_list] - - # Build the base query string (without sort/dir) for column sort links - base_params = {} - if q: - base_params["q"] = q - if type_slug: - base_params["type"] = type_slug - if tag_slug: - base_params["tag"] = tag_slug - if owner_id: - base_params["owner"] = owner_id - if per_page: - base_params["per_page"] = per_page - sort_base = urlencode(base_params) - - sort_headers = { - col: _sort_header(sort_base, col, sort_col, sort_dir) for col in ("type", "object", "owner", "field") - } - - context = { - "object": instance, - "tab": self.tab, - # base_template must match the parent model's detail template - # so that tabs, breadcrumbs, and the page header render correctly. - "base_template": _get_base_template(instance), - "page_obj": page, - "paginator": paginator, - "page_rows": page_rows, - "q": q, - "type_slug": type_slug, - "tag_slug": tag_slug, - "owner_id": owner_id, - "available_types": available_types, - "available_tags": available_tags, - "available_owners": available_owners, - "sort": sort_col, - "sort_dir": sort_dir, - "sort_headers": sort_headers, - "htmx_table": SimpleNamespace(htmx_url=request.path, embedded=False), - "return_url": request.get_full_path(), - "tab_table": tab_table, - "selected_columns": selected_columns, - } - - if htmx_partial(request): - return render( - request, - "netbox_custom_objects_tab/combined/tab_partial.html", - context, - ) - return render( - request, - "netbox_custom_objects_tab/combined/tab.html", - context, - ) - - _TabView.__name__ = f"{model_class.__name__}CustomObjectsTabView" - _TabView.__qualname__ = f"{model_class.__name__}CustomObjectsTabView" - return _TabView - - -def register_combined_tabs(model_classes, label, weight): - """ - Register a combined Custom Objects tab view for each model in the list. - """ - from netbox.registry import registry - - for model_class in model_classes: - app_label = model_class._meta.app_label - model_name = model_class._meta.model_name - - # Skip if already registered (idempotent — guards against reloader re-runs). - existing = registry["views"].get(app_label, {}).get(model_name, []) - if any(e["name"] == "custom_objects" for e in existing): - logger.debug( - "combined tab already registered for %s.%s — skipping", - app_label, - model_name, - ) - continue - - view_class = _make_tab_view(model_class, label=label, weight=weight) - register_model_view( - model_class, - name="custom_objects", - path="custom-objects", - )(view_class) - logger.debug( - "registered combined tab for %s.%s", - app_label, - model_name, - ) diff --git a/netbox_custom_objects_tab/views/typed.py b/netbox_custom_objects_tab/views/typed.py index 5bc2020..2f5597f 100644 --- a/netbox_custom_objects_tab/views/typed.py +++ b/netbox_custom_objects_tab/views/typed.py @@ -16,12 +16,24 @@ from netbox_custom_objects.filtersets import get_filterset_class from netbox_custom_objects.models import CustomObjectTypeField from netbox_custom_objects.tables import CustomObjectTable -from utilities.views import ViewTab, register_model_view - -from ._co_common import _CO_BASE_TEMPLATE, _CUSTOM_OBJECTS_APP, _get_base_template # noqa: F401 +from utilities.views import ViewTab, get_default_template, register_model_view logger = logging.getLogger("netbox_custom_objects_tab") +_CUSTOM_OBJECTS_APP = "netbox_custom_objects" +# Dynamic CO models use a single shared detail template; per-model templates don't exist. +_CO_BASE_TEMPLATE = "netbox_custom_objects/customobject.html" + + +def _get_base_template(instance): + """Return the correct base_template for an object's detail page.""" + if instance._meta.app_label == _CUSTOM_OBJECTS_APP: + return _CO_BASE_TEMPLATE + # Not every model has an "{app}/{model}.html" detail template (e.g. ipam/vrf.html + # and dcim/macaddress.html don't exist). get_default_template falls back to + # generic/object.html — the same resolution NetBox's Journal/Changelog tabs use. + return get_default_template(instance._meta.model) + def _build_q_for_field(host_ct_id, instance_pk, field_info): """ diff --git a/pyproject.toml b/pyproject.toml index e0123fe..163f7c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-custom-objects-tab" -version = "2.6.1" -description = "NetBox plugin that adds a Custom Objects tab to object detail pages" +version = "3.0.0" +description = "NetBox plugin that adds one tab per Custom Object Type to object detail pages" readme = "README.md" requires-python = ">=3.12" license = "Apache-2.0" diff --git a/tests/conftest.py b/tests/conftest.py index a8d5aa5..c02e426 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -60,9 +60,6 @@ class _CustomFieldUIVisibleChoices: _mock("netbox") _mock("netbox.registry", registry={"views": {}}) _mock("netbox.plugins", PluginConfig=type("PluginConfig", (), {}), get_plugin_config=MagicMock(return_value=[])) -_NetBoxModelFilterSetForm = type("NetBoxModelFilterSetForm", (), {}) -_mock("netbox.forms", NetBoxModelFilterSetForm=_NetBoxModelFilterSetForm) -_mock("netbox.forms.mixins", SavedFiltersMixin=type("SavedFiltersMixin", (), {})) # --- extras.* --- _mock("extras") @@ -83,10 +80,6 @@ class _CustomFieldUIVisibleChoices: get_action_url=MagicMock(return_value="/x/"), get_default_template=MagicMock(side_effect=lambda model: f"{model._meta.app_label}/{model._meta.model_name}.html"), ) -_mock("utilities.paginator", EnhancedPaginator=MagicMock(), get_paginate_count=MagicMock()) -_mock("utilities.htmx", htmx_partial=MagicMock()) -_mock("utilities.forms") -_mock("utilities.forms.fields", TagFilterField=MagicMock()) _mock( "utilities.permissions", get_permission_for_model=MagicMock(return_value="netbox_custom_objects.add_customobject"), @@ -134,13 +127,11 @@ def _set_columns(self, selected_columns): # --- netbox_custom_objects.* --- _mock("netbox_custom_objects") -_mock("netbox_custom_objects.models", CustomObjectTypeField=MagicMock()) +_mock("netbox_custom_objects.models", CustomObjectTypeField=MagicMock(), CustomObjectType=MagicMock()) +_mock("netbox_custom_objects.related_tabs") +_mock("netbox_custom_objects.related_tabs.registry", register_tabs=MagicMock()) _mock("netbox_custom_objects.field_types", FIELD_TYPE_CLASS={}) _mock("netbox_custom_objects.filtersets", get_filterset_class=MagicMock()) _mock("netbox_custom_objects.dynamic_forms", build_filterset_form_class=MagicMock()) -_mock( - "netbox_custom_objects.choices", - CustomObjectFieldTypeChoices=MagicMock(TYPE_COORDINATES="coordinates"), -) _CustomObjectTable = type("CustomObjectTable", (), {}) _mock("netbox_custom_objects.tables", CustomObjectTable=_CustomObjectTable) diff --git a/tests/test_plugin_config.py b/tests/test_plugin_config.py new file mode 100644 index 0000000..fce91ca --- /dev/null +++ b/tests/test_plugin_config.py @@ -0,0 +1,66 @@ +""" +Startup gate in NetBoxCustomObjectsTabConfig.ready(): requires netbox-custom-objects >= 0.7.0 +(probed via the related_tabs package) and warns about removed combined_* settings. +""" + +import logging +import sys +from unittest.mock import MagicMock, patch + +import pytest +from django.core.exceptions import ImproperlyConfigured +from django.test import override_settings + + +def _config(): + from netbox.plugins import PluginConfig + + from netbox_custom_objects_tab import NetBoxCustomObjectsTabConfig + + PluginConfig.ready = lambda self: None + return NetBoxCustomObjectsTabConfig.__new__(NetBoxCustomObjectsTabConfig) + + +@override_settings(PLUGINS_CONFIG={}) +def test_ready_registers_tabs_on_0_7(): + from netbox_custom_objects_tab import views + + with ( + patch("django.apps.apps.is_installed", return_value=True), + patch.object(views, "register_tabs") as register_tabs, + ): + _config().ready() + + register_tabs.assert_called_once() + + +@override_settings(PLUGINS_CONFIG={}) +def test_ready_raises_when_related_tabs_missing(): + # A None entry in sys.modules makes `import x` raise ImportError — simulates 0.6.x. + with ( + patch("django.apps.apps.is_installed", return_value=True), + patch.dict(sys.modules, {"netbox_custom_objects.related_tabs.registry": None}), + ): + with pytest.raises(ImproperlyConfigured, match="0.7.0"): + _config().ready() + + +@override_settings(PLUGINS_CONFIG={}) +def test_ready_raises_when_upstream_not_loaded(): + with patch("django.apps.apps.is_installed", return_value=False): + with pytest.raises(ImproperlyConfigured, match="netbox_custom_objects plugin"): + _config().ready() + + +@override_settings(PLUGINS_CONFIG={"netbox_custom_objects_tab": {"combined_models": ["dcim.*"], "typed_models": []}}) +def test_ready_warns_about_removed_combined_settings(caplog): + from netbox_custom_objects_tab import views + + with ( + patch("django.apps.apps.is_installed", return_value=True), + patch.object(views, "register_tabs", MagicMock()), + caplog.at_level(logging.WARNING, logger="netbox_custom_objects_tab"), + ): + _config().ready() + + assert any("combined_models" in r.message for r in caplog.records) diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py deleted file mode 100644 index 621e345..0000000 --- a/tests/test_templatetags.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -Unit tests for the plugin_extra_tabs template tag. -""" - -from types import SimpleNamespace -from unittest.mock import patch - -import pytest - - -def _tab(label, weight): - return SimpleNamespace( - label=label, weight=weight, permission=None, render=lambda obj: {"label": label, "badge": 3, "weight": weight} - ) - - -def _render(active_tab, registered_tab, name="custom_objects"): - from netbox_custom_objects_tab.templatetags import custom_object_tab_tags as tags - - instance = SimpleNamespace( - _meta=SimpleNamespace(app_label="netbox_custom_objects", model_name="table149model"), pk=1 - ) - view = SimpleNamespace(tab=registered_tab) - registry = {"views": {"netbox_custom_objects": {"table149model": [{"name": name, "view": view}]}}} - context = {"request": SimpleNamespace(user=SimpleNamespace(has_perm=lambda p: True)), "tab": active_tab} - with patch.object(tags, "registry", registry), patch.object(tags, "get_action_url", return_value="/x/"): - return tags.plugin_extra_tabs(context, instance)["tabs"] - - -def test_same_instance_is_active(): - tab = _tab("Custom Objects", 2000) - assert _render(tab, tab)[0]["is_active"] is True - - -def test_equal_label_and_weight_but_different_instance_is_active(): - # Generic CO-page URL serves another model's view class -> different ViewTab object. - assert _render(_tab("Custom Objects", 2000), _tab("Custom Objects", 2000))[0]["is_active"] is True - - -def test_different_label_is_not_active(): - assert _render(_tab("Service", 2100), _tab("Custom Objects", 2000))[0]["is_active"] is False - - -def test_no_active_tab_in_context(): - assert _render(None, _tab("Custom Objects", 2000))[0]["is_active"] is False - - -@pytest.mark.parametrize("marker", ["journal", "changelog", "contacts", "configcontext"]) -def test_string_active_tab_marker_is_not_active(marker): - # Upstream CO journal/changelog/contacts/configcontext views set context["tab"] to a str (#19). - assert _render(marker, _tab("Custom Objects", 2000))[0]["is_active"] is False - - -@pytest.mark.parametrize("name", ["contacts", "journal", "changelog"]) -def test_hardcoded_tab_names_are_skipped(name): - # NetBox auto-registers these; the CO template renders them by hand, so the tag must not duplicate them. - assert _render(None, _tab("Contacts", 5000), name=name) == [] diff --git a/tests/test_views_combined.py b/tests/test_views_combined.py deleted file mode 100644 index e4b1424..0000000 --- a/tests/test_views_combined.py +++ /dev/null @@ -1,314 +0,0 @@ -""" -Unit tests for netbox_custom_objects_tab.views.combined helpers. -""" - -from unittest.mock import MagicMock, patch - -import pytest -from extras.choices import CustomFieldTypeChoices - - -def _make_pair(obj_str, type_str, field_str): - """Return a mock (custom_object_instance, field) tuple.""" - obj = MagicMock() - obj.__str__ = lambda self: obj_str - - cot = MagicMock() - cot.__str__ = lambda self: type_str - - field = MagicMock() - field.__str__ = lambda self: field_str - field.custom_object_type = cot - - return (obj, field) - - -class TestFilterLinkedObjects: - """Pure-Python filter helper — no DB or Django state required.""" - - @pytest.fixture(autouse=True) - def get_fn(self): - from netbox_custom_objects_tab.views.combined import _filter_linked_objects - - self.fn = _filter_linked_objects - - def test_empty_query_returns_same_list(self): - linked = [_make_pair("Device A", "Server", "dev_field")] - assert self.fn(linked, "") is linked - - def test_whitespace_only_returns_same_list(self): - linked = [_make_pair("Device A", "Server", "dev_field")] - assert self.fn(linked, " ") is linked - - def test_no_match_returns_empty(self): - linked = [ - _make_pair("Device A", "Server", "dev_field"), - _make_pair("Device B", "Router", "net_field"), - ] - assert self.fn(linked, "zzznomatch") == [] - - def test_match_on_object_str(self): - linked = [ - _make_pair("Device Alpha", "Server", "dev_field"), - _make_pair("Device Beta", "Router", "dev_field"), - ] - result = self.fn(linked, "alpha") - assert len(result) == 1 - assert result[0][0] is linked[0][0] - - def test_match_on_type_str(self): - linked = [ - _make_pair("Device A", "ServerType", "dev_field"), - _make_pair("Device B", "RouterType", "dev_field"), - ] - result = self.fn(linked, "router") - assert len(result) == 1 - assert result[0][0] is linked[1][0] - - def test_match_on_field_str(self): - linked = [ - _make_pair("Device A", "Server", "primary_device_field"), - _make_pair("Device B", "Router", "network_interface_field"), - ] - result = self.fn(linked, "network") - assert len(result) == 1 - assert result[0][0] is linked[1][0] - - def test_case_insensitive(self): - linked = [_make_pair("Device UPPERCASE", "Server", "dev_field")] - assert self.fn(linked, "uppercase") != [] - assert self.fn(linked, "UPPERCASE") != [] - assert self.fn(linked, "UpperCase") != [] - - def test_leading_trailing_whitespace_stripped(self): - linked = [_make_pair("Device A", "Server", "dev_field")] - assert len(self.fn(linked, " device ")) == 1 - - -class TestCountLinkedCustomObjects: - """Badge callable must return None (not 0) when nothing is linked.""" - - def _count(self, fields_and_counts): - """ - Build mock fields, patch query dependencies, then call the count function. - - fields_and_counts: list of (field_type_value, count_int) - """ - mock_fields = [] - for field_type, count in fields_and_counts: - model = MagicMock() - model.objects.filter.return_value.count.return_value = count - - cot = MagicMock() - cot.get_model.return_value = model - - field = MagicMock() - field.type = field_type - field.custom_object_type = cot - field.name = "some_field" - mock_fields.append(field) - - instance = MagicMock() - instance.pk = 1 - instance._meta.model = MagicMock() - - with ( - patch("netbox_custom_objects_tab.views.combined.CustomObjectTypeField") as mock_cotf, - patch("netbox_custom_objects_tab.views.combined.ContentType") as mock_ct, - ): - mock_ct.objects.get_for_model.return_value = MagicMock() - # _iter_linked_fields makes two separate filter() calls (non-poly FK, - # then poly M2M). Configure them as distinct querysets so the second - # one returns no fields and we don't double-count. - non_poly_qs = MagicMock() - non_poly_qs.select_related.return_value = mock_fields - poly_qs = MagicMock() - poly_qs.select_related.return_value = [] - mock_cotf.objects.filter.side_effect = [non_poly_qs, poly_qs] - - from netbox_custom_objects_tab.views.combined import _count_linked_custom_objects - - return _count_linked_custom_objects(instance) - - def test_returns_none_when_no_fields(self): - assert self._count([]) is None - - def test_returns_none_not_zero(self): - result = self._count([]) - assert result is None - assert result != 0 - - def test_returns_total_when_positive(self): - result = self._count( - [ - (CustomFieldTypeChoices.TYPE_OBJECT, 3), - (CustomFieldTypeChoices.TYPE_OBJECT, 2), - ] - ) - assert result == 5 - - def test_returns_none_when_all_counts_are_zero(self): - result = self._count( - [ - (CustomFieldTypeChoices.TYPE_OBJECT, 0), - (CustomFieldTypeChoices.TYPE_MULTIOBJECT, 0), - ] - ) - assert result is None - - -class TestCustomObjectsTabTable: - """Column-preference machinery on the lightweight table class.""" - - @pytest.fixture(autouse=True) - def table_cls(self): - from netbox_custom_objects_tab.views.combined import CustomObjectsTabTable - - self.cls = CustomObjectsTabTable - - def test_default_columns_contains_all_seven(self): - assert set(self.cls.Meta.default_columns) == {"type", "object", "value", "owner", "field", "tags", "actions"} - - def test_actions_is_exempt(self): - assert "actions" in self.cls.exempt_columns - - def test_name_property(self): - t = self.cls([], empty_text="") - assert t.name == "CustomObjectsTabTable" - - def test_all_columns_visible_by_default(self): - t = self.cls([], empty_text="") - t._set_columns(list(self.cls.Meta.default_columns)) - visible = {col for col, _ in t.selected_columns} - assert {"type", "object", "value", "field", "tags"}.issubset(visible) - - def test_hidden_column_not_in_selected(self): - t = self.cls([], empty_text="") - cols_without_value = [c for c in self.cls.Meta.default_columns if c != "value"] - t._set_columns(cols_without_value) - visible = {col for col, _ in t.selected_columns} - assert "value" not in visible - - def test_exempt_column_always_visible(self): - t = self.cls([], empty_text="") - t._set_columns(["type"]) - selected_names = {col for col, _ in t.selected_columns} - assert "actions" not in selected_names - - -class TestSortHeader: - @pytest.fixture(autouse=True) - def get_fn(self): - from netbox_custom_objects_tab.views.combined import _sort_header - - self.fn = _sort_header - - def test_inactive_column_points_to_asc(self): - result = self.fn("", "type", "object", "asc") - assert "sort=type" in result["url"] - assert "dir=asc" in result["url"] - assert result["icon"] is None - - def test_active_asc_column_icon_is_arrow_up(self): - result = self.fn("", "type", "type", "asc") - assert result["icon"] == "arrow-up" - assert "dir=desc" in result["url"] - - def test_active_desc_column_icon_is_arrow_down(self): - result = self.fn("", "type", "type", "desc") - assert result["icon"] == "arrow-down" - assert "dir=asc" in result["url"] - - def test_base_params_preserved(self): - result = self.fn("q=foo&tag=bar", "type", "", "asc") - assert result["url"].startswith("?q=foo&tag=bar&") - - -# --------------------------------------------------------------------------- -# _get_field_value -# --------------------------------------------------------------------------- -class TestGetFieldValue: - @pytest.fixture(autouse=True) - def get_fn(self): - from netbox_custom_objects_tab.views.combined import _get_field_value - - self.fn = _get_field_value - - def test_type_object_returns_getattr(self): - obj = MagicMock() - obj.device_ref = MagicMock(name="Device-1") - field = MagicMock() - field.type = CustomFieldTypeChoices.TYPE_OBJECT - field.name = "device_ref" - - result = self.fn(obj, field) - assert result is obj.device_ref - - def test_type_multiobject_returns_sliced_list(self): - from netbox_custom_objects_tab.views.combined import _MAX_MULTIOBJECT_DISPLAY - - related = [MagicMock() for _ in range(_MAX_MULTIOBJECT_DISPLAY + 2)] - qs = MagicMock() - qs.all.return_value.__getitem__ = lambda self, s: related[: s.stop] - - obj = MagicMock() - obj.multi_ref = qs - field = MagicMock() - field.type = CustomFieldTypeChoices.TYPE_MULTIOBJECT - field.name = "multi_ref" - - result = self.fn(obj, field) - assert isinstance(result, list) - assert len(result) == _MAX_MULTIOBJECT_DISPLAY + 1 - - def test_type_multiobject_none_qs_returns_empty(self): - obj = MagicMock(spec=[]) # no attributes - field = MagicMock() - field.type = CustomFieldTypeChoices.TYPE_MULTIOBJECT - field.name = "missing_ref" - - result = self.fn(obj, field) - assert result == [] - - def test_unknown_field_type_returns_none(self): - obj = MagicMock() - field = MagicMock() - field.type = "unknown_type" - field.name = "whatever" - - result = self.fn(obj, field) - assert result is None - - -# --------------------------------------------------------------------------- -# register_combined_tabs -# --------------------------------------------------------------------------- -class TestRegisterCombinedTabs: - def test_register_called_once_per_model(self): - from netbox_custom_objects_tab.views.combined import register_combined_tabs - - m1 = MagicMock() - m1.__name__ = "Device" - m1._meta.app_label = "dcim" - m1._meta.model_name = "device" - m2 = MagicMock() - m2.__name__ = "Site" - m2._meta.app_label = "dcim" - m2._meta.model_name = "site" - - with patch("netbox_custom_objects_tab.views.combined.register_model_view") as mock_register: - mock_register.return_value = lambda cls: cls - register_combined_tabs([m1, m2], "Custom Objects", 2000) - - assert mock_register.call_count == 2 - - def test_view_class_name_matches_model(self): - from netbox_custom_objects_tab.views.combined import _make_tab_view - - model = MagicMock() - model.__name__ = "Device" - model._meta.app_label = "dcim" - model._meta.model_name = "device" - - view_cls = _make_tab_view(model) - assert view_cls.__name__ == "DeviceCustomObjectsTabView" diff --git a/tests/test_views_init.py b/tests/test_views_init.py index 1dac8d7..6767b05 100644 --- a/tests/test_views_init.py +++ b/tests/test_views_init.py @@ -54,61 +54,58 @@ def test_unknown_specific_model_logs_warning_no_exception(self, caplog): class TestRegisterTabs: - def test_dispatches_combined_and_typed_tabs_synchronously(self): - """register_tabs() registers BOTH combined and typed tabs synchronously - in ready(). Registration must be synchronous because NetBox builds each - model's URLconf on the first resolve() call by snapshotting - registry['views']; anything added after that has no URL pattern. - See 2.3.0 fix. + def test_registers_typed_tabs_synchronously(self): + """register_tabs() registers typed tabs synchronously in ready(). + Registration must be synchronous because NetBox builds each model's + URLconf on the first resolve() call by snapshotting registry['views']; + anything added after that has no URL pattern. See 2.3.0 fix. """ from netbox_custom_objects_tab import views - combined_models = [MagicMock()] typed_models = [MagicMock()] - combined_models[0]._meta.app_label = "dcim" typed_models[0]._meta.app_label = "ipam" + config_map = {"typed_models": ["ipam.prefix"], "typed_weight": 2100} - config_map = { - "combined_models": ["dcim.device"], - "combined_label": "Custom Objects", - "combined_weight": 2000, - "typed_models": ["ipam.prefix"], - "typed_weight": 2100, - } - - # _resolve_model_labels is called twice (combined, then typed); return - # different lists for each call. with ( patch.object(views, "get_plugin_config", side_effect=lambda _plugin, key: config_map[key]), - patch.object(views, "_resolve_model_labels", side_effect=[combined_models, typed_models]), - patch.object(views, "register_combined_tabs") as register_combined, + patch.object(views, "_resolve_model_labels", return_value=typed_models), patch.object(views, "register_typed_tabs") as register_typed, patch.object(views, "_inject_co_urls") as inject_co_urls, patch.object(views, "_deduplicate_registry") as dedup, ): views.register_tabs() - register_combined.assert_called_once_with(combined_models, "Custom Objects", 2000) register_typed.assert_called_once_with(typed_models, 2100) - # No CO models in either list → CO URL injection skipped. + # No CO models → CO URL injection skipped. inject_co_urls.assert_not_called() dedup.assert_called_once() - def test_skips_dispatch_when_configured_model_lists_are_empty(self): + def test_injects_co_urls_when_a_custom_object_model_is_configured(self): from netbox_custom_objects_tab import views - config_map = { - "combined_models": [], - "combined_label": "Custom Objects", - "combined_weight": 2000, - "typed_models": [], - "typed_weight": 2100, - } + co_model = MagicMock() + co_model._meta.app_label = "netbox_custom_objects" + config_map = {"typed_models": ["netbox_custom_objects.*"], "typed_weight": 2100} + + with ( + patch.object(views, "get_plugin_config", side_effect=lambda _plugin, key: config_map[key]), + patch.object(views, "_resolve_model_labels", return_value=[co_model]), + patch.object(views, "register_typed_tabs"), + patch.object(views, "_inject_co_urls") as inject_co_urls, + patch.object(views, "_deduplicate_registry"), + ): + views.register_tabs() + + inject_co_urls.assert_called_once() + + def test_skips_dispatch_when_typed_models_is_empty(self): + from netbox_custom_objects_tab import views + + config_map = {"typed_models": [], "typed_weight": 2100} with ( patch.object(views, "get_plugin_config", side_effect=lambda _plugin, key: config_map[key]), patch.object(views, "_resolve_model_labels") as resolve_labels, - patch.object(views, "register_combined_tabs") as register_combined, patch.object(views, "register_typed_tabs") as register_typed, patch.object(views, "_inject_co_urls"), patch.object(views, "_deduplicate_registry"), @@ -116,7 +113,6 @@ def test_skips_dispatch_when_configured_model_lists_are_empty(self): views.register_tabs() resolve_labels.assert_not_called() - register_combined.assert_not_called() register_typed.assert_not_called() def test_config_exception_is_handled(self, caplog): @@ -124,10 +120,65 @@ def test_config_exception_is_handled(self, caplog): with ( patch.object(views, "get_plugin_config", side_effect=RuntimeError("boom")), - patch.object(views, "register_combined_tabs") as register_combined, + patch.object(views, "register_typed_tabs") as register_typed, ): with caplog.at_level(logging.ERROR, logger="netbox_custom_objects_tab"): views.register_tabs() - register_combined.assert_not_called() + register_typed.assert_not_called() assert any("Could not read netbox_custom_objects_tab plugin config" in r.message for r in caplog.records) + + +class TestCoDispatcher: + """ + The CO-page URL `//custom-objects-/` is shared by every host + Custom Object model, so it must resolve the host model from the slug per + request and dispatch to the view registered for that model — not to the + first model's view class (which would load the wrong object and put the + wrong ViewTab in the context). + """ + + def test_dispatches_to_view_registered_for_slug_model(self): + from netbox.registry import registry + + from netbox_custom_objects_tab import views + + view_b = MagicMock(name="view_b") + view_c = MagicMock(name="view_c") + registry["views"]["netbox_custom_objects"] = { + "table28model": [{"name": "custom_objects_type-a", "path": "x", "view": view_c}], + "table29model": [{"name": "custom_objects_type-a", "path": "x", "view": view_b}], + } + cot = MagicMock() + cot.get_model.return_value._meta.model_name = "table29model" + request = MagicMock() + + try: + with patch.object(views, "get_object_or_404", return_value=cot): + dispatch = views._make_co_dispatcher("custom_objects_type-a") + response = dispatch(request, custom_object_type="type-b", pk=5) + finally: + registry["views"].pop("netbox_custom_objects", None) + + view_b.as_view.return_value.assert_called_once_with(request, custom_object_type="type-b", pk=5) + view_c.as_view.assert_not_called() + assert response is view_b.as_view.return_value.return_value + + def test_404_when_no_view_registered_for_slug_model(self): + import pytest + from django.http import Http404 + from netbox.registry import registry + + from netbox_custom_objects_tab import views + + registry["views"]["netbox_custom_objects"] = {"table28model": []} + cot = MagicMock() + cot.get_model.return_value._meta.model_name = "table28model" + + try: + with patch.object(views, "get_object_or_404", return_value=cot): + dispatch = views._make_co_dispatcher("custom_objects_type-a") + with pytest.raises(Http404): + dispatch(MagicMock(), custom_object_type="type-b", pk=5) + finally: + registry["views"].pop("netbox_custom_objects", None) diff --git a/tests/test_views_typed_smoke.py b/tests/test_views_typed_smoke.py index f968473..3878b61 100644 --- a/tests/test_views_typed_smoke.py +++ b/tests/test_views_typed_smoke.py @@ -463,7 +463,7 @@ def test_non_co_model_delegates_to_get_default_template(self): assert _get_base_template(instance) == "dcim/device.html" def test_non_co_model_without_detail_template_falls_back_to_generic(self): - from netbox_custom_objects_tab.views import _co_common + from netbox_custom_objects_tab.views import typed as _co_common from netbox_custom_objects_tab.views.typed import _get_base_template instance = self._make_instance("ipam", "vrf")