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