Initial commit: add compliance_checks table, per-check metadata on assets, and compliance audit trail

This commit is contained in:
2026-04-24 07:14:27 +03:00
commit e63b486ec2
58 changed files with 6468 additions and 0 deletions

245
app/routes/paperwork.py Normal file
View File

@@ -0,0 +1,245 @@
import json
import os
from datetime import datetime
from flask import (Blueprint, render_template, redirect, url_for,
flash, request, current_app, send_from_directory, abort, jsonify)
from flask_login import login_required, current_user
from app.extensions import db
from app.models.paperwork import Paperwork, DOC_TYPES
from app.models.document_template import DocumentTemplate
from app.models.user import User
from app.models.asset import Asset
from app.models.assignment import Assignment
from app.models.audit_log import AuditLog
from app.services.pdf_service import generate_paperwork_pdf
from app.services.template_service import (
build_context, render_template_to_docx, _template_path
)
bp = Blueprint('paperwork', __name__, url_prefix='/paperwork')
def _log(action, record_id, description, new=None):
entry = AuditLog(
table_name='paperwork',
record_id=record_id,
action=action,
new_values=json.dumps(new) if new else None,
performed_by_id=current_user.id,
ip_address=request.remote_addr,
description=description,
)
db.session.add(entry)
@bp.route('/')
@login_required
def index():
page = request.args.get('page', 1, type=int)
q = request.args.get('q', '').strip()
doc_type_filter = request.args.get('doc_type', '')
query = Paperwork.query
if doc_type_filter:
query = query.filter_by(document_type=doc_type_filter)
pagination = query.order_by(Paperwork.created_at.desc()).paginate(
page=page, per_page=current_app.config['ITEMS_PER_PAGE'], error_out=False
)
return render_template('paperwork/index.html',
pagination=pagination, q=q,
doc_type_filter=doc_type_filter,
doc_types=DOC_TYPES)
@bp.route('/new', methods=['GET', 'POST'])
@login_required
def create():
preselect_user_id = request.args.get('user_id', type=int)
preselect_asset_id = request.args.get('asset_id', type=int)
preselect_assignment_id = request.args.get('assignment_id', type=int)
all_templates = DocumentTemplate.query.order_by(DocumentTemplate.name).all()
if request.method == 'POST':
user_id = request.form.get('user_id', type=int)
asset_id = request.form.get('asset_id', type=int) or None
assignment_id = request.form.get('assignment_id', type=int) or None
doc_type = request.form.get('document_type', 'handover')
title = request.form.get('title', '').strip()
notes = request.form.get('notes', '').strip() or None
template_id = request.form.get('template_id', type=int) or None
user = User.query.get(user_id) if user_id else None
if not user:
flash('User is required.', 'danger')
return render_template('paperwork/form.html',
doc_types=DOC_TYPES, all_templates=all_templates,
preselect_user_id=preselect_user_id,
preselect_asset_id=preselect_asset_id)
asset = Asset.query.get(asset_id) if asset_id else None
assignment = Assignment.query.get(assignment_id) if assignment_id else None
if not title:
title = f'{dict(DOC_TYPES).get(doc_type, doc_type)}{user.display_name}'
doc = Paperwork(
document_type=doc_type,
title=title,
user_id=user.id,
asset_id=asset_id,
assignment_id=assignment_id,
template_id=template_id,
notes=notes,
created_by_id=current_user.id,
)
db.session.add(doc)
db.session.flush()
# Build and store merge variables
ctx = build_context(user, asset=asset, assignment=assignment,
paperwork=doc, app=current_app._get_current_object())
# Allow form overrides for any variable (textarea fields named var_*)
for k, v in request.form.items():
if k.startswith('var_'):
ctx[k[4:]] = v
ctx['admin_name'] = current_user.username
doc.merge_vars = json.dumps(ctx)
# Generate .docx from template if one is selected
if template_id:
tpl_obj = DocumentTemplate.query.get(template_id)
if tpl_obj:
tpl_file = _template_path(current_app._get_current_object(), tpl_obj.filename)
if os.path.exists(tpl_file):
out_name = f'doc_{doc.id}_{tpl_obj.id}.docx'
try:
render_template_to_docx(tpl_file, ctx, out_name)
doc.docx_filename = out_name
except Exception as exc:
current_app.logger.error('docx render failed: %s', exc)
flash(f'Word document generation failed: {exc}', 'warning')
# Always generate PDF
try:
pdf_filename = generate_paperwork_pdf(doc, current_app._get_current_object())
doc.pdf_filename = pdf_filename
except Exception as exc:
current_app.logger.error(f'PDF generation failed: {exc}')
flash(f'Document saved but PDF generation failed: {exc}', 'warning')
_log('create', doc.id, f'Created paperwork "{title}" type={doc_type}',
new={'type': doc_type, 'user_id': user_id, 'asset_id': asset_id,
'template_id': template_id})
db.session.commit()
flash(f'Document "{title}" created.', 'success')
return redirect(url_for('paperwork.detail', doc_id=doc.id))
return render_template('paperwork/form.html',
doc_types=DOC_TYPES,
all_templates=all_templates,
preselect_user_id=preselect_user_id,
preselect_asset_id=preselect_asset_id,
preselect_assignment_id=preselect_assignment_id)
@bp.route('/<int:doc_id>')
@login_required
def detail(doc_id):
doc = Paperwork.query.get_or_404(doc_id)
return render_template('paperwork/detail.html', doc=doc,
merge_vars=doc.get_merge_vars())
@bp.route('/<int:doc_id>/download')
@login_required
def download(doc_id):
doc = Paperwork.query.get_or_404(doc_id)
if not doc.pdf_filename:
flash('No PDF available for this document.', 'warning')
return redirect(url_for('paperwork.detail', doc_id=doc_id))
pdf_dir = os.path.join(current_app.root_path, '..', current_app.config['PDF_FOLDER'])
return send_from_directory(pdf_dir, doc.pdf_filename, as_attachment=True)
@bp.route('/<int:doc_id>/download-docx')
@login_required
def download_docx(doc_id):
doc = Paperwork.query.get_or_404(doc_id)
if not doc.docx_filename:
flash('No Word document available for this record.', 'warning')
return redirect(url_for('paperwork.detail', doc_id=doc_id))
docx_dir = os.path.join(current_app.root_path, '..', current_app.config.get('DOCX_FOLDER', 'docx_output'))
safe_name = (doc.title or f'document_{doc.id}') + '.docx'
return send_from_directory(docx_dir, doc.docx_filename, as_attachment=True,
download_name=safe_name)
@bp.route('/<int:doc_id>/regenerate', methods=['POST'])
@login_required
def regenerate(doc_id):
doc = Paperwork.query.get_or_404(doc_id)
app = current_app._get_current_object()
# Regenerate .docx if template-based
if doc.template_id and doc.template:
tpl_file = _template_path(app, doc.template.filename)
ctx = doc.get_merge_vars()
out_name = doc.docx_filename or f'doc_{doc.id}_{doc.template_id}.docx'
try:
render_template_to_docx(tpl_file, ctx, out_name)
doc.docx_filename = out_name
except Exception as exc:
flash(f'Word regeneration failed: {exc}', 'warning')
try:
pdf_filename = generate_paperwork_pdf(doc, app)
doc.pdf_filename = pdf_filename
db.session.commit()
flash('Document regenerated.', 'success')
except Exception as exc:
flash(f'PDF generation failed: {exc}', 'danger')
return redirect(url_for('paperwork.detail', doc_id=doc_id))
@bp.route('/<int:doc_id>/sign', methods=['POST'])
@login_required
def sign(doc_id):
"""Record a signature on a document."""
doc = Paperwork.query.get_or_404(doc_id)
signed_by_name = request.form.get('signed_by_name', '').strip()
signature_data = request.form.get('signature_data', '').strip() # base64 PNG from canvas
if not signed_by_name:
flash('Signer name is required.', 'danger')
return redirect(url_for('paperwork.detail', doc_id=doc_id))
doc.signed_at = datetime.utcnow()
doc.signed_by_name = signed_by_name
if signature_data:
doc.signature_data = signature_data
# Regenerate PDF to embed signature
try:
pdf_filename = generate_paperwork_pdf(doc, current_app._get_current_object())
doc.pdf_filename = pdf_filename
except Exception as exc:
current_app.logger.error('PDF re-gen after sign failed: %s', exc)
_log('sign', doc.id, f'Document signed by {signed_by_name}')
db.session.commit()
flash(f'Document signed by {signed_by_name}.', 'success')
return redirect(url_for('paperwork.detail', doc_id=doc_id))
@bp.route('/<int:doc_id>/unsign', methods=['POST'])
@login_required
def unsign(doc_id):
doc = Paperwork.query.get_or_404(doc_id)
doc.signed_at = None
doc.signed_by_name = None
doc.signature_data = None
db.session.commit()
flash('Signature removed.', 'info')
return redirect(url_for('paperwork.detail', doc_id=doc_id))