Initial commit: add compliance_checks table, per-check metadata on assets, and compliance audit trail
This commit is contained in:
245
app/routes/paperwork.py
Normal file
245
app/routes/paperwork.py
Normal 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))
|
||||
|
||||
Reference in New Issue
Block a user