Initial commit: add compliance_checks table, per-check metadata on assets, and compliance audit trail
This commit is contained in:
57
app/__init__.py
Normal file
57
app/__init__.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import os
|
||||
from datetime import datetime, date
|
||||
from flask import Flask
|
||||
from config import config
|
||||
from app.extensions import db, migrate, login_manager
|
||||
|
||||
|
||||
def create_app(config_name='default'):
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
# Ensure storage directories exist
|
||||
for folder_key in ('UPLOAD_FOLDER', 'PDF_FOLDER', 'TEMPLATE_FOLDER', 'DOCX_FOLDER'):
|
||||
folder = os.path.join(app.root_path, '..', app.config[folder_key])
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
login_manager.init_app(app)
|
||||
|
||||
# Import models so Flask-Migrate detects them
|
||||
from app.models import admin_user, user, asset, assignment, paperwork, audit_log, document_template # noqa: F401
|
||||
|
||||
# Register blueprints
|
||||
from app.routes.auth import bp as auth_bp
|
||||
from app.routes.dashboard import bp as dashboard_bp
|
||||
from app.routes.users import bp as users_bp
|
||||
from app.routes.assets import bp as assets_bp
|
||||
from app.routes.assignments import bp as assignments_bp
|
||||
from app.routes.paperwork import bp as paperwork_bp
|
||||
from app.routes.audit import bp as audit_bp
|
||||
from app.routes.settings import bp as settings_bp
|
||||
from app.routes.doc_templates import bp as doc_templates_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(users_bp)
|
||||
app.register_blueprint(assets_bp)
|
||||
app.register_blueprint(assignments_bp)
|
||||
app.register_blueprint(paperwork_bp)
|
||||
app.register_blueprint(audit_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(doc_templates_bp)
|
||||
|
||||
# Inject common template variables
|
||||
from datetime import datetime, date
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
return {
|
||||
'now': datetime.utcnow(),
|
||||
'today': date.today(),
|
||||
'today_date': date.today().isoformat(),
|
||||
}
|
||||
|
||||
return app
|
||||
10
app/extensions.py
Normal file
10
app/extensions.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
login_manager = LoginManager()
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = 'Please log in to access this page.'
|
||||
login_manager.login_message_category = 'warning'
|
||||
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}>'
|
||||
13
app/routes/__init__.py
Normal file
13
app/routes/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from app.routes.auth import bp as auth_bp
|
||||
from app.routes.dashboard import bp as dashboard_bp
|
||||
from app.routes.users import bp as users_bp
|
||||
from app.routes.assets import bp as assets_bp
|
||||
from app.routes.assignments import bp as assignments_bp
|
||||
from app.routes.paperwork import bp as paperwork_bp
|
||||
from app.routes.audit import bp as audit_bp
|
||||
from app.routes.settings import bp as settings_bp
|
||||
|
||||
__all__ = [
|
||||
'auth_bp', 'dashboard_bp', 'users_bp', 'assets_bp',
|
||||
'assignments_bp', 'paperwork_bp', 'audit_bp', 'settings_bp',
|
||||
]
|
||||
389
app/routes/assets.py
Normal file
389
app/routes/assets.py
Normal file
@@ -0,0 +1,389 @@
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from flask import (Blueprint, render_template, redirect, url_for,
|
||||
flash, request, current_app, jsonify)
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db
|
||||
from app.models.asset import Asset, ASSET_TYPES, ASSET_STATUSES
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.compliance_check import ComplianceCheck
|
||||
from app.services.dell_service import lookup_service_tag
|
||||
|
||||
bp = Blueprint('assets', __name__, url_prefix='/assets')
|
||||
|
||||
|
||||
def _parse_date(value):
|
||||
"""Convert a 'YYYY-MM-DD' string to a date object; return None if blank."""
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
try:
|
||||
return date.fromisoformat(value.strip())
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def _log(action, record_id, description, old=None, new=None):
|
||||
entry = AuditLog(
|
||||
table_name='assets',
|
||||
record_id=record_id,
|
||||
action=action,
|
||||
old_values=json.dumps(old) if old else None,
|
||||
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)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# List
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
q = request.args.get('q', '').strip()
|
||||
status_filter = request.args.get('status', '')
|
||||
type_filter = request.args.get('asset_type', '')
|
||||
|
||||
query = Asset.query
|
||||
if q:
|
||||
like = f'%{q}%'
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Asset.serial_number.like(like),
|
||||
Asset.service_tag.like(like),
|
||||
Asset.asset_tag.like(like),
|
||||
Asset.brand.like(like),
|
||||
Asset.model.like(like),
|
||||
)
|
||||
)
|
||||
if status_filter:
|
||||
query = query.filter_by(status=status_filter)
|
||||
if type_filter:
|
||||
query = query.filter_by(asset_type=type_filter)
|
||||
|
||||
pagination = query.order_by(Asset.created_at.desc()).paginate(
|
||||
page=page, per_page=current_app.config['ITEMS_PER_PAGE'], error_out=False
|
||||
)
|
||||
return render_template(
|
||||
'assets/index.html',
|
||||
pagination=pagination, q=q,
|
||||
status_filter=status_filter, type_filter=type_filter,
|
||||
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES,
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Create
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create():
|
||||
if request.method == 'POST':
|
||||
sn = request.form.get('serial_number', '').strip()
|
||||
if not sn:
|
||||
flash('Serial Number is required.', 'danger')
|
||||
return render_template('assets/form.html', asset=None,
|
||||
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES)
|
||||
|
||||
if Asset.query.filter_by(serial_number=sn).first():
|
||||
flash(f'An asset with serial number {sn} already exists.', 'danger')
|
||||
return render_template('assets/form.html', asset=None,
|
||||
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES)
|
||||
|
||||
service_tag = request.form.get('service_tag', '').strip() or None
|
||||
if service_tag and Asset.query.filter_by(service_tag=service_tag).first():
|
||||
flash(f'An asset with service tag {service_tag} already exists.', 'danger')
|
||||
return render_template('assets/form.html', asset=None,
|
||||
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES)
|
||||
|
||||
asset = Asset(
|
||||
serial_number=sn,
|
||||
service_tag=service_tag,
|
||||
asset_tag=request.form.get('asset_tag', '').strip() or None,
|
||||
asset_type=request.form.get('asset_type', 'Laptop'),
|
||||
brand=request.form.get('brand', '').strip() or None,
|
||||
model=request.form.get('model', '').strip() or None,
|
||||
processor=request.form.get('processor', '').strip() or None,
|
||||
ram_gb=request.form.get('ram_gb', type=int),
|
||||
storage_gb=request.form.get('storage_gb', type=int),
|
||||
operating_system=request.form.get('operating_system', '').strip() or None,
|
||||
mac_address=request.form.get('mac_address', '').strip() or None,
|
||||
purchase_date=_parse_date(request.form.get('purchase_date')),
|
||||
warranty_expiry=_parse_date(request.form.get('warranty_expiry')),
|
||||
purchase_price=request.form.get('purchase_price', type=float),
|
||||
supplier=request.form.get('supplier', '').strip() or None,
|
||||
po_number=request.form.get('po_number', '').strip() or None,
|
||||
status=request.form.get('status', 'available'),
|
||||
location=request.form.get('location', '').strip() or None,
|
||||
notes=request.form.get('notes', '').strip() or None,
|
||||
created_by_id=current_user.id,
|
||||
)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
_log('create', asset.id, f'Created asset SN={sn}',
|
||||
new={'serial_number': sn, 'asset_type': asset.asset_type})
|
||||
db.session.commit()
|
||||
flash(f'Asset {sn} created.', 'success')
|
||||
return redirect(url_for('assets.detail', asset_id=asset.id))
|
||||
|
||||
# Pre-fill values from Dell lookup (passed as query string params)
|
||||
prefill = {
|
||||
'service_tag': request.args.get('service_tag', ''),
|
||||
'serial_number': request.args.get('serial_number', ''),
|
||||
'brand': request.args.get('brand', ''),
|
||||
'model': request.args.get('model', ''),
|
||||
'asset_type': request.args.get('asset_type', ''),
|
||||
'operating_system': request.args.get('operating_system', ''),
|
||||
'warranty_expiry': request.args.get('warranty_expiry', ''),
|
||||
'purchase_date': request.args.get('purchase_date', ''),
|
||||
}
|
||||
return render_template('assets/form.html', asset=None,
|
||||
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES,
|
||||
prefill=prefill)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dell service-tag lookup (AJAX)
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/dell-lookup')
|
||||
@login_required
|
||||
def dell_lookup():
|
||||
tag = request.args.get('tag', '').strip()
|
||||
if not tag:
|
||||
return jsonify({'error': 'Service tag is required.'}), 400
|
||||
|
||||
# Check for duplicates first
|
||||
existing = Asset.query.filter_by(service_tag=tag.upper()).first()
|
||||
if existing:
|
||||
return jsonify({
|
||||
'error': f'An asset with service tag {tag.upper()} already exists.',
|
||||
'existing_id': existing.id,
|
||||
}), 409
|
||||
|
||||
try:
|
||||
data = lookup_service_tag(tag)
|
||||
return jsonify(data)
|
||||
except RuntimeError as exc:
|
||||
return jsonify({'error': str(exc)}), 502
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Detail
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/<int:asset_id>')
|
||||
@login_required
|
||||
def detail(asset_id):
|
||||
asset = Asset.query.get_or_404(asset_id)
|
||||
history = asset.assignments.order_by(db.text('assigned_date DESC')).all()
|
||||
docs = asset.paperwork_docs.order_by(db.text('created_at DESC')).all()
|
||||
compliance_log = (
|
||||
AuditLog.query
|
||||
.filter_by(table_name='assets', record_id=asset_id, action='compliance_update')
|
||||
.order_by(AuditLog.performed_at.desc())
|
||||
.all()
|
||||
)
|
||||
check_history = (
|
||||
ComplianceCheck.query
|
||||
.filter_by(asset_id=asset_id)
|
||||
.order_by(ComplianceCheck.performed_at.desc())
|
||||
.all()
|
||||
)
|
||||
return render_template('assets/detail.html', asset=asset, history=history,
|
||||
docs=docs, compliance_log=compliance_log,
|
||||
check_history=check_history)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Edit
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/<int:asset_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit(asset_id):
|
||||
asset = Asset.query.get_or_404(asset_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
old = {'serial_number': asset.serial_number, 'status': asset.status}
|
||||
|
||||
new_sn = request.form.get('serial_number', '').strip()
|
||||
if not new_sn:
|
||||
flash('Serial Number is required.', 'danger')
|
||||
return render_template('assets/form.html', asset=asset,
|
||||
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES)
|
||||
|
||||
# Check uniqueness only if SN changed
|
||||
if new_sn != asset.serial_number:
|
||||
if Asset.query.filter(Asset.serial_number == new_sn, Asset.id != asset_id).first():
|
||||
flash(f'Serial number {new_sn} is already used by another asset.', 'danger')
|
||||
return render_template('assets/form.html', asset=asset,
|
||||
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES)
|
||||
|
||||
asset.serial_number = new_sn
|
||||
asset.service_tag = request.form.get('service_tag', '').strip() or None
|
||||
asset.asset_tag = request.form.get('asset_tag', '').strip() or None
|
||||
asset.asset_type = request.form.get('asset_type', asset.asset_type)
|
||||
asset.brand = request.form.get('brand', '').strip() or None
|
||||
asset.model = request.form.get('model', '').strip() or None
|
||||
asset.processor = request.form.get('processor', '').strip() or None
|
||||
asset.ram_gb = request.form.get('ram_gb', type=int)
|
||||
asset.storage_gb = request.form.get('storage_gb', type=int)
|
||||
asset.operating_system = request.form.get('operating_system', '').strip() or None
|
||||
asset.mac_address = request.form.get('mac_address', '').strip() or None
|
||||
asset.purchase_date = _parse_date(request.form.get('purchase_date'))
|
||||
asset.warranty_expiry = _parse_date(request.form.get('warranty_expiry'))
|
||||
asset.purchase_price = request.form.get('purchase_price', type=float)
|
||||
asset.supplier = request.form.get('supplier', '').strip() or None
|
||||
asset.po_number = request.form.get('po_number', '').strip() or None
|
||||
asset.status = request.form.get('status', asset.status)
|
||||
asset.location = request.form.get('location', '').strip() or None
|
||||
asset.notes = request.form.get('notes', '').strip() or None
|
||||
|
||||
_log('update', asset.id, f'Updated asset SN={asset.serial_number}',
|
||||
old=old, new={'serial_number': asset.serial_number, 'status': asset.status})
|
||||
db.session.commit()
|
||||
flash('Asset updated.', 'success')
|
||||
return redirect(url_for('assets.detail', asset_id=asset_id))
|
||||
|
||||
return render_template('assets/form.html', asset=asset,
|
||||
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Compliance update (Desktop / Laptop specific fields)
|
||||
# ------------------------------------------------------------------
|
||||
_COMPLIANCE_FIELDS = {
|
||||
'inventory_number': 'Inventory Number',
|
||||
'ad_device_name': 'AD Device Name',
|
||||
'location_note': 'Location Note',
|
||||
'encryption_checked': 'Encryption Checked',
|
||||
'backup_checked': 'Backup Checked',
|
||||
'hr_notified': 'HR Notified',
|
||||
}
|
||||
|
||||
@bp.route('/<int:asset_id>/compliance', methods=['POST'])
|
||||
@login_required
|
||||
def update_compliance(asset_id):
|
||||
asset = Asset.query.get_or_404(asset_id)
|
||||
|
||||
old = {
|
||||
'inventory_number': asset.inventory_number,
|
||||
'ad_device_name': asset.ad_device_name,
|
||||
'location_note': asset.location_note,
|
||||
'encryption_checked': asset.encryption_checked,
|
||||
'backup_checked': asset.backup_checked,
|
||||
'hr_notified': asset.hr_notified,
|
||||
}
|
||||
|
||||
asset.inventory_number = request.form.get('inventory_number', '').strip() or None
|
||||
asset.ad_device_name = request.form.get('ad_device_name', '').strip() or None
|
||||
asset.location_note = request.form.get('location_note', '').strip() or None
|
||||
|
||||
new_encryption = bool(request.form.get('encryption_checked'))
|
||||
new_backup = bool(request.form.get('backup_checked'))
|
||||
new_hr = bool(request.form.get('hr_notified'))
|
||||
|
||||
notes = request.form.get('compliance_notes', '').strip() or None
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Record a ComplianceCheck event for each boolean that changed
|
||||
_check_map = [
|
||||
('encryption', 'encryption_checked', new_encryption,
|
||||
'encryption_checked_by_id', 'encryption_checked_at'),
|
||||
('backup', 'backup_checked', new_backup,
|
||||
'backup_checked_by_id', 'backup_checked_at'),
|
||||
('hr', 'hr_notified', new_hr,
|
||||
'hr_notified_by_id', 'hr_notified_at'),
|
||||
]
|
||||
for check_type, field, new_val, by_field, at_field in _check_map:
|
||||
if old[field] != new_val:
|
||||
setattr(asset, field, new_val)
|
||||
setattr(asset, by_field, current_user.id)
|
||||
setattr(asset, at_field, now)
|
||||
db.session.add(ComplianceCheck(
|
||||
asset_id=asset_id,
|
||||
check_type=check_type,
|
||||
checked=new_val,
|
||||
performed_by_id=current_user.id,
|
||||
performed_at=now,
|
||||
notes=notes,
|
||||
))
|
||||
|
||||
new = {
|
||||
'inventory_number': asset.inventory_number,
|
||||
'ad_device_name': asset.ad_device_name,
|
||||
'location_note': asset.location_note,
|
||||
'encryption_checked': asset.encryption_checked,
|
||||
'backup_checked': asset.backup_checked,
|
||||
'hr_notified': asset.hr_notified,
|
||||
}
|
||||
|
||||
# Build human-readable description of what changed
|
||||
changed = [
|
||||
f'{_COMPLIANCE_FIELDS[k]}: {repr(old[k])} → {repr(new[k])}'
|
||||
for k in _COMPLIANCE_FIELDS if old[k] != new[k]
|
||||
]
|
||||
if changed:
|
||||
_log('compliance_update', asset.id,
|
||||
f'Compliance updated: {"; ".join(changed)}',
|
||||
old=old, new=new)
|
||||
db.session.commit()
|
||||
flash('Compliance fields updated.', 'success')
|
||||
else:
|
||||
flash('No changes detected.', 'info')
|
||||
|
||||
return redirect(url_for('assets.detail', asset_id=asset_id))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Quick lookup by SN or service tag (AJAX)
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/lookup')
|
||||
@login_required
|
||||
def lookup():
|
||||
q = request.args.get('q', '').strip()
|
||||
if not q:
|
||||
return jsonify(None)
|
||||
asset = Asset.query.filter(
|
||||
db.or_(Asset.serial_number == q, Asset.service_tag == q)
|
||||
).first()
|
||||
if not asset:
|
||||
return jsonify(None)
|
||||
return jsonify({
|
||||
'id': asset.id,
|
||||
'serial_number': asset.serial_number,
|
||||
'service_tag': asset.service_tag,
|
||||
'brand': asset.brand,
|
||||
'model': asset.model,
|
||||
'asset_type': asset.asset_type,
|
||||
'status': asset.status,
|
||||
})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Search (AJAX dropdown)
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/search')
|
||||
@login_required
|
||||
def search():
|
||||
q = request.args.get('q', '').strip()
|
||||
if len(q) < 2:
|
||||
return jsonify([])
|
||||
like = f'%{q}%'
|
||||
assets = Asset.query.filter(
|
||||
db.or_(
|
||||
Asset.serial_number.like(like),
|
||||
Asset.service_tag.like(like),
|
||||
Asset.asset_tag.like(like),
|
||||
)
|
||||
).limit(15).all()
|
||||
return jsonify([{
|
||||
'id': a.id,
|
||||
'text': f'{a.brand or ""} {a.model or ""} — SN: {a.serial_number}'.strip(' —'),
|
||||
'serial_number': a.serial_number,
|
||||
'service_tag': a.service_tag,
|
||||
'status': a.status,
|
||||
} for a in assets])
|
||||
145
app/routes/assignments.py
Normal file
145
app/routes/assignments.py
Normal file
@@ -0,0 +1,145 @@
|
||||
import json
|
||||
from datetime import date
|
||||
from flask import (Blueprint, render_template, redirect, url_for,
|
||||
flash, request, current_app)
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db
|
||||
from app.models.assignment import Assignment
|
||||
from app.models.asset import Asset
|
||||
from app.models.user import User
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
bp = Blueprint('assignments', __name__, url_prefix='/assignments')
|
||||
|
||||
|
||||
def _log(action, record_id, description, old=None, new=None):
|
||||
entry = AuditLog(
|
||||
table_name='assignments',
|
||||
record_id=record_id,
|
||||
action=action,
|
||||
old_values=json.dumps(old) if old else None,
|
||||
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)
|
||||
active_only = request.args.get('active', '1') == '1'
|
||||
q = request.args.get('q', '').strip()
|
||||
|
||||
query = Assignment.query
|
||||
if active_only:
|
||||
query = query.filter_by(is_active=True)
|
||||
|
||||
pagination = query.order_by(Assignment.assigned_date.desc()).paginate(
|
||||
page=page, per_page=current_app.config['ITEMS_PER_PAGE'], error_out=False
|
||||
)
|
||||
return render_template('assignments/index.html',
|
||||
pagination=pagination, active_only=active_only, q=q)
|
||||
|
||||
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create():
|
||||
# Pre-fill from query params (used from asset / user detail pages)
|
||||
preselect_asset_id = request.args.get('asset_id', type=int)
|
||||
preselect_user_id = request.args.get('user_id', type=int)
|
||||
|
||||
if request.method == 'POST':
|
||||
user_id = request.form.get('user_id', type=int)
|
||||
asset_id = request.form.get('asset_id', type=int)
|
||||
assigned_date_str = request.form.get('assigned_date', '')
|
||||
notes = request.form.get('notes', '').strip() or None
|
||||
|
||||
user = User.query.get(user_id) if user_id else None
|
||||
asset = Asset.query.get(asset_id) if asset_id else None
|
||||
|
||||
errors = []
|
||||
if not user:
|
||||
errors.append('User is required.')
|
||||
if not asset:
|
||||
errors.append('Asset is required.')
|
||||
if user and user.is_masked:
|
||||
errors.append('Cannot assign assets to a masked user.')
|
||||
if asset and asset.status == 'assigned':
|
||||
errors.append(f'Asset {asset.serial_number} is already assigned.')
|
||||
if asset and asset.status in ('retired', 'lost'):
|
||||
errors.append(f'Asset {asset.serial_number} is {asset.status} and cannot be assigned.')
|
||||
|
||||
if errors:
|
||||
for e in errors:
|
||||
flash(e, 'danger')
|
||||
return render_template('assignments/form.html',
|
||||
preselect_asset_id=asset_id,
|
||||
preselect_user_id=user_id)
|
||||
|
||||
try:
|
||||
assigned_date = date.fromisoformat(assigned_date_str) if assigned_date_str else date.today()
|
||||
except ValueError:
|
||||
assigned_date = date.today()
|
||||
|
||||
assignment = Assignment(
|
||||
asset_id=asset.id,
|
||||
user_id=user.id,
|
||||
assigned_date=assigned_date,
|
||||
assigned_by_id=current_user.id,
|
||||
notes=notes,
|
||||
is_active=True,
|
||||
)
|
||||
asset.status = 'assigned'
|
||||
db.session.add(assignment)
|
||||
db.session.flush()
|
||||
_log('assign', assignment.id,
|
||||
f'Assigned asset SN={asset.serial_number} to WID={user.windows_id}',
|
||||
new={'asset_sn': asset.serial_number, 'user_wid': user.windows_id,
|
||||
'date': str(assigned_date)})
|
||||
db.session.commit()
|
||||
flash(f'Asset {asset.serial_number} assigned to {user.display_name}.', 'success')
|
||||
return redirect(url_for('assignments.index'))
|
||||
|
||||
return render_template('assignments/form.html',
|
||||
preselect_asset_id=preselect_asset_id,
|
||||
preselect_user_id=preselect_user_id)
|
||||
|
||||
|
||||
@bp.route('/<int:assignment_id>/return', methods=['POST'])
|
||||
@login_required
|
||||
def return_asset(assignment_id):
|
||||
assignment = Assignment.query.get_or_404(assignment_id)
|
||||
|
||||
if not assignment.is_active:
|
||||
flash('This assignment is already closed.', 'info')
|
||||
return redirect(url_for('assignments.index'))
|
||||
|
||||
returned_date_str = request.form.get('returned_date', '')
|
||||
try:
|
||||
returned_date = date.fromisoformat(returned_date_str) if returned_date_str else date.today()
|
||||
except ValueError:
|
||||
returned_date = date.today()
|
||||
|
||||
assignment.returned_date = returned_date
|
||||
assignment.returned_by_id = current_user.id
|
||||
assignment.is_active = False
|
||||
assignment.notes = (assignment.notes or '') + ('\n' + request.form.get('return_notes', '').strip() if request.form.get('return_notes') else '')
|
||||
|
||||
# Only set asset back to available if no other active assignment (safety check)
|
||||
other_active = Assignment.query.filter(
|
||||
Assignment.asset_id == assignment.asset_id,
|
||||
Assignment.is_active == True, # noqa: E712
|
||||
Assignment.id != assignment.id,
|
||||
).first()
|
||||
if not other_active:
|
||||
assignment.asset.status = 'available'
|
||||
|
||||
_log('return', assignment.id,
|
||||
f'Returned asset SN={assignment.asset.serial_number} from WID={assignment.user.windows_id}',
|
||||
new={'returned_date': str(returned_date)})
|
||||
db.session.commit()
|
||||
flash(f'Asset {assignment.asset.serial_number} returned.', 'success')
|
||||
return redirect(url_for('assignments.index'))
|
||||
30
app/routes/audit.py
Normal file
30
app/routes/audit.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from flask import Blueprint, render_template, request, current_app
|
||||
from flask_login import login_required
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
bp = Blueprint('audit', __name__, url_prefix='/audit')
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
table_filter = request.args.get('table', '')
|
||||
action_filter = request.args.get('action', '')
|
||||
|
||||
query = AuditLog.query
|
||||
if table_filter:
|
||||
query = query.filter_by(table_name=table_filter)
|
||||
if action_filter:
|
||||
query = query.filter_by(action=action_filter)
|
||||
|
||||
pagination = query.order_by(AuditLog.performed_at.desc()).paginate(
|
||||
page=page, per_page=current_app.config['ITEMS_PER_PAGE'], error_out=False
|
||||
)
|
||||
tables = ['users', 'assets', 'assignments', 'paperwork']
|
||||
actions = ['create', 'update', 'delete', 'mask', 'assign', 'return', 'import']
|
||||
return render_template('audit/index.html',
|
||||
pagination=pagination,
|
||||
table_filter=table_filter,
|
||||
action_filter=action_filter,
|
||||
tables=tables, actions=actions)
|
||||
41
app/routes/auth.py
Normal file
41
app/routes/auth.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from app.extensions import db
|
||||
from app.models.admin_user import AdminUser
|
||||
|
||||
bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('dashboard.index'))
|
||||
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
|
||||
if not username or not password:
|
||||
flash('Please enter username and password.', 'danger')
|
||||
return render_template('auth/login.html')
|
||||
|
||||
user = AdminUser.query.filter_by(username=username).first()
|
||||
if user and user.is_active and user.check_password(password):
|
||||
user.last_login = datetime.utcnow()
|
||||
db.session.commit()
|
||||
login_user(user, remember=False)
|
||||
next_page = request.args.get('next')
|
||||
return redirect(next_page or url_for('dashboard.index'))
|
||||
|
||||
flash('Invalid username or password.', 'danger')
|
||||
|
||||
return render_template('auth/login.html')
|
||||
|
||||
|
||||
@bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
flash('You have been logged out.', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
35
app/routes/dashboard.py
Normal file
35
app/routes/dashboard.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from flask import Blueprint, render_template
|
||||
from flask_login import login_required
|
||||
from app.models.user import User
|
||||
from app.models.asset import Asset
|
||||
from app.models.assignment import Assignment
|
||||
from app.models.paperwork import Paperwork
|
||||
|
||||
bp = Blueprint('dashboard', __name__)
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
stats = {
|
||||
'total_users': User.query.count(),
|
||||
'active_users': User.query.filter_by(is_active=True, is_masked=False).count(),
|
||||
'masked_users': User.query.filter_by(is_masked=True).count(),
|
||||
'total_assets': Asset.query.count(),
|
||||
'available_assets': Asset.query.filter_by(status='available').count(),
|
||||
'assigned_assets': Asset.query.filter_by(status='assigned').count(),
|
||||
'maintenance_assets': Asset.query.filter_by(status='maintenance').count(),
|
||||
'active_assignments': Assignment.query.filter_by(is_active=True).count(),
|
||||
'total_paperwork': Paperwork.query.count(),
|
||||
}
|
||||
|
||||
# Recent assignments
|
||||
recent_assignments = (
|
||||
Assignment.query
|
||||
.filter_by(is_active=True)
|
||||
.order_by(Assignment.created_at.desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
|
||||
return render_template('dashboard/index.html', stats=stats, recent_assignments=recent_assignments)
|
||||
202
app/routes/doc_templates.py
Normal file
202
app/routes/doc_templates.py
Normal file
@@ -0,0 +1,202 @@
|
||||
import json
|
||||
import os
|
||||
from flask import (Blueprint, render_template, redirect, url_for,
|
||||
flash, request, current_app, send_from_directory, jsonify)
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
from app.extensions import db
|
||||
from app.models.document_template import DocumentTemplate
|
||||
from app.models.paperwork import DOC_TYPES
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.services.template_service import extract_variables
|
||||
|
||||
bp = Blueprint('doc_templates', __name__, url_prefix='/doc-templates')
|
||||
|
||||
ALLOWED_EXT = {'docx'}
|
||||
|
||||
|
||||
def _log(action, record_id, description, new=None):
|
||||
entry = AuditLog(
|
||||
table_name='document_templates',
|
||||
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)
|
||||
|
||||
|
||||
def _template_folder(app):
|
||||
folder = os.path.join(app.root_path, '..', app.config.get('TEMPLATE_FOLDER', 'doc_templates'))
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
return folder
|
||||
|
||||
|
||||
def _allowed(filename):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXT
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# List
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
templates = DocumentTemplate.query.order_by(DocumentTemplate.name).all()
|
||||
return render_template('doc_templates/index.html',
|
||||
templates=templates, doc_types=DOC_TYPES)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Upload
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/upload', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def upload():
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name', '').strip()
|
||||
description = request.form.get('description', '').strip() or None
|
||||
category = request.form.get('category', '') or None
|
||||
f = request.files.get('docx_file')
|
||||
|
||||
if not name:
|
||||
flash('Template name is required.', 'danger')
|
||||
return render_template('doc_templates/upload.html', doc_types=DOC_TYPES)
|
||||
|
||||
if not f or not f.filename:
|
||||
flash('Please select a .docx file.', 'danger')
|
||||
return render_template('doc_templates/upload.html', doc_types=DOC_TYPES)
|
||||
|
||||
if not _allowed(f.filename):
|
||||
flash('Only .docx files are accepted.', 'danger')
|
||||
return render_template('doc_templates/upload.html', doc_types=DOC_TYPES)
|
||||
|
||||
folder = _template_folder(current_app)
|
||||
safe_name = secure_filename(f.filename)
|
||||
# Prefix with timestamp to avoid collisions
|
||||
from datetime import datetime as _dt
|
||||
prefix = _dt.utcnow().strftime('%Y%m%d_%H%M%S_')
|
||||
filename = prefix + safe_name
|
||||
save_path = os.path.join(folder, filename)
|
||||
f.save(save_path)
|
||||
|
||||
# Extract variables from the uploaded template
|
||||
try:
|
||||
variables = extract_variables(save_path)
|
||||
except Exception as exc:
|
||||
current_app.logger.warning('Variable extraction failed: %s', exc)
|
||||
variables = []
|
||||
|
||||
tpl = DocumentTemplate(
|
||||
name=name,
|
||||
description=description,
|
||||
category=category,
|
||||
filename=filename,
|
||||
created_by_id=current_user.id,
|
||||
)
|
||||
tpl.variables = variables
|
||||
db.session.add(tpl)
|
||||
db.session.flush()
|
||||
_log('create', tpl.id, f'Uploaded template "{name}"', new={'name': name, 'filename': filename})
|
||||
db.session.commit()
|
||||
flash(f'Template "{name}" uploaded. Found {len(variables)} variable(s).', 'success')
|
||||
return redirect(url_for('doc_templates.detail', tpl_id=tpl.id))
|
||||
|
||||
return render_template('doc_templates/upload.html', doc_types=DOC_TYPES)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Detail / variable list
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/<int:tpl_id>')
|
||||
@login_required
|
||||
def detail(tpl_id):
|
||||
tpl = DocumentTemplate.query.get_or_404(tpl_id)
|
||||
return render_template('doc_templates/detail.html', tpl=tpl, doc_types=DOC_TYPES)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Download original template file
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/<int:tpl_id>/download')
|
||||
@login_required
|
||||
def download(tpl_id):
|
||||
tpl = DocumentTemplate.query.get_or_404(tpl_id)
|
||||
folder = _template_folder(current_app)
|
||||
return send_from_directory(folder, tpl.filename, as_attachment=True,
|
||||
download_name=tpl.name + '.docx')
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Re-scan variables (after template is replaced / edited externally)
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/<int:tpl_id>/rescan', methods=['POST'])
|
||||
@login_required
|
||||
def rescan(tpl_id):
|
||||
tpl = DocumentTemplate.query.get_or_404(tpl_id)
|
||||
folder = _template_folder(current_app)
|
||||
path = os.path.join(folder, tpl.filename)
|
||||
try:
|
||||
variables = extract_variables(path)
|
||||
tpl.variables = variables
|
||||
db.session.commit()
|
||||
flash(f'Rescanned: found {len(variables)} variable(s).', 'success')
|
||||
except Exception as exc:
|
||||
flash(f'Rescan failed: {exc}', 'danger')
|
||||
return redirect(url_for('doc_templates.detail', tpl_id=tpl_id))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Edit metadata
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/<int:tpl_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit(tpl_id):
|
||||
tpl = DocumentTemplate.query.get_or_404(tpl_id)
|
||||
if request.method == 'POST':
|
||||
tpl.name = request.form.get('name', tpl.name).strip()
|
||||
tpl.description = request.form.get('description', '').strip() or None
|
||||
tpl.category = request.form.get('category', '') or None
|
||||
db.session.commit()
|
||||
flash('Template updated.', 'success')
|
||||
return redirect(url_for('doc_templates.detail', tpl_id=tpl_id))
|
||||
return render_template('doc_templates/edit.html', tpl=tpl, doc_types=DOC_TYPES)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Delete
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/<int:tpl_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete(tpl_id):
|
||||
tpl = DocumentTemplate.query.get_or_404(tpl_id)
|
||||
# Check if any documents were generated from this template
|
||||
if tpl.paperwork_docs.count() > 0:
|
||||
flash(f'Cannot delete — {tpl.paperwork_docs.count()} document(s) were generated from this template.', 'danger')
|
||||
return redirect(url_for('doc_templates.detail', tpl_id=tpl_id))
|
||||
|
||||
folder = _template_folder(current_app)
|
||||
file_path = os.path.join(folder, tpl.filename)
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
_log('delete', tpl.id, f'Deleted template "{tpl.name}"')
|
||||
db.session.delete(tpl)
|
||||
db.session.commit()
|
||||
flash(f'Template "{tpl.name}" deleted.', 'success')
|
||||
return redirect(url_for('doc_templates.index'))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# AJAX: return variables for a template (used by paperwork form)
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/<int:tpl_id>/variables.json')
|
||||
@login_required
|
||||
def variables_json(tpl_id):
|
||||
tpl = DocumentTemplate.query.get_or_404(tpl_id)
|
||||
return jsonify({'variables': tpl.variables, 'name': tpl.name})
|
||||
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))
|
||||
|
||||
52
app/routes/settings.py
Normal file
52
app/routes/settings.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, flash, request, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db
|
||||
from app.models.admin_user import AdminUser
|
||||
|
||||
bp = Blueprint('settings', __name__, url_prefix='/settings')
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
admins = AdminUser.query.order_by(AdminUser.username).all()
|
||||
return render_template('settings/index.html', admins=admins, config=current_app.config)
|
||||
|
||||
|
||||
@bp.route('/admin/new', methods=['POST'])
|
||||
@login_required
|
||||
def create_admin():
|
||||
username = request.form.get('username', '').strip()
|
||||
email = request.form.get('email', '').strip()
|
||||
full_name = request.form.get('full_name', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
role = request.form.get('role', 'admin')
|
||||
|
||||
if not username or not email or not password:
|
||||
flash('Username, email and password are required.', 'danger')
|
||||
return redirect(url_for('settings.index'))
|
||||
|
||||
if AdminUser.query.filter_by(username=username).first():
|
||||
flash(f'Username "{username}" is already taken.', 'danger')
|
||||
return redirect(url_for('settings.index'))
|
||||
|
||||
admin = AdminUser(username=username, email=email, full_name=full_name, role=role)
|
||||
admin.set_password(password)
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
flash(f'Admin user "{username}" created.', 'success')
|
||||
return redirect(url_for('settings.index'))
|
||||
|
||||
|
||||
@bp.route('/admin/<int:admin_id>/toggle', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_admin(admin_id):
|
||||
admin = AdminUser.query.get_or_404(admin_id)
|
||||
if admin.id == current_user.id:
|
||||
flash('You cannot deactivate your own account.', 'danger')
|
||||
return redirect(url_for('settings.index'))
|
||||
admin.is_active = not admin.is_active
|
||||
db.session.commit()
|
||||
status = 'activated' if admin.is_active else 'deactivated'
|
||||
flash(f'Admin "{admin.username}" {status}.', 'success')
|
||||
return redirect(url_for('settings.index'))
|
||||
332
app/routes/users.py
Normal file
332
app/routes/users.py
Normal file
@@ -0,0 +1,332 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from flask import (Blueprint, render_template, redirect, url_for,
|
||||
flash, request, current_app, jsonify)
|
||||
from flask_login import login_required, current_user
|
||||
from app.extensions import db
|
||||
from app.models.user import User
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.services.csv_service import parse_users_csv
|
||||
from app.services.ldap_service import LDAPService
|
||||
from app.services.template_service import mask_variables, regenerate_for_paperwork
|
||||
|
||||
bp = Blueprint('users', __name__, url_prefix='/users')
|
||||
|
||||
|
||||
def _log(action, record_id, description, old=None, new=None):
|
||||
entry = AuditLog(
|
||||
table_name='users',
|
||||
record_id=record_id,
|
||||
action=action,
|
||||
old_values=json.dumps(old) if old else None,
|
||||
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)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# List
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
q = request.args.get('q', '').strip()
|
||||
show_masked = request.args.get('masked', '0') == '1'
|
||||
|
||||
query = User.query
|
||||
if not show_masked:
|
||||
query = query.filter_by(is_masked=False)
|
||||
if q:
|
||||
like = f'%{q}%'
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
User.windows_id.like(like),
|
||||
User.first_name.like(like),
|
||||
User.last_name.like(like),
|
||||
User.email.like(like),
|
||||
User.department.like(like),
|
||||
)
|
||||
)
|
||||
|
||||
pagination = query.order_by(User.last_name, User.first_name).paginate(
|
||||
page=page, per_page=current_app.config['ITEMS_PER_PAGE'], error_out=False
|
||||
)
|
||||
return render_template('users/index.html', pagination=pagination, q=q, show_masked=show_masked)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Create
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create():
|
||||
if request.method == 'POST':
|
||||
windows_id = request.form.get('windows_id', '').strip()
|
||||
if not windows_id:
|
||||
flash('Windows ID is required.', 'danger')
|
||||
return render_template('users/form.html', user=None)
|
||||
|
||||
if User.query.filter_by(windows_id=windows_id).first():
|
||||
flash(f'A user with Windows ID {windows_id} already exists.', 'danger')
|
||||
return render_template('users/form.html', user=None)
|
||||
|
||||
user = User(
|
||||
windows_id=windows_id,
|
||||
first_name=request.form.get('first_name', '').strip() or None,
|
||||
last_name=request.form.get('last_name', '').strip() or None,
|
||||
email=request.form.get('email', '').strip() or None,
|
||||
phone=request.form.get('phone', '').strip() or None,
|
||||
department=request.form.get('department', '').strip() or None,
|
||||
job_title=request.form.get('job_title', '').strip() or None,
|
||||
location=request.form.get('location', '').strip() or None,
|
||||
import_source='manual',
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.flush()
|
||||
_log('create', user.id, f'Created user WID={windows_id}',
|
||||
new={'windows_id': windows_id, 'email': user.email})
|
||||
db.session.commit()
|
||||
flash(f'User {user.display_name} created.', 'success')
|
||||
return redirect(url_for('users.detail', user_id=user.id))
|
||||
|
||||
return render_template('users/form.html', user=None)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Detail
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/<int:user_id>')
|
||||
@login_required
|
||||
def detail(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
assignments = user.assignments.order_by(db.text('assigned_date DESC')).all()
|
||||
docs = user.paperwork_docs.order_by(db.text('created_at DESC')).all()
|
||||
return render_template('users/detail.html', user=user, assignments=assignments, docs=docs)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Edit
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/<int:user_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
|
||||
if user.is_masked:
|
||||
flash('Cannot edit a masked user record.', 'warning')
|
||||
return redirect(url_for('users.detail', user_id=user_id))
|
||||
|
||||
if request.method == 'POST':
|
||||
old = {
|
||||
'first_name': user.first_name, 'last_name': user.last_name,
|
||||
'email': user.email, 'department': user.department,
|
||||
}
|
||||
user.first_name = request.form.get('first_name', '').strip() or None
|
||||
user.last_name = request.form.get('last_name', '').strip() or None
|
||||
user.email = request.form.get('email', '').strip() or None
|
||||
user.phone = request.form.get('phone', '').strip() or None
|
||||
user.department = request.form.get('department', '').strip() or None
|
||||
user.job_title = request.form.get('job_title', '').strip() or None
|
||||
user.location = request.form.get('location', '').strip() or None
|
||||
user.is_active = request.form.get('is_active') == 'on'
|
||||
|
||||
_log('update', user.id, f'Updated user WID={user.windows_id}',
|
||||
old=old, new={'first_name': user.first_name, 'last_name': user.last_name})
|
||||
db.session.commit()
|
||||
flash('User updated.', 'success')
|
||||
return redirect(url_for('users.detail', user_id=user_id))
|
||||
|
||||
return render_template('users/form.html', user=user)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Mask (GDPR / off-boarding)
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/<int:user_id>/mask', methods=['POST'])
|
||||
@login_required
|
||||
def mask(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
|
||||
if user.is_masked:
|
||||
flash('User is already masked.', 'info')
|
||||
return redirect(url_for('users.detail', user_id=user_id))
|
||||
|
||||
old = {'first_name': user.first_name, 'last_name': user.last_name, 'email': user.email}
|
||||
user.mask(current_user.id)
|
||||
_log('mask', user.id,
|
||||
f'Masked PII for WID={user.windows_id}',
|
||||
old=old, new={'is_masked': True})
|
||||
|
||||
# Re-render all template-based documents with masked PII
|
||||
app = current_app._get_current_object()
|
||||
for pw_doc in user.paperwork_docs:
|
||||
if pw_doc.merge_vars:
|
||||
try:
|
||||
masked_ctx = mask_variables(pw_doc.get_merge_vars())
|
||||
pw_doc.merge_vars = json.dumps(masked_ctx)
|
||||
regenerate_for_paperwork(pw_doc, app)
|
||||
except Exception as exc:
|
||||
app.logger.error('Failed to re-render doc %s on mask: %s', pw_doc.id, exc)
|
||||
|
||||
db.session.commit()
|
||||
flash(f'User WID={user.windows_id} has been masked. Asset history is preserved.', 'success')
|
||||
return redirect(url_for('users.detail', user_id=user_id))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Import page
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/import')
|
||||
@login_required
|
||||
def import_page():
|
||||
return render_template('users/import.html')
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CSV import
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/import/csv', methods=['POST'])
|
||||
@login_required
|
||||
def import_csv():
|
||||
file = request.files.get('csv_file')
|
||||
if not file or not file.filename.endswith('.csv'):
|
||||
flash('Please upload a valid CSV file.', 'danger')
|
||||
return redirect(url_for('users.import_page'))
|
||||
|
||||
users_data, errors = parse_users_csv(file.stream)
|
||||
|
||||
created = updated = skipped = 0
|
||||
for row in users_data:
|
||||
wid = row.get('windows_id', '').strip()
|
||||
if not wid:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
existing = User.query.filter_by(windows_id=wid).first()
|
||||
if existing:
|
||||
if not existing.is_masked:
|
||||
existing.first_name = row.get('first_name') or existing.first_name
|
||||
existing.last_name = row.get('last_name') or existing.last_name
|
||||
existing.email = row.get('email') or existing.email
|
||||
existing.department = row.get('department') or existing.department
|
||||
existing.job_title = row.get('job_title') or existing.job_title
|
||||
existing.location = row.get('location') or existing.location
|
||||
existing.import_source = 'csv'
|
||||
updated += 1
|
||||
else:
|
||||
skipped += 1
|
||||
else:
|
||||
u = User(
|
||||
windows_id=wid,
|
||||
first_name=row.get('first_name') or None,
|
||||
last_name=row.get('last_name') or None,
|
||||
email=row.get('email') or None,
|
||||
department=row.get('department') or None,
|
||||
job_title=row.get('job_title') or None,
|
||||
location=row.get('location') or None,
|
||||
import_source='csv',
|
||||
)
|
||||
db.session.add(u)
|
||||
created += 1
|
||||
|
||||
_log('import', None,
|
||||
f'CSV import: {created} created, {updated} updated, {skipped} skipped',
|
||||
new={'created': created, 'updated': updated, 'skipped': skipped, 'errors': errors})
|
||||
db.session.commit()
|
||||
|
||||
if errors:
|
||||
flash(f'Import completed with warnings: {"; ".join(errors[:5])}', 'warning')
|
||||
flash(f'CSV import done — {created} created, {updated} updated, {skipped} skipped.', 'success')
|
||||
return redirect(url_for('users.index'))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# LDAP / AD sync
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/import/ldap', methods=['POST'])
|
||||
@login_required
|
||||
def import_ldap():
|
||||
if not current_app.config.get('LDAP_SERVER'):
|
||||
flash('LDAP server is not configured. Update Settings first.', 'danger')
|
||||
return redirect(url_for('users.import_page'))
|
||||
|
||||
try:
|
||||
service = LDAPService()
|
||||
users_data = service.sync_users()
|
||||
except Exception as exc:
|
||||
flash(f'LDAP connection failed: {exc}', 'danger')
|
||||
return redirect(url_for('users.import_page'))
|
||||
|
||||
created = updated = skipped = 0
|
||||
for row in users_data:
|
||||
wid = row.get('windows_id', '').strip()
|
||||
if not wid:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
existing = User.query.filter_by(windows_id=wid).first()
|
||||
if existing:
|
||||
if not existing.is_masked:
|
||||
existing.first_name = row.get('first_name') or existing.first_name
|
||||
existing.last_name = row.get('last_name') or existing.last_name
|
||||
existing.email = row.get('email') or existing.email
|
||||
existing.department = row.get('department') or existing.department
|
||||
existing.job_title = row.get('job_title') or existing.job_title
|
||||
existing.location = row.get('location') or existing.location
|
||||
existing.ldap_dn = row.get('ldap_dn') or existing.ldap_dn
|
||||
existing.is_active = row.get('is_active', True)
|
||||
existing.import_source = 'ldap'
|
||||
updated += 1
|
||||
else:
|
||||
skipped += 1
|
||||
else:
|
||||
u = User(
|
||||
windows_id=wid,
|
||||
first_name=row.get('first_name') or None,
|
||||
last_name=row.get('last_name') or None,
|
||||
email=row.get('email') or None,
|
||||
department=row.get('department') or None,
|
||||
job_title=row.get('job_title') or None,
|
||||
location=row.get('location') or None,
|
||||
ldap_dn=row.get('ldap_dn') or None,
|
||||
is_active=row.get('is_active', True),
|
||||
import_source='ldap',
|
||||
)
|
||||
db.session.add(u)
|
||||
created += 1
|
||||
|
||||
_log('import', None,
|
||||
f'LDAP sync: {created} created, {updated} updated, {skipped} skipped',
|
||||
new={'created': created, 'updated': updated, 'skipped': skipped})
|
||||
db.session.commit()
|
||||
flash(f'LDAP sync done — {created} created, {updated} updated, {skipped} skipped.', 'success')
|
||||
return redirect(url_for('users.index'))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Quick search (AJAX)
|
||||
# ------------------------------------------------------------------
|
||||
@bp.route('/search')
|
||||
@login_required
|
||||
def search():
|
||||
q = request.args.get('q', '').strip()
|
||||
if len(q) < 2:
|
||||
return jsonify([])
|
||||
like = f'%{q}%'
|
||||
users = User.query.filter(
|
||||
User.is_masked == False, # noqa: E712
|
||||
db.or_(
|
||||
User.windows_id.like(like),
|
||||
User.first_name.like(like),
|
||||
User.last_name.like(like),
|
||||
)
|
||||
).limit(15).all()
|
||||
return jsonify([{
|
||||
'id': u.id,
|
||||
'text': f'{u.display_name} (WID: {u.windows_id})',
|
||||
'windows_id': u.windows_id,
|
||||
} for u in users])
|
||||
5
app/services/__init__.py
Normal file
5
app/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from app.services.csv_service import parse_users_csv
|
||||
from app.services.ldap_service import LDAPService
|
||||
from app.services.pdf_service import generate_paperwork_pdf
|
||||
|
||||
__all__ = ['parse_users_csv', 'LDAPService', 'generate_paperwork_pdf']
|
||||
60
app/services/csv_service.py
Normal file
60
app/services/csv_service.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import csv
|
||||
import io
|
||||
|
||||
|
||||
# Maps our field names to possible CSV column header aliases (case-insensitive)
|
||||
FIELD_ALIASES = {
|
||||
'windows_id': ['windows_id', 'employeeid', 'employee_id', 'wid', 'id', 'user_id', 'samaccountname'],
|
||||
'first_name': ['first_name', 'firstname', 'givenname', 'given_name', 'prenom'],
|
||||
'last_name': ['last_name', 'lastname', 'surname', 'sn', 'family_name', 'nom'],
|
||||
'email': ['email', 'mail', 'email_address', 'emailaddress'],
|
||||
'department': ['department', 'dept', 'division'],
|
||||
'job_title': ['job_title', 'title', 'position', 'jobtitle', 'job_position'],
|
||||
'phone': ['phone', 'telephone', 'mobile', 'phonenumber', 'telephonenumber'],
|
||||
'location': ['location', 'office', 'site', 'physicaldeliveryofficename'],
|
||||
}
|
||||
|
||||
|
||||
def parse_users_csv(file_stream):
|
||||
"""
|
||||
Parse a CSV file stream and return (users_list, errors_list).
|
||||
|
||||
Accepts BOM-prefixed UTF-8 or plain UTF-8. Column headers are
|
||||
matched case-insensitively against FIELD_ALIASES.
|
||||
"""
|
||||
try:
|
||||
content = file_stream.read().decode('utf-8-sig')
|
||||
except (UnicodeDecodeError, AttributeError):
|
||||
return [], ['Could not decode file. Please use UTF-8 encoding.']
|
||||
|
||||
reader = csv.DictReader(io.StringIO(content))
|
||||
|
||||
if not reader.fieldnames:
|
||||
return [], ['CSV file is empty or has no header row.']
|
||||
|
||||
# Build a lookup: normalised header -> our field name
|
||||
norm_headers = {h.lower().strip().replace(' ', '_'): h for h in reader.fieldnames}
|
||||
col_map = {} # our_field -> actual_csv_header
|
||||
for field, aliases in FIELD_ALIASES.items():
|
||||
for alias in aliases:
|
||||
if alias in norm_headers:
|
||||
col_map[field] = norm_headers[alias]
|
||||
break
|
||||
|
||||
errors = []
|
||||
users = []
|
||||
|
||||
for row_num, row in enumerate(reader, start=2):
|
||||
user = {}
|
||||
for field in FIELD_ALIASES:
|
||||
csv_col = col_map.get(field)
|
||||
user[field] = (row.get(csv_col, '') or '').strip() if csv_col else ''
|
||||
|
||||
wid = user.get('windows_id', '').strip()
|
||||
if not wid:
|
||||
errors.append(f'Row {row_num}: missing windows_id — skipped.')
|
||||
continue
|
||||
|
||||
users.append(user)
|
||||
|
||||
return users, errors
|
||||
164
app/services/dell_service.py
Normal file
164
app/services/dell_service.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Dell asset lookup service.
|
||||
|
||||
Two modes (chosen automatically):
|
||||
|
||||
1. **No credentials** – Returns a partial pre-fill (brand=Dell, OS default, service_tag).
|
||||
Model and warranty must be filled in manually; a link to Dell's support page is provided.
|
||||
Dell's public website is protected by Akamai and cannot be scraped reliably.
|
||||
|
||||
2. **TechDirect API** – Full data including model, warranty dates, serial number.
|
||||
Register free at https://tdm.dell.com → API Services → Create an API key pair.
|
||||
Set DELL_CLIENT_ID and DELL_CLIENT_SECRET in your .env file.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
from flask import current_app
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_TOKEN_URL = "https://apigtwb2c.us.dell.com/auth/oauth/v2/token"
|
||||
_ASSET_URL = "https://apigtwb2c.us.dell.com/PROD/sbil/eapi/v5/asset-entitlements"
|
||||
_SUPPORT_PAGE = "https://www.dell.com/support/home/en-us/product-support/servicetag/{tag}/overview"
|
||||
|
||||
_TYPE_MAP = [
|
||||
(["latitude", "inspiron", "xps", "vostro", "precision 5", "precision 7"], "Laptop"),
|
||||
(["optiplex", "precision tower", "precision 3", "precision 9",
|
||||
"optiplex micro", "optiplex small"], "Desktop"),
|
||||
(["poweredge", "server"], "Server"),
|
||||
(["wyse", "thin client"], "Other"),
|
||||
(["monitor", "display", "screen", "s24", "s27", "p24", "u27"], "Monitor"),
|
||||
]
|
||||
|
||||
|
||||
def _detect_type(description: str) -> str:
|
||||
desc = description.lower()
|
||||
for keywords, asset_type in _TYPE_MAP:
|
||||
if any(kw in desc for kw in keywords):
|
||||
return asset_type
|
||||
return "Laptop" # sensible default for Dell business hardware
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Partial pre-fill (no credentials)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _partial_prefill(tag: str) -> dict:
|
||||
"""
|
||||
Return a minimal pre-fill dict using only what we know without querying Dell.
|
||||
Includes a link to Dell's support page so the user can look up the rest.
|
||||
"""
|
||||
return {
|
||||
"service_tag": tag,
|
||||
"serial_number": "",
|
||||
"brand": "Dell",
|
||||
"model": "",
|
||||
"asset_type": "Laptop",
|
||||
"operating_system": "Windows 11 Pro",
|
||||
"warranty_expiry": "",
|
||||
"purchase_date": "",
|
||||
"source": "partial",
|
||||
"support_url": _SUPPORT_PAGE.format(tag=tag),
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Official TechDirect API (requires credentials)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_token(client_id: str, client_secret: str) -> str:
|
||||
resp = requests.post(
|
||||
_TOKEN_URL,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
def _lookup_api(tag: str, client_id: str, client_secret: str) -> dict:
|
||||
try:
|
||||
token = _get_token(client_id, client_secret)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Failed to obtain Dell API token: {exc}") from exc
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
_ASSET_URL,
|
||||
params={"servicetags": tag},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Dell API request failed: {exc}") from exc
|
||||
|
||||
if not data:
|
||||
raise RuntimeError(f"No data returned for service tag '{tag}'.")
|
||||
|
||||
item = data[0] if isinstance(data, list) else data
|
||||
system_desc = item.get("productLineDescription") or item.get("systemDescription") or ""
|
||||
model = system_desc.strip()
|
||||
serial_number = (item.get("serviceTag") or tag).upper()
|
||||
|
||||
warranty_expiry = None
|
||||
for ent in (item.get("entitlements") or []):
|
||||
end_str = ent.get("endDate") or ""
|
||||
if end_str:
|
||||
try:
|
||||
dt = datetime.fromisoformat(end_str[:10])
|
||||
if warranty_expiry is None or dt > warranty_expiry:
|
||||
warranty_expiry = dt
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
ship_date = item.get("shipDate") or ""
|
||||
purchase_date = None
|
||||
if ship_date:
|
||||
try:
|
||||
purchase_date = datetime.fromisoformat(ship_date[:10])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"service_tag": tag,
|
||||
"serial_number": serial_number,
|
||||
"brand": "Dell",
|
||||
"model": model,
|
||||
"asset_type": _detect_type(f"{system_desc} {item.get('productFamily', '')}"),
|
||||
"operating_system": "Windows 11 Pro",
|
||||
"warranty_expiry": warranty_expiry.strftime("%Y-%m-%d") if warranty_expiry else "",
|
||||
"purchase_date": purchase_date.strftime("%Y-%m-%d") if purchase_date else "",
|
||||
"source": "techdirect_api",
|
||||
"support_url": _SUPPORT_PAGE.format(tag=tag),
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def lookup_service_tag(service_tag: str) -> dict:
|
||||
"""
|
||||
Look up a Dell service tag.
|
||||
|
||||
Uses TechDirect API when DELL_CLIENT_ID + DELL_CLIENT_SECRET are configured;
|
||||
otherwise returns a partial pre-fill with a link to Dell's support page.
|
||||
"""
|
||||
tag = service_tag.strip().upper()
|
||||
client_id = current_app.config.get("DELL_CLIENT_ID", "")
|
||||
client_secret = current_app.config.get("DELL_CLIENT_SECRET", "")
|
||||
|
||||
if client_id and client_secret:
|
||||
log.debug("Dell lookup via TechDirect API for %s", tag)
|
||||
return _lookup_api(tag, client_id, client_secret)
|
||||
|
||||
log.debug("Dell lookup returning partial pre-fill for %s (no API credentials)", tag)
|
||||
return _partial_prefill(tag)
|
||||
84
app/services/ldap_service.py
Normal file
84
app/services/ldap_service.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from ldap3 import Server, Connection, ALL, SUBTREE
|
||||
from flask import current_app
|
||||
|
||||
|
||||
class LDAPService:
|
||||
"""Wraps ldap3 to sync users from Active Directory."""
|
||||
|
||||
ATTRIBUTES = [
|
||||
'employeeID', 'sAMAccountName', 'givenName', 'sn',
|
||||
'mail', 'department', 'title', 'telephoneNumber',
|
||||
'distinguishedName', 'physicalDeliveryOfficeName',
|
||||
'userAccountControl',
|
||||
]
|
||||
|
||||
def _connect(self):
|
||||
cfg = current_app.config
|
||||
server = Server(
|
||||
cfg['LDAP_SERVER'],
|
||||
port=cfg['LDAP_PORT'],
|
||||
use_ssl=cfg['LDAP_USE_SSL'],
|
||||
get_info=ALL,
|
||||
)
|
||||
conn = Connection(
|
||||
server,
|
||||
user=cfg['LDAP_BIND_USER'],
|
||||
password=cfg['LDAP_BIND_PASSWORD'],
|
||||
auto_bind=True,
|
||||
)
|
||||
return conn
|
||||
|
||||
def sync_users(self):
|
||||
"""
|
||||
Query AD and return a list of dicts ready to be upserted into the
|
||||
User model. Raises an exception if the connection fails.
|
||||
"""
|
||||
cfg = current_app.config
|
||||
conn = self._connect()
|
||||
|
||||
conn.search(
|
||||
search_base=cfg['LDAP_BASE_DN'],
|
||||
search_filter=cfg['LDAP_USER_SEARCH_FILTER'],
|
||||
search_scope=SUBTREE,
|
||||
attributes=self.ATTRIBUTES,
|
||||
)
|
||||
|
||||
wid_attr = cfg['LDAP_WINDOWS_ID_ATTR']
|
||||
users = []
|
||||
for entry in conn.entries:
|
||||
# Resolve windows_id from the configured attribute, fall back to sAMAccountName
|
||||
raw_wid = str(getattr(entry, wid_attr, '') or '')
|
||||
if not raw_wid:
|
||||
raw_wid = str(entry.sAMAccountName or '')
|
||||
if not raw_wid:
|
||||
continue # skip entries with no identifier
|
||||
|
||||
# userAccountControl bit 2 = disabled account
|
||||
uac = 0
|
||||
try:
|
||||
uac = int(str(entry.userAccountControl or 0))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
is_active = not bool(uac & 2)
|
||||
|
||||
users.append({
|
||||
'windows_id': raw_wid.strip(),
|
||||
'first_name': str(entry.givenName or '').strip(),
|
||||
'last_name': str(entry.sn or '').strip(),
|
||||
'email': str(entry.mail or '').strip(),
|
||||
'department': str(entry.department or '').strip(),
|
||||
'job_title': str(entry.title or '').strip(),
|
||||
'phone': str(entry.telephoneNumber or '').strip(),
|
||||
'location': str(entry.physicalDeliveryOfficeName or '').strip(),
|
||||
'ldap_dn': str(entry.distinguishedName or '').strip(),
|
||||
'is_active': is_active,
|
||||
})
|
||||
|
||||
conn.unbind()
|
||||
return users
|
||||
|
||||
def test_connection(self):
|
||||
"""Returns True if a bind succeeds, raises on failure."""
|
||||
conn = self._connect()
|
||||
conn.unbind()
|
||||
return True
|
||||
238
app/services/pdf_service.py
Normal file
238
app/services/pdf_service.py
Normal file
@@ -0,0 +1,238 @@
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
|
||||
from reportlab.platypus import (
|
||||
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable
|
||||
)
|
||||
|
||||
|
||||
def _styles():
|
||||
base = getSampleStyleSheet()
|
||||
custom = {
|
||||
'title': ParagraphStyle(
|
||||
'DocTitle', parent=base['Title'],
|
||||
fontSize=16, spaceAfter=6, alignment=TA_CENTER, textColor=colors.HexColor('#1a3a5c'),
|
||||
),
|
||||
'subtitle': ParagraphStyle(
|
||||
'Subtitle', parent=base['Normal'],
|
||||
fontSize=10, spaceAfter=4, alignment=TA_CENTER, textColor=colors.HexColor('#555555'),
|
||||
),
|
||||
'section': ParagraphStyle(
|
||||
'Section', parent=base['Heading2'],
|
||||
fontSize=11, spaceBefore=12, spaceAfter=4,
|
||||
textColor=colors.HexColor('#1a3a5c'), borderPad=2,
|
||||
),
|
||||
'normal': base['Normal'],
|
||||
'small': ParagraphStyle(
|
||||
'Small', parent=base['Normal'], fontSize=8, textColor=colors.grey,
|
||||
),
|
||||
'footer': ParagraphStyle(
|
||||
'Footer', parent=base['Normal'],
|
||||
fontSize=8, alignment=TA_CENTER, textColor=colors.grey,
|
||||
),
|
||||
'signature_label': ParagraphStyle(
|
||||
'SigLabel', parent=base['Normal'], fontSize=9, alignment=TA_CENTER,
|
||||
),
|
||||
'right': ParagraphStyle(
|
||||
'Right', parent=base['Normal'], alignment=TA_RIGHT,
|
||||
),
|
||||
}
|
||||
return custom
|
||||
|
||||
|
||||
def _header_table(company_name, company_address, doc_type_label, doc_id, created_at, styles):
|
||||
left_data = [
|
||||
[Paragraph(f'<b>{company_name}</b>', styles['normal'])],
|
||||
[Paragraph(company_address or '', styles['small'])],
|
||||
]
|
||||
right_data = [
|
||||
[Paragraph(f'<b>{doc_type_label}</b>', styles['right'])],
|
||||
[Paragraph(f'Doc #: {doc_id}', styles['right'])],
|
||||
[Paragraph(f'Date: {created_at.strftime("%d/%m/%Y") if created_at else ""}', styles['right'])],
|
||||
]
|
||||
table = Table(
|
||||
[[Table(left_data, colWidths=[9 * cm]), Table(right_data, colWidths=[8 * cm])]],
|
||||
colWidths=[9 * cm, 8 * cm],
|
||||
)
|
||||
table.setStyle(TableStyle([('VALIGN', (0, 0), (-1, -1), 'TOP')]))
|
||||
return table
|
||||
|
||||
|
||||
def _field_table(rows, styles):
|
||||
"""rows: list of (label, value) tuples."""
|
||||
data = [[Paragraph(f'<b>{label}</b>', styles['normal']), Paragraph(str(value or '—'), styles['normal'])]
|
||||
for label, value in rows]
|
||||
t = Table(data, colWidths=[5 * cm, 12 * cm])
|
||||
t.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (0, -1), colors.HexColor('#eaf0f8')),
|
||||
('GRID', (0, 0), (-1, -1), 0.4, colors.HexColor('#cccccc')),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 6),
|
||||
]))
|
||||
return t
|
||||
|
||||
|
||||
def _signature_block(styles):
|
||||
data = [
|
||||
[Paragraph('Issued by (IT Dept.)', styles['signature_label']),
|
||||
Paragraph('Received by (Employee)', styles['signature_label'])],
|
||||
[Spacer(1, 1.5 * cm), Spacer(1, 1.5 * cm)],
|
||||
[HRFlowable(width='95%'), HRFlowable(width='95%')],
|
||||
[Paragraph('Name / Signature / Date', styles['signature_label']),
|
||||
Paragraph('Name / Signature / Date', styles['signature_label'])],
|
||||
]
|
||||
t = Table(data, colWidths=[8.5 * cm, 8.5 * cm])
|
||||
t.setStyle(TableStyle([
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||
]))
|
||||
return t
|
||||
|
||||
|
||||
def generate_paperwork_pdf(doc, app):
|
||||
"""
|
||||
Generate a PDF for a Paperwork document and save it to PDF_FOLDER.
|
||||
Returns the filename (not the full path).
|
||||
"""
|
||||
pdf_dir = os.path.join(app.root_path, '..', app.config['PDF_FOLDER'])
|
||||
os.makedirs(pdf_dir, exist_ok=True)
|
||||
|
||||
filename = f'doc_{doc.id}_{datetime.utcnow().strftime("%Y%m%d_%H%M%S")}.pdf'
|
||||
filepath = os.path.join(pdf_dir, filename)
|
||||
|
||||
page_doc = SimpleDocTemplate(
|
||||
filepath, pagesize=A4,
|
||||
rightMargin=2 * cm, leftMargin=2 * cm,
|
||||
topMargin=2 * cm, bottomMargin=2.5 * cm,
|
||||
title=doc.title,
|
||||
)
|
||||
|
||||
styles = _styles()
|
||||
company_name = app.config.get('COMPANY_NAME', '')
|
||||
company_address = app.config.get('COMPANY_ADDRESS', '')
|
||||
|
||||
user = doc.user
|
||||
asset = doc.asset
|
||||
assignment = doc.assignment
|
||||
|
||||
# Load extra template fields
|
||||
extra_fields = {}
|
||||
if doc.template_data:
|
||||
try:
|
||||
raw = json.loads(doc.template_data)
|
||||
extra_fields = {k.replace('td_', '').replace('_', ' ').title(): v
|
||||
for k, v in raw.items()}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
story = []
|
||||
|
||||
# ── Header ──────────────────────────────────────────────────────────────
|
||||
story.append(_header_table(company_name, company_address,
|
||||
doc.doc_type_label, doc.id, doc.created_at, styles))
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
story.append(HRFlowable(width='100%', thickness=1.5, color=colors.HexColor('#1a3a5c')))
|
||||
story.append(Spacer(1, 0.4 * cm))
|
||||
|
||||
# ── Title ────────────────────────────────────────────────────────────────
|
||||
story.append(Paragraph(doc.title, styles['title']))
|
||||
story.append(Spacer(1, 0.5 * cm))
|
||||
|
||||
# ── User section ─────────────────────────────────────────────────────────
|
||||
story.append(Paragraph('Employee Information', styles['section']))
|
||||
user_rows = [
|
||||
('Windows ID', user.windows_id),
|
||||
('Full Name', user.display_name),
|
||||
('Email', user.display_email),
|
||||
('Department', user.department or '—'),
|
||||
('Job Title', user.job_title or '—'),
|
||||
('Location', user.location or '—'),
|
||||
]
|
||||
story.append(_field_table(user_rows, styles))
|
||||
story.append(Spacer(1, 0.4 * cm))
|
||||
|
||||
# ── Asset section ─────────────────────────────────────────────────────────
|
||||
if asset:
|
||||
story.append(Paragraph('Asset Information', styles['section']))
|
||||
asset_rows = [
|
||||
('Asset Type', asset.asset_type),
|
||||
('Brand / Model', f'{asset.brand or ""} {asset.model or ""}'.strip() or '—'),
|
||||
('Serial Number', asset.serial_number),
|
||||
('Service Tag', asset.service_tag or '—'),
|
||||
('Asset Tag', asset.asset_tag or '—'),
|
||||
('Operating System', asset.operating_system or '—'),
|
||||
]
|
||||
if asset.ram_gb:
|
||||
asset_rows.append(('RAM', f'{asset.ram_gb} GB'))
|
||||
if asset.storage_gb:
|
||||
asset_rows.append(('Storage', f'{asset.storage_gb} GB'))
|
||||
story.append(_field_table(asset_rows, styles))
|
||||
story.append(Spacer(1, 0.4 * cm))
|
||||
|
||||
# ── Assignment section ────────────────────────────────────────────────────
|
||||
if assignment:
|
||||
story.append(Paragraph('Assignment Details', styles['section']))
|
||||
assign_rows = [
|
||||
('Assigned Date', str(assignment.assigned_date) if assignment.assigned_date else '—'),
|
||||
('Returned Date', str(assignment.returned_date) if assignment.returned_date else 'Currently assigned'),
|
||||
]
|
||||
story.append(_field_table(assign_rows, styles))
|
||||
story.append(Spacer(1, 0.4 * cm))
|
||||
|
||||
# ── Extra / custom fields ─────────────────────────────────────────────────
|
||||
if extra_fields:
|
||||
story.append(Paragraph('Additional Information', styles['section']))
|
||||
story.append(_field_table(list(extra_fields.items()), styles))
|
||||
story.append(Spacer(1, 0.4 * cm))
|
||||
|
||||
# ── Notes ─────────────────────────────────────────────────────────────────
|
||||
if doc.notes:
|
||||
story.append(Paragraph('Notes', styles['section']))
|
||||
story.append(Paragraph(doc.notes, styles['normal']))
|
||||
story.append(Spacer(1, 0.4 * cm))
|
||||
|
||||
# Type-specific clauses
|
||||
if doc.document_type == 'assignment':
|
||||
story.append(Paragraph('Terms & Conditions', styles['section']))
|
||||
clause = (
|
||||
'By signing below the employee acknowledges receipt of the above equipment in good '
|
||||
'working condition and agrees to: (1) use it solely for company business, '
|
||||
'(2) report any damage or loss immediately to the IT department, and '
|
||||
'(3) return it upon request or at the end of employment.'
|
||||
)
|
||||
story.append(Paragraph(clause, styles['normal']))
|
||||
story.append(Spacer(1, 0.4 * cm))
|
||||
|
||||
if doc.document_type == 'return':
|
||||
story.append(Paragraph('Return Confirmation', styles['section']))
|
||||
clause = (
|
||||
'By signing below both parties confirm that the above equipment has been returned '
|
||||
'to the IT department and has been inspected for completeness and condition.'
|
||||
)
|
||||
story.append(Paragraph(clause, styles['normal']))
|
||||
story.append(Spacer(1, 0.4 * cm))
|
||||
|
||||
# ── Signatures ────────────────────────────────────────────────────────────
|
||||
story.append(Paragraph('Signatures', styles['section']))
|
||||
story.append(Spacer(1, 0.3 * cm))
|
||||
story.append(_signature_block(styles))
|
||||
story.append(Spacer(1, 0.5 * cm))
|
||||
|
||||
# ── Footer ────────────────────────────────────────────────────────────────
|
||||
story.append(HRFlowable(width='100%', thickness=0.5, color=colors.grey))
|
||||
story.append(Spacer(1, 0.2 * cm))
|
||||
story.append(Paragraph(
|
||||
f'Generated by IT Asset Management System · {datetime.utcnow().strftime("%d/%m/%Y %H:%M")} UTC',
|
||||
styles['footer'],
|
||||
))
|
||||
|
||||
page_doc.build(story)
|
||||
return filename
|
||||
221
app/services/template_service.py
Normal file
221
app/services/template_service.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Template service: fill Word (.docx) templates and generate output files.
|
||||
|
||||
Variables available in templates (use {{ variable_name }} syntax):
|
||||
|
||||
User:
|
||||
{{ user_name }} Full name (or [MASKED] after PII erasure)
|
||||
{{ user_email }}
|
||||
{{ user_phone }}
|
||||
{{ user_department }}
|
||||
{{ user_job_title }}
|
||||
{{ user_location }}
|
||||
{{ user_windows_id }} Always present — survives masking
|
||||
{{ user_employee_id }} Same as windows_id (alias)
|
||||
|
||||
Asset:
|
||||
{{ asset_serial }} Serial number
|
||||
{{ asset_service_tag }}
|
||||
{{ asset_tag }} Internal asset tag
|
||||
{{ asset_brand }}
|
||||
{{ asset_model }}
|
||||
{{ asset_type }} e.g. Laptop / Desktop
|
||||
{{ asset_os }}
|
||||
{{ asset_warranty_expiry }}
|
||||
{{ asset_location }}
|
||||
|
||||
Assignment:
|
||||
{{ assignment_date }}
|
||||
{{ assignment_id }}
|
||||
{{ return_date }}
|
||||
|
||||
Document / company:
|
||||
{{ document_date }} Date of generation (dd/mm/yyyy)
|
||||
{{ document_number }} Paperwork record ID
|
||||
{{ company_name }}
|
||||
{{ company_address }}
|
||||
{{ admin_name }} Logged-in admin who generated the doc
|
||||
|
||||
PII masking:
|
||||
When User.mask() is called, all Paperwork records that were generated
|
||||
from a template have their merge_vars updated (PII keys replaced with
|
||||
[MASKED]) and the .docx/.pdf files are regenerated.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from docxtpl import DocxTemplate
|
||||
from flask import current_app
|
||||
|
||||
# PII variable keys — these are blanked out on user mask
|
||||
PII_VARS = {'user_name', 'user_email', 'user_phone'}
|
||||
|
||||
# Variables that survive masking (non-PII)
|
||||
SAFE_VARS = {
|
||||
'user_department', 'user_job_title', 'user_location',
|
||||
'user_windows_id', 'user_employee_id',
|
||||
}
|
||||
|
||||
|
||||
def build_context(user, asset=None, assignment=None, paperwork=None, app=None):
|
||||
"""
|
||||
Build the Jinja2 context dict from ORM objects.
|
||||
Used both at generation time and when re-rendering after masking.
|
||||
"""
|
||||
if app is None:
|
||||
app = current_app._get_current_object()
|
||||
|
||||
ctx = {
|
||||
# User
|
||||
'user_name': user.display_name,
|
||||
'user_email': user.display_email,
|
||||
'user_phone': user.display_phone,
|
||||
'user_department': user.department or '',
|
||||
'user_job_title': user.job_title or '',
|
||||
'user_location': user.location or '',
|
||||
'user_windows_id': user.windows_id or '',
|
||||
'user_employee_id': user.windows_id or '',
|
||||
|
||||
# Asset
|
||||
'asset_serial': '',
|
||||
'asset_service_tag': '',
|
||||
'asset_tag': '',
|
||||
'asset_brand': '',
|
||||
'asset_model': '',
|
||||
'asset_type': '',
|
||||
'asset_os': '',
|
||||
'asset_warranty_expiry': '',
|
||||
'asset_location': '',
|
||||
|
||||
# Assignment
|
||||
'assignment_date': '',
|
||||
'assignment_id': '',
|
||||
'return_date': '',
|
||||
|
||||
# Document/company
|
||||
'document_date': datetime.utcnow().strftime('%d/%m/%Y'),
|
||||
'document_number': str(paperwork.id) if paperwork else '',
|
||||
'company_name': app.config.get('COMPANY_NAME', ''),
|
||||
'company_address': app.config.get('COMPANY_ADDRESS', ''),
|
||||
'admin_name': '',
|
||||
}
|
||||
|
||||
if asset:
|
||||
ctx.update({
|
||||
'asset_serial': asset.serial_number or '',
|
||||
'asset_service_tag': asset.service_tag or '',
|
||||
'asset_tag': asset.asset_tag or '',
|
||||
'asset_brand': asset.brand or '',
|
||||
'asset_model': asset.model or '',
|
||||
'asset_type': asset.asset_type or '',
|
||||
'asset_os': asset.operating_system or '',
|
||||
'asset_warranty_expiry': (asset.warranty_expiry.strftime('%d/%m/%Y')
|
||||
if asset.warranty_expiry else ''),
|
||||
'asset_location': asset.location or '',
|
||||
})
|
||||
|
||||
if assignment:
|
||||
ctx['assignment_date'] = (assignment.assigned_date.strftime('%d/%m/%Y')
|
||||
if assignment.assigned_date else '')
|
||||
ctx['assignment_id'] = str(assignment.id)
|
||||
ctx['return_date'] = (assignment.returned_date.strftime('%d/%m/%Y')
|
||||
if assignment.returned_date else '')
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
def _docx_path(app, filename):
|
||||
folder = os.path.join(app.root_path, '..', app.config.get('DOCX_FOLDER', 'docx_output'))
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
return os.path.join(folder, filename)
|
||||
|
||||
|
||||
def _template_path(app, filename):
|
||||
folder = os.path.join(app.root_path, '..', app.config.get('TEMPLATE_FOLDER', 'doc_templates'))
|
||||
return os.path.join(folder, filename)
|
||||
|
||||
|
||||
def extract_variables(template_path):
|
||||
"""
|
||||
Parse a .docx file and return all unique Jinja2 variable names found
|
||||
in the document text ({{ var_name }} syntax).
|
||||
"""
|
||||
try:
|
||||
tpl = DocxTemplate(template_path)
|
||||
return sorted(tpl.get_undeclared_template_variables())
|
||||
except Exception:
|
||||
# Fallback: open as zip and scan XML for {{ ... }}
|
||||
import zipfile
|
||||
vars_found = set()
|
||||
try:
|
||||
with zipfile.ZipFile(template_path, 'r') as z:
|
||||
for name in z.namelist():
|
||||
if name.endswith('.xml'):
|
||||
content = z.read(name).decode('utf-8', errors='ignore')
|
||||
vars_found.update(re.findall(r'\{\{\s*(\w+)\s*\}\}', content))
|
||||
except Exception:
|
||||
pass
|
||||
return sorted(vars_found)
|
||||
|
||||
|
||||
def render_template_to_docx(template_filepath, context, output_filename):
|
||||
"""
|
||||
Fill a .docx template with context values and save to DOCX_FOLDER.
|
||||
Returns the saved filename.
|
||||
"""
|
||||
app = current_app._get_current_object()
|
||||
tpl = DocxTemplate(template_filepath)
|
||||
tpl.render(context)
|
||||
out_path = _docx_path(app, output_filename)
|
||||
tpl.save(out_path)
|
||||
return output_filename
|
||||
|
||||
|
||||
def regenerate_for_paperwork(paperwork, app=None):
|
||||
"""
|
||||
Re-render the .docx for an existing Paperwork record using its stored
|
||||
merge_vars. Called after PII masking to overwrite files with sanitised data.
|
||||
|
||||
If the record was generated from a template, regenerates .docx.
|
||||
Also regenerates the PDF via pdf_service if a PDF exists.
|
||||
|
||||
Returns (docx_filename, pdf_filename) — either may be None.
|
||||
"""
|
||||
from app.services.pdf_service import generate_paperwork_pdf
|
||||
|
||||
if app is None:
|
||||
app = current_app._get_current_object()
|
||||
|
||||
docx_out = None
|
||||
pdf_out = None
|
||||
|
||||
if paperwork.template_id and paperwork.template:
|
||||
tpl_file = _template_path(app, paperwork.template.filename)
|
||||
if os.path.exists(tpl_file):
|
||||
ctx = paperwork.get_merge_vars()
|
||||
out_name = paperwork.docx_filename or f'doc_{paperwork.id}.docx'
|
||||
try:
|
||||
render_template_to_docx(tpl_file, ctx, out_name)
|
||||
docx_out = out_name
|
||||
except Exception as exc:
|
||||
app.logger.error('Template re-render failed for paperwork %s: %s', paperwork.id, exc)
|
||||
|
||||
# Always regenerate the PDF (uses pdf_service, reads from Paperwork + User model)
|
||||
if paperwork.pdf_filename:
|
||||
try:
|
||||
pdf_out = generate_paperwork_pdf(paperwork, app)
|
||||
except Exception as exc:
|
||||
app.logger.error('PDF re-render failed for paperwork %s: %s', paperwork.id, exc)
|
||||
|
||||
return docx_out, pdf_out
|
||||
|
||||
|
||||
def mask_variables(merge_vars: dict) -> dict:
|
||||
"""Return a copy of merge_vars with PII values replaced by [MASKED]."""
|
||||
masked = dict(merge_vars)
|
||||
for key in PII_VARS:
|
||||
if key in masked:
|
||||
masked[key] = '[MASKED]'
|
||||
return masked
|
||||
453
app/templates/assets/detail.html
Normal file
453
app/templates/assets/detail.html
Normal file
@@ -0,0 +1,453 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ asset.serial_number }} – 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('assets.index') }}">Assets</a></li>
|
||||
<li class="breadcrumb-item active">{{ asset.serial_number }}</li>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header d-flex align-items-center justify-content-between mb-4">
|
||||
<h1>
|
||||
<i class="bi bi-laptop me-2"></i>{{ asset.brand or '' }} {{ asset.model or '' }}
|
||||
<span class="badge badge-{{ asset.status }} fs-6 align-middle ms-2">{{ asset.status | title }}</span>
|
||||
</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('assets.edit', asset_id=asset.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
{% if asset.status == 'available' %}
|
||||
<a href="{{ url_for('assignments.create', asset_id=asset.id) }}" class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-plus-circle me-1"></i>Assign
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if asset.current_user %}
|
||||
<a href="{{ url_for('paperwork.create', asset_id=asset.id, user_id=asset.current_user.id) }}"
|
||||
class="btn btn-sm btn-outline-info">
|
||||
<i class="bi bi-file-earmark-plus me-1"></i>New Doc
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Asset details -->
|
||||
<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>Asset Details
|
||||
</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-secondary">{{ asset.asset_type }}</span></dd>
|
||||
|
||||
<dt class="col-5 text-muted small">Brand</dt>
|
||||
<dd class="col-7">{{ asset.brand or '—' }}</dd>
|
||||
|
||||
<dt class="col-5 text-muted small">Model</dt>
|
||||
<dd class="col-7">{{ asset.model or '—' }}</dd>
|
||||
|
||||
<dt class="col-5 text-muted small">Serial No.</dt>
|
||||
<dd class="col-7"><code>{{ asset.serial_number }}</code></dd>
|
||||
|
||||
<dt class="col-5 text-muted small">Service Tag</dt>
|
||||
<dd class="col-7"><code>{{ asset.service_tag or '—' }}</code></dd>
|
||||
|
||||
<dt class="col-5 text-muted small">Asset Tag</dt>
|
||||
<dd class="col-7">{{ asset.asset_tag or '—' }}</dd>
|
||||
|
||||
<dt class="col-5 text-muted small">OS</dt>
|
||||
<dd class="col-7">{{ asset.operating_system or '—' }}</dd>
|
||||
|
||||
{% if asset.processor %}
|
||||
<dt class="col-5 text-muted small">CPU</dt>
|
||||
<dd class="col-7">{{ asset.processor }}</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if asset.ram_gb %}
|
||||
<dt class="col-5 text-muted small">RAM</dt>
|
||||
<dd class="col-7">{{ asset.ram_gb }} GB</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if asset.storage_gb %}
|
||||
<dt class="col-5 text-muted small">Storage</dt>
|
||||
<dd class="col-7">{{ asset.storage_gb }} GB</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if asset.mac_address %}
|
||||
<dt class="col-5 text-muted small">MAC</dt>
|
||||
<dd class="col-7"><code>{{ asset.mac_address }}</code></dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white fw-semibold py-3">
|
||||
<i class="bi bi-receipt me-2 text-primary"></i>Procurement
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-5 text-muted small">Purchased</dt>
|
||||
<dd class="col-7">{{ asset.purchase_date.strftime('%d/%m/%Y') if asset.purchase_date else '—' }}</dd>
|
||||
|
||||
<dt class="col-5 text-muted small">Warranty</dt>
|
||||
<dd class="col-7">{{ asset.warranty_expiry.strftime('%d/%m/%Y') if asset.warranty_expiry else '—' }}</dd>
|
||||
|
||||
<dt class="col-5 text-muted small">Price</dt>
|
||||
<dd class="col-7">{{ '%.2f'|format(asset.purchase_price) if asset.purchase_price else '—' }}</dd>
|
||||
|
||||
<dt class="col-5 text-muted small">Supplier</dt>
|
||||
<dd class="col-7">{{ asset.supplier or '—' }}</dd>
|
||||
|
||||
<dt class="col-5 text-muted small">PO #</dt>
|
||||
<dd class="col-7">{{ asset.po_number or '—' }}</dd>
|
||||
|
||||
<dt class="col-5 text-muted small">Location</dt>
|
||||
<dd class="col-7">{{ asset.location or '—' }}</dd>
|
||||
</dl>
|
||||
{% if asset.notes %}
|
||||
<hr class="my-2">
|
||||
<p class="small text-muted mb-0">{{ asset.notes }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History + Docs -->
|
||||
<div class="col-md-8">
|
||||
<!-- Assignment history -->
|
||||
<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-clock-history me-2 text-primary"></i>Assignment History</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Windows ID</th>
|
||||
<th>From</th>
|
||||
<th>Until</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in history %}
|
||||
<tr {% if a.user.is_masked %}class="masked-row"{% endif %}>
|
||||
<td>{{ a.user.display_name }}</td>
|
||||
<td><code>{{ a.user.windows_id }}</code></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 '—' }}</td>
|
||||
<td>
|
||||
{% if a.is_active %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Returned</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if a.is_active %}
|
||||
<button class="btn btn-sm btn-outline-warning py-0 px-2"
|
||||
data-bs-toggle="modal" data-bs-target="#returnModal{{ a.id }}">
|
||||
<i class="bi bi-arrow-return-left"></i> Return
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="text-center text-muted py-3">No assignment history.</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 asset.current_user %}
|
||||
<a href="{{ url_for('paperwork.create', asset_id=asset.id, user_id=asset.current_user.id) }}"
|
||||
class="btn 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>User</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.user.display_name }}</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>
|
||||
|
||||
{% if asset.asset_type in ('Laptop', 'Desktop') %}
|
||||
<!-- Compliance card — Laptop / Desktop only -->
|
||||
<div class="card border-0 shadow-sm mt-3">
|
||||
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-shield-check me-2 text-success"></i>IT Compliance & Inventory</span>
|
||||
<button class="btn btn-sm btn-outline-primary" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#complianceEdit">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Read-only summary -->
|
||||
<div class="card-body pb-2">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4 col-6">
|
||||
<div class="small text-muted">Inventory #</div>
|
||||
<div class="fw-semibold">{{ asset.inventory_number or '—' }}</div>
|
||||
</div>
|
||||
<div class="col-md-4 col-6">
|
||||
<div class="small text-muted">AD Device Name</div>
|
||||
<div class="fw-semibold"><code>{{ asset.ad_device_name or '—' }}</code></div>
|
||||
</div>
|
||||
<div class="col-md-4 col-12">
|
||||
<div class="small text-muted">Location Note</div>
|
||||
<div class="fw-semibold">{{ asset.location_note or '—' }}</div>
|
||||
</div>
|
||||
<div class="col-md-4 col-4 mt-2">
|
||||
{% if asset.encryption_checked %}
|
||||
<span class="badge bg-success"><i class="bi bi-lock-fill me-1"></i>Encrypted</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger"><i class="bi bi-lock me-1"></i>Not Encrypted</span>
|
||||
{% endif %}
|
||||
{% if asset.encryption_checked_by %}
|
||||
<div class="small text-muted mt-1">
|
||||
by <strong>{{ asset.encryption_checked_by.username }}</strong>
|
||||
{% if asset.encryption_checked_at %}
|
||||
— {{ asset.encryption_checked_at.strftime('%d/%m/%Y %H:%M') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4 col-4 mt-2">
|
||||
{% if asset.backup_checked %}
|
||||
<span class="badge bg-success"><i class="bi bi-cloud-check me-1"></i>Backup OK</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-cloud me-1"></i>No Backup</span>
|
||||
{% endif %}
|
||||
{% if asset.backup_checked_by %}
|
||||
<div class="small text-muted mt-1">
|
||||
by <strong>{{ asset.backup_checked_by.username }}</strong>
|
||||
{% if asset.backup_checked_at %}
|
||||
— {{ asset.backup_checked_at.strftime('%d/%m/%Y %H:%M') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4 col-4 mt-2">
|
||||
{% if asset.hr_notified %}
|
||||
<span class="badge bg-success"><i class="bi bi-person-check me-1"></i>HR Notified</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary"><i class="bi bi-person me-1"></i>HR Pending</span>
|
||||
{% endif %}
|
||||
{% if asset.hr_notified_by %}
|
||||
<div class="small text-muted mt-1">
|
||||
by <strong>{{ asset.hr_notified_by.username }}</strong>
|
||||
{% if asset.hr_notified_at %}
|
||||
— {{ asset.hr_notified_at.strftime('%d/%m/%Y %H:%M') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible edit form -->
|
||||
<div class="collapse" id="complianceEdit">
|
||||
<div class="card-body border-top pt-3">
|
||||
<form method="POST" action="{{ url_for('assets.update_compliance', asset_id=asset.id) }}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold">Inventory Number</label>
|
||||
<input type="text" name="inventory_number" class="form-control form-control-sm"
|
||||
value="{{ asset.inventory_number or '' }}" placeholder="INV-0001">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold">AD Device Name</label>
|
||||
<input type="text" name="ad_device_name" class="form-control form-control-sm"
|
||||
value="{{ asset.ad_device_name or '' }}" placeholder="DESKTOP-AB1234">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold">Current User in AD</label>
|
||||
{% if asset.current_user %}
|
||||
<div class="form-control form-control-sm bg-light text-muted">
|
||||
{{ asset.current_user.display_name }} ({{ asset.current_user.windows_id }})
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="form-control form-control-sm bg-light text-muted">Not assigned</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold">Location Note</label>
|
||||
<textarea name="location_note" class="form-control form-control-sm" rows="2"
|
||||
placeholder="e.g. Building A, Room 102, Desk 5">{{ asset.location_note or '' }}</textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="d-flex gap-4 flex-wrap mt-1">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="encryption_checked"
|
||||
id="chkEncrypt" value="1"
|
||||
{% if asset.encryption_checked %}checked{% endif %}>
|
||||
<label class="form-check-label fw-semibold" for="chkEncrypt">
|
||||
<i class="bi bi-lock-fill me-1 text-success"></i>Encryption Verified
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="backup_checked"
|
||||
id="chkBackup" value="1"
|
||||
{% if asset.backup_checked %}checked{% endif %}>
|
||||
<label class="form-check-label fw-semibold" for="chkBackup">
|
||||
<i class="bi bi-cloud-check me-1 text-primary"></i>Backup Configured
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="hr_notified"
|
||||
id="chkHR" value="1"
|
||||
{% if asset.hr_notified %}checked{% endif %}>
|
||||
<label class="form-check-label fw-semibold" for="chkHR">
|
||||
<i class="bi bi-person-check me-1 text-warning"></i>HR Send / Notified
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold" for="compliance_notes">
|
||||
<i class="bi bi-chat-left-text me-1 text-secondary"></i>Note
|
||||
<span class="text-muted fw-normal">(reason for check / uncheck — saved with each change)</span>
|
||||
</label>
|
||||
<textarea name="compliance_notes" id="compliance_notes"
|
||||
class="form-control form-control-sm" rows="2"
|
||||
placeholder="e.g. BitLocker verified on site visit, backup re-enabled after restore…"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-sm btn-success">
|
||||
<i class="bi bi-check2 me-1"></i>Save Changes
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
data-bs-toggle="collapse" data-bs-target="#complianceEdit">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compliance change history -->
|
||||
{% if compliance_log %}
|
||||
<div class="card border-0 shadow-sm mt-2">
|
||||
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
|
||||
<!-- Compliance per-check history -->
|
||||
{% if check_history %}
|
||||
<div class="card border-0 shadow-sm mt-2">
|
||||
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-shield-exclamation me-2 text-secondary"></i>Compliance Check History</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#checkHistory">
|
||||
Show / Hide
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse" id="checkHistory">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0 small">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:160px">Date & Time</th>
|
||||
<th style="width:140px">Check</th>
|
||||
<th style="width:90px">Result</th>
|
||||
<th style="width:130px">Performed by</th>
|
||||
<th>Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in check_history %}
|
||||
<tr>
|
||||
<td class="text-nowrap">{{ entry.performed_at.strftime('%d/%m/%Y %H:%M') }}</td>
|
||||
<td>{{ entry.check_type_label }}</td>
|
||||
<td>
|
||||
{% if entry.checked %}
|
||||
<span class="badge bg-success">Verified</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Cleared</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if entry.performed_by %}
|
||||
<span class="fw-semibold">{{ entry.performed_by.username }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ entry.notes or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}{# end asset_type in Laptop/Desktop #}
|
||||
|
||||
<!-- Return modals -->
|
||||
{% for a in history %}{% if a.is_active %}
|
||||
<div class="modal fade" id="returnModal{{ a.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Return Asset</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('assignments.return_asset', assignment_id=a.id) }}">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Return Date</label>
|
||||
<input type="date" name="returned_date" class="form-control"
|
||||
value="{{ today_date }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Notes (optional)</label>
|
||||
<textarea name="return_notes" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-warning">Confirm Return</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}{% endfor %}
|
||||
{% endblock %}
|
||||
160
app/templates/assets/form.html
Normal file
160
app/templates/assets/form.html
Normal file
@@ -0,0 +1,160 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ 'Edit Asset' if asset else 'New Asset' }} – 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('assets.index') }}">Assets</a></li>
|
||||
<li class="breadcrumb-item active">{{ 'Edit' if asset else 'New Asset' }}</li>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header mb-4">
|
||||
<h1><i class="bi bi-laptop me-2"></i>{{ 'Edit Asset' if asset else 'Add Asset' }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm" style="max-width:800px;">
|
||||
<div class="card-body">
|
||||
{% if not asset and prefill and prefill.service_tag %}
|
||||
<div class="alert alert-info py-2 mb-3 small">
|
||||
<i class="bi bi-cloud-check me-1"></i>
|
||||
Pre-filled from Dell service tag <strong>{{ prefill.service_tag }}</strong>. Review the details below before saving.
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('assets.edit', asset_id=asset.id) if asset else url_for('assets.create') }}">
|
||||
|
||||
<h6 class="text-uppercase text-muted mb-3 small">Identifiers</h6>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Serial Number <span class="text-danger">*</span></label>
|
||||
<input type="text" name="serial_number" class="form-control"
|
||||
value="{{ asset.serial_number if asset else (prefill.serial_number if prefill else '') }}" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Service Tag</label>
|
||||
<input type="text" name="service_tag" class="form-control"
|
||||
value="{{ asset.service_tag or '' if asset else (prefill.service_tag if prefill else '') }}"
|
||||
placeholder="e.g. Dell service tag">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Asset Tag</label>
|
||||
<input type="text" name="asset_tag" class="form-control"
|
||||
value="{{ asset.asset_tag or '' if asset else '' }}"
|
||||
placeholder="Internal barcode/tag">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="text-uppercase text-muted mb-3 small">Classification</h6>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Asset Type <span class="text-danger">*</span></label>
|
||||
<select name="asset_type" class="form-select" required>
|
||||
{% for t in asset_types %}
|
||||
<option value="{{ t }}"
|
||||
{% if asset and asset.asset_type == t %}selected
|
||||
{% elif not asset and prefill and prefill.asset_type == t %}selected
|
||||
{% elif not asset and not prefill and t == 'Laptop' %}selected
|
||||
{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Brand</label>
|
||||
<input type="text" name="brand" class="form-control"
|
||||
value="{{ asset.brand or '' if asset else (prefill.brand if prefill else '') }}"
|
||||
placeholder="e.g. Dell, Lenovo, HP">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Model</label>
|
||||
<input type="text" name="model" class="form-control"
|
||||
value="{{ asset.model or '' if asset else (prefill.model if prefill else '') }}"
|
||||
placeholder="e.g. Latitude 5540">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="text-uppercase text-muted mb-3 small">Technical Specs</h6>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Processor</label>
|
||||
<input type="text" name="processor" class="form-control"
|
||||
value="{{ asset.processor or '' if asset else '' }}"
|
||||
placeholder="e.g. Intel Core i5-1345U">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">RAM (GB)</label>
|
||||
<input type="number" name="ram_gb" class="form-control" min="0"
|
||||
value="{{ asset.ram_gb or '' if asset else '' }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Storage (GB)</label>
|
||||
<input type="number" name="storage_gb" class="form-control" min="0"
|
||||
value="{{ asset.storage_gb or '' if asset else '' }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Operating System</label>
|
||||
<input type="text" name="operating_system" class="form-control"
|
||||
value="{{ asset.operating_system or '' if asset else (prefill.operating_system if prefill else 'Windows 11 Pro') }}"
|
||||
placeholder="e.g. Windows 11 Pro">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">MAC Address</label>
|
||||
<input type="text" name="mac_address" class="form-control"
|
||||
value="{{ asset.mac_address or '' if asset else '' }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Location</label>
|
||||
<input type="text" name="location" class="form-control"
|
||||
value="{{ asset.location or '' if asset else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h6 class="text-uppercase text-muted mb-3 small">Procurement</h6>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Purchase Date</label>
|
||||
<input type="date" name="purchase_date" class="form-control"
|
||||
value="{{ asset.purchase_date.isoformat() if asset and asset.purchase_date else (prefill.purchase_date if prefill else '') }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Warranty Expiry</label>
|
||||
<input type="date" name="warranty_expiry" class="form-control"
|
||||
value="{{ asset.warranty_expiry.isoformat() if asset and asset.warranty_expiry else (prefill.warranty_expiry if prefill else '') }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Purchase Price</label>
|
||||
<input type="number" name="purchase_price" class="form-control" step="0.01" min="0"
|
||||
value="{{ asset.purchase_price or '' if asset else '' }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Status</label>
|
||||
<select name="status" class="form-select">
|
||||
{% for val, label in asset_statuses %}
|
||||
<option value="{{ val }}" {% if asset and asset.status == val %}selected{% elif not asset and val == 'available' %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Supplier</label>
|
||||
<input type="text" name="supplier" class="form-control"
|
||||
value="{{ asset.supplier or '' if asset else '' }}">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">PO Number</label>
|
||||
<input type="text" name="po_number" class="form-control"
|
||||
value="{{ asset.po_number or '' if asset else '' }}">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea name="notes" class="form-control" rows="2">{{ asset.notes or '' if asset else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 asset else 'Create Asset' }}
|
||||
</button>
|
||||
<a href="{{ url_for('assets.detail', asset_id=asset.id) if asset else url_for('assets.index') }}"
|
||||
class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
300
app/templates/assets/index.html
Normal file
300
app/templates/assets/index.html
Normal file
@@ -0,0 +1,300 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Assets – IT Asset Management{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||
<li class="breadcrumb-item active">Assets</li>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header d-flex align-items-center justify-content-between mb-4">
|
||||
<h1><i class="bi bi-laptop me-2"></i>Assets</h1>
|
||||
<a href="{{ url_for('assets.create') }}" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-circle me-1"></i>Add Asset
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Dell Service Tag Quick Import -->
|
||||
<div class="card border-0 shadow-sm mb-4" id="dellLookupCard">
|
||||
<div class="card-body py-3">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="fw-semibold text-nowrap">
|
||||
<i class="bi bi-search me-1 text-primary"></i>Dell Quick Import
|
||||
</span>
|
||||
<span class="text-muted small text-nowrap">Enter a service tag to open the asset form pre-filled:</span>
|
||||
<div class="input-group input-group-sm" style="max-width:220px;">
|
||||
<input type="text" id="dellTagInput" class="form-control text-uppercase"
|
||||
placeholder="e.g. ABC1234" maxlength="20"
|
||||
style="text-transform:uppercase; letter-spacing:.05em;">
|
||||
<button class="btn btn-outline-primary" id="dellLookupBtn" type="button">
|
||||
<i class="bi bi-cloud-download me-1"></i>Lookup
|
||||
</button>
|
||||
</div>
|
||||
<div id="dellLookupSpinner" class="spinner-border spinner-border-sm text-primary d-none" role="status">
|
||||
<span class="visually-hidden">Loading…</span>
|
||||
</div>
|
||||
<span class="text-muted small ms-auto">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Full auto-fill available with a
|
||||
<a href="https://tdm.dell.com" target="_blank" class="text-decoration-none">free Dell TechDirect API key</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Result preview (hidden until data arrives) -->
|
||||
<div id="dellLookupResult" class="mt-3 d-none">
|
||||
<div class="alert mb-2 py-2 d-flex align-items-start gap-3" id="dellResultBody">
|
||||
<i class="bi bi-pc-display-horizontal fs-4 flex-shrink-0 mt-1" id="dellResultIcon"></i>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-semibold mb-1" id="dellResultTitle"></div>
|
||||
<div class="row row-cols-2 row-cols-md-4 g-1 small" id="dellResultMeta"></div>
|
||||
</div>
|
||||
<div class="d-flex flex-column gap-1 flex-shrink-0">
|
||||
<a id="dellCreateBtn" href="#" class="btn btn-sm btn-primary">
|
||||
<i class="bi bi-plus-circle me-1"></i>Create Asset
|
||||
</a>
|
||||
<a id="dellSupportLink" href="#" target="_blank" class="btn btn-sm btn-outline-secondary d-none">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>View on Dell
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error area -->
|
||||
<div id="dellLookupError" class="alert alert-warning mt-2 py-2 d-none small mb-0"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="GET" class="row g-2 mb-3">
|
||||
<div class="col-md-4">
|
||||
<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 SN, service tag, brand, model…" value="{{ q }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="">All statuses</option>
|
||||
{% for val, label in asset_statuses %}
|
||||
<option value="{{ val }}" {% if status_filter == val %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="asset_type" class="form-select form-select-sm">
|
||||
<option value="">All types</option>
|
||||
{% for t in asset_types %}
|
||||
<option value="{{ t }}" {% if type_filter == t %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Filter</button>
|
||||
<a href="{{ url_for('assets.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>Type</th>
|
||||
<th>Brand / Model</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Service Tag</th>
|
||||
<th>Status</th>
|
||||
<th>Assigned To</th>
|
||||
<th>Warranty</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in pagination.items %}
|
||||
<tr>
|
||||
<td><span class="badge bg-secondary">{{ a.asset_type }}</span></td>
|
||||
<td>{{ a.brand or '' }} {{ a.model or '' }}</td>
|
||||
<td><code>{{ a.serial_number }}</code></td>
|
||||
<td><code>{{ a.service_tag or '—' }}</code></td>
|
||||
<td>
|
||||
<span class="badge badge-{{ a.status }}">{{ a.status | title }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if a.current_user %}
|
||||
<a href="{{ url_for('users.detail', user_id=a.current_user.id) }}">
|
||||
{{ a.current_user.display_name }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if a.warranty_expiry %}
|
||||
<span class="{% if a.warranty_expiry < today %}text-danger{% else %}text-success{% endif %}">
|
||||
{{ a.warranty_expiry.strftime('%d/%m/%Y') }}
|
||||
</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('assets.detail', asset_id=a.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 assets 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('assets.index', page=pagination.prev_num, q=q, status=status_filter, asset_type=type_filter) }}">‹</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('assets.index', page=p, q=q, status=status_filter, asset_type=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('assets.index', page=pagination.next_num, q=q, status=status_filter, asset_type=type_filter) }}">›</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<script>
|
||||
// make today available for warranty colour
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
const input = document.getElementById('dellTagInput');
|
||||
const btn = document.getElementById('dellLookupBtn');
|
||||
const spinner = document.getElementById('dellLookupSpinner');
|
||||
const result = document.getElementById('dellLookupResult');
|
||||
const errBox = document.getElementById('dellLookupError');
|
||||
const title = document.getElementById('dellResultTitle');
|
||||
const meta = document.getElementById('dellResultMeta');
|
||||
const createBtn = document.getElementById('dellCreateBtn');
|
||||
|
||||
function setLoading(on) {
|
||||
btn.disabled = on;
|
||||
spinner.classList.toggle('d-none', !on);
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
errBox.textContent = msg;
|
||||
errBox.classList.remove('d-none');
|
||||
result.classList.add('d-none');
|
||||
}
|
||||
|
||||
function doLookup() {
|
||||
const tag = input.value.trim().toUpperCase();
|
||||
if (!tag) { input.focus(); return; }
|
||||
|
||||
result.classList.add('d-none');
|
||||
errBox.classList.add('d-none');
|
||||
setLoading(true);
|
||||
|
||||
fetch(`{{ url_for('assets.dell_lookup') }}?tag=${encodeURIComponent(tag)}`)
|
||||
.then(r => r.json().then(d => ({ ok: r.ok, status: r.status, data: d })))
|
||||
.then(({ ok, status, data }) => {
|
||||
setLoading(false);
|
||||
if (!ok) {
|
||||
if (status === 409 && data.existing_id) {
|
||||
errBox.innerHTML = `⚠ ${data.error} — <a href="/assets/${data.existing_id}">View asset</a>`;
|
||||
errBox.classList.remove('d-none');
|
||||
} else {
|
||||
showError(data.error || 'Lookup failed.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Build preview
|
||||
const isPartial = data.source === 'partial';
|
||||
const resultBody = document.getElementById('dellResultBody');
|
||||
const resultIcon = document.getElementById('dellResultIcon');
|
||||
const supportLink = document.getElementById('dellSupportLink');
|
||||
|
||||
resultBody.className = `alert mb-2 py-2 d-flex align-items-start gap-3 ${isPartial ? 'alert-warning' : 'alert-info'}`;
|
||||
resultIcon.className = `bi bi-pc-display-horizontal fs-4 flex-shrink-0 mt-1 ${isPartial ? 'text-warning' : 'text-primary'}`;
|
||||
|
||||
if (isPartial) {
|
||||
// Auto-open Dell's warranty page in a new tab so the user can read model + warranty
|
||||
if (data.support_url) window.open(data.support_url, '_blank', 'noopener');
|
||||
title.innerHTML = `Dell service tag <strong>${data.service_tag}</strong> — Dell’s page opened in a new tab. Copy model & warranty date into the form below.`;
|
||||
meta.innerHTML = `
|
||||
<div><span class="fw-medium">Brand:</span> Dell</div>
|
||||
<div><span class="fw-medium">OS:</span> ${data.operating_system}</div>
|
||||
<div><span class="fw-medium">Model:</span> <em class="text-muted">fill from Dell tab →</em></div>
|
||||
<div><span class="fw-medium">Warranty:</span> <em class="text-muted">fill from Dell tab →</em></div>`;
|
||||
supportLink.href = data.support_url;
|
||||
supportLink.classList.remove('d-none');
|
||||
createBtn.textContent = '';
|
||||
createBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Open Form';
|
||||
} else {
|
||||
title.textContent = `Dell ${data.model || data.service_tag}`;
|
||||
supportLink.href = data.support_url || '#';
|
||||
supportLink.classList.remove('d-none');
|
||||
const fields = [
|
||||
['Type', data.asset_type],
|
||||
['Service Tag', data.service_tag],
|
||||
['Serial', data.serial_number || '—'],
|
||||
['Warranty', data.warranty_expiry || '—'],
|
||||
['Purchased', data.purchase_date || '—'],
|
||||
['OS', data.operating_system],
|
||||
];
|
||||
meta.innerHTML = fields
|
||||
.map(([k, v]) => `<div><span class="fw-medium">${k}:</span> ${v || '—'}</div>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// Build "Create Asset" URL with pre-filled params
|
||||
const params = new URLSearchParams({
|
||||
service_tag: data.service_tag || '',
|
||||
serial_number: data.serial_number || '',
|
||||
brand: data.brand || 'Dell',
|
||||
model: data.model || '',
|
||||
asset_type: data.asset_type || '',
|
||||
operating_system: data.operating_system || '',
|
||||
warranty_expiry: data.warranty_expiry || '',
|
||||
purchase_date: data.purchase_date || '',
|
||||
});
|
||||
createBtn.href = `{{ url_for('assets.create') }}?${params.toString()}`;
|
||||
result.classList.remove('d-none');
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
showError('Network error – could not reach the server.');
|
||||
});
|
||||
}
|
||||
|
||||
btn.addEventListener('click', doLookup);
|
||||
input.addEventListener('keydown', e => { if (e.key === 'Enter') doLookup(); });
|
||||
input.addEventListener('input', () => {
|
||||
input.value = input.value.toUpperCase();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
133
app/templates/assignments/form.html
Normal file
133
app/templates/assignments/form.html
Normal file
@@ -0,0 +1,133 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Assign Asset – 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('assignments.index') }}">Assignments</a></li>
|
||||
<li class="breadcrumb-item active">New Assignment</li>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header mb-4">
|
||||
<h1><i class="bi bi-arrow-left-right me-2"></i>Assign Asset to User</h1>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm" style="max-width:600px;">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('assignments.create') }}">
|
||||
<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="Search by name or Windows ID…"
|
||||
value="" 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>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Asset <span class="text-danger">*</span></label>
|
||||
<input type="hidden" name="asset_id" id="assetId" value="{{ preselect_asset_id or '' }}">
|
||||
<input type="text" id="assetSearch" class="form-control"
|
||||
placeholder="Search by serial number or service tag…"
|
||||
value="" 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>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Assigned Date</label>
|
||||
<input type="date" name="assigned_date" class="form-control" id="assignedDate">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<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-check-lg me-1"></i>Create Assignment
|
||||
</button>
|
||||
<a href="{{ url_for('assignments.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Set today's date as default
|
||||
document.getElementById('assignedDate').value = new Date().toISOString().slice(0,10);
|
||||
|
||||
// ── Generic live-search helper ───────────────────────────────────
|
||||
function liveSearch(inputId, dropdownId, hiddenId, displayId, endpoint, labelField) {
|
||||
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 || item[labelField];
|
||||
// colour disabled assets
|
||||
if (item.status && item.status !== 'available') {
|
||||
a.className += ' text-muted';
|
||||
a.textContent += ` [${item.status}]`;
|
||||
}
|
||||
a.addEventListener('click', () => {
|
||||
hidden.value = item.id;
|
||||
input.value = item.text || item[labelField];
|
||||
display.textContent = '✓ Selected: ' + (item.windows_id || item.serial_number || '');
|
||||
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") }}', 'text');
|
||||
liveSearch('assetSearch', 'assetDropdown', 'assetId', 'assetDisplay',
|
||||
'{{ url_for("assets.search") }}', 'text');
|
||||
|
||||
// Pre-fill labels if IDs were passed via URL
|
||||
{% if preselect_user_id %}
|
||||
fetch('{{ url_for("users.search") }}?q={{ preselect_user_id }}')
|
||||
.then(r => r.json()).then(items => {
|
||||
if (items.length) {
|
||||
document.getElementById('userSearch').value = items[0].text;
|
||||
document.getElementById('userDisplay').textContent = '✓ Pre-selected';
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
{% if preselect_asset_id %}
|
||||
fetch('{{ url_for("assets.search") }}?q={{ preselect_asset_id }}')
|
||||
.then(r => r.json()).then(items => {
|
||||
if (items.length) {
|
||||
document.getElementById('assetSearch').value = items[0].text;
|
||||
document.getElementById('assetDisplay').textContent = '✓ Pre-selected';
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
144
app/templates/assignments/index.html
Normal file
144
app/templates/assignments/index.html
Normal file
@@ -0,0 +1,144 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Assignments – IT Asset Management{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||
<li class="breadcrumb-item active">Assignments</li>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header d-flex align-items-center justify-content-between mb-4">
|
||||
<h1><i class="bi bi-arrow-left-right me-2"></i>Assignments</h1>
|
||||
<a href="{{ url_for('assignments.create') }}" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-circle me-1"></i>Assign Asset
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="GET" class="row g-2 mb-3">
|
||||
<div class="col-auto">
|
||||
<div class="form-check form-check-inline mt-1">
|
||||
<input class="form-check-input" type="checkbox" name="active" value="0" id="chkAll"
|
||||
{% if not active_only %}checked{% endif %} onchange="this.form.submit()">
|
||||
<label class="form-check-label" for="chkAll">Show returned</label>
|
||||
</div>
|
||||
</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>User</th>
|
||||
<th>Windows ID</th>
|
||||
<th>Asset</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Assigned</th>
|
||||
<th>Returned</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in pagination.items %}
|
||||
<tr {% if a.user.is_masked %}class="masked-row"{% endif %}>
|
||||
<td>
|
||||
<a href="{{ url_for('users.detail', user_id=a.user.id) }}">{{ a.user.display_name }}</a>
|
||||
</td>
|
||||
<td><code>{{ a.user.windows_id }}</code></td>
|
||||
<td>
|
||||
<a href="{{ url_for('assets.detail', asset_id=a.asset.id) }}">
|
||||
{{ a.asset.brand or '' }} {{ a.asset.model or '' }}
|
||||
</a>
|
||||
</td>
|
||||
<td><code>{{ a.asset.serial_number }}</code></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 '—' }}</td>
|
||||
<td>
|
||||
{% if a.is_active %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Returned</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if a.is_active %}
|
||||
<button class="btn btn-sm btn-outline-warning py-0 px-2"
|
||||
data-bs-toggle="modal" data-bs-target="#returnModal{{ a.id }}">
|
||||
<i class="bi bi-arrow-return-left"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('paperwork.create', assignment_id=a.id, user_id=a.user.id, asset_id=a.asset.id) }}"
|
||||
class="btn btn-sm btn-outline-info py-0 px-2" title="Create document">
|
||||
<i class="bi bi-file-earmark-plus"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="text-center text-muted py-4">No assignments 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('assignments.index', page=pagination.prev_num, active='0' if not active_only else '1') }}">‹</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('assignments.index', page=p, active='0' if not active_only else '1') }}">{{ 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('assignments.index', page=pagination.next_num, active='0' if not active_only else '1') }}">›</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Return modals -->
|
||||
{% for a in pagination.items %}{% if a.is_active %}
|
||||
<div class="modal fade" id="returnModal{{ a.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Return Asset</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('assignments.return_asset', assignment_id=a.id) }}">
|
||||
<div class="modal-body">
|
||||
<p>Returning <strong>{{ a.asset.serial_number }}</strong> from
|
||||
<strong>{{ a.user.display_name }}</strong>.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Return Date</label>
|
||||
<input type="date" name="returned_date" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea name="return_notes" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-warning">Confirm Return</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}{% endfor %}
|
||||
{% endblock %}
|
||||
99
app/templates/audit/index.html
Normal file
99
app/templates/audit/index.html
Normal file
@@ -0,0 +1,99 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Audit Log – IT Asset Management{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||
<li class="breadcrumb-item active">Audit Log</li>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header mb-4">
|
||||
<h1><i class="bi bi-shield-check me-2"></i>Audit Log</h1>
|
||||
</div>
|
||||
|
||||
<form method="GET" class="row g-2 mb-3">
|
||||
<div class="col-md-2">
|
||||
<select name="table" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">All tables</option>
|
||||
{% for t in tables %}
|
||||
<option value="{{ t }}" {% if table_filter == t %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="action" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">All actions</option>
|
||||
{% for a in actions %}
|
||||
<option value="{{ a }}" {% if action_filter == a %}selected{% endif %}>{{ a }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="{{ url_for('audit.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-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date/Time</th>
|
||||
<th>Performed By</th>
|
||||
<th>Action</th>
|
||||
<th>Table</th>
|
||||
<th>Record</th>
|
||||
<th>Description</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in pagination.items %}
|
||||
<tr>
|
||||
<td class="text-nowrap">{{ e.performed_at.strftime('%d/%m/%Y %H:%M') if e.performed_at else '—' }}</td>
|
||||
<td>{{ e.performed_by.username if e.performed_by else '<system>' }}</td>
|
||||
<td>
|
||||
{% set colours = {'create':'success','update':'primary','delete':'danger','mask':'purple','assign':'info','return':'warning','import':'secondary'} %}
|
||||
<span class="badge bg-{{ colours.get(e.action, 'secondary') }}">{{ e.action }}</span>
|
||||
</td>
|
||||
<td><code>{{ e.table_name }}</code></td>
|
||||
<td>{{ e.record_id or '—' }}</td>
|
||||
<td>{{ e.description or '—' }}</td>
|
||||
<td><small class="text-muted">{{ e.ip_address or '—' }}</small></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">No audit entries 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('audit.index', page=pagination.prev_num, table=table_filter, action=action_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('audit.index', page=p, table=table_filter, action=action_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('audit.index', page=pagination.next_num, table=table_filter, action=action_filter) }}">›</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
52
app/templates/auth/login.html
Normal file
52
app/templates/auth/login.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login – IT Asset Management</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
body { background: #1a3a5c; min-height: 100vh; display:flex; align-items:center; justify-content:center; }
|
||||
.login-card { width: 380px; border-radius: .8rem; border: none;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.3); }
|
||||
.login-brand { font-size: 1.1rem; font-weight: 700; color: #1a3a5c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card login-card p-4">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-hdd-rack-fill text-primary" style="font-size:2.5rem;"></i>
|
||||
<div class="login-brand mt-2">IT Asset Management</div>
|
||||
<small class="text-muted">Sign in to continue</small>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ 'danger' if cat == 'error' else cat }} py-2">{{ msg }}</div>
|
||||
{% endfor %}{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-person"></i></span>
|
||||
<input type="text" name="username" class="form-control" required autofocus>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-lock"></i></span>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i> Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
223
app/templates/base.html
Normal file
223
app/templates/base.html
Normal file
@@ -0,0 +1,223 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}IT Asset Management{% endblock %}</title>
|
||||
<!-- Bootstrap 5 -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
:root {
|
||||
--sidebar-bg: #1a3a5c;
|
||||
--sidebar-text: #cce0f5;
|
||||
--sidebar-active: #2e6da4;
|
||||
--accent: #2e86de;
|
||||
}
|
||||
body { background: #f0f4f8; font-size: .92rem; }
|
||||
|
||||
/* Sidebar */
|
||||
#sidebar {
|
||||
min-height: 100vh;
|
||||
width: 240px;
|
||||
background: var(--sidebar-bg);
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
display: flex; flex-direction: column;
|
||||
z-index: 1000;
|
||||
transition: width .2s;
|
||||
}
|
||||
#sidebar .brand {
|
||||
padding: 1.1rem 1.2rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid rgba(255,255,255,.12);
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
#sidebar .brand small { font-weight: 400; font-size: .7rem; color: var(--sidebar-text); display:block; }
|
||||
#sidebar .nav-section {
|
||||
font-size: .68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
color: rgba(255,255,255,.4);
|
||||
padding: .75rem 1.2rem .25rem;
|
||||
}
|
||||
#sidebar .nav-link {
|
||||
color: var(--sidebar-text);
|
||||
padding: .45rem 1.2rem;
|
||||
border-radius: 0;
|
||||
display: flex; align-items: center; gap: .6rem;
|
||||
font-size: .88rem;
|
||||
}
|
||||
#sidebar .nav-link:hover,
|
||||
#sidebar .nav-link.active {
|
||||
background: var(--sidebar-active);
|
||||
color: #fff;
|
||||
}
|
||||
#sidebar .nav-link i { font-size: 1rem; width: 1.2rem; text-align: center; }
|
||||
#sidebar .sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding: .8rem 1.2rem;
|
||||
border-top: 1px solid rgba(255,255,255,.12);
|
||||
font-size: .8rem;
|
||||
color: var(--sidebar-text);
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
#main-wrapper { margin-left: 240px; min-height: 100vh; display: flex; flex-direction: column; }
|
||||
#topbar {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #dde3ea;
|
||||
padding: .55rem 1.5rem;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
position: sticky; top: 0; z-index: 900;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.06);
|
||||
}
|
||||
#topbar .breadcrumb { margin: 0; background: none; padding: 0; font-size: .85rem; }
|
||||
#page-content { flex: 1; padding: 1.5rem; }
|
||||
|
||||
/* Cards */
|
||||
.stat-card { border: none; border-radius: .6rem; box-shadow: 0 2px 8px rgba(0,0,0,.07); }
|
||||
.stat-card .card-body { padding: 1.1rem 1.3rem; }
|
||||
.stat-card .stat-icon { font-size: 2rem; opacity: .85; }
|
||||
.stat-card .stat-value { font-size: 2rem; font-weight: 700; line-height: 1; }
|
||||
.stat-card .stat-label { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; opacity: .8; }
|
||||
|
||||
/* Badges */
|
||||
.badge-available { background:#198754 !important; }
|
||||
.badge-assigned { background:#0d6efd !important; }
|
||||
.badge-maintenance{ background:#ffc107 !important; color:#000 !important; }
|
||||
.badge-retired { background:#6c757d !important; }
|
||||
.badge-lost { background:#dc3545 !important; }
|
||||
.badge-masked { background:#6f42c1 !important; }
|
||||
|
||||
/* Tables */
|
||||
.table-hover tbody tr:hover { background:#f5f8ff; }
|
||||
|
||||
/* Masked row */
|
||||
tr.masked-row { opacity: .65; font-style: italic; }
|
||||
|
||||
/* Search bar */
|
||||
.search-bar { max-width: 360px; }
|
||||
|
||||
/* Page header */
|
||||
.page-header h1 { font-size: 1.35rem; font-weight: 700; color: #1a3a5c; margin: 0; }
|
||||
</style>
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ===== SIDEBAR ===== -->
|
||||
<nav id="sidebar">
|
||||
<div class="brand">
|
||||
<i class="bi bi-hdd-rack-fill me-2"></i>IT Assets
|
||||
<small>Hardware Management</small>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">Main</div>
|
||||
<a href="{{ url_for('dashboard.index') }}"
|
||||
class="nav-link {% if request.endpoint == 'dashboard.index' %}active{% endif %}">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
|
||||
<div class="nav-section">People</div>
|
||||
<a href="{{ url_for('users.index') }}"
|
||||
class="nav-link {% if request.blueprint == 'users' %}active{% endif %}">
|
||||
<i class="bi bi-people-fill"></i> Users
|
||||
</a>
|
||||
<a href="{{ url_for('users.import_page') }}"
|
||||
class="nav-link {% if request.endpoint == 'users.import_page' %}active{% endif %}">
|
||||
<i class="bi bi-cloud-download"></i> Import Users
|
||||
</a>
|
||||
|
||||
<div class="nav-section">Hardware</div>
|
||||
<a href="{{ url_for('assets.index') }}"
|
||||
class="nav-link {% if request.blueprint == 'assets' %}active{% endif %}">
|
||||
<i class="bi bi-laptop"></i> Assets
|
||||
</a>
|
||||
<a href="{{ url_for('assets.create') }}"
|
||||
class="nav-link {% if request.endpoint == 'assets.create' %}active{% endif %}">
|
||||
<i class="bi bi-plus-circle"></i> Add Asset
|
||||
</a>
|
||||
|
||||
<div class="nav-section">Assignments</div>
|
||||
<a href="{{ url_for('assignments.index') }}"
|
||||
class="nav-link {% if request.blueprint == 'assignments' %}active{% endif %}">
|
||||
<i class="bi bi-arrow-left-right"></i> Assignments
|
||||
</a>
|
||||
<a href="{{ url_for('assignments.create') }}"
|
||||
class="nav-link {% if request.endpoint == 'assignments.create' %}active{% endif %}">
|
||||
<i class="bi bi-plus-circle"></i> Assign Asset
|
||||
</a>
|
||||
|
||||
<div class="nav-section">Documents</div>
|
||||
<a href="{{ url_for('paperwork.index') }}"
|
||||
class="nav-link {% if request.blueprint == 'paperwork' %}active{% endif %}">
|
||||
<i class="bi bi-file-earmark-text"></i> Paperwork
|
||||
</a>
|
||||
<a href="{{ url_for('paperwork.create') }}"
|
||||
class="nav-link {% if request.endpoint == 'paperwork.create' %}active{% endif %}">
|
||||
<i class="bi bi-file-earmark-plus"></i> New Document
|
||||
</a>
|
||||
<a href="{{ url_for('doc_templates.index') }}"
|
||||
class="nav-link {% if request.blueprint == 'doc_templates' %}active{% endif %}">
|
||||
<i class="bi bi-file-earmark-word"></i> Templates
|
||||
</a>
|
||||
|
||||
<div class="nav-section">System</div>
|
||||
<a href="{{ url_for('audit.index') }}"
|
||||
class="nav-link {% if request.blueprint == 'audit' %}active{% endif %}">
|
||||
<i class="bi bi-shield-check"></i> Audit Log
|
||||
</a>
|
||||
<a href="{{ url_for('settings.index') }}"
|
||||
class="nav-link {% if request.blueprint == 'settings' %}active{% endif %}">
|
||||
<i class="bi bi-gear"></i> Settings
|
||||
</a>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<i class="bi bi-person-circle me-1"></i>
|
||||
<strong>{{ current_user.username }}</strong>
|
||||
<a href="{{ url_for('auth.logout') }}" class="ms-2 text-warning text-decoration-none">
|
||||
<i class="bi bi-box-arrow-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ===== MAIN WRAPPER ===== -->
|
||||
<div id="main-wrapper">
|
||||
<!-- Topbar -->
|
||||
<div id="topbar">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">{% block breadcrumb %}<li class="breadcrumb-item active">Home</li>{% endblock %}</ol>
|
||||
</nav>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="text-muted" style="font-size:.8rem;">
|
||||
<i class="bi bi-calendar3"></i>
|
||||
{{ now.strftime('%d %b %Y') if now else '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flash messages -->
|
||||
<div id="page-content">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ 'danger' if cat == 'error' else cat }} alert-dismissible fade show mb-3" role="alert">
|
||||
{{ msg }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
169
app/templates/dashboard/index.html
Normal file
169
app/templates/dashboard/index.html
Normal file
@@ -0,0 +1,169 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Dashboard – IT Asset Management{% endblock %}
|
||||
{% block breadcrumb %}<li class="breadcrumb-item active">Dashboard</li>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header mb-4">
|
||||
<h1><i class="bi bi-speedometer2 me-2"></i>Dashboard</h1>
|
||||
</div>
|
||||
|
||||
<!-- ── Stat Cards ──────────────────────────────────────────────── -->
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- Users -->
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card stat-card text-white" style="background:#1a3a5c;">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<i class="bi bi-people-fill stat-icon"></i>
|
||||
<div>
|
||||
<div class="stat-value">{{ stats.active_users }}</div>
|
||||
<div class="stat-label">Active Users</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card stat-card text-white" style="background:#6f42c1;">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<i class="bi bi-eye-slash-fill stat-icon"></i>
|
||||
<div>
|
||||
<div class="stat-value">{{ stats.masked_users }}</div>
|
||||
<div class="stat-label">Masked Records</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Assets -->
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card stat-card text-white" style="background:#198754;">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<i class="bi bi-laptop stat-icon"></i>
|
||||
<div>
|
||||
<div class="stat-value">{{ stats.available_assets }}</div>
|
||||
<div class="stat-label">Available Assets</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card stat-card text-white" style="background:#0d6efd;">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<i class="bi bi-arrow-left-right stat-icon"></i>
|
||||
<div>
|
||||
<div class="stat-value">{{ stats.assigned_assets }}</div>
|
||||
<div class="stat-label">Assigned Assets</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card stat-card text-white" style="background:#ffc107; color:#000 !important;">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<i class="bi bi-tools stat-icon"></i>
|
||||
<div>
|
||||
<div class="stat-value">{{ stats.maintenance_assets }}</div>
|
||||
<div class="stat-label">In Maintenance</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card stat-card text-white" style="background:#6c757d;">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<i class="bi bi-hdd-fill stat-icon"></i>
|
||||
<div>
|
||||
<div class="stat-value">{{ stats.total_assets }}</div>
|
||||
<div class="stat-label">Total Assets</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card stat-card text-white" style="background:#0dcaf0; color:#000 !important;">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<i class="bi bi-file-earmark-text stat-icon"></i>
|
||||
<div>
|
||||
<div class="stat-value">{{ stats.total_paperwork }}</div>
|
||||
<div class="stat-label">Documents</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card stat-card text-white" style="background:#fd7e14;">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<i class="bi bi-person-badge stat-icon"></i>
|
||||
<div>
|
||||
<div class="stat-value">{{ stats.active_assignments }}</div>
|
||||
<div class="stat-label">Open Assignments</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Quick Actions ──────────────────────────────────────────── -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body py-2">
|
||||
<span class="text-muted me-3" style="font-size:.8rem;">QUICK ACTIONS</span>
|
||||
<a href="{{ url_for('assets.create') }}" class="btn btn-sm btn-outline-primary me-2">
|
||||
<i class="bi bi-plus-circle me-1"></i>Add Asset
|
||||
</a>
|
||||
<a href="{{ url_for('assignments.create') }}" class="btn btn-sm btn-outline-success me-2">
|
||||
<i class="bi bi-arrow-left-right me-1"></i>Assign Asset
|
||||
</a>
|
||||
<a href="{{ url_for('paperwork.create') }}" class="btn btn-sm btn-outline-info me-2">
|
||||
<i class="bi bi-file-earmark-plus me-1"></i>New Document
|
||||
</a>
|
||||
<a href="{{ url_for('users.import_page') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-cloud-download me-1"></i>Import Users
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Recent Assignments ─────────────────────────────────────── -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white fw-semibold py-3">
|
||||
<i class="bi bi-clock-history me-2 text-primary"></i>Current Assignments (latest 10)
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Windows ID</th>
|
||||
<th>Asset</th>
|
||||
<th>Serial / Service Tag</th>
|
||||
<th>Since</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in recent_assignments %}
|
||||
<tr {% if a.user.is_masked %}class="masked-row"{% endif %}>
|
||||
<td>{{ a.user.display_name }}</td>
|
||||
<td><code>{{ a.user.windows_id }}</code></td>
|
||||
<td>{{ a.asset.brand or '' }} {{ a.asset.model or '' }}</td>
|
||||
<td>
|
||||
<code>{{ a.asset.serial_number }}</code>
|
||||
{% if a.asset.service_tag %}<br><small class="text-muted">{{ a.asset.service_tag }}</small>{% endif %}
|
||||
</td>
|
||||
<td>{{ a.assigned_date.strftime('%d/%m/%Y') if a.assigned_date else '—' }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('assets.detail', asset_id=a.asset.id) }}"
|
||||
class="btn btn-xs btn-outline-secondary btn-sm 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 active assignments.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
155
app/templates/doc_templates/detail.html
Normal file
155
app/templates/doc_templates/detail.html
Normal file
@@ -0,0 +1,155 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}{{ tpl.name }} – Templates{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('doc_templates.index') }}">Templates</a></li>
|
||||
<li class="breadcrumb-item active">{{ tpl.name }}</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-word me-2"></i>{{ tpl.name }}</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('doc_templates.download', tpl_id=tpl.id) }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-download me-1"></i>Download .docx
|
||||
</a>
|
||||
<a href="{{ url_for('doc_templates.edit', tpl_id=tpl.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-pencil me-1"></i>Edit details
|
||||
</a>
|
||||
<form method="POST" action="{{ url_for('doc_templates.rescan', tpl_id=tpl.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Re-scan variables
|
||||
</button>
|
||||
</form>
|
||||
<button class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteModal">
|
||||
<i class="bi bi-trash me-1"></i>Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Metadata -->
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white fw-semibold small text-uppercase text-muted">Details</div>
|
||||
<div class="card-body">
|
||||
<dl class="row small mb-0">
|
||||
<dt class="col-5">Category</dt>
|
||||
<dd class="col-7">
|
||||
{% if tpl.category %}
|
||||
<span class="badge bg-secondary">{{ dict(doc_types)[tpl.category] if tpl.category in dict(doc_types) else tpl.category }}</span>
|
||||
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||
</dd>
|
||||
<dt class="col-5">File</dt>
|
||||
<dd class="col-7"><code>{{ tpl.filename }}</code></dd>
|
||||
<dt class="col-5">Uploaded</dt>
|
||||
<dd class="col-7">{{ tpl.created_at.strftime('%d %b %Y %H:%M') }}</dd>
|
||||
<dt class="col-5">By</dt>
|
||||
<dd class="col-7">{{ tpl.created_by.username if tpl.created_by else '—' }}</dd>
|
||||
<dt class="col-5">Docs generated</dt>
|
||||
<dd class="col-7">{{ tpl.paperwork_docs.count() }}</dd>
|
||||
</dl>
|
||||
{% if tpl.description %}
|
||||
<hr class="my-2">
|
||||
<p class="small text-muted mb-0">{{ tpl.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Variables -->
|
||||
<div class="col-md-8">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white fw-semibold small text-uppercase text-muted d-flex justify-content-between align-items-center">
|
||||
Detected Variables
|
||||
<span class="badge bg-primary rounded-pill">{{ tpl.variables | length }}</span>
|
||||
</div>
|
||||
{% set vars = tpl.variables %}
|
||||
{% if vars %}
|
||||
<div class="card-body">
|
||||
<p class="small text-muted mb-3">
|
||||
These placeholders were detected in the template file.
|
||||
They will be filled automatically when generating a document.
|
||||
<strong class="text-danger">PII variables</strong> (name, email, phone)
|
||||
are replaced with <code>[MASKED]</code> when a user's record is erased.
|
||||
</p>
|
||||
{% set pii = ['user_name','user_email','user_phone'] %}
|
||||
<div class="row row-cols-2 row-cols-md-3 g-2">
|
||||
{% for v in vars %}
|
||||
<div class="col">
|
||||
<span class="badge {% if v in pii %}bg-danger{% else %}bg-light text-dark border{% endif %} w-100 text-start p-2">
|
||||
{% if v in pii %}<i class="bi bi-shield-x me-1"></i>{% else %}<i class="bi bi-braces me-1"></i>{% endif %}
|
||||
{{ v }}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-3 small">
|
||||
<span class="badge bg-danger me-1">PII</span> masked on departure
|
||||
<span class="badge bg-light text-dark border me-1">other</span> retained
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-body text-muted small">
|
||||
No variables detected. Make sure your template uses <code>{{ variable_name }}</code> syntax
|
||||
and click <strong>Re-scan</strong>.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documents generated from this template -->
|
||||
{% set recent_docs = tpl.paperwork_docs.order_by('created_at desc').limit(10).all() %}
|
||||
{% if recent_docs %}
|
||||
<div class="card border-0 shadow-sm mt-4">
|
||||
<div class="card-header bg-white fw-semibold small text-uppercase text-muted">Recently Generated Documents</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0 small">
|
||||
<thead class="table-light">
|
||||
<tr><th>Title</th><th>User</th><th>Created</th><th>Signed</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for doc in recent_docs %}
|
||||
<tr>
|
||||
<td>{{ doc.title }}</td>
|
||||
<td>{{ doc.user.display_name if doc.user else '—' }}</td>
|
||||
<td>{{ doc.created_at.strftime('%d/%m/%Y') }}</td>
|
||||
<td>{% if doc.is_signed %}<i class="bi bi-check-circle text-success"></i>{% else %}<span class="text-muted">—</span>{% endif %}</td>
|
||||
<td><a href="{{ url_for('paperwork.detail', doc_id=doc.id) }}" class="btn btn-sm btn-outline-secondary py-0 px-2"><i class="bi bi-eye"></i></a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Delete modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete Template</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Delete <strong>{{ tpl.name }}</strong>?
|
||||
{% if tpl.paperwork_docs.count() > 0 %}
|
||||
<div class="alert alert-danger mt-2 small">
|
||||
Cannot delete — {{ tpl.paperwork_docs.count() }} document(s) were generated from this template.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
||||
{% if tpl.paperwork_docs.count() == 0 %}
|
||||
<form method="POST" action="{{ url_for('doc_templates.delete', tpl_id=tpl.id) }}">
|
||||
<button class="btn btn-danger btn-sm" type="submit">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
41
app/templates/doc_templates/edit.html
Normal file
41
app/templates/doc_templates/edit.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Edit {{ tpl.name }} – Templates{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('doc_templates.index') }}">Templates</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('doc_templates.detail', tpl_id=tpl.id) }}">{{ tpl.name }}</a></li>
|
||||
<li class="breadcrumb-item active">Edit</li>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header mb-4">
|
||||
<h1><i class="bi bi-pencil me-2"></i>Edit Template</h1>
|
||||
</div>
|
||||
<div class="card border-0 shadow-sm" style="max-width:600px;">
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Name <span class="text-danger">*</span></label>
|
||||
<input type="text" name="name" class="form-control" value="{{ tpl.name }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Category</label>
|
||||
<select name="category" class="form-select">
|
||||
<option value="">— no category —</option>
|
||||
{% for val, label in doc_types %}
|
||||
<option value="{{ val }}" {% if tpl.category == val %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Description</label>
|
||||
<textarea name="description" class="form-control" rows="3">{{ tpl.description or '' }}</textarea>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<a href="{{ url_for('doc_templates.detail', tpl_id=tpl.id) }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
77
app/templates/doc_templates/index.html
Normal file
77
app/templates/doc_templates/index.html
Normal file
@@ -0,0 +1,77 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Document Templates – IT Asset Management{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||
<li class="breadcrumb-item active">Document Templates</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-word me-2"></i>Document Templates</h1>
|
||||
<a href="{{ url_for('doc_templates.upload') }}" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-upload me-1"></i>Upload Template
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info small mb-4">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Upload <strong>.docx</strong> Word files with <code>{{ variable_name }}</code> placeholders.
|
||||
When creating paperwork, the system fills them automatically from user / asset data.
|
||||
All generated documents can be regenerated with masked PII if a user leaves the company.
|
||||
</div>
|
||||
|
||||
{% if templates %}
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-3 g-3">
|
||||
{% for tpl in templates %}
|
||||
<div class="col">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start justify-content-between mb-2">
|
||||
<h6 class="mb-0 fw-semibold">
|
||||
<i class="bi bi-file-earmark-word text-primary me-1"></i>{{ tpl.name }}
|
||||
</h6>
|
||||
{% if tpl.category %}
|
||||
<span class="badge bg-secondary ms-2">{{ dict(doc_types)[tpl.category] if tpl.category in dict(doc_types) else tpl.category }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if tpl.description %}
|
||||
<p class="text-muted small mb-2">{{ tpl.description }}</p>
|
||||
{% endif %}
|
||||
<div class="small text-muted mb-3">
|
||||
<i class="bi bi-braces me-1"></i>
|
||||
{% set vars = tpl.variables %}
|
||||
{% if vars %}
|
||||
{{ vars | length }} variable(s):
|
||||
{% for v in vars[:5] %}<code class="me-1">{{ v }}</code>{% endfor %}
|
||||
{% if vars | length > 5 %}<em>+{{ vars | length - 5 }} more</em>{% endif %}
|
||||
{% else %}
|
||||
No variables detected
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<a href="{{ url_for('doc_templates.detail', tpl_id=tpl.id) }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye me-1"></i>View
|
||||
</a>
|
||||
<a href="{{ url_for('doc_templates.download', tpl_id=tpl.id) }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-download me-1"></i>Download
|
||||
</a>
|
||||
<span class="ms-auto text-muted small">
|
||||
{{ tpl.paperwork_docs.count() }} doc(s) generated
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-white text-muted small">
|
||||
Uploaded {{ tpl.created_at.strftime('%d %b %Y') }}
|
||||
{% if tpl.created_by %} by {{ tpl.created_by.username }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-file-earmark-word display-4 d-block mb-3"></i>
|
||||
No templates yet. <a href="{{ url_for('doc_templates.upload') }}">Upload your first template</a>.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
96
app/templates/doc_templates/upload.html
Normal file
96
app/templates/doc_templates/upload.html
Normal file
@@ -0,0 +1,96 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Upload Template – 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('doc_templates.index') }}">Templates</a></li>
|
||||
<li class="breadcrumb-item active">Upload</li>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header mb-4">
|
||||
<h1><i class="bi bi-upload me-2"></i>Upload Document Template</h1>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-7">
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Template Name <span class="text-danger">*</span></label>
|
||||
<input type="text" name="name" class="form-control" placeholder="e.g. Equipment Handover Receipt" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Category</label>
|
||||
<select name="category" class="form-select">
|
||||
<option value="">— no category —</option>
|
||||
{% for val, label in doc_types %}
|
||||
<option value="{{ val }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">Used to pre-select this template when creating paperwork of that type.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Description</label>
|
||||
<textarea name="description" class="form-control" rows="2" placeholder="Optional notes about this template…"></textarea>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">.docx Template File <span class="text-danger">*</span></label>
|
||||
<input type="file" name="docx_file" class="form-control" accept=".docx" required>
|
||||
<div class="form-text">Word document (.docx) with <code>{{ variable_name }}</code> placeholders.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-upload me-1"></i>Upload
|
||||
</button>
|
||||
<a href="{{ url_for('doc_templates.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-5">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header fw-semibold small text-uppercase text-muted bg-white">
|
||||
Available Variables
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-hover mb-0 small">
|
||||
<thead class="table-light"><tr><th>Variable</th><th>Value</th></tr></thead>
|
||||
<tbody>
|
||||
{% set var_docs = [
|
||||
('user_name','Full name (masked if user left)'),
|
||||
('user_email','Email address'),
|
||||
('user_phone','Phone number'),
|
||||
('user_department','Department (retained after masking)'),
|
||||
('user_job_title','Job title'),
|
||||
('user_location','Office location'),
|
||||
('user_windows_id','Windows / AD ID — never masked'),
|
||||
('asset_serial','Asset serial number'),
|
||||
('asset_service_tag','Dell / vendor service tag'),
|
||||
('asset_brand','Brand (e.g. Dell)'),
|
||||
('asset_model','Model name'),
|
||||
('asset_type','Type (Laptop / Desktop / …)'),
|
||||
('asset_os','Operating system'),
|
||||
('asset_warranty_expiry','Warranty expiry date'),
|
||||
('assignment_date','Date asset was assigned'),
|
||||
('return_date','Date asset was returned'),
|
||||
('document_date','Today\'s date'),
|
||||
('document_number','Document / paperwork ID'),
|
||||
('company_name','Your company name'),
|
||||
('company_address','Your company address'),
|
||||
] %}
|
||||
{% for var, desc in var_docs %}
|
||||
<tr>
|
||||
<td><code>{{ {{ var }} }}</code></td>
|
||||
<td class="text-muted">{{ desc }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
238
app/templates/paperwork/detail.html
Normal file
238
app/templates/paperwork/detail.html
Normal 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 %}
|
||||
168
app/templates/paperwork/form.html
Normal file
168
app/templates/paperwork/form.html
Normal 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 %}
|
||||
110
app/templates/paperwork/index.html
Normal file
110
app/templates/paperwork/index.html
Normal 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 %}
|
||||
126
app/templates/settings/index.html
Normal file
126
app/templates/settings/index.html
Normal file
@@ -0,0 +1,126 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block title %}Settings – IT Asset Management{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||
<li class="breadcrumb-item active">Settings</li>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header mb-4">
|
||||
<h1><i class="bi bi-gear me-2"></i>Settings</h1>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Admin users -->
|
||||
<div class="col-md-7">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white fw-semibold py-3">
|
||||
<i class="bi bi-person-gear me-2 text-primary"></i>Admin Users
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr><th>Username</th><th>Full Name</th><th>Email</th><th>Role</th><th>Last Login</th><th>Active</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in admins %}
|
||||
<tr>
|
||||
<td><strong>{{ a.username }}</strong></td>
|
||||
<td>{{ a.full_name or '—' }}</td>
|
||||
<td>{{ a.email }}</td>
|
||||
<td><span class="badge bg-secondary">{{ a.role }}</span></td>
|
||||
<td>{{ a.last_login.strftime('%d/%m/%Y') if a.last_login else '—' }}</td>
|
||||
<td>
|
||||
{% if a.is_active %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if a.id != current_user.id %}
|
||||
<form method="POST" action="{{ url_for('settings.toggle_admin', admin_id=a.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-xs btn-sm btn-outline-{{ 'warning' if a.is_active else 'success' }} py-0 px-2">
|
||||
{{ 'Deactivate' if a.is_active else 'Activate' }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Add admin form -->
|
||||
<div class="card-footer bg-white">
|
||||
<h6 class="fw-semibold mb-3 mt-1">Add Admin User</h6>
|
||||
<form method="POST" action="{{ url_for('settings.create_admin') }}">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="username" class="form-control form-control-sm" placeholder="Username" required>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="full_name" class="form-control form-control-sm" placeholder="Full Name">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="email" name="email" class="form-control form-control-sm" placeholder="Email" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="password" name="password" class="form-control form-control-sm" placeholder="Password" required minlength="8">
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LDAP config info -->
|
||||
<div class="col-md-5">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white fw-semibold py-3">
|
||||
<i class="bi bi-diagram-3 me-2 text-primary"></i>LDAP / AD Configuration
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-3">
|
||||
LDAP settings are managed via environment variables (see <code>.env</code> file).
|
||||
Restart the application after changing these values.
|
||||
</p>
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<tbody>
|
||||
<tr><th class="bg-light">LDAP_SERVER</th><td><code>{{ config.LDAP_SERVER or '(not set)' }}</code></td></tr>
|
||||
<tr><th class="bg-light">LDAP_PORT</th><td>{{ config.LDAP_PORT }}</td></tr>
|
||||
<tr><th class="bg-light">LDAP_USE_SSL</th><td>{{ config.LDAP_USE_SSL }}</td></tr>
|
||||
<tr><th class="bg-light">LDAP_BASE_DN</th><td><code>{{ config.LDAP_BASE_DN or '(not set)' }}</code></td></tr>
|
||||
<tr><th class="bg-light">LDAP_BIND_USER</th><td>{{ config.LDAP_BIND_USER or '(not set)' }}</td></tr>
|
||||
<tr><th class="bg-light">Windows ID attr</th><td><code>{{ config.LDAP_WINDOWS_ID_ATTR }}</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mt-3">
|
||||
<a href="{{ url_for('users.import_page') }}" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Go to Import / Sync
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm mt-3">
|
||||
<div class="card-header bg-white fw-semibold py-3">
|
||||
<i class="bi bi-building me-2 text-primary"></i>Company Info (for PDFs)
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<tbody>
|
||||
<tr><th class="bg-light">COMPANY_NAME</th><td>{{ config.COMPANY_NAME or '(not set)' }}</td></tr>
|
||||
<tr><th class="bg-light">COMPANY_ADDRESS</th><td>{{ config.COMPANY_ADDRESS or '(not set)' }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="small text-muted mt-2 mb-0">Edit these in <code>.env</code> and restart.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
211
app/templates/users/detail.html
Normal file
211
app/templates/users/detail.html
Normal 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 %}
|
||||
92
app/templates/users/form.html
Normal file
92
app/templates/users/form.html
Normal 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 %}
|
||||
89
app/templates/users/import.html
Normal file
89
app/templates/users/import.html
Normal 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 %}
|
||||
125
app/templates/users/index.html
Normal file
125
app/templates/users/index.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user