Compare commits
15
Commits
d9ded6287f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
117ffac210 | ||
|
|
2af5ea997d | ||
|
|
791b40b3c4 | ||
|
|
d20baa3ea3 | ||
|
|
b632a5bb04 | ||
|
|
8d6b6a46db | ||
|
|
83cac4447a | ||
|
|
dbf3e7a62a | ||
|
|
4690e67b32 | ||
|
|
214502277f | ||
|
|
5e17e9ecdb | ||
|
|
3f2e7ca667 | ||
|
|
e4f4cd7c14 | ||
|
|
8a2ccfd516 | ||
|
|
6ef3a593b9 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.2"
|
version = "0.9.2"
|
||||||
author = "LKE"
|
author = "LKE"
|
||||||
base_url = "documentation"
|
base_url = "documentation"
|
||||||
min_version = "4.0.0"
|
min_version = "4.0.0"
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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')}"
|
||||||
|
|||||||
@@ -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 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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from html import escape
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ class ImportFailure(ValueError):
|
|||||||
class ImportResult:
|
class ImportResult:
|
||||||
markdown: str
|
markdown: str
|
||||||
warnings: list[str] = field(default_factory=list)
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
body_format: str = "markdown"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -27,6 +29,8 @@ class ExcelSheetResult:
|
|||||||
title: str
|
title: str
|
||||||
markdown: str
|
markdown: str
|
||||||
images: list[ImportedImage] = field(default_factory=list)
|
images: list[ImportedImage] = field(default_factory=list)
|
||||||
|
body_format: str = "markdown"
|
||||||
|
source_index: int = 0
|
||||||
|
|
||||||
|
|
||||||
def import_document(upload, flatten_excel=False) -> ImportResult:
|
def import_document(upload, flatten_excel=False) -> ImportResult:
|
||||||
@@ -80,7 +84,7 @@ def _escape_markdown_text(value):
|
|||||||
|
|
||||||
def _xlsx(content, flatten=False):
|
def _xlsx(content, flatten=False):
|
||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
workbook = load_workbook(BytesIO(content), read_only=not flatten, data_only=True)
|
workbook = load_workbook(BytesIO(content), read_only=False, data_only=True)
|
||||||
sections = []
|
sections = []
|
||||||
for sheet in workbook.worksheets:
|
for sheet in workbook.worksheets:
|
||||||
rows = [["" if cell is None else str(cell) for cell in row] for row in sheet.iter_rows(values_only=True)]
|
rows = [["" if cell is None else str(cell) for cell in row] for row in sheet.iter_rows(values_only=True)]
|
||||||
@@ -88,14 +92,17 @@ def _xlsx(content, flatten=False):
|
|||||||
rows.pop()
|
rows.pop()
|
||||||
if not rows:
|
if not rows:
|
||||||
continue
|
continue
|
||||||
rendered = _render_flattened_worksheet(sheet) if flatten else _render_excel_rows(rows)
|
rendered = _render_flattened_worksheet(sheet, workbook) if flatten else _render_excel_worksheet_html(sheet, workbook)
|
||||||
sections.append(f"## {sheet.title}\n\n{rendered}")
|
sections.append(f"<h2>{escape(sheet.title)}</h2>\n{rendered}")
|
||||||
if not sections:
|
if not sections:
|
||||||
raise ImportFailure("Die Arbeitsmappe enthält keine Daten.")
|
raise ImportFailure("Die Arbeitsmappe enthält keine Daten.")
|
||||||
return ImportResult("\n\n".join(sections))
|
return ImportResult("\n\n".join(sections), body_format="html")
|
||||||
|
|
||||||
|
|
||||||
def import_excel_sheets(upload, flatten=False) -> list[ExcelSheetResult]:
|
def import_excel_sheets(
|
||||||
|
upload, flatten=False, *, metadata_only=False, preview_max_rows=None,
|
||||||
|
include_image_data=True, selected_indexes=None,
|
||||||
|
) -> list[ExcelSheetResult]:
|
||||||
"""Convert each non-empty worksheet into an individual document payload."""
|
"""Convert each non-empty worksheet into an individual document payload."""
|
||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
from openpyxl.utils import get_column_letter
|
from openpyxl.utils import get_column_letter
|
||||||
@@ -103,14 +110,13 @@ def import_excel_sheets(upload, flatten=False) -> list[ExcelSheetResult]:
|
|||||||
suffix = Path(upload.name).suffix.lower()
|
suffix = Path(upload.name).suffix.lower()
|
||||||
if suffix not in {".xlsx", ".xlsm"}:
|
if suffix not in {".xlsx", ".xlsm"}:
|
||||||
raise ImportFailure("Der Mehrblattimport unterstützt XLSX- und XLSM-Dateien.")
|
raise ImportFailure("Der Mehrblattimport unterstützt XLSX- und XLSM-Dateien.")
|
||||||
content = upload.read()
|
|
||||||
upload.seek(0)
|
upload.seek(0)
|
||||||
workbook = load_workbook(BytesIO(content), read_only=False, data_only=True)
|
workbook = load_workbook(upload, read_only=False, data_only=True)
|
||||||
results = []
|
results = []
|
||||||
for sheet in workbook.worksheets:
|
selected_indexes = set(selected_indexes) if selected_indexes is not None else None
|
||||||
rows = [["" if cell is None else str(cell) for cell in row] for row in sheet.iter_rows(values_only=True)]
|
for source_index, sheet in enumerate(workbook.worksheets):
|
||||||
while rows and not any(value for value in rows[-1]):
|
if selected_indexes is not None and source_index not in selected_indexes:
|
||||||
rows.pop()
|
continue
|
||||||
images = []
|
images = []
|
||||||
for number, image in enumerate(getattr(sheet, "_images", ()), 1):
|
for number, image in enumerate(getattr(sheet, "_images", ()), 1):
|
||||||
image_format = (getattr(image, "format", None) or "png").lower()
|
image_format = (getattr(image, "format", None) or "png").lower()
|
||||||
@@ -121,32 +127,232 @@ def import_excel_sheets(upload, flatten=False) -> list[ExcelSheetResult]:
|
|||||||
anchor = getattr(image, "anchor", None)
|
anchor = getattr(image, "anchor", None)
|
||||||
marker = getattr(anchor, "_from", None)
|
marker = getattr(anchor, "_from", None)
|
||||||
cell = f"{get_column_letter(marker.col + 1)}{marker.row + 1}" if marker else ""
|
cell = f"{get_column_letter(marker.col + 1)}{marker.row + 1}" if marker else ""
|
||||||
try:
|
image_content = b""
|
||||||
image_content = image._data()
|
if include_image_data and not metadata_only:
|
||||||
except (AttributeError, OSError, ValueError):
|
try:
|
||||||
continue
|
image_content = image._data()
|
||||||
|
except (AttributeError, OSError, ValueError):
|
||||||
|
continue
|
||||||
images.append(ImportedImage(
|
images.append(ImportedImage(
|
||||||
name=f"{slugify_filename(sheet.title)}-{number}.{image_format if image_format != 'jpeg' else 'jpg'}",
|
name=f"{slugify_filename(sheet.title)}-{number}.{image_format if image_format != 'jpeg' else 'jpg'}",
|
||||||
content=image_content, content_type=f"image/{image_format}", cell=cell,
|
content=image_content, content_type=f"image/{image_format}", cell=cell,
|
||||||
))
|
))
|
||||||
if not rows and not images:
|
has_content = _worksheet_has_content(sheet)
|
||||||
|
if not has_content and not images:
|
||||||
continue
|
continue
|
||||||
if rows:
|
if metadata_only:
|
||||||
markdown = _render_flattened_worksheet(sheet) if flatten else _render_excel_rows(rows)
|
markdown = ""
|
||||||
|
elif has_content:
|
||||||
|
markdown = (
|
||||||
|
_render_flattened_worksheet(sheet, workbook, max_rows=preview_max_rows) if flatten else
|
||||||
|
_render_excel_worksheet_html(sheet, workbook, max_rows=preview_max_rows)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
markdown = ""
|
markdown = ""
|
||||||
results.append(ExcelSheetResult(title=sheet.title, markdown=markdown, images=images))
|
results.append(ExcelSheetResult(
|
||||||
|
title=sheet.title, markdown=markdown, images=images,
|
||||||
|
body_format="html", source_index=source_index,
|
||||||
|
))
|
||||||
|
workbook.close()
|
||||||
|
upload.seek(0)
|
||||||
if not results:
|
if not results:
|
||||||
raise ImportFailure("Die Arbeitsmappe enthält keine Daten oder unterstützten Bilder.")
|
raise ImportFailure("Die Arbeitsmappe enthält keine Daten oder unterstützten Bilder.")
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _worksheet_has_content(sheet):
|
||||||
|
"""Check for a value without materializing the complete worksheet in memory."""
|
||||||
|
return any(
|
||||||
|
cell.value not in (None, "")
|
||||||
|
for row in sheet.iter_rows()
|
||||||
|
for cell in row
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def slugify_filename(value):
|
def slugify_filename(value):
|
||||||
value = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-.")
|
value = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-.")
|
||||||
return value[:80] or "arbeitsblatt"
|
return value[:80] or "arbeitsblatt"
|
||||||
|
|
||||||
|
|
||||||
def _render_flattened_worksheet(sheet):
|
def _excel_color(color, workbook):
|
||||||
|
"""Resolve RGB, indexed and theme-based openpyxl colors to a CSS hex value."""
|
||||||
|
if not color or not getattr(color, "type", None):
|
||||||
|
return None
|
||||||
|
value = None
|
||||||
|
if color.type == "rgb" and color.rgb:
|
||||||
|
value = str(color.rgb)[-6:]
|
||||||
|
elif color.type == "indexed" and color.indexed is not None:
|
||||||
|
from openpyxl.styles.colors import COLOR_INDEX
|
||||||
|
index = int(color.indexed)
|
||||||
|
if 0 <= index < len(COLOR_INDEX):
|
||||||
|
value = COLOR_INDEX[index][-6:]
|
||||||
|
elif color.type == "theme" and color.theme is not None and workbook.loaded_theme:
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
try:
|
||||||
|
root = ElementTree.fromstring(workbook.loaded_theme)
|
||||||
|
scheme = root.find(".//{http://schemas.openxmlformats.org/drawingml/2006/main}clrScheme")
|
||||||
|
entries = list(scheme) if scheme is not None else []
|
||||||
|
entry = entries[int(color.theme)]
|
||||||
|
color_node = next(iter(entry))
|
||||||
|
value = color_node.attrib.get("val") or color_node.attrib.get("lastClr")
|
||||||
|
except (ElementTree.ParseError, IndexError, StopIteration, TypeError, ValueError):
|
||||||
|
value = None
|
||||||
|
if not value or not re.fullmatch(r"[0-9A-Fa-f]{6}", value):
|
||||||
|
return None
|
||||||
|
rgb = [int(value[index:index + 2], 16) for index in (0, 2, 4)]
|
||||||
|
tint = float(getattr(color, "tint", 0) or 0)
|
||||||
|
if tint:
|
||||||
|
rgb = [round(component * (1 + tint) if tint < 0 else component + (255 - component) * tint) for component in rgb]
|
||||||
|
return "#" + "".join(f"{max(0, min(255, component)):02x}" for component in rgb)
|
||||||
|
|
||||||
|
|
||||||
|
def _excel_cell_style(cell, workbook):
|
||||||
|
declarations = []
|
||||||
|
if cell.fill and cell.fill.fill_type:
|
||||||
|
fill = (
|
||||||
|
_excel_color(getattr(cell.fill, "fgColor", None), workbook) or
|
||||||
|
_excel_color(getattr(cell.fill, "bgColor", None), workbook)
|
||||||
|
)
|
||||||
|
if not fill and cell.fill.fill_type == "linear":
|
||||||
|
stops = getattr(cell.fill, "stop", ())
|
||||||
|
fill = _excel_color(stops[0].color, workbook) if stops else None
|
||||||
|
if fill:
|
||||||
|
declarations.append(f"background-color: {fill}")
|
||||||
|
font_color = _excel_color(cell.font.color, workbook)
|
||||||
|
if font_color:
|
||||||
|
declarations.append(f"color: {font_color}")
|
||||||
|
if cell.font.bold:
|
||||||
|
declarations.append("font-weight: bold")
|
||||||
|
if cell.font.italic:
|
||||||
|
declarations.append("font-style: italic")
|
||||||
|
if cell.font.sz:
|
||||||
|
declarations.append(f"font-size: {float(cell.font.sz):g}pt")
|
||||||
|
if cell.alignment.horizontal in {"left", "center", "right", "justify"}:
|
||||||
|
declarations.append(f"text-align: {cell.alignment.horizontal}")
|
||||||
|
if cell.alignment.vertical in {"top", "center", "bottom"}:
|
||||||
|
declarations.append(f"vertical-align: {'middle' if cell.alignment.vertical == 'center' else cell.alignment.vertical}")
|
||||||
|
if cell.alignment.wrap_text:
|
||||||
|
declarations.append("white-space: normal")
|
||||||
|
return "; ".join(declarations)
|
||||||
|
|
||||||
|
|
||||||
|
def _tint_hex(value, tint):
|
||||||
|
import colorsys
|
||||||
|
rgb = tuple(int(value[index:index + 2], 16) / 255 for index in (1, 3, 5))
|
||||||
|
hue, lightness, saturation = colorsys.rgb_to_hls(*rgb)
|
||||||
|
lightness = lightness * (1 + tint) if tint < 0 else lightness + (1 - lightness) * tint
|
||||||
|
tinted = colorsys.hls_to_rgb(hue, max(0, min(1, lightness)), saturation)
|
||||||
|
return "#" + "".join(f"{round(component * 255):02x}" for component in tinted)
|
||||||
|
|
||||||
|
|
||||||
|
def _excel_table_cell_styles(sheet, workbook, max_row, max_column):
|
||||||
|
"""Create visual fallbacks for Excel's built-in 'Format as Table' styles."""
|
||||||
|
from openpyxl.styles import Color
|
||||||
|
from openpyxl.utils.cell import range_boundaries
|
||||||
|
|
||||||
|
styles = {}
|
||||||
|
for table in sheet.tables.values():
|
||||||
|
info = table.tableStyleInfo
|
||||||
|
match = re.fullmatch(r"TableStyle(Light|Medium|Dark)(\d+)", info.name or "") if info else None
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
family, number = match.group(1), int(match.group(2))
|
||||||
|
palette_slot = (number - 1) % 7
|
||||||
|
theme_index = 0 if palette_slot == 0 else 3 + palette_slot
|
||||||
|
base = _excel_color(Color(theme=theme_index), workbook) or "#4472c4"
|
||||||
|
min_col, min_row, table_max_col, table_max_row = range_boundaries(table.ref)
|
||||||
|
table_max_col, table_max_row = min(table_max_col, max_column), min(table_max_row, max_row)
|
||||||
|
if min_col > table_max_col or min_row > table_max_row:
|
||||||
|
continue
|
||||||
|
header_fill = _tint_hex(base, 0.55) if family == "Light" else base
|
||||||
|
header_color = "#000000" if family == "Light" else "#ffffff"
|
||||||
|
stripe_fill = _tint_hex(base, 0.88 if family == "Light" else 0.82)
|
||||||
|
for column in range(min_col, table_max_col + 1):
|
||||||
|
styles[(min_row, column)] = f"background-color: {header_fill}; color: {header_color}; font-weight: bold"
|
||||||
|
data_start = min_row + (1 if table.headerRowCount else 0)
|
||||||
|
data_end = table_max_row - (1 if table.totalsRowShown else 0)
|
||||||
|
if info.showRowStripes:
|
||||||
|
for row in range(data_start, data_end + 1):
|
||||||
|
if (row - data_start) % 2 == 1:
|
||||||
|
for column in range(min_col, table_max_col + 1):
|
||||||
|
styles[(row, column)] = f"background-color: {stripe_fill}"
|
||||||
|
if table.totalsRowShown and table_max_row >= min_row:
|
||||||
|
for column in range(min_col, table_max_col + 1):
|
||||||
|
styles[(table_max_row, column)] = f"font-weight: bold; border-top: 2px solid {base}"
|
||||||
|
return styles
|
||||||
|
|
||||||
|
|
||||||
|
def _render_excel_worksheet_html(sheet, workbook, max_rows=None, bounds=None):
|
||||||
|
"""Render worksheet values and the most relevant visual Excel cell formatting."""
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
|
if bounds:
|
||||||
|
start_column, start_row, bound_max_column, bound_max_row = bounds
|
||||||
|
else:
|
||||||
|
start_column, start_row, bound_max_column, bound_max_row = 1, 1, sheet.max_column, sheet.max_row
|
||||||
|
last_row = last_column = 0
|
||||||
|
for row in sheet.iter_rows(
|
||||||
|
min_row=start_row, max_row=bound_max_row,
|
||||||
|
min_col=start_column, max_col=bound_max_column,
|
||||||
|
):
|
||||||
|
for cell in row:
|
||||||
|
if cell.value not in (None, ""):
|
||||||
|
last_row = max(last_row, cell.row)
|
||||||
|
last_column = max(last_column, cell.column)
|
||||||
|
if not last_row:
|
||||||
|
return "<p><em>Keine Inhalte</em></p>"
|
||||||
|
max_row = min(last_row, start_row + max_rows - 1) if max_rows else last_row
|
||||||
|
max_column = last_column
|
||||||
|
table_styles = _excel_table_cell_styles(sheet, workbook, max_row, max_column)
|
||||||
|
merged_starts, merged_children = {}, set()
|
||||||
|
for merged in sheet.merged_cells.ranges:
|
||||||
|
if merged.min_row > max_row or merged.min_col > max_column:
|
||||||
|
continue
|
||||||
|
merged_starts[(merged.min_row, merged.min_col)] = (
|
||||||
|
min(merged.max_row, max_row) - merged.min_row + 1,
|
||||||
|
min(merged.max_col, max_column) - merged.min_col + 1,
|
||||||
|
)
|
||||||
|
for row in range(merged.min_row, min(merged.max_row, max_row) + 1):
|
||||||
|
for column in range(merged.min_col, min(merged.max_col, max_column) + 1):
|
||||||
|
if (row, column) != (merged.min_row, merged.min_col):
|
||||||
|
merged_children.add((row, column))
|
||||||
|
columns = []
|
||||||
|
for column in range(start_column, max_column + 1):
|
||||||
|
width = sheet.column_dimensions[get_column_letter(column)].width
|
||||||
|
columns.append(f'<col style="width: {max(3, min(float(width or 13), 80)):.2f}ch">')
|
||||||
|
output = ['<table class="doc-table-bordered"><colgroup>', *columns, "</colgroup><tbody>"]
|
||||||
|
for row in range(start_row, max_row + 1):
|
||||||
|
row_style = ""
|
||||||
|
if sheet.row_dimensions[row].height:
|
||||||
|
row_style = f' style="height: {float(sheet.row_dimensions[row].height):g}pt"'
|
||||||
|
output.append(f"<tr{row_style}>")
|
||||||
|
for column in range(start_column, max_column + 1):
|
||||||
|
if (row, column) in merged_children:
|
||||||
|
continue
|
||||||
|
cell = sheet.cell(row=row, column=column)
|
||||||
|
attributes = []
|
||||||
|
rowspan, colspan = merged_starts.get((row, column), (1, 1))
|
||||||
|
if rowspan > 1:
|
||||||
|
attributes.append(f'rowspan="{rowspan}"')
|
||||||
|
if colspan > 1:
|
||||||
|
attributes.append(f'colspan="{colspan}"')
|
||||||
|
style = _excel_cell_style(cell, workbook)
|
||||||
|
if not style and (row, column) in table_styles:
|
||||||
|
style = table_styles[(row, column)]
|
||||||
|
elif (row, column) in table_styles and "background-color" not in style:
|
||||||
|
style = "; ".join(filter(None, (style, table_styles[(row, column)])))
|
||||||
|
if style:
|
||||||
|
attributes.append(f'style="{style}"')
|
||||||
|
value = "" if cell.value is None else escape(str(cell.value)).replace("\n", "<br>")
|
||||||
|
output.append(f"<td{' ' if attributes else ''}{' '.join(attributes)}>{value}</td>")
|
||||||
|
output.append("</tr>")
|
||||||
|
output.append("</tbody></table>")
|
||||||
|
if max_rows and last_row > start_row + max_rows - 1:
|
||||||
|
output.append(f"<p><em>Vorschau auf {max_rows} Zeilen begrenzt.</em></p>")
|
||||||
|
return "".join(output)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_flattened_worksheet(sheet, workbook, max_rows=None):
|
||||||
"""Flatten ordinary cells while preserving explicitly defined Excel tables."""
|
"""Flatten ordinary cells while preserving explicitly defined Excel tables."""
|
||||||
from openpyxl.utils.cell import range_boundaries
|
from openpyxl.utils.cell import range_boundaries
|
||||||
|
|
||||||
@@ -165,7 +371,8 @@ def _render_flattened_worksheet(sheet):
|
|||||||
|
|
||||||
blocks = []
|
blocks = []
|
||||||
rendered_tables = set()
|
rendered_tables = set()
|
||||||
for row_number in range(1, sheet.max_row + 1):
|
rendered_max_row = min(sheet.max_row, max_rows) if max_rows else sheet.max_row
|
||||||
|
for row_number in range(1, rendered_max_row + 1):
|
||||||
ordinary_values = []
|
ordinary_values = []
|
||||||
tables_starting_here = []
|
tables_starting_here = []
|
||||||
for column_number in range(1, sheet.max_column + 1):
|
for column_number in range(1, sheet.max_column + 1):
|
||||||
@@ -175,20 +382,24 @@ def _render_flattened_worksheet(sheet):
|
|||||||
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_markdown_text(value))
|
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(" \n".join(ordinary_values))
|
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:
|
||||||
rows = []
|
blocks.append(_render_excel_worksheet_html(
|
||||||
for table_row in sheet.iter_rows(
|
sheet, workbook,
|
||||||
min_row=min_row, max_row=max_row, min_col=min_col, max_col=max_col, values_only=True
|
bounds=(min_col, min_row, max_col, min(max_row, rendered_max_row)),
|
||||||
):
|
))
|
||||||
rows.append(["" if value is None else str(value) for value in table_row])
|
if max_rows and sheet.max_row > max_rows:
|
||||||
if rows:
|
blocks.append(f"<p><em>Vorschau auf {max_rows} von {sheet.max_row} Zeilen begrenzt.</em></p>")
|
||||||
blocks.append(_render_excel_rows(rows, flatten=False))
|
return "\n".join(blocks) or "<p><em>Keine Inhalte</em></p>"
|
||||||
return "\n\n".join(blocks) or "_Keine Inhalte_"
|
|
||||||
|
|
||||||
|
|
||||||
def _pdf(content):
|
def _pdf(content):
|
||||||
|
|||||||
@@ -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)
|
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",)
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
<style>
|
<style>
|
||||||
body.documentation-editor-page .container-xl { max-width: none !important; }
|
body.documentation-editor-page .container-xl { max-width: none !important; }
|
||||||
body.documentation-editor-focus #form_fields .field-group:not(.documentation-content-group) { display: none; }
|
|
||||||
body.documentation-editor-focus #form_fields .documentation-content-group { margin-bottom: 0 !important; }
|
|
||||||
</style>
|
</style>
|
||||||
<script src="{% url 'plugins:netbox_documentation:editor_asset' asset_path='tinymce.min.js' %}"></script>
|
<script src="{% url 'plugins:netbox_documentation:editor_asset' asset_path='tinymce.min.js' %}"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -36,10 +34,10 @@
|
|||||||
plugins: 'advlist anchor autolink charmap code fullscreen image link lists media preview searchreplace table visualblocks wordcount',
|
plugins: 'advlist anchor autolink charmap code fullscreen image link lists media preview searchreplace table visualblocks wordcount',
|
||||||
toolbar: [
|
toolbar: [
|
||||||
'undo redo | blocks styles fontfamily fontsize | bold italic underline strikethrough | forecolor backcolor',
|
'undo redo | blocks styles fontfamily fontsize | bold italic underline strikethrough | forecolor backcolor',
|
||||||
'alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table link image media | removeformat code netboxfocus fullscreen'
|
'alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table tablecellcolor link image media | removeformat code fullscreen'
|
||||||
],
|
],
|
||||||
toolbar_mode: 'sliding',
|
toolbar_mode: 'sliding',
|
||||||
table_toolbar: 'tableprops tablecellprops | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol | tablemergecells tablesplitcells | tabledelete',
|
table_toolbar: 'tableprops tablecellprops tablecellcolor | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol | tablemergecells tablesplitcells | tabledelete',
|
||||||
table_appearance_options: true,
|
table_appearance_options: true,
|
||||||
table_advtab: true,
|
table_advtab: true,
|
||||||
table_cell_advtab: true,
|
table_cell_advtab: true,
|
||||||
@@ -103,7 +101,79 @@
|
|||||||
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,
|
||||||
relative_urls: false
|
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 = () => {
|
||||||
|
const markedCells = Array.from(editor.getBody().querySelectorAll(
|
||||||
|
'td[data-mce-selected="1"], th[data-mce-selected="1"]'
|
||||||
|
));
|
||||||
|
if (markedCells.length) return markedCells;
|
||||||
|
const currentCell = editor.dom.getParent(editor.selection.getNode(), 'td,th');
|
||||||
|
return currentCell ? [currentCell] : [];
|
||||||
|
};
|
||||||
|
const applyCellColor = color => {
|
||||||
|
const cells = selectedTableCells();
|
||||||
|
if (!cells.length) return;
|
||||||
|
editor.undoManager.transact(() => {
|
||||||
|
cells.forEach(cell => editor.dom.setStyle(cell, 'background-color', color));
|
||||||
|
});
|
||||||
|
editor.nodeChanged();
|
||||||
|
};
|
||||||
|
const chooseCellColor = () => {
|
||||||
|
const picker = document.createElement('input');
|
||||||
|
picker.type = 'color';
|
||||||
|
picker.value = '#fff3bf';
|
||||||
|
picker.style.position = 'fixed';
|
||||||
|
picker.style.opacity = '0';
|
||||||
|
picker.addEventListener('input', () => applyCellColor(picker.value), {once: true});
|
||||||
|
picker.addEventListener('change', () => picker.remove(), {once: true});
|
||||||
|
document.body.appendChild(picker);
|
||||||
|
picker.click();
|
||||||
|
};
|
||||||
|
const palette = [
|
||||||
|
['Gelb', '#fff3bf'], ['Grün', '#d3f9d8'], ['Blau', '#dbeafe'],
|
||||||
|
['Rot', '#ffe3e3'], ['Orange', '#ffe8cc'], ['Grau', '#e9ecef'],
|
||||||
|
['Dunkelgrün', '#a5bf60'], ['Dunkelblau', '#206bc4'], ['Dunkelgrau', '#343a40']
|
||||||
|
];
|
||||||
|
editor.ui.registry.addMenuButton('tablecellcolor', {
|
||||||
|
icon: 'highlight-bg-color',
|
||||||
|
tooltip: 'Farbeimer für Tabellenzellen',
|
||||||
|
fetch: callback => callback([
|
||||||
|
...palette.map(([name, color]) => ({
|
||||||
|
type: 'menuitem',
|
||||||
|
text: name,
|
||||||
|
onAction: () => applyCellColor(color)
|
||||||
|
})),
|
||||||
|
{type: 'separator'},
|
||||||
|
{type: 'menuitem', text: 'Eigene Farbe …', onAction: chooseCellColor},
|
||||||
|
{type: 'menuitem', text: 'Zellfarbe entfernen', onAction: () => applyCellColor('')}
|
||||||
|
])
|
||||||
|
});
|
||||||
|
}
|
||||||
{% if object.pk %},
|
{% if object.pk %},
|
||||||
images_file_types: 'jpg,jpeg,png,gif,webp',
|
images_file_types: 'jpg,jpeg,png,gif,webp',
|
||||||
automatic_uploads: true,
|
automatic_uploads: true,
|
||||||
@@ -121,19 +191,7 @@
|
|||||||
resolve(payload.location);
|
resolve(payload.location);
|
||||||
}).catch(error => reject(error.message));
|
}).catch(error => reject(error.message));
|
||||||
})
|
})
|
||||||
{% endif %},
|
{% endif %}
|
||||||
setup: (editor) => {
|
|
||||||
editor.ui.registry.addToggleButton('netboxfocus', {
|
|
||||||
icon: 'expand',
|
|
||||||
tooltip: 'Fokusmodus im NetBox-Fenster',
|
|
||||||
onAction: api => {
|
|
||||||
const active = document.body.classList.toggle('documentation-editor-focus');
|
|
||||||
const container = editor.getContainer();
|
|
||||||
container.style.height = active ? 'calc(100vh - 190px)' : '650px';
|
|
||||||
api.setActive(active);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -141,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">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
.excel-preview-content { max-height: 32rem; overflow: auto; }
|
.excel-preview-content { max-height: 32rem; overflow: auto; }
|
||||||
.excel-preview-content table { width: max-content; min-width: 100%; border-collapse: collapse; font-size: .825rem; }
|
.excel-preview-content table { width: max-content; min-width: 100%; border-collapse: collapse; font-size: .825rem; }
|
||||||
.excel-preview-content th, .excel-preview-content td { min-width: 7rem; max-width: 22rem; padding: .3rem .45rem; border: 1px solid var(--tblr-border-color, #adb5bd); vertical-align: top; overflow-wrap: anywhere; }
|
.excel-preview-content th, .excel-preview-content td { min-width: 7rem; max-width: 22rem; padding: .3rem .45rem; border: 1px solid var(--tblr-border-color, #adb5bd); vertical-align: top; overflow-wrap: anywhere; }
|
||||||
.excel-preview-content th { background: var(--tblr-bg-surface-secondary, #f1f3f5); }
|
.excel-preview-content th:not([style*="background"]) { background: var(--tblr-bg-surface-secondary, #f1f3f5); }
|
||||||
</style>
|
</style>
|
||||||
{% endblock head %}
|
{% endblock head %}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
+133
-47
@@ -1,5 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from html import escape
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import tinymce
|
import tinymce
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -7,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
|
||||||
@@ -44,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):
|
||||||
@@ -83,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):
|
||||||
@@ -168,7 +170,13 @@ 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
|
||||||
document = Document.objects.create(title=title, slug=slug, body=result.markdown, body_format="markdown")
|
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)
|
keep = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True)
|
||||||
if keep:
|
if keep:
|
||||||
upload.seek(0)
|
upload.seek(0)
|
||||||
@@ -181,7 +189,10 @@ class DocumentImportView(PermissionRequiredMixin, View):
|
|||||||
|
|
||||||
def _import_excel_sheets(self, request, form, upload):
|
def _import_excel_sheets(self, request, form, upload):
|
||||||
try:
|
try:
|
||||||
sheets = import_excel_sheets(upload, flatten=form.cleaned_data.get("flatten_excel_tables", False))
|
sheets = import_excel_sheets(
|
||||||
|
upload, flatten=form.cleaned_data.get("flatten_excel_tables", False),
|
||||||
|
metadata_only=True, include_image_data=False,
|
||||||
|
)
|
||||||
except (ImportFailure, Exception) as exc:
|
except (ImportFailure, Exception) as exc:
|
||||||
form.add_error("file", f"Excel-Mehrblattimport fehlgeschlagen: {exc}")
|
form.add_error("file", f"Excel-Mehrblattimport fehlgeschlagen: {exc}")
|
||||||
return render(request, self.template_name, {"form": form})
|
return render(request, self.template_name, {"form": form})
|
||||||
@@ -195,7 +206,10 @@ class DocumentImportView(PermissionRequiredMixin, View):
|
|||||||
user=request.user, category=form.cleaned_data["category"], file=upload,
|
user=request.user, category=form.cleaned_data["category"], file=upload,
|
||||||
original_name=upload.name, content_type=upload.content_type or "",
|
original_name=upload.name, content_type=upload.content_type or "",
|
||||||
flatten=form.cleaned_data.get("flatten_excel_tables", False),
|
flatten=form.cleaned_data.get("flatten_excel_tables", False),
|
||||||
sheet_metadata=[{"title": sheet.title, "image_count": len(sheet.images)} for sheet in sheets],
|
sheet_metadata=[{
|
||||||
|
"title": sheet.title, "image_count": len(sheet.images),
|
||||||
|
"source_index": sheet.source_index,
|
||||||
|
} for sheet in sheets],
|
||||||
)
|
)
|
||||||
return redirect("plugins:netbox_documentation:excel_import_preview", token=preview.pk)
|
return redirect("plugins:netbox_documentation:excel_import_preview", token=preview.pk)
|
||||||
|
|
||||||
@@ -207,29 +221,42 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
|
|||||||
def get_preview(self, request, token):
|
def get_preview(self, request, token):
|
||||||
return get_object_or_404(ExcelImportPreview, pk=token, user=request.user)
|
return get_object_or_404(ExcelImportPreview, pk=token, user=request.user)
|
||||||
|
|
||||||
def read_sheets(self, preview):
|
def read_sheets(self, preview, *, for_preview=False, selected_indexes=None):
|
||||||
preview.file.open("rb")
|
preview.file.open("rb")
|
||||||
try:
|
try:
|
||||||
return import_excel_sheets(preview.file, flatten=preview.flatten)
|
return import_excel_sheets(
|
||||||
|
preview.file, flatten=preview.flatten,
|
||||||
|
preview_max_rows=100 if for_preview else None,
|
||||||
|
include_image_data=not for_preview,
|
||||||
|
selected_indexes=selected_indexes,
|
||||||
|
)
|
||||||
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
|
||||||
if sheet.images:
|
if sheet.images:
|
||||||
sample += f"\n\n## Bilder\n\n_{len(sheet.images)} eingebettete Bilder werden beim Import angefügt._"
|
image_notice = f"{len(sheet.images)} eingebettete Bilder werden beim Import angefügt."
|
||||||
|
if sheet.body_format == "html":
|
||||||
|
sample += f"\n<h2>Bilder</h2><p><em>{image_notice}</em></p>"
|
||||||
|
else:
|
||||||
|
sample += f"\n\n## Bilder\n\n_{image_notice}_"
|
||||||
truncated = len(sample) > 100000
|
truncated = len(sample) > 100000
|
||||||
if truncated:
|
if truncated:
|
||||||
sample = sample[:100000] + "\n\n_… Vorschau gekürzt …_"
|
notice = "… Vorschau gekürzt …"
|
||||||
preview_document = Document(body=sample, body_format="markdown")
|
sample = sample[:100000] + (
|
||||||
|
f"<p><em>{notice}</em></p>" if sheet.body_format == "html" else f"\n\n_{notice}_"
|
||||||
|
)
|
||||||
|
preview_document = Document(body=sample, body_format=sheet.body_format)
|
||||||
items.append({
|
items.append({
|
||||||
"form": form, "sheet": sheet,
|
"form": form, "sheet": sheet,
|
||||||
"preview_html": preview_document.rendered_body(), "truncated": truncated,
|
"preview_html": preview_document.rendered_body(), "truncated": truncated,
|
||||||
})
|
})
|
||||||
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):
|
||||||
@@ -239,7 +266,7 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
|
|||||||
messages.error(request, "Diese Importvorschau ist abgelaufen. Bitte die Exceldatei erneut hochladen.")
|
messages.error(request, "Diese Importvorschau ist abgelaufen. Bitte die Exceldatei erneut hochladen.")
|
||||||
return redirect("plugins:netbox_documentation:document_import")
|
return redirect("plugins:netbox_documentation:document_import")
|
||||||
try:
|
try:
|
||||||
sheets = self.read_sheets(preview)
|
sheets = self.read_sheets(preview, for_preview=True)
|
||||||
except (ImportFailure, Exception) as exc:
|
except (ImportFailure, Exception) as exc:
|
||||||
self.delete_preview(preview)
|
self.delete_preview(preview)
|
||||||
messages.error(request, f"Die Excel-Vorschau konnte nicht erzeugt werden: {exc}")
|
messages.error(request, f"Die Excel-Vorschau konnte nicht erzeugt werden: {exc}")
|
||||||
@@ -253,15 +280,15 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
|
|||||||
self.delete_preview(preview)
|
self.delete_preview(preview)
|
||||||
messages.info(request, "Excel-Import wurde abgebrochen.")
|
messages.info(request, "Excel-Import wurde abgebrochen.")
|
||||||
return redirect("plugins:netbox_documentation:document_import")
|
return redirect("plugins:netbox_documentation:document_import")
|
||||||
try:
|
|
||||||
sheets = self.read_sheets(preview)
|
|
||||||
except (ImportFailure, Exception) as exc:
|
|
||||||
self.delete_preview(preview)
|
|
||||||
messages.error(request, f"Die Exceldatei konnte nicht erneut gelesen werden: {exc}")
|
|
||||||
return redirect("plugins:netbox_documentation:document_import")
|
|
||||||
formset = ExcelSheetSelectionFormSet(request.POST)
|
formset = ExcelSheetSelectionFormSet(request.POST)
|
||||||
if not formset.is_valid():
|
if not formset.is_valid():
|
||||||
return self.render_preview(request, preview, sheets, formset)
|
try:
|
||||||
|
preview_sheets = self.read_sheets(preview, for_preview=True)
|
||||||
|
except (ImportFailure, Exception) as exc:
|
||||||
|
self.delete_preview(preview)
|
||||||
|
messages.error(request, f"Die Exceldatei konnte nicht erneut gelesen werden: {exc}")
|
||||||
|
return redirect("plugins:netbox_documentation:document_import")
|
||||||
|
return self.render_preview(request, preview, preview_sheets, formset)
|
||||||
if not preview.category:
|
if not preview.category:
|
||||||
self.delete_preview(preview)
|
self.delete_preview(preview)
|
||||||
messages.error(request, "Der gewählte Zielordner existiert nicht mehr. Bitte den Import erneut starten.")
|
messages.error(request, "Der gewählte Zielordner existiert nicht mehr. Bitte den Import erneut starten.")
|
||||||
@@ -270,13 +297,45 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
|
|||||||
for form in formset.forms:
|
for form in formset.forms:
|
||||||
data = form.cleaned_data
|
data = form.cleaned_data
|
||||||
if data.get("include"):
|
if data.get("include"):
|
||||||
if data["index"] < 0 or data["index"] >= len(sheets):
|
if data["index"] < 0 or data["index"] >= len(preview.sheet_metadata):
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
selections[data["index"]] = data["title"].strip()
|
metadata = preview.sheet_metadata[data["index"]]
|
||||||
result = self.create_documents(preview, sheets, selections)
|
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:
|
||||||
|
messages.error(request, f"Die ausgewählten Excel-Blätter konnten nicht importiert werden: {exc}")
|
||||||
|
return redirect("plugins:netbox_documentation:excel_import_preview", token=preview.pk)
|
||||||
|
sheets_by_index = {sheet.source_index: sheet for sheet in selected_sheets}
|
||||||
|
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")
|
||||||
@@ -285,24 +344,36 @@ 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
|
||||||
preview.file.open("rb")
|
created_documents, image_count, created_count, updated_count = [], 0, 0, 0
|
||||||
try:
|
|
||||||
original_content = preview.file.read()
|
|
||||||
finally:
|
|
||||||
preview.file.close()
|
|
||||||
created_documents, image_count = [], 0
|
|
||||||
for index, title in selections.items():
|
for index, title in selections.items():
|
||||||
sheet = sheets[index]
|
sheet = sheets[index]
|
||||||
base_slug = slugify(title)[:180] or "arbeitsblatt"
|
document = Document.objects.select_for_update().filter(
|
||||||
document_slug, counter = base_slug, 2
|
category=preview.category, title__iexact=title,
|
||||||
while Document.objects.filter(slug=document_slug).exists():
|
).order_by("pk").first()
|
||||||
document_slug = f"{base_slug[:190-len(str(counter))]}-{counter}"
|
if document:
|
||||||
counter += 1
|
document.title = title[:200]
|
||||||
document = Document.objects.create(
|
document.body = sheet.markdown
|
||||||
title=title[:200], slug=document_slug, body=sheet.markdown,
|
document.body_format = sheet.body_format
|
||||||
body_format="markdown", category=preview.category,
|
document.summary = f"Importiert aus {preview.original_name}"
|
||||||
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:
|
if sheet.images:
|
||||||
image_lines = []
|
image_lines = []
|
||||||
for image in sheet.images:
|
for image in sheet.images:
|
||||||
@@ -313,20 +384,35 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
|
|||||||
attachment.file.save(image.name, ContentFile(image.content), save=False)
|
attachment.file.save(image.name, ContentFile(image.content), save=False)
|
||||||
attachment.save()
|
attachment.save()
|
||||||
location = f" ({image.cell})" if image.cell else ""
|
location = f" ({image.cell})" if image.cell else ""
|
||||||
image_lines.append(f"")
|
if document.body_format == "html":
|
||||||
document.body += "\n\n## Bilder\n\n" + "\n\n".join(image_lines)
|
image_lines.append(
|
||||||
|
f'<p><img src="{escape(attachment.file.url)}" alt="{escape(image.name + location)}"></p>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
image_lines.append(f"")
|
||||||
|
if document.body_format == "html":
|
||||||
|
document.body += "\n<h2>Bilder</h2>\n" + "\n".join(image_lines)
|
||||||
|
else:
|
||||||
|
document.body += "\n\n## Bilder\n\n" + "\n\n".join(image_lines)
|
||||||
document.save(update_fields=("body", "last_updated"))
|
document.save(update_fields=("body", "last_updated"))
|
||||||
image_count += len(sheet.images)
|
image_count += len(sheet.images)
|
||||||
created_documents.append(document)
|
created_documents.append(document)
|
||||||
if settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True):
|
if settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True):
|
||||||
for document in created_documents:
|
for document in created_documents:
|
||||||
|
preview.file.open("rb")
|
||||||
attachment = DocumentAttachment(
|
attachment = DocumentAttachment(
|
||||||
document=document, original_name=preview.original_name,
|
document=document, original_name=preview.original_name,
|
||||||
content_type=preview.content_type, size=len(original_content),
|
content_type=preview.content_type, size=preview.file.size,
|
||||||
)
|
)
|
||||||
attachment.file.save(preview.original_name, ContentFile(original_content), save=False)
|
try:
|
||||||
attachment.save()
|
attachment.file.save(preview.original_name, preview.file, save=False)
|
||||||
return {"documents": len(created_documents), "images": image_count, "category": preview.category}
|
attachment.save()
|
||||||
|
finally:
|
||||||
|
preview.file.close()
|
||||||
|
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-documentation"
|
name = "netbox-documentation"
|
||||||
version = "0.7.2"
|
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"
|
||||||
|
|||||||
@@ -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")
|
||||||
+152
-6
@@ -5,7 +5,8 @@ import sys
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from openpyxl import Workbook
|
from openpyxl import Workbook
|
||||||
from openpyxl.worksheet.table import Table
|
from openpyxl.styles import PatternFill, Font
|
||||||
|
from openpyxl.worksheet.table import Table, TableStyleInfo
|
||||||
|
|
||||||
|
|
||||||
spec = importlib.util.spec_from_file_location(
|
spec = importlib.util.spec_from_file_location(
|
||||||
@@ -39,10 +40,54 @@ def test_xlsx_imports_sheets_as_tables():
|
|||||||
stream = BytesIO()
|
stream = BytesIO()
|
||||||
workbook.save(stream)
|
workbook.save(stream)
|
||||||
result = import_document(Upload(stream.getvalue(), "server.xlsx"))
|
result = import_document(Upload(stream.getvalue(), "server.xlsx"))
|
||||||
assert "## Server" in result.markdown
|
assert "<h2>Server</h2>" in result.markdown
|
||||||
|
assert result.body_format == "html"
|
||||||
assert "web01" in result.markdown
|
assert "web01" in result.markdown
|
||||||
|
|
||||||
|
|
||||||
|
def test_xlsx_preserves_cell_fill_and_font_colors_as_html():
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet["A1"] = "Standort"
|
||||||
|
sheet["A1"].fill = PatternFill(fill_type="solid", fgColor="A5BF60")
|
||||||
|
sheet["A1"].font = Font(color="FFFFFF", bold=True)
|
||||||
|
sheet["A2"] = "Verwaltung"
|
||||||
|
sheet["A2"].fill = PatternFill(fill_type="solid", fgColor="EAF0DC")
|
||||||
|
stream = BytesIO()
|
||||||
|
workbook.save(stream)
|
||||||
|
|
||||||
|
result = import_document(Upload(stream.getvalue(), "farben.xlsx"))
|
||||||
|
|
||||||
|
assert result.body_format == "html"
|
||||||
|
assert "background-color: #a5bf60" in result.markdown
|
||||||
|
assert "color: #ffffff" in result.markdown
|
||||||
|
assert "font-weight: bold" in result.markdown
|
||||||
|
assert "background-color: #eaf0dc" in result.markdown
|
||||||
|
|
||||||
|
|
||||||
|
def test_xlsx_renders_builtin_excel_table_style_colors_in_preview_html():
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.append(["Standort", "Adresse"])
|
||||||
|
sheet.append(["Verwaltung", "10.1.2.17"])
|
||||||
|
sheet.append(["Büro", "10.1.2.18"])
|
||||||
|
table = Table(displayName="Standorte", ref="A1:B3")
|
||||||
|
table.tableStyleInfo = TableStyleInfo(
|
||||||
|
name="TableStyleMedium4", showFirstColumn=False,
|
||||||
|
showLastColumn=False, showRowStripes=True, showColumnStripes=False,
|
||||||
|
)
|
||||||
|
sheet.add_table(table)
|
||||||
|
stream = BytesIO()
|
||||||
|
workbook.save(stream)
|
||||||
|
|
||||||
|
result = import_document(Upload(stream.getvalue(), "tabelle.xlsx"))
|
||||||
|
|
||||||
|
assert result.body_format == "html"
|
||||||
|
assert "Standort" in result.markdown
|
||||||
|
assert result.markdown.count("background-color:") >= 4
|
||||||
|
assert "color: #ffffff" in result.markdown
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_legacy_excel():
|
def test_rejects_legacy_excel():
|
||||||
with pytest.raises(ImportFailure, match="xlsx"):
|
with pytest.raises(ImportFailure, match="xlsx"):
|
||||||
import_document(Upload(b"", "legacy.xls"))
|
import_document(Upload(b"", "legacy.xls"))
|
||||||
@@ -68,6 +113,66 @@ def test_excel_multi_sheet_import_creates_one_result_per_sheet():
|
|||||||
assert "Leitstelle" in results[1].markdown
|
assert "Leitstelle" in results[1].markdown
|
||||||
|
|
||||||
|
|
||||||
|
def test_excel_import_renders_only_selected_source_sheets():
|
||||||
|
workbook = Workbook()
|
||||||
|
first = workbook.active
|
||||||
|
first.title = "Nicht gewählt"
|
||||||
|
first.append(["Sehr viele Daten"])
|
||||||
|
second = workbook.create_sheet("Gewählt")
|
||||||
|
second.append(["Nur dieses Blatt"])
|
||||||
|
third = workbook.create_sheet("Auch nicht gewählt")
|
||||||
|
third.append(["Weitere Daten"])
|
||||||
|
stream = BytesIO()
|
||||||
|
workbook.save(stream)
|
||||||
|
|
||||||
|
results = import_excel_sheets(
|
||||||
|
Upload(stream.getvalue(), "auswahl.xlsx"), selected_indexes={1},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].title == "Gewählt"
|
||||||
|
assert results[0].source_index == 1
|
||||||
|
assert "Nur dieses Blatt" in results[0].markdown
|
||||||
|
assert "Sehr viele Daten" not in results[0].markdown
|
||||||
|
|
||||||
|
|
||||||
|
def test_excel_metadata_phase_skips_document_rendering():
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.title = "Großes Blatt"
|
||||||
|
sheet.append(["Name", "IP"])
|
||||||
|
sheet.append(["web01", "10.0.0.1"])
|
||||||
|
stream = BytesIO()
|
||||||
|
workbook.save(stream)
|
||||||
|
|
||||||
|
results = import_excel_sheets(
|
||||||
|
Upload(stream.getvalue(), "kunde.xlsx"), metadata_only=True,
|
||||||
|
include_image_data=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [result.title for result in results] == ["Großes Blatt"]
|
||||||
|
assert results[0].markdown == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_excel_preview_limits_rows_without_affecting_sheet_detection():
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet.append(["Nummer"])
|
||||||
|
for number in range(1, 151):
|
||||||
|
sheet.append([number])
|
||||||
|
stream = BytesIO()
|
||||||
|
workbook.save(stream)
|
||||||
|
|
||||||
|
results = import_excel_sheets(
|
||||||
|
Upload(stream.getvalue(), "gross.xlsx"), preview_max_rows=100,
|
||||||
|
include_image_data=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Vorschau auf 100 Zeilen begrenzt" in results[0].markdown
|
||||||
|
assert ">99<" in results[0].markdown
|
||||||
|
assert ">100<" not in results[0].markdown
|
||||||
|
|
||||||
|
|
||||||
def test_excel_flatten_turns_cells_into_document_flow():
|
def test_excel_flatten_turns_cells_into_document_flow():
|
||||||
workbook = Workbook()
|
workbook = Workbook()
|
||||||
sheet = workbook.active
|
sheet = workbook.active
|
||||||
@@ -78,10 +183,11 @@ def test_excel_flatten_turns_cells_into_document_flow():
|
|||||||
|
|
||||||
results = import_excel_sheets(Upload(stream.getvalue(), "server.xlsx"), flatten=True)
|
results = import_excel_sheets(Upload(stream.getvalue(), "server.xlsx"), flatten=True)
|
||||||
|
|
||||||
assert results[0].markdown == "Name \nIP\n\ndb01 \n10.0.0.2"
|
assert results[0].body_format == "html"
|
||||||
|
assert results[0].markdown == "<p>Name<br>IP</p>\n<p>db01<br>10.0.0.2</p>"
|
||||||
assert "Datensatz" not in results[0].markdown
|
assert "Datensatz" not in results[0].markdown
|
||||||
assert "Feld" not in results[0].markdown
|
assert "Feld" not in results[0].markdown
|
||||||
assert "|" not in results[0].markdown
|
assert "<table" not in results[0].markdown
|
||||||
|
|
||||||
|
|
||||||
def test_excel_flatten_preserves_explicit_excel_tables_only():
|
def test_excel_flatten_preserves_explicit_excel_tables_only():
|
||||||
@@ -100,6 +206,46 @@ def test_excel_flatten_preserves_explicit_excel_tables_only():
|
|||||||
markdown = results[0].markdown
|
markdown = results[0].markdown
|
||||||
|
|
||||||
assert "Normaler Hinweis" in markdown
|
assert "Normaler Hinweis" in markdown
|
||||||
assert "| Name" in markdown
|
assert "<table" in markdown
|
||||||
assert "| web01" in markdown
|
assert "Name" in markdown
|
||||||
|
assert "web01" in markdown
|
||||||
assert "Datensatz" not in markdown
|
assert "Datensatz" not in markdown
|
||||||
|
|
||||||
|
|
||||||
|
def test_excel_flatten_preserves_colors_of_explicit_excel_tables():
|
||||||
|
workbook = Workbook()
|
||||||
|
sheet = workbook.active
|
||||||
|
sheet["A1"] = "Standort"
|
||||||
|
sheet["B1"] = "Adresse"
|
||||||
|
sheet["A2"] = "Verwaltung"
|
||||||
|
sheet["B2"] = "10.1.2.17"
|
||||||
|
for cell in (sheet["A1"], sheet["B1"]):
|
||||||
|
cell.fill = PatternFill(fill_type="solid", fgColor="A5BF60")
|
||||||
|
cell.font = Font(color="FFFFFF", bold=True)
|
||||||
|
sheet.add_table(Table(displayName="Standorte", ref="A1:B2"))
|
||||||
|
stream = BytesIO()
|
||||||
|
workbook.save(stream)
|
||||||
|
|
||||||
|
results = import_excel_sheets(Upload(stream.getvalue(), "farben.xlsx"), flatten=True)
|
||||||
|
|
||||||
|
assert results[0].body_format == "html"
|
||||||
|
assert results[0].markdown.count("background-color: #a5bf60") == 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
|
||||||
|
|||||||
Reference in New Issue
Block a user