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:
@@ -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,6 +1,6 @@
|
||||
from netbox.plugins import PluginConfig, get_plugin_config
|
||||
|
||||
__version__ = "0.13.0"
|
||||
__version__ = "0.14.0"
|
||||
|
||||
|
||||
class NetBoxUtilitiesConfig(PluginConfig):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user