diff --git a/dokumente/docx_template/template.docx b/dokumente/docx_template/template.docx new file mode 100644 index 0000000..d2261fe Binary files /dev/null and b/dokumente/docx_template/template.docx differ diff --git a/dokumente/docx_utils.py b/dokumente/docx_utils.py new file mode 100644 index 0000000..47b2359 --- /dev/null +++ b/dokumente/docx_utils.py @@ -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 diff --git a/dokumente/management/commands/export_docx.py b/dokumente/management/commands/export_docx.py new file mode 100644 index 0000000..adf4e1a --- /dev/null +++ b/dokumente/management/commands/export_docx.py @@ -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}')) diff --git a/dokumente/urls.py b/dokumente/urls.py index 0d6ffff..cc16381 100644 --- a/dokumente/urls.py +++ b/dokumente/urls.py @@ -12,6 +12,7 @@ urlpatterns = [ path('/checkliste/', views.standard_checkliste, name='standard_checkliste'), path('/json/', views.standard_json, name='standard_json'), path('/xml/', views.standard_xml, name='standard_xml'), + path('/docx/', views.standard_docx, name='standard_docx'), path('comments//', views.get_vorgabe_comments, name='get_vorgabe_comments'), path('comments//add/', views.add_vorgabe_comment, name='add_vorgabe_comment'), path('comments/delete//', views.delete_vorgabe_comment, name='delete_vorgabe_comment'), diff --git a/dokumente/views.py b/dokumente/views.py index 08ed98e..6034afa 100644 --- a/dokumente/views.py +++ b/dokumente/views.py @@ -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 diff --git a/requirements.txt b/requirements.txt index 21fe605..ddec645 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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