feat: add bulk images and tenant autofill
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
+2
-2
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user