feat: filter device and rack lists by site prefix

Adds a site_prefix filter to the device and rack filtersets and filter
forms. It matches the first word of the site name case-insensitively
and accepts several comma-separated prefixes. Bumps version to 0.14.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 11:07:03 +02:00
co-authored by Claude Opus 5.5
parent ae4b05d539
commit 337cd28c23
5 changed files with 127 additions and 4 deletions
+13 -1
View File
@@ -41,6 +41,10 @@ Filterfelder für Mandantengruppe, Mandant und Standort. Sie schränken die
Auswahlliste des B-seitigen Geräts ein und sind mit dem Mandanten der A-Seite
beziehungsweise dem globalen Mandantenfilter vorbelegt.
Ab Version `0.14.0` enthält das Plugin die Prefix-Spalte des bisherigen
Plugins NetBox Site Prefix. Geräte- und Rack-Listen lassen sich zusätzlich
nach diesem Prefix filtern.
## Kompatibilität
- NetBox `>=4.6.5,<4.7`
@@ -273,7 +277,15 @@ bleibt die Zelle leer.
Hat ein Benutzer bereits eine eigene Spaltenauswahl gespeichert, hat diese
Vorrang. Dann unter **Spalten konfigurieren** die Spalte **Prefix** auswählen
und nach vorne schieben oder die Tabellenkonfiguration zurücksetzen. Mit
und nach vorne schieben oder die Tabellenkonfiguration zurücksetzen.
Im Filter-Reiter beider Listen steht unter **Standort** das Feld **Prefix**
zur Verfügung. Der Vergleich ignoriert Groß- und Kleinschreibung und trifft
nur das erste Wort des Standortnamens: `DC01` findet `DC01 Frankfurt`, aber
nicht `DC011 Hamburg`. Mehrere Prefixe werden durch Komma getrennt
(`Berlin, DC01`). Derselbe Filter funktioniert auch per URL und REST-API,
z. B. `/dcim/devices/?site_prefix=DC01` oder
`/api/dcim/racks/?site_prefix=Berlin&site_prefix=DC01`. Mit
`"site_prefix_column_enabled": False` lässt sich die Spalte abschalten.
### Vorfilter für die B-Seite einer Verkabelung
+1 -1
View File
@@ -1,6 +1,6 @@
from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.13.0"
__version__ = "0.14.0"
class NetBoxUtilitiesConfig(PluginConfig):
+69
View File
@@ -1,4 +1,8 @@
import re
import django_tables2 as tables
from django import forms
from django.db.models import Q
COLUMN_NAME = "site_prefix"
@@ -9,6 +13,32 @@ def first_word(value):
return words[0] if words else None
def split_prefixes(values):
"""Split filter input such as ["Berlin, DC01", "HH"] into single prefixes."""
prefixes = []
for value in values or ():
for part in re.split(r"[,\s]+", str(value)):
if part and part not in prefixes:
prefixes.append(part)
return prefixes
def site_prefix_query(prefixes, field="site__name"):
"""Match records whose site name starts with one of the prefixes as its first word."""
query = Q()
for prefix in prefixes:
query |= Q(**{f"{field}__iexact": prefix})
query |= Q(**{f"{field}__iregex": rf"^{re.escape(prefix)}\s"})
return query
def filter_site_prefix(queryset, name, value):
prefixes = split_prefixes(value)
if not prefixes:
return queryset
return queryset.filter(site_prefix_query(prefixes))
class SitePrefixColumn(tables.Column):
"""Render the first word of a record's site name."""
@@ -28,6 +58,44 @@ def prepend_to_default_columns(table):
)
def insert_into_fieldsets(fieldsets, field_name, after="site_id"):
"""Place the field next to the site filter, or in its own fieldset as a fallback."""
from utilities.forms.rendering import FieldSet
result = []
inserted = False
for fieldset in fieldsets:
items = tuple(fieldset.items)
if not inserted and after in items:
index = items.index(after) + 1
fieldset = FieldSet(*items[:index], field_name, *items[index:], name=fieldset.name)
inserted = True
result.append(fieldset)
if not inserted:
result.append(FieldSet(field_name, name="Prefix"))
return tuple(result)
def install_site_prefix_filter():
from dcim.filtersets import DeviceFilterSet, RackFilterSet
from dcim.forms import DeviceFilterForm, RackFilterForm
from utilities.filters import MultiValueCharFilter
for filterset in (DeviceFilterSet, RackFilterSet):
filterset.base_filters[COLUMN_NAME] = MultiValueCharFilter(
method=filter_site_prefix,
label="Site prefix (first word of the site name)",
)
for form in (DeviceFilterForm, RackFilterForm):
form.base_fields[COLUMN_NAME] = forms.CharField(
required=False,
label="Prefix",
help_text="Erstes Wort des Standortnamens, mehrere durch Komma getrennt",
)
form.fieldsets = insert_into_fieldsets(form.fieldsets, COLUMN_NAME)
def install_site_prefix_column():
from dcim.tables import DeviceTable, RackTable
from utilities.tables import register_table_column
@@ -41,3 +109,4 @@ def install_site_prefix_column():
register_table_column(column, COLUMN_NAME, DeviceTable, RackTable)
prepend_to_default_columns(DeviceTable)
prepend_to_default_columns(RackTable)
install_site_prefix_filter()
+43 -1
View File
@@ -2,10 +2,52 @@ from types import SimpleNamespace
from django.test import SimpleTestCase
from netbox_utilities.site_prefix import COLUMN_NAME, SitePrefixColumn, first_word, prepend_to_default_columns
from netbox_utilities.site_prefix import (
COLUMN_NAME,
SitePrefixColumn,
first_word,
insert_into_fieldsets,
prepend_to_default_columns,
site_prefix_query,
split_prefixes,
)
class SitePrefixTest(SimpleTestCase):
def test_split_prefixes_accepts_commas_and_repeated_values(self):
self.assertEqual(split_prefixes(["Berlin, DC01", "HH", "DC01", " "]), ["Berlin", "DC01", "HH"])
self.assertEqual(split_prefixes(None), [])
def test_prefix_query_matches_only_the_whole_first_word(self):
query = str(site_prefix_query(["DC01"]))
self.assertIn("('site__name__iexact', 'DC01')", query)
self.assertIn("('site__name__iregex', '^DC01\\\\s')", query)
def test_prefix_query_escapes_regex_characters(self):
self.assertIn("^DC\\\\.1\\\\s", str(site_prefix_query(["DC.1"])))
def test_prefix_field_is_placed_after_site_filter(self):
from utilities.forms.rendering import FieldSet
fieldsets = insert_into_fieldsets(
(FieldSet("q", name="Suche"), FieldSet("region_id", "site_id", "location_id", name="Standort")),
COLUMN_NAME,
)
self.assertEqual(fieldsets[1].items, ("region_id", "site_id", COLUMN_NAME, "location_id"))
self.assertEqual(fieldsets[1].name, "Standort")
def test_device_and_rack_filters_offer_prefix(self):
from dcim.filtersets import DeviceFilterSet, RackFilterSet
from dcim.forms import DeviceFilterForm, RackFilterForm
for filterset in (DeviceFilterSet, RackFilterSet):
self.assertIn(COLUMN_NAME, filterset.base_filters)
for form in (DeviceFilterForm, RackFilterForm):
self.assertIn(COLUMN_NAME, form.base_fields)
self.assertTrue(any(COLUMN_NAME in fieldset.items for fieldset in form.fieldsets))
def test_first_word(self):
self.assertEqual(first_word("Berlin Campus West"), "Berlin")
self.assertEqual(first_word(" DC01\tFrankfurt "), "DC01")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "netbox-utilities"
version = "0.13.0"
version = "0.14.0"
description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations for NetBox 4.6/4.7"
readme = "README.md"
requires-python = ">=3.12"