From d54fc646eea63385c0529a98221e8d2d398431ed Mon Sep 17 00:00:00 2001 From: Louis Date: Mon, 3 Aug 2026 15:35:44 +0200 Subject: [PATCH] feat: add bulk images and tenant autofill --- README.md | 32 +++- netbox_utilities/__init__.py | 4 +- netbox_utilities/forms.py | 41 +++++ .../static/netbox_utilities/forms.js | 106 +++++++++++++ .../netbox_utilities/netbox_utilities.css | 19 +++ netbox_utilities/template_content.py | 5 + .../netbox_utilities/bulk_image_upload.html | 43 ++++++ .../templates/netbox_utilities/head.html | 4 + netbox_utilities/tenant_autofill.py | 143 ++++++++++++++++++ netbox_utilities/tenant_validation.py | 2 + .../tests/test_bulk_image_form.py | 31 ++++ .../tests/test_bulk_image_view.py | 25 +++ .../tests/test_tenant_autofill.py | 84 ++++++++++ netbox_utilities/urls.py | 1 + netbox_utilities/views.py | 106 ++++++++++++- pyproject.toml | 4 +- 16 files changed, 641 insertions(+), 9 deletions(-) create mode 100644 netbox_utilities/static/netbox_utilities/forms.js create mode 100644 netbox_utilities/templates/netbox_utilities/bulk_image_upload.html create mode 100644 netbox_utilities/tenant_autofill.py create mode 100644 netbox_utilities/tests/test_bulk_image_form.py create mode 100644 netbox_utilities/tests/test_bulk_image_view.py create mode 100644 netbox_utilities/tests/test_tenant_autofill.py diff --git a/README.md b/README.md index ff2f8de..a741551 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # NetBox Utilities -Plugin für **NetBox 4.6.5** mit fünf Funktionen: +Plugin für **NetBox 4.6.5** mit sieben Funktionen: - Jeder Benutzer kann die Menüs der linken Navigation verschieben oder ausblenden. - Ein Dropdown in der Kopfleiste setzt einen sitzungsweiten Filter für einen Mandanten oder eine Mandantengruppe. - Optional verpflichtende Mandantenzuordnung für alle mandantenfähigen Objekte. +- Automatische Vorbelegung von Mandant und Mandantengruppe aus dem Objekt- oder Filterkontext. +- Mehrere Bilder in einem Schritt im Bilder-Tab eines Objekts hochladen. - Mehrere Module desselben Typs in einem Schritt in freie Modulschächte einbauen. - Optionale Mehrfachspeicherung für verschobene Geräte aus NetBox Reorder Rack. @@ -32,7 +34,7 @@ Release-Tag oder ein bestimmter Commit verwendet werden: ```bash /opt/netbox/venv/bin/pip install --upgrade --force-reinstall \ - "git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.5.3" + "git+https://git.mrblake.cc/MrBlake/Netbox-Utilities.git@v0.6.0" ``` Alternativ kann hinter dem `@` die vollständige Commit-ID stehen. @@ -170,6 +172,20 @@ Reorder-Projekt selbst weist derzeit offiziell nur Kompatibilität bis NetBox 4.5 aus; die Erweiterung in diesem Plugin ist gezielt für die hier unterstützte NetBox-Version 4.6.5 umgesetzt. +### Mehrere Bilder hochladen + +Im **Bilder**-Tab eines unterstützten NetBox-Objekts wird der bisherige +Einzel-Upload durch **Mehrere Bilder hochladen** ersetzt. Im Dateidialog können +bis zu 50 Bilder gemeinsam ausgewählt werden. Vor dem Speichern zeigt das +Plugin Vorschaubilder und die jeweiligen Dateinamen an. + +Alle Dateien werden zunächst mit NetBox' eigener Bildvalidierung geprüft. Ist +eine Datei ungültig, wird kein Bild aus dieser Auswahl gespeichert. Eine +optionale gemeinsame Beschreibung kann auf alle Bilder angewendet werden; als +Anzeigename bleibt der jeweilige ursprüngliche Dateiname erhalten. Benötigt +werden weiterhin die NetBox-Berechtigung zum Hinzufügen von Bildanhängen und +eine Leseberechtigung für das Zielobjekt. + ### Module mehrfach einbauen Unter **Plugins > NetBox Utilities > Module mehrfach einbauen** kann ein @@ -247,6 +263,18 @@ Bestehende Objekte ohne Mandant bleiben nach Aktivierung zunächst bestehen. Beim nächsten Speichern eines solchen Objekts muss ein Mandant ergänzt werden. Globale Referenzmodelle ohne `tenant`-Feld sind nicht betroffen. +Beim Öffnen eines NetBox-Formulars versucht das Plugin, fehlende Zuordnungen +vorsichtig vorzubelegen. Vorrang hat ein bereits erkennbares Elternobjekt, zum +Beispiel Rack, Standort, Gerät oder Cluster. Ist dort keine Zuordnung +erkennbar, wird der global ausgewählte Mandant verwendet. Felder für eine +Mandantengruppe werden aus dem erkannten Mandanten oder der global ausgewählten +Mandantengruppe vorbelegt. Bereits vorhandene Werte werden nicht überschrieben. + +Bleibt ein automatisch gesetzter Mandant bis zum Speichern unverändert, fragt +der Browser unmittelbar nach dem Klick auf **Speichern** noch einmal nach einer +ausdrücklichen Bestätigung. Wird der Mandant manuell geändert, entfällt diese +zusätzliche Rückfrage. + Die Einstellung kann durch einen Superuser deaktiviert werden. Mit `tenant_required = False` in `PLUGINS_CONFIG` wird sie installationsweit fest deaktiviert; diese Konfiguration hat Vorrang vor der Admin-Oberfläche. diff --git a/netbox_utilities/__init__.py b/netbox_utilities/__init__.py index 9f67910..0a9fd71 100644 --- a/netbox_utilities/__init__.py +++ b/netbox_utilities/__init__.py @@ -1,12 +1,12 @@ from netbox.plugins import PluginConfig, get_plugin_config -__version__ = "0.5.3" +__version__ = "0.6.0" class NetBoxUtilitiesConfig(PluginConfig): name = "netbox_utilities" verbose_name = "NetBox Utilities" - description = "Navigation, tenant filtering, bulk module installation, and atomic rack reordering" + description = "Navigation, tenant utilities, bulk image/module upload, and atomic rack reordering" version = __version__ author = "LKE" base_url = "utilities" diff --git a/netbox_utilities/forms.py b/netbox_utilities/forms.py index aad19a7..636a2d9 100644 --- a/netbox_utilities/forms.py +++ b/netbox_utilities/forms.py @@ -1,11 +1,52 @@ from dcim.choices import ModuleStatusChoices from dcim.models import Device, ModuleBay, ModuleType from django import forms +from extras.constants import IMAGE_ATTACHMENT_IMAGE_FORMATS from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField from .models import UtilitiesSettings +class MultipleImageInput(forms.ClearableFileInput): + allow_multiple_selected = True + + +class MultipleImageField(forms.ImageField): + widget = MultipleImageInput + + def clean(self, data, initial=None): + if not data: + return super().clean(data, initial) + files = data if isinstance(data, (list, tuple)) else [data] + return [super().clean(file, initial) for file in files] + + +class BulkImageUploadForm(forms.Form): + images = MultipleImageField( + label="Bilder", + widget=MultipleImageInput( + attrs={ + "accept": ",".join(sorted(set(IMAGE_ATTACHMENT_IMAGE_FORMATS.values()))), + "class": "netbox-utilities-multi-image-input", + } + ), + help_text="Bis zu 50 Bilder auswählen. Alle Dateien werden demselben NetBox-Objekt zugeordnet.", + ) + description = forms.CharField( + label="Gemeinsame Beschreibung", + required=False, + max_length=200, + widget=forms.Textarea(attrs={"rows": 3}), + help_text="Optional: Diese Beschreibung wird für jedes ausgewählte Bild übernommen.", + ) + + def clean_images(self): + images = self.cleaned_data["images"] + if len(images) > 50: + raise forms.ValidationError("Pro Upload können höchstens 50 Bilder verarbeitet werden.") + return images + + class BulkModuleInstallForm(forms.Form): device = DynamicModelChoiceField( label="Gerät", diff --git a/netbox_utilities/static/netbox_utilities/forms.js b/netbox_utilities/static/netbox_utilities/forms.js new file mode 100644 index 0000000..be45cbe --- /dev/null +++ b/netbox_utilities/static/netbox_utilities/forms.js @@ -0,0 +1,106 @@ +(() => { + 'use strict'; + + const dataElement = document.getElementById('netbox-utilities-frontend-data'); + if (!dataElement) return; + + let config; + try { + config = JSON.parse(dataElement.textContent); + } catch (_error) { + return; + } + + function rewriteImageUploadLinks(root = document) { + if (!config.image_attachment_add_url || !config.bulk_image_upload_url) return; + const singleUploadPath = new URL(config.image_attachment_add_url, document.baseURI).pathname; + + root.querySelectorAll('a[href]').forEach((link) => { + const current = new URL(link.href, document.baseURI); + if (current.pathname !== singleUploadPath) return; + if (!current.searchParams.has('object_type') || !current.searchParams.has('object_id')) return; + + const bulkUpload = new URL(config.bulk_image_upload_url, document.baseURI); + bulkUpload.search = current.search; + link.href = bulkUpload.href; + link.title = 'Mehrere Bilder gleichzeitig hochladen'; + link.replaceChildren(); + const icon = document.createElement('i'); + icon.className = 'mdi mdi-image-multiple me-1'; + icon.setAttribute('aria-hidden', 'true'); + link.append(icon, document.createTextNode('Mehrere Bilder hochladen')); + }); + } + + function configureTenantConfirmation(root = document) { + root.querySelectorAll('[data-netbox-utilities-autofilled-tenant]').forEach((field) => { + const form = field.form; + if (!form || form.dataset.netboxUtilitiesTenantConfirmation === 'true') return; + form.dataset.netboxUtilitiesTenantConfirmation = 'true'; + + form.addEventListener('submit', (event) => { + const candidateFields = form.querySelectorAll('[data-netbox-utilities-autofilled-tenant]'); + for (const candidateField of candidateFields) { + const candidate = candidateField.dataset.netboxUtilitiesAutofilledTenant; + if (!candidate || String(candidateField.value) !== candidate) continue; + if (candidateField.dataset.netboxUtilitiesTenantConfirmed === candidate) continue; + + const selectedOption = candidateField.selectedOptions?.[0]; + const tenantLabel = selectedOption?.textContent.trim() || `ID ${candidate}`; + const confirmed = window.confirm( + `Der Mandant „${tenantLabel}“ wurde automatisch erkannt. Bitte bestätigen Sie diese Zuordnung.` + ); + if (!confirmed) { + event.preventDefault(); + candidateField.focus(); + return; + } + candidateField.dataset.netboxUtilitiesTenantConfirmed = candidate; + } + }); + }); + } + + function configureImagePreview(root = document) { + const input = root.querySelector('.netbox-utilities-multi-image-input'); + const preview = document.getElementById('netbox-utilities-image-preview'); + if (!input || !preview || input.dataset.netboxUtilitiesPreview === 'true') return; + input.dataset.netboxUtilitiesPreview = 'true'; + let objectUrls = []; + + input.addEventListener('change', () => { + objectUrls.forEach((url) => URL.revokeObjectURL(url)); + objectUrls = []; + preview.replaceChildren(); + + Array.from(input.files).forEach((file) => { + const objectUrl = URL.createObjectURL(file); + objectUrls.push(objectUrl); + const item = document.createElement('div'); + item.className = 'netbox-utilities-image-preview-item'; + const image = document.createElement('img'); + image.src = objectUrl; + image.alt = ''; + const name = document.createElement('div'); + name.className = 'netbox-utilities-image-preview-name text-secondary mt-1'; + name.title = file.name; + name.textContent = file.name; + item.append(image, name); + preview.appendChild(item); + }); + }); + } + + function initialize(root = document) { + rewriteImageUploadLinks(root); + configureTenantConfirmation(root); + configureImagePreview(root); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => initialize(), {once: true}); + } else { + initialize(); + } + document.addEventListener('htmx:afterSettle', (event) => initialize(event.target)); +})(); diff --git a/netbox_utilities/static/netbox_utilities/netbox_utilities.css b/netbox_utilities/static/netbox_utilities/netbox_utilities.css index b156873..3f44c04 100644 --- a/netbox_utilities/static/netbox_utilities/netbox_utilities.css +++ b/netbox_utilities/static/netbox_utilities/netbox_utilities.css @@ -10,6 +10,25 @@ white-space: nowrap; } +.netbox-utilities-image-preview-item { + width: 9rem; +} + +.netbox-utilities-image-preview-item img { + width: 9rem; + height: 7rem; + object-fit: cover; + border: 1px solid var(--tblr-border-color); + border-radius: var(--tblr-border-radius); +} + +.netbox-utilities-image-preview-name { + overflow: hidden; + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; +} + .netbox-utilities-sidebar-resizer { display: none; } diff --git a/netbox_utilities/template_content.py b/netbox_utilities/template_content.py index 7e8758f..60154dd 100644 --- a/netbox_utilities/template_content.py +++ b/netbox_utilities/template_content.py @@ -49,6 +49,11 @@ class UtilitiesGlobalContent(PluginTemplateExtension): return self.render( "netbox_utilities/head.html", { + "frontend_enabled": request.user.is_authenticated, + "frontend_data": { + "image_attachment_add_url": reverse("extras:imageattachment_add"), + "bulk_image_upload_url": reverse("plugins:netbox_utilities:bulk_image_upload"), + }, "navigation_enabled": request.user.is_authenticated and navigation_customization_enabled(), "navigation_preferences": preference_data, "asset_version": __version__, diff --git a/netbox_utilities/templates/netbox_utilities/bulk_image_upload.html b/netbox_utilities/templates/netbox_utilities/bulk_image_upload.html new file mode 100644 index 0000000..c2cb7cb --- /dev/null +++ b/netbox_utilities/templates/netbox_utilities/bulk_image_upload.html @@ -0,0 +1,43 @@ +{% extends 'base/layout.html' %} +{% load form_helpers %} + +{% block title %}Mehrere Bilder hochladen{% endblock %} + +{% render_errors form %} + +{% block content %} +
+ {% csrf_token %} + + + +
+
+
+

