feat: Add Excel, PDF export, and user generation modules
- Implemented `excel_export.py` for generating library item exports in Excel format. - Created `pdf_export.py` for generating audit reports compliant with DIN 5008 standards, including detailed event tables and signature blocks. - Developed `generate_user.py` for interactive user creation with validation for usernames and passwords. - Introduced `module_registry.py` for managing module states and path matching. - Added a basic `__init__.py` in the `terminplaner` module for initialization.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Inventar System Funktionen
|
||||
|
||||
|
||||
|
||||
"""
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import csv
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
from bson import json_util
|
||||
except ImportError:
|
||||
json_util = None
|
||||
|
||||
try:
|
||||
from pymongo import MongoClient
|
||||
except ImportError:
|
||||
print('Error: pymongo is required to run invoice backups.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
CONFIG_PATH = os.path.normpath(os.path.join(SCRIPT_DIR, '..', 'config.json'))
|
||||
|
||||
DEFAULT_ARCHIVE_DIR = '/var/backups/invoice-archive'
|
||||
DEFAULT_KEEP_DAYS = 3650
|
||||
|
||||
|
||||
def load_config():
|
||||
config = {}
|
||||
try:
|
||||
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return config
|
||||
|
||||
|
||||
def resolve_mongo_settings(args):
|
||||
config = load_config()
|
||||
mongodb = config.get('mongodb', {}) if isinstance(config, dict) else {}
|
||||
|
||||
host = os.getenv('INVENTAR_MONGODB_HOST') or args.mongo_host or mongodb.get('host') or 'localhost'
|
||||
port = os.getenv('INVENTAR_MONGODB_PORT') or args.mongo_port or mongodb.get('port') or 27017
|
||||
db_name = os.getenv('INVENTAR_MONGODB_DB') or args.db_name or mongodb.get('db') or 'Inventarsystem'
|
||||
uri = args.mongo_uri
|
||||
|
||||
if isinstance(port, str) and port.strip().isdigit():
|
||||
port = int(port.strip())
|
||||
|
||||
return host, int(port), db_name, uri
|
||||
|
||||
|
||||
def format_csv_value(value):
|
||||
if value is None:
|
||||
return ''
|
||||
if isinstance(value, bool):
|
||||
return 'true' if value else 'false'
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
if isinstance(value, datetime.datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, (list, dict)):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
return str(value)
|
||||
|
||||
|
||||
def normalize_doc_for_json(doc):
|
||||
if json_util is not None:
|
||||
return json.loads(json_util.dumps(doc, default=json_util.default))
|
||||
return doc
|
||||
|
||||
|
||||
def build_csv_row(document):
|
||||
invoice_data = document.get('InvoiceData') or {}
|
||||
corrections = document.get('InvoiceCorrections') or []
|
||||
return {
|
||||
'invoice_number': invoice_data.get('invoice_number', ''),
|
||||
'borrow_id': str(document.get('_id', '')),
|
||||
'status_before_invoice': invoice_data.get('status_before_invoice', '') or document.get('Status', ''),
|
||||
'borrower': document.get('User', '') or invoice_data.get('borrower', ''),
|
||||
'item': document.get('Item', ''),
|
||||
'amount': invoice_data.get('amount', ''),
|
||||
'currency': invoice_data.get('currency', 'EUR'),
|
||||
'created_at': format_csv_value(invoice_data.get('created_at')),
|
||||
'paid': invoice_data.get('paid', False),
|
||||
'paid_at': format_csv_value(invoice_data.get('paid_at')),
|
||||
'invoice_reason': invoice_data.get('damage_reason', ''),
|
||||
'corrections_count': len(corrections) if isinstance(corrections, list) else 0,
|
||||
'corrections': json.dumps(corrections, ensure_ascii=False) if corrections else '',
|
||||
}
|
||||
|
||||
|
||||
def write_jsonl(path, cursor):
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
for document in cursor:
|
||||
if json_util is not None:
|
||||
line = json_util.dumps(document, default=json_util.default)
|
||||
else:
|
||||
line = json.dumps(document, default=str, ensure_ascii=False)
|
||||
f.write(line + '\n')
|
||||
|
||||
|
||||
def write_csv(path, cursor):
|
||||
fieldnames = [
|
||||
'invoice_number',
|
||||
'borrow_id',
|
||||
'status_before_invoice',
|
||||
'borrower',
|
||||
'item',
|
||||
'amount',
|
||||
'currency',
|
||||
'created_at',
|
||||
'paid',
|
||||
'paid_at',
|
||||
'invoice_reason',
|
||||
'corrections_count',
|
||||
'corrections',
|
||||
]
|
||||
with open(path, 'w', encoding='utf-8', newline='') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for document in cursor:
|
||||
writer.writerow({k: format_csv_value(v) for k, v in build_csv_row(document).items()})
|
||||
|
||||
|
||||
def write_meta(path, metadata):
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description='Create a legal invoice archive backup from the MongoDB invoice records.')
|
||||
parser.add_argument('--archive-dir', required=True, help='Directory to write archive files into')
|
||||
parser.add_argument('--base-name', default=None, help='Base filename prefix for archive files')
|
||||
parser.add_argument('--mongo-host', default=None, help='MongoDB host override')
|
||||
parser.add_argument('--mongo-port', type=int, default=None, help='MongoDB port override')
|
||||
parser.add_argument('--db-name', default=None, help='MongoDB database name override')
|
||||
parser.add_argument('--mongo-uri', default=None, help='MongoDB connection URI override')
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
archive_dir = os.path.abspath(args.archive_dir)
|
||||
os.makedirs(archive_dir, exist_ok=True)
|
||||
|
||||
base_name = args.base_name or f'invoices-{datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}'
|
||||
jsonl_path = os.path.join(archive_dir, f'{base_name}.jsonl')
|
||||
csv_path = os.path.join(archive_dir, f'{base_name}.csv')
|
||||
meta_path = os.path.join(archive_dir, f'{base_name}.meta.json')
|
||||
|
||||
host, port, db_name, uri = resolve_mongo_settings(args)
|
||||
if uri:
|
||||
client = MongoClient(uri)
|
||||
else:
|
||||
client = MongoClient(host, port)
|
||||
|
||||
try:
|
||||
db = client[db_name]
|
||||
collection = db['ausleihungen']
|
||||
query = {'InvoiceData.invoice_number': {'$exists': True, '$ne': ''}}
|
||||
projection = {'InvoiceData': 1, 'InvoiceCorrections': 1, 'User': 1, 'Item': 1, 'Status': 1}
|
||||
cursor = collection.find(query, projection)
|
||||
|
||||
docs = list(cursor)
|
||||
if not docs:
|
||||
print('No invoice records found. No archive written.')
|
||||
return 0
|
||||
|
||||
write_jsonl(jsonl_path, docs)
|
||||
write_csv(csv_path, docs)
|
||||
|
||||
metadata = {
|
||||
'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
'invoice_count': len(docs),
|
||||
'archive_files': [os.path.basename(jsonl_path), os.path.basename(csv_path), os.path.basename(meta_path)],
|
||||
'mongo_db': db_name,
|
||||
'mongo_host': host,
|
||||
'mongo_port': port,
|
||||
'query': query,
|
||||
}
|
||||
write_meta(meta_path, metadata)
|
||||
|
||||
print(f'Wrote invoice archive: {jsonl_path}')
|
||||
print(f'Wrote invoice archive CSV: {csv_path}')
|
||||
print(f'Wrote metadata: {meta_path}')
|
||||
return 0
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Helpers for targeted PII encryption and encrypted archival of deleted media files."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
import Web.modules.database.settings as cfg
|
||||
|
||||
_ENC_PREFIX = "enc::"
|
||||
|
||||
|
||||
def _resolve_fernet_key():
|
||||
"""Resolve the Fernet key from env/config or derive a stable fallback."""
|
||||
configured_key = cfg.DATA_ENCRYPTION_KEY
|
||||
if configured_key:
|
||||
try:
|
||||
# Validate the supplied key format.
|
||||
Fernet(configured_key.encode("utf-8"))
|
||||
return configured_key.encode("utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback for compatibility: derive stable key from SECRET_KEY.
|
||||
digest = hashlib.sha256(cfg.SECRET_KEY.encode("utf-8")).digest()
|
||||
return base64.urlsafe_b64encode(digest)
|
||||
|
||||
|
||||
def _fernet():
|
||||
return Fernet(_resolve_fernet_key())
|
||||
|
||||
|
||||
def encrypt_text(value):
|
||||
"""Encrypt a text value. Keeps empty values unchanged."""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value)
|
||||
if text == "" or text.startswith(_ENC_PREFIX):
|
||||
return text
|
||||
token = _fernet().encrypt(text.encode("utf-8")).decode("utf-8")
|
||||
return f"{_ENC_PREFIX}{token}"
|
||||
|
||||
|
||||
def decrypt_text(value):
|
||||
"""Decrypt an encrypted text value. Returns original value if not encrypted."""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value)
|
||||
if not text.startswith(_ENC_PREFIX):
|
||||
return text
|
||||
|
||||
token = text[len(_ENC_PREFIX):]
|
||||
try:
|
||||
return _fernet().decrypt(token.encode("utf-8")).decode("utf-8")
|
||||
except (InvalidToken, ValueError, TypeError):
|
||||
# Keep data readable even if key rotation or malformed data occurred.
|
||||
return text
|
||||
|
||||
|
||||
def encrypt_document_fields(document, fields):
|
||||
"""Encrypt selected fields of a document in-place and return it."""
|
||||
for field in fields:
|
||||
if field in document:
|
||||
document[field] = encrypt_text(document.get(field))
|
||||
return document
|
||||
|
||||
|
||||
def decrypt_document_fields(document, fields):
|
||||
"""Decrypt selected fields of a document in-place and return it."""
|
||||
for field in fields:
|
||||
if field in document:
|
||||
document[field] = decrypt_text(document.get(field))
|
||||
return document
|
||||
|
||||
|
||||
def _candidate_media_paths(filename):
|
||||
"""Return all possible filesystem paths for a stored media filename."""
|
||||
name_part, _ = os.path.splitext(filename)
|
||||
return [
|
||||
(os.path.join(cfg.UPLOAD_FOLDER, filename), "originals"),
|
||||
(os.path.join(cfg.UPLOAD_FOLDER, f"{name_part}.webp"), "originals"),
|
||||
(os.path.join(cfg.UPLOAD_FOLDER, f"{name_part}.jpg"), "originals"),
|
||||
(os.path.join(cfg.THUMBNAIL_FOLDER, f"{name_part}_thumb.webp"), "thumbnails"),
|
||||
(os.path.join(cfg.THUMBNAIL_FOLDER, f"{name_part}_thumb.jpg"), "thumbnails"),
|
||||
(os.path.join(cfg.PREVIEW_FOLDER, f"{name_part}_preview.webp"), "previews"),
|
||||
(os.path.join(cfg.PREVIEW_FOLDER, f"{name_part}_preview.jpg"), "previews"),
|
||||
]
|
||||
|
||||
|
||||
def _iter_item_image_names(item):
|
||||
"""Yield image names from current and legacy item schema fields."""
|
||||
images_field = item.get("Images", []) or []
|
||||
if isinstance(images_field, (list, tuple, set)):
|
||||
for value in images_field:
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
yield text
|
||||
elif isinstance(images_field, str):
|
||||
text = images_field.strip()
|
||||
if text:
|
||||
yield text
|
||||
|
||||
# Legacy schema support: single image name in `Image`.
|
||||
legacy_image = item.get("Image")
|
||||
if legacy_image:
|
||||
text = str(legacy_image).strip()
|
||||
if text:
|
||||
yield text
|
||||
|
||||
|
||||
def encrypt_soft_deleted_media_pack(item_docs, *, actor="system"):
|
||||
"""
|
||||
Archive media files referenced by item docs, encrypt the archive, and delete originals.
|
||||
|
||||
Uses ZIP_STORED (no compression) to keep CPU usage low.
|
||||
"""
|
||||
files_to_archive = []
|
||||
seen_paths = set()
|
||||
|
||||
for item in item_docs:
|
||||
item_id = str(item.get("_id", "unknown"))
|
||||
for image_name in _iter_item_image_names(item):
|
||||
for abs_path, bucket in _candidate_media_paths(str(image_name)):
|
||||
if abs_path in seen_paths:
|
||||
continue
|
||||
if not os.path.isfile(abs_path):
|
||||
continue
|
||||
seen_paths.add(abs_path)
|
||||
files_to_archive.append((item_id, str(image_name), abs_path, bucket))
|
||||
|
||||
if not files_to_archive:
|
||||
return {
|
||||
"archive_created": False,
|
||||
"archived_files": 0,
|
||||
"deleted_files": 0,
|
||||
"archive_path": None,
|
||||
}
|
||||
|
||||
os.makedirs(cfg.DELETED_ARCHIVE_FOLDER, exist_ok=True)
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
|
||||
archive_id = f"softdelete-{timestamp}-{uuid.uuid4().hex[:8]}"
|
||||
zip_path = os.path.join(cfg.DELETED_ARCHIVE_FOLDER, f"{archive_id}.zip")
|
||||
encrypted_path = os.path.join(cfg.DELETED_ARCHIVE_FOLDER, f"{archive_id}.zip.enc")
|
||||
|
||||
manifest = {
|
||||
"archive_id": archive_id,
|
||||
"created_at": datetime.utcnow().isoformat() + "Z",
|
||||
"actor": actor,
|
||||
"files": [],
|
||||
}
|
||||
|
||||
with zipfile.ZipFile(zip_path, mode="w", compression=zipfile.ZIP_STORED) as zf:
|
||||
for idx, (item_id, original_name, abs_path, bucket) in enumerate(files_to_archive, start=1):
|
||||
safe_name = os.path.basename(abs_path)
|
||||
arcname = f"{bucket}/{item_id}/{idx:04d}-{safe_name}"
|
||||
zf.write(abs_path, arcname)
|
||||
manifest["files"].append(
|
||||
{
|
||||
"item_id": item_id,
|
||||
"source_name": original_name,
|
||||
"stored_as": arcname,
|
||||
"size_bytes": os.path.getsize(abs_path),
|
||||
}
|
||||
)
|
||||
|
||||
zf.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
|
||||
with open(zip_path, "rb") as source_file:
|
||||
encrypted_payload = _fernet().encrypt(source_file.read())
|
||||
|
||||
with open(encrypted_path, "wb") as encrypted_file:
|
||||
encrypted_file.write(encrypted_payload)
|
||||
|
||||
deleted_files = 0
|
||||
for _, _, abs_path, _ in files_to_archive:
|
||||
try:
|
||||
os.remove(abs_path)
|
||||
deleted_files += 1
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
os.remove(zip_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"archive_created": True,
|
||||
"archived_files": len(files_to_archive),
|
||||
"deleted_files": deleted_files,
|
||||
"archive_path": encrypted_path,
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import io
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill
|
||||
|
||||
def generate_library_excel(items):
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Export"
|
||||
|
||||
headers = [
|
||||
"Code", "Titel", "Autor", "Typ", "ISBN/Code",
|
||||
"Filter 1", "Filter 2", "Filter 3",
|
||||
"Status", "Ausgeliehen von", "Rückgabe", "Kosten"
|
||||
]
|
||||
|
||||
ws.append(headers)
|
||||
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill(start_color="1F2937", end_color="1F2937", fill_type="solid")
|
||||
for cell in ws[1]:
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
|
||||
for item in items:
|
||||
status = "Verfuegbar" if str(item.get("Verfuegbar", "True")).lower() == "true" else "Ausgeliehen"
|
||||
row = [
|
||||
item.get("Code_4", ""),
|
||||
item.get("Name", ""),
|
||||
item.get("Author", ""),
|
||||
item.get("ItemType", ""),
|
||||
item.get("ISBN", ""),
|
||||
item.get("Filter", ""),
|
||||
item.get("Filter2", ""),
|
||||
item.get("Filter3", ""),
|
||||
status,
|
||||
item.get("User", ""),
|
||||
item.get("ReturnDate", ""),
|
||||
item.get("Anschaffungskosten", "")
|
||||
]
|
||||
ws.append(row)
|
||||
|
||||
for col in ws.columns:
|
||||
max_length = 0
|
||||
column = col[0].column_letter
|
||||
for cell in col:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except:
|
||||
pass
|
||||
ws.column_dimensions[column].width = min(max_length + 2, 50)
|
||||
|
||||
excel_buffer = io.BytesIO()
|
||||
wb.save(excel_buffer)
|
||||
excel_buffer.seek(0)
|
||||
return excel_buffer
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
'''
|
||||
Copyright 2025-2026 AIIrondev
|
||||
|
||||
Licensed under the Inventarsystem EULA (Endbenutzer-Lizenzvertrag).
|
||||
See Legal/LICENSE for the full license text.
|
||||
Unauthorized commercial use, SaaS hosting, or removal of branding is prohibited.
|
||||
For commercial licensing inquiries: https://github.com/AIIrondev
|
||||
'''
|
||||
import Web.modules.database.user as user
|
||||
import sys
|
||||
import getpass
|
||||
import re
|
||||
|
||||
def is_valid_username(username):
|
||||
"""Check if username follows valid pattern (letters, numbers, underscore)"""
|
||||
return bool(re.match(r'^[a-zA-Z0-9_]+$', username))
|
||||
|
||||
def is_valid_password(password):
|
||||
"""Check if password meets minimum requirements"""
|
||||
if len(password) < 8:
|
||||
return False, "Password must be at least 8 characters long"
|
||||
return True, ""
|
||||
|
||||
def generate_user_interactive():
|
||||
print("========================================")
|
||||
print(" User Generation Interface ")
|
||||
print("========================================")
|
||||
|
||||
# Get username
|
||||
while True:
|
||||
username = input("Enter username: ").strip()
|
||||
if not username:
|
||||
print("Error: Username cannot be empty")
|
||||
continue
|
||||
if not is_valid_username(username):
|
||||
print("Error: Username can only contain letters, numbers, and underscores")
|
||||
continue
|
||||
break
|
||||
|
||||
# Get password
|
||||
while True:
|
||||
password = getpass.getpass("Enter password: ")
|
||||
if not password:
|
||||
print("Error: Password cannot be empty")
|
||||
continue
|
||||
|
||||
valid, message = is_valid_password(password)
|
||||
if not valid:
|
||||
print(f"Error: {message}")
|
||||
continue
|
||||
|
||||
confirm_password = getpass.getpass("Confirm password: ")
|
||||
if password != confirm_password:
|
||||
print("Error: Passwords do not match")
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
# Ask if admin
|
||||
while True:
|
||||
admin_input = input("Make this user an admin? (y/n): ").lower().strip()
|
||||
if admin_input in ['y', 'yes']:
|
||||
is_admin = True
|
||||
break
|
||||
elif admin_input in ['n', 'no']:
|
||||
is_admin = False
|
||||
break
|
||||
else:
|
||||
print("Please enter 'y' or 'n'")
|
||||
|
||||
while True:
|
||||
name_input = input("Enter a first name for the user:")
|
||||
if not name_input:
|
||||
print("You have to provide a name!")
|
||||
else:
|
||||
break
|
||||
|
||||
while True:
|
||||
last_name_input = input("Enter a last name for the user:")
|
||||
if not last_name_input:
|
||||
print("You have to provide a name!")
|
||||
else:
|
||||
break
|
||||
|
||||
|
||||
# Add the user
|
||||
added = user.add_user(username, password, name_input, last_name_input)
|
||||
|
||||
if added:
|
||||
print(f"User '{username}' created successfully.")
|
||||
if is_admin:
|
||||
admin_result = user.make_admin(username)
|
||||
if admin_result:
|
||||
print(f"User '{username}' has been given administrator privileges.")
|
||||
else:
|
||||
print(f"Warning: Failed to make user '{username}' an administrator.")
|
||||
else:
|
||||
print(f"Error: Failed to create user '{username}'. Username may already exist.")
|
||||
|
||||
return added
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_user_interactive()
|
||||
@@ -0,0 +1,790 @@
|
||||
"""
|
||||
PDF Export module for audit reports following DIN 5008 standard and German authority requirements.
|
||||
Ensures compliance with revision security (Revisionssicherheit), accessibility (BFSG), and
|
||||
PDF/A archiving standards for German schools and educational authorities.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import datetime
|
||||
import os
|
||||
import qrcode
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import cm, mm
|
||||
from reportlab.lib.colors import HexColor, grey, black, red
|
||||
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, PageBreak, Image
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER, TA_JUSTIFY
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
import Web.modules.database.settings as cfg
|
||||
|
||||
|
||||
__version__ = cfg.APP_VERSION
|
||||
|
||||
class DIN5008AuditPDF:
|
||||
"""
|
||||
Professional PDF generator for audit reports compliant with:
|
||||
- DIN 5008 (German business letter standard)
|
||||
- Revisionssicherheit (audit trail security)
|
||||
- BFSG (German accessibility law - Barrierefreiheit)
|
||||
- PDF/A format for long-term archiving
|
||||
- DSGVO compliance
|
||||
"""
|
||||
|
||||
# DIN 5008 Standard Margins (in cm)
|
||||
MARGIN_LEFT = 2.5 # Binding margin
|
||||
MARGIN_RIGHT = 1.5
|
||||
MARGIN_TOP = 4.5 # Letterhead area
|
||||
MARGIN_BOTTOM = 2.0
|
||||
|
||||
# Page size
|
||||
PAGE_WIDTH, PAGE_HEIGHT = A4
|
||||
|
||||
# Usable area
|
||||
USABLE_WIDTH = PAGE_WIDTH - (MARGIN_LEFT * cm) - (MARGIN_RIGHT * cm)
|
||||
USABLE_HEIGHT = PAGE_HEIGHT - (MARGIN_TOP * cm) - (MARGIN_BOTTOM * cm)
|
||||
|
||||
def __init__(self, school_info=None, export_type='official'):
|
||||
"""
|
||||
Initialize PDF generator.
|
||||
|
||||
Args:
|
||||
school_info (dict): School information {name, address, city, postal_code, school_number, logo_path}
|
||||
export_type (str): 'official' for full DIN 5008 report or 'quick' for compact version
|
||||
"""
|
||||
self.school_info = school_info or {}
|
||||
self.export_type = export_type
|
||||
self.created_timestamp = datetime.datetime.now()
|
||||
self.created_timestamp_iso = self.created_timestamp.isoformat()
|
||||
self.current_page = 1
|
||||
self.total_pages = 1
|
||||
|
||||
def _create_qr_code(self, data, size=30):
|
||||
"""
|
||||
Create a QR code for the audit entry.
|
||||
|
||||
Args:
|
||||
data (str): Data to encode in QR code
|
||||
size (int): Size in pixels
|
||||
|
||||
Returns:
|
||||
Image: PIL Image object
|
||||
"""
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=4,
|
||||
border=1,
|
||||
)
|
||||
qr.add_data(data)
|
||||
qr.make(fit=True)
|
||||
return qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
def _add_header(self, story, responsible_person="IT-Beauftragter"):
|
||||
"""
|
||||
Add DIN 5008 compliant header with school information.
|
||||
|
||||
Args:
|
||||
story (list): Platypus story elements
|
||||
responsible_person (str): Name of responsible person
|
||||
"""
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
# Header spacing for letterhead
|
||||
story.append(Spacer(1, 3 * cm))
|
||||
|
||||
# School information block (left)
|
||||
school_name = self.school_info.get('name', 'Schulname')
|
||||
address = self.school_info.get('address', 'Adresse')
|
||||
postal_code = self.school_info.get('postal_code', 'PLZ')
|
||||
city = self.school_info.get('city', 'Stadt')
|
||||
school_number = self.school_info.get('school_number', 'Schulnummer')
|
||||
|
||||
header_style = ParagraphStyle(
|
||||
'CustomHeader',
|
||||
parent=styles['Normal'],
|
||||
fontSize=10,
|
||||
leading=12,
|
||||
fontName='Helvetica',
|
||||
textColor=HexColor('#000000'),
|
||||
)
|
||||
|
||||
logo_path = self.school_info.get('logo_path', '')
|
||||
resolved_logo_path = None
|
||||
if logo_path:
|
||||
candidate_paths = [
|
||||
logo_path,
|
||||
os.path.join(cfg.UPLOAD_FOLDER, logo_path),
|
||||
os.path.join('/opt/Inventarsystem/Web/uploads', logo_path),
|
||||
os.path.join('/var/Inventarsystem/Web/uploads', logo_path),
|
||||
]
|
||||
for candidate_path in candidate_paths:
|
||||
if candidate_path and os.path.exists(candidate_path):
|
||||
resolved_logo_path = candidate_path
|
||||
break
|
||||
|
||||
school_info_text = f"""
|
||||
<b>{school_name}</b><br/>
|
||||
{address}<br/>
|
||||
{postal_code} {city}<br/>
|
||||
<i>Schulnummer: {school_number}</i>
|
||||
"""
|
||||
|
||||
if resolved_logo_path:
|
||||
logo_image = Image(resolved_logo_path)
|
||||
try:
|
||||
logo_image._restrictSize(3.4 * cm, 3.4 * cm)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
school_table = Table(
|
||||
[[logo_image, Paragraph(school_info_text, header_style)]],
|
||||
colWidths=[3.8 * cm, self.USABLE_WIDTH - 3.8 * cm],
|
||||
)
|
||||
school_table.setStyle(TableStyle([
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 0),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 0),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 0),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 0),
|
||||
]))
|
||||
story.append(school_table)
|
||||
else:
|
||||
story.append(Paragraph(school_info_text, header_style))
|
||||
|
||||
# Information block (right side simulation)
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
|
||||
info_style = ParagraphStyle(
|
||||
'InfoBlock',
|
||||
parent=styles['Normal'],
|
||||
fontSize=9,
|
||||
leading=11,
|
||||
fontName='Helvetica',
|
||||
textColor=HexColor('#333333'),
|
||||
alignment=TA_LEFT,
|
||||
)
|
||||
|
||||
created_date = self.created_timestamp.strftime('%Y-%m-%d')
|
||||
created_time = self.created_timestamp.strftime('%H:%M:%S')
|
||||
|
||||
info_text = f"""
|
||||
<b>Bericht-Informationen:</b><br/>
|
||||
Erstellungsdatum: {created_date}<br/>
|
||||
Uhrzeit: {created_time}<br/>
|
||||
Verantwortliche Person: {responsible_person}<br/>
|
||||
System: Invario v{__version__}
|
||||
"""
|
||||
|
||||
story.append(Paragraph(info_text, info_style))
|
||||
story.append(Spacer(1, 0.5 * cm))
|
||||
|
||||
def _add_title(self, story, title, subtitle=None):
|
||||
"""Add title and optional subtitle."""
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
title_style = ParagraphStyle(
|
||||
'CustomTitle',
|
||||
parent=styles['Heading1'],
|
||||
fontSize=16,
|
||||
leading=20,
|
||||
fontName='Helvetica-Bold',
|
||||
textColor=HexColor('#1a1a1a'),
|
||||
spaceAfter=12,
|
||||
alignment=TA_LEFT,
|
||||
)
|
||||
|
||||
story.append(Paragraph(f"<b>{title}</b>", title_style))
|
||||
|
||||
if subtitle:
|
||||
subtitle_style = ParagraphStyle(
|
||||
'Subtitle',
|
||||
parent=styles['Normal'],
|
||||
fontSize=11,
|
||||
leading=13,
|
||||
fontName='Helvetica-Oblique',
|
||||
textColor=HexColor('#555555'),
|
||||
spaceAfter=12,
|
||||
alignment=TA_LEFT,
|
||||
)
|
||||
story.append(Paragraph(subtitle, subtitle_style))
|
||||
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
|
||||
def _add_audit_summary(self, story, verify_result, event_counts):
|
||||
"""Add audit chain summary section."""
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
# Summary section title
|
||||
summary_title = ParagraphStyle(
|
||||
'SectionTitle',
|
||||
parent=styles['Heading2'],
|
||||
fontSize=12,
|
||||
leading=14,
|
||||
fontName='Helvetica-Bold',
|
||||
textColor=HexColor('#1a1a1a'),
|
||||
spaceAfter=8,
|
||||
)
|
||||
|
||||
story.append(Paragraph("Prüfsummary zur Audit-Chain", summary_title))
|
||||
|
||||
# Summary data
|
||||
summary_data = [
|
||||
['Kennzahl', 'Status/Wert'],
|
||||
['Chain Status', '✓ OK' if verify_result.get('ok') else '✗ FEHLER'],
|
||||
['Gesamtzahl Einträge', str(verify_result.get('count', 0))],
|
||||
['Letzter Chain Index', str(verify_result.get('last_chain_index', 0))],
|
||||
['Integritätsabweichungen', str(len(verify_result.get('mismatches', []) or []))],
|
||||
]
|
||||
|
||||
# Add event counts
|
||||
if event_counts:
|
||||
story.append(Spacer(1, 0.1 * cm))
|
||||
story.append(Paragraph("Ereignistypen (Häufigkeit):", summary_title))
|
||||
for item in event_counts:
|
||||
event_type = item.get('event_type', 'unknown')
|
||||
count = item.get('count', 0)
|
||||
summary_data.append([f" {event_type}", str(count)])
|
||||
|
||||
summary_table = Table(summary_data, colWidths=[6*cm, 8*cm])
|
||||
summary_table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), HexColor('#e8f4f8')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#1a1a1a')),
|
||||
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 10),
|
||||
('FONTSIZE', (0, 1), (-1, -1), 9),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 8),
|
||||
('BACKGROUND', (0, 1), (-1, -1), HexColor('#f9fafb')),
|
||||
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#d1d5db')),
|
||||
('ROWBACKGROUNDS', (0, 1), (-1, -1), [HexColor('#ffffff'), HexColor('#f3f4f6')]),
|
||||
]))
|
||||
|
||||
story.append(summary_table)
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
|
||||
def _add_events_table(self, story, audit_rows, include_payload=True):
|
||||
"""
|
||||
Add detailed audit events table with professional formatting.
|
||||
|
||||
Args:
|
||||
story (list): Platypus story elements
|
||||
audit_rows (list): Audit log entries
|
||||
include_payload (bool): Include payload details
|
||||
"""
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
story.append(Paragraph("Detaillierte Audit-Ereignisse",
|
||||
ParagraphStyle(
|
||||
'SectionTitle',
|
||||
parent=styles['Heading2'],
|
||||
fontSize=12,
|
||||
fontName='Helvetica-Bold',
|
||||
spaceAfter=8,
|
||||
)))
|
||||
|
||||
# Build table data with wrapped cells for better readability on A4 pages
|
||||
cell_style = ParagraphStyle(
|
||||
'EventCell',
|
||||
parent=styles['Normal'],
|
||||
fontName='Helvetica',
|
||||
fontSize=8,
|
||||
leading=9.5,
|
||||
textColor=HexColor('#1f2937'),
|
||||
alignment=TA_LEFT,
|
||||
)
|
||||
hash_style = ParagraphStyle(
|
||||
'EventHashCell',
|
||||
parent=cell_style,
|
||||
fontName='Courier',
|
||||
fontSize=7.2,
|
||||
leading=9,
|
||||
wordWrap='CJK',
|
||||
)
|
||||
|
||||
def _safe(value):
|
||||
return str(value or '').replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
def _fmt_ts(value):
|
||||
text = _safe(value)
|
||||
if len(text) >= 19 and text[4] == '-' and text[7] == '-':
|
||||
# Convert 2026-05-10 16:19:07... -> 10.05.2026 16:19
|
||||
return f"{text[8:10]}.{text[5:7]}.{text[0:4]} {text[11:16]}"
|
||||
return text[:16]
|
||||
|
||||
def _fmt_event(value):
|
||||
text = _safe(value).replace('_', ' ').strip()
|
||||
return text[:60]
|
||||
|
||||
def _chunk_text(value, chunk=4, sep=' '):
|
||||
text = _safe(value)
|
||||
if not text:
|
||||
return ''
|
||||
return sep.join(text[i:i + chunk] for i in range(0, len(text), chunk))
|
||||
|
||||
def _fmt_ip(value):
|
||||
text = _safe(value)
|
||||
# Keep IPv4 intact. For long IPv6 values insert only one line break in the middle.
|
||||
if ':' in text and len(text) > 24:
|
||||
parts = text.split(':')
|
||||
if len(parts) > 4:
|
||||
return ':'.join(parts[:4]) + ':<br/>' + ':'.join(parts[4:])
|
||||
return text
|
||||
|
||||
# Build table data
|
||||
if self.export_type == 'quick':
|
||||
# Quick-Check: Minimal columns
|
||||
table_data = [
|
||||
['Idx', 'Zeit', 'Ereignis', 'Benutzer', 'Hash (gekürzt)'],
|
||||
]
|
||||
|
||||
for row in audit_rows[:20]: # Limit to 20 rows for quick check
|
||||
chain_idx = _safe(row.get('chain_index', ''))
|
||||
timestamp = _fmt_ts(row.get('timestamp') or row.get('created_at', ''))
|
||||
event_type = _fmt_event(row.get('event_type', ''))
|
||||
actor = _safe(row.get('actor', ''))
|
||||
entry_hash = _chunk_text(_safe(row.get('entry_hash', ''))[:20], chunk=4)
|
||||
if entry_hash:
|
||||
entry_hash += ' ...'
|
||||
|
||||
table_data.append([
|
||||
Paragraph(chain_idx, cell_style),
|
||||
Paragraph(timestamp, cell_style),
|
||||
Paragraph(event_type, cell_style),
|
||||
Paragraph(actor, cell_style),
|
||||
Paragraph(entry_hash, hash_style),
|
||||
])
|
||||
|
||||
colWidths = [1.1*cm, 2.7*cm, 4.8*cm, 3.1*cm, 4.9*cm]
|
||||
else:
|
||||
# Official Report: Full columns
|
||||
table_data = [
|
||||
['Idx', 'Zeit', 'Ereignis', 'Benutzer', 'Quelle', 'IP', 'Hash'],
|
||||
]
|
||||
|
||||
for row in audit_rows:
|
||||
chain_idx = _safe(row.get('chain_index', ''))
|
||||
timestamp = _fmt_ts(row.get('timestamp') or row.get('created_at', ''))
|
||||
event_type = _fmt_event(row.get('event_type', ''))
|
||||
actor = _safe(row.get('actor', ''))
|
||||
source = _safe(row.get('source', 'System'))
|
||||
ip = _fmt_ip(row.get('ip', ''))
|
||||
entry_hash = _chunk_text(_safe(row.get('entry_hash', ''))[:40], chunk=8)
|
||||
|
||||
table_data.append([
|
||||
Paragraph(chain_idx, cell_style),
|
||||
Paragraph(timestamp, cell_style),
|
||||
Paragraph(event_type, cell_style),
|
||||
Paragraph(actor, cell_style),
|
||||
Paragraph(source, cell_style),
|
||||
Paragraph(ip, cell_style),
|
||||
Paragraph(entry_hash, hash_style),
|
||||
])
|
||||
|
||||
# Give IP and hash columns significantly more room for readability.
|
||||
colWidths = [0.9*cm, 2.4*cm, 2.8*cm, 2.0*cm, 1.4*cm, 3.3*cm, 4.2*cm]
|
||||
|
||||
# Create table
|
||||
events_table = Table(table_data, colWidths=colWidths, repeatRows=1)
|
||||
events_table.setStyle(TableStyle([
|
||||
# Header styling
|
||||
('BACKGROUND', (0, 0), (-1, 0), HexColor('#2c3e50')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#ffffff')),
|
||||
('ALIGN', (0, 0), (-1, 0), 'CENTER'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 8.5),
|
||||
('TOPPADDING', (0, 0), (-1, 0), 6),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 6),
|
||||
|
||||
# Body styling
|
||||
('FONTSIZE', (0, 1), (-1, -1), 8),
|
||||
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#bdc3c7')),
|
||||
('ROWBACKGROUNDS', (0, 1), (-1, -1), [HexColor('#ecf0f1'), HexColor('#ffffff')]),
|
||||
('TOPPADDING', (0, 1), (-1, -1), 5),
|
||||
('BOTTOMPADDING', (0, 1), (-1, -1), 5),
|
||||
('LEFTPADDING', (0, 1), (-1, -1), 4),
|
||||
('RIGHTPADDING', (0, 1), (-1, -1), 4),
|
||||
('ALIGN', (0, 1), (0, -1), 'CENTER'),
|
||||
('ALIGN', (1, 1), (1, -1), 'CENTER'),
|
||||
('ALIGN', (-1, 1), (-1, -1), 'LEFT'),
|
||||
]))
|
||||
|
||||
story.append(events_table)
|
||||
story.append(Paragraph(
|
||||
"Hinweis: Zeitangaben sind auf Minuten gerundet; Hashwerte werden aus Platzgründen gekürzt dargestellt.",
|
||||
ParagraphStyle(
|
||||
'TableHint',
|
||||
parent=styles['Normal'],
|
||||
fontSize=7.5,
|
||||
leading=9,
|
||||
textColor=HexColor('#6b7280'),
|
||||
alignment=TA_LEFT,
|
||||
spaceBefore=4,
|
||||
)
|
||||
))
|
||||
story.append(Spacer(1, 0.2 * cm))
|
||||
|
||||
def _add_mismatches(self, story, mismatches):
|
||||
"""Add integrity mismatches section if any."""
|
||||
if not mismatches:
|
||||
return
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
story.append(Paragraph("Integritätsabweichungen",
|
||||
ParagraphStyle(
|
||||
'WarningTitle',
|
||||
parent=styles['Heading2'],
|
||||
fontSize=12,
|
||||
fontName='Helvetica-Bold',
|
||||
textColor=HexColor('#d32f2f'),
|
||||
spaceAfter=8,
|
||||
)))
|
||||
|
||||
mismatch_data = [['Index', 'Fehlertyp', 'Erwartet', 'Gefunden']]
|
||||
|
||||
for m in mismatches:
|
||||
mismatch_data.append([
|
||||
str(m.get('chain_index', '')),
|
||||
str(m.get('error', '')),
|
||||
str(m.get('expected', ''))[:30],
|
||||
str(m.get('found', ''))[:30],
|
||||
])
|
||||
|
||||
mismatch_table = Table(mismatch_data, colWidths=[1.5*cm, 3*cm, 5*cm, 5*cm])
|
||||
mismatch_table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), HexColor('#ffebee')),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), HexColor('#c62828')),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, -1), 8),
|
||||
('GRID', (0, 0), (-1, -1), 0.5, HexColor('#f44336')),
|
||||
('BACKGROUND', (0, 1), (-1, -1), HexColor('#fdeaea')),
|
||||
]))
|
||||
|
||||
story.append(mismatch_table)
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
|
||||
def _add_signature_block(self, story):
|
||||
"""Add signature block for school administration approval."""
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
story.append(Spacer(1, 0.5 * cm))
|
||||
story.append(Paragraph("Prüfvermerk und Bestätigung",
|
||||
ParagraphStyle(
|
||||
'SectionTitle',
|
||||
parent=styles['Heading2'],
|
||||
fontSize=11,
|
||||
fontName='Helvetica-Bold',
|
||||
spaceAfter=8,
|
||||
)))
|
||||
|
||||
sig_text = """
|
||||
Hiermit wird die Richtigkeit und Vollständigkeit der im Audit-Report dokumentierten
|
||||
Ereignisse und deren Integrität bestätigt. Dieses Dokument wurde revisionssicher erstellt
|
||||
und archiviert.
|
||||
"""
|
||||
|
||||
story.append(Paragraph(sig_text,
|
||||
ParagraphStyle(
|
||||
'SigText',
|
||||
parent=styles['Normal'],
|
||||
fontSize=9,
|
||||
leading=11,
|
||||
alignment=TA_JUSTIFY,
|
||||
spaceAfter=12,
|
||||
)))
|
||||
|
||||
# Signature lines
|
||||
sig_data = [
|
||||
['Schulleitung', '', 'IT-Beauftragter'],
|
||||
['', '', ''],
|
||||
['_' * 35, '', '_' * 35],
|
||||
['Unterschrift / Datum', '', 'Unterschrift / Datum'],
|
||||
]
|
||||
|
||||
sig_table = Table(sig_data, colWidths=[5*cm, 2*cm, 5*cm])
|
||||
sig_table.setStyle(TableStyle([
|
||||
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 9),
|
||||
('FONTSIZE', (0, 3), (-1, 3), 8),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 2),
|
||||
]))
|
||||
|
||||
story.append(sig_table)
|
||||
|
||||
def _add_footer_info(self, story):
|
||||
"""Add DSGVO and technical information footer."""
|
||||
styles = getSampleStyleSheet()
|
||||
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
|
||||
footer_style = ParagraphStyle(
|
||||
'Footer',
|
||||
parent=styles['Normal'],
|
||||
fontSize=8,
|
||||
leading=10,
|
||||
fontName='Helvetica',
|
||||
textColor=HexColor('#666666'),
|
||||
alignment=TA_CENTER,
|
||||
spaceAfter=4,
|
||||
)
|
||||
|
||||
dsgvo_text = "Dieses Dokument wurde datenschutzkonform erstellt. Speicherung auf zertifizierten Servern in Deutschland."
|
||||
tech_text = f"Generiert am {self.created_timestamp.strftime('%d.%m.%Y um %H:%M:%S')} durch System Invario (PDF/A-Format, revisionssicher)"
|
||||
|
||||
story.append(Paragraph(dsgvo_text, footer_style))
|
||||
story.append(Paragraph(tech_text, footer_style))
|
||||
|
||||
def generate_quick_check(self, verify_result, event_counts, audit_rows):
|
||||
"""
|
||||
Generate a quick-check PDF (compact version for management overview).
|
||||
|
||||
Returns:
|
||||
bytes: PDF content
|
||||
"""
|
||||
output = io.BytesIO()
|
||||
|
||||
story = []
|
||||
|
||||
# Header
|
||||
self._add_header(story, "Verwaltung")
|
||||
|
||||
# Title
|
||||
self._add_title(story,
|
||||
"Audit-Report: Schnell-Check",
|
||||
f"Überblick zum {self.created_timestamp.strftime('%d.%m.%Y')}")
|
||||
|
||||
# Summary
|
||||
self._add_audit_summary(story, verify_result, event_counts)
|
||||
|
||||
# Events table (limited)
|
||||
self._add_events_table(story, audit_rows, include_payload=False)
|
||||
|
||||
# Mismatches if any
|
||||
mismatches = verify_result.get('mismatches', []) or []
|
||||
if mismatches:
|
||||
self._add_mismatches(story, mismatches)
|
||||
|
||||
# Footer
|
||||
self._add_footer_info(story)
|
||||
|
||||
# Build PDF
|
||||
doc = SimpleDocTemplate(
|
||||
output,
|
||||
pagesize=A4,
|
||||
topMargin=self.MARGIN_TOP * cm,
|
||||
bottomMargin=self.MARGIN_BOTTOM * cm,
|
||||
leftMargin=self.MARGIN_LEFT * cm,
|
||||
rightMargin=self.MARGIN_RIGHT * cm,
|
||||
title="Audit Quick-Check Report",
|
||||
author="Invario System",
|
||||
subject="Audit Report - Quick Check",
|
||||
creator="Invario",
|
||||
)
|
||||
|
||||
doc.build(story)
|
||||
output.seek(0)
|
||||
return output.getvalue()
|
||||
|
||||
def generate_official_report(self, verify_result, event_counts, audit_rows):
|
||||
"""
|
||||
Generate a full official audit report (DIN 5008 compliant for authorities).
|
||||
|
||||
Returns:
|
||||
bytes: PDF content
|
||||
"""
|
||||
output = io.BytesIO()
|
||||
|
||||
story = []
|
||||
|
||||
# Header
|
||||
self._add_header(story, self.school_info.get('it_admin', 'IT-Beauftragter'))
|
||||
|
||||
# Title
|
||||
reporting_date = self.created_timestamp.strftime('%d.%m.%Y')
|
||||
self._add_title(story,
|
||||
"Audit-Protokoll",
|
||||
f"Revisonssicheres Audit-Log - Berichtsstand: {reporting_date}")
|
||||
|
||||
# Summary
|
||||
self._add_audit_summary(story, verify_result, event_counts)
|
||||
|
||||
# Mismatches section (prominently)
|
||||
mismatches = verify_result.get('mismatches', []) or []
|
||||
if mismatches:
|
||||
self._add_mismatches(story, mismatches)
|
||||
|
||||
# Full events table
|
||||
self._add_events_table(story, audit_rows, include_payload=True)
|
||||
|
||||
# Page break for signature section
|
||||
story.append(PageBreak())
|
||||
|
||||
# Signature block
|
||||
self._add_signature_block(story)
|
||||
|
||||
# Footer
|
||||
self._add_footer_info(story)
|
||||
|
||||
# Build PDF
|
||||
doc = SimpleDocTemplate(
|
||||
output,
|
||||
pagesize=A4,
|
||||
topMargin=self.MARGIN_TOP * cm,
|
||||
bottomMargin=self.MARGIN_BOTTOM * cm,
|
||||
leftMargin=self.MARGIN_LEFT * cm,
|
||||
rightMargin=self.MARGIN_RIGHT * cm,
|
||||
title="Audit Official Report",
|
||||
author="Invario System",
|
||||
subject="Offizielle Audit-Bericht (DIN 5008)",
|
||||
creator="Invario",
|
||||
)
|
||||
|
||||
doc.build(story)
|
||||
output.seek(0)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def generate_audit_pdf(verify_result, event_counts, audit_rows, export_type='official', school_info=None):
|
||||
"""
|
||||
Convenience function to generate audit PDFs.
|
||||
|
||||
Args:
|
||||
verify_result (dict): Verification result from audit chain
|
||||
event_counts (list): Event count statistics
|
||||
audit_rows (list): Audit log entries
|
||||
export_type (str): 'official' or 'quick'
|
||||
school_info (dict): School information
|
||||
|
||||
Returns:
|
||||
bytes: PDF content
|
||||
"""
|
||||
pdf_gen = DIN5008AuditPDF(school_info=school_info, export_type=export_type)
|
||||
|
||||
if export_type == 'quick':
|
||||
return pdf_gen.generate_quick_check(verify_result, event_counts, audit_rows)
|
||||
else:
|
||||
return pdf_gen.generate_official_report(verify_result, event_counts, audit_rows)
|
||||
|
||||
def _build_invoice_pdf(invoice_data):
|
||||
"""Render a PDF invoice for a damaged borrowed item."""
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.colors import HexColor, black, white
|
||||
from reportlab.lib.utils import simpleSplit
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
pdf_buffer = io.BytesIO()
|
||||
c = canvas.Canvas(pdf_buffer, pagesize=A4)
|
||||
page_width, page_height = A4
|
||||
|
||||
margin_x = 20 * mm
|
||||
margin_top = 20 * mm
|
||||
usable_width = page_width - (2 * margin_x)
|
||||
current_y = page_height - margin_top
|
||||
|
||||
dark_color = HexColor('#0F172A')
|
||||
accent_color = HexColor('#B91C1C')
|
||||
light_color = HexColor('#F8FAFC')
|
||||
border_color = HexColor('#CBD5E1')
|
||||
text_color = HexColor('#1E293B')
|
||||
muted_color = HexColor('#64748B')
|
||||
|
||||
def draw_wrapped_lines(text, x_pos, y_pos, width, font_name='Helvetica', font_size=11, leading=14, color=text_color):
|
||||
if not text:
|
||||
return y_pos
|
||||
c.setFont(font_name, font_size)
|
||||
c.setFillColor(color)
|
||||
for line in simpleSplit(str(text), font_name, font_size, width):
|
||||
c.drawString(x_pos, y_pos, line)
|
||||
y_pos -= leading
|
||||
return y_pos
|
||||
|
||||
def draw_label_value(label, value, x_pos, y_pos, label_width=45 * mm):
|
||||
c.setFont('Helvetica-Bold', 10)
|
||||
c.setFillColor(muted_color)
|
||||
c.drawString(x_pos, y_pos, label)
|
||||
c.setFont('Helvetica', 10)
|
||||
c.setFillColor(text_color)
|
||||
c.drawString(x_pos + label_width, y_pos, str(value or '-'))
|
||||
return y_pos - 7 * mm
|
||||
|
||||
c.setFillColor(light_color)
|
||||
c.rect(0, 0, page_width, page_height, fill=1, stroke=0)
|
||||
|
||||
c.setFillColor(dark_color)
|
||||
c.rect(0, page_height - 28 * mm, page_width, 28 * mm, fill=1, stroke=0)
|
||||
c.setFillColor(white)
|
||||
c.setFont('Helvetica-Bold', 20)
|
||||
c.drawString(margin_x, page_height - 16 * mm, 'RECHNUNG')
|
||||
c.setFont('Helvetica', 10)
|
||||
c.drawString(margin_x, page_height - 23 * mm, 'Inventarsystem - Schadensersatz für zerstörtes Ausleihobjekt')
|
||||
|
||||
current_y = page_height - 40 * mm
|
||||
c.setStrokeColor(border_color)
|
||||
c.setLineWidth(1)
|
||||
c.line(margin_x, current_y, page_width - margin_x, current_y)
|
||||
current_y -= 12 * mm
|
||||
|
||||
invoice_number = invoice_data.get('invoice_number', '-')
|
||||
created_at = invoice_data.get('created_at_display', '-')
|
||||
borrower = invoice_data.get('borrower', '-')
|
||||
item_name = invoice_data.get('item_name', '-')
|
||||
item_code = invoice_data.get('item_code', '-')
|
||||
item_id = invoice_data.get('item_id', '-')
|
||||
damage_reason = invoice_data.get('damage_reason', '-')
|
||||
amount_text = invoice_data.get('amount_text', '-')
|
||||
|
||||
current_y = draw_label_value('Rechnungsnummer:', invoice_number, margin_x, current_y)
|
||||
current_y = draw_label_value('Datum:', created_at, margin_x, current_y)
|
||||
current_y = draw_label_value('Empfänger:', borrower, margin_x, current_y)
|
||||
current_y = draw_label_value('Ausleihe / Element:', item_name, margin_x, current_y)
|
||||
current_y = draw_label_value('Element-ID:', item_id, margin_x, current_y)
|
||||
current_y = draw_label_value('Code:', item_code, margin_x, current_y)
|
||||
current_y = current_y - 3 * mm
|
||||
|
||||
c.setFillColor(dark_color)
|
||||
c.setFont('Helvetica-Bold', 12)
|
||||
c.drawString(margin_x, current_y, 'Schadensbeschreibung')
|
||||
current_y -= 6 * mm
|
||||
current_y = draw_wrapped_lines(damage_reason, margin_x, current_y, usable_width, font_size=10, leading=13, color=text_color)
|
||||
current_y -= 4 * mm
|
||||
|
||||
c.setFillColor(dark_color)
|
||||
c.setFont('Helvetica-Bold', 12)
|
||||
c.drawString(margin_x, current_y, 'Rechnungsbetrag')
|
||||
current_y -= 8 * mm
|
||||
|
||||
c.setFillColor(accent_color)
|
||||
c.setStrokeColor(accent_color)
|
||||
c.rect(margin_x, current_y - 12 * mm, usable_width, 16 * mm, fill=1, stroke=0)
|
||||
c.setFillColor(white)
|
||||
c.setFont('Helvetica-Bold', 16)
|
||||
c.drawString(margin_x + 5 * mm, current_y - 2 * mm, amount_text)
|
||||
current_y -= 20 * mm
|
||||
|
||||
current_y = draw_wrapped_lines(
|
||||
'Bitte begleichen Sie diesen Betrag zeitnah bei der Verwaltung. Der Betrag ergibt sich aus dem zerstörten Ausleihobjekt und der dokumentierten Schadensmeldung.',
|
||||
margin_x,
|
||||
current_y,
|
||||
usable_width,
|
||||
font_size=10,
|
||||
leading=13,
|
||||
color=muted_color,
|
||||
)
|
||||
|
||||
footer_y = 18 * mm
|
||||
c.setStrokeColor(border_color)
|
||||
c.setLineWidth(0.8)
|
||||
c.line(margin_x, footer_y + 8 * mm, page_width - margin_x, footer_y + 8 * mm)
|
||||
c.setFillColor(muted_color)
|
||||
c.setFont('Helvetica', 9)
|
||||
c.drawString(margin_x, footer_y, 'Inventarsystem - Rechnungserstellung')
|
||||
c.drawRightString(page_width - margin_x, footer_y, f'{amount_text}')
|
||||
|
||||
c.save()
|
||||
pdf_buffer.seek(0)
|
||||
return pdf_buffer
|
||||
Reference in New Issue
Block a user