Compare commits

...
8 Commits
20 changed files with 430 additions and 43 deletions
+3 -2
View File
@@ -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 - Dokumentationen direkt in NetBox schreiben und als sicher bereinigten Rich Text anzeigen
- Word-ähnlicher WYSIWYG-Editor mit Schriftarten, Schriftgrößen, Farben, Ausrichtung und Tabellen - Word-ähnlicher WYSIWYG-Editor mit Schriftarten, Schriftgrößen, Farben, Ausrichtung und Tabellen
- Hierarchische Ordner und Unterordner für eine BookStack-ähnliche Struktur - 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 - Bilder direkt vom eigenen Gerät in die Dokumentation hochladen
- Formatierte Word-Inhalte inklusive Tabellen und unterstützten Zwischenablage-Bildern einfügen - 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 - 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 ## 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 ## Installation
+1 -1
View File
@@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig):
name = "netbox_documentation" name = "netbox_documentation"
verbose_name = "NetBox Dokumentation" verbose_name = "NetBox Dokumentation"
description = "Wiki und Office-Dokumentation direkt in NetBox" description = "Wiki und Office-Dokumentation direkt in NetBox"
version = "0.7.9" version = "0.9.2"
author = "LKE" author = "LKE"
base_url = "documentation" base_url = "documentation"
min_version = "4.0.0" min_version = "4.0.0"
+2 -2
View File
@@ -8,7 +8,7 @@ class DocumentSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = Document 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): class DocumentAssignmentSerializer(NetBoxModelSerializer):
@@ -24,7 +24,7 @@ class DocumentCategorySerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = DocumentCategory 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): class DocumentAttachmentSerializer(NetBoxModelSerializer):
+32 -4
View File
@@ -7,6 +7,7 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import transaction from django.db import transaction
from django.utils.text import slugify from django.utils.text import slugify
from tenancy.models import Tenant, TenantGroup
from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory
@@ -43,11 +44,25 @@ def _category_path(category):
return list(reversed(path)) 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): def export_documents(documents):
stream = SpooledTemporaryFile(max_size=10 * 1024 * 1024, mode="w+b") stream = SpooledTemporaryFile(max_size=10 * 1024 * 1024, mode="w+b")
manifest = {"format": ARCHIVE_FORMAT, "version": ARCHIVE_VERSION, "documents": []} manifest = {"format": ARCHIVE_FORMAT, "version": ARCHIVE_VERSION, "documents": []}
with ZipFile(stream, "w", compression=ZIP_DEFLATED, compresslevel=6) as archive: 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}" root = f"documents/{document.pk}"
extension = "html" if document.body_format == "html" else "md" extension = "html" if document.body_format == "html" else "md"
body_path = f"{root}/content.{extension}" body_path = f"{root}/content.{extension}"
@@ -76,6 +91,9 @@ def export_documents(documents):
"title": document.title, "slug": document.slug, "summary": document.summary, "title": document.title, "slug": document.slug, "summary": document.summary,
"body_format": document.body_format, "is_published": document.is_published, "body_format": document.body_format, "is_published": document.is_published,
"category_path": _category_path(document.category), "body_path": body_path, "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, "assignments": assignments, "attachments": attachments,
}) })
archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8")) 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 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 parent = None
for name in names: tenancy_path = tenancy_path or []
for index, name in enumerate(names):
name = str(name).strip()[:100] name = str(name).strip()[:100]
if not name: if not name:
continue continue
@@ -115,6 +134,11 @@ def _category_from_path(names):
category = DocumentCategory.objects.filter(parent=parent, slug=slug).first() category = DocumentCategory.objects.filter(parent=parent, slug=slug).first()
if not category: if not category:
category = DocumentCategory.objects.create(name=name, slug=slug, parent=parent) 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 parent = category
return parent 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 raise ArchiveFailure("Ein Dokumentinhalt fehlt oder ist nicht UTF-8-kodiert.") from exc
source_slug = str(record.get("slug") or record["title"]) source_slug = str(record.get("slug") or record["title"])
document = Document.objects.filter(slug=source_slug).first() if update_existing else None 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: if document:
updated += 1 updated += 1
else: 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.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.is_published = bool(record.get("is_published", True))
document.category = category 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() document.save()
for assignment in record.get("assignments") or []: for assignment in record.get("assignments") or []:
label = f"{assignment.get('app_label')}.{assignment.get('model')}" label = f"{assignment.get('app_label')}.{assignment.get('model')}"
+54
View File
@@ -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 "")
+2 -2
View File
@@ -5,7 +5,7 @@ from .models import Document, DocumentAssignment, DocumentCategory
class DocumentFilterSet(NetBoxModelFilterSet): class DocumentFilterSet(NetBoxModelFilterSet):
class Meta: class Meta:
model = Document 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): def search(self, queryset, name, value):
from django.db.models import Q from django.db.models import Q
@@ -21,7 +21,7 @@ class AssignmentFilterSet(NetBoxModelFilterSet):
class DocumentCategoryFilterSet(NetBoxModelFilterSet): class DocumentCategoryFilterSet(NetBoxModelFilterSet):
class Meta: class Meta:
model = DocumentCategory 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): def search(self, queryset, name, value):
return queryset.filter(name__icontains=value) return queryset.filter(name__icontains=value)
+74 -5
View File
@@ -1,6 +1,7 @@
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.files.base import ContentFile
from django.db.models import Q from django.db.models import Q
from django.utils.html import format_html from django.utils.html import format_html
from utilities.forms import get_field_value from utilities.forms import get_field_value
@@ -11,7 +12,9 @@ from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import HTMXSelect from utilities.forms.widgets import HTMXSelect
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from dcim.models import Device 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): def help_label(label, description):
@@ -40,15 +43,22 @@ class DocumentForm(NetBoxModelForm):
}), help_text="Formatierter Text mit Tabellen, Farben, Schriftarten und Größen.") }), help_text="Formatierter Text mit Tabellen, Farben, Schriftarten und Größen.")
body_format = forms.CharField(widget=forms.HiddenInput(), initial="html") body_format = forms.CharField(widget=forms.HiddenInput(), initial="html")
category = DynamicModelChoiceField(queryset=DocumentCategory.objects.all(), required=False, label="Ordner") 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 = ( 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"), FieldSet("body", "body_format", name="Inhalt"),
) )
class Meta: class Meta:
model = Document 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): def __init__(self, *args, **kwargs):
super().__init__(*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"] = markdown(self.instance.body, extensions=("extra", "sane_lists"))
self.initial["body_format"] = "html" 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): class AssignmentForm(NetBoxModelForm):
assigned_object_type = ContentTypeChoiceField(queryset=ContentType.objects.none(), label="Objekttyp", widget=HTMXSelect()) assigned_object_type = ContentTypeChoiceField(queryset=ContentType.objects.none(), label="Objekttyp", widget=HTMXSelect())
@@ -99,18 +150,33 @@ class AssignmentForm(NetBoxModelForm):
class DocumentCategoryForm(NetBoxModelForm): class DocumentCategoryForm(NetBoxModelForm):
slug = SlugField(slug_source="name") slug = SlugField(slug_source="name")
parent = DynamicModelChoiceField(queryset=DocumentCategory.objects.all(), required=False, label="Übergeordneter Ordner") 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: class Meta:
model = DocumentCategory model = DocumentCategory
fields = ("name", "slug", "parent", "description", "tags") fields = ("name", "slug", "parent", "tenant_group", "tenant", "description", "tags")
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.instance.pk: if self.instance.pk:
self.fields["parent"].queryset = DocumentCategory.objects.exclude(pk=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): class ImportForm(forms.Form):
file = forms.FileField(label="Word-, Excel- oder PDF-Datei") file = forms.FileField(label="Word-, Excel- oder PDF-Datei")
@@ -232,6 +298,9 @@ class BaseExcelSheetSelectionFormSet(forms.BaseFormSet):
indices = [item["index"] for item in selected] indices = [item["index"] for item in selected]
if len(indices) != len(set(indices)): if len(indices) != len(set(indices)):
raise forms.ValidationError("Die Arbeitsblattauswahl ist ungültig.") 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( ExcelSheetSelectionFormSet = forms.formset_factory(
+7 -2
View File
@@ -382,9 +382,14 @@ def _render_flattened_worksheet(sheet, workbook, max_rows=None):
tables_starting_here.append(bounds) tables_starting_here.append(bounds)
rendered_tables.add(bounds) rendered_tables.add(bounds)
continue continue
value = sheet.cell(row=row_number, column=column_number).value cell = sheet.cell(row=row_number, column=column_number)
value = cell.value
if value not in (None, ""): if value not in (None, ""):
ordinary_values.append(escape(str(value)).replace("\n", "<br>")) rendered_value = escape(str(value)).replace("\n", "<br>")
style = _excel_cell_style(cell, workbook) if cell.has_style else ""
if style:
rendered_value = f'<span style="{style}">{rendered_value}</span>'
ordinary_values.append(rendered_value)
if ordinary_values: if ordinary_values:
blocks.append("<p>" + "<br>".join(ordinary_values) + "</p>") blocks.append("<p>" + "<br>".join(ordinary_values) + "</p>")
for min_col, min_row, max_col, max_row in tables_starting_here: for min_col, min_row, max_col, max_row in tables_starting_here:
@@ -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",
),
),
]
+16
View File
@@ -19,6 +19,14 @@ class Document(NetBoxModel):
summary = models.CharField(max_length=500, blank=True) summary = models.CharField(max_length=500, blank=True)
is_published = models.BooleanField(default=True) is_published = models.BooleanField(default=True)
category = models.ForeignKey("DocumentCategory", on_delete=models.SET_NULL, null=True, blank=True, related_name="documents") 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: class Meta:
ordering = ("title",) ordering = ("title",)
@@ -68,6 +76,14 @@ class DocumentCategory(NetBoxModel):
slug = models.SlugField(max_length=100) slug = models.SlugField(max_length=100)
parent = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="children") parent = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="children")
description = models.CharField(max_length=500, blank=True) 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: class Meta:
ordering = ("name",) ordering = ("name",)
+2 -2
View File
@@ -10,7 +10,7 @@ class DocumentTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Document 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): class DocumentCategoryTable(NetBoxTable):
@@ -20,7 +20,7 @@ class DocumentCategoryTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = DocumentCategory model = DocumentCategory
fields = ("pk", "name", "parent", "description", "document_count", "actions") fields = ("pk", "name", "parent", "tenant_group", "tenant", "description", "document_count", "actions")
class AssignmentTable(NetBoxTable): 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 class="card"><div class="card-body rendered-markdown documentation-content">{{ object.rendered_body|safe }}</div></div>
</div> </div>
<div class="col col-md-3"> <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 %} {% 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"> <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 %} {% 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, paste_convert_word_fake_lists: true,
smart_paste: true, smart_paste: true,
images_reuse_filename: 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, relative_urls: false,
setup: editor => { setup: editor => {
const selectedTableCells = () => { const selectedTableCells = () => {
@@ -175,6 +199,6 @@
{% block pre_form_fields %} {% block pre_form_fields %}
{% if not object.pk %} {% 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 %} {% endif %}
{% endblock pre_form_fields %} {% endblock pre_form_fields %}
@@ -11,7 +11,9 @@
.toolbar { position: sticky; top: 0; z-index: 2; display: flex; justify-content: center; gap: .75rem; padding: .8rem; background: #fff; border-bottom: 1px solid #ccd2d8; } .toolbar { position: sticky; top: 0; z-index: 2; display: flex; justify-content: center; gap: .75rem; padding: .8rem; background: #fff; border-bottom: 1px solid #ccd2d8; }
.toolbar button, .toolbar a { border: 1px solid #6c757d; border-radius: .3rem; padding: .55rem .9rem; background: #fff; color: #212529; text-decoration: none; cursor: pointer; font: inherit; } .toolbar button, .toolbar a { border: 1px solid #6c757d; border-radius: .3rem; padding: .55rem .9rem; background: #fff; color: #212529; text-decoration: none; cursor: pointer; font: inherit; }
.toolbar button { color: #fff; background: #4263eb; border-color: #4263eb; } .toolbar button { color: #fff; background: #4263eb; border-color: #4263eb; }
.toolbar a.active { color: #fff; background: #495057; border-color: #495057; }
article { width: min(210mm, calc(100% - 2rem)); min-height: 297mm; margin: 1.5rem auto; padding: 18mm 17mm; background: #fff; box-shadow: 0 .2rem 1rem rgba(0,0,0,.12); } article { width: min(210mm, calc(100% - 2rem)); min-height: 297mm; margin: 1.5rem auto; padding: 18mm 17mm; background: #fff; box-shadow: 0 .2rem 1rem rgba(0,0,0,.12); }
article.landscape { width: min(297mm, calc(100% - 2rem)); min-height: 210mm; }
h1 { margin: 0 0 .25rem; font-size: 24pt; line-height: 1.2; } h1 { margin: 0 0 .25rem; font-size: 24pt; line-height: 1.2; }
.summary { color: #4b5563; font-size: 13pt; margin: 0 0 1rem; } .summary { color: #4b5563; font-size: 13pt; margin: 0 0 1rem; }
.meta { display: flex; flex-wrap: wrap; gap: .4rem 1.25rem; padding: .65rem 0; margin-bottom: 1.4rem; color: #6b7280; border-top: 1px solid #ddd; border-bottom: 1px solid #ddd; font-size: 9pt; } .meta { display: flex; flex-wrap: wrap; gap: .4rem 1.25rem; padding: .65rem 0; margin-bottom: 1.4rem; color: #6b7280; border-top: 1px solid #ddd; border-bottom: 1px solid #ddd; font-size: 9pt; }
@@ -32,7 +34,7 @@
.content .doc-cell-middle { vertical-align: middle; } .content .doc-cell-middle { vertical-align: middle; }
.content pre, .content code { white-space: pre-wrap; overflow-wrap: anywhere; font-family: Consolas, monospace; } .content pre, .content code { white-space: pre-wrap; overflow-wrap: anywhere; font-family: Consolas, monospace; }
.content a { color: #174ea6; overflow-wrap: anywhere; } .content a { color: #174ea6; overflow-wrap: anywhere; }
@page { size: A4; margin: 15mm; } @page { size: A4 {% if request.GET.orientation == 'landscape' %}landscape{% else %}portrait{% endif %}; margin: 15mm; }
@media print { @media print {
body { background: #fff; } body { background: #fff; }
.toolbar { display: none !important; } .toolbar { display: none !important; }
@@ -46,9 +48,11 @@
<body> <body>
<div class="toolbar"> <div class="toolbar">
<button type="button" onclick="window.print()">Drucken / Als PDF speichern</button> <button type="button" onclick="window.print()">Drucken / Als PDF speichern</button>
<a href="?orientation=portrait"{% if request.GET.orientation != 'landscape' %} class="active"{% endif %}>Hochformat</a>
<a href="?orientation=landscape"{% if request.GET.orientation == 'landscape' %} class="active"{% endif %}>Querformat</a>
<a href="{{ object.get_absolute_url }}">Zurück zur Dokumentation</a> <a href="{{ object.get_absolute_url }}">Zurück zur Dokumentation</a>
</div> </div>
<article> <article{% if request.GET.orientation == 'landscape' %} class="landscape"{% endif %}>
<header> <header>
<h1>{{ object.title }}</h1> <h1>{{ object.title }}</h1>
{% if object.summary %}<p class="summary">{{ object.summary }}</p>{% endif %} {% if object.summary %}<p class="summary">{{ object.summary }}</p>{% endif %}
@@ -3,6 +3,15 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-4"> <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"> <div class="card">
<h5 class="card-header">Unterordner</h5> <h5 class="card-header">Unterordner</h5>
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
@@ -26,6 +26,16 @@
<form method="post"> <form method="post">
{% csrf_token %} {% 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 }} {{ formset.management_form }}
{% if formset.non_form_errors %}<div class="alert alert-danger">{{ formset.non_form_errors }}</div>{% endif %} {% 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="sticky-actions sticky-actions-footer d-print-none" data-sticky-position="full" data-sticky-when="always">
<div class="btn-list"> <div class="btn-list">
<button type="submit" name="action" value="cancel" class="btn btn-outline-secondary">Abbrechen</button> <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>
</div> </div>
</form> </form>
+58 -8
View File
@@ -8,6 +8,7 @@ from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models import Q
from django.http import FileResponse, Http404, JsonResponse from django.http import FileResponse, Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils.text import slugify from django.utils.text import slugify
@@ -45,14 +46,14 @@ class EditorAssetView(View):
class DocumentListView(generic.ObjectListView): class DocumentListView(generic.ObjectListView):
queryset = Document.objects.prefetch_related("assignments") queryset = Document.objects.select_related("category", "tenant_group", "tenant").prefetch_related("assignments")
table = DocumentTable table = DocumentTable
filterset = DocumentFilterSet filterset = DocumentFilterSet
actions = (AddObject, BulkImport, BulkDelete) actions = (AddObject, BulkImport, BulkDelete)
class DocumentView(generic.ObjectView): 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): class DocumentPrintView(PermissionRequiredMixin, View):
@@ -84,14 +85,14 @@ class DocumentBulkDeleteView(generic.BulkDeleteView):
class DocumentCategoryListView(generic.ObjectListView): 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 table = DocumentCategoryTable
filterset = DocumentCategoryFilterSet filterset = DocumentCategoryFilterSet
actions = (AddObject, BulkImport, BulkDelete) actions = (AddObject, BulkImport, BulkDelete)
class DocumentCategoryView(generic.ObjectView): 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): class DocumentCategoryEditView(generic.ObjectEditView):
@@ -169,8 +170,12 @@ class DocumentImportView(PermissionRequiredMixin, View):
while Document.objects.filter(slug=slug).exists(): while Document.objects.filter(slug=slug).exists():
slug = f"{base_slug}-{counter}" slug = f"{base_slug}-{counter}"
counter += 1 counter += 1
category = form.cleaned_data.get("category")
document = Document.objects.create( document = Document.objects.create(
title=title, slug=slug, body=result.markdown, body_format=result.body_format, 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) keep = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True)
if keep: if keep:
@@ -228,7 +233,7 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
finally: finally:
preview.file.close() preview.file.close()
def render_preview(self, request, preview, sheets, formset): def render_preview(self, request, preview, sheets, formset, overwrite_conflicts=None):
items = [] items = []
for form, sheet in zip(formset.forms, sheets): for form, sheet in zip(formset.forms, sheets):
sample = sheet.markdown sample = sheet.markdown
@@ -251,6 +256,7 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
}) })
return render(request, self.template_name, { return render(request, self.template_name, {
"batch": preview, "formset": formset, "items": items, "batch": preview, "formset": formset, "items": items,
"overwrite_conflicts": overwrite_conflicts or [],
}) })
def get(self, request, token): def get(self, request, token):
@@ -296,6 +302,29 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
metadata = preview.sheet_metadata[data["index"]] metadata = preview.sheet_metadata[data["index"]]
source_index = metadata.get("source_index", data["index"]) source_index = metadata.get("source_index", data["index"])
selections[source_index] = data["title"].strip() 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: try:
selected_sheets = self.read_sheets(preview, selected_indexes=selections) selected_sheets = self.read_sheets(preview, selected_indexes=selections)
except (ImportFailure, Exception) as exc: except (ImportFailure, Exception) as exc:
@@ -305,7 +334,8 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
result = self.create_documents(preview, sheets_by_index, selections) result = self.create_documents(preview, sheets_by_index, selections)
self.delete_preview(preview) self.delete_preview(preview)
messages.success(request, ( 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." f"{result['images']} Bilder übernommen."
)) ))
return redirect("plugins:netbox_documentation:document_list") return redirect("plugins:netbox_documentation:document_list")
@@ -314,9 +344,24 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
@transaction.atomic @transaction.atomic
def create_documents(preview, sheets, selections): def create_documents(preview, sheets, selections):
from django.core.files.base import ContentFile 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(): for index, title in selections.items():
sheet = sheets[index] sheet = sheets[index]
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" base_slug = slugify(title)[:180] or "arbeitsblatt"
document_slug, counter = base_slug, 2 document_slug, counter = base_slug, 2
while Document.objects.filter(slug=document_slug).exists(): while Document.objects.filter(slug=document_slug).exists():
@@ -325,8 +370,10 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
document = Document.objects.create( document = Document.objects.create(
title=title[:200], slug=document_slug, body=sheet.markdown, title=title[:200], slug=document_slug, body=sheet.markdown,
body_format=sheet.body_format, category=preview.category, 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}", summary=f"Importiert aus {preview.original_name}",
) )
created_count += 1
if sheet.images: if sheet.images:
image_lines = [] image_lines = []
for image in sheet.images: for image in sheet.images:
@@ -362,7 +409,10 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
attachment.save() attachment.save()
finally: finally:
preview.file.close() 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 @staticmethod
def delete_preview(preview): def delete_preview(preview):
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netbox-documentation" name = "netbox-documentation"
version = "0.7.9" version = "0.9.2"
description = "Integrated Markdown wiki and office document importer for NetBox" description = "Integrated Markdown wiki and office document importer for NetBox"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
+48
View File
@@ -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")
+18
View File
@@ -231,3 +231,21 @@ def test_excel_flatten_preserves_colors_of_explicit_excel_tables():
assert results[0].body_format == "html" assert results[0].body_format == "html"
assert results[0].markdown.count("background-color: #a5bf60") == 2 assert results[0].markdown.count("background-color: #a5bf60") == 2
assert results[0].markdown.count("color: #ffffff") == 2 assert results[0].markdown.count("color: #ffffff") == 2
def test_excel_flatten_preserves_colored_cells_outside_tables():
workbook = Workbook()
sheet = workbook.active
sheet["A1"] = "Wichtiger Hinweis"
sheet["A1"].fill = PatternFill(fill_type="solid", fgColor="FFF3BF")
sheet["A1"].font = Font(color="9C2A2A", bold=True)
sheet["A3"] = "Normaler Text"
stream = BytesIO()
workbook.save(stream)
results = import_excel_sheets(Upload(stream.getvalue(), "hinweis.xlsx"), flatten=True)
assert results[0].body_format == "html"
assert '<span style="background-color: #fff3bf; color: #9c2a2a; font-weight: bold' in results[0].markdown
assert "Wichtiger Hinweis</span>" in results[0].markdown
assert "<table" not in results[0].markdown