Initial commit: add compliance_checks table, per-check metadata on assets, and compliance audit trail

This commit is contained in:
2026-04-24 07:14:27 +03:00
commit e63b486ec2
58 changed files with 6468 additions and 0 deletions

37
.env.example Normal file
View File

@@ -0,0 +1,37 @@
# Copy this file to .env and fill in the values
# Flask
SECRET_KEY=change-this-to-a-long-random-string
FLASK_ENV=development
# MySQL
MYSQL_HOST=db
MYSQL_PORT=3306
MYSQL_USER=itasset_user
MYSQL_PASSWORD=itasset_pass
MYSQL_DB=itasset_db
MYSQL_ROOT_PASSWORD=rootpassword
# LDAP / Active Directory (leave blank to disable LDAP sync)
LDAP_SERVER=ldap://your-dc.company.local
LDAP_PORT=389
LDAP_USE_SSL=false
LDAP_BIND_USER=CN=svc-itasset,OU=Service Accounts,DC=company,DC=local
LDAP_BIND_PASSWORD=service-account-password
LDAP_BASE_DN=OU=Users,DC=company,DC=local
LDAP_USER_SEARCH_FILTER=(&(objectClass=person)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))
# Attribute in AD that stores the numeric Windows ID (employeeID is common)
LDAP_WINDOWS_ID_ATTR=employeeID
# Company info for PDF generation
COMPANY_NAME=Your Company Name
COMPANY_ADDRESS=123 Street, City, Country
# Dell TechDirect API (for automatic service-tag lookup)
# Register at https://tdm.dell.com → API Services → Create an API key pair
DELL_CLIENT_ID=
DELL_CLIENT_SECRET=
# File storage (relative to project root)
UPLOAD_FOLDER=uploads
PDF_FOLDER=pdfs

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
.env
*.pyc
__pycache__/
*.egg-info/
dist/
build/
.venv/
venv/
uploads/
pdfs/
instance/
migrations/
*.log
.DS_Store

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM python:3.12-slim
WORKDIR /app
# System dependencies (none needed for reportlab + PyMySQL)
RUN apt-get update && apt-get install -y --no-install-recommends \
default-libmysqlclient-dev \
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Create storage directories
RUN mkdir -p uploads pdfs
ENV FLASK_ENV=production
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
EXPOSE 5000
CMD ["python", "run.py"]

132
README.md Normal file
View File

@@ -0,0 +1,132 @@
# IT Hardware Asset Management System
A full-featured web application built with **Python / Flask + MySQL**, containerised with **Docker Compose**.
---
## Features
| Module | Capabilities |
|---|---|
| **Users** | Manual, CSV, and LDAP/AD import · search · GDPR masking |
| **Assets** | Track by Serial Number + Service Tag · full history |
| **Assignments** | Assign/return assets · complete audit trail |
| **Paperwork** | Generate PDF documents (handover, assignment, return, custom) |
| **Audit Log** | Immutable log of every create / update / delete / mask action |
| **Settings** | Admin user management · LDAP config view |
### User masking (GDPR / off-boarding)
When an employee leaves, press **Mask User** to permanently erase PII (name, email, phone).
The record is kept — linked by the permanent **Windows ID** — so full asset history is preserved for audits, without exposing personal data.
---
## Quick Start (Docker Compose)
```bash
# 1. Copy the example env file and fill in your values
cp .env.example .env
nano .env
# 2. Build and start
docker compose up -d --build
# 3. Open the app
http://localhost:5000
# Default credentials (change immediately in Settings!)
Username: admin
Password: ChangeMe123!
```
---
## Running locally (without Docker)
```bash
# 1. Create a virtual environment
python -m venv venv
source venv/bin/activate # Linux/macOS
# venv\Scripts\activate # Windows
# 2. Install dependencies
pip install -r requirements.txt
# 3. Configure environment
cp .env.example .env
# Edit .env — set MYSQL_HOST=localhost and your DB credentials
# 4. Initialise the database
flask db init
flask db migrate -m "initial"
flask db upgrade
python init_db.py
# 5. Run
python run.py
```
---
## Environment Variables (`.env`)
| Variable | Description | Default |
|---|---|---|
| `SECRET_KEY` | Flask secret key | *(must change)* |
| `MYSQL_HOST` | MySQL host | `db` (Docker) / `localhost` |
| `MYSQL_USER` | MySQL user | `itasset_user` |
| `MYSQL_PASSWORD` | MySQL password | `itasset_pass` |
| `MYSQL_DB` | Database name | `itasset_db` |
| `LDAP_SERVER` | LDAP server URL | *(blank = disabled)* |
| `LDAP_BIND_USER` | Service account DN | — |
| `LDAP_BIND_PASSWORD` | Service account password | — |
| `LDAP_BASE_DN` | Search base | — |
| `LDAP_WINDOWS_ID_ATTR` | AD attribute for numeric ID | `employeeID` |
| `COMPANY_NAME` | Shown on PDF headers | `Your Company Name` |
| `COMPANY_ADDRESS` | Shown on PDF headers | — |
| `DEFAULT_ADMIN_USER` | First-run admin username | `admin` |
| `DEFAULT_ADMIN_PASS` | First-run admin password | `ChangeMe123!` |
---
## CSV Import Format
```csv
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
```
Column names are matched case-insensitively. `windows_id` is required; all others are optional.
---
## Project Structure
```
IT_asset_management/
├── app/
│ ├── __init__.py # App factory
│ ├── extensions.py # SQLAlchemy, Flask-Login, Migrate
│ ├── models/ # User, Asset, Assignment, Paperwork, AuditLog, AdminUser
│ ├── routes/ # auth, dashboard, users, assets, assignments, paperwork, audit, settings
│ ├── services/ # csv_service, ldap_service, pdf_service
│ └── templates/ # Jinja2 + Bootstrap 5
├── config.py
├── run.py
├── init_db.py
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
└── .env.example
```
---
## Security Notes
- Change `DEFAULT_ADMIN_PASS` immediately after first login.
- Set a strong random `SECRET_KEY` in `.env`.
- The LDAP bind password is read from the environment — never commit `.env` to source control.
- PDF files are stored server-side in the `pdfs/` directory (Docker volume). Restrict access as needed.
- Masked user records permanently destroy PII and cannot be reversed.

