diff --git a/README.md b/README.md index 3ab1108..83cb6d8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index 5934e99..36c3314 100644 --- a/netbox_utilities/__init__.py +++ b/netbox_utilities/__init__.py @@ -1,6 +1,6 @@ from netbox.plugins import PluginConfig, get_plugin_config -__version__ = "0.13.0" +__version__ = "0.14.0" class NetBoxUtilitiesConfig(PluginConfig): diff --git a/netbox_utilities/site_prefix.py b/netbox_utilities/site_prefix.py index f361c46..7208f42 100644 --- a/netbox_utilities/site_prefix.py +++ b/netbox_utilities/site_prefix.py @@ -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() diff --git a/netbox_utilities/tests/test_site_prefix.py b/netbox_utilities/tests/test_site_prefix.py index aaa1d94..bfa4841 100644 --- a/netbox_utilities/tests/test_site_prefix.py +++ b/netbox_utilities/tests/test_site_prefix.py @@ -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") diff --git a/pyproject.toml b/pyproject.toml index 2ca466c..4100cf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"