diff --git a/.gitignore b/.gitignore index 43096db..02d6c4e 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ db.sqlite3 *.swo tools/ + +ignore/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index ac38ff2..a19d425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ 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). +## [2.0.1] - 2026-02-25 + +### Added + +- **Typed tabs (per-type)** — each Custom Object Type gets its own tab with a full-featured + list view: type-specific columns, filterset sidebar, bulk edit/delete, configure table, + and HTMX pagination. +- `typed_models` and `typed_weight` config settings. +- Third-party plugin model support for both tab modes. + +### Changed + +- Renamed `models` config to `combined_models`; `label` to `combined_label`; `weight` to + `combined_weight`. +- Refactored views from single `views.py` to `views/` package (`__init__.py`, `combined.py`, + `typed.py`). +- Templates reorganized into `combined/` and `typed/` subdirectories. + +### Fixed + +- Handle missing database during startup — `register_typed_tabs()` now catches + `OperationalError` and `ProgrammingError` so NetBox can start even when the database + is unavailable or migrations haven't run yet. +- Bulk action return URL in typed tabs — uses query parameter `?return_url=` on `formaction` + for reliable redirect. + ## [1.0.1] - 2026-02-24 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index d12daac..80c326e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,12 +24,17 @@ Always run both before committing Python changes. ## Purpose -Adds a **"Custom Objects"** tab to NetBox object detail pages (Device, Site, Rack, etc.), -showing Custom Object instances from the `netbox_custom_objects` plugin that reference -those objects via OBJECT or MULTIOBJECT typed fields. +Adds **two tab modes** to NetBox object detail pages (Device, Site, Rack, etc.): -The tab supports **pagination** (NetBox `EnhancedPaginator`) and **`?q=` text search** -so it stays usable with large numbers of linked objects. +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. + +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. + +Both modes coexist. Config variables control which models get which behavior. ## Architecture @@ -38,10 +43,42 @@ so it stays usable with large numbers of linked objects. | File | Role | |------|------| | `netbox_custom_objects_tab/__init__.py` | `PluginConfig`; calls `views.register_tabs()` in `ready()` | -| `netbox_custom_objects_tab/views.py` | View factory + `register_tabs()` using `register_model_view` + `ViewTab` | +| `netbox_custom_objects_tab/views/__init__.py` | `register_tabs()` + `_resolve_model_labels()` helper | +| `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/urls.py` | Empty `urlpatterns` (required by NetBox plugin loader) | -| `netbox_custom_objects_tab/templates/netbox_custom_objects_tab/custom_objects_tab.html` | Tab content template (full page, extends base_template) | -| `netbox_custom_objects_tab/templates/netbox_custom_objects_tab/custom_objects_tab_partial.html` | Swappable HTMX zone — returned for HTMX partial requests; no `{% extends %}` | +| `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`) | + +## Config Design + +```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 +} +``` + +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 @@ -65,141 +102,85 @@ Reference: `netbox_custom_objects/template_content.py::CustomObjectLink.left_pag 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 +from extras.choices import CustomFieldTypeChoices, CustomFieldUIVisibleChoices from netbox.plugins import get_plugin_config from utilities.htmx import htmx_partial -from types import SimpleNamespace +from netbox_custom_objects.tables import CustomObjectTable +from netbox_custom_objects import field_types +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 ``` -## Pagination & Filtering Design +## 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 (one extra - query per field) and cached on each object instance — no N+1 cost in the template. + 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 in the view from `linked_all` (unfiltered) by iterating - `_obj.tags.all()` (uses prefetch cache). Deduplicated by slug, sorted by `name.lower()`. - Passed to context as `available_tags`; the active tag filter slug is `tag_slug`. -- **Tag filter** — `tag_slug = request.GET.get('tag', '').strip()`; applied after the type - filter by checking `tag_slug in {t.slug for t in obj.tags.all()}` (cache hit, no query). -- **`EnhancedPaginator(linked, get_paginate_count(request))`** — paginates the filtered list. - `get_paginate_count` respects `?per_page=`, user prefs, and global `PAGINATE_COUNT`. -- **`inc/paginator.html`** — pass `htmx=True table=htmx_table` to emit `hx-get` links. - `htmx_table = SimpleNamespace(htmx_url=request.path, embedded=False)`. - The paginator uses `{% querystring request page=p %}` which copies all current GET params - (including `?tag=`, `?type=`, `?q=`, etc.) so filter state is preserved across pages. -- **`htmx_partial(request)`** — returns `True` when the request carries `HX-Request` and - is not boosted. View returns `custom_objects_tab_partial.html` in that case. -- The partial wraps everything in `
`. - Paginator and sort-header links target this div via `hx-target` / `hx-swap="outerHTML"`. -- Badge count (`_count_linked_custom_objects`) still counts the **unfiltered** total so the - badge reflects all linked objects regardless of active search. -- **`_count_linked_custom_objects`** uses `.count()` (DB-side `COUNT(*)`) per field — - no object rows are fetched. Full rows are loaded only when the tab view itself is - called (`_get_linked_custom_objects`). Verified empirically: on a Device with 2214 - linked custom objects, the detail page (`/dcim/devices//`) runs only COUNT queries; - the full fetch fires only on `/dcim/devices//custom-objects/`. - -## Model Registration - -`register_tabs()` in `views.py` supports two label formats in the `models` config: - -| Format | Behaviour | -|--------|-----------| -| `dcim.device` | Registers the tab for that single model | -| `dcim.*` | Registers the tab for **every model** in the `dcim` app | - -Default: `['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*', 'contacts.*']` - -**Third-party plugin models are fully supported.** Django's `apps.get_app_config()` and -`apps.get_model()` treat plugin apps identically to built-in apps, so any installed plugin's -models work with the same syntax: - -```python -'models': ['dcim.*', 'ipam.*', 'inventory_monitor.*'] -``` - -Verified working with `inventory_monitor` and other third-party NetBox plugins. +- **`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. ## Permission Checks in Template -Action buttons and column links use the `perms` templatetag from `utilities.templatetags.perms`: - -```django -{% load perms %} -{% if request.user|can_change:obj %} -... -{% endif %} -{% if request.user|can_delete:obj %} -... -{% endif %} -{% if request.user|can_view:field.custom_object_type %}...{% endif %} -``` +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()`. -- `can_change`, `can_delete`, `can_view` are template filters that take the user as the - left-hand value and the object instance as the argument. -- Edit and Delete are rendered as **inline `` buttons** (not inclusion tags) so that - `?return_url={{ return_url|urlencode }}` can be appended. `return_url` is set in the - view context as `request.get_full_path()` (path + query string, e.g. - `/dcim/devices/42/custom-objects/?q=foo&sort=type&dir=asc`), so active filters are - preserved when the user returns from Edit or Delete. -- The `custom_object_edit_button` / `custom_object_delete_button` inclusion tags from - `netbox_custom_objects` do **not** accept a `return_url` argument — do not use them. -- Do **not** add bulk-edit or bulk-delete buttons to this tab — the tab shows objects +- 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. ## 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; `0` is falsy but some NetBox versions check truthiness + 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"` — this gives proper breadcrumbs, tabs, page header -- `CustomObjectTypeField.related_object_type` is a FK to `core.ObjectType` (a proxy of - Django's ContentType); using `ContentType.objects.get_for_model()` works because the - underlying DB table and IDs are shared -- Each model needs its own View subclass (factory pattern) so the view registry stores - distinct entries and URL reverse names don't collide -- `inc/paginator.html` uses `page.smart_pages` (from `EnhancedPage`) — this is **not** - available on Django's built-in `Page`; always use `EnhancedPaginator` -- Template is split into two files: `custom_objects_tab.html` (full page, extends base_template) - and `custom_objects_tab_partial.html` (no extends — just the htmx-container div). The view - returns the partial when `htmx_partial(request)` is True. -- The search form uses `hx-get` (no `method="get"`). The type select uses `hx-include="closest - form"` to pull in sibling fields (q, sort, dir, per_page) when it fires on change. -- `CustomObjectDeleteView.get_return_url()` overrides the mixin and ignores request params. - However, `ObjectDeleteView.post()` checks `form.cleaned_data['return_url']` **before** - calling `get_return_url()`, so passing `?return_url=` in the delete button URL still works - — NetBox initialises the delete confirmation form's hidden `return_url` field from the - GET param, which is then submitted with the form. - -## TODO — Step-by-step Implementation Checklist - -- [x] 1. Create `.gitignore` -- [x] 2. Create `pyproject.toml` -- [x] 3. Create `netbox_custom_objects_tab/__init__.py` (PluginConfig) -- [x] 4. Create `netbox_custom_objects_tab/views.py` (view factory + register_tabs) -- [x] 5. Create `netbox_custom_objects_tab/urls.py` (empty urlpatterns) -- [x] 6. Create `netbox_custom_objects_tab/templates/netbox_custom_objects_tab/custom_objects_tab.html` -- [x] 7. Create `README.md` and `CLAUDE.md` -- [x] 8. Initialize git repo: `git init && git add -A && git commit -m "Initial plugin scaffold"` -- [x] 9. Install into NetBox venv: `source /opt/netbox/venv/bin/activate && pip install -e /opt/custom_objects_additional_tab_plugin/` -- [x] 10. Add plugin to NetBox `configuration.py` under `PLUGINS` and `PLUGINS_CONFIG` -- [x] 11. Restart NetBox: `sudo systemctl restart netbox netbox-rq` -- [x] 12. Test: create a Custom Object Type with a Device field, create a Custom Object - instance referencing a Device, verify the "Custom Objects" tab appears on the - Device detail page with badge count = 1 -- [x] 13. Add wildcard model registration (`dcim.*`, `ipam.*`) -- [x] 14. Add pagination (`EnhancedPaginator`) and `?q=` text search -- [x] 15. Verify badge COUNT vs full fetch split (COUNT-only on detail page; full fetch only on tab) -- [x] 16. Add permission-gated Edit button (`can_change`) and Delete button (`can_delete`) per row -- [x] 17. Link the Type column to the CustomObjectType detail page (`can_view`-gated) -- [x] 18. Add HTMX partial rendering (paginator, sort headers, search form, type dropdown) -- [x] 19. Fix Edit/Delete return URL to redirect back to the Custom Objects tab -- [x] 20. Add Tags column and tag filter dropdown to the Custom Objects tab -- [x] 21. Add "Configure Table" button with per-user column show/hide/reorder preferences + 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 +- `SavedFiltersMixin` lives at `netbox.forms.mixins`, not `extras.forms.mixins` ## Critical Reference Files @@ -207,29 +188,24 @@ Action buttons and column links use the `perms` templatetag from `utilities.temp |------|---------| | `/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/inc/paginator.html` | Pagination partial — expects `page` + `paginator` context vars | -| `/opt/netbox/netbox/netbox/views/generic/object_views.py` | How `base_template` context is constructed | +| `/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 | ## Verification Steps 1. Activate venv and install: `pip install -e /opt/custom_objects_additional_tab_plugin/` 2. Add to NetBox config, restart -3. In NetBox UI: Customization → Custom Object Types → create a type with a Device field -4. Create a Custom Object instance that references an existing Device -5. Navigate to that Device's detail page — "Custom Objects" tab appears (badge = 1) -6. Click tab — table shows: type name | object link | value | field name | actions -7. Paginator appears when results exceed the per-page threshold -8. Type a search term — table filters; badge count stays at total -8a. Click a paginator link — only the table zone re-renders (no full page reload) -8b. Click a sort column — table updates in-place; URL bar reflects new sort params -8c. Change the type dropdown — table filters without full reload -8d. Network tab in devtools: HTMX requests carry `HX-Request: true`; response has no `` tag -9. As a superuser: Edit and Delete buttons appear; Type column is a clickable link -10. As a read-only user: no action buttons; Type column is a link if user has `view_customobjecttype`, plain text otherwise -11. Click Edit → navigates to the Custom Object instance edit page; save → returns to the tab -12. Click Delete → navigates to the delete confirmation page; confirm → returns to the tab -13. Click the Type column link → navigates to the Custom Object Type detail page -14. Delete the custom object — tab disappears -15. Check logs: `journalctl -u netbox` for any import errors +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 diff --git a/README.md b/README.md index ae316c2..4c14249 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,17 @@ [![NetBox](https://img.shields.io/badge/NetBox-4.5.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 plugin that adds a **Custom Objects** tab to standard object detail pages, -showing any Custom Object instances from the `netbox_custom_objects` plugin that reference +A NetBox 4.5.x plugin that adds **Custom Objects** tabs to standard object detail pages, +showing Custom Object instances from the `netbox_custom_objects` plugin that reference those objects via OBJECT or MULTIOBJECT fields. -The tab includes **pagination**, **text search**, **column sorting**, **type filtering**, -and **tag filtering**, with HTMX-powered partial updates so table interactions don't reload -the full page. +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 @@ -21,13 +25,14 @@ the full page. ## Requirements - NetBox 4.5.0 – 4.5.99 -- `netbox_custom_objects` plugin installed and configured +- `netbox_custom_objects` plugin **≥ 0.4.6** installed and configured ## Compatibility -| Plugin version | NetBox version | -|----------------|----------------| -| 1.0.x | 4.5.x | +| Plugin version | NetBox version | `netbox_custom_objects` version | +|----------------|----------------|---------------------------------| +| 2.0.x | 4.5.x | ≥ 0.4.6 | +| 1.0.x | 4.5.x | ≥ 0.4.4 | ## Installation @@ -47,9 +52,11 @@ PLUGINS = [ # Optional — defaults shown below PLUGINS_CONFIG = { 'netbox_custom_objects_tab': { - 'models': ['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*', 'contacts.*'], - 'label': 'Custom Objects', - 'weight': 2000, + 'combined_models': ['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*'], + 'combined_label': 'Custom Objects', + 'combined_weight': 2000, + 'typed_models': [], # opt-in: e.g. ['dcim.*'] + 'typed_weight': 2100, } } ``` @@ -58,26 +65,34 @@ Restart NetBox. No database migrations required. ## Configuration -| Setting | Default | Description | -|----------|---------|-------------| -| `models` | `['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*', 'contacts.*']` | Models that get the Custom Objects tab. Accepts `app_label.model_name` strings **or** `app_label.*` wildcards to register every model in an app. | -| `label` | `'Custom Objects'` | Text displayed on the tab. | -| `weight` | `2000` | Controls tab position in the tab bar; lower values appear further left. | +| 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. ### Examples ```python -# Default — all common NetBox apps -'models': ['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*', 'contacts.*'] +# Combined tab only (default) +'combined_models': ['dcim.*', 'ipam.*', 'virtualization.*', 'tenancy.*'] -# Only specific models -'models': ['dcim.device', 'dcim.site', 'ipam.prefix'] +# Per-type tabs for dcim models +'typed_models': ['dcim.*'] -# Mix wildcards and specifics -'models': ['dcim.*', 'virtualization.*', 'ipam.ipaddress'] +# 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'] # Third-party plugin models work identically -'models': ['dcim.*', 'ipam.*', 'inventory_monitor.*'] +'combined_models': ['dcim.*', 'ipam.*', 'inventory_monitor.*'] ``` Third-party plugin models are fully supported — Django treats plugin apps and built-in apps diff --git a/TODO.md b/TODO.md index 6652eab..015a6c7 100644 --- a/TODO.md +++ b/TODO.md @@ -12,3 +12,9 @@ Currently MULTIOBJECT values truncate at 3 items with a bare `…`. - 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 a1278f3..253a10e 100644 --- a/netbox_custom_objects_tab/__init__.py +++ b/netbox_custom_objects_tab/__init__.py @@ -5,25 +5,28 @@ class NetBoxCustomObjectsTabConfig(PluginConfig): name = "netbox_custom_objects_tab" verbose_name = "Custom Objects Tab" description = 'Adds a "Custom Objects" tab to NetBox object detail pages' - version = "1.0.1" + version = "2.0.1" author = "Jan Krupa" author_email = "jan.krupa@cesnet.cz" base_url = "custom-objects-tab" min_version = "4.5.0" max_version = "4.5.99" default_settings = { - # app_label.model_name strings, or app_label.* to include all models in an app. - "models": [ + # Per-type tabs: each Custom Object Type gets its own tab (opt-in, empty by default). + "typed_models": [], + # Combined tab: single "Custom Objects" tab showing all types (current behavior). + "combined_models": [ "dcim.*", "ipam.*", "virtualization.*", "tenancy.*", - "contacts.*", ], - # Label shown on the tab; override in PLUGINS_CONFIG. - "label": "Custom Objects", - # Tab sort weight; lower values appear further left. - "weight": 2000, + # 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. + "typed_weight": 2100, } def ready(self): diff --git a/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/custom_objects_tab.html b/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab.html similarity index 98% rename from netbox_custom_objects_tab/templates/netbox_custom_objects_tab/custom_objects_tab.html rename to netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab.html index 95c617d..81a2062 100644 --- a/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/custom_objects_tab.html +++ b/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab.html @@ -82,7 +82,7 @@

