From e63b486ec2ee928a50b84e9dd45fb340c612db83 Mon Sep 17 00:00:00 2001 From: scheianu Date: Fri, 24 Apr 2026 07:14:27 +0300 Subject: [PATCH] Initial commit: add compliance_checks table, per-check metadata on assets, and compliance audit trail --- .env.example | 37 ++ .gitignore | 14 + Dockerfile | 25 ++ README.md | 132 +++++++ app/__init__.py | 57 +++ app/extensions.py | 10 + app/models/__init__.py | 9 + app/models/admin_user.py | 33 ++ app/models/asset.py | 114 ++++++ app/models/assignment.py | 37 ++ app/models/audit_log.py | 27 ++ app/models/compliance_check.py | 56 +++ app/models/document_template.py | 53 +++ app/models/paperwork.py | 72 ++++ app/models/user.py | 96 +++++ app/routes/__init__.py | 13 + app/routes/assets.py | 389 ++++++++++++++++++++ app/routes/assignments.py | 145 ++++++++ app/routes/audit.py | 30 ++ app/routes/auth.py | 41 +++ app/routes/dashboard.py | 35 ++ app/routes/doc_templates.py | 202 +++++++++++ app/routes/paperwork.py | 245 +++++++++++++ app/routes/settings.py | 52 +++ app/routes/users.py | 332 +++++++++++++++++ app/services/__init__.py | 5 + app/services/csv_service.py | 60 ++++ app/services/dell_service.py | 164 +++++++++ app/services/ldap_service.py | 84 +++++ app/services/pdf_service.py | 238 +++++++++++++ app/services/template_service.py | 221 ++++++++++++ app/templates/assets/detail.html | 453 ++++++++++++++++++++++++ app/templates/assets/form.html | 160 +++++++++ app/templates/assets/index.html | 300 ++++++++++++++++ app/templates/assignments/form.html | 133 +++++++ app/templates/assignments/index.html | 144 ++++++++ app/templates/audit/index.html | 99 ++++++ app/templates/auth/login.html | 52 +++ app/templates/base.html | 223 ++++++++++++ app/templates/dashboard/index.html | 169 +++++++++ app/templates/doc_templates/detail.html | 155 ++++++++ app/templates/doc_templates/edit.html | 41 +++ app/templates/doc_templates/index.html | 77 ++++ app/templates/doc_templates/upload.html | 96 +++++ app/templates/paperwork/detail.html | 238 +++++++++++++ app/templates/paperwork/form.html | 168 +++++++++ app/templates/paperwork/index.html | 110 ++++++ app/templates/settings/index.html | 126 +++++++ app/templates/users/detail.html | 211 +++++++++++ app/templates/users/form.html | 92 +++++ app/templates/users/import.html | 89 +++++ app/templates/users/index.html | 125 +++++++ config.py | 74 ++++ docker-compose.yml | 43 +++ info.txt | 0 init_db.py | 40 +++ requirements.txt | 15 + run.py | 7 + 58 files changed, 6468 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/extensions.py create mode 100644 app/models/__init__.py create mode 100644 app/models/admin_user.py create mode 100644 app/models/asset.py create mode 100644 app/models/assignment.py create mode 100644 app/models/audit_log.py create mode 100644 app/models/compliance_check.py create mode 100644 app/models/document_template.py create mode 100644 app/models/paperwork.py create mode 100644 app/models/user.py create mode 100644 app/routes/__init__.py create mode 100644 app/routes/assets.py create mode 100644 app/routes/assignments.py create mode 100644 app/routes/audit.py create mode 100644 app/routes/auth.py create mode 100644 app/routes/dashboard.py create mode 100644 app/routes/doc_templates.py create mode 100644 app/routes/paperwork.py create mode 100644 app/routes/settings.py create mode 100644 app/routes/users.py create mode 100644 app/services/__init__.py create mode 100644 app/services/csv_service.py create mode 100644 app/services/dell_service.py create mode 100644 app/services/ldap_service.py create mode 100644 app/services/pdf_service.py create mode 100644 app/services/template_service.py create mode 100644 app/templates/assets/detail.html create mode 100644 app/templates/assets/form.html create mode 100644 app/templates/assets/index.html create mode 100644 app/templates/assignments/form.html create mode 100644 app/templates/assignments/index.html create mode 100644 app/templates/audit/index.html create mode 100644 app/templates/auth/login.html create mode 100644 app/templates/base.html create mode 100644 app/templates/dashboard/index.html create mode 100644 app/templates/doc_templates/detail.html create mode 100644 app/templates/doc_templates/edit.html create mode 100644 app/templates/doc_templates/index.html create mode 100644 app/templates/doc_templates/upload.html create mode 100644 app/templates/paperwork/detail.html create mode 100644 app/templates/paperwork/form.html create mode 100644 app/templates/paperwork/index.html create mode 100644 app/templates/settings/index.html create mode 100644 app/templates/users/detail.html create mode 100644 app/templates/users/form.html create mode 100644 app/templates/users/import.html create mode 100644 app/templates/users/index.html create mode 100644 config.py create mode 100644 docker-compose.yml create mode 100644 info.txt create mode 100644 init_db.py create mode 100644 requirements.txt create mode 100644 run.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b924478 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4e547b --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.env +*.pyc +__pycache__/ +*.egg-info/ +dist/ +build/ +.venv/ +venv/ +uploads/ +pdfs/ +instance/ +migrations/ +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4a607e7 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d1d1b4 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..10f1cb7 --- /dev/null +++ b/app/__init__.py @@ -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 diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..2f08516 --- /dev/null +++ b/app/extensions.py @@ -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' diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..76eb9aa --- /dev/null +++ b/app/models/__init__.py @@ -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'] diff --git a/app/models/admin_user.py b/app/models/admin_user.py new file mode 100644 index 0000000..dbcb7f0 --- /dev/null +++ b/app/models/admin_user.py @@ -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'' + + +@login_manager.user_loader +def load_user(user_id): + return AdminUser.query.get(int(user_id)) diff --git a/app/models/asset.py b/app/models/asset.py new file mode 100644 index 0000000..f35c48d --- /dev/null +++ b/app/models/asset.py @@ -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'' diff --git a/app/models/assignment.py b/app/models/assignment.py new file mode 100644 index 0000000..94df479 --- /dev/null +++ b/app/models/assignment.py @@ -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'' diff --git a/app/models/audit_log.py b/app/models/audit_log.py new file mode 100644 index 0000000..1b4ea60 --- /dev/null +++ b/app/models/audit_log.py @@ -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'' diff --git a/app/models/compliance_check.py b/app/models/compliance_check.py new file mode 100644 index 0000000..ae41159 --- /dev/null +++ b/app/models/compliance_check.py @@ -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'' + ) diff --git a/app/models/document_template.py b/app/models/document_template.py new file mode 100644 index 0000000..47f22e7 --- /dev/null +++ b/app/models/document_template.py @@ -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'' diff --git a/app/models/paperwork.py b/app/models/paperwork.py new file mode 100644 index 0000000..9f7c60a --- /dev/null +++ b/app/models/paperwork.py @@ -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'' diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..5c04c21 --- /dev/null +++ b/app/models/user.py @@ -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'' diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..24dbc8d --- /dev/null +++ b/app/routes/__init__.py @@ -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', +] diff --git a/app/routes/assets.py b/app/routes/assets.py new file mode 100644 index 0000000..c416646 --- /dev/null +++ b/app/routes/assets.py @@ -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('/') +@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('//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('//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]) diff --git a/app/routes/assignments.py b/app/routes/assignments.py new file mode 100644 index 0000000..c49bfe3 --- /dev/null +++ b/app/routes/assignments.py @@ -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('//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')) diff --git a/app/routes/audit.py b/app/routes/audit.py new file mode 100644 index 0000000..71b6917 --- /dev/null +++ b/app/routes/audit.py @@ -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) diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..da53880 --- /dev/null +++ b/app/routes/auth.py @@ -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')) diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py new file mode 100644 index 0000000..1d10ce0 --- /dev/null +++ b/app/routes/dashboard.py @@ -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) diff --git a/app/routes/doc_templates.py b/app/routes/doc_templates.py new file mode 100644 index 0000000..99d5db8 --- /dev/null +++ b/app/routes/doc_templates.py @@ -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('/') +@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('//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('//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('//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('//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('//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}) diff --git a/app/routes/paperwork.py b/app/routes/paperwork.py new file mode 100644 index 0000000..694c9af --- /dev/null +++ b/app/routes/paperwork.py @@ -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('/') +@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('//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('//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('//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('//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('//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)) + diff --git a/app/routes/settings.py b/app/routes/settings.py new file mode 100644 index 0000000..df59442 --- /dev/null +++ b/app/routes/settings.py @@ -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//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')) diff --git a/app/routes/users.py b/app/routes/users.py new file mode 100644 index 0000000..3a838e6 --- /dev/null +++ b/app/routes/users.py @@ -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('/') +@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('//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('//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]) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..d1f46e2 --- /dev/null +++ b/app/services/__init__.py @@ -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'] diff --git a/app/services/csv_service.py b/app/services/csv_service.py new file mode 100644 index 0000000..4764004 --- /dev/null +++ b/app/services/csv_service.py @@ -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 diff --git a/app/services/dell_service.py b/app/services/dell_service.py new file mode 100644 index 0000000..b98c785 --- /dev/null +++ b/app/services/dell_service.py @@ -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) diff --git a/app/services/ldap_service.py b/app/services/ldap_service.py new file mode 100644 index 0000000..b4667d5 --- /dev/null +++ b/app/services/ldap_service.py @@ -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 diff --git a/app/services/pdf_service.py b/app/services/pdf_service.py new file mode 100644 index 0000000..aa156ab --- /dev/null +++ b/app/services/pdf_service.py @@ -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'{company_name}', styles['normal'])], + [Paragraph(company_address or '', styles['small'])], + ] + right_data = [ + [Paragraph(f'{doc_type_label}', 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'{label}', 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 diff --git a/app/services/template_service.py b/app/services/template_service.py new file mode 100644 index 0000000..0c7b11f --- /dev/null +++ b/app/services/template_service.py @@ -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 diff --git a/app/templates/assets/detail.html b/app/templates/assets/detail.html new file mode 100644 index 0000000..f206bc1 --- /dev/null +++ b/app/templates/assets/detail.html @@ -0,0 +1,453 @@ +{% extends 'base.html' %} +{% block title %}{{ asset.serial_number }} – IT Asset Management{% endblock %} +{% block breadcrumb %} + + + +{% endblock %} + +{% block content %} + + +
+ +
+
+
+ Asset Details +
+
+
+
Type
+
{{ asset.asset_type }}
+ +
Brand
+
{{ asset.brand or '—' }}
+ +
Model
+
{{ asset.model or '—' }}
+ +
Serial No.
+
{{ asset.serial_number }}
+ +
Service Tag
+
{{ asset.service_tag or '—' }}
+ +
Asset Tag
+
{{ asset.asset_tag or '—' }}
+ +
OS
+
{{ asset.operating_system or '—' }}
+ + {% if asset.processor %} +
CPU
+
{{ asset.processor }}
+ {% endif %} + + {% if asset.ram_gb %} +
RAM
+
{{ asset.ram_gb }} GB
+ {% endif %} + + {% if asset.storage_gb %} +
Storage
+
{{ asset.storage_gb }} GB
+ {% endif %} + + {% if asset.mac_address %} +
MAC
+
{{ asset.mac_address }}
+ {% endif %} +
+
+
+ +
+
+ Procurement +
+
+
+
Purchased
+
{{ asset.purchase_date.strftime('%d/%m/%Y') if asset.purchase_date else '—' }}
+ +
Warranty
+
{{ asset.warranty_expiry.strftime('%d/%m/%Y') if asset.warranty_expiry else '—' }}
+ +
Price
+
{{ '%.2f'|format(asset.purchase_price) if asset.purchase_price else '—' }}
+ +
Supplier
+
{{ asset.supplier or '—' }}
+ +
PO #
+
{{ asset.po_number or '—' }}
+ +
Location
+
{{ asset.location or '—' }}
+
+ {% if asset.notes %} +
+