+ + Mehrere Bilder für {{ parent }} hochladen +

+
+
+ Wählen Sie mehrere Bilder im Dateidialog aus. Alle Bilder werden gemeinsam geprüft; + bei einem Validierungsfehler wird keines gespeichert. Der ursprüngliche Dateiname + bleibt als Bildname sichtbar. +
+ {% render_form form %} +
+
+
+
+
+
+
+ Abbrechen + +
+
+
+{% endblock content %} diff --git a/netbox_utilities/templates/netbox_utilities/head.html b/netbox_utilities/templates/netbox_utilities/head.html index 503550a..1463e68 100644 --- a/netbox_utilities/templates/netbox_utilities/head.html +++ b/netbox_utilities/templates/netbox_utilities/head.html @@ -1,5 +1,9 @@ {% load static %} +{% if frontend_enabled %} + {{ frontend_data|json_script:"netbox-utilities-frontend-data" }} + +{% endif %} {% if navigation_enabled %} {{ navigation_preferences|json_script:"netbox-utilities-navigation-data" }} diff --git a/netbox_utilities/tenant_autofill.py b/netbox_utilities/tenant_autofill.py new file mode 100644 index 0000000..f66a85e --- /dev/null +++ b/netbox_utilities/tenant_autofill.py @@ -0,0 +1,143 @@ +from django.core.exceptions import ObjectDoesNotExist +from tenancy.models import Tenant, TenantGroup + +from .tenant_scope import active_tenant_scope + +PARENT_RELATIONS = ( + "device", + "virtual_machine", + "rack", + "location", + "site", + "cluster", + "circuit", + "virtual_circuit", + "tunnel", + "l2vpn", + "wireless_lan", + "power_panel", +) + + +def tenant_id_from_object(obj, depth=0): + if obj is None or depth > 3: + return None + if isinstance(obj, Tenant): + return obj.pk + + field_names = {field.name for field in obj._meta.get_fields()} + if "tenant" in field_names and getattr(obj, "tenant_id", None): + return obj.tenant_id + + for relation in PARENT_RELATIONS: + if relation not in field_names: + continue + try: + parent = getattr(obj, relation, None) + except (ObjectDoesNotExist, ValueError): + continue + if tenant_id := tenant_id_from_object(parent, depth + 1): + return tenant_id + return None + + +def _related_object(form, field_name): + field = form.fields.get(field_name) + if field is None or not hasattr(field, "queryset"): + return None + + value = None + if form.is_bound: + value = form.data.get(form.add_prefix(field_name)) + if not value: + value = form.initial.get(field_name) + if hasattr(value, "_meta"): + return value + if isinstance(value, (list, tuple)): + value = value[0] if value else None + if not value: + return None + try: + return field.queryset.filter(pk=value).first() + except (TypeError, ValueError): + return None + + +def infer_tenant_id(form): + instance = getattr(form, "instance", None) + if instance is not None and getattr(instance, "tenant_id", None): + return None + + initial_tenant = form.initial.get("tenant") + if isinstance(initial_tenant, Tenant): + return initial_tenant.pk + if initial_tenant: + try: + return int(initial_tenant) + except (TypeError, ValueError): + pass + + for relation in PARENT_RELATIONS: + if tenant_id := tenant_id_from_object(_related_object(form, relation)): + return tenant_id + + if instance is not None and (tenant_id := tenant_id_from_object(instance)): + return tenant_id + + scope = active_tenant_scope.get() + if scope is not None and scope.kind == "tenant": + return scope.object_id + return None + + +def _tenant_group_field_names(form): + names = [] + for name, field in form.fields.items(): + queryset = getattr(field, "queryset", None) + if queryset is not None and queryset.model is TenantGroup: + names.append(name) + return names + + +def _infer_group_id(tenant_id): + if tenant_id: + group_id = Tenant.objects.filter(pk=tenant_id).values_list("group_id", flat=True).first() + if group_id: + return group_id + scope = active_tenant_scope.get() + if scope is not None and scope.kind == "group": + return scope.object_id + return None + + +def apply_tenant_autofill(form): + tenant_id = None + tenant_field = form.fields.get("tenant") + if tenant_field is not None: + tenant_id = infer_tenant_id(form) + if tenant_id: + if not form.is_bound: + form.initial["tenant"] = tenant_id + tenant_field.widget.attrs["data-netbox-utilities-autofilled-tenant"] = str(tenant_id) + tenant_field.help_text = _append_help( + tenant_field.help_text, + "Automatisch aus dem Objektkontext oder dem globalen Mandantenfilter vorbelegt.", + ) + + group_id = _infer_group_id(tenant_id) + if not group_id: + return + for name in _tenant_group_field_names(form): + if getattr(form.instance, f"{name}_id", None) or form.initial.get(name): + continue + if not form.is_bound: + form.initial[name] = group_id + form.fields[name].help_text = _append_help( + form.fields[name].help_text, + "Automatisch aus dem erkannten Mandanten oder der globalen Mandantengruppe vorbelegt.", + ) + + +def _append_help(existing, addition): + existing = str(existing or "").strip() + return f"{existing} {addition}".strip() diff --git a/netbox_utilities/tenant_validation.py b/netbox_utilities/tenant_validation.py index fd000d4..70d21ff 100644 --- a/netbox_utilities/tenant_validation.py +++ b/netbox_utilities/tenant_validation.py @@ -7,6 +7,7 @@ from django.forms.models import BaseModelForm from tenancy.models import Tenant from .runtime import tenant_required +from .tenant_autofill import apply_tenant_autofill VALIDATION_MESSAGE = "Für dieses Objekt muss ein Mandant angegeben werden." @@ -55,6 +56,7 @@ def _install_form_validation(): model = getattr(form._meta, "model", None) if tenant_required() and model and model_supports_tenant(model) and "tenant" in form.fields: form.fields["tenant"].required = True + apply_tenant_autofill(form) BaseModelForm.__init__ = tenant_aware_init BaseModelForm._netbox_utilities_tenant_validation = True diff --git a/netbox_utilities/tests/test_bulk_image_form.py b/netbox_utilities/tests/test_bulk_image_form.py new file mode 100644 index 0000000..0bbcde4 --- /dev/null +++ b/netbox_utilities/tests/test_bulk_image_form.py @@ -0,0 +1,31 @@ +from io import BytesIO + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import SimpleTestCase +from django.utils.datastructures import MultiValueDict +from PIL import Image + +from netbox_utilities.forms import BulkImageUploadForm + + +class BulkImageUploadFormTest(SimpleTestCase): + @staticmethod + def _image(name): + content = BytesIO() + Image.new("RGB", (2, 2), "white").save(content, format="PNG") + return SimpleUploadedFile(name, content.getvalue(), content_type="image/png") + + def test_accepts_multiple_images(self): + files = MultiValueDict({"images": [self._image("front.png"), self._image("rear.png")]}) + + form = BulkImageUploadForm(data={"description": "Dokumentation"}, files=files) + + self.assertTrue(form.is_valid(), form.errors) + self.assertEqual(len(form.cleaned_data["images"]), 2) + self.assertEqual(form.cleaned_data["description"], "Dokumentation") + + def test_requires_at_least_one_image(self): + form = BulkImageUploadForm(data={}, files={}) + + self.assertFalse(form.is_valid()) + self.assertIn("images", form.errors) diff --git a/netbox_utilities/tests/test_bulk_image_view.py b/netbox_utilities/tests/test_bulk_image_view.py new file mode 100644 index 0000000..773ffda --- /dev/null +++ b/netbox_utilities/tests/test_bulk_image_view.py @@ -0,0 +1,25 @@ +from types import SimpleNamespace + +from django.test import RequestFactory, SimpleTestCase + +from netbox_utilities.views import BulkImageUploadView + + +class BulkImageUploadViewTest(SimpleTestCase): + def setUp(self): + self.factory = RequestFactory() + self.parent = SimpleNamespace(get_absolute_url=lambda: "/dcim/sites/42/") + + def test_accepts_local_return_url(self): + request = self.factory.get("/plugins/utilities/images/bulk-upload/?return_url=/dcim/sites/42/images/") + + result = BulkImageUploadView()._return_url(request, self.parent) + + self.assertEqual(result, "/dcim/sites/42/images/") + + def test_rejects_external_return_url(self): + request = self.factory.get("/plugins/utilities/images/bulk-upload/?return_url=https://example.net/steal") + + result = BulkImageUploadView()._return_url(request, self.parent) + + self.assertEqual(result, "/dcim/sites/42/") diff --git a/netbox_utilities/tests/test_tenant_autofill.py b/netbox_utilities/tests/test_tenant_autofill.py new file mode 100644 index 0000000..eb4d985 --- /dev/null +++ b/netbox_utilities/tests/test_tenant_autofill.py @@ -0,0 +1,84 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from django import forms +from django.test import SimpleTestCase +from tenancy.models import TenantGroup + +from netbox_utilities.tenant_autofill import apply_tenant_autofill, infer_tenant_id, tenant_id_from_object +from netbox_utilities.tenant_scope import ActiveTenantScope, active_tenant_scope + + +class FakeMeta: + def __init__(self, *field_names): + self.field_names = field_names + + def get_fields(self): + return [SimpleNamespace(name=name) for name in self.field_names] + + +class FakeObject: + def __init__(self, *, tenant_id=None, **relations): + self.tenant_id = tenant_id + self._meta = FakeMeta("tenant", *relations) + for name, value in relations.items(): + setattr(self, name, value) + + +class FakeQuerySet: + def __init__(self, model): + self.model = model + + +class TenantAutofillTest(SimpleTestCase): + def test_finds_tenant_through_parent_relation(self): + rack = FakeObject(tenant_id=42) + device = FakeObject(rack=rack) + + self.assertEqual(tenant_id_from_object(device), 42) + + def test_uses_selected_global_tenant_as_fallback(self): + form = SimpleNamespace( + fields={"tenant": forms.IntegerField()}, + initial={}, + instance=FakeObject(), + is_bound=False, + ) + token = active_tenant_scope.set(ActiveTenantScope("tenant", 17, frozenset({17}))) + try: + self.assertEqual(infer_tenant_id(form), 17) + finally: + active_tenant_scope.reset(token) + + @patch("netbox_utilities.tenant_autofill._infer_group_id", return_value=None) + @patch("netbox_utilities.tenant_autofill.infer_tenant_id", return_value=23) + def test_prefills_and_marks_tenant_field(self, _infer_tenant_id, _infer_group_id): + tenant_field = forms.IntegerField() + form = SimpleNamespace( + fields={"tenant": tenant_field}, + initial={}, + instance=FakeObject(), + is_bound=False, + ) + + apply_tenant_autofill(form) + + self.assertEqual(form.initial["tenant"], 23) + self.assertEqual(tenant_field.widget.attrs["data-netbox-utilities-autofilled-tenant"], "23") + + @patch("netbox_utilities.tenant_autofill._infer_group_id", return_value=9) + def test_prefills_tenant_group_relation(self, _infer_group_id): + group_field = SimpleNamespace( + queryset=FakeQuerySet(TenantGroup), + help_text="", + ) + form = SimpleNamespace( + fields={"group": group_field}, + initial={}, + instance=SimpleNamespace(group_id=None), + is_bound=False, + ) + + apply_tenant_autofill(form) + + self.assertEqual(form.initial["group"], 9) diff --git a/netbox_utilities/urls.py b/netbox_utilities/urls.py index fa95418..7de0365 100644 --- a/netbox_utilities/urls.py +++ b/netbox_utilities/urls.py @@ -5,6 +5,7 @@ from . import views app_name = "netbox_utilities" urlpatterns = [ + path("images/bulk-upload/", views.BulkImageUploadView.as_view(), name="bulk_image_upload"), path("modules/bulk-install/", views.BulkModuleInstallView.as_view(), name="bulk_module_install"), path("navigation/", views.NavigationPreferencesView.as_view(), name="navigation_preferences"), path("navigation/layout/", views.NavigationLayoutView.as_view(), name="navigation_layout"), diff --git a/netbox_utilities/views.py b/netbox_utilities/views.py index d86bc0a..979eb4b 100644 --- a/netbox_utilities/views.py +++ b/netbox_utilities/views.py @@ -1,16 +1,22 @@ from dcim.models import Device, Module from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin -from django.http import HttpResponseBadRequest, JsonResponse -from django.shortcuts import redirect, render +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import SuspiciousFileOperation, ValidationError +from django.db import DatabaseError, transaction +from django.http import Http404, HttpResponseBadRequest, JsonResponse +from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.http import url_has_allowed_host_and_scheme from django.views import View +from extras.forms import ImageAttachmentForm +from extras.models import ImageAttachment +from netbox.models.features import has_feature from netbox.plugins import get_plugin_config from tenancy.models import Tenant, TenantGroup from utilities.views import ContentTypePermissionRequiredMixin -from .forms import BulkModuleInstallForm, UtilitiesSettingsForm +from .forms import BulkImageUploadForm, BulkModuleInstallForm, UtilitiesSettingsForm from .middleware import ( SESSION_TENANT_GROUP_KEY, SESSION_TENANT_KEY, @@ -42,6 +48,100 @@ def _safe_return_url(request, default_name="home"): return reverse(default_name) +class BulkImageUploadView(ContentTypePermissionRequiredMixin, View): + template_name = "netbox_utilities/bulk_image_upload.html" + + def get_required_permission(self): + return "extras.add_imageattachment" + + def get(self, request): + parent = self._get_parent(request, request.GET) + form = BulkImageUploadForm() + return self._render(request, form, parent) + + def post(self, request): + parent = self._get_parent(request, request.POST) + form = BulkImageUploadForm(request.POST, request.FILES) + if form.is_valid(): + image_forms = self._build_image_forms(parent, form.cleaned_data) + invalid = [image_form for image_form in image_forms if not image_form.is_valid()] + if invalid: + for image_form in invalid: + filename = getattr(image_form.files.get("image"), "name", "Bild") + for errors in image_form.errors.values(): + for error in errors: + form.add_error("images", f"{filename}: {error}") + else: + created = [] + try: + with transaction.atomic(): + for image_form in image_forms: + created.append(image_form.save()) + except (DatabaseError, OSError, SuspiciousFileOperation, ValidationError, ValueError) as error: + for image_form in image_forms: + image_file = image_form.instance.image + if image_file.name and image_file._committed: + image_file.delete(save=False) + form.add_error("images", f"Die Bilder konnten nicht gespeichert werden: {error}") + else: + count = len(created) + noun = "Bild wurde" if count == 1 else "Bilder wurden" + messages.success(request, f"{count} {noun} gleichzeitig hochgeladen.") + return redirect(self._return_url(request, parent)) + return self._render(request, form, parent) + + @staticmethod + def _get_parent(request, data): + try: + object_type_id = int(data.get("object_type", "")) + object_id = int(data.get("object_id", "")) + except (TypeError, ValueError): + raise Http404("Ungültiges Zielobjekt.") from None + + object_type = get_object_or_404(ContentType, pk=object_type_id) + model = object_type.model_class() + if model is None or not has_feature(model, "image_attachments"): + raise Http404("Dieses Objekt unterstützt keine Bilder.") + queryset = model.objects + if hasattr(queryset, "restrict"): + queryset = queryset.restrict(request.user, "view") + return get_object_or_404(queryset, pk=object_id) + + @staticmethod + def _build_image_forms(parent, cleaned_data): + return [ + ImageAttachmentForm( + data={"name": "", "description": cleaned_data["description"]}, + files={"image": image}, + instance=ImageAttachment(parent=parent), + ) + for image in cleaned_data["images"] + ] + + def _return_url(self, request, parent): + candidate = request.POST.get("return_url") or request.GET.get("return_url") + if candidate and url_has_allowed_host_and_scheme( + candidate, + allowed_hosts={request.get_host()}, + require_https=request.is_secure(), + ): + return candidate + return parent.get_absolute_url() + + def _render(self, request, form, parent): + object_type = ContentType.objects.get_for_model(parent) + return render( + request, + self.template_name, + { + "form": form, + "parent": parent, + "object_type": object_type.pk, + "return_url": self._return_url(request, parent), + }, + ) + + class BulkModuleInstallView(ContentTypePermissionRequiredMixin, View): template_name = "netbox_utilities/bulk_module_install.html" diff --git a/pyproject.toml b/pyproject.toml index eb62e2a..1683b32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "hatchling.build" [project] name = "netbox-utilities" -version = "0.5.3" -description = "Navigation, tenant filtering, bulk module installation, and atomic rack reordering for NetBox 4.6" +version = "0.6.0" +description = "Navigation, tenant utilities, bulk image/module upload, and atomic rack reordering for NetBox 4.6" readme = "README.md" requires-python = ">=3.12" license = { text = "MIT" }