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

View File

@@ -0,0 +1,238 @@
{% extends 'base.html' %}
{% block title %}{{ doc.title }} IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('paperwork.index') }}">Paperwork</a></li>
<li class="breadcrumb-item active">{{ doc.title }}</li>
{% endblock %}
{% block content %}
<div class="page-header d-flex align-items-center justify-content-between mb-4">
<h1><i class="bi bi-file-earmark-text me-2"></i>{{ doc.title }}</h1>
<div class="d-flex gap-2 flex-wrap">
{% if doc.pdf_filename %}
<a href="{{ url_for('paperwork.download', doc_id=doc.id) }}" class="btn btn-sm btn-primary">
<i class="bi bi-filetype-pdf me-1"></i>Download PDF
</a>
{% endif %}
{% if doc.docx_filename %}
<a href="{{ url_for('paperwork.download_docx', doc_id=doc.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-file-earmark-word me-1"></i>Download .docx
</a>
{% endif %}
<form method="POST" action="{{ url_for('paperwork.regenerate', doc_id=doc.id) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-clockwise me-1"></i>Regenerate
</button>
</form>
</div>
</div>
<div class="row g-3">
<!-- Left column: meta -->
<div class="col-md-4">
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-info-circle me-2 text-primary"></i>Document Info
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-5 text-muted small">Type</dt>
<dd class="col-7"><span class="badge bg-info text-dark">{{ doc.doc_type_label }}</span></dd>
<dt class="col-5 text-muted small">User</dt>
<dd class="col-7">
<a href="{{ url_for('users.detail', user_id=doc.user.id) }}">{{ doc.user.display_name }}</a>
<br><code class="small">WID: {{ doc.user.windows_id }}</code>
</dd>
{% if doc.asset %}
<dt class="col-5 text-muted small">Asset</dt>
<dd class="col-7">
<a href="{{ url_for('assets.detail', asset_id=doc.asset.id) }}">
{{ doc.asset.brand or '' }} {{ doc.asset.model or '' }}
</a>
<br><code class="small">{{ doc.asset.serial_number }}</code>
</dd>
{% endif %}
{% if doc.template %}
<dt class="col-5 text-muted small">Template</dt>
<dd class="col-7">
<a href="{{ url_for('doc_templates.detail', template_id=doc.template.id) }}">{{ doc.template.name }}</a>
</dd>
{% endif %}
<dt class="col-5 text-muted small">Created</dt>
<dd class="col-7">{{ doc.created_at.strftime('%d/%m/%Y %H:%M') if doc.created_at else '—' }}</dd>
<dt class="col-5 text-muted small">Created by</dt>
<dd class="col-7">{{ doc.created_by.username if doc.created_by else '—' }}</dd>
<dt class="col-5 text-muted small">PDF</dt>
<dd class="col-7">
{% if doc.pdf_filename %}<span class="badge bg-success">Generated</span>
{% else %}<span class="badge bg-secondary">Not generated</span>{% endif %}
</dd>
<dt class="col-5 text-muted small">Word doc</dt>
<dd class="col-7">
{% if doc.docx_filename %}<span class="badge bg-primary">Available</span>
{% else %}<span class="badge bg-secondary">None</span>{% endif %}
</dd>
<dt class="col-5 text-muted small">Signed</dt>
<dd class="col-7">
{% if doc.is_signed %}
<span class="badge bg-success">
<i class="bi bi-pen me-1"></i>{{ doc.signed_by_name }}
</span>
<br><span class="small text-muted">{{ doc.signed_at.strftime('%d/%m/%Y %H:%M') }}</span>
{% else %}
<span class="badge bg-warning text-dark">Unsigned</span>
{% endif %}
</dd>
</dl>
</div>
</div>
<!-- Signature card -->
{% if doc.is_signed %}
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between">
<span><i class="bi bi-pen me-2 text-success"></i>Signature</span>
<form method="POST" action="{{ url_for('paperwork.unsign', doc_id=doc.id) }}" class="d-inline">
<button class="btn btn-sm btn-outline-danger" onclick="return confirm('Remove signature?')">Remove</button>
</form>
</div>
<div class="card-body text-center">
{% if doc.signature_data %}
<img src="{{ doc.signature_data }}" alt="Signature" class="img-fluid border rounded"
style="max-height:80px; background:#fff;">
{% endif %}
<p class="mb-0 mt-2 small text-muted">
Signed by <strong>{{ doc.signed_by_name }}</strong><br>
{{ doc.signed_at.strftime('%d/%m/%Y at %H:%M') }}
</p>
</div>
</div>
{% else %}
<!-- Sign document card -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-pen me-2 text-warning"></i>Sign Document
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('paperwork.sign', doc_id=doc.id) }}">
<div class="mb-2">
<label class="form-label small">Signer's full name <span class="text-danger">*</span></label>
<input type="text" name="signed_by_name" class="form-control form-control-sm"
placeholder="{{ doc.user.display_name }}" required>
</div>
<div class="mb-2">
<label class="form-label small">Draw signature (optional)</label>
<canvas id="sigCanvas" width="260" height="80"
class="border rounded d-block"
style="background:#fff; cursor:crosshair; touch-action:none;"></canvas>
<input type="hidden" name="signature_data" id="sigData">
<div class="d-flex gap-2 mt-1">
<button type="button" class="btn btn-xs btn-outline-secondary btn-sm" id="clearSig">Clear</button>
</div>
</div>
<button type="submit" class="btn btn-sm btn-success w-100" id="signBtn">
<i class="bi bi-pen me-1"></i>Sign Document
</button>
</form>
</div>
</div>
{% endif %}
</div>
<!-- Right column: notes + merge vars -->
<div class="col-md-8">
{% if doc.notes %}
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-chat-left-text me-2 text-primary"></i>Notes
</div>
<div class="card-body">
<p class="mb-0">{{ doc.notes }}</p>
</div>
</div>
{% endif %}
{% if merge_vars %}
<div class="card border-0 shadow-sm">
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
<span><i class="bi bi-braces me-2 text-primary"></i>Merge Variables Used</span>
<button class="btn btn-sm btn-outline-secondary" type="button"
data-bs-toggle="collapse" data-bs-target="#mergeVarsBody">
Toggle
</button>
</div>
<div id="mergeVarsBody" class="collapse show">
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0 small">
<thead class="table-light"><tr><th>Variable</th><th>Value</th></tr></thead>
<tbody>
{% set PII = ['user_name','user_email','user_phone'] %}
{% for k, v in merge_vars.items()|sort %}
<tr {% if k in PII %}class="table-danger"{% endif %}>
<td><code>{{ '{{' }} {{ k }} {{ '}}' }}</code>
{% if k in PII %}<span class="badge bg-danger ms-1 small">PII</span>{% endif %}
</td>
<td>{{ v or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_js %}
{% if not doc.is_signed %}
<script>
(function () {
const canvas = document.getElementById('sigCanvas');
const ctx = canvas.getContext('2d');
let drawing = false;
function getPos(e) {
const r = canvas.getBoundingClientRect();
const src = e.touches ? e.touches[0] : e;
return { x: src.clientX - r.left, y: src.clientY - r.top };
}
canvas.addEventListener('mousedown', e => { drawing = true; const p = getPos(e); ctx.beginPath(); ctx.moveTo(p.x, p.y); });
canvas.addEventListener('mousemove', e => { if (!drawing) return; const p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); });
canvas.addEventListener('mouseup', () => { drawing = false; });
canvas.addEventListener('touchstart', e => { e.preventDefault(); drawing = true; const p = getPos(e); ctx.beginPath(); ctx.moveTo(p.x, p.y); });
canvas.addEventListener('touchmove', e => { e.preventDefault(); if (!drawing) return; const p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); });
canvas.addEventListener('touchend', () => { drawing = false; });
ctx.strokeStyle = '#1a1a1a';
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
document.getElementById('clearSig').addEventListener('click', () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
});
document.querySelector('form[action*="/sign"]').addEventListener('submit', () => {
// Only attach non-empty canvas
const blank = document.createElement('canvas');
blank.width = canvas.width; blank.height = canvas.height;
if (canvas.toDataURL() !== blank.toDataURL()) {
document.getElementById('sigData').value = canvas.toDataURL('image/png');
}
});
})();
</script>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,168 @@
{% extends 'base.html' %}
{% block title %}New Document IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('paperwork.index') }}">Paperwork</a></li>
<li class="breadcrumb-item active">New Document</li>
{% endblock %}
{% block content %}
<div class="page-header mb-4">
<h1><i class="bi bi-file-earmark-plus me-2"></i>Create Document</h1>
</div>
<div class="card border-0 shadow-sm" style="max-width:740px;">
<div class="card-body">
<form method="POST" action="{{ url_for('paperwork.create') }}">
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Document Type <span class="text-danger">*</span></label>
<select name="document_type" class="form-select" id="docType">
{% for val, label in doc_types %}
<option value="{{ val }}">{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Title</label>
<input type="text" name="title" class="form-control"
placeholder="Leave blank to auto-generate">
</div>
</div>
<!-- Word Template selector -->
{% if all_templates %}
<div class="mb-3">
<label class="form-label">
Word Template
<span class="text-muted small">(optional — generates an editable .docx)</span>
</label>
<select name="template_id" id="templateSelect" class="form-select">
<option value="">— No template (PDF only) —</option>
{% for tpl in all_templates %}
<option value="{{ tpl.id }}">{{ tpl.name }}{% if tpl.category %} [{{ tpl.category }}]{% endif %}</option>
{% endfor %}
</select>
<!-- Variable preview loaded via AJAX -->
<div id="tplVarsBox" class="mt-2" style="display:none">
<div class="small text-muted mb-1">Variables auto-filled from this template:</div>
<div id="tplVarsList" class="d-flex flex-wrap gap-1"></div>
</div>
</div>
{% endif %}
<!-- User search -->
<div class="mb-3">
<label class="form-label">User <span class="text-danger">*</span></label>
<input type="hidden" name="user_id" id="userId" value="{{ preselect_user_id or '' }}">
<input type="text" id="userSearch" class="form-control"
placeholder="Type name or Windows ID…" autocomplete="off">
<div id="userDropdown" class="list-group position-absolute shadow" style="z-index:1000;display:none;min-width:350px;"></div>
<div id="userDisplay" class="form-text text-success fw-semibold"></div>
</div>
<!-- Asset search (optional) -->
<div class="mb-3">
<label class="form-label">Asset <span class="text-muted small">(optional)</span></label>
<input type="hidden" name="asset_id" id="assetId" value="{{ preselect_asset_id or '' }}">
<input type="text" id="assetSearch" class="form-control"
placeholder="Serial number or service tag…" autocomplete="off">
<div id="assetDropdown" class="list-group position-absolute shadow" style="z-index:1000;display:none;min-width:350px;"></div>
<div id="assetDisplay" class="form-text text-success fw-semibold"></div>
</div>
{% if preselect_assignment_id %}
<input type="hidden" name="assignment_id" value="{{ preselect_assignment_id }}">
{% endif %}
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea name="notes" class="form-control" rows="2"></textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-file-earmark-check me-1"></i>Generate Document
</button>
<a href="{{ url_for('paperwork.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{% if all_templates %}
<div class="mt-3">
<a href="{{ url_for('doc_templates.index') }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-file-earmark-word me-1"></i>Manage Templates
</a>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
function liveSearch(inputId, dropdownId, hiddenId, displayId, endpoint) {
const input = document.getElementById(inputId);
const dropdown = document.getElementById(dropdownId);
const hidden = document.getElementById(hiddenId);
const display = document.getElementById(displayId);
let timer;
input.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(() => {
const q = input.value.trim();
if (q.length < 2) { dropdown.style.display = 'none'; return; }
fetch(`${endpoint}?q=${encodeURIComponent(q)}`)
.then(r => r.json())
.then(items => {
dropdown.innerHTML = '';
if (!items.length) { dropdown.style.display = 'none'; return; }
items.forEach(item => {
const a = document.createElement('a');
a.className = 'list-group-item list-group-item-action py-2';
a.textContent = item.text;
a.addEventListener('click', () => {
hidden.value = item.id;
input.value = item.text;
display.textContent = '✓ Selected';
dropdown.style.display = 'none';
});
dropdown.appendChild(a);
});
dropdown.style.display = 'block';
});
}, 250);
});
document.addEventListener('click', e => { if (!input.contains(e.target)) dropdown.style.display = 'none'; });
}
liveSearch('userSearch', 'userDropdown', 'userId', 'userDisplay', '{{ url_for("users.search") }}');
liveSearch('assetSearch', 'assetDropdown', 'assetId', 'assetDisplay', '{{ url_for("assets.search") }}');
// Template variable preview
const tplSelect = document.getElementById('templateSelect');
if (tplSelect) {
const PII_VARS = new Set(['user_name', 'user_email', 'user_phone']);
tplSelect.addEventListener('change', () => {
const id = tplSelect.value;
const box = document.getElementById('tplVarsBox');
const list = document.getElementById('tplVarsList');
if (!id) { box.style.display = 'none'; return; }
fetch(`/doc-templates/${id}/variables.json`)
.then(r => r.json())
.then(data => {
list.innerHTML = '';
(data.variables || []).forEach(v => {
const badge = document.createElement('span');
badge.className = 'badge ' + (PII_VARS.has(v) ? 'bg-danger' : 'bg-secondary');
badge.title = PII_VARS.has(v) ? 'PII — will be masked on user departure' : '';
badge.textContent = '{{ ' + v + ' }}';
list.appendChild(badge);
});
box.style.display = 'block';
})
.catch(() => { box.style.display = 'none'; });
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,110 @@
{% extends 'base.html' %}
{% block title %}Paperwork IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item active">Paperwork</li>
{% endblock %}
{% block content %}
<div class="page-header d-flex align-items-center justify-content-between mb-4">
<h1><i class="bi bi-file-earmark-text me-2"></i>Paperwork</h1>
<a href="{{ url_for('paperwork.create') }}" class="btn btn-primary btn-sm">
<i class="bi bi-file-earmark-plus me-1"></i>New Document
</a>
</div>
<form method="GET" class="row g-2 mb-3">
<div class="col-md-3">
<select name="doc_type" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">All document types</option>
{% for val, label in doc_types %}
<option value="{{ val }}" {% if doc_type_filter == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<a href="{{ url_for('paperwork.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
</div>
</form>
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Title</th>
<th>Type</th>
<th>User</th>
<th>Asset SN</th>
<th>Created</th>
<th>PDF</th>
<th></th>
</tr>
</thead>
<tbody>
{% for d in pagination.items %}
<tr>
<td><a href="{{ url_for('paperwork.detail', doc_id=d.id) }}">{{ d.title }}</a></td>
<td><span class="badge bg-info text-dark">{{ d.doc_type_label }}</span></td>
<td>
<a href="{{ url_for('users.detail', user_id=d.user.id) }}">{{ d.user.display_name }}</a>
</td>
<td>{{ d.asset.serial_number if d.asset else '—' }}</td>
<td>{{ d.created_at.strftime('%d/%m/%Y') if d.created_at else '—' }}</td>
<td>
{% if d.pdf_filename %}
<span class="badge bg-success"><i class="bi bi-check"></i> Ready</span>
{% else %}
<span class="badge bg-secondary"></span>
{% endif %}
</td>
<td>
<a href="{{ url_for('paperwork.detail', doc_id=d.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-eye"></i>
</a>
{% if d.pdf_filename %}
<a href="{{ url_for('paperwork.download', doc_id=d.id) }}"
class="btn btn-sm btn-outline-primary py-0 px-2">
<i class="bi bi-download"></i>
</a>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted py-4">No documents found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pagination.pages > 1 %}
<div class="card-footer bg-white d-flex justify-content-between align-items-center py-2">
<small class="text-muted">Showing {{ pagination.first }}{{ pagination.last }} of {{ pagination.total }}</small>
<nav>
<ul class="pagination pagination-sm mb-0">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('paperwork.index', page=pagination.prev_num, doc_type=doc_type_filter) }}"></a>
</li>
{% endif %}
{% for p in pagination.iter_pages() %}
{% if p %}
<li class="page-item {% if p == pagination.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('paperwork.index', page=p, doc_type=doc_type_filter) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('paperwork.index', page=pagination.next_num, doc_type=doc_type_filter) }}"></a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
{% endblock %}