DocX-export added - not perfect yet.

This commit is contained in:
2026-02-20 15:59:51 +01:00
parent 67a967da67
commit 3faa88fcea
6 changed files with 298 additions and 0 deletions

Binary file not shown.

207
dokumente/docx_utils.py Normal file
View File

@@ -0,0 +1,207 @@
"""
Utility functions for exporting Dokument instances as Word (.docx) files.
"""
import os
from docx import Document
from docx.shared import Pt
from docx.oxml.ns import qn
TEMPLATE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'docx_template', 'template.docx')
def build_standard_docx(dokument):
"""
Generate a Word Document object for the given Dokument.
Opens the template .docx to inherit custom styles (Hermes, Kurztext,
Tabelle, etc.), clears the body, then writes the document structure.
Args:
dokument: a Dokument instance (should be prefetched with related data)
Returns:
docx.Document instance ready to be saved
"""
doc = Document(TEMPLATE_PATH)
# Clear all body content while preserving page-layout section properties
body = doc.element.body
for element in list(body):
if element.tag != qn('w:sectPr'):
body.remove(element)
# 1. Title
title_para = doc.add_paragraph(style='Normal')
run = title_para.add_run(f'Standard\nIT-Sicherheit-{dokument.name}')
run.bold = True
run.font.size = Pt(22)
# 2. Metadata table (uses the "Hermes" table style from the template)
meta_table = doc.add_table(rows=0, cols=2, style='Hermes')
meta_rows = [
('Identifikation:', dokument.nummer),
('Dokumentationsklasse:', dokument.dokumententyp.name if dokument.dokumententyp else ''),
('Gültig ab:', dokument.gueltigkeit_von.strftime('%d.%m.%Y') if dokument.gueltigkeit_von else ''),
('Gültig bis:', dokument.gueltigkeit_bis.strftime('%d.%m.%Y') if dokument.gueltigkeit_bis else ''),
('Klassifizierung:', ''),
('Verantwortliche Stelle:', 'Information Security Management BIT'),
('Autoren:', ', '.join(a.name for a in dokument.autoren.all())),
('Prüfende:', ', '.join(p.name for p in dokument.pruefende.all())),
]
for label, value in meta_rows:
row = meta_table.add_row()
row.cells[0].text = label
row.cells[1].text = value
# 3. Einleitung
doc.add_heading('Einleitung', level=1)
for abschnitt in dokument.einleitung_set.order_by('order'):
_add_abschnitt(doc, abschnitt)
# 4. Geltungsbereich
doc.add_heading('Geltungsbereich', level=1)
for abschnitt in dokument.geltungsbereich_set.order_by('order'):
_add_abschnitt(doc, abschnitt)
# 5. Vorgaben
doc.add_heading('Vorgaben', level=1)
vorgaben = list(
dokument.vorgaben
.order_by('thema__name', 'nummer')
.select_related('thema')
.prefetch_related(
'vorgabekurztext_set__abschnitttyp',
'vorgabelangtext_set__abschnitttyp',
'checklistenfragen',
'referenzen',
)
)
current_thema = None
for vorgabe in vorgaben:
thema_name = vorgabe.thema.name if vorgabe.thema else ''
if thema_name != current_thema:
current_thema = thema_name
doc.add_heading(thema_name, level=2)
doc.add_heading(f'{vorgabe.Vorgabennummer()} \u2013 {vorgabe.titel}', level=4)
for kt in vorgabe.vorgabekurztext_set.order_by('order'):
_add_abschnitt(doc, kt, default_style='Kurztext')
for lt in vorgabe.vorgabelangtext_set.order_by('order'):
_add_abschnitt(doc, lt)
fragen = list(vorgabe.checklistenfragen.all())
if fragen:
doc.add_paragraph('Checklistenfragen', style='Normal')
for frage in fragen:
doc.add_paragraph(frage.frage, style='Normal')
refs = list(vorgabe.referenzen.all())
if refs:
doc.add_paragraph('Referenzen: ' + ', '.join(r.Path() for r in refs), style='Normal')
# 6. Checkliste
doc.add_heading('Checkliste', level=1)
all_fragen = [
(vorgabe.Vorgabennummer(), frage.frage)
for vorgabe in vorgaben
for frage in vorgabe.checklistenfragen.all()
]
if all_fragen:
checklist_table = doc.add_table(rows=1, cols=3, style='Normal Table')
header = checklist_table.rows[0].cells
header[0].text = '#'
header[1].text = 'Bezeichnung (WAS)'
header[2].text = 'Richtlinieregel (WIE)'
for vorgabe_num, frage_text in all_fragen:
row = checklist_table.add_row()
row.cells[0].text = vorgabe_num
row.cells[1].text = ''
row.cells[2].text = frage_text
# 7. Changelog
changelog_entries = list(
dokument.changelog.order_by('-datum').prefetch_related('autoren')
)
if changelog_entries:
doc.add_heading('Änderungskontrolle', level=1)
changelog_table = doc.add_table(rows=1, cols=4, style='Tabelle')
header = changelog_table.rows[0].cells
header[0].text = 'Wann'
header[1].text = 'Version'
header[2].text = 'Wer'
header[3].text = 'Beschreibung'
for cl in changelog_entries:
row = changelog_table.add_row()
row.cells[0].text = cl.datum.strftime('%d.%m.%Y') if cl.datum else ''
row.cells[1].text = ''
row.cells[2].text = ', '.join(a.name for a in cl.autoren.all())
row.cells[3].text = cl.aenderung
return doc
def _add_abschnitt(doc, abschnitt, default_style='Normal'):
"""
Add a single Textabschnitt to the document, respecting its section type.
"""
typ = abschnitt.abschnitttyp.abschnitttyp if abschnitt.abschnitttyp else 'text'
inhalt = abschnitt.inhalt or ''
if not inhalt.strip():
return
if typ == 'text':
doc.add_paragraph(inhalt, style=default_style)
elif typ in ('liste ungeordnet', 'liste geordnet'):
for line in inhalt.strip().split('\n'):
line = line.strip().lstrip('- ').lstrip('* ')
if line:
doc.add_paragraph(line, style='Normal')
elif typ == 'tabelle':
_add_markdown_table(doc, inhalt)
else:
doc.add_paragraph(inhalt, style='Normal')
def _add_markdown_table(doc, markdown_content):
"""
Parse a markdown table and add it to the document as a Word table.
Falls back to a plain paragraph if parsing fails.
"""
lines = [line.strip() for line in markdown_content.strip().split('\n') if line.strip()]
if len(lines) < 2:
doc.add_paragraph(markdown_content, style='Normal')
return
header_cells = [c.strip() for c in lines[0].split('|') if c.strip()]
if not header_cells:
doc.add_paragraph(markdown_content, style='Normal')
return
# Skip the separator row (dashes), collect data rows
data_lines = [
line for line in lines[2:]
if not all(c in '-|: ' for c in line)
]
table = doc.add_table(rows=1, cols=len(header_cells), style='Table Grid')
header_row = table.rows[0].cells
for i, cell_text in enumerate(header_cells):
if i < len(header_row):
header_row[i].text = cell_text
for line in data_lines:
row_cells = [c.strip() for c in line.split('|') if c.strip()]
row = table.add_row()
for i, cell_text in enumerate(row_cells):
if i < len(row.cells):
row.cells[i].text = cell_text

