Compare commits
6
Commits
83cac4447a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
117ffac210 | ||
|
|
2af5ea997d | ||
|
|
791b40b3c4 | ||
|
|
d20baa3ea3 | ||
|
|
b632a5bb04 | ||
|
|
8d6b6a46db |
@@ -7,7 +7,8 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit
|
||||
- Dokumentationen direkt in NetBox schreiben und als sicher bereinigten Rich Text anzeigen
|
||||
- Word-ähnlicher WYSIWYG-Editor mit Schriftarten, Schriftgrößen, Farben, Ausrichtung und Tabellen
|
||||
- Hierarchische Ordner und Unterordner für eine BookStack-ähnliche Struktur
|
||||
- Breite Editoransicht und Fokusmodus innerhalb des NetBox-Layouts
|
||||
- Breite Editoransicht und TinyMCE-Vollbildmodus
|
||||
- Dokumentationen und Ordner optional Mandanten und Mandantengruppen zuweisen
|
||||
- Bilder direkt vom eigenen Gerät in die Dokumentation hochladen
|
||||
- Formatierte Word-Inhalte inklusive Tabellen und unterstützten Zwischenablage-Bildern einfügen
|
||||
- Mehrere Dokumentationen mit Ordnern, Zuordnungen und Anhängen als ZIP exportieren und wieder importieren
|
||||
@@ -28,7 +29,7 @@ Ein in NetBox integriertes Markdown-Wiki für Betriebsdokumentationen und Anleit
|
||||
|
||||
## Kompatibilität
|
||||
|
||||
Die Version `0.7.0` zielt auf NetBox 4.x (mindestens 4.0). Vor einem produktiven Rollout sollte das Plugin gegen die konkret eingesetzte NetBox-Minor-Version in einer Testinstanz geprüft werden.
|
||||
Die Version `0.9.2` zielt auf NetBox 4.x (mindestens 4.0). Vor einem produktiven Rollout sollte das Plugin gegen die konkret eingesetzte NetBox-Minor-Version in einer Testinstanz geprüft werden.
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig):
|
||||
name = "netbox_documentation"
|
||||
verbose_name = "NetBox Dokumentation"
|
||||
description = "Wiki und Office-Dokumentation direkt in NetBox"
|
||||
version = "0.7.11"
|
||||
version = "0.9.2"
|
||||
author = "LKE"
|
||||
base_url = "documentation"
|
||||
min_version = "4.0.0"
|
||||
|
||||
@@ -8,7 +8,7 @@ class DocumentSerializer(NetBoxModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Document
|
||||
fields = ("id", "url", "display", "title", "slug", "category", "summary", "body", "body_format", "is_published", "tags", "created", "last_updated")
|
||||
fields = ("id", "url", "display", "title", "slug", "category", "tenant_group", "tenant", "summary", "body", "body_format", "is_published", "tags", "created", "last_updated")
|
||||
|
||||
|
||||
class DocumentAssignmentSerializer(NetBoxModelSerializer):
|
||||
@@ -24,7 +24,7 @@ class DocumentCategorySerializer(NetBoxModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = DocumentCategory
|
||||
fields = ("id", "url", "display", "name", "slug", "parent", "description", "tags", "created", "last_updated")
|
||||
fields = ("id", "url", "display", "name", "slug", "parent", "tenant_group", "tenant", "description", "tags", "created", "last_updated")
|
||||
|
||||
|
||||
class DocumentAttachmentSerializer(NetBoxModelSerializer):
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import transaction
|
||||
from django.utils.text import slugify
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
|
||||
from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory
|
||||
|
||||
@@ -43,11 +44,25 @@ def _category_path(category):
|
||||
return list(reversed(path))
|
||||
|
||||
|
||||
def _category_tenancy_path(category):
|
||||
path, seen = [], set()
|
||||
while category and category.pk not in seen:
|
||||
path.append({
|
||||
"tenant": category.tenant.slug if category.tenant else None,
|
||||
"tenant_group": category.tenant_group.slug if category.tenant_group else None,
|
||||
})
|
||||
seen.add(category.pk)
|
||||
category = category.parent
|
||||
return list(reversed(path))
|
||||
|
||||
|
||||
def export_documents(documents):
|
||||
stream = SpooledTemporaryFile(max_size=10 * 1024 * 1024, mode="w+b")
|
||||
manifest = {"format": ARCHIVE_FORMAT, "version": ARCHIVE_VERSION, "documents": []}
|
||||
with ZipFile(stream, "w", compression=ZIP_DEFLATED, compresslevel=6) as archive:
|
||||
for document in documents.prefetch_related("assignments__assigned_object_type", "attachments").select_related("category"):
|
||||
for document in documents.prefetch_related("assignments__assigned_object_type", "attachments").select_related(
|
||||
"category", "tenant", "tenant_group", "category__tenant", "category__tenant_group",
|
||||
):
|
||||
root = f"documents/{document.pk}"
|
||||
extension = "html" if document.body_format == "html" else "md"
|
||||
body_path = f"{root}/content.{extension}"
|
||||
@@ -76,6 +91,9 @@ def export_documents(documents):
|
||||
"title": document.title, "slug": document.slug, "summary": document.summary,
|
||||
"body_format": document.body_format, "is_published": document.is_published,
|
||||
"category_path": _category_path(document.category), "body_path": body_path,
|
||||
"category_tenancy": _category_tenancy_path(document.category),
|
||||
"tenant": document.tenant.slug if document.tenant else None,
|
||||
"tenant_group": document.tenant_group.slug if document.tenant_group else None,
|
||||
"assignments": assignments, "attachments": attachments,
|
||||
})
|
||||
archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8"))
|
||||
@@ -105,9 +123,10 @@ def _read_json(archive, name):
|
||||
raise ArchiveFailure("Das Archiv enthält kein gültiges manifest.json.") from exc
|
||||
|
||||
|
||||
def _category_from_path(names):
|
||||
def _category_from_path(names, tenancy_path=None):
|
||||
parent = None
|
||||
for name in names:
|
||||
tenancy_path = tenancy_path or []
|
||||
for index, name in enumerate(names):
|
||||
name = str(name).strip()[:100]
|
||||
if not name:
|
||||
continue
|
||||
@@ -115,6 +134,11 @@ def _category_from_path(names):
|
||||
category = DocumentCategory.objects.filter(parent=parent, slug=slug).first()
|
||||
if not category:
|
||||
category = DocumentCategory.objects.create(name=name, slug=slug, parent=parent)
|
||||
tenancy = tenancy_path[index] if index < len(tenancy_path) and isinstance(tenancy_path[index], dict) else {}
|
||||
if tenancy:
|
||||
category.tenant_group = TenantGroup.objects.filter(slug=tenancy.get("tenant_group")).first()
|
||||
category.tenant = Tenant.objects.filter(slug=tenancy.get("tenant")).first()
|
||||
category.save(update_fields=("tenant_group", "tenant", "last_updated"))
|
||||
parent = category
|
||||
return parent
|
||||
|
||||
@@ -153,7 +177,9 @@ def import_archive(upload, update_existing=False):
|
||||
raise ArchiveFailure("Ein Dokumentinhalt fehlt oder ist nicht UTF-8-kodiert.") from exc
|
||||
source_slug = str(record.get("slug") or record["title"])
|
||||
document = Document.objects.filter(slug=source_slug).first() if update_existing else None
|
||||
category = _category_from_path(record.get("category_path") or [])
|
||||
category = _category_from_path(
|
||||
record.get("category_path") or [], record.get("category_tenancy") or [],
|
||||
)
|
||||
if document:
|
||||
updated += 1
|
||||
else:
|
||||
@@ -165,6 +191,8 @@ def import_archive(upload, update_existing=False):
|
||||
document.body_format = record.get("body_format") if record.get("body_format") in {"html", "markdown"} else "html"
|
||||
document.is_published = bool(record.get("is_published", True))
|
||||
document.category = category
|
||||
document.tenant_group = TenantGroup.objects.filter(slug=record.get("tenant_group")).first()
|
||||
document.tenant = Tenant.objects.filter(slug=record.get("tenant")).first()
|
||||
document.save()
|
||||
for assignment in record.get("assignments") or []:
|
||||
label = f"{assignment.get('app_label')}.{assignment.get('model')}"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import re
|
||||
|
||||
|
||||
class EmbeddedImageError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
DATA_IMAGE_RE = re.compile(
|
||||
r"(?P<prefix>src\s*=\s*(?P<quote>['\"]))"
|
||||
r"data:(?P<mime>image/(?:png|jpeg|gif|webp));base64,(?P<data>[^'\"]+)"
|
||||
r"(?P=quote)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _decode(match):
|
||||
payload = re.sub(r"\s+", "", match.group("data"))
|
||||
try:
|
||||
content = base64.b64decode(payload, validate=True)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise EmbeddedImageError("Ein eingefügtes Bild enthält ungültige Base64-Daten.") from exc
|
||||
try:
|
||||
from PIL import Image
|
||||
image = Image.open(BytesIO(content))
|
||||
image.verify()
|
||||
except Exception as exc:
|
||||
raise EmbeddedImageError("Ein eingefügter Inhalt ist keine gültige Bilddatei.") from exc
|
||||
return match.group("mime").lower(), content
|
||||
|
||||
|
||||
def validate_embedded_images(html, max_total_bytes):
|
||||
total = 0
|
||||
for match in DATA_IMAGE_RE.finditer(html or ""):
|
||||
_, content = _decode(match)
|
||||
total += len(content)
|
||||
if total > max_total_bytes:
|
||||
raise EmbeddedImageError("Die eingefügten Bilder überschreiten die erlaubte Gesamtgröße.")
|
||||
|
||||
|
||||
def store_embedded_images(html, max_total_bytes, store):
|
||||
total = 0
|
||||
|
||||
def replace(match):
|
||||
nonlocal total
|
||||
mime, content = _decode(match)
|
||||
total += len(content)
|
||||
if total > max_total_bytes:
|
||||
raise EmbeddedImageError("Die eingefügten Bilder überschreiten die erlaubte Gesamtgröße.")
|
||||
url = store(mime, content)
|
||||
return f'{match.group("prefix")}{url}{match.group("quote")}'
|
||||
|
||||
return DATA_IMAGE_RE.sub(replace, html or "")
|
||||
@@ -5,7 +5,7 @@ from .models import Document, DocumentAssignment, DocumentCategory
|
||||
class DocumentFilterSet(NetBoxModelFilterSet):
|
||||
class Meta:
|
||||
model = Document
|
||||
fields = ("id", "title", "slug", "category_id", "is_published")
|
||||
fields = ("id", "title", "slug", "category_id", "tenant_group_id", "tenant_id", "is_published")
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
from django.db.models import Q
|
||||
@@ -21,7 +21,7 @@ class AssignmentFilterSet(NetBoxModelFilterSet):
|
||||
class DocumentCategoryFilterSet(NetBoxModelFilterSet):
|
||||
class Meta:
|
||||
model = DocumentCategory
|
||||
fields = ("id", "name", "slug", "parent_id")
|
||||
fields = ("id", "name", "slug", "parent_id", "tenant_group_id", "tenant_id")
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
return queryset.filter(name__icontains=value)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db.models import Q
|
||||
from django.utils.html import format_html
|
||||
from utilities.forms import get_field_value
|
||||
@@ -11,7 +12,9 @@ from utilities.forms.rendering import FieldSet
|
||||
from utilities.forms.widgets import HTMXSelect
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from dcim.models import Device
|
||||
from .models import Document, DocumentAssignment, DocumentCategory
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from .embedded_images import EmbeddedImageError, store_embedded_images, validate_embedded_images
|
||||
from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory
|
||||
|
||||
|
||||
def help_label(label, description):
|
||||
@@ -40,15 +43,22 @@ class DocumentForm(NetBoxModelForm):
|
||||
}), help_text="Formatierter Text mit Tabellen, Farben, Schriftarten und Größen.")
|
||||
body_format = forms.CharField(widget=forms.HiddenInput(), initial="html")
|
||||
category = DynamicModelChoiceField(queryset=DocumentCategory.objects.all(), required=False, label="Ordner")
|
||||
tenant_group = DynamicModelChoiceField(
|
||||
queryset=TenantGroup.objects.all(), required=False, selector=True, label="Mandantengruppe",
|
||||
)
|
||||
tenant = DynamicModelChoiceField(
|
||||
queryset=Tenant.objects.all(), required=False, selector=True, label="Mandant",
|
||||
query_params={"group_id": "$tenant_group"},
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
FieldSet("title", "slug", "category", "summary", "is_published", "tags", name="Dokumentation"),
|
||||
FieldSet("title", "slug", "category", "tenant_group", "tenant", "summary", "is_published", "tags", name="Dokumentation"),
|
||||
FieldSet("body", "body_format", name="Inhalt"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Document
|
||||
fields = ("title", "slug", "category", "summary", "body", "body_format", "is_published", "tags")
|
||||
fields = ("title", "slug", "category", "tenant_group", "tenant", "summary", "body", "body_format", "is_published", "tags")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -59,6 +69,47 @@ class DocumentForm(NetBoxModelForm):
|
||||
self.initial["body"] = markdown(self.instance.body, extensions=("extra", "sane_lists"))
|
||||
self.initial["body_format"] = "html"
|
||||
|
||||
def clean_body(self):
|
||||
body = self.cleaned_data.get("body", "")
|
||||
limit = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("max_import_size_mb", 25)
|
||||
try:
|
||||
validate_embedded_images(body, limit * 1024 * 1024)
|
||||
except EmbeddedImageError as exc:
|
||||
raise forms.ValidationError(str(exc)) from exc
|
||||
return body
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
cleaned = self.cleaned_data
|
||||
tenant, group = cleaned.get("tenant"), cleaned.get("tenant_group")
|
||||
if tenant and group and tenant.group_id != group.pk:
|
||||
self.add_error("tenant", "Der Mandant gehört nicht zur ausgewählten Mandantengruppe.")
|
||||
return cleaned
|
||||
|
||||
def save(self, commit=True):
|
||||
document = super().save(commit=commit)
|
||||
if not commit or not document.pk or "data:image/" not in (document.body or "").lower():
|
||||
return document
|
||||
|
||||
limit = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("max_import_size_mb", 25)
|
||||
counter = 0
|
||||
|
||||
def save_image(mime, content):
|
||||
nonlocal counter
|
||||
counter += 1
|
||||
extension = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp"}[mime]
|
||||
name = f"editor-bild-{document.pk}-{counter}.{extension}"
|
||||
attachment = DocumentAttachment(
|
||||
document=document, original_name=name, content_type=mime, size=len(content),
|
||||
)
|
||||
attachment.file.save(name, ContentFile(content), save=False)
|
||||
attachment.save()
|
||||
return attachment.file.url
|
||||
|
||||
document.body = store_embedded_images(document.body, limit * 1024 * 1024, save_image)
|
||||
document.save(update_fields=("body", "last_updated"))
|
||||
return document
|
||||
|
||||
|
||||
class AssignmentForm(NetBoxModelForm):
|
||||
assigned_object_type = ContentTypeChoiceField(queryset=ContentType.objects.none(), label="Objekttyp", widget=HTMXSelect())
|
||||
@@ -99,18 +150,33 @@ class AssignmentForm(NetBoxModelForm):
|
||||
class DocumentCategoryForm(NetBoxModelForm):
|
||||
slug = SlugField(slug_source="name")
|
||||
parent = DynamicModelChoiceField(queryset=DocumentCategory.objects.all(), required=False, label="Übergeordneter Ordner")
|
||||
tenant_group = DynamicModelChoiceField(
|
||||
queryset=TenantGroup.objects.all(), required=False, selector=True, label="Mandantengruppe",
|
||||
)
|
||||
tenant = DynamicModelChoiceField(
|
||||
queryset=Tenant.objects.all(), required=False, selector=True, label="Mandant",
|
||||
query_params={"group_id": "$tenant_group"},
|
||||
)
|
||||
|
||||
fieldsets = (FieldSet("name", "slug", "parent", "description", "tags", name="Ordner"),)
|
||||
fieldsets = (FieldSet("name", "slug", "parent", "tenant_group", "tenant", "description", "tags", name="Ordner"),)
|
||||
|
||||
class Meta:
|
||||
model = DocumentCategory
|
||||
fields = ("name", "slug", "parent", "description", "tags")
|
||||
fields = ("name", "slug", "parent", "tenant_group", "tenant", "description", "tags")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.instance.pk:
|
||||
self.fields["parent"].queryset = DocumentCategory.objects.exclude(pk=self.instance.pk)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
cleaned = self.cleaned_data
|
||||
tenant, group = cleaned.get("tenant"), cleaned.get("tenant_group")
|
||||
if tenant and group and tenant.group_id != group.pk:
|
||||
self.add_error("tenant", "Der Mandant gehört nicht zur ausgewählten Mandantengruppe.")
|
||||
return cleaned
|
||||
|
||||
|
||||
class ImportForm(forms.Form):
|
||||
file = forms.FileField(label="Word-, Excel- oder PDF-Datei")
|
||||
@@ -232,6 +298,9 @@ class BaseExcelSheetSelectionFormSet(forms.BaseFormSet):
|
||||
indices = [item["index"] for item in selected]
|
||||
if len(indices) != len(set(indices)):
|
||||
raise forms.ValidationError("Die Arbeitsblattauswahl ist ungültig.")
|
||||
titles = [item["title"].strip().casefold() for item in selected]
|
||||
if len(titles) != len(set(titles)):
|
||||
raise forms.ValidationError("Ausgewählte Arbeitsblätter dürfen nicht denselben Dokumenttitel haben.")
|
||||
|
||||
|
||||
ExcelSheetSelectionFormSet = forms.formset_factory(
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("netbox_documentation", "0004_excelimportpreview"),
|
||||
("tenancy", "0001_squashed_0012"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="document",
|
||||
name="tenant_group",
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="documentation_documents", to="tenancy.tenantgroup",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="document",
|
||||
name="tenant",
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="documentation_documents", to="tenancy.tenant",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="documentcategory",
|
||||
name="tenant_group",
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="documentation_categories", to="tenancy.tenantgroup",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="documentcategory",
|
||||
name="tenant",
|
||||
field=models.ForeignKey(
|
||||
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="documentation_categories", to="tenancy.tenant",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -19,6 +19,14 @@ class Document(NetBoxModel):
|
||||
summary = models.CharField(max_length=500, blank=True)
|
||||
is_published = models.BooleanField(default=True)
|
||||
category = models.ForeignKey("DocumentCategory", on_delete=models.SET_NULL, null=True, blank=True, related_name="documents")
|
||||
tenant_group = models.ForeignKey(
|
||||
"tenancy.TenantGroup", on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name="documentation_documents",
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
"tenancy.Tenant", on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name="documentation_documents",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ("title",)
|
||||
@@ -68,6 +76,14 @@ class DocumentCategory(NetBoxModel):
|
||||
slug = models.SlugField(max_length=100)
|
||||
parent = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="children")
|
||||
description = models.CharField(max_length=500, blank=True)
|
||||
tenant_group = models.ForeignKey(
|
||||
"tenancy.TenantGroup", on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name="documentation_categories",
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
"tenancy.Tenant", on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name="documentation_categories",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ("name",)
|
||||
|
||||
@@ -10,7 +10,7 @@ class DocumentTable(NetBoxTable):
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Document
|
||||
fields = ("pk", "title", "category", "summary", "is_published", "assignments", "last_updated", "actions")
|
||||
fields = ("pk", "title", "category", "tenant_group", "tenant", "summary", "is_published", "assignments", "last_updated", "actions")
|
||||
|
||||
|
||||
class DocumentCategoryTable(NetBoxTable):
|
||||
@@ -20,7 +20,7 @@ class DocumentCategoryTable(NetBoxTable):
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = DocumentCategory
|
||||
fields = ("pk", "name", "parent", "description", "document_count", "actions")
|
||||
fields = ("pk", "name", "parent", "tenant_group", "tenant", "description", "document_count", "actions")
|
||||
|
||||
|
||||
class AssignmentTable(NetBoxTable):
|
||||
|
||||
@@ -78,6 +78,10 @@
|
||||
<div class="card"><div class="card-body rendered-markdown documentation-content">{{ object.rendered_body|safe }}</div></div>
|
||||
</div>
|
||||
<div class="col col-md-3">
|
||||
{% if object.tenant_group or object.tenant %}<div class="card mb-3"><h5 class="card-header">Mandantenzuordnung</h5><div class="list-group list-group-flush">
|
||||
{% if object.tenant_group %}<a class="list-group-item list-group-item-action" href="{{ object.tenant_group.get_absolute_url }}"><i class="mdi mdi-account-group"></i> {{ object.tenant_group }}</a>{% endif %}
|
||||
{% if object.tenant %}<a class="list-group-item list-group-item-action" href="{{ object.tenant.get_absolute_url }}"><i class="mdi mdi-domain"></i> {{ object.tenant }}</a>{% endif %}
|
||||
</div></div>{% endif %}
|
||||
{% if object.category %}<div class="card mb-3"><h5 class="card-header">Ordner</h5><a class="list-group-item list-group-item-action" href="{{ object.category.get_absolute_url }}"><i class="mdi mdi-folder"></i> {{ object.category }}</a></div>{% endif %}
|
||||
<div class="card"><h5 class="card-header">Zuordnungen</h5><div class="list-group list-group-flush">
|
||||
{% for assignment in object.assignments.all %}<a class="list-group-item" href="{{ assignment.assigned_object.get_absolute_url }}">{{ assignment.assigned_object_type }}: {{ assignment.assigned_object }}</a>{% empty %}<div class="list-group-item text-muted">Noch nicht zugeordnet</div>{% endfor %}
|
||||
|
||||
@@ -101,6 +101,30 @@
|
||||
paste_convert_word_fake_lists: true,
|
||||
smart_paste: true,
|
||||
images_reuse_filename: true,
|
||||
file_picker_types: 'image',
|
||||
file_picker_callback: (callback, value, meta) => {
|
||||
if (meta.filetype !== 'image') return;
|
||||
const picker = document.createElement('input');
|
||||
picker.type = 'file';
|
||||
picker.accept = 'image/png,image/jpeg,image/gif,image/webp';
|
||||
picker.addEventListener('change', () => {
|
||||
const file = picker.files && picker.files[0];
|
||||
if (!file) return;
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
window.alert('Unterstützt werden PNG, JPEG, GIF und WebP.');
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', () => callback(reader.result, {
|
||||
alt: file.name,
|
||||
title: file.name
|
||||
}));
|
||||
reader.addEventListener('error', () => window.alert('Das Bild konnte nicht gelesen werden.'));
|
||||
reader.readAsDataURL(file);
|
||||
}, {once: true});
|
||||
picker.click();
|
||||
},
|
||||
relative_urls: false,
|
||||
setup: editor => {
|
||||
const selectedTableCells = () => {
|
||||
@@ -175,6 +199,6 @@
|
||||
|
||||
{% block pre_form_fields %}
|
||||
{% if not object.pk %}
|
||||
<div class="alert alert-info">Speichere die neue Dokumentation einmal. Danach können Bilder direkt über die Editor-Werkzeugleiste hochgeladen werden.</div>
|
||||
<div class="alert alert-info">Eingefügte oder hineinkopierte Bilder werden beim ersten Speichern automatisch als Medien der neuen Dokumentation abgelegt. Nach dem ersten Speichern ist zusätzlich der direkte Bildupload verfügbar.</div>
|
||||
{% endif %}
|
||||
{% endblock pre_form_fields %}
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-md-4">
|
||||
{% if object.tenant_group or object.tenant %}
|
||||
<div class="card mb-3">
|
||||
<h5 class="card-header">Mandantenzuordnung</h5>
|
||||
<div class="list-group list-group-flush">
|
||||
{% if object.tenant_group %}<a class="list-group-item list-group-item-action" href="{{ object.tenant_group.get_absolute_url }}"><i class="mdi mdi-account-group"></i> {{ object.tenant_group }}</a>{% endif %}
|
||||
{% if object.tenant %}<a class="list-group-item list-group-item-action" href="{{ object.tenant.get_absolute_url }}"><i class="mdi mdi-domain"></i> {{ object.tenant }}</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">Unterordner</h5>
|
||||
<div class="list-group list-group-flush">
|
||||
|
||||
@@ -26,6 +26,16 @@
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% if overwrite_conflicts %}
|
||||
<input type="hidden" name="confirm_overwrite" value="1">
|
||||
<div class="alert alert-warning mx-0" role="alert">
|
||||
<h3 class="alert-title">Bestehende Dokumentationen aktualisieren?</h3>
|
||||
<p>Die folgenden Dokumentationen existieren bereits im Zielordner. Ihr Inhalt wird überschrieben; bestehende NetBox-Zuordnungen bleiben erhalten:</p>
|
||||
<ul class="mb-0">
|
||||
{% for document in overwrite_conflicts %}<li>{{ document.title }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ formset.management_form }}
|
||||
{% if formset.non_form_errors %}<div class="alert alert-danger">{{ formset.non_form_errors }}</div>{% endif %}
|
||||
|
||||
@@ -62,7 +72,10 @@
|
||||
<div class="sticky-actions sticky-actions-footer d-print-none" data-sticky-position="full" data-sticky-when="always">
|
||||
<div class="btn-list">
|
||||
<button type="submit" name="action" value="cancel" class="btn btn-outline-secondary">Abbrechen</button>
|
||||
<button type="submit" name="action" value="confirm" class="btn btn-primary"><i class="mdi mdi-file-import"></i> Ausgewählte Arbeitsblätter importieren</button>
|
||||
<button type="submit" name="action" value="confirm" class="btn {% if overwrite_conflicts %}btn-warning{% else %}btn-primary{% endif %}">
|
||||
<i class="mdi mdi-file-import"></i>
|
||||
{% if overwrite_conflicts %}Überschreiben und importieren{% else %}Ausgewählte Arbeitsblätter importieren{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.contrib import messages
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.http import FileResponse, Http404, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.text import slugify
|
||||
@@ -45,14 +46,14 @@ class EditorAssetView(View):
|
||||
|
||||
|
||||
class DocumentListView(generic.ObjectListView):
|
||||
queryset = Document.objects.prefetch_related("assignments")
|
||||
queryset = Document.objects.select_related("category", "tenant_group", "tenant").prefetch_related("assignments")
|
||||
table = DocumentTable
|
||||
filterset = DocumentFilterSet
|
||||
actions = (AddObject, BulkImport, BulkDelete)
|
||||
|
||||
|
||||
class DocumentView(generic.ObjectView):
|
||||
queryset = Document.objects.prefetch_related("assignments", "attachments")
|
||||
queryset = Document.objects.select_related("category", "tenant_group", "tenant").prefetch_related("assignments", "attachments")
|
||||
|
||||
|
||||
class DocumentPrintView(PermissionRequiredMixin, View):
|
||||
@@ -84,14 +85,14 @@ class DocumentBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
|
||||
class DocumentCategoryListView(generic.ObjectListView):
|
||||
queryset = DocumentCategory.objects.select_related("parent").prefetch_related("documents")
|
||||
queryset = DocumentCategory.objects.select_related("parent", "tenant_group", "tenant").prefetch_related("documents")
|
||||
table = DocumentCategoryTable
|
||||
filterset = DocumentCategoryFilterSet
|
||||
actions = (AddObject, BulkImport, BulkDelete)
|
||||
|
||||
|
||||
class DocumentCategoryView(generic.ObjectView):
|
||||
queryset = DocumentCategory.objects.prefetch_related("children", "documents")
|
||||
queryset = DocumentCategory.objects.select_related("tenant_group", "tenant").prefetch_related("children", "documents")
|
||||
|
||||
|
||||
class DocumentCategoryEditView(generic.ObjectEditView):
|
||||
@@ -169,8 +170,12 @@ class DocumentImportView(PermissionRequiredMixin, View):
|
||||
while Document.objects.filter(slug=slug).exists():
|
||||
slug = f"{base_slug}-{counter}"
|
||||
counter += 1
|
||||
category = form.cleaned_data.get("category")
|
||||
document = Document.objects.create(
|
||||
title=title, slug=slug, body=result.markdown, body_format=result.body_format,
|
||||
category=category,
|
||||
tenant_group=category.tenant_group if category else None,
|
||||
tenant=category.tenant if category else None,
|
||||
)
|
||||
keep = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True)
|
||||
if keep:
|
||||
@@ -228,7 +233,7 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
|
||||
finally:
|
||||
preview.file.close()
|
||||
|
||||
def render_preview(self, request, preview, sheets, formset):
|
||||
def render_preview(self, request, preview, sheets, formset, overwrite_conflicts=None):
|
||||
items = []
|
||||
for form, sheet in zip(formset.forms, sheets):
|
||||
sample = sheet.markdown
|
||||
@@ -251,6 +256,7 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
|
||||
})
|
||||
return render(request, self.template_name, {
|
||||
"batch": preview, "formset": formset, "items": items,
|
||||
"overwrite_conflicts": overwrite_conflicts or [],
|
||||
})
|
||||
|
||||
def get(self, request, token):
|
||||
@@ -296,6 +302,29 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
|
||||
metadata = preview.sheet_metadata[data["index"]]
|
||||
source_index = metadata.get("source_index", data["index"])
|
||||
selections[source_index] = data["title"].strip()
|
||||
title_query = Q()
|
||||
for title in selections.values():
|
||||
title_query |= Q(title__iexact=title)
|
||||
conflicts = list(Document.objects.filter(
|
||||
title_query, category=preview.category,
|
||||
).order_by("title"))
|
||||
if conflicts:
|
||||
allowed_ids = set(Document.objects.restrict(request.user, "change").filter(
|
||||
pk__in=[document.pk for document in conflicts]
|
||||
).values_list("pk", flat=True))
|
||||
if any(document.pk not in allowed_ids for document in conflicts):
|
||||
messages.error(request, "Mindestens eine gleichnamige Dokumentation darf nicht aktualisiert werden.")
|
||||
return redirect("plugins:netbox_documentation:excel_import_preview", token=preview.pk)
|
||||
if request.POST.get("confirm_overwrite") != "1":
|
||||
try:
|
||||
preview_sheets = self.read_sheets(preview, for_preview=True)
|
||||
except (ImportFailure, Exception) as exc:
|
||||
messages.error(request, f"Die Bestätigung konnte nicht angezeigt werden: {exc}")
|
||||
return redirect("plugins:netbox_documentation:excel_import_preview", token=preview.pk)
|
||||
return self.render_preview(
|
||||
request, preview, preview_sheets, formset,
|
||||
overwrite_conflicts=conflicts,
|
||||
)
|
||||
try:
|
||||
selected_sheets = self.read_sheets(preview, selected_indexes=selections)
|
||||
except (ImportFailure, Exception) as exc:
|
||||
@@ -305,7 +334,8 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
|
||||
result = self.create_documents(preview, sheets_by_index, selections)
|
||||
self.delete_preview(preview)
|
||||
messages.success(request, (
|
||||
f"{result['documents']} Arbeitsblätter als Dokumentationen in „{result['category']}“ importiert; "
|
||||
f"{result['documents']} Arbeitsblätter verarbeitet "
|
||||
f"({result['created']} neu, {result['updated']} aktualisiert) in „{result['category']}“; "
|
||||
f"{result['images']} Bilder übernommen."
|
||||
))
|
||||
return redirect("plugins:netbox_documentation:document_list")
|
||||
@@ -314,19 +344,36 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
|
||||
@transaction.atomic
|
||||
def create_documents(preview, sheets, selections):
|
||||
from django.core.files.base import ContentFile
|
||||
created_documents, image_count = [], 0
|
||||
created_documents, image_count, created_count, updated_count = [], 0, 0, 0
|
||||
for index, title in selections.items():
|
||||
sheet = sheets[index]
|
||||
base_slug = slugify(title)[:180] or "arbeitsblatt"
|
||||
document_slug, counter = base_slug, 2
|
||||
while Document.objects.filter(slug=document_slug).exists():
|
||||
document_slug = f"{base_slug[:190-len(str(counter))]}-{counter}"
|
||||
counter += 1
|
||||
document = Document.objects.create(
|
||||
title=title[:200], slug=document_slug, body=sheet.markdown,
|
||||
body_format=sheet.body_format, category=preview.category,
|
||||
summary=f"Importiert aus {preview.original_name}",
|
||||
)
|
||||
document = Document.objects.select_for_update().filter(
|
||||
category=preview.category, title__iexact=title,
|
||||
).order_by("pk").first()
|
||||
if document:
|
||||
document.title = title[:200]
|
||||
document.body = sheet.markdown
|
||||
document.body_format = sheet.body_format
|
||||
document.summary = f"Importiert aus {preview.original_name}"
|
||||
document.tenant_group = preview.category.tenant_group
|
||||
document.tenant = preview.category.tenant
|
||||
document.save(update_fields=(
|
||||
"title", "body", "body_format", "summary", "tenant_group", "tenant", "last_updated",
|
||||
))
|
||||
updated_count += 1
|
||||
else:
|
||||
base_slug = slugify(title)[:180] or "arbeitsblatt"
|
||||
document_slug, counter = base_slug, 2
|
||||
while Document.objects.filter(slug=document_slug).exists():
|
||||
document_slug = f"{base_slug[:190-len(str(counter))]}-{counter}"
|
||||
counter += 1
|
||||
document = Document.objects.create(
|
||||
title=title[:200], slug=document_slug, body=sheet.markdown,
|
||||
body_format=sheet.body_format, category=preview.category,
|
||||
tenant_group=preview.category.tenant_group, tenant=preview.category.tenant,
|
||||
summary=f"Importiert aus {preview.original_name}",
|
||||
)
|
||||
created_count += 1
|
||||
if sheet.images:
|
||||
image_lines = []
|
||||
for image in sheet.images:
|
||||
@@ -362,7 +409,10 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
|
||||
attachment.save()
|
||||
finally:
|
||||
preview.file.close()
|
||||
return {"documents": len(created_documents), "images": image_count, "category": preview.category}
|
||||
return {
|
||||
"documents": len(created_documents), "created": created_count,
|
||||
"updated": updated_count, "images": image_count, "category": preview.category,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def delete_preview(preview):
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "netbox-documentation"
|
||||
version = "0.7.11"
|
||||
version = "0.9.2"
|
||||
description = "Integrated Markdown wiki and office document importer for NetBox"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"documentation_embedded_images",
|
||||
Path(__file__).parents[1] / "netbox_documentation" / "embedded_images.py",
|
||||
)
|
||||
embedded = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = embedded
|
||||
spec.loader.exec_module(embedded)
|
||||
|
||||
|
||||
def image_data_url():
|
||||
stream = BytesIO()
|
||||
Image.new("RGB", (2, 2), "red").save(stream, format="PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(stream.getvalue()).decode()
|
||||
|
||||
|
||||
def test_embedded_image_is_replaced_with_stored_url():
|
||||
html = f'<p><img alt="Test" src="{image_data_url()}"></p>'
|
||||
stored = []
|
||||
|
||||
def store(mime, content):
|
||||
stored.append((mime, content))
|
||||
return "/media/documentation/bild.png"
|
||||
|
||||
result = embedded.store_embedded_images(html, 1024 * 1024, store)
|
||||
|
||||
assert 'src="/media/documentation/bild.png"' in result
|
||||
assert "data:image/" not in result
|
||||
assert stored[0][0] == "image/png"
|
||||
assert stored[0][1]
|
||||
|
||||
|
||||
def test_embedded_images_respect_total_size_limit():
|
||||
html = f'<img src="{image_data_url()}">'
|
||||
try:
|
||||
embedded.validate_embedded_images(html, 1)
|
||||
except embedded.EmbeddedImageError as exc:
|
||||
assert "Gesamtgröße" in str(exc)
|
||||
else:
|
||||
raise AssertionError("Größenlimit wurde nicht angewendet")
|
||||
Reference in New Issue
Block a user