Initial commit: add compliance_checks table, per-check metadata on assets, and compliance audit trail
This commit is contained in:
221
app/services/template_service.py
Normal file
221
app/services/template_service.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user