View File

@@ -0,0 +1,55 @@
from django.core.management.base import BaseCommand, CommandError
from dokumente.models import Dokument
from dokumente.docx_utils import build_standard_docx
import os
class Command(BaseCommand):
help = 'Export a Dokument (or all active Dokumente) as Word (.docx) files'
def add_arguments(self, parser):
parser.add_argument(
'--nummer',
type=str,
help='Document number to export (e.g. R0129). Omit to export all active documents.',
)
parser.add_argument(
'--output',
type=str,
default='.',
help='Output directory (default: current directory)',
)
def handle(self, *args, **options):
output_dir = options['output']
os.makedirs(output_dir, exist_ok=True)
if options['nummer']:
try:
dokumente = [Dokument.objects.prefetch_related(
'autoren', 'pruefende', 'vorgaben__thema',
'vorgaben__referenzen', 'vorgaben__checklistenfragen',
'vorgaben__vorgabekurztext_set__abschnitttyp',
'vorgaben__vorgabelangtext_set__abschnitttyp',
'geltungsbereich_set__abschnitttyp',
'einleitung_set__abschnitttyp',
'changelog__autoren',
).get(nummer=options['nummer'])]
except Dokument.DoesNotExist:
raise CommandError(f"Dokument with nummer '{options['nummer']}' not found.")
else:
dokumente = Dokument.objects.filter(aktiv=True).prefetch_related(
'autoren', 'pruefende', 'vorgaben__thema',
'vorgaben__referenzen', 'vorgaben__checklistenfragen',
'vorgaben__vorgabekurztext_set__abschnitttyp',
'vorgaben__vorgabelangtext_set__abschnitttyp',
'geltungsbereich_set__abschnitttyp',
'einleitung_set__abschnitttyp',
'changelog__autoren',
).order_by('nummer')
for dokument in dokumente:
doc = build_standard_docx(dokument)
filename = os.path.join(output_dir, f'{dokument.nummer}.docx')
doc.save(filename)
self.stdout.write(self.style.SUCCESS(f'Exported {dokument.nummer}{filename}'))

