""" Template service: fill Word (.docx) templates and generate output files. Variables available in templates (use {{ variable_name }} syntax): User: {{ user_name }} Full name (or [MASKED] after PII erasure) {{ user_email }} {{ user_phone }} {{ user_department }} {{ user_job_title }} {{ user_location }} {{ user_windows_id }} Always present — survives masking {{ user_employee_id }} Same as windows_id (alias) Asset: {{ asset_serial }} Serial number {{ asset_service_tag }} {{ asset_tag }} Internal asset tag {{ asset_brand }} {{ asset_model }} {{ asset_type }} e.g. Laptop / Desktop {{ asset_os }} {{ asset_warranty_expiry }} {{ asset_location }} Assignment: {{ assignment_date }} {{ assignment_id }} {{ return_date }} Document / company: {{ document_date }} Date of generation (dd/mm/yyyy) {{ document_number }} Paperwork record ID {{ company_name }} {{ company_address }} {{ admin_name }} Logged-in admin who generated the doc PII masking: When User.mask() is called, all Paperwork records that were generated from a template have their merge_vars updated (PII keys replaced with [MASKED]) and the .docx/.pdf files are regenerated. """ import json import os import re from datetime import datetime from docxtpl import DocxTemplate from flask import current_app # PII variable keys — these are blanked out on user mask PII_VARS = {'user_name', 'user_email', 'user_phone'} # Variables that survive masking (non-PII) SAFE_VARS = { 'user_department', 'user_job_title', 'user_location', 'user_windows_id', 'user_employee_id', } def build_context(user, asset=None, assignment=None, paperwork=None, app=None): """ Build the Jinja2 context dict from ORM objects. Used both at generation time and when re-rendering after masking. """ if app is None: app = current_app._get_current_object() ctx = { # User 'user_name': user.display_name, 'user_email': user.display_email, 'user_phone': user.display_phone, 'user_department': user.department or '', 'user_job_title': user.job_title or '', 'user_location': user.location or '', 'user_windows_id': user.windows_id or '', 'user_employee_id': user.windows_id or '', # Asset 'asset_serial': '', 'asset_service_tag': '', 'asset_tag': '', 'asset_brand': '', 'asset_model': '', 'asset_type': '', 'asset_os': '', 'asset_warranty_expiry': '', 'asset_location': '', # Assignment 'assignment_date': '', 'assignment_id': '', 'return_date': '', # Document/company 'document_date': datetime.utcnow().strftime('%d/%m/%Y'), 'document_number': str(paperwork.id) if paperwork else '', 'company_name': app.config.get('COMPANY_NAME', ''), 'company_address': app.config.get('COMPANY_ADDRESS', ''), 'admin_name': '', } if asset: ctx.update({ 'asset_serial': asset.serial_number or '', 'asset_service_tag': asset.service_tag or '', 'asset_tag': asset.asset_tag or '', 'asset_brand': asset.brand or '', 'asset_model': asset.model or '', 'asset_type': asset.asset_type or '', 'asset_os': asset.operating_system or '', 'asset_warranty_expiry': (asset.warranty_expiry.strftime('%d/%m/%Y') if asset.warranty_expiry else ''), 'asset_location': asset.location or '', }) if assignment: ctx['assignment_date'] = (assignment.assigned_date.strftime('%d/%m/%Y') if assignment.assigned_date else '') ctx['assignment_id'] = str(assignment.id) ctx['return_date'] = (assignment.returned_date.strftime('%d/%m/%Y') if assignment.returned_date else '') return ctx def _docx_path(app, filename): folder = os.path.join(app.root_path, '..', app.config.get('DOCX_FOLDER', 'docx_output')) os.makedirs(folder, exist_ok=True) return os.path.join(folder, filename) def _template_path(app, filename): folder = os.path.join(app.root_path, '..', app.config.get('TEMPLATE_FOLDER', 'doc_templates')) return os.path.join(folder, filename) def extract_variables(template_path): """ Parse a .docx file and return all unique Jinja2 variable names found in the document text ({{ var_name }} syntax). """ try: tpl = DocxTemplate(template_path) return sorted(tpl.get_undeclared_template_variables()) except Exception: # Fallback: open as zip and scan XML for {{ ... }} import zipfile vars_found = set() try: with zipfile.ZipFile(template_path, 'r') as z: for name in z.namelist(): if name.endswith('.xml'): content = z.read(name).decode('utf-8', errors='ignore') vars_found.update(re.findall(r'\{\{\s*(\w+)\s*\}\}', content)) except Exception: pass return sorted(vars_found) def render_template_to_docx(template_filepath, context, output_filename): """ Fill a .docx template with context values and save to DOCX_FOLDER. Returns the saved filename. """ app = current_app._get_current_object() tpl = DocxTemplate(template_filepath) tpl.render(context) out_path = _docx_path(app, output_filename) tpl.save(out_path) return output_filename def regenerate_for_paperwork(paperwork, app=None): """ Re-render the .docx for an existing Paperwork record using its stored merge_vars. Called after PII masking to overwrite files with sanitised data. If the record was generated from a template, regenerates .docx. Also regenerates the PDF via pdf_service if a PDF exists. Returns (docx_filename, pdf_filename) — either may be None. """ from app.services.pdf_service import generate_paperwork_pdf if app is None: app = current_app._get_current_object() docx_out = None pdf_out = None if paperwork.template_id and paperwork.template: tpl_file = _template_path(app, paperwork.template.filename) if os.path.exists(tpl_file): ctx = paperwork.get_merge_vars() out_name = paperwork.docx_filename or f'doc_{paperwork.id}.docx' try: render_template_to_docx(tpl_file, ctx, out_name) docx_out = out_name except Exception as exc: app.logger.error('Template re-render failed for paperwork %s: %s', paperwork.id, exc) # Always regenerate the PDF (uses pdf_service, reads from Paperwork + User model) if paperwork.pdf_filename: try: pdf_out = generate_paperwork_pdf(paperwork, app) except Exception as exc: app.logger.error('PDF re-render failed for paperwork %s: %s', paperwork.id, exc) return docx_out, pdf_out def mask_variables(merge_vars: dict) -> dict: """Return a copy of merge_vars with PII values replaced by [MASKED].""" masked = dict(merge_vars) for key in PII_VARS: if key in masked: masked[key] = '[MASKED]' return masked