Skip to content

Commit 2c4b9e9

Browse files
committed
Add Tags column and tag filter to Custom Objects tab
- prefetch_related('tags') on each per-field queryset eliminates N+1 queries; one extra batch query per field regardless of result count - Tags column renders colored badge(s) via {% tag %} builtin, or — when no tags are assigned - Tag filter dropdown appears when ≥1 linked object has a tag; uses ?tag=<slug>, composes with ?q=, ?type=, sort, and pagination - available_tags collected from unfiltered list (prefetch cache), sorted by name; tag_slug added to base_params so sort links preserve the filter - Clear-filters button and empty-state message now cover tag_slug - Update README, CHANGELOG, CLAUDE.md
1 parent 72a130c commit 2c4b9e9

6 files changed

Lines changed: 80 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4646
type-dropdown changes now swap only the table zone in-place, without a full page reload.
4747
The URL is updated via `pushState` so links remain shareable and the browser back button
4848
restores the previous filter/page state.
49+
- **Tags column** — each row in the Custom Objects table now shows the tags assigned to
50+
that Custom Object instance as colored badges. Rows with no tags display ``.
51+
- **Tag filter dropdown** (`?tag=<slug>`) — a tag dropdown appears in the search bar
52+
whenever at least one linked Custom Object has a tag, letting users narrow the table to
53+
objects with a specific tag. Tag filtering composes with `?q=`, `?type=`, sort, and
54+
pagination. Tags are pre-fetched in bulk (`prefetch_related('tags')`) so there is no
55+
N+1 query cost.
4956

5057
### Fixed
5158

CLAUDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,21 @@ from types import SimpleNamespace
5757

