feat: integriertes Dokumentations-Wiki für NetBox hinzufügen

- Markdown-Dokumentationen direkt in NetBox erstellen
- Dokumente Standorten, Racks, Geräten, VMs und Clustern zuordnen
- DOCX-, XLSX-, PDF-, Markdown- und Textimporte unterstützen
- REST-API, Suche, Berechtigungen und Änderungsprotokoll ergänzen
- Installation, Konfiguration und Importgrenzen dokumentieren
This commit is contained in:
2026-07-22 10:23:58 +02:00
commit ab0ed26ad3
24 changed files with 764 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
from netbox.plugins import PluginConfig
class DocumentationConfig(PluginConfig):
name = "netbox_documentation"
verbose_name = "Dokumentation"
description = "Wiki und Office-Dokumentation direkt in NetBox"
version = "0.1.0"
author = "NetBox Documentation Contributors"
base_url = "documentation"
min_version = "4.0.0"
default_settings = {
"allowed_object_types": [
"dcim.region", "dcim.site", "dcim.location", "dcim.rack",
"dcim.device", "virtualization.virtualmachine",
"virtualization.cluster", "tenancy.tenant",
],
"max_import_size_mb": 25,
"keep_imported_file": True,
}
config = DocumentationConfig
+1
View File
@@ -0,0 +1 @@
+20
View File
@@ -0,0 +1,20 @@
from netbox.api.serializers import NetBoxModelSerializer
from rest_framework import serializers
from ..models import Document, DocumentAssignment
class DocumentSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_documentation-api:document-detail")
class Meta:
model = Document
fields = ("id", "url", "display", "title", "slug", "summary", "body", "is_published", "tags", "created", "last_updated")
class DocumentAssignmentSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name="plugins-api:netbox_documentation-api:documentassignment-detail")
class Meta:
model = DocumentAssignment
fields = ("id", "url", "display", "document", "assigned_object_type", "assigned_object_id", "note", "tags", "created", "last_updated")
+8
View File
@@ -0,0 +1,8 @@
from netbox.api.routers import NetBoxRouter
from . import views
router = NetBoxRouter()
router.register("documents", views.DocumentViewSet)
router.register("assignments", views.DocumentAssignmentViewSet)
urlpatterns = router.urls
+17
View File
@@ -0,0 +1,17 @@
from netbox.api.viewsets import NetBoxModelViewSet
from ..filtersets import DocumentFilterSet, AssignmentFilterSet
from ..models import Document, DocumentAssignment
from .serializers import DocumentSerializer, DocumentAssignmentSerializer
class DocumentViewSet(NetBoxModelViewSet):
queryset = Document.objects.all()
serializer_class = DocumentSerializer
filterset_class = DocumentFilterSet
class DocumentAssignmentViewSet(NetBoxModelViewSet):
queryset = DocumentAssignment.objects.all()
serializer_class = DocumentAssignmentSerializer
filterset_class = AssignmentFilterSet
+19
View File
@@ -0,0 +1,19 @@
from netbox.filtersets import NetBoxModelFilterSet
from .models import Document, DocumentAssignment
class DocumentFilterSet(NetBoxModelFilterSet):
class Meta:
model = Document
fields = ("id", "title", "slug", "is_published")
def search(self, queryset, name, value):
from django.db.models import Q
return queryset.filter(Q(title__icontains=value) | Q(summary__icontains=value) | Q(body__icontains=value))
class AssignmentFilterSet(NetBoxModelFilterSet):
class Meta:
model = DocumentAssignment
fields = ("id", "document_id", "assigned_object_type", "assigned_object_id")
+76
View File
@@ -0,0 +1,76 @@
from django import forms
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from utilities.forms.fields import SlugField
from netbox.forms import NetBoxModelForm
from .models import Document, DocumentAssignment
def allowed_content_types():
labels = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get(
"allowed_object_types", []
)
pairs = [value.split(".", 1) for value in labels if "." in value]
query = Q()
for app_label, model in pairs:
query |= Q(app_label=app_label, model=model)
return ContentType.objects.filter(query).order_by("app_label", "model")
class DocumentForm(NetBoxModelForm):
slug = SlugField(slug_source="title")
body = forms.CharField(required=False, widget=forms.Textarea(attrs={
"rows": 28, "class": "font-monospace", "data-markdown-editor": "true"
}), help_text="Markdown wird unterstützt. HTML wird bei der Ausgabe sicher gefiltert.")
class Meta:
model = Document
fields = ("title", "slug", "summary", "body", "is_published", "tags")
class AssignmentForm(NetBoxModelForm):
assigned_object_type = forms.ModelChoiceField(queryset=ContentType.objects.none(), label="Objekttyp")
assigned_object_id = forms.TypedChoiceField(coerce=int, label="NetBox-Objekt")
class Meta:
model = DocumentAssignment
fields = ("document", "assigned_object_type", "assigned_object_id", "note", "tags")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["assigned_object_type"].queryset = allowed_content_types()
content_type = None
ct_id = (self.data.get("assigned_object_type") or self.initial.get("assigned_object_type")
or getattr(self.instance, "assigned_object_type_id", None))
if ct_id:
content_type = ContentType.objects.filter(pk=ct_id).first()
if content_type:
model = content_type.model_class()
objects = model.objects.all().order_by("pk")
self.fields["assigned_object_id"].choices = [(obj.pk, str(obj)) for obj in objects]
def clean(self):
cleaned = super().clean()
object_id = cleaned.get("assigned_object_id")
ct = cleaned.get("assigned_object_type")
if object_id and ct and not ct.model_class().objects.filter(pk=object_id).exists():
self.add_error("assigned_object_id", "Das Objekt existiert für diesen Objekttyp nicht.")
return cleaned
class ImportForm(forms.Form):
file = forms.FileField(label="Word-, Excel- oder PDF-Datei")
title = forms.CharField(max_length=200, required=False, help_text="Leer lassen, um den Dateinamen zu verwenden")
append = forms.BooleanField(required=False, initial=False, label="An bestehende Dokumentation anhängen")
document = forms.ModelChoiceField(queryset=Document.objects.all(), required=False, label="Bestehende Dokumentation")
def clean(self):
data = super().clean()
if data.get("append") and not data.get("document"):
self.add_error("document", "Zum Anhängen muss eine Dokumentation gewählt werden.")
upload = data.get("file")
limit = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("max_import_size_mb", 25)
if upload and upload.size > limit * 1024 * 1024:
self.add_error("file", f"Die Datei ist größer als {limit} MB.")
return data
+73
View File
@@ -0,0 +1,73 @@
from dataclasses import dataclass, field
from io import BytesIO
from pathlib import Path
import re
class ImportFailure(ValueError):
pass
@dataclass
class ImportResult:
markdown: str
warnings: list[str] = field(default_factory=list)
def import_document(upload) -> ImportResult:
suffix = Path(upload.name).suffix.lower()
content = upload.read()
upload.seek(0)
if suffix == ".docx":
return _docx(content)
if suffix in {".xlsx", ".xlsm"}:
return _xlsx(content)
if suffix == ".pdf":
return _pdf(content)
if suffix in {".md", ".txt"}:
return ImportResult(content.decode("utf-8-sig"))
if suffix in {".doc", ".xls"}:
raise ImportFailure("Alte .doc/.xls-Dateien bitte zuerst als .docx/.xlsx speichern.")
raise ImportFailure("Unterstützt werden DOCX, XLSX, XLSM, PDF, Markdown und Text.")
def _docx(content):
import mammoth
result = mammoth.convert_to_markdown(BytesIO(content))
warnings = [message.message for message in result.messages]
return ImportResult(result.value.strip(), warnings)
def _xlsx(content):
from openpyxl import load_workbook
from tabulate import tabulate
workbook = load_workbook(BytesIO(content), read_only=True, data_only=True)
sections = []
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)]
while rows and not any(value for value in rows[-1]):
rows.pop()
if not rows:
continue
width = max(len(row) for row in rows)
rows = [row + [""] * (width - len(row)) for row in rows]
header, body = rows[0], rows[1:]
sections.append(f"## {sheet.title}\n\n{tabulate(body, headers=header, tablefmt='github')}")
if not sections:
raise ImportFailure("Die Arbeitsmappe enthält keine Daten.")
return ImportResult("\n\n".join(sections))
def _pdf(content):
from pypdf import PdfReader
reader = PdfReader(BytesIO(content))
pages = []
for number, page in enumerate(reader.pages, 1):
text = (page.extract_text() or "").strip()
if text:
text = re.sub(r"[ \t]+\n", "\n", text)
pages.append(f"## Seite {number}\n\n{text}")
if not pages:
raise ImportFailure("Das PDF enthält keinen extrahierbaren Text. Für Scans ist OCR erforderlich.")
return ImportResult("\n\n".join(pages), ["PDF-Layout und Bilder können nicht vollständig übernommen werden."])
@@ -0,0 +1,42 @@
from django.db import migrations, models
import django.db.models.deletion
import netbox.models.features
import netbox_documentation.models
class Migration(migrations.Migration):
initial = True
dependencies = [("contenttypes", "0002_remove_content_type_name"), ("extras", "0001_squashed")]
operations = [
migrations.CreateModel(name="Document", fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
("created", models.DateTimeField(auto_now_add=True, null=True)),
("last_updated", models.DateTimeField(auto_now=True, null=True)),
("custom_field_data", models.JSONField(blank=True, default=dict, encoder=netbox.models.features.CustomFieldJSONEncoder)),
("title", models.CharField(max_length=200)), ("slug", models.SlugField(max_length=200, unique=True)),
("body", models.TextField(blank=True, help_text="Markdown")), ("summary", models.CharField(blank=True, max_length=500)),
("is_published", models.BooleanField(default=True)),
("tags", models.ManyToManyField(blank=True, related_name="netbox_documentation_document_items", to="extras.tag")),
], options={"ordering": ("title",), "permissions": (("import_document", "Can import office documents"),)}),
migrations.CreateModel(name="DocumentAssignment", fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
("created", models.DateTimeField(auto_now_add=True, null=True)), ("last_updated", models.DateTimeField(auto_now=True, null=True)),
("custom_field_data", models.JSONField(blank=True, default=dict, encoder=netbox.models.features.CustomFieldJSONEncoder)),
("assigned_object_id", models.PositiveBigIntegerField()), ("note", models.CharField(blank=True, max_length=200)),
("assigned_object_type", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype")),
("document", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="assignments", to="netbox_documentation.document")),
("tags", models.ManyToManyField(blank=True, related_name="netbox_documentation_documentassignment_items", to="extras.tag")),
], options={"ordering": ("document", "assigned_object_type", "assigned_object_id")}),
migrations.CreateModel(name="DocumentAttachment", fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
("created", models.DateTimeField(auto_now_add=True, null=True)), ("last_updated", models.DateTimeField(auto_now=True, null=True)),
("custom_field_data", models.JSONField(blank=True, default=dict, encoder=netbox.models.features.CustomFieldJSONEncoder)),
("file", models.FileField(upload_to=netbox_documentation.models.attachment_upload_path)),
("original_name", models.CharField(max_length=255)), ("content_type", models.CharField(blank=True, max_length=100)),
("size", models.PositiveBigIntegerField(default=0)),
("document", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="attachments", to="netbox_documentation.document")),
("tags", models.ManyToManyField(blank=True, related_name="netbox_documentation_documentattachment_items", to="extras.tag")),
], options={"ordering": ("original_name",)}),
migrations.AddConstraint(model_name="documentassignment", constraint=models.UniqueConstraint(fields=("document", "assigned_object_type", "assigned_object_id"), name="netbox_documentation_documentassignment_unique_assignment")),
]
@@ -0,0 +1 @@
+60
View File
@@ -0,0 +1,60 @@
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.urls import reverse
from netbox.models import NetBoxModel
class Document(NetBoxModel):
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True)
body = models.TextField(blank=True, help_text="Markdown")
summary = models.CharField(max_length=500, blank=True)
is_published = models.BooleanField(default=True)
class Meta:
ordering = ("title",)
permissions = (("import_document", "Can import office documents"),)
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("plugins:netbox_documentation:document", args=[self.pk])
class DocumentAssignment(NetBoxModel):
document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="assignments")
assigned_object_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
assigned_object_id = models.PositiveBigIntegerField()
assigned_object = GenericForeignKey("assigned_object_type", "assigned_object_id")
note = models.CharField(max_length=200, blank=True)
class Meta:
ordering = ("document", "assigned_object_type", "assigned_object_id")
constraints = [models.UniqueConstraint(
fields=("document", "assigned_object_type", "assigned_object_id"),
name="%(app_label)s_%(class)s_unique_assignment",
)]
def __str__(self):
return f"{self.document}{self.assigned_object}"
def attachment_upload_path(instance, filename):
return f"netbox_documentation/{instance.document_id}/{filename}"
class DocumentAttachment(NetBoxModel):
document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="attachments")
file = models.FileField(upload_to=attachment_upload_path)
original_name = models.CharField(max_length=255)
content_type = models.CharField(max_length=100, blank=True)
size = models.PositiveBigIntegerField(default=0)
class Meta:
ordering = ("original_name",)
def __str__(self):
return self.original_name
+17
View File
@@ -0,0 +1,17 @@
from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices
menu = PluginMenu(
label="Dokumentation",
groups=(("Wiki", (
PluginMenuItem(link="plugins:netbox_documentation:document_list", link_text="Dokumentationen", buttons=(
PluginMenuButton(link="plugins:netbox_documentation:document_add", title="Neu", icon_class="mdi mdi-plus-thick", color=ButtonColorChoices.GREEN),
PluginMenuButton(link="plugins:netbox_documentation:document_import", title="Import", icon_class="mdi mdi-file-import", color=ButtonColorChoices.BLUE),
)),
PluginMenuItem(link="plugins:netbox_documentation:documentassignment_list", link_text="Zuordnungen", buttons=(
PluginMenuButton(link="plugins:netbox_documentation:documentassignment_add", title="Zuordnen", icon_class="mdi mdi-link-plus", color=ButtonColorChoices.GREEN),
)),
)),),
icon_class="mdi mdi-book-open-page-variant",
)
+10
View File
@@ -0,0 +1,10 @@
from netbox.search import SearchIndex, register_search
from .models import Document
@register_search
class DocumentIndex(SearchIndex):
model = Document
fields = (("title", 100), ("summary", 80), ("body", 50))
display_attrs = ("summary",)
+24
View File
@@ -0,0 +1,24 @@
import django_tables2 as tables
from netbox.tables import NetBoxTable, columns
from .models import Document, DocumentAssignment
class DocumentTable(NetBoxTable):
title = tables.Column(linkify=True)
assignments = tables.Column(accessor="assignments.count", verbose_name="Zuordnungen", orderable=False)
actions = columns.ActionsColumn(actions=("edit", "delete"))
class Meta(NetBoxTable.Meta):
model = Document
fields = ("pk", "title", "summary", "is_published", "assignments", "last_updated", "actions")
class AssignmentTable(NetBoxTable):
document = tables.Column(linkify=True)
assigned_object = tables.Column(linkify=True, verbose_name="NetBox-Objekt")
actions = columns.ActionsColumn(actions=("edit", "delete"))
class Meta(NetBoxTable.Meta):
model = DocumentAssignment
fields = ("pk", "document", "assigned_object_type", "assigned_object", "note", "actions")
+26
View File
@@ -0,0 +1,26 @@
from netbox.plugins import PluginTemplateExtension
from django.contrib.contenttypes.models import ContentType
from .models import DocumentAssignment
class ObjectDocumentation(PluginTemplateExtension):
models = [
"dcim.region", "dcim.site", "dcim.location", "dcim.rack", "dcim.device",
"virtualization.virtualmachine", "virtualization.cluster", "tenancy.tenant",
]
def right_page(self):
obj = self.context["object"]
content_type = ContentType.objects.get_for_model(obj)
assignments = DocumentAssignment.objects.filter(
assigned_object_type=content_type, assigned_object_id=obj.pk,
document__is_published=True,
).select_related("document")
return self.render("netbox_documentation/inc/object_documents.html", extra_context={
"documentation_assignments": assignments,
"content_type": content_type,
})
template_extensions = [ObjectDocumentation]
@@ -0,0 +1,19 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% block content %}
<div class="row">
<div class="col col-md-9">
{% if object.summary %}<p class="lead">{{ object.summary }}</p>{% endif %}
<div class="card"><div class="card-body rendered-markdown">{{ object.body|render_markdown }}</div></div>
</div>
<div class="col col-md-3">
<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 %}
</div></div>
{% if object.attachments.all %}<div class="card mt-3"><h5 class="card-header">Originaldateien</h5><div class="list-group list-group-flush">
{% for attachment in object.attachments.all %}<a class="list-group-item" href="{{ attachment.file.url }}">{{ attachment.original_name }}</a>{% endfor %}
</div></div>{% endif %}
</div>
</div>
{% endblock content %}
@@ -0,0 +1,13 @@
{% extends 'base/layout.html' %}
{% load form_helpers %}
{% block title %}Dokument importieren{% endblock %}
{% block content %}
<div class="row justify-content-center"><div class="col col-md-8"><div class="card">
<h5 class="card-header">Word, Excel oder PDF importieren</h5>
<div class="card-body"><form method="post" enctype="multipart/form-data">{% csrf_token %}{% render_form form %}
<div class="text-muted mb-3">DOCX übernimmt Überschriften, Listen und Tabellen. XLSX wird je Tabellenblatt zu einer Markdown-Tabelle. PDF benötigt eine echte Textebene; Scan-OCR ist nicht enthalten.</div>
<button type="submit" class="btn btn-primary">Importieren</button>
</form></div>
</div></div></div>
{% endblock %}
@@ -0,0 +1,11 @@
<div class="card">
<h5 class="card-header"><i class="mdi mdi-book-open-page-variant"></i> Dokumentation</h5>
<div class="list-group list-group-flush">
{% for assignment in documentation_assignments %}
<a class="list-group-item list-group-item-action" href="{{ assignment.document.get_absolute_url }}">
<strong>{{ assignment.document.title }}</strong>{% if assignment.note %}<br><small>{{ assignment.note }}</small>{% endif %}
</a>
{% empty %}<div class="list-group-item text-muted">Keine Dokumentation zugeordnet.</div>{% endfor %}
</div>
{% if perms.netbox_documentation.add_documentassignment %}<div class="card-footer"><a href="{% url 'plugins:netbox_documentation:documentassignment_add' %}?assigned_object_type={{ content_type.pk }}&assigned_object_id={{ object.pk }}" class="btn btn-sm btn-primary"><i class="mdi mdi-link-plus"></i> Zuordnen</a></div>{% endif %}
</div>
+18
View File
@@ -0,0 +1,18 @@
from django.urls import path
from netbox.views.generic import ObjectChangeLogView
from . import models, views
urlpatterns = (
path("", views.DocumentListView.as_view(), name="document_list"),
path("documents/add/", views.DocumentEditView.as_view(), name="document_add"),
path("documents/import/", views.DocumentImportView.as_view(), name="document_import"),
path("documents/<int:pk>/", views.DocumentView.as_view(), name="document"),
path("documents/<int:pk>/edit/", views.DocumentEditView.as_view(), name="document_edit"),
path("documents/<int:pk>/delete/", views.DocumentDeleteView.as_view(), name="document_delete"),
path("documents/<int:pk>/changelog/", ObjectChangeLogView.as_view(), name="document_changelog", kwargs={"model": models.Document}),
path("assignments/", views.AssignmentListView.as_view(), name="documentassignment_list"),
path("assignments/add/", views.AssignmentEditView.as_view(), name="documentassignment_add"),
path("assignments/<int:pk>/edit/", views.AssignmentEditView.as_view(), name="documentassignment_edit"),
path("assignments/<int:pk>/delete/", views.AssignmentDeleteView.as_view(), name="documentassignment_delete"),
)
+93
View File
@@ -0,0 +1,93 @@
from pathlib import Path
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction
from django.shortcuts import redirect, render
from django.utils.text import slugify
from django.views import View
from netbox.views import generic
from .filtersets import DocumentFilterSet, AssignmentFilterSet
from .forms import DocumentForm, AssignmentForm, ImportForm
from .importers import ImportFailure, import_document
from .models import Document, DocumentAssignment, DocumentAttachment
from .tables import DocumentTable, AssignmentTable
class DocumentListView(generic.ObjectListView):
queryset = Document.objects.prefetch_related("assignments")
table = DocumentTable
filterset = DocumentFilterSet
class DocumentView(generic.ObjectView):
queryset = Document.objects.prefetch_related("assignments", "attachments")
class DocumentEditView(generic.ObjectEditView):
queryset = Document.objects.all()
form = DocumentForm
class DocumentDeleteView(generic.ObjectDeleteView):
queryset = Document.objects.all()
class AssignmentListView(generic.ObjectListView):
queryset = DocumentAssignment.objects.select_related("document", "assigned_object_type")
table = AssignmentTable
filterset = AssignmentFilterSet
class AssignmentEditView(generic.ObjectEditView):
queryset = DocumentAssignment.objects.all()
form = AssignmentForm
class AssignmentDeleteView(generic.ObjectDeleteView):
queryset = DocumentAssignment.objects.all()
class DocumentImportView(PermissionRequiredMixin, View):
permission_required = "netbox_documentation.import_document"
template_name = "netbox_documentation/document_import.html"
def get(self, request):
return render(request, self.template_name, {"form": ImportForm()})
def post(self, request):
form = ImportForm(request.POST, request.FILES)
if not form.is_valid():
return render(request, self.template_name, {"form": form})
upload = form.cleaned_data["file"]
try:
result = import_document(upload)
except (ImportFailure, Exception) as exc:
# Known conversion/library errors are presented without exposing a traceback.
form.add_error("file", f"Import fehlgeschlagen: {exc}")
return render(request, self.template_name, {"form": form})
with transaction.atomic():
document = form.cleaned_data.get("document")
if document:
separator = "\n\n---\n\n" if document.body else ""
document.body += separator + result.markdown
document.save()
else:
title = form.cleaned_data.get("title") or Path(upload.name).stem
base_slug = slugify(title)[:180] or "dokumentation"
slug = base_slug
counter = 2
while Document.objects.filter(slug=slug).exists():
slug = f"{base_slug}-{counter}"
counter += 1
document = Document.objects.create(title=title, slug=slug, body=result.markdown)
keep = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True)
if keep:
upload.seek(0)
DocumentAttachment.objects.create(document=document, file=upload,
original_name=upload.name, content_type=upload.content_type or "", size=upload.size)
for warning in result.warnings:
messages.warning(request, warning)
messages.success(request, f"{upload.name} wurde importiert.")
return redirect(document)