{% trans "Custom Objects" %}

{# --- table zone (swapped by HTMX) --- #} - {% include 'netbox_custom_objects_tab/custom_objects_tab_partial.html' %} + {% include 'netbox_custom_objects_tab/combined/tab_partial.html' %} diff --git a/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/custom_objects_tab_partial.html b/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab_partial.html similarity index 100% rename from netbox_custom_objects_tab/templates/netbox_custom_objects_tab/custom_objects_tab_partial.html rename to netbox_custom_objects_tab/templates/netbox_custom_objects_tab/combined/tab_partial.html diff --git a/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/typed/tab.html b/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/typed/tab.html new file mode 100644 index 0000000..e634f59 --- /dev/null +++ b/netbox_custom_objects_tab/templates/netbox_custom_objects_tab/typed/tab.html @@ -0,0 +1,117 @@ +{% extends base_template %} +{% load helpers %} +{% load buttons %} +{% load render_table from django_tables2 %} +{% load i18n %} + +{% block content %} + + {% if table %} +
+ {# Results / Filters inner tabs #} +
+ + {# Results tab pane #} +
+ + {# Applied filters #} + {% if filter_form %} + {% applied_filters model filter_form request.GET %} + {% endif %} + + {# Table controls: quick search + configure table #} + {% include 'inc/table_controls_htmx.html' with table_modal=table.name|add:"_config" %} + +
+ {% csrf_token %} + + {# Select all (multi-page) #} + {% if table.paginator.num_pages > 1 %} +
+
+
+
+ + +
+
+ + +
+
+
+
+ {% endif %} + +
+ {% csrf_token %} + + + {# Objects table #} +
+
+ {% include 'htmx/table.html' %} +
+
+ + {# Bulk action buttons #} +
+
+ + +
+
+
+
+
+ + {# Filters tab pane #} + {% if filter_form %} +
+ {% include 'inc/filter_list.html' %} +
+ {% endif %} + + {% else %} +
+
+ {% trans "No custom objects are linked to this object." %} +
+
+ {% endif %} + +{% endblock content %} + +{% block modals %} + {% if table %} + {% table_config_form table %} + {% endif %} +{% endblock modals %} diff --git a/netbox_custom_objects_tab/views/__init__.py b/netbox_custom_objects_tab/views/__init__.py new file mode 100644 index 0000000..5312447 --- /dev/null +++ b/netbox_custom_objects_tab/views/__init__.py @@ -0,0 +1,72 @@ +import logging + +from django.apps import apps +from netbox.plugins import get_plugin_config + +from .combined import register_combined_tabs +from .typed import register_typed_tabs + +logger = logging.getLogger("netbox_custom_objects_tab") + + +def _resolve_model_labels(labels): + """ + Resolve a list of model label strings (e.g. ["dcim.*", "ipam.device"]) + into a deduplicated list of Django model classes. + """ + seen = set() + result = [] + for label in labels: + label = label.lower() + if label.endswith(".*"): + app_label = label[:-2] + try: + model_classes = list(apps.get_app_config(app_label).get_models()) + except LookupError: + logger.warning( + "netbox_custom_objects_tab: could not find app %r — skipping", + app_label, + ) + continue + else: + try: + app_label, model_name = label.split(".", 1) + model_classes = [apps.get_model(app_label, model_name)] + except (ValueError, LookupError): + logger.warning( + "netbox_custom_objects_tab: could not find model %r — skipping", + label, + ) + continue + + for model_class in model_classes: + key = (model_class._meta.app_label, model_class._meta.model_name) + if key not in seen: + seen.add(key) + result.append(model_class) + + return result + + +def register_tabs(): + """ + Read plugin config and register both combined and typed tabs. + Called from AppConfig.ready(). + """ + 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 + + if combined_labels: + combined_models = _resolve_model_labels(combined_labels) + register_combined_tabs(combined_models, combined_label, combined_weight) + + if typed_labels: + typed_models = _resolve_model_labels(typed_labels) + register_typed_tabs(typed_models, typed_weight) diff --git a/netbox_custom_objects_tab/views.py b/netbox_custom_objects_tab/views/combined.py similarity index 83% rename from netbox_custom_objects_tab/views.py rename to netbox_custom_objects_tab/views/combined.py index 83b79e5..2764660 100644 --- a/netbox_custom_objects_tab/views.py +++ b/netbox_custom_objects_tab/views/combined.py @@ -3,15 +3,14 @@ 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.plugins import get_plugin_config 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 @@ -49,7 +48,6 @@ def _get_linked_custom_objects(instance): Mirrors the query logic in: netbox_custom_objects/template_content.py::CustomObjectLink.left_page() """ - from netbox_custom_objects.models import CustomObjectTypeField content_type = ContentType.objects.get_for_model(instance._meta.model) fields = CustomObjectTypeField.objects.filter( @@ -87,7 +85,6 @@ def _count_linked_custom_objects(instance): 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. """ - from netbox_custom_objects.models import CustomObjectTypeField content_type = ContentType.objects.get_for_model(instance._meta.model) fields = CustomObjectTypeField.objects.filter( @@ -304,12 +301,12 @@ def get(self, request, pk): if htmx_partial(request): return render( request, - "netbox_custom_objects_tab/custom_objects_tab_partial.html", + "netbox_custom_objects_tab/combined/tab_partial.html", context, ) return render( request, - "netbox_custom_objects_tab/custom_objects_tab.html", + "netbox_custom_objects_tab/combined/tab.html", context, ) @@ -318,55 +315,21 @@ def get(self, request, pk): return _TabView -def register_tabs(): +def register_combined_tabs(model_classes, label, weight): """ - Programmatically register a Custom Objects tab view for each model listed - in the plugin's 'models' setting. Called from AppConfig.ready(). + Register a combined Custom Objects tab view for each model in the list. """ - try: - model_labels = get_plugin_config("netbox_custom_objects_tab", "models") - tab_label = get_plugin_config("netbox_custom_objects_tab", "label") - tab_weight = get_plugin_config("netbox_custom_objects_tab", "weight") - except Exception: - logger.exception("Could not read netbox_custom_objects_tab plugin config") - return - - for model_label in model_labels: - model_label = model_label.lower() - if model_label.endswith(".*"): - app_label = model_label[:-2] - try: - model_classes = list(apps.get_app_config(app_label).get_models()) - except LookupError: - logger.warning( - "netbox_custom_objects_tab: could not find app %r — skipping", - app_label, - ) - continue - else: - try: - app_label, model_name = model_label.split(".", 1) - model_classes = [apps.get_model(app_label, model_name)] - except (ValueError, LookupError): - logger.warning( - "netbox_custom_objects_tab: could not find model %r — skipping", - model_label, - ) - continue - - for model_class in model_classes: - app_label = model_class._meta.app_label - model_name = model_class._meta.model_name - view_class = _make_tab_view(model_class, label=tab_label, weight=tab_weight) - # Programmatic equivalent of: - # @register_model_view(model_class, name='custom_objects', path='custom-objects') - register_model_view( - model_class, - name="custom_objects", - path="custom-objects", - )(view_class) - logger.debug( - "netbox_custom_objects_tab: registered tab for %s.%s", - app_label, - model_name, - ) + for model_class in model_classes: + app_label = model_class._meta.app_label + model_name = model_class._meta.model_name + 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( + "netbox_custom_objects_tab: 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 new file mode 100644 index 0000000..395e8d7 --- /dev/null +++ b/netbox_custom_objects_tab/views/typed.py @@ -0,0 +1,304 @@ +import logging + +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q +from django.db.utils import OperationalError, ProgrammingError +from django.shortcuts import get_object_or_404, render +from django.views.generic import View +from extras.choices import CustomFieldTypeChoices, CustomFieldUIVisibleChoices +from netbox.forms import NetBoxModelFilterSetForm +from netbox_custom_objects import field_types +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.forms.fields import TagFilterField +from utilities.views import ViewTab, register_model_view + +logger = logging.getLogger("netbox_custom_objects_tab") + + +def _build_typed_table_class(custom_object_type, dynamic_model): + """ + Dynamically build a django-tables2 table class for a Custom Object Type. + Replicates CustomObjectTableMixin.get_table() logic. + """ + model_fields = custom_object_type.fields.all() + fields = ["id"] + [field.name for field in model_fields if field.ui_visible != CustomFieldUIVisibleChoices.HIDDEN] + + meta = type( + "Meta", + (), + { + "model": dynamic_model, + "fields": fields, + "attrs": { + "class": "table table-hover object-list", + }, + }, + ) + + attrs = { + "Meta": meta, + "__module__": "database.tables", + } + + for field in model_fields: + if field.ui_visible == CustomFieldUIVisibleChoices.HIDDEN: + continue + field_type = field_types.FIELD_TYPE_CLASS[field.type]() + try: + attrs[field.name] = field_type.get_table_column_field(field) + except NotImplementedError: + logger.debug("typed tab: %s field type not implemented; using default column", field.name) + + linkable_field_types = [ + CustomFieldTypeChoices.TYPE_TEXT, + CustomFieldTypeChoices.TYPE_LONGTEXT, + ] + if field.primary and field.type in linkable_field_types: + attrs[f"render_{field.name}"] = field_type.render_table_column_linkified + else: + try: + attrs[f"render_{field.name}"] = field_type.render_table_column + except AttributeError: + pass + + return type( + f"{dynamic_model._meta.object_name}Table", + (CustomObjectTable,), + attrs, + ) + + +def _build_filterset_form(custom_object_type, dynamic_model): + """ + Dynamically build a filterset form class for a Custom Object Type. + Replicates CustomObjectListView.get_filterset_form() logic. + """ + attrs = { + "model": dynamic_model, + "__module__": "database.filterset_forms", + "tag": TagFilterField(dynamic_model), + } + + for field in custom_object_type.fields.all(): + field_type = field_types.FIELD_TYPE_CLASS[field.type]() + try: + attrs[field.name] = field_type.get_filterform_field(field) + except NotImplementedError: + logger.debug("typed tab: %s filter field not supported", field.name) + + return type( + f"{dynamic_model._meta.object_name}FilterForm", + (NetBoxModelFilterSetForm,), + attrs, + ) + + +def _count_for_type(custom_object_type, field_infos): + """ + Return a badge callable for one Custom Object Type. + field_infos = list of (field_name, field_type) for fields referencing the parent model. + Uses COUNT(*) only. Returns None when 0. + """ + + def _badge(instance): + try: + dynamic_model = custom_object_type.get_model() + except Exception: + logger.exception( + "Could not get model for CustomObjectType %s", + custom_object_type.pk, + ) + return None + + total = 0 + for field_name, field_type in field_infos: + if field_type == CustomFieldTypeChoices.TYPE_OBJECT: + total += dynamic_model.objects.filter(**{f"{field_name}_id": instance.pk}).count() + elif field_type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + total += dynamic_model.objects.filter(**{field_name: instance.pk}).count() + + return total if total > 0 else None + + return _badge + + +def _make_typed_tab_view(model_class, custom_object_type, field_infos, weight): + """ + Factory returning a View subclass for a per-type tab. + field_infos = list of (field_name, field_type) for fields of this Custom Object Type + that reference model_class. + """ + badge_fn = _count_for_type(custom_object_type, field_infos) + cot_pk = custom_object_type.pk + cot_label = str(custom_object_type) + + class _TypedTabView(View): + tab = ViewTab( + label=cot_label, + badge=badge_fn, + weight=weight, + hide_if_empty=True, + ) + + def get(self, request, pk): + try: + qs = model_class.objects.restrict(request.user, "view") + except AttributeError: + qs = model_class.objects.all() + + instance = get_object_or_404(qs, pk=pk) + + # Re-fetch CustomObjectType at request time (may have changed since ready()) + from netbox_custom_objects.models import CustomObjectType as COTModel + + try: + cot = COTModel.objects.get(pk=cot_pk) + except COTModel.DoesNotExist: + return render( + request, + "netbox_custom_objects_tab/typed/tab.html", + { + "object": instance, + "tab": self.tab, + "base_template": f"{instance._meta.app_label}/{instance._meta.model_name}.html", + "table": None, + }, + ) + + try: + dynamic_model = cot.get_model() + except Exception: + logger.exception("Could not get model for CustomObjectType %s", cot_pk) + return render( + request, + "netbox_custom_objects_tab/typed/tab.html", + { + "object": instance, + "tab": self.tab, + "base_template": f"{instance._meta.app_label}/{instance._meta.model_name}.html", + "table": None, + }, + ) + + # Build base queryset: union of all field filters for this type + q_filter = Q() + for field_name, field_type in field_infos: + if field_type == CustomFieldTypeChoices.TYPE_OBJECT: + q_filter |= Q(**{f"{field_name}_id": instance.pk}) + elif field_type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + q_filter |= Q(**{field_name: instance.pk}) + + base_qs = dynamic_model.objects.filter(q_filter).distinct() + + # Apply filterset + filterset_class = get_filterset_class(dynamic_model) + filterset = filterset_class(request.GET, queryset=base_qs) + filtered_qs = filterset.qs + + # Build filterset form for the filter sidebar + filterset_form_class = _build_filterset_form(cot, dynamic_model) + filter_form = filterset_form_class(request.GET) + + # Build table class and instantiate + table_class = _build_typed_table_class(cot, dynamic_model) + table = table_class(filtered_qs, user=request.user) + table.columns.show("pk") + + # Shadow @cached_property to avoid reverse error for dynamic models + table.htmx_url = request.path + table.embedded = False + + table.configure(request) + + # User preferences for paginator placement + preferences = {} + if request.user.is_authenticated and (userconfig := getattr(request.user, "config", None)): + preferences["pagination.placement"] = userconfig.get("pagination.placement", "bottom") + else: + preferences = {"pagination.placement": "bottom"} + + return_url = request.get_full_path() + + context = { + "object": instance, + "tab": self.tab, + "base_template": f"{instance._meta.app_label}/{instance._meta.model_name}.html", + "table": table, + "filter_form": filter_form, + "return_url": return_url, + "custom_object_type": cot, + "model": dynamic_model, + "preferences": preferences, + } + + if request.htmx and not request.htmx.boosted: + return render(request, "htmx/table.html", context) + return render(request, "netbox_custom_objects_tab/typed/tab.html", context) + + _TypedTabView.__name__ = f"{model_class.__name__}_{custom_object_type.slug}_TypedTabView" + _TypedTabView.__qualname__ = f"{model_class.__name__}_{custom_object_type.slug}_TypedTabView" + return _TypedTabView + + +def register_typed_tabs(model_classes, weight): + """ + Register per-type tabs for each model × CustomObjectType pair. + Pre-fetches all relevant CustomObjectTypeFields and groups them. + """ + + try: + # Collect all relevant fields + all_fields = CustomObjectTypeField.objects.filter( + type__in=[ + CustomFieldTypeChoices.TYPE_OBJECT, + CustomFieldTypeChoices.TYPE_MULTIOBJECT, + ], + ).select_related("custom_object_type") + + # Group by (content_type_id, custom_object_type_pk) + # -> list of (field_name, field_type) + from collections import defaultdict + + ct_cot_fields = defaultdict(list) + ct_cot_map = {} # (ct_id, cot_pk) -> CustomObjectType + for field in all_fields: + if field.related_object_type_id is None: + continue + key = (field.related_object_type_id, field.custom_object_type_id) + ct_cot_fields[key].append((field.name, field.type)) + ct_cot_map[key] = field.custom_object_type + + # Build a set of content_type_ids we care about + model_ct_map = {} # content_type_id -> model_class + for model_class in model_classes: + ct = ContentType.objects.get_for_model(model_class) + model_ct_map[ct.pk] = model_class + except (OperationalError, ProgrammingError): + logger.warning( + "netbox_custom_objects_tab: database unavailable — typed tabs not registered. " + "Restart NetBox once the database is ready." + ) + return + + for (ct_id, cot_pk), field_infos in ct_cot_fields.items(): + if ct_id not in model_ct_map: + continue + + model_class = model_ct_map[ct_id] + custom_object_type = ct_cot_map[(ct_id, cot_pk)] + slug = custom_object_type.slug + + view_class = _make_typed_tab_view(model_class, custom_object_type, field_infos, weight) + register_model_view( + model_class, + name=f"custom_objects_{slug}", + path=f"custom-objects-{slug}", + )(view_class) + logger.debug( + "netbox_custom_objects_tab: registered typed tab '%s' for %s.%s", + slug, + model_class._meta.app_label, + model_class._meta.model_name, + ) diff --git a/pyproject.toml b/pyproject.toml index 3dbbd9d..991509c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netbox-custom-objects-tab" -version = "1.0.1" +version = "2.0.1" description = "NetBox plugin that adds a Custom Objects tab to object detail pages" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/conftest.py b/tests/conftest.py index 1d3a500..b63138c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,8 @@ """ -Populate sys.modules with lightweight mocks for all NetBox-specific packages -so the plugin's views.py can be imported without a live NetBox installation. +Populate sys.modules with lightweight mocks for NetBox-specific packages. -This file is loaded by pytest automatically before any test collection, -ensuring the mocks are in place before test modules import plugin code. +This file is loaded by pytest before collection, ensuring mocks exist before +plugin modules are imported. """ import sys from types import ModuleType @@ -48,6 +47,12 @@ def _mock(dotted_name, **attrs): class _CustomFieldTypeChoices: TYPE_OBJECT = 'object' TYPE_MULTIOBJECT = 'multiobject' + TYPE_TEXT = 'text' + TYPE_LONGTEXT = 'longtext' + + +class _CustomFieldUIVisibleChoices: + HIDDEN = 'hidden' # --- netbox.* --- @@ -55,16 +60,25 @@ class _CustomFieldTypeChoices: _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') -_mock('extras.choices', CustomFieldTypeChoices=_CustomFieldTypeChoices) +_mock( + 'extras.choices', + CustomFieldTypeChoices=_CustomFieldTypeChoices, + CustomFieldUIVisibleChoices=_CustomFieldUIVisibleChoices, +) # --- utilities.* --- _mock('utilities') _mock('utilities.views', ViewTab=MagicMock(), register_model_view=MagicMock()) _mock('utilities.paginator', EnhancedPaginator=MagicMock(), get_paginate_count=MagicMock()) _mock('utilities.htmx', htmx_partial=MagicMock()) +_mock('utilities.forms') +_mock('utilities.forms.fields', TagFilterField=MagicMock()) class _FakeBaseTable(_tables2.Table): @@ -109,3 +123,7 @@ def _set_columns(self, selected_columns): # --- netbox_custom_objects.* --- _mock('netbox_custom_objects') _mock('netbox_custom_objects.models', CustomObjectTypeField=MagicMock()) +_mock('netbox_custom_objects.field_types', FIELD_TYPE_CLASS={}) +_mock('netbox_custom_objects.filtersets', get_filterset_class=MagicMock()) +_CustomObjectTable = type('CustomObjectTable', (), {}) +_mock('netbox_custom_objects.tables', CustomObjectTable=_CustomObjectTable) diff --git a/tests/test_register_tabs.py b/tests/test_register_tabs.py deleted file mode 100644 index 900196f..0000000 --- a/tests/test_register_tabs.py +++ /dev/null @@ -1,281 +0,0 @@ -""" -Unit tests for netbox_custom_objects_tab.views. - -These tests do NOT require a live NetBox instance or database. -All NetBox-specific packages are mocked in tests/conftest.py; -Django is configured via tests/settings.py (in-memory SQLite, no migrations). -""" -import logging -from unittest.mock import MagicMock, patch - -import pytest - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -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) - - -# --------------------------------------------------------------------------- -# _filter_linked_objects -# --------------------------------------------------------------------------- - -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 import views - self.fn = views._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 - # The filter returns new tuples; compare the contained mock objects by identity - 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 - - -# --------------------------------------------------------------------------- -# _count_linked_custom_objects -# --------------------------------------------------------------------------- - -class TestCountLinkedCustomObjects: - """Badge callable must return None (not 0) when nothing is linked.""" - - def _count(self, fields_and_counts): - """ - Build mock fields, patch CustomObjectTypeField (local import target) - and ContentType (module-level name), 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() - - # CustomObjectTypeField is imported *locally* inside the function, so we - # must patch the source module, not netbox_custom_objects_tab.views. - with patch('netbox_custom_objects.models.CustomObjectTypeField') as MockCOTF, \ - patch('netbox_custom_objects_tab.views.ContentType') as MockCT: - MockCT.objects.get_for_model.return_value = MagicMock() - MockCOTF.objects.filter.return_value.select_related.return_value = mock_fields - - from netbox_custom_objects_tab import views - return views._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([]) - # Must be None, not 0 — hide_if_empty checks truthiness - assert result is None - assert result != 0 - - def test_returns_total_when_positive(self): - from extras.choices import CustomFieldTypeChoices - result = self._count([ - (CustomFieldTypeChoices.TYPE_OBJECT, 3), - (CustomFieldTypeChoices.TYPE_OBJECT, 2), - ]) - assert result == 5 - - def test_returns_none_when_all_counts_are_zero(self): - from extras.choices import CustomFieldTypeChoices - result = self._count([ - (CustomFieldTypeChoices.TYPE_OBJECT, 0), - (CustomFieldTypeChoices.TYPE_MULTIOBJECT, 0), - ]) - assert result is None - - -# --------------------------------------------------------------------------- -# register_tabs — graceful handling of unknown app labels -# --------------------------------------------------------------------------- - -class TestRegisterTabs: - - def test_unknown_wildcard_app_logs_warning_no_exception(self, caplog): - """register_tabs() with 'unknownapp.*' must warn and not raise.""" - with patch('netbox_custom_objects_tab.views.get_plugin_config') as mock_cfg, \ - patch('netbox_custom_objects_tab.views.register_model_view'), \ - patch('netbox_custom_objects_tab.views.apps') as mock_apps: - - mock_cfg.return_value = ['nonexistent_app_xyz.*'] - mock_apps.get_app_config.side_effect = LookupError('no such app') - - from netbox_custom_objects_tab import views - - with caplog.at_level(logging.WARNING, logger='netbox_custom_objects_tab'): - views.register_tabs() # must not raise - - assert any('nonexistent_app_xyz' in r.message for r in caplog.records) - - def test_unknown_specific_model_logs_warning_no_exception(self, caplog): - """register_tabs() with 'unknownapp.somemodel' must warn and not raise.""" - with patch('netbox_custom_objects_tab.views.get_plugin_config') as mock_cfg, \ - patch('netbox_custom_objects_tab.views.register_model_view'), \ - patch('netbox_custom_objects_tab.views.apps') as mock_apps: - - mock_cfg.return_value = ['nonexistent_app_xyz.somemodel'] - mock_apps.get_model.side_effect = LookupError('no such model') - - from netbox_custom_objects_tab import views - - with caplog.at_level(logging.WARNING, logger='netbox_custom_objects_tab'): - views.register_tabs() # must not raise - - assert any('nonexistent_app_xyz.somemodel' in r.message for r in caplog.records) - - -# --------------------------------------------------------------------------- -# CustomObjectsTabTable -# --------------------------------------------------------------------------- - -class TestCustomObjectsTabTable: - """Column-preference machinery on the lightweight table class.""" - - @pytest.fixture(autouse=True) - def table_cls(self): - from netbox_custom_objects_tab.views import CustomObjectsTabTable - self.cls = CustomObjectsTabTable - - def test_default_columns_contains_all_six(self): - assert set(self.cls.Meta.default_columns) == { - 'type', 'object', 'value', '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='') - # Pass only non-exempt, non-actions columns - t._set_columns(['type']) - # 'actions' is exempt — must not appear in selected_columns - # (exempt columns are excluded from the modal, not from rendering) - selected_names = {col for col, _ in t.selected_columns} - assert 'actions' not in selected_names # exempt cols excluded from selected_columns - - -# --------------------------------------------------------------------------- -# _sort_header -# --------------------------------------------------------------------------- - -class TestSortHeader: - @pytest.fixture(autouse=True) - def get_fn(self): - from netbox_custom_objects_tab.views 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&') diff --git a/tests/test_views_combined.py b/tests/test_views_combined.py new file mode 100644 index 0000000..2ee0d84 --- /dev/null +++ b/tests/test_views_combined.py @@ -0,0 +1,307 @@ +""" +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() + mock_cotf.objects.filter.return_value.select_related.return_value = mock_fields + + 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_six(self): + assert set(self.cls.Meta.default_columns) == {"type", "object", "value", "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 new file mode 100644 index 0000000..a7c998d --- /dev/null +++ b/tests/test_views_init.py @@ -0,0 +1,118 @@ +""" +Unit tests for netbox_custom_objects_tab.views package init helpers. +""" + +import logging +from unittest.mock import MagicMock, patch + + +class TestResolveModelLabels: + def test_deduplicates_models_across_wildcard_and_explicit(self): + from netbox_custom_objects_tab import views + + m1 = MagicMock() + m1._meta.app_label = "dcim" + m1._meta.model_name = "device" + m2 = MagicMock() + m2._meta.app_label = "dcim" + m2._meta.model_name = "site" + + app_config = MagicMock() + app_config.get_models.return_value = [m1, m2] + + with patch.object(views, "apps") as mock_apps: + mock_apps.get_app_config.return_value = app_config + mock_apps.get_model.return_value = m1 + + result = views._resolve_model_labels(["dcim.*", "dcim.device"]) + + assert result == [m1, m2] + + def test_unknown_wildcard_app_logs_warning_no_exception(self, caplog): + from netbox_custom_objects_tab import views + + with patch.object(views, "apps") as mock_apps: + mock_apps.get_app_config.side_effect = LookupError("no such app") + + with caplog.at_level(logging.WARNING, logger="netbox_custom_objects_tab"): + result = views._resolve_model_labels(["nonexistent_app_xyz.*"]) + + assert result == [] + assert any("nonexistent_app_xyz" in r.message for r in caplog.records) + + def test_unknown_specific_model_logs_warning_no_exception(self, caplog): + from netbox_custom_objects_tab import views + + with patch.object(views, "apps") as mock_apps: + mock_apps.get_model.side_effect = LookupError("no such model") + + with caplog.at_level(logging.WARNING, logger="netbox_custom_objects_tab"): + result = views._resolve_model_labels(["nonexistent_app_xyz.somemodel"]) + + assert result == [] + assert any("nonexistent_app_xyz.somemodel" in r.message for r in caplog.records) + + +class TestRegisterTabs: + def test_dispatches_combined_and_typed_tabs(self): + from netbox_custom_objects_tab import views + + combined_models = [MagicMock()] + typed_models = [MagicMock()] + + config_map = { + "combined_models": ["dcim.device"], + "combined_label": "Custom Objects", + "combined_weight": 2000, + "typed_models": ["ipam.prefix"], + "typed_weight": 2100, + } + + 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, "register_typed_tabs") as register_typed, + ): + views.register_tabs() + + register_combined.assert_called_once_with(combined_models, "Custom Objects", 2000) + register_typed.assert_called_once_with(typed_models, 2100) + + def test_skips_dispatch_when_configured_model_lists_are_empty(self): + from netbox_custom_objects_tab import views + + config_map = { + "combined_models": [], + "combined_label": "Custom Objects", + "combined_weight": 2000, + "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, + ): + 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): + from netbox_custom_objects_tab import views + + 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) diff --git a/tests/test_views_typed_smoke.py b/tests/test_views_typed_smoke.py new file mode 100644 index 0000000..4a56f4e --- /dev/null +++ b/tests/test_views_typed_smoke.py @@ -0,0 +1,372 @@ +""" +Smoke/unit tests for netbox_custom_objects_tab.views.typed. +""" + +import logging +from collections import defaultdict +from unittest.mock import MagicMock, patch + +from extras.choices import CustomFieldTypeChoices, CustomFieldUIVisibleChoices +from netbox_custom_objects.tables import CustomObjectTable + + +def test_typed_module_imports_under_test_mocks(): + import netbox_custom_objects_tab.views.typed as typed_views + + assert typed_views is not None + + +# --------------------------------------------------------------------------- +# _count_for_type +# --------------------------------------------------------------------------- +class TestCountForType: + def _make_custom_object_type(self, field_count_map): + """ + Build a mock custom_object_type returning a dynamic model where: + filter(**{field_name condition})->count() returns field_count_map[field_name]. + """ + dynamic_model = MagicMock() + + def filter_side_effect(**kwargs): + query_key = next(iter(kwargs.keys())) + field_name = query_key[:-3] if query_key.endswith("_id") else query_key + count = field_count_map.get(field_name, 0) + qs = MagicMock() + qs.count.return_value = count + return qs + + dynamic_model.objects.filter.side_effect = filter_side_effect + + cot = MagicMock() + cot.get_model.return_value = dynamic_model + cot.pk = 123 + return cot + + def test_returns_none_when_zero_total(self): + from netbox_custom_objects_tab.views.typed import _count_for_type + + cot = self._make_custom_object_type({"ref_object": 0, "ref_multi": 0}) + badge = _count_for_type( + cot, + [ + ("ref_object", CustomFieldTypeChoices.TYPE_OBJECT), + ("ref_multi", CustomFieldTypeChoices.TYPE_MULTIOBJECT), + ], + ) + instance = MagicMock(pk=42) + + assert badge(instance) is None + + def test_returns_sum_for_object_and_multiobject_fields(self): + from netbox_custom_objects_tab.views.typed import _count_for_type + + cot = self._make_custom_object_type({"ref_object": 2, "ref_multi": 3}) + badge = _count_for_type( + cot, + [ + ("ref_object", CustomFieldTypeChoices.TYPE_OBJECT), + ("ref_multi", CustomFieldTypeChoices.TYPE_MULTIOBJECT), + ], + ) + instance = MagicMock(pk=42) + + assert badge(instance) == 5 + + def test_returns_none_when_get_model_raises(self, caplog): + from netbox_custom_objects_tab.views.typed import _count_for_type + + cot = MagicMock() + cot.get_model.side_effect = RuntimeError("broken model") + cot.pk = 123 + badge = _count_for_type(cot, [("ref_object", CustomFieldTypeChoices.TYPE_OBJECT)]) + instance = MagicMock(pk=42) + + assert badge(instance) is None + assert any("Could not get model for CustomObjectType" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# _build_typed_table_class +# --------------------------------------------------------------------------- +class TestBuildTypedTableClass: + def _make_cot_and_model(self, field_specs): + """ + field_specs: list of dicts with keys: name, type, ui_visible, primary. + Returns (cot, dynamic_model). + """ + fields = [] + for spec in field_specs: + f = MagicMock() + f.name = spec["name"] + f.type = spec.get("type", CustomFieldTypeChoices.TYPE_TEXT) + f.ui_visible = spec.get("ui_visible", "visible") + f.primary = spec.get("primary", False) + fields.append(f) + + cot = MagicMock() + cot.fields.all.return_value = fields + + dynamic_model = MagicMock() + dynamic_model._meta.object_name = "TestDynModel" + + return cot, dynamic_model + + def test_inherits_from_custom_object_table(self): + from netbox_custom_objects_tab.views.typed import _build_typed_table_class + + cot, model = self._make_cot_and_model([]) + table_cls = _build_typed_table_class(cot, model) + assert issubclass(table_cls, CustomObjectTable) + + def test_visible_fields_included_hidden_excluded(self): + from netbox_custom_objects_tab.views.typed import _build_typed_table_class + + ft_mock = MagicMock() + ft_mock.return_value.get_table_column_field.return_value = MagicMock() + ft_mock.return_value.render_table_column = MagicMock() + + with patch.dict("netbox_custom_objects.field_types.FIELD_TYPE_CLASS", { + CustomFieldTypeChoices.TYPE_TEXT: ft_mock, + }): + cot, model = self._make_cot_and_model([ + {"name": "visible_field", "type": CustomFieldTypeChoices.TYPE_TEXT, "ui_visible": "visible"}, + {"name": "hidden_field", "type": CustomFieldTypeChoices.TYPE_TEXT, "ui_visible": CustomFieldUIVisibleChoices.HIDDEN}, + ]) + table_cls = _build_typed_table_class(cot, model) + + assert "visible_field" in table_cls.Meta.fields + assert "hidden_field" not in table_cls.Meta.fields + + def test_get_table_column_field_called_per_visible_field(self): + from netbox_custom_objects_tab.views.typed import _build_typed_table_class + + ft_instance = MagicMock() + ft_instance.get_table_column_field.return_value = MagicMock() + ft_instance.render_table_column = MagicMock() + ft_mock = MagicMock(return_value=ft_instance) + + with patch.dict("netbox_custom_objects.field_types.FIELD_TYPE_CLASS", { + CustomFieldTypeChoices.TYPE_TEXT: ft_mock, + }): + cot, model = self._make_cot_and_model([ + {"name": "field_a", "type": CustomFieldTypeChoices.TYPE_TEXT}, + {"name": "field_b", "type": CustomFieldTypeChoices.TYPE_TEXT}, + ]) + _build_typed_table_class(cot, model) + + assert ft_instance.get_table_column_field.call_count == 2 + + def test_primary_text_field_gets_linkified_render(self): + from netbox_custom_objects_tab.views.typed import _build_typed_table_class + + ft_instance = MagicMock() + ft_instance.get_table_column_field.return_value = MagicMock() + ft_instance.render_table_column_linkified = MagicMock() + ft_mock = MagicMock(return_value=ft_instance) + + with patch.dict("netbox_custom_objects.field_types.FIELD_TYPE_CLASS", { + CustomFieldTypeChoices.TYPE_TEXT: ft_mock, + }): + cot, model = self._make_cot_and_model([ + {"name": "title", "type": CustomFieldTypeChoices.TYPE_TEXT, "primary": True}, + ]) + table_cls = _build_typed_table_class(cot, model) + + assert hasattr(table_cls, "render_title") + assert table_cls.render_title is ft_instance.render_table_column_linkified + + def test_not_implemented_column_logged_and_skipped(self, caplog): + from netbox_custom_objects_tab.views.typed import _build_typed_table_class + + ft_instance = MagicMock() + ft_instance.get_table_column_field.side_effect = NotImplementedError + ft_mock = MagicMock(return_value=ft_instance) + + with ( + patch.dict("netbox_custom_objects.field_types.FIELD_TYPE_CLASS", {"custom_type": ft_mock}), + caplog.at_level(logging.DEBUG, logger="netbox_custom_objects_tab"), + ): + cot, model = self._make_cot_and_model([ + {"name": "weird_field", "type": "custom_type"}, + ]) + table_cls = _build_typed_table_class(cot, model) + + # Should still produce a valid class + assert issubclass(table_cls, CustomObjectTable) + + +# --------------------------------------------------------------------------- +# _build_filterset_form +# --------------------------------------------------------------------------- +class TestBuildFiltersetForm: + def _make_cot_and_model(self, field_specs): + fields = [] + for spec in field_specs: + f = MagicMock() + f.name = spec["name"] + f.type = spec.get("type", CustomFieldTypeChoices.TYPE_TEXT) + fields.append(f) + + cot = MagicMock() + cot.fields.all.return_value = fields + + dynamic_model = MagicMock() + dynamic_model._meta.object_name = "TestDynModel" + + return cot, dynamic_model + + def test_inherits_from_netbox_model_filter_set_form(self): + from netbox.forms import NetBoxModelFilterSetForm + from netbox_custom_objects_tab.views.typed import _build_filterset_form + + cot, model = self._make_cot_and_model([]) + form_cls = _build_filterset_form(cot, model) + assert issubclass(form_cls, NetBoxModelFilterSetForm) + + def test_tag_field_present(self): + from netbox_custom_objects_tab.views.typed import _build_filterset_form + + cot, model = self._make_cot_and_model([]) + form_cls = _build_filterset_form(cot, model) + assert hasattr(form_cls, "tag") + + def test_get_filterform_field_called_per_field(self): + from netbox_custom_objects_tab.views.typed import _build_filterset_form + + ft_instance = MagicMock() + ft_instance.get_filterform_field.return_value = MagicMock() + ft_mock = MagicMock(return_value=ft_instance) + + with patch.dict("netbox_custom_objects.field_types.FIELD_TYPE_CLASS", { + CustomFieldTypeChoices.TYPE_TEXT: ft_mock, + }): + cot, model = self._make_cot_and_model([ + {"name": "field_a", "type": CustomFieldTypeChoices.TYPE_TEXT}, + {"name": "field_b", "type": CustomFieldTypeChoices.TYPE_TEXT}, + ]) + _build_filterset_form(cot, model) + + assert ft_instance.get_filterform_field.call_count == 2 + + def test_not_implemented_filter_logged_and_skipped(self, caplog): + from netbox_custom_objects_tab.views.typed import _build_filterset_form + + ft_instance = MagicMock() + ft_instance.get_filterform_field.side_effect = NotImplementedError + ft_mock = MagicMock(return_value=ft_instance) + + with ( + patch.dict("netbox_custom_objects.field_types.FIELD_TYPE_CLASS", {"custom_type": ft_mock}), + caplog.at_level(logging.DEBUG, logger="netbox_custom_objects_tab"), + ): + cot, model = self._make_cot_and_model([ + {"name": "weird_field", "type": "custom_type"}, + ]) + form_cls = _build_filterset_form(cot, model) + + assert not hasattr(form_cls, "weird_field") + + +# --------------------------------------------------------------------------- +# register_typed_tabs +# --------------------------------------------------------------------------- +class TestRegisterTypedTabs: + def test_register_called_once_per_model_cot_pair(self): + from netbox_custom_objects_tab.views.typed import register_typed_tabs + + model_class = MagicMock(__name__="Device") + model_class._meta.app_label = "dcim" + model_class._meta.model_name = "device" + + ct = MagicMock() + ct.pk = 10 + + field1 = MagicMock() + field1.related_object_type_id = 10 + field1.custom_object_type_id = 100 + field1.custom_object_type = MagicMock(slug="server", pk=100) + field1.custom_object_type.__str__ = lambda self: "Server" + field1.name = "device_ref" + field1.type = CustomFieldTypeChoices.TYPE_OBJECT + + field2 = MagicMock() + field2.related_object_type_id = 10 + field2.custom_object_type_id = 200 + field2.custom_object_type = MagicMock(slug="link", pk=200) + field2.custom_object_type.__str__ = lambda self: "Link" + field2.name = "device_link" + field2.type = CustomFieldTypeChoices.TYPE_MULTIOBJECT + + with ( + patch("netbox_custom_objects_tab.views.typed.CustomObjectTypeField") as mock_cotf, + patch("netbox_custom_objects_tab.views.typed.ContentType") as mock_ct, + patch("netbox_custom_objects_tab.views.typed.register_model_view") as mock_register, + ): + mock_cotf.objects.filter.return_value.select_related.return_value = [field1, field2] + mock_ct.objects.get_for_model.return_value = ct + mock_register.return_value = lambda cls: cls + + register_typed_tabs([model_class], weight=2100) + + # Two distinct COTs -> two register calls + assert mock_register.call_count == 2 + + def test_fields_with_no_related_object_type_skipped(self): + from netbox_custom_objects_tab.views.typed import register_typed_tabs + + model_class = MagicMock() + model_class._meta.app_label = "dcim" + model_class._meta.model_name = "device" + + ct = MagicMock() + ct.pk = 10 + + field = MagicMock() + field.related_object_type_id = None # should be skipped + field.custom_object_type_id = 100 + field.name = "orphan" + field.type = CustomFieldTypeChoices.TYPE_OBJECT + + with ( + patch("netbox_custom_objects_tab.views.typed.CustomObjectTypeField") as mock_cotf, + patch("netbox_custom_objects_tab.views.typed.ContentType") as mock_ct, + patch("netbox_custom_objects_tab.views.typed.register_model_view") as mock_register, + ): + mock_cotf.objects.filter.return_value.select_related.return_value = [field] + mock_ct.objects.get_for_model.return_value = ct + mock_register.return_value = lambda cls: cls + + register_typed_tabs([model_class], weight=2100) + + mock_register.assert_not_called() + + def test_models_not_in_model_classes_skipped(self): + from netbox_custom_objects_tab.views.typed import register_typed_tabs + + model_class = MagicMock() + model_class._meta.app_label = "dcim" + model_class._meta.model_name = "device" + + ct = MagicMock() + ct.pk = 10 + + # Field references content_type 99, not 10 + field = MagicMock() + field.related_object_type_id = 99 + field.custom_object_type_id = 100 + field.custom_object_type = MagicMock(slug="server", pk=100) + field.name = "other_ref" + field.type = CustomFieldTypeChoices.TYPE_OBJECT + + with ( + patch("netbox_custom_objects_tab.views.typed.CustomObjectTypeField") as mock_cotf, + patch("netbox_custom_objects_tab.views.typed.ContentType") as mock_ct, + patch("netbox_custom_objects_tab.views.typed.register_model_view") as mock_register, + ): + mock_cotf.objects.filter.return_value.select_related.return_value = [field] + mock_ct.objects.get_for_model.return_value = ct + mock_register.return_value = lambda cls: cls + + register_typed_tabs([model_class], weight=2100) + + mock_register.assert_not_called()