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
+30 -2
View File
@@ -1,10 +1,12 @@
# NetBox Utilities # 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. - 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. - 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. - 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. - Mehrere Module desselben Typs in einem Schritt in freie Modulschächte einbauen.
- Optionale Mehrfachspeicherung für verschobene Geräte aus NetBox Reorder Rack. - Optionale Mehrfachspeicherung für verschobene Geräte aus NetBox Reorder Rack.
@@ -32,7 +34,7 @@ Release-Tag oder ein bestimmter Commit verwendet werden:
```bash ```bash
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \ /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. 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 4.5 aus; die Erweiterung in diesem Plugin ist gezielt für die hier unterstützte
NetBox-Version 4.6.5 umgesetzt. 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 ### Module mehrfach einbauen
Unter **Plugins > NetBox Utilities > Module mehrfach einbauen** kann ein 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. Beim nächsten Speichern eines solchen Objekts muss ein Mandant ergänzt werden.
Globale Referenzmodelle ohne `tenant`-Feld sind nicht betroffen. 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 Die Einstellung kann durch einen Superuser deaktiviert werden. Mit
`tenant_required = False` in `PLUGINS_CONFIG` wird sie installationsweit fest `tenant_required = False` in `PLUGINS_CONFIG` wird sie installationsweit fest
deaktiviert; diese Konfiguration hat Vorrang vor der Admin-Oberfläche. deaktiviert; diese Konfiguration hat Vorrang vor der Admin-Oberfläche.
+2 -2
View File
@@ -1,12 +1,12 @@
from netbox.plugins import PluginConfig, get_plugin_config from netbox.plugins import PluginConfig, get_plugin_config
__version__ = "0.5.3" __version__ = "0.6.0"
class NetBoxUtilitiesConfig(PluginConfig): class NetBoxUtilitiesConfig(PluginConfig):
name = "netbox_utilities" name = "netbox_utilities"
verbose_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__ version = __version__
author = "LKE" author = "LKE"
base_url = "utilities" base_url = "utilities"
+41
View File
@@ -1,11 +1,52 @@
from dcim.choices import ModuleStatusChoices from dcim.choices import ModuleStatusChoices
from dcim.models import Device, ModuleBay, ModuleType from dcim.models import Device, ModuleBay, ModuleType
from django import forms from django import forms
from extras.constants import IMAGE_ATTACHMENT_IMAGE_FORMATS
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .models import UtilitiesSettings 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): class BulkModuleInstallForm(forms.Form):
device = DynamicModelChoiceField( device = DynamicModelChoiceField(
label="Gerät", 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; 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 { .netbox-utilities-sidebar-resizer {
display: none; display: none;
} }
+5
View File
@@ -49,6 +49,11 @@ class UtilitiesGlobalContent(PluginTemplateExtension):
return self.render( return self.render(
"netbox_utilities/head.html", "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_enabled": request.user.is_authenticated and navigation_customization_enabled(),
"navigation_preferences": preference_data, "navigation_preferences": preference_data,
"asset_version": __version__, "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 %} {% load static %}
<link rel="stylesheet" href="{% static 'netbox_utilities/netbox_utilities.css' %}?v={{ asset_version }}"> <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 %} {% if navigation_enabled %}
{{ navigation_preferences|json_script:"netbox-utilities-navigation-data" }} {{ navigation_preferences|json_script:"netbox-utilities-navigation-data" }}
<script src="{% static 'netbox_utilities/navigation.js' %}?v={{ asset_version }}" defer></script> <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 tenancy.models import Tenant
from .runtime import tenant_required from .runtime import tenant_required
from .tenant_autofill import apply_tenant_autofill
VALIDATION_MESSAGE = "Für dieses Objekt muss ein Mandant angegeben werden." 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) model = getattr(form._meta, "model", None)
if tenant_required() and model and model_supports_tenant(model) and "tenant" in form.fields: if tenant_required() and model and model_supports_tenant(model) and "tenant" in form.fields:
form.fields["tenant"].required = True form.fields["tenant"].required = True
apply_tenant_autofill(form)
BaseModelForm.__init__ = tenant_aware_init BaseModelForm.__init__ = tenant_aware_init
BaseModelForm._netbox_utilities_tenant_validation = True 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" app_name = "netbox_utilities"
urlpatterns = [ 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("modules/bulk-install/", views.BulkModuleInstallView.as_view(), name="bulk_module_install"),
path("navigation/", views.NavigationPreferencesView.as_view(), name="navigation_preferences"), path("navigation/", views.NavigationPreferencesView.as_view(), name="navigation_preferences"),
path("navigation/layout/", views.NavigationLayoutView.as_view(), name="navigation_layout"), 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 dcim.models import Device, Module
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.http import HttpResponseBadRequest, JsonResponse from django.contrib.contenttypes.models import ContentType
from django.shortcuts import redirect, render 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.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme from django.utils.http import url_has_allowed_host_and_scheme
from django.views import View 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 netbox.plugins import get_plugin_config
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.views import ContentTypePermissionRequiredMixin from utilities.views import ContentTypePermissionRequiredMixin
from .forms import BulkModuleInstallForm, UtilitiesSettingsForm from .forms import BulkImageUploadForm, BulkModuleInstallForm, UtilitiesSettingsForm
from .middleware import ( from .middleware import (
SESSION_TENANT_GROUP_KEY, SESSION_TENANT_GROUP_KEY,
SESSION_TENANT_KEY, SESSION_TENANT_KEY,
@@ -42,6 +48,100 @@ def _safe_return_url(request, default_name="home"):
return reverse(default_name) 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): class BulkModuleInstallView(ContentTypePermissionRequiredMixin, View):
template_name = "netbox_utilities/bulk_module_install.html" template_name = "netbox_utilities/bulk_module_install.html"
+2 -2
View File
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
[project] [project]
name = "netbox-utilities" name = "netbox-utilities"
version = "0.5.3" version = "0.6.0"
description = "Navigation, tenant filtering, bulk module installation, and atomic rack reordering for NetBox 4.6" description = "Navigation, tenant utilities, bulk image/module upload, and atomic rack reordering for NetBox 4.6"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { text = "MIT" } license = { text = "MIT" }