5858
- **`_get_linked_custom_objects(instance)`** — returns a Python `list` of `(obj, field)` tuples
5959
by querying across multiple dynamic model tables. A single queryset is not possible.
60+
Each queryset uses `.prefetch_related('tags')` so tag data is batch-fetched (one extra
61+
query per field) and cached on each object instance — no N+1 cost in the template.
6062
- **`_filter_linked_objects(linked, q)`** — filters that list in Python; case-insensitive
6163
match against `str(obj)`, `str(field.custom_object_type)`, `str(field)`.
64+
- **`available_tags`** — collected in the view from `linked_all` (unfiltered) by iterating
65+
`_obj.tags.all()` (uses prefetch cache). Deduplicated by slug, sorted by `name.lower()`.
66+
Passed to context as `available_tags`; the active tag filter slug is `tag_slug`.
67+
- **Tag filter**`tag_slug = request.GET.get('tag', '').strip()`; applied after the type
68+
filter by checking `tag_slug in {t.slug for t in obj.tags.all()}` (cache hit, no query).
6269
- **`EnhancedPaginator(linked, get_paginate_count(request))`** — paginates the filtered list.
6370
`get_paginate_count` respects `?per_page=`, user prefs, and global `PAGINATE_COUNT`.
6471
- **`inc/paginator.html`** — pass `htmx=True table=htmx_table` to emit `hx-get` links.
6572
`htmx_table = SimpleNamespace(htmx_url=request.path, embedded=False)`.
73+
The paginator uses `{% querystring request page=p %}` which copies all current GET params
74+
(including `?tag=`, `?type=`, `?q=`, etc.) so filter state is preserved across pages.
6675
- **`htmx_partial(request)`** — returns `True` when the request carries `HX-Request` and
6776
is not boosted. View returns `custom_objects_tab_partial.html` in that case.
6877
- The partial wraps everything in `<div id="custom_objects_list" class="htmx-container">`.
@@ -171,6 +180,7 @@ Action buttons and column links use the `perms` templatetag from `utilities.temp
171180
- [x] 17. Link the Type column to the CustomObjectType detail page (`can_view`-gated)
172181
- [x] 18. Add HTMX partial rendering (paginator, sort headers, search form, type dropdown)
173182
- [x] 19. Fix Edit/Delete return URL to redirect back to the Custom Objects tab
183+
- [x] 20. Add Tags column and tag filter dropdown to the Custom Objects tab
174184

175185
## Critical Reference Files
176186

README.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ A NetBox 4.5.x plugin that adds a **Custom Objects** tab to standard object deta
44
showing any Custom Object instances from the `netbox_custom_objects` plugin that reference
55
those objects via OBJECT or MULTIOBJECT fields.
66

7-
The tab includes **pagination**, **text search**, **column sorting**, and **type filtering**,
8-
with HTMX-powered partial updates so table interactions don't reload the full page.
7+
The tab includes **pagination**, **text search**, **column sorting**, **type filtering**,
8+
and **tag filtering**, with HTMX-powered partial updates so table interactions don't reload
9+
the full page.
910

1011
## Screenshot
1112

@@ -100,17 +101,23 @@ results to a single type. Uses the `?type=<slug>` query parameter. The dropdown
100101
auto-submits on selection and is populated from the types actually present in the
101102
current result set.
102103

104+
### Tag filter
105+
A dropdown (shown when at least one linked Custom Object has a tag) lets you narrow
106+
results to objects with a specific tag. Uses the `?tag=<slug>` query parameter. The
107+
dropdown auto-submits on selection and is populated from the tags present across the
108+
full result set. Tag data is pre-fetched in bulk so there is no N+1 query cost.
109+
103110
### Column sorting
104111
Clicking the **Type**, **Object**, or **Field** column header sorts the table
105112
in-memory. A second click on the same header reverses the direction. The active
106113
column shows an up/down arrow icon. Sort state is preserved when the search form
107114
is submitted.
108115

109116
### HTMX / Partial updates
110-
Pagination clicks, column sort clicks, search form submissions, and type-dropdown
111-
changes all update the table zone in-place using HTMX — no full page reload. The
112-
URL is updated via `pushState` so links stay shareable and the browser back button
113-
returns to the previous filter/page state.
117+
Pagination clicks, column sort clicks, search form submissions, type-dropdown changes,
118+
and tag-dropdown changes all update the table zone in-place using HTMX — no full page
119+
reload. The URL is updated via `pushState` so links stay shareable and the browser back
120+
button returns to the previous filter/page state.
114121

115122
### Value column
116123
Each row includes a **Value** column showing the actual field value on the Custom
@@ -148,6 +155,7 @@ The tab displays:
148155
| **Object** | Link to the Custom Object instance (sortable) |
149156
| **Value** | The value stored in the linking field — a link for Object fields, comma-separated links for Multi-Object fields |
150157
| **Field** | The field that holds the reference (sortable) |
158+
| **Tags** | Colored tag badges assigned to the Custom Object instance; `` when none |
151159
| *(actions)* | Edit and Delete buttons, each shown only when the user has the corresponding permission |
152160

153161
## Support

netbox_custom_objects_tab/templates/netbox_custom_objects_tab/custom_objects_tab.html

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ <h2 class="mb-0">{% trans "Custom Objects" %}</h2>
2121
<button type="submit" class="btn btn-primary">
2222
<i class="mdi mdi-magnify"></i>
2323
</button>
24-
{% if q or type_slug %}
24+
{% if q or type_slug or tag_slug %}
2525
<a href="?" class="btn btn-outline-secondary" title="{% trans 'Clear filters' %}">
2626
<i class="mdi mdi-close"></i>
2727
</a>
@@ -46,6 +46,23 @@ <h2 class="mb-0">{% trans "Custom Objects" %}</h2>
4646
{% endfor %}
4747
</select>
4848
{% endif %}
49+
{# Tag dropdown — only shown when any object has tags #}
50+
{% if available_tags %}
51+
<select name="tag"
52+
hx-get="{{ request.path }}"
53+
hx-target="#custom_objects_list"
54+
hx-swap="outerHTML"
55+
hx-push-url="true"
56+
hx-include="closest form"
57+
hx-trigger="change"
58+
class="form-select form-select-sm w-auto"
59+
aria-label="{% trans 'Filter by tag' %}">
60+
<option value="">{% trans "All tags" %}</option>
61+
{% for t in available_tags %}
62+
<option value="{{ t.slug }}"{% if t.slug == tag_slug %} selected{% endif %}>{{ t.name }}</option>
63+
{% endfor %}
64+
</select>
65+
{% endif %}
4966
{# Preserve sort state and per_page when submitting the search form #}
5067
{% if sort %}<input type="hidden" name="sort" value="{{ sort }}">{% endif %}
5168
{% if sort_dir and sort_dir != 'asc' %}<input type="hidden" name="dir" value="{{ sort_dir }}">{% endif %}

netbox_custom_objects_tab/templates/netbox_custom_objects_tab/custom_objects_tab_partial.html

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
{% if sort_headers.field.icon %}<i class="mdi mdi-{{ sort_headers.field.icon }}"></i>{% endif %}
3131
</a>
3232
</th>
33+
<th>{% trans "Tags" %}</th>
3334
<th></th>
3435
</tr>
3536
</thead>
@@ -65,6 +66,13 @@
6566
{% endif %}
6667
</td>
6768
<td>{{ field }}</td>
69+
<td>
70+
{% for t in obj.tags.all %}
71+
{% tag t %}{% if not forloop.last %} {% endif %}
72+
{% empty %}
73+
&mdash;
74+
{% endfor %}
75+
</td>
6876
<td class="text-end text-nowrap">
6977
{% if request.user|can_change:obj %}
7078
<a href="{% url 'plugins:netbox_custom_objects:customobject_edit' pk=obj.pk custom_object_type=obj.custom_object_type.slug %}?return_url={{ return_url|urlencode }}" class="btn btn-yellow" role="button">
@@ -84,7 +92,7 @@
8492
</div>
8593
{% else %}
8694
<div class="card-body text-muted">
87-
{% if q or type_slug %}
95+
{% if q or type_slug or tag_slug %}
8896
{% trans "No custom objects match your filters." %}
8997
{% else %}
9098
{% trans "No custom objects are linked to this object." %}

netbox_custom_objects_tab/views.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,10 @@ def _get_linked_custom_objects(instance):
5252
continue
5353

5454
if field.type == CustomFieldTypeChoices.TYPE_OBJECT:
55-
for obj in model.objects.filter(**{f'{field.name}_id': instance.pk}):
55+
for obj in model.objects.filter(**{f'{field.name}_id': instance.pk}).prefetch_related('tags'):
5656
results.append((obj, field))
5757
elif field.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
58-
for obj in model.objects.filter(**{field.name: instance.pk}):
58+
for obj in model.objects.filter(**{field.name: instance.pk}).prefetch_related('tags'):
5959
results.append((obj, field))
6060

6161
return results
@@ -197,17 +197,33 @@ def get(self, request, pk):
197197
# Read filter/sort params
198198
q = request.GET.get('q', '')
199199
type_slug = request.GET.get('type', '')
200+
tag_slug = request.GET.get('tag', '').strip()
200201
sort_col = request.GET.get('sort', '')
201202
sort_dir = request.GET.get('dir', 'asc')
202203
per_page = request.GET.get('per_page', '')
203204

205+
# Collect unique tags for the dropdown (always from the unfiltered list)
206+
seen_tag_slugs = set()
207+
available_tags = []
208+
for _obj, _field in linked_all:
209+
for t in _obj.tags.all():
210+
if t.slug not in seen_tag_slugs:
211+
seen_tag_slugs.add(t.slug)
212+
available_tags.append(t)
213+
available_tags.sort(key=lambda t: t.name.lower())
214+
204215
# Apply filters
205216
linked = _filter_linked_objects(linked_all, q)
206217
if type_slug:
207218
linked = [
208219
(obj, field) for obj, field in linked
209220
if field.custom_object_type.slug == type_slug
210221
]
222+
if tag_slug:
223+
linked = [
224+
(obj, field) for obj, field in linked
225+
if tag_slug in {t.slug for t in obj.tags.all()}
226+
]
211227

212228
# In-memory sort (applied after filters, before pagination)
213229
if sort_col in _SORT_KEYS:
@@ -232,6 +248,8 @@ def get(self, request, pk):
232248
base_params['q'] = q
233249
if type_slug:
234250
base_params['type'] = type_slug
251+
if tag_slug:
252+
base_params['tag'] = tag_slug
235253
if per_page:
236254
base_params['per_page'] = per_page
237255
sort_base = urlencode(base_params)
@@ -254,7 +272,9 @@ def get(self, request, pk):
254272
'page_rows': page_rows,
255273
'q': q,
256274
'type_slug': type_slug,
275+
'tag_slug': tag_slug,
257276
'available_types': available_types,
277+
'available_tags': available_tags,
258278
'sort': sort_col,
259279
'sort_dir': sort_dir,
260280
'sort_headers': sort_headers,

0 commit comments

Comments
 (0)