Files
IT_asset_management/app/services/template_service.py

222 lines
7.5 KiB
Python

"""
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