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
|
Auswahlliste des B-seitigen Geräts ein und sind mit dem Mandanten der A-Seite
|
||||||
beziehungsweise dem globalen Mandantenfilter vorbelegt.
|
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
|
## Kompatibilität
|
||||||
|
|
||||||
- NetBox `>=4.6.5,<4.7`
|
- NetBox `>=4.6.5,<4.7`
|
||||||
@@ -273,7 +277,15 @@ bleibt die Zelle leer.
|
|||||||
|
|
||||||
Hat ein Benutzer bereits eine eigene Spaltenauswahl gespeichert, hat diese
|
Hat ein Benutzer bereits eine eigene Spaltenauswahl gespeichert, hat diese
|
||||||
Vorrang. Dann unter **Spalten konfigurieren** die Spalte **Prefix** auswählen
|
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.
|
`"site_prefix_column_enabled": False` lässt sich die Spalte abschalten.
|
||||||
|
|
||||||
### Vorfilter für die B-Seite einer Verkabelung
|
### Vorfilter für die B-Seite einer Verkabelung
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from netbox.plugins import PluginConfig, get_plugin_config
|
from netbox.plugins import PluginConfig, get_plugin_config
|
||||||
|
|
||||||
__version__ = "0.13.0"
|
__version__ = "0.14.0"
|
||||||
|
|
||||||
|
|
||||||
class NetBoxUtilitiesConfig(PluginConfig):
|
class NetBoxUtilitiesConfig(PluginConfig):
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
|
from django import forms
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
COLUMN_NAME = "site_prefix"
|
COLUMN_NAME = "site_prefix"
|
||||||
|
|
||||||
@@ -9,6 +13,32 @@ def first_word(value):
|
|||||||
return words[0] if words else None
|
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):
|
class SitePrefixColumn(tables.Column):
|
||||||
"""Render the first word of a record's site name."""
|
"""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():
|
def install_site_prefix_column():
|
||||||
from dcim.tables import DeviceTable, RackTable
|
from dcim.tables import DeviceTable, RackTable
|
||||||
from utilities.tables import register_table_column
|
from utilities.tables import register_table_column
|
||||||
@@ -41,3 +109,4 @@ def install_site_prefix_column():
|
|||||||
register_table_column(column, COLUMN_NAME, DeviceTable, RackTable)
|
register_table_column(column, COLUMN_NAME, DeviceTable, RackTable)
|
||||||
prepend_to_default_columns(DeviceTable)
|
prepend_to_default_columns(DeviceTable)
|
||||||
prepend_to_default_columns(RackTable)
|
prepend_to_default_columns(RackTable)
|
||||||
|
install_site_prefix_filter()
|
||||||
|
|||||||
@@ -2,10 +2,52 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
from django.test import SimpleTestCase
|
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):
|
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):
|
def test_first_word(self):
|
||||||
self.assertEqual(first_word("Berlin Campus West"), "Berlin")
|
self.assertEqual(first_word("Berlin Campus West"), "Berlin")
|
||||||
self.assertEqual(first_word(" DC01\tFrankfurt "), "DC01")
|
self.assertEqual(first_word(" DC01\tFrankfurt "), "DC01")
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-utilities"
|
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"
|
description = "Navigation, tenant utilities, connection VLANs, partial-width racks, and bulk operations for NetBox 4.6/4.7"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
Reference in New Issue
Block a user