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,211 @@
{% extends 'base.html' %}
{% block title %}{{ user.display_name }} 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('users.index') }}">Users</a></li>
<li class="breadcrumb-item active">{{ user.display_name }}</li>
{% endblock %}
{% block content %}
<div class="page-header d-flex align-items-center justify-content-between mb-4">
<h1>
<i class="bi bi-person-circle me-2"></i>
{{ user.display_name }}
{% if user.is_masked %}<span class="badge badge-masked fs-6 align-middle ms-2">MASKED</span>{% endif %}
</h1>
<div class="d-flex gap-2">
{% if not user.is_masked %}
<a href="{{ url_for('users.edit', user_id=user.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil me-1"></i>Edit
</a>
<a href="{{ url_for('assignments.create', user_id=user.id) }}" class="btn btn-sm btn-outline-success">
<i class="bi bi-plus-circle me-1"></i>Assign Asset
</a>
<a href="{{ url_for('paperwork.create', user_id=user.id) }}" class="btn btn-sm btn-outline-info">
<i class="bi bi-file-earmark-plus me-1"></i>New Document
</a>
<!-- Mask button -->
<button class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#maskModal">
<i class="bi bi-eye-slash me-1"></i>Mask User
</button>
{% endif %}
</div>
</div>
<div class="row g-3">
<!-- Info card -->
<div class="col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-info-circle me-2 text-primary"></i>User Information
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-5 text-muted small">Windows ID</dt>
<dd class="col-7"><code>{{ user.windows_id }}</code></dd>
<dt class="col-5 text-muted small">Full Name</dt>
<dd class="col-7">{{ user.display_name }}</dd>
<dt class="col-5 text-muted small">Email</dt>
<dd class="col-7" style="word-break:break-all;">{{ user.display_email }}</dd>
<dt class="col-5 text-muted small">Phone</dt>
<dd class="col-7">{{ user.display_phone }}</dd>
<dt class="col-5 text-muted small">Department</dt>
<dd class="col-7">{{ user.department or '—' }}</dd>
<dt class="col-5 text-muted small">Job Title</dt>
<dd class="col-7">{{ user.job_title or '—' }}</dd>
<dt class="col-5 text-muted small">Location</dt>
<dd class="col-7">{{ user.location or '—' }}</dd>
<dt class="col-5 text-muted small">Source</dt>
<dd class="col-7"><span class="badge bg-secondary">{{ user.import_source }}</span></dd>
<dt class="col-5 text-muted small">Status</dt>
<dd class="col-7">
{% if user.is_masked %}
<span class="badge badge-masked">Masked</span>
<br><small class="text-muted">{{ user.masked_at.strftime('%d/%m/%Y') if user.masked_at else '' }}</small>
{% elif user.is_active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-warning text-dark">Inactive</span>
{% endif %}
</dd>
<dt class="col-5 text-muted small">Added</dt>
<dd class="col-7">{{ user.created_at.strftime('%d/%m/%Y') if user.created_at else '—' }}</dd>
</dl>
</div>
</div>
</div>
<!-- Assignments -->
<div class="col-md-8">
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
<span><i class="bi bi-arrow-left-right me-2 text-primary"></i>Asset History</span>
{% if not user.is_masked %}
<a href="{{ url_for('assignments.create', user_id=user.id) }}" class="btn btn-xs btn-sm btn-outline-success py-0 px-2">
<i class="bi bi-plus"></i> Assign
</a>
{% endif %}
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Asset</th>
<th>SN / Service Tag</th>
<th>From</th>
<th>Until</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for a in assignments %}
<tr>
<td>{{ a.asset.brand or '' }} {{ a.asset.model or '' }}</td>
<td>
<code>{{ a.asset.serial_number }}</code>
{% if a.asset.service_tag %}<br><small>{{ a.asset.service_tag }}</small>{% endif %}
</td>
<td>{{ a.assigned_date.strftime('%d/%m/%Y') if a.assigned_date else '—' }}</td>
<td>{{ a.returned_date.strftime('%d/%m/%Y') if a.returned_date else '<span class="badge bg-primary">current</span>' | safe }}</td>
<td>
{% if a.is_active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Returned</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('assets.detail', asset_id=a.asset.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted py-3">No assignments.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Documents -->
<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-file-earmark-text me-2 text-primary"></i>Documents</span>
{% if not user.is_masked %}
<a href="{{ url_for('paperwork.create', user_id=user.id) }}"
class="btn btn-xs btn-sm btn-outline-info py-0 px-2">
<i class="bi bi-plus"></i> New
</a>
{% endif %}
</div>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr><th>Title</th><th>Type</th><th>Asset</th><th>Date</th><th></th></tr>
</thead>
<tbody>
{% for d in docs %}
<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>{{ 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 %}
<a href="{{ url_for('paperwork.download', doc_id=d.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-download"></i>
</a>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="5" class="text-center text-muted py-3">No documents.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Mask confirmation modal -->
{% if not user.is_masked %}
<div class="modal fade" id="maskModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title text-danger"><i class="bi bi-eye-slash me-2"></i>Mask User Record</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>This will <strong>permanently erase all PII</strong> (name, email, phone) for
<strong>{{ user.display_name }}</strong> (WID: <code>{{ user.windows_id }}</code>).</p>
<p class="mb-0 text-muted">Asset history and assignments will be retained, linked only to the Windows ID.
This action <strong>cannot be undone</strong>.</p>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="POST" action="{{ url_for('users.mask', user_id=user.id) }}" class="d-inline">
<button type="submit" class="btn btn-danger">
<i class="bi bi-eye-slash me-1"></i>Confirm Mask
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,92 @@
{% extends 'base.html' %}
{% block title %}{{ user.display_name if user else 'New User' }} 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('users.index') }}">Users</a></li>
<li class="breadcrumb-item active">{{ 'Edit' if user else 'New User' }}</li>
{% endblock %}
{% block content %}
<div class="page-header mb-4">
<h1><i class="bi bi-person-{{ 'pencil' if user else 'plus' }} me-2"></i>
{{ 'Edit User' if user else 'Add User' }}
</h1>
</div>
<div class="card border-0 shadow-sm" style="max-width:700px;">
<div class="card-body">
<form method="POST" action="{{ url_for('users.edit', user_id=user.id) if user else url_for('users.create') }}">
<h6 class="text-uppercase text-muted mb-3 small">Identity</h6>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label">Windows ID <span class="text-danger">*</span></label>
<input type="text" name="windows_id" class="form-control"
value="{{ user.windows_id if user else '' }}"
{% if user %}readonly{% endif %} required>
<div class="form-text">Numeric ID e.g. 408525</div>
</div>
<div class="col-md-4">
<label class="form-label">First Name</label>
<input type="text" name="first_name" class="form-control"
value="{{ user.first_name or '' if user else '' }}">
</div>
<div class="col-md-4">
<label class="form-label">Last Name</label>
<input type="text" name="last_name" class="form-control"
value="{{ user.last_name or '' if user else '' }}">
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-control"
value="{{ user.email or '' if user else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">Phone</label>
<input type="text" name="phone" class="form-control"
value="{{ user.phone or '' if user else '' }}">
</div>
</div>
<hr class="my-3">
<h6 class="text-uppercase text-muted mb-3 small">Organisation</h6>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label">Department</label>
<input type="text" name="department" class="form-control"
value="{{ user.department or '' if user else '' }}">
</div>
<div class="col-md-4">
<label class="form-label">Job Title</label>
<input type="text" name="job_title" class="form-control"
value="{{ user.job_title or '' if user else '' }}">
</div>
<div class="col-md-4">
<label class="form-label">Location / Office</label>
<input type="text" name="location" class="form-control"
value="{{ user.location or '' if user else '' }}">
</div>
</div>
{% if user %}
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="is_active" id="isActive"
{% if user.is_active %}checked{% endif %}>
<label class="form-check-label" for="isActive">Active employee</label>
</div>
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>{{ 'Save Changes' if user else 'Create User' }}
</button>
<a href="{{ url_for('users.detail', user_id=user.id) if user else url_for('users.index') }}"
class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,89 @@
{% extends 'base.html' %}
{% block title %}Import Users 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('users.index') }}">Users</a></li>
<li class="breadcrumb-item active">Import</li>
{% endblock %}
{% block content %}
<div class="page-header mb-4">
<h1><i class="bi bi-cloud-download me-2"></i>Import Users</h1>
</div>
<div class="row g-4">
<!-- CSV Import -->
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-filetype-csv me-2 text-success"></i>Import from CSV
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Upload a CSV file with employee data. The file must include a
<code>windows_id</code> column. Additional columns are matched by common aliases.
</p>
<div class="bg-light rounded p-2 mb-3" style="font-size:.78rem;">
<strong>Recognised column names:</strong><br>
<code>windows_id</code>, <code>first_name</code>, <code>last_name</code>,
<code>email</code>, <code>department</code>, <code>job_title</code>,
<code>phone</code>, <code>location</code>
<br><span class="text-muted">(case-insensitive, spaces or underscores)</span>
</div>
<form method="POST" action="{{ url_for('users.import_csv') }}" enctype="multipart/form-data">
<div class="mb-3">
<label class="form-label">CSV File</label>
<input type="file" name="csv_file" class="form-control" accept=".csv" required>
</div>
<button type="submit" class="btn btn-success w-100">
<i class="bi bi-upload me-1"></i>Import CSV
</button>
</form>
</div>
</div>
</div>
<!-- LDAP / AD Sync -->
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white fw-semibold py-3">
<i class="bi bi-diagram-3 me-2 text-primary"></i>Sync from Active Directory
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Connects to the LDAP/AD server configured in Settings and upserts all
matching user accounts. Masked users are never overwritten.
</p>
<div class="alert alert-info py-2 small mb-3">
<i class="bi bi-info-circle me-1"></i>
Existing non-masked users will be updated with fresh AD data.
New accounts will be created. Masked records are skipped.
</div>
<form method="POST" action="{{ url_for('users.import_ldap') }}">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-arrow-repeat me-1"></i>Sync from AD Now
</button>
</form>
<hr>
<small class="text-muted d-block">
Configure LDAP server, bind credentials and base DN in
<a href="{{ url_for('settings.index') }}">Settings</a>.
</small>
</div>
</div>
</div>
</div>
<!-- CSV template download hint -->
<div class="card border-0 shadow-sm mt-4" style="max-width:500px;">
<div class="card-body py-3">
<h6 class="fw-semibold mb-2"><i class="bi bi-file-earmark-spreadsheet me-2"></i>CSV Template</h6>
<p class="small text-muted mb-2">Your CSV should look like this:</p>
<pre class="bg-light rounded p-2 small mb-0">windows_id,first_name,last_name,email,department,job_title,location
408525,John,Doe,john.doe@company.com,IT,Engineer,HQ
408526,Jane,Smith,jane.smith@company.com,HR,Manager,HQ</pre>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,125 @@
{% extends 'base.html' %}
{% block title %}Users IT Asset Management{% endblock %}
{% block breadcrumb %}
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
<li class="breadcrumb-item active">Users</li>
{% endblock %}
{% block content %}
<div class="page-header d-flex align-items-center justify-content-between mb-4">
<h1><i class="bi bi-people-fill me-2"></i>Users</h1>
<div class="d-flex gap-2">
<a href="{{ url_for('users.import_page') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-cloud-download me-1"></i>Import
</a>
<a href="{{ url_for('users.create') }}" class="btn btn-primary btn-sm">
<i class="bi bi-person-plus me-1"></i>Add User
</a>
</div>
</div>
<!-- Filters -->
<form method="GET" class="row g-2 mb-3">
<div class="col-md-5">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" name="q" class="form-control" placeholder="Search name, email, WID, dept…" value="{{ q }}">
</div>
</div>
<div class="col-auto">
<div class="form-check form-check-inline mt-1">
<input class="form-check-input" type="checkbox" name="masked" value="1" id="chkMasked"
{% if show_masked %}checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label" for="chkMasked">Show masked users</label>
</div>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-sm btn-primary">Search</button>
<a href="{{ url_for('users.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>Windows ID</th>
<th>Name</th>
<th>Email</th>
<th>Department</th>
<th>Job Title</th>
<th>Source</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in pagination.items %}
<tr {% if u.is_masked %}class="masked-row"{% endif %}>
<td><code>{{ u.windows_id }}</code></td>
<td>
<a href="{{ url_for('users.detail', user_id=u.id) }}">{{ u.display_name }}</a>
{% if u.is_masked %}<span class="badge badge-masked ms-1">MASKED</span>{% endif %}
</td>
<td>{{ u.display_email }}</td>
<td>{{ u.department or '—' }}</td>
<td>{{ u.job_title or '—' }}</td>
<td>
<span class="badge bg-secondary">{{ u.import_source }}</span>
</td>
<td>
{% if not u.is_masked and u.is_active %}
<span class="badge bg-success">Active</span>
{% elif not u.is_masked %}
<span class="badge bg-warning text-dark">Inactive</span>
{% else %}
<span class="badge badge-masked">Masked</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('users.detail', user_id=u.id) }}" class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% else %}
<tr><td colspan="8" class="text-center text-muted py-4">No users found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% 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('users.index', page=pagination.prev_num, q=q, masked='1' if show_masked else '0') }}"></a>
</li>
{% endif %}
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == pagination.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('users.index', page=p, q=q, masked='1' if show_masked else '0') }}">{{ 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('users.index', page=pagination.next_num, q=q, masked='1' if show_masked else '0') }}"></a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
{% endblock %}