Initial commit: add compliance_checks table, per-check metadata on assets, and compliance audit trail
This commit is contained in:
9
app/models/__init__.py
Normal file
9
app/models/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.user import User
|
||||
from app.models.asset import Asset
|
||||
from app.models.assignment import Assignment
|
||||
from app.models.paperwork import Paperwork
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.compliance_check import ComplianceCheck
|
||||
|
||||
__all__ = ['AdminUser', 'User', 'Asset', 'Assignment', 'Paperwork', 'AuditLog', 'ComplianceCheck']
|
||||
33
app/models/admin_user.py
Normal file
33
app/models/admin_user.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from datetime import datetime
|
||||
from flask_login import UserMixin
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from app.extensions import db, login_manager
|
||||
|
||||
|
||||
class AdminUser(UserMixin, db.Model):
|
||||
"""IT staff accounts that manage this application."""
|
||||
__tablename__ = 'admin_users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(100), unique=True, nullable=False)
|
||||
full_name = db.Column(db.String(200), nullable=True)
|
||||
email = db.Column(db.String(200), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(256), nullable=False)
|
||||
role = db.Column(db.String(30), default='admin') # admin, readonly
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
last_login = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<AdminUser {self.username}>'
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return AdminUser.query.get(int(user_id))
|
||||
114
app/models/asset.py
Normal file
114
app/models/asset.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
|
||||
ASSET_TYPES = [
|
||||
'Laptop', 'Desktop', 'Monitor', 'Keyboard', 'Mouse',
|
||||
'Headset', 'Docking Station', 'Printer', 'Scanner',
|
||||
'Tablet', 'Phone', 'Server', 'Network Equipment', 'Other',
|
||||
]
|
||||
|
||||
ASSET_STATUSES = [
|
||||
('available', 'Available'),
|
||||
('assigned', 'Assigned'),
|
||||
('maintenance', 'In Maintenance'),
|
||||
('retired', 'Retired'),
|
||||
('lost', 'Lost / Stolen'),
|
||||
]
|
||||
|
||||
|
||||
class Asset(db.Model):
|
||||
"""Hardware asset tracked by serial number and/or service tag."""
|
||||
__tablename__ = 'assets'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Primary identifiers
|
||||
serial_number = db.Column(db.String(200), unique=True, nullable=False, index=True)
|
||||
service_tag = db.Column(db.String(200), unique=True, nullable=True, index=True)
|
||||
asset_tag = db.Column(db.String(100), nullable=True) # internal barcode / tag
|
||||
|
||||
# Classification
|
||||
asset_type = db.Column(db.String(50), nullable=False)
|
||||
brand = db.Column(db.String(100), nullable=True)
|
||||
model = db.Column(db.String(150), nullable=True)
|
||||
|
||||
# Technical specs (optional)
|
||||
processor = db.Column(db.String(200), nullable=True)
|
||||
ram_gb = db.Column(db.Integer, nullable=True)
|
||||
storage_gb = db.Column(db.Integer, nullable=True)
|
||||
operating_system = db.Column(db.String(100), nullable=True)
|
||||
mac_address = db.Column(db.String(50), nullable=True)
|
||||
|
||||
# Procurement
|
||||
purchase_date = db.Column(db.Date, nullable=True)
|
||||
warranty_expiry = db.Column(db.Date, nullable=True)
|
||||
purchase_price = db.Column(db.Numeric(10, 2), nullable=True)
|
||||
supplier = db.Column(db.String(200), nullable=True)
|
||||
po_number = db.Column(db.String(100), nullable=True)
|
||||
|
||||
# Current state
|
||||
status = db.Column(db.String(30), default='available', nullable=False)
|
||||
location = db.Column(db.String(200), nullable=True)
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Compliance / IT checks — Desktop & Laptop only
|
||||
inventory_number = db.Column(db.String(100), nullable=True)
|
||||
ad_device_name = db.Column(db.String(150), nullable=True)
|
||||
location_note = db.Column(db.Text, nullable=True) # free-text location note
|
||||
|
||||
# Current boolean state
|
||||
encryption_checked = db.Column(db.Boolean, default=False, nullable=False)
|
||||
backup_checked = db.Column(db.Boolean, default=False, nullable=False)
|
||||
hr_notified = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
# Who last changed each check and when
|
||||
encryption_checked_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||
encryption_checked_at = db.Column(db.DateTime, nullable=True)
|
||||
backup_checked_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||
backup_checked_at = db.Column(db.DateTime, nullable=True)
|
||||
hr_notified_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||
hr_notified_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
assignments = db.relationship(
|
||||
'Assignment', backref='asset', lazy='dynamic', cascade='all, delete-orphan'
|
||||
)
|
||||
paperwork_docs = db.relationship(
|
||||
'Paperwork', backref='asset', lazy='dynamic'
|
||||
)
|
||||
compliance_checks = db.relationship(
|
||||
'ComplianceCheck', back_populates='asset', lazy='dynamic',
|
||||
cascade='all, delete-orphan',
|
||||
order_by='ComplianceCheck.performed_at.desc()',
|
||||
)
|
||||
created_by = db.relationship('AdminUser', foreign_keys=[created_by_id])
|
||||
encryption_checked_by = db.relationship('AdminUser', foreign_keys=[encryption_checked_by_id])
|
||||
backup_checked_by = db.relationship('AdminUser', foreign_keys=[backup_checked_by_id])
|
||||
hr_notified_by = db.relationship('AdminUser', foreign_keys=[hr_notified_by_id])
|
||||
|
||||
@property
|
||||
def current_assignment(self):
|
||||
return self.assignments.filter_by(is_active=True).first()
|
||||
|
||||
@property
|
||||
def current_user(self):
|
||||
a = self.current_assignment
|
||||
return a.user if a else None
|
||||
|
||||
@property
|
||||
def status_badge(self):
|
||||
colours = {
|
||||
'available': 'success',
|
||||
'assigned': 'primary',
|
||||
'maintenance': 'warning',
|
||||
'retired': 'secondary',
|
||||
'lost': 'danger',
|
||||
}
|
||||
return colours.get(self.status, 'secondary')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Asset sn={self.serial_number} type={self.asset_type}>'
|
||||
37
app/models/assignment.py
Normal file
37
app/models/assignment.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class Assignment(db.Model):
|
||||
"""Records the assignment of an asset to a user.
|
||||
|
||||
Every assignment (including past ones) is kept permanently so that
|
||||
asset history is preserved even after a user record is masked.
|
||||
"""
|
||||
__tablename__ = 'assignments'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
asset_id = db.Column(db.Integer, db.ForeignKey('assets.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
assigned_date = db.Column(db.Date, nullable=False, default=datetime.utcnow)
|
||||
returned_date = db.Column(db.Date, nullable=True)
|
||||
|
||||
assigned_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||
returned_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
assigned_by = db.relationship('AdminUser', foreign_keys=[assigned_by_id])
|
||||
returned_by = db.relationship('AdminUser', foreign_keys=[returned_by_id])
|
||||
|
||||
# Paperwork linked to this assignment
|
||||
paperwork_docs = db.relationship('Paperwork', backref='assignment', lazy='dynamic')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Assignment asset={self.asset_id} user={self.user_id} active={self.is_active}>'
|
||||
27
app/models/audit_log.py
Normal file
27
app/models/audit_log.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class AuditLog(db.Model):
|
||||
"""Immutable audit trail for all sensitive operations."""
|
||||
__tablename__ = 'audit_log'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
table_name = db.Column(db.String(100), nullable=False)
|
||||
record_id = db.Column(db.Integer, nullable=True)
|
||||
action = db.Column(db.String(50), nullable=False) # create | update | delete | mask | assign | return | import
|
||||
|
||||
# JSON snapshots
|
||||
old_values = db.Column(db.Text, nullable=True)
|
||||
new_values = db.Column(db.Text, nullable=True)
|
||||
|
||||
performed_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||
performed_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
ip_address = db.Column(db.String(50), nullable=True)
|
||||
description = db.Column(db.String(500), nullable=True)
|
||||
|
||||
performed_by = db.relationship('AdminUser', foreign_keys=[performed_by_id])
|
||||
|
||||
def __repr__(self):
|
||||
return f'<AuditLog {self.action} on {self.table_name}#{self.record_id}>'
|
||||
56
app/models/compliance_check.py
Normal file
56
app/models/compliance_check.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
|
||||
CHECK_TYPES = [
|
||||
('encryption', 'Encryption Verified'),
|
||||
('backup', 'Backup Configured'),
|
||||
('hr', 'HR Notified'),
|
||||
]
|
||||
|
||||
|
||||
class ComplianceCheck(db.Model):
|
||||
"""
|
||||
Audit log for every compliance check/uncheck event on an asset.
|
||||
|
||||
One row is created each time a check field changes state, recording
|
||||
who changed it, when, the new state, and an optional note explaining
|
||||
the action (e.g. "Unverified – BitLocker disabled by user").
|
||||
"""
|
||||
__tablename__ = 'compliance_checks'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
asset_id = db.Column(
|
||||
db.Integer, db.ForeignKey('assets.id', ondelete='CASCADE'),
|
||||
nullable=False, index=True
|
||||
)
|
||||
# 'encryption' | 'backup' | 'hr'
|
||||
check_type = db.Column(db.String(30), nullable=False)
|
||||
|
||||
# True = checked/verified, False = unchecked/cleared
|
||||
checked = db.Column(db.Boolean, nullable=False)
|
||||
|
||||
performed_by_id = db.Column(
|
||||
db.Integer, db.ForeignKey('admin_users.id'),
|
||||
nullable=True
|
||||
)
|
||||
performed_at = db.Column(
|
||||
db.DateTime, default=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
# Free-text reason / note supplied at the time of check or uncheck
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Relationships
|
||||
asset = db.relationship('Asset', back_populates='compliance_checks')
|
||||
performed_by = db.relationship('AdminUser', foreign_keys=[performed_by_id])
|
||||
|
||||
@property
|
||||
def check_type_label(self):
|
||||
return dict(CHECK_TYPES).get(self.check_type, self.check_type)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f'<ComplianceCheck asset={self.asset_id} type={self.check_type} '
|
||||
f'checked={self.checked} by={self.performed_by_id}>'
|
||||
)
|
||||
53
app/models/document_template.py
Normal file
53
app/models/document_template.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class DocumentTemplate(db.Model):
|
||||
"""
|
||||
Uploaded Word (.docx) templates with Jinja2-style placeholders.
|
||||
|
||||
Template authors write {{ variable_name }} in their .docx file.
|
||||
When a document is generated the placeholders are replaced with actual
|
||||
context values (user, asset, assignment data).
|
||||
|
||||
PII variables (those prefixed user_name, user_email, user_phone) are
|
||||
re-rendered with masked values when a user's record is masked.
|
||||
"""
|
||||
__tablename__ = 'document_templates'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Category maps to Paperwork.document_type so the UI can pre-filter
|
||||
category = db.Column(db.String(30), nullable=True) # handover|assignment|return|offboarding|custom
|
||||
|
||||
# Stored filename relative to TEMPLATE_FOLDER
|
||||
filename = db.Column(db.String(300), nullable=False)
|
||||
|
||||
# JSON list of placeholder names detected at upload time, e.g. ["user_name","asset_serial"]
|
||||
variables_json = db.Column(db.Text, nullable=True)
|
||||
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
created_by = db.relationship('AdminUser', foreign_keys=[created_by_id])
|
||||
paperwork_docs = db.relationship('Paperwork', back_populates='template', lazy='dynamic')
|
||||
|
||||
@property
|
||||
def variables(self):
|
||||
if self.variables_json:
|
||||
try:
|
||||
return json.loads(self.variables_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
return []
|
||||
|
||||
@variables.setter
|
||||
def variables(self, val):
|
||||
self.variables_json = json.dumps(val)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<DocumentTemplate id={self.id} name={self.name!r}>'
|
||||
72
app/models/paperwork.py
Normal file
72
app/models/paperwork.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
|
||||
DOC_TYPES = [
|
||||
('handover', 'Equipment Handover Receipt'),
|
||||
('assignment', 'Asset Assignment Agreement'),
|
||||
('return', 'Equipment Return Form'),
|
||||
('offboarding', 'Off-Boarding Checklist'),
|
||||
('custom', 'Custom Document'),
|
||||
]
|
||||
|
||||
|
||||
class Paperwork(db.Model):
|
||||
"""Generated paperwork documents tied to a user and optionally an asset."""
|
||||
__tablename__ = 'paperwork'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
document_type = db.Column(db.String(30), nullable=False) # see DOC_TYPES
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
asset_id = db.Column(db.Integer, db.ForeignKey('assets.id'), nullable=True)
|
||||
assignment_id = db.Column(db.Integer, db.ForeignKey('assignments.id'), nullable=True)
|
||||
|
||||
# FK to DocumentTemplate — null for legacy ReportLab-generated docs
|
||||
template_id = db.Column(db.Integer, db.ForeignKey('document_templates.id'), nullable=True)
|
||||
|
||||
# JSON snapshot of merge variables used at generation time.
|
||||
# Kept forever so the document can be re-rendered (e.g. after PII masking).
|
||||
merge_vars = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Legacy free-text / JSON snapshot (kept for backwards compat)
|
||||
template_data = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Generated output files (relative to their respective folders)
|
||||
pdf_filename = db.Column(db.String(300), nullable=True)
|
||||
docx_filename = db.Column(db.String(300), nullable=True)
|
||||
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Signature
|
||||
signed_at = db.Column(db.DateTime, nullable=True)
|
||||
signed_by_name = db.Column(db.String(200), nullable=True) # printed name
|
||||
signature_data = db.Column(db.Text, nullable=True) # base64 PNG
|
||||
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
created_by = db.relationship('AdminUser', foreign_keys=[created_by_id])
|
||||
template = db.relationship('DocumentTemplate', back_populates='paperwork_docs',
|
||||
foreign_keys=[template_id])
|
||||
|
||||
@property
|
||||
def doc_type_label(self):
|
||||
return dict(DOC_TYPES).get(self.document_type, self.document_type)
|
||||
|
||||
@property
|
||||
def is_signed(self):
|
||||
return self.signed_at is not None
|
||||
|
||||
def get_merge_vars(self):
|
||||
if self.merge_vars:
|
||||
try:
|
||||
import json
|
||||
return json.loads(self.merge_vars)
|
||||
except (ValueError, TypeError):
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Paperwork id={self.id} type={self.document_type}>'
|
||||
96
app/models/user.py
Normal file
96
app/models/user.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
"""
|
||||
Tracked employees / users of IT assets.
|
||||
|
||||
Privacy / GDPR masking:
|
||||
When a user leaves the company, an admin can mask the record.
|
||||
All PII fields are cleared and replaced with a reference to the
|
||||
permanent windows_id so asset history is preserved without
|
||||
exposing personal data during audits.
|
||||
"""
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# Permanent, non-PII identifier — used as the anchor for history after masking
|
||||
windows_id = db.Column(db.String(50), unique=True, nullable=False, index=True)
|
||||
|
||||
# PII fields — nulled out when masked
|
||||
first_name = db.Column(db.String(100), nullable=True)
|
||||
last_name = db.Column(db.String(100), nullable=True)
|
||||
email = db.Column(db.String(200), nullable=True)
|
||||
phone = db.Column(db.String(50), nullable=True)
|
||||
|
||||
# Non-PII organisational data — retained after masking
|
||||
department = db.Column(db.String(100), nullable=True)
|
||||
job_title = db.Column(db.String(100), nullable=True)
|
||||
location = db.Column(db.String(100), nullable=True)
|
||||
manager_windows_id = db.Column(db.String(50), nullable=True)
|
||||
|
||||
# Status
|
||||
is_active = db.Column(db.Boolean, default=True) # employed / active in company
|
||||
is_masked = db.Column(db.Boolean, default=False) # PII erased
|
||||
|
||||
masked_at = db.Column(db.DateTime, nullable=True)
|
||||
masked_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||
|
||||
# Import metadata
|
||||
import_source = db.Column(db.String(20), default='manual') # manual | ldap | csv
|
||||
ldap_dn = db.Column(db.String(500), nullable=True) # AD Distinguished Name
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
assignments = db.relationship(
|
||||
'Assignment', foreign_keys='Assignment.user_id',
|
||||
backref='user', lazy='dynamic', cascade='all, delete-orphan'
|
||||
)
|
||||
paperwork_docs = db.relationship(
|
||||
'Paperwork', foreign_keys='Paperwork.user_id',
|
||||
backref='user', lazy='dynamic', cascade='all, delete-orphan'
|
||||
)
|
||||
masked_by = db.relationship('AdminUser', foreign_keys=[masked_by_id])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Display helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
if self.is_masked:
|
||||
return f'[MASKED – WID: {self.windows_id}]'
|
||||
parts = [self.first_name, self.last_name]
|
||||
full = ' '.join(p for p in parts if p)
|
||||
return full or self.windows_id
|
||||
|
||||
@property
|
||||
def display_email(self):
|
||||
return '[MASKED]' if self.is_masked else (self.email or '—')
|
||||
|
||||
@property
|
||||
def display_phone(self):
|
||||
return '[MASKED]' if self.is_masked else (self.phone or '—')
|
||||
|
||||
@property
|
||||
def current_assets(self):
|
||||
"""Returns active assignments."""
|
||||
return self.assignments.filter_by(is_active=True).all()
|
||||
|
||||
def mask(self, admin_user_id):
|
||||
"""Erase PII while preserving the record for asset-history purposes."""
|
||||
self.first_name = None
|
||||
self.last_name = None
|
||||
self.email = None
|
||||
self.phone = None
|
||||
self.is_active = False
|
||||
self.is_masked = True
|
||||
self.masked_at = datetime.utcnow()
|
||||
self.masked_by_id = admin_user_id
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User wid={self.windows_id} masked={self.is_masked}>'
|
||||
Reference in New Issue
Block a user