feat: Vorschau und Blattauswahl für Excel-Mehrblattimport ergänzen
This commit is contained in:
@@ -5,7 +5,7 @@ class DocumentationConfig(PluginConfig):
|
||||
name = "netbox_documentation"
|
||||
verbose_name = "NetBox Dokumentation"
|
||||
description = "Wiki und Office-Dokumentation direkt in NetBox"
|
||||
version = "0.6.1"
|
||||
version = "0.7.0"
|
||||
author = "LKE"
|
||||
base_url = "documentation"
|
||||
min_version = "4.0.0"
|
||||
|
||||
@@ -201,3 +201,39 @@ class ArchiveImportForm(forms.Form):
|
||||
if upload.size > limit * 1024 * 1024:
|
||||
raise forms.ValidationError(f"Das Archiv ist größer als {limit} MB.")
|
||||
return upload
|
||||
|
||||
|
||||
class ExcelSheetSelectionForm(forms.Form):
|
||||
include = forms.BooleanField(
|
||||
required=False, initial=True, label="Importieren",
|
||||
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
)
|
||||
index = forms.IntegerField(widget=forms.HiddenInput())
|
||||
title = forms.CharField(
|
||||
max_length=200, required=False, label="Dokumenttitel",
|
||||
widget=forms.TextInput(attrs={"class": "form-control"}),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
if data.get("include") and not (data.get("title") or "").strip():
|
||||
self.add_error("title", "Für ein ausgewähltes Arbeitsblatt ist ein Titel erforderlich.")
|
||||
return data
|
||||
|
||||
|
||||
class BaseExcelSheetSelectionFormSet(forms.BaseFormSet):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if any(form.errors for form in self.forms):
|
||||
return
|
||||
selected = [form.cleaned_data for form in self.forms if form.cleaned_data.get("include")]
|
||||
if not selected:
|
||||
raise forms.ValidationError("Bitte mindestens ein Arbeitsblatt auswählen.")
|
||||
indices = [item["index"] for item in selected]
|
||||
if len(indices) != len(set(indices)):
|
||||
raise forms.ValidationError("Die Arbeitsblattauswahl ist ungültig.")
|
||||
|
||||
|
||||
ExcelSheetSelectionFormSet = forms.formset_factory(
|
||||
ExcelSheetSelectionForm, formset=BaseExcelSheetSelectionFormSet, extra=0
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import netbox_documentation.models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("netbox_documentation", "0003_documentcategory_document_category"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ExcelImportPreview",
|
||||
fields=[
|
||||
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
("file", models.FileField(upload_to=netbox_documentation.models.preview_upload_path)),
|
||||
("original_name", models.CharField(max_length=255)),
|
||||
("content_type", models.CharField(blank=True, max_length=100)),
|
||||
("flatten", models.BooleanField(default=False)),
|
||||
("sheet_metadata", models.JSONField(default=list)),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("category", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to="netbox_documentation.documentcategory")),
|
||||
("user", models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={"ordering": ("-created",)},
|
||||
),
|
||||
]
|
||||
@@ -1,7 +1,10 @@
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
from netbox.models import NetBoxModel
|
||||
|
||||
|
||||
@@ -141,3 +144,28 @@ class DocumentAttachment(NetBoxModel):
|
||||
# have no standalone detail view. This is also used by NetBox's delete
|
||||
# dependency collector when rendering the confirmation dialog.
|
||||
return self.document.get_absolute_url()
|
||||
|
||||
|
||||
def preview_upload_path(instance, filename):
|
||||
return f"netbox_documentation/import-previews/{instance.pk}/{Path(filename).name}"
|
||||
|
||||
|
||||
class ExcelImportPreview(models.Model):
|
||||
"""Short-lived, user-bound state for the two-step Excel import workflow."""
|
||||
_netbox_private = True
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
|
||||
category = models.ForeignKey(DocumentCategory, on_delete=models.SET_NULL, null=True)
|
||||
file = models.FileField(upload_to=preview_upload_path)
|
||||
original_name = models.CharField(max_length=255)
|
||||
content_type = models.CharField(max_length=100, blank=True)
|
||||
flatten = models.BooleanField(default=False)
|
||||
sheet_metadata = models.JSONField(default=list)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ("-created",)
|
||||
|
||||
def __str__(self):
|
||||
return self.original_name
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
{% extends 'base/layout.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Excel-Importvorschau{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ block.super }}
|
||||
<style>
|
||||
.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 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); }
|
||||
</style>
|
||||
{% endblock head %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap gap-4">
|
||||
<div><strong>Datei:</strong> {{ batch.original_name }}</div>
|
||||
<div><strong>Zielordner:</strong> {{ batch.category|default:"Nicht mehr vorhanden" }}</div>
|
||||
<div><strong>Glätten:</strong> {% if batch.flatten %}Ja{% else %}Nein{% endif %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ formset.management_form }}
|
||||
{% if formset.non_form_errors %}<div class="alert alert-danger">{{ formset.non_form_errors }}</div>{% endif %}
|
||||
|
||||
{% for item in items %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<div class="row align-items-center w-100 g-3">
|
||||
<div class="col-auto">
|
||||
{{ item.form.include }}
|
||||
<label for="{{ item.form.include.id_for_label }}" class="ms-1">Importieren</label>
|
||||
</div>
|
||||
<div class="col">
|
||||
{{ item.form.index }}
|
||||
<label for="{{ item.form.title.id_for_label }}" class="form-label mb-1">Dokumenttitel</label>
|
||||
{{ item.form.title }}
|
||||
{% for error in item.form.title.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-auto text-muted">
|
||||
{{ item.sheet.images|length }} Bild{{ item.sheet.images|length|pluralize:"er" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<details>
|
||||
<summary class="p-3 fw-bold" style="cursor:pointer">Vorschau der erzeugten Dokumentation anzeigen</summary>
|
||||
<div class="excel-preview-content border-top p-3 rendered-markdown">
|
||||
{{ item.preview_html|safe }}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="sticky-actions sticky-actions-footer d-print-none" data-sticky-position="full" data-sticky-when="always">
|
||||
<div class="btn-list">
|
||||
<button type="submit" name="action" value="cancel" class="btn btn-outline-secondary">Abbrechen</button>
|
||||
<button type="submit" name="action" value="confirm" class="btn btn-primary"><i class="mdi mdi-file-import"></i> Ausgewählte Arbeitsblätter importieren</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
@@ -8,6 +8,7 @@ urlpatterns = (
|
||||
path("documents/add/", views.DocumentEditView.as_view(), name="document_add"),
|
||||
path("documents/import/", views.DocumentImportView.as_view(), name="document_import"),
|
||||
path("documents/import/", views.DocumentImportView.as_view(), name="document_bulk_import"),
|
||||
path("documents/import/excel/<uuid:token>/", views.ExcelImportPreviewView.as_view(), name="excel_import_preview"),
|
||||
path("documents/delete/", views.DocumentBulkDeleteView.as_view(), name="document_bulk_delete"),
|
||||
path("archive/", views.DocumentArchiveView.as_view(), name="document_archive"),
|
||||
path("documents/<int:pk>/", views.DocumentView.as_view(), name="document"),
|
||||
|
||||
+150
-45
@@ -1,4 +1,5 @@
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
import mimetypes
|
||||
import tinymce
|
||||
from django.conf import settings
|
||||
@@ -9,17 +10,21 @@ from django.db import transaction
|
||||
from django.http import FileResponse, Http404, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.text import slugify
|
||||
from django.utils import timezone
|
||||
from django.views import View
|
||||
from netbox.views import generic
|
||||
from netbox.object_actions import AddObject, BulkDelete, BulkImport
|
||||
from .filtersets import DocumentFilterSet, AssignmentFilterSet, DocumentCategoryFilterSet
|
||||
from .forms import (
|
||||
ArchiveExportForm, ArchiveImportForm, AssignmentForm, DocumentCategoryForm,
|
||||
DocumentForm, ImportForm,
|
||||
DocumentForm, ExcelSheetSelectionFormSet, ImportForm,
|
||||
)
|
||||
from .archive import ArchiveFailure, documents_for_categories, export_documents, import_archive
|
||||
from .importers import ImportFailure, import_document, import_excel_sheets
|
||||
from .models import Document, DocumentAssignment, DocumentAttachment, DocumentCategory
|
||||
from .models import (
|
||||
Document, DocumentAssignment, DocumentAttachment, DocumentCategory,
|
||||
ExcelImportPreview,
|
||||
)
|
||||
from .tables import DocumentTable, AssignmentTable, DocumentCategoryTable
|
||||
|
||||
|
||||
@@ -175,59 +180,159 @@ class DocumentImportView(PermissionRequiredMixin, View):
|
||||
return redirect(document)
|
||||
|
||||
def _import_excel_sheets(self, request, form, upload):
|
||||
from django.core.files.base import ContentFile
|
||||
try:
|
||||
sheets = import_excel_sheets(upload, flatten=form.cleaned_data.get("flatten_excel_tables", False))
|
||||
except (ImportFailure, Exception) as exc:
|
||||
form.add_error("file", f"Excel-Mehrblattimport fehlgeschlagen: {exc}")
|
||||
return render(request, self.template_name, {"form": form})
|
||||
category = form.cleaned_data["category"]
|
||||
created_documents = []
|
||||
# Remove abandoned previews owned by this user before creating a new one.
|
||||
stale = ExcelImportPreview.objects.filter(created__lt=timezone.now() - timedelta(hours=24))
|
||||
for preview in stale:
|
||||
preview.file.delete(save=False)
|
||||
preview.delete()
|
||||
upload.seek(0)
|
||||
original_content = upload.read()
|
||||
upload.seek(0)
|
||||
with transaction.atomic():
|
||||
for sheet in sheets:
|
||||
base_slug = slugify(sheet.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=sheet.title[:200], slug=document_slug, body=sheet.markdown,
|
||||
body_format="markdown", category=category,
|
||||
summary=f"Importiert aus {upload.name}",
|
||||
)
|
||||
if sheet.images:
|
||||
image_lines = []
|
||||
for image in sheet.images:
|
||||
attachment = DocumentAttachment(
|
||||
document=document, original_name=image.name,
|
||||
content_type=image.content_type, size=len(image.content),
|
||||
)
|
||||
attachment.file.save(image.name, ContentFile(image.content), save=False)
|
||||
attachment.save()
|
||||
location = f" ({image.cell})" if image.cell else ""
|
||||
image_lines.append(f"")
|
||||
document.body += "\n\n## Bilder\n\n" + "\n\n".join(image_lines)
|
||||
document.save(update_fields=("body", "last_updated"))
|
||||
created_documents.append(document)
|
||||
keep = settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True)
|
||||
if keep:
|
||||
for document in created_documents:
|
||||
attachment = DocumentAttachment(
|
||||
document=document, original_name=upload.name,
|
||||
content_type=upload.content_type or "", size=len(original_content),
|
||||
)
|
||||
attachment.file.save(upload.name, ContentFile(original_content), save=False)
|
||||
attachment.save()
|
||||
image_count = sum(len(sheet.images) for sheet in sheets)
|
||||
preview = ExcelImportPreview.objects.create(
|
||||
user=request.user, category=form.cleaned_data["category"], file=upload,
|
||||
original_name=upload.name, content_type=upload.content_type or "",
|
||||
flatten=form.cleaned_data.get("flatten_excel_tables", False),
|
||||
sheet_metadata=[{"title": sheet.title, "image_count": len(sheet.images)} for sheet in sheets],
|
||||
)
|
||||
return redirect("plugins:netbox_documentation:excel_import_preview", token=preview.pk)
|
||||
|
||||
|
||||
class ExcelImportPreviewView(PermissionRequiredMixin, View):
|
||||
permission_required = "netbox_documentation.import_document"
|
||||
template_name = "netbox_documentation/excel_import_preview.html"
|
||||
|
||||
def get_preview(self, request, token):
|
||||
return get_object_or_404(ExcelImportPreview, pk=token, user=request.user)
|
||||
|
||||
def read_sheets(self, preview):
|
||||
preview.file.open("rb")
|
||||
try:
|
||||
return import_excel_sheets(preview.file, flatten=preview.flatten)
|
||||
finally:
|
||||
preview.file.close()
|
||||
|
||||
def render_preview(self, request, preview, sheets, formset):
|
||||
items = []
|
||||
for form, sheet in zip(formset.forms, sheets):
|
||||
sample = sheet.markdown
|
||||
if sheet.images:
|
||||
sample += f"\n\n## Bilder\n\n_{len(sheet.images)} eingebettete Bilder werden beim Import angefügt._"
|
||||
truncated = len(sample) > 100000
|
||||
if truncated:
|
||||
sample = sample[:100000] + "\n\n_… Vorschau gekürzt …_"
|
||||
preview_document = Document(body=sample, body_format="markdown")
|
||||
items.append({
|
||||
"form": form, "sheet": sheet,
|
||||
"preview_html": preview_document.rendered_body(), "truncated": truncated,
|
||||
})
|
||||
return render(request, self.template_name, {
|
||||
"batch": preview, "formset": formset, "items": items,
|
||||
})
|
||||
|
||||
def get(self, request, token):
|
||||
preview = self.get_preview(request, token)
|
||||
if preview.created < timezone.now() - timedelta(hours=24):
|
||||
self.delete_preview(preview)
|
||||
messages.error(request, "Diese Importvorschau ist abgelaufen. Bitte die Exceldatei erneut hochladen.")
|
||||
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 Excel-Vorschau konnte nicht erzeugt werden: {exc}")
|
||||
return redirect("plugins:netbox_documentation:document_import")
|
||||
initial = [{"include": True, "index": index, "title": sheet.title} for index, sheet in enumerate(sheets)]
|
||||
return self.render_preview(request, preview, sheets, ExcelSheetSelectionFormSet(initial=initial))
|
||||
|
||||
def post(self, request, token):
|
||||
preview = self.get_preview(request, token)
|
||||
if request.POST.get("action") == "cancel":
|
||||
self.delete_preview(preview)
|
||||
messages.info(request, "Excel-Import wurde abgebrochen.")
|
||||
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)
|
||||
if not formset.is_valid():
|
||||
return self.render_preview(request, preview, sheets, formset)
|
||||
if not preview.category:
|
||||
self.delete_preview(preview)
|
||||
messages.error(request, "Der gewählte Zielordner existiert nicht mehr. Bitte den Import erneut starten.")
|
||||
return redirect("plugins:netbox_documentation:document_import")
|
||||
selections = {}
|
||||
for form in formset.forms:
|
||||
data = form.cleaned_data
|
||||
if data.get("include"):
|
||||
if data["index"] < 0 or data["index"] >= len(sheets):
|
||||
raise PermissionDenied
|
||||
selections[data["index"]] = data["title"].strip()
|
||||
result = self.create_documents(preview, sheets, selections)
|
||||
self.delete_preview(preview)
|
||||
messages.success(request, (
|
||||
f"{len(created_documents)} Arbeitsblätter als Dokumentationen in „{category}“ importiert; "
|
||||
f"{image_count} Bilder übernommen."
|
||||
f"{result['documents']} Arbeitsblätter als Dokumentationen in „{result['category']}“ importiert; "
|
||||
f"{result['images']} Bilder übernommen."
|
||||
))
|
||||
return redirect("plugins:netbox_documentation:document_list")
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_documents(preview, sheets, selections):
|
||||
from django.core.files.base import ContentFile
|
||||
preview.file.open("rb")
|
||||
try:
|
||||
original_content = preview.file.read()
|
||||
finally:
|
||||
preview.file.close()
|
||||
created_documents, image_count = [], 0
|
||||
for index, title in selections.items():
|
||||
sheet = sheets[index]
|
||||
base_slug = slugify(title)[:180] or "arbeitsblatt"
|
||||
document_slug, counter = base_slug, 2
|
||||
while Document.objects.filter(slug=document_slug).exists():
|
||||
document_slug = f"{base_slug[:190-len(str(counter))]}-{counter}"
|
||||
counter += 1
|
||||
document = Document.objects.create(
|
||||
title=title[:200], slug=document_slug, body=sheet.markdown,
|
||||
body_format="markdown", category=preview.category,
|
||||
summary=f"Importiert aus {preview.original_name}",
|
||||
)
|
||||
if sheet.images:
|
||||
image_lines = []
|
||||
for image in sheet.images:
|
||||
attachment = DocumentAttachment(
|
||||
document=document, original_name=image.name,
|
||||
content_type=image.content_type, size=len(image.content),
|
||||
)
|
||||
attachment.file.save(image.name, ContentFile(image.content), save=False)
|
||||
attachment.save()
|
||||
location = f" ({image.cell})" if image.cell else ""
|
||||
image_lines.append(f"")
|
||||
document.body += "\n\n## Bilder\n\n" + "\n\n".join(image_lines)
|
||||
document.save(update_fields=("body", "last_updated"))
|
||||
image_count += len(sheet.images)
|
||||
created_documents.append(document)
|
||||
if settings.PLUGINS_CONFIG.get("netbox_documentation", {}).get("keep_imported_file", True):
|
||||
for document in created_documents:
|
||||
attachment = DocumentAttachment(
|
||||
document=document, original_name=preview.original_name,
|
||||
content_type=preview.content_type, size=len(original_content),
|
||||
)
|
||||
attachment.file.save(preview.original_name, ContentFile(original_content), save=False)
|
||||
attachment.save()
|
||||
return {"documents": len(created_documents), "images": image_count, "category": preview.category}
|
||||
|
||||
@staticmethod
|
||||
def delete_preview(preview):
|
||||
preview.file.delete(save=False)
|
||||
preview.delete()
|
||||
|
||||
|
||||
class DocumentMediaUploadView(PermissionRequiredMixin, View):
|
||||
permission_required = "netbox_documentation.change_document"
|
||||
|
||||
Reference in New Issue
Block a user