{{ asset.notes }}

+ {% endif %} +
+
+
+ + +
+ +
+
+ Assignment History +
+
+ + + + + + + + + + + + + {% for a in history %} + + + + + + + + + {% else %} + + {% endfor %} + +
UserWindows IDFromUntilStatus
{{ a.user.display_name }}{{ a.user.windows_id }}{{ a.assigned_date.strftime('%d/%m/%Y') if a.assigned_date else '—' }}{{ a.returned_date.strftime('%d/%m/%Y') if a.returned_date else '—' }} + {% if a.is_active %} + Active + {% else %} + Returned + {% endif %} + + {% if a.is_active %} + + {% endif %} +
No assignment history.
+
+
+ + +
+
+ Documents + {% if asset.current_user %} + + New + + {% endif %} +
+
+ + + + + + {% for d in docs %} + + + + + + + + {% else %} + + {% endfor %} + +
TitleTypeUserDate
{{ d.title }}{{ d.doc_type_label }}{{ d.user.display_name }}{{ d.created_at.strftime('%d/%m/%Y') if d.created_at else '—' }} + {% if d.pdf_filename %} + + + + {% endif %} +
No documents.
+
+
+
+
+ +{% if asset.asset_type in ('Laptop', 'Desktop') %} + +
+
+ IT Compliance & Inventory + +
+ + +
+
+
+
Inventory #
+
{{ asset.inventory_number or '—' }}
+
+
+
AD Device Name
+
{{ asset.ad_device_name or '—' }}
+
+
+
Location Note
+
{{ asset.location_note or '—' }}
+
+
+ {% if asset.encryption_checked %} + Encrypted + {% else %} + Not Encrypted + {% endif %} + {% if asset.encryption_checked_by %} +
+ by {{ asset.encryption_checked_by.username }} + {% if asset.encryption_checked_at %} + — {{ asset.encryption_checked_at.strftime('%d/%m/%Y %H:%M') }} + {% endif %} +
+ {% endif %} +
+
+ {% if asset.backup_checked %} + Backup OK + {% else %} + No Backup + {% endif %} + {% if asset.backup_checked_by %} +
+ by {{ asset.backup_checked_by.username }} + {% if asset.backup_checked_at %} + — {{ asset.backup_checked_at.strftime('%d/%m/%Y %H:%M') }} + {% endif %} +
+ {% endif %} +
+
+ {% if asset.hr_notified %} + HR Notified + {% else %} + HR Pending + {% endif %} + {% if asset.hr_notified_by %} +
+ by {{ asset.hr_notified_by.username }} + {% if asset.hr_notified_at %} + — {{ asset.hr_notified_at.strftime('%d/%m/%Y %H:%M') }} + {% endif %} +
+ {% endif %} +
+
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ + {% if asset.current_user %} +
+ {{ asset.current_user.display_name }} ({{ asset.current_user.windows_id }}) +
+ {% else %} +
Not assigned
+ {% endif %} +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+
+
+
+ + +{% if compliance_log %} +
+
+ +{% if check_history %} +
+
+ Compliance Check History + +
+
+
+ + + + + + + + + + + + {% for entry in check_history %} + + + + + + + + {% endfor %} + +
Date & TimeCheckResultPerformed byNote
{{ entry.performed_at.strftime('%d/%m/%Y %H:%M') }}{{ entry.check_type_label }} + {% if entry.checked %} + Verified + {% else %} + Cleared + {% endif %} + + {% if entry.performed_by %} + {{ entry.performed_by.username }} + {% else %} + + {% endif %} + {{ entry.notes or '—' }}
+
+
+
+{% endif %} + +{% endif %}{# end asset_type in Laptop/Desktop #} + + +{% for a in history %}{% if a.is_active %} + +{% endif %}{% endfor %} +{% endblock %} diff --git a/app/templates/assets/form.html b/app/templates/assets/form.html new file mode 100644 index 0000000..a08e7c5 --- /dev/null +++ b/app/templates/assets/form.html @@ -0,0 +1,160 @@ +{% extends 'base.html' %} +{% block title %}{{ 'Edit Asset' if asset else 'New Asset' }} – IT Asset Management{% endblock %} +{% block breadcrumb %} + + + +{% endblock %} + +{% block content %} + + +
+
+ {% if not asset and prefill and prefill.service_tag %} +
+ + Pre-filled from Dell service tag {{ prefill.service_tag }}. Review the details below before saving. +
+ {% endif %} +
+ +
Identifiers
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
Classification
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
Technical Specs
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
Procurement
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + Cancel +
+
+
+
+{% endblock %} diff --git a/app/templates/assets/index.html b/app/templates/assets/index.html new file mode 100644 index 0000000..714303f --- /dev/null +++ b/app/templates/assets/index.html @@ -0,0 +1,300 @@ +{% extends 'base.html' %} +{% block title %}Assets – IT Asset Management{% endblock %} +{% block breadcrumb %} + + +{% endblock %} + +{% block content %} + + + +
+
+
+ + Dell Quick Import + + Enter a service tag to open the asset form pre-filled: +
+ + +
+
+ Loading… +
+ + + Full auto-fill available with a + free Dell TechDirect API key + +
+ + + + + +
+
+
+ + +
+
+
+ + +
+
+
+ +
+
+ +
+
+ + Clear +
+
+ +
+
+ + + + + + + + + + + + + + + {% for a in pagination.items %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
TypeBrand / ModelSerial NumberService TagStatusAssigned ToWarranty
{{ a.asset_type }}{{ a.brand or '' }} {{ a.model or '' }}{{ a.serial_number }}{{ a.service_tag or '—' }} + {{ a.status | title }} + + {% if a.current_user %} + + {{ a.current_user.display_name }} + + {% else %} + + {% endif %} + + {% if a.warranty_expiry %} + + {{ a.warranty_expiry.strftime('%d/%m/%Y') }} + + {% else %}—{% endif %} + + + + +
No assets found.
+
+ + {% if pagination.pages > 1 %} + + {% endif %} +
+{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/assignments/form.html b/app/templates/assignments/form.html new file mode 100644 index 0000000..c227f58 --- /dev/null +++ b/app/templates/assignments/form.html @@ -0,0 +1,133 @@ +{% extends 'base.html' %} +{% block title %}Assign Asset – IT Asset Management{% endblock %} +{% block breadcrumb %} + + + +{% endblock %} + +{% block content %} + + +
+
+
+
+ + + + +
+
+ +
+ + + + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + Cancel +
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/assignments/index.html b/app/templates/assignments/index.html new file mode 100644 index 0000000..2ab0338 --- /dev/null +++ b/app/templates/assignments/index.html @@ -0,0 +1,144 @@ +{% extends 'base.html' %} +{% block title %}Assignments – IT Asset Management{% endblock %} +{% block breadcrumb %} + + +{% endblock %} + +{% block content %} + + +
+
+
+ + +
+
+
+ +
+
+ + + + + + + + + + + + + + + {% for a in pagination.items %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
UserWindows IDAssetSerial NumberAssignedReturnedStatus
+ {{ a.user.display_name }} + {{ a.user.windows_id }} + + {{ a.asset.brand or '' }} {{ a.asset.model or '' }} + + {{ a.asset.serial_number }}{{ a.assigned_date.strftime('%d/%m/%Y') if a.assigned_date else '—' }}{{ a.returned_date.strftime('%d/%m/%Y') if a.returned_date else '—' }} + {% if a.is_active %} + Active + {% else %} + Returned + {% endif %} + + {% if a.is_active %} + + {% endif %} + + + +
No assignments found.
+
+ + {% if pagination.pages > 1 %} + + {% endif %} +
+ + +{% for a in pagination.items %}{% if a.is_active %} + +{% endif %}{% endfor %} +{% endblock %} diff --git a/app/templates/audit/index.html b/app/templates/audit/index.html new file mode 100644 index 0000000..c033ba1 --- /dev/null +++ b/app/templates/audit/index.html @@ -0,0 +1,99 @@ +{% extends 'base.html' %} +{% block title %}Audit Log – IT Asset Management{% endblock %} +{% block breadcrumb %} + + +{% endblock %} + +{% block content %} + + +
+
+ +
+
+ +
+
+ Clear +
+
+ +
+
+ + + + + + + + + + + + + + {% for e in pagination.items %} + + + + + + + + + + {% else %} + + {% endfor %} + +
Date/TimePerformed ByActionTableRecordDescriptionIP
{{ e.performed_at.strftime('%d/%m/%Y %H:%M') if e.performed_at else '—' }}{{ e.performed_by.username if e.performed_by else '' }} + {% set colours = {'create':'success','update':'primary','delete':'danger','mask':'purple','assign':'info','return':'warning','import':'secondary'} %} + {{ e.action }} + {{ e.table_name }}{{ e.record_id or '—' }}{{ e.description or '—' }}{{ e.ip_address or '—' }}
No audit entries found.
+
+ + {% if pagination.pages > 1 %} + + {% endif %} +
+{% endblock %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..44e83df --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,52 @@ + + + + + + Login – IT Asset Management + + + + + + + + + diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..03e1cf7 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,223 @@ + + + + + + {% block title %}IT Asset Management{% endblock %} + + + + + + {% block extra_head %}{% endblock %} + + + + + + + +
+ +
+ +
+ + + {{ now.strftime('%d %b %Y') if now else '' }} + +
+
+ + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for cat, msg in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+
+ + +{% block extra_js %}{% endblock %} + + diff --git a/app/templates/dashboard/index.html b/app/templates/dashboard/index.html new file mode 100644 index 0000000..493b979 --- /dev/null +++ b/app/templates/dashboard/index.html @@ -0,0 +1,169 @@ +{% extends 'base.html' %} +{% block title %}Dashboard – IT Asset Management{% endblock %} +{% block breadcrumb %}{% endblock %} + +{% block content %} + + + +
+ +
+
+
+ +
+
{{ stats.active_users }}
+
Active Users
+
+
+
+
+
+
+
+ +
+
{{ stats.masked_users }}
+
Masked Records
+
+
+
+
+ +
+
+
+ +
+
{{ stats.available_assets }}
+
Available Assets
+
+
+
+
+
+
+
+ +
+
{{ stats.assigned_assets }}
+
Assigned Assets
+
+
+
+
+
+
+
+ +
+
{{ stats.maintenance_assets }}
+
In Maintenance
+
+
+
+
+
+
+
+ +
+
{{ stats.total_assets }}
+
Total Assets
+
+
+
+
+
+
+
+ +
+
{{ stats.total_paperwork }}
+
Documents
+
+
+
+
+
+
+
+ +
+
{{ stats.active_assignments }}
+
Open Assignments
+
+
+
+
+
+ + + + + +
+
+ Current Assignments (latest 10) +
+
+ + + + + + + + + + + + + {% for a in recent_assignments %} + + + + + + + + + {% else %} + + {% endfor %} + +
UserWindows IDAssetSerial / Service TagSince
{{ a.user.display_name }}{{ a.user.windows_id }}{{ a.asset.brand or '' }} {{ a.asset.model or '' }} + {{ a.asset.serial_number }} + {% if a.asset.service_tag %}
{{ a.asset.service_tag }}{% endif %} +
{{ a.assigned_date.strftime('%d/%m/%Y') if a.assigned_date else '—' }} + + + +
No active assignments.
+
+
+{% endblock %} diff --git a/app/templates/doc_templates/detail.html b/app/templates/doc_templates/detail.html new file mode 100644 index 0000000..5dc6da2 --- /dev/null +++ b/app/templates/doc_templates/detail.html @@ -0,0 +1,155 @@ +{% extends 'base.html' %} +{% block title %}{{ tpl.name }} – Templates{% endblock %} +{% block breadcrumb %} + + + +{% endblock %} + +{% block content %} + + +
+ +
+
+
Details
+
+
+
Category
+
+ {% if tpl.category %} + {{ dict(doc_types)[tpl.category] if tpl.category in dict(doc_types) else tpl.category }} + {% else %}{% endif %} +
+
File
+
{{ tpl.filename }}
+
Uploaded
+
{{ tpl.created_at.strftime('%d %b %Y %H:%M') }}
+
By
+
{{ tpl.created_by.username if tpl.created_by else '—' }}
+
Docs generated
+
{{ tpl.paperwork_docs.count() }}
+
+ {% if tpl.description %} +
+

{{ tpl.description }}

+ {% endif %} +
+
+
+ + +
+
+
+ Detected Variables + {{ tpl.variables | length }} +
+ {% set vars = tpl.variables %} + {% if vars %} +
+

+ These placeholders were detected in the template file. + They will be filled automatically when generating a document. + PII variables (name, email, phone) + are replaced with [MASKED] when a user's record is erased. +

+ {% set pii = ['user_name','user_email','user_phone'] %} +
+ {% for v in vars %} +
+ + {% if v in pii %}{% else %}{% endif %} + {{ v }} + +
+ {% endfor %} +
+
+ PII masked on departure   + other retained +
+
+ {% else %} +
+ No variables detected. Make sure your template uses {{ variable_name }} syntax + and click Re-scan. +
+ {% endif %} +
+
+
+ + +{% set recent_docs = tpl.paperwork_docs.order_by('created_at desc').limit(10).all() %} +{% if recent_docs %} +
+
Recently Generated Documents
+
+ + + + + + {% for doc in recent_docs %} + + + + + + + + {% endfor %} + +
TitleUserCreatedSigned
{{ doc.title }}{{ doc.user.display_name if doc.user else '—' }}{{ doc.created_at.strftime('%d/%m/%Y') }}{% if doc.is_signed %}{% else %}{% endif %}
+
+
+{% endif %} + + + +{% endblock %} diff --git a/app/templates/doc_templates/edit.html b/app/templates/doc_templates/edit.html new file mode 100644 index 0000000..4f3173c --- /dev/null +++ b/app/templates/doc_templates/edit.html @@ -0,0 +1,41 @@ +{% extends 'base.html' %} +{% block title %}Edit {{ tpl.name }} – Templates{% endblock %} +{% block breadcrumb %} + + + + +{% endblock %} + +{% block content %} + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + Cancel +
+
+
+
+{% endblock %} diff --git a/app/templates/doc_templates/index.html b/app/templates/doc_templates/index.html new file mode 100644 index 0000000..2ea3c70 --- /dev/null +++ b/app/templates/doc_templates/index.html @@ -0,0 +1,77 @@ +{% extends 'base.html' %} +{% block title %}Document Templates – IT Asset Management{% endblock %} +{% block breadcrumb %} + + +{% endblock %} + +{% block content %} + + +
+ + Upload .docx Word files with {{ variable_name }} 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. +
+ +{% if templates %} +
+ {% for tpl in templates %} +
+
+
+
+
+ {{ tpl.name }} +
+ {% if tpl.category %} + {{ dict(doc_types)[tpl.category] if tpl.category in dict(doc_types) else tpl.category }} + {% endif %} +
+ {% if tpl.description %} +

{{ tpl.description }}

+ {% endif %} +
+ + {% set vars = tpl.variables %} + {% if vars %} + {{ vars | length }} variable(s): + {% for v in vars[:5] %}{{ v }}{% endfor %} + {% if vars | length > 5 %}+{{ vars | length - 5 }} more{% endif %} + {% else %} + No variables detected + {% endif %} +
+
+ + View + + + Download + + + {{ tpl.paperwork_docs.count() }} doc(s) generated + +
+
+ +
+
+ {% endfor %} +
+{% else %} +
+ + No templates yet. Upload your first template. +
+{% endif %} +{% endblock %} diff --git a/app/templates/doc_templates/upload.html b/app/templates/doc_templates/upload.html new file mode 100644 index 0000000..c00ca89 --- /dev/null +++ b/app/templates/doc_templates/upload.html @@ -0,0 +1,96 @@ +{% extends 'base.html' %} +{% block title %}Upload Template – IT Asset Management{% endblock %} +{% block breadcrumb %} + + + +{% endblock %} + +{% block content %} + + +
+
+
+
+
+
+ + +
+
+ + +
Used to pre-select this template when creating paperwork of that type.
+
+
+ + +
+
+ + +
Word document (.docx) with {{ variable_name }} placeholders.
+
+
+ + Cancel +
+
+
+
+
+ +
+
+
+ Available Variables +
+
+ + + + {% 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 %} + + + + + {% endfor %} + +
VariableValue
{{ {{ var }} }}{{ desc }}
+
+
+
+
+{% endblock %} diff --git a/app/templates/paperwork/detail.html b/app/templates/paperwork/detail.html new file mode 100644 index 0000000..3c29355 --- /dev/null +++ b/app/templates/paperwork/detail.html @@ -0,0 +1,238 @@ +{% extends 'base.html' %} +{% block title %}{{ doc.title }} – IT Asset Management{% endblock %} +{% block breadcrumb %} + + + +{% endblock %} + +{% block content %} + + +
+ +
+
+
+ Document Info +
+
+
+
Type
+
{{ doc.doc_type_label }}
+ +
User
+
+ {{ doc.user.display_name }} +
WID: {{ doc.user.windows_id }} +
+ + {% if doc.asset %} +
Asset
+
+ + {{ doc.asset.brand or '' }} {{ doc.asset.model or '' }} + +
{{ doc.asset.serial_number }} +
+ {% endif %} + + {% if doc.template %} +
Template
+
+ {{ doc.template.name }} +
+ {% endif %} + +
Created
+
{{ doc.created_at.strftime('%d/%m/%Y %H:%M') if doc.created_at else '—' }}
+ +
Created by
+
{{ doc.created_by.username if doc.created_by else '—' }}
+ +
PDF
+
+ {% if doc.pdf_filename %}Generated + {% else %}Not generated{% endif %} +
+ +
Word doc
+
+ {% if doc.docx_filename %}Available + {% else %}None{% endif %} +
+ +
Signed
+
+ {% if doc.is_signed %} + + {{ doc.signed_by_name }} + +
{{ doc.signed_at.strftime('%d/%m/%Y %H:%M') }} + {% else %} + Unsigned + {% endif %} +
+
+
+
+ + + {% if doc.is_signed %} +
+
+ Signature +
+ +
+
+
+ {% if doc.signature_data %} + Signature + {% endif %} +

+ Signed by {{ doc.signed_by_name }}
+ {{ doc.signed_at.strftime('%d/%m/%Y at %H:%M') }} +

+
+
+ {% else %} + +
+
+ Sign Document +
+
+
+
+ + +
+
+ + + +
+ +
+
+ +
+
+
+ {% endif %} +
+ + +
+ {% if doc.notes %} +
+
+ Notes +
+
+

{{ doc.notes }}

+
+
+ {% endif %} + + {% if merge_vars %} +
+
+ Merge Variables Used + +
+
+
+ + + + {% set PII = ['user_name','user_email','user_phone'] %} + {% for k, v in merge_vars.items()|sort %} + + + + + {% endfor %} + +
VariableValue
{{ '{{' }} {{ k }} {{ '}}' }} + {% if k in PII %}PII{% endif %} + {{ v or '—' }}
+
+
+
+ {% endif %} +
+
+{% endblock %} + +{% block extra_js %} +{% if not doc.is_signed %} + +{% endif %} +{% endblock %} diff --git a/app/templates/paperwork/form.html b/app/templates/paperwork/form.html new file mode 100644 index 0000000..36b6279 --- /dev/null +++ b/app/templates/paperwork/form.html @@ -0,0 +1,168 @@ +{% extends 'base.html' %} +{% block title %}New Document – IT Asset Management{% endblock %} +{% block breadcrumb %} + + + +{% endblock %} + +{% block content %} + + +
+
+
+
+
+ + +
+
+ + +
+
+ + + {% if all_templates %} +
+ + + + +
+ {% endif %} + + +
+ + + + +
+
+ + +
+ + + + +
+
+ + {% if preselect_assignment_id %} + + {% endif %} + +
+ + +
+ +
+ + Cancel +
+
+
+
+ +{% if all_templates %} + +{% endif %} +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/paperwork/index.html b/app/templates/paperwork/index.html new file mode 100644 index 0000000..5cc50dd --- /dev/null +++ b/app/templates/paperwork/index.html @@ -0,0 +1,110 @@ +{% extends 'base.html' %} +{% block title %}Paperwork – IT Asset Management{% endblock %} +{% block breadcrumb %} + + +{% endblock %} + +{% block content %} + + +
+
+ +
+
+ Clear +
+
+ +
+
+ + + + + + + + + + + + + + {% for d in pagination.items %} + + + + + + + + + + {% else %} + + {% endfor %} + +
TitleTypeUserAsset SNCreatedPDF
{{ d.title }}{{ d.doc_type_label }} + {{ d.user.display_name }} + {{ d.asset.serial_number if d.asset else '—' }}{{ d.created_at.strftime('%d/%m/%Y') if d.created_at else '—' }} + {% if d.pdf_filename %} + Ready + {% else %} + + {% endif %} + + + + + {% if d.pdf_filename %} + + + + {% endif %} +
No documents found.
+
+ + {% if pagination.pages > 1 %} + + {% endif %} +
+{% endblock %} diff --git a/app/templates/settings/index.html b/app/templates/settings/index.html new file mode 100644 index 0000000..1b9ff13 --- /dev/null +++ b/app/templates/settings/index.html @@ -0,0 +1,126 @@ +{% extends 'base.html' %} +{% block title %}Settings – IT Asset Management{% endblock %} +{% block breadcrumb %} + + +{% endblock %} + +{% block content %} + + +
+ +
+
+
+ Admin Users +
+
+ + + + + + {% for a in admins %} + + + + + + + + + + {% endfor %} + +
UsernameFull NameEmailRoleLast LoginActive
{{ a.username }}{{ a.full_name or '—' }}{{ a.email }}{{ a.role }}{{ a.last_login.strftime('%d/%m/%Y') if a.last_login else '—' }} + {% if a.is_active %} + Active + {% else %} + Inactive + {% endif %} + + {% if a.id != current_user.id %} +
+ +
+ {% endif %} +
+
+ + + +
+
+ + +
+
+
+ LDAP / AD Configuration +
+
+

+ LDAP settings are managed via environment variables (see .env file). + Restart the application after changing these values. +

+ + + + + + + + + +
LDAP_SERVER{{ config.LDAP_SERVER or '(not set)' }}
LDAP_PORT{{ config.LDAP_PORT }}
LDAP_USE_SSL{{ config.LDAP_USE_SSL }}
LDAP_BASE_DN{{ config.LDAP_BASE_DN or '(not set)' }}
LDAP_BIND_USER{{ config.LDAP_BIND_USER or '(not set)' }}
Windows ID attr{{ config.LDAP_WINDOWS_ID_ATTR }}
+ +
+
+ +
+
+ Company Info (for PDFs) +
+
+ + + + + +
COMPANY_NAME{{ config.COMPANY_NAME or '(not set)' }}
COMPANY_ADDRESS{{ config.COMPANY_ADDRESS or '(not set)' }}
+

Edit these in .env and restart.

+
+
+
+
+{% endblock %} diff --git a/app/templates/users/detail.html b/app/templates/users/detail.html new file mode 100644 index 0000000..757c8c3 --- /dev/null +++ b/app/templates/users/detail.html @@ -0,0 +1,211 @@ +{% extends 'base.html' %} +{% block title %}{{ user.display_name }} – IT Asset Management{% endblock %} +{% block breadcrumb %} + + + +{% endblock %} + +{% block content %} + + +
+ +
+
+
+ User Information +
+
+
+
Windows ID
+
{{ user.windows_id }}
+ +
Full Name
+
{{ user.display_name }}
+ +
Email
+
{{ user.display_email }}
+ +
Phone
+
{{ user.display_phone }}
+ +
Department
+
{{ user.department or '—' }}
+ +
Job Title
+
{{ user.job_title or '—' }}
+ +
Location
+
{{ user.location or '—' }}
+ +
Source
+
{{ user.import_source }}
+ +
Status
+
+ {% if user.is_masked %} + Masked +
{{ user.masked_at.strftime('%d/%m/%Y') if user.masked_at else '' }} + {% elif user.is_active %} + Active + {% else %} + Inactive + {% endif %} +
+ +
Added
+
{{ user.created_at.strftime('%d/%m/%Y') if user.created_at else '—' }}
+
+
+
+
+ + +
+
+
+ Asset History + {% if not user.is_masked %} + + Assign + + {% endif %} +
+
+ + + + + + + + + + + + + {% for a in assignments %} + + + + + + + + + {% else %} + + {% endfor %} + +
AssetSN / Service TagFromUntilStatus
{{ a.asset.brand or '' }} {{ a.asset.model or '' }} + {{ a.asset.serial_number }} + {% if a.asset.service_tag %}
{{ a.asset.service_tag }}{% endif %} +
{{ a.assigned_date.strftime('%d/%m/%Y') if a.assigned_date else '—' }}{{ a.returned_date.strftime('%d/%m/%Y') if a.returned_date else 'current' | safe }} + {% if a.is_active %} + Active + {% else %} + Returned + {% endif %} + + + + +
No assignments.
+
+
+ + +
+
+ Documents + {% if not user.is_masked %} + + New + + {% endif %} +
+
+ + + + + + {% for d in docs %} + + + + + + + + {% else %} + + {% endfor %} + +
TitleTypeAssetDate
{{ d.title }}{{ d.doc_type_label }}{{ d.asset.serial_number if d.asset else '—' }}{{ d.created_at.strftime('%d/%m/%Y') if d.created_at else '—' }} + {% if d.pdf_filename %} + + + + {% endif %} +
No documents.
+
+
+
+
+ + +{% if not user.is_masked %} + +{% endif %} +{% endblock %} diff --git a/app/templates/users/form.html b/app/templates/users/form.html new file mode 100644 index 0000000..ecfb4cc --- /dev/null +++ b/app/templates/users/form.html @@ -0,0 +1,92 @@ +{% extends 'base.html' %} +{% block title %}{{ user.display_name if user else 'New User' }} – IT Asset Management{% endblock %} +{% block breadcrumb %} + + + +{% endblock %} + +{% block content %} + + +
+
+
+
Identity
+
+
+ + +
Numeric ID e.g. 408525
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+
Organisation
+
+
+ + +
+
+ + +
+
+ + +
+
+ + {% if user %} +
+
+ + +
+
+ {% endif %} + +
+ + Cancel +
+
+
+
+{% endblock %} diff --git a/app/templates/users/import.html b/app/templates/users/import.html new file mode 100644 index 0000000..0a48a5a --- /dev/null +++ b/app/templates/users/import.html @@ -0,0 +1,89 @@ +{% extends 'base.html' %} +{% block title %}Import Users – IT Asset Management{% endblock %} +{% block breadcrumb %} + + + +{% endblock %} + +{% block content %} + + +
+ +
+
+
+ Import from CSV +
+
+

+ Upload a CSV file with employee data. The file must include a + windows_id column. Additional columns are matched by common aliases. +

+
+ Recognised column names:
+ windows_id, first_name, last_name, + email, department, job_title, + phone, location +
(case-insensitive, spaces or underscores) +
+ +
+
+ + +
+ +
+
+
+
+ + +
+
+
+ Sync from Active Directory +
+
+

+ Connects to the LDAP/AD server configured in Settings and upserts all + matching user accounts. Masked users are never overwritten. +

+
+ + Existing non-masked users will be updated with fresh AD data. + New accounts will be created. Masked records are skipped. +
+
+ +
+ +
+ + Configure LDAP server, bind credentials and base DN in + Settings. + +
+
+
+
+ + +
+
+
CSV Template
+

Your CSV should look like this:

+
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
+
+
+{% endblock %} diff --git a/app/templates/users/index.html b/app/templates/users/index.html new file mode 100644 index 0000000..39bf9a1 --- /dev/null +++ b/app/templates/users/index.html @@ -0,0 +1,125 @@ +{% extends 'base.html' %} +{% block title %}Users – IT Asset Management{% endblock %} +{% block breadcrumb %} + + +{% endblock %} + +{% block content %} + + + +
+
+
+ + +
+
+
+
+ + +
+
+
+ + Clear +
+
+ +
+
+ + + + + + + + + + + + + + + {% for u in pagination.items %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
Windows IDNameEmailDepartmentJob TitleSourceStatus
{{ u.windows_id }} + {{ u.display_name }} + {% if u.is_masked %}MASKED{% endif %} + {{ u.display_email }}{{ u.department or '—' }}{{ u.job_title or '—' }} + {{ u.import_source }} + + {% if not u.is_masked and u.is_active %} + Active + {% elif not u.is_masked %} + Inactive + {% else %} + Masked + {% endif %} + + + + +
No users found.
+
+ + + {% if pagination.pages > 1 %} + + {% endif %} +
+{% endblock %} diff --git a/config.py b/config.py new file mode 100644 index 0000000..1648188 --- /dev/null +++ b/config.py @@ -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, +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7864c4c --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/info.txt b/info.txt new file mode 100644 index 0000000..e69de29 diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..cc6f1ac --- /dev/null +++ b/init_db.py @@ -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.') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..578f974 --- /dev/null +++ b/requirements.txt @@ -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] diff --git a/run.py b/run.py new file mode 100644 index 0000000..34b6c92 --- /dev/null +++ b/run.py @@ -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)