57
app/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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}>'

View 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}>'
)

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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']

View 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

View 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)

View 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
View 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

View 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

View 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 &amp; 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 %}
&mdash; {{ 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 %}
&mdash; {{ 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 %}
&mdash; {{ 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 &amp; 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 %}

View 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 %}

View 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> &mdash; Dell&rsquo;s page opened in a new tab. Copy model &amp; 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 &rarr;</em></div>
<div><span class="fw-medium">Warranty:</span> <em class="text-muted">fill from Dell tab &rarr;</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 %}

View 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 %}

View 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 %}

View 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 %}

View 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
View 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>

View 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 %}

View 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 &nbsp;
<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>&#123;&#123; variable_name &#125;&#125;</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 %}

View 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 %}

View 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>&#123;&#123; variable_name &#125;&#125;</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 %}

View 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>&#123;&#123; variable_name &#125;&#125;</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>&#123;&#123; {{ var }} &#125;&#125;</code></td>
<td class="text-muted">{{ desc }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View File

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

View File

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

View File

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

View 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 %}

View File

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

View File

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

View File

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

View File

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

74
config.py Normal file
View File

@@ -0,0 +1,74 @@
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY', 'change-this-secret-in-production-use-a-long-random-string')
# MySQL
MYSQL_HOST = os.environ.get('MYSQL_HOST', 'localhost')
MYSQL_PORT = int(os.environ.get('MYSQL_PORT', 3306))
MYSQL_USER = os.environ.get('MYSQL_USER', 'itasset_user')
MYSQL_PASSWORD = os.environ.get('MYSQL_PASSWORD', 'itasset_pass')
MYSQL_DB = os.environ.get('MYSQL_DB', 'itasset_db')
# Allow SQLALCHEMY_DATABASE_URI env var to override (used for SQLite in local dev)
_mysql_uri = (
f"mysql+pymysql://{os.environ.get('MYSQL_USER', 'itasset_user')}:"
f"{os.environ.get('MYSQL_PASSWORD', 'itasset_pass')}@"
f"{os.environ.get('MYSQL_HOST', 'localhost')}:"
f"{os.environ.get('MYSQL_PORT', 3306)}/"
f"{os.environ.get('MYSQL_DB', 'itasset_db')}?charset=utf8mb4"
)
SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI', _mysql_uri)
SQLALCHEMY_TRACK_MODIFICATIONS = False
# LDAP / Active Directory
LDAP_SERVER = os.environ.get('LDAP_SERVER', '')
LDAP_PORT = int(os.environ.get('LDAP_PORT', 389))
LDAP_USE_SSL = os.environ.get('LDAP_USE_SSL', 'false').lower() == 'true'
LDAP_BIND_USER = os.environ.get('LDAP_BIND_USER', '')
LDAP_BIND_PASSWORD = os.environ.get('LDAP_BIND_PASSWORD', '')
LDAP_BASE_DN = os.environ.get('LDAP_BASE_DN', '')
LDAP_USER_SEARCH_FILTER = os.environ.get(
'LDAP_USER_SEARCH_FILTER', '(&(objectClass=person)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))'
)
# AD attribute that stores the numeric Windows ID (e.g. employeeID)
LDAP_WINDOWS_ID_ATTR = os.environ.get('LDAP_WINDOWS_ID_ATTR', 'employeeID')
# File storage
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'uploads')
PDF_FOLDER = os.environ.get('PDF_FOLDER', 'pdfs')
TEMPLATE_FOLDER = os.environ.get('TEMPLATE_FOLDER', 'doc_templates')
DOCX_FOLDER = os.environ.get('DOCX_FOLDER', 'docx_output')
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16 MB
# Company info used in generated PDFs
COMPANY_NAME = os.environ.get('COMPANY_NAME', 'Your Company Name')
COMPANY_ADDRESS = os.environ.get('COMPANY_ADDRESS', '')
COMPANY_LOGO = os.environ.get('COMPANY_LOGO', '') # path to logo file
# Pagination
ITEMS_PER_PAGE = int(os.environ.get('ITEMS_PER_PAGE', 25))
# Dell TechDirect API (for service-tag auto-lookup)
# Register at https://tdm.dell.com → API Services → Create credentials
DELL_CLIENT_ID = os.environ.get('DELL_CLIENT_ID', '')
DELL_CLIENT_SECRET = os.environ.get('DELL_CLIENT_SECRET', '')
class DevelopmentConfig(Config):
DEBUG = True
class ProductionConfig(Config):
DEBUG = False
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'default': DevelopmentConfig,
}