View File

@@ -12,6 +12,7 @@ urlpatterns = [
path('<str:nummer>/checkliste/', views.standard_checkliste, name='standard_checkliste'),
path('<str:nummer>/json/', views.standard_json, name='standard_json'),
path('<str:nummer>/xml/', views.standard_xml, name='standard_xml'),
path('<str:nummer>/docx/', views.standard_docx, name='standard_docx'),
path('comments/<int:vorgabe_id>/', views.get_vorgabe_comments, name='get_vorgabe_comments'),
path('comments/<int:vorgabe_id>/add/', views.add_vorgabe_comment, name='add_vorgabe_comment'),
path('comments/delete/<int:comment_id>/', views.delete_vorgabe_comment, name='delete_vorgabe_comment'),

View File

@@ -6,10 +6,12 @@ from django.views.decorators.http import require_POST
from django.views.decorators.csrf import csrf_exempt
from django.utils.html import escape, mark_safe
from django.utils.safestring import SafeString
import io
import json
import xml.etree.ElementTree as ET
from .models import Dokument, Vorgabe, VorgabeKurztext, VorgabeLangtext, Checklistenfrage, VorgabeComment
from .utils import build_dokument_xml_element, prettify_xml
from .docx_utils import build_standard_docx
from abschnitte.utils import render_textabschnitte
from datetime import date
@@ -256,6 +258,37 @@ def standard_json(request, nummer):
return JsonResponse(doc_data, json_dumps_params={'indent': 2, 'ensure_ascii': False}, encoder=DjangoJSONEncoder)
def standard_docx(request, nummer):
"""
Export a single Dokument as a Word (.docx) file.
"""
dokument = get_object_or_404(
Dokument.objects.prefetch_related(
'autoren', 'pruefende', 'vorgaben__thema',
'vorgaben__referenzen', 'vorgaben__checklistenfragen',
'vorgaben__vorgabekurztext_set__abschnitttyp',
'vorgaben__vorgabelangtext_set__abschnitttyp',
'geltungsbereich_set__abschnitttyp',
'einleitung_set__abschnitttyp',
'changelog__autoren',
),
nummer=nummer,
)
doc = build_standard_docx(dokument)
buffer = io.BytesIO()
doc.save(buffer)
buffer.seek(0)
response = HttpResponse(
buffer.read(),
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
)
response['Content-Disposition'] = f'attachment; filename="{dokument.nummer}.docx"'
return response
def standard_xml(request, nummer):
"""
Export a single Dokument as XML

View File

@@ -19,6 +19,7 @@ greenlet==3.3.0
gunicorn==23.0.0
idna==3.11
jedi==0.19.2
lxml==6.0.2
jproperties==2.1.2
Markdown==3.10
packaging==25.0
@@ -30,6 +31,7 @@ pyfakefs==5.9.3
Pygments==2.19.2
pysonar==1.2.1.3951
python-dateutil==2.9.0.post0
python-docx==1.2.0
python-monkey-business==1.1.0
pyxdg==0.28
PyYAML==6.0.3