Compare commits
2
Commits
8a2ccfd516
...
3f2e7ca667
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f2e7ca667 | ||
|
|
e4f4cd7c14 |
@@ -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.4"
|
version = "0.7.6"
|
||||||
author = "LKE"
|
author = "LKE"
|
||||||
base_url = "documentation"
|
base_url = "documentation"
|
||||||
min_version = "4.0.0"
|
min_version = "4.0.0"
|
||||||
|
|||||||
@@ -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,7 @@ 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"
|
||||||
|
|
||||||
|
|
||||||
def import_document(upload, flatten_excel=False) -> ImportResult:
|
def import_document(upload, flatten_excel=False) -> ImportResult:
|
||||||
@@ -80,7 +83,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 +91,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) if flatten else _render_excel_worksheet_html(sheet, workbook)
|
||||||
sections.append(f"## {sheet.title}\n\n{rendered}")
|
sections.append(f"## {sheet.title}\n\n{rendered}" if flatten else 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="markdown" if flatten else "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,
|
||||||
|
) -> 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 +109,10 @@ 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:
|
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()
|
|
||||||
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 +123,168 @@ 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, 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="markdown" if flatten else "html",
|
||||||
|
))
|
||||||
|
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 == "solid":
|
||||||
|
fill = _excel_color(cell.fill.fgColor, workbook)
|
||||||
|
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 _render_excel_worksheet_html(sheet, workbook, max_rows=None):
|
||||||
|
"""Render worksheet values and the most relevant visual Excel cell formatting."""
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
|
last_row = last_column = 0
|
||||||
|
for row in sheet.iter_rows():
|
||||||
|
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, max_rows) if max_rows else last_row
|
||||||
|
max_column = last_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(1, 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(1, 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(1, 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 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 > max_rows:
|
||||||
|
output.append(f"<p><em>Vorschau auf {max_rows} von {last_row} Zeilen begrenzt.</em></p>")
|
||||||
|
return "".join(output)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_flattened_worksheet(sheet, 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 +303,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):
|
||||||
@@ -183,11 +322,14 @@ def _render_flattened_worksheet(sheet):
|
|||||||
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 = []
|
rows = []
|
||||||
for table_row in sheet.iter_rows(
|
for table_row in sheet.iter_rows(
|
||||||
min_row=min_row, max_row=max_row, min_col=min_col, max_col=max_col, values_only=True
|
min_row=min_row, max_row=min(max_row, rendered_max_row),
|
||||||
|
min_col=min_col, max_col=max_col, values_only=True
|
||||||
):
|
):
|
||||||
rows.append(["" if value is None else str(value) for value in table_row])
|
rows.append(["" if value is None else str(value) for value in table_row])
|
||||||
if rows:
|
if rows:
|
||||||
blocks.append(_render_excel_rows(rows, flatten=False))
|
blocks.append(_render_excel_rows(rows, flatten=False))
|
||||||
|
if max_rows and sheet.max_row > max_rows:
|
||||||
|
blocks.append(f"_Vorschau auf {max_rows} von {sheet.max_row} Zeilen begrenzt._")
|
||||||
return "\n\n".join(blocks) or "_Keine Inhalte_"
|
return "\n\n".join(blocks) or "_Keine Inhalte_"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -168,7 +169,9 @@ 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")
|
document = Document.objects.create(
|
||||||
|
title=title, slug=slug, body=result.markdown, body_format=result.body_format,
|
||||||
|
)
|
||||||
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 +184,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})
|
||||||
@@ -207,10 +213,14 @@ 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):
|
||||||
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,
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
preview.file.close()
|
preview.file.close()
|
||||||
|
|
||||||
@@ -219,11 +229,18 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
|
|||||||
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,
|
||||||
@@ -239,7 +256,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}")
|
||||||
@@ -300,7 +317,7 @@ class ExcelImportPreviewView(PermissionRequiredMixin, View):
|
|||||||
counter += 1
|
counter += 1
|
||||||
document = Document.objects.create(
|
document = Document.objects.create(
|
||||||
title=title[:200], slug=document_slug, body=sheet.markdown,
|
title=title[:200], slug=document_slug, body=sheet.markdown,
|
||||||
body_format="markdown", category=preview.category,
|
body_format=sheet.body_format, category=preview.category,
|
||||||
summary=f"Importiert aus {preview.original_name}",
|
summary=f"Importiert aus {preview.original_name}",
|
||||||
)
|
)
|
||||||
if sheet.images:
|
if sheet.images:
|
||||||
@@ -313,8 +330,16 @@ 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)
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-documentation"
|
name = "netbox-documentation"
|
||||||
version = "0.7.4"
|
version = "0.7.6"
|
||||||
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"
|
||||||
|
|||||||
+60
-1
@@ -5,6 +5,7 @@ import sys
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from openpyxl import Workbook
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import PatternFill, Font
|
||||||
from openpyxl.worksheet.table import Table
|
from openpyxl.worksheet.table import Table
|
||||||
|
|
||||||
|
|
||||||
@@ -39,10 +40,31 @@ 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_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 +90,43 @@ 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_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 von 151 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
|
||||||
|
|||||||
Reference in New Issue
Block a user