feat: add bulk images and tenant autofill

This commit is contained in:
2026-08-03 15:35:44 +02:00
parent cd542f04f7
commit d54fc646ee
16 changed files with 641 additions and 9 deletions
+2 -2
View File
@@ -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"
+41
View File
@@ -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",
@@ -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));
})();
@@ -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;
}
+5
View File
@@ -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__,
@@ -0,0 +1,43 @@
{% extends 'base/layout.html' %}
{% load form_helpers %}
{% block title %}Mehrere Bilder hochladen{% endblock %}
{% render_errors form %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="object_type" value="{{ object_type }}">
<input type="hidden" name="object_id" value="{{ parent.pk }}">
<input type="hidden" name="return_url" value="{{ return_url }}">
<div class="row mb-3">
<div class="col col-lg-10 col-xl-8 offset-lg-1 offset-xl-2">
<div class="card">
<h2 class="card-header">
<i class="mdi mdi-image-multiple me-1" aria-hidden="true"></i>
Mehrere Bilder für {{ parent }} hochladen
</h2>
<div class="card-body">
<div class="alert alert-info">
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.
</div>
{% render_form form %}
<div id="netbox-utilities-image-preview" class="d-flex flex-wrap gap-3 mt-3" aria-live="polite"></div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col col-lg-10 col-xl-8 offset-lg-1 offset-xl-2 text-end">
<a href="{{ return_url }}" class="btn btn-outline-secondary">Abbrechen</a>
<button type="submit" class="btn btn-primary">
<i class="mdi mdi-cloud-upload" aria-hidden="true"></i>
Bilder hochladen
</button>
</div>
</div>
</form>
{% endblock content %}
@@ -1,5 +1,9 @@
{% load static %}
<link rel="stylesheet" href="{% static 'netbox_utilities/netbox_utilities.css' %}?v={{ asset_version }}">
{% if frontend_enabled %}
{{ frontend_data|json_script:"netbox-utilities-frontend-data" }}
<script src="{% static 'netbox_utilities/forms.js' %}?v={{ asset_version }}" defer></script>
{% endif %}
{% if navigation_enabled %}
{{ navigation_preferences|json_script:"netbox-utilities-navigation-data" }}
<script src="{% static 'netbox_utilities/navigation.js' %}?v={{ asset_version }}" defer></script>
+143
View File
@@ -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()
+2
View File
@@ -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
@@ -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)
@@ -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/")
@@ -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)
+1
View File
@@ -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"),
+103 -3
View File
@@ -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"