43
docker-compose.yml Normal file
View File

@@ -0,0 +1,43 @@
version: '3.9'
services:
db:
image: mysql:8.0
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword}
MYSQL_DATABASE: ${MYSQL_DB:-itasset_db}
MYSQL_USER: ${MYSQL_USER:-itasset_user}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-itasset_pass}
volumes:
- mysql_data:/var/lib/mysql
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-rootpassword}"]
interval: 10s
timeout: 5s
retries: 10
web:
build: .
restart: always
depends_on:
db:
condition: service_healthy
env_file:
- .env
environment:
MYSQL_HOST: db
ports:
- "5000:5000"
volumes:
- uploads_data:/app/uploads
- pdfs_data:/app/pdfs
command: >
sh -c "flask db upgrade && python init_db.py && python run.py"
volumes:
mysql_data:
uploads_data:
pdfs_data:

0
info.txt Normal file
View File

40
init_db.py Normal file
View File

@@ -0,0 +1,40 @@
"""
init_db.py — Run once after `flask db upgrade` to:
1. Create all tables (idempotent)
2. Create the default admin user if no admin exists
Usage:
python init_db.py
(or it runs automatically via Docker Compose CMD)
"""
import os
from app import create_app
from app.extensions import db
from app.models.admin_user import AdminUser
app = create_app(os.environ.get('FLASK_ENV', 'development'))
with app.app_context():
db.create_all()
# Only create default admin if none exists
if AdminUser.query.count() == 0:
default_user = os.environ.get('DEFAULT_ADMIN_USER', 'admin')
default_pass = os.environ.get('DEFAULT_ADMIN_PASS', 'ChangeMe123!')
default_email = os.environ.get('DEFAULT_ADMIN_EMAIL', 'admin@company.local')
admin = AdminUser(
username=default_user,
email=default_email,
full_name='System Administrator',
role='admin',
)
admin.set_password(default_pass)
db.session.add(admin)
db.session.commit()
print(f'[init_db] Default admin created — username: {default_user}')
print(f'[init_db] *** CHANGE THE DEFAULT PASSWORD IMMEDIATELY ***')
else:
print('[init_db] Admin users already exist — skipping default creation.')
print('[init_db] Database initialised.')

15
requirements.txt Normal file
View File

@@ -0,0 +1,15 @@
Flask==3.0.3
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.7
Flask-Login==0.6.3
Flask-WTF==1.2.1
WTForms==3.1.2
email-validator==2.2.0
PyMySQL==1.1.1
python-dotenv==1.0.1
ldap3==2.9.1
reportlab==4.2.2
Werkzeug==3.0.3
cryptography==42.0.8
requests==2.32.3
httpx[http2]

7
run.py Normal file
View File

@@ -0,0 +1,7 @@
import os
from app import create_app
app = create_app(os.environ.get('FLASK_ENV', 'development'))
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)