Initial commit: add compliance_checks table, per-check metadata on assets, and compliance audit trail
This commit is contained in:
37
.env.example
Normal file
37
.env.example
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Copy this file to .env and fill in the values
|
||||||
|
|
||||||
|
# Flask
|
||||||
|
SECRET_KEY=change-this-to-a-long-random-string
|
||||||
|
FLASK_ENV=development
|
||||||
|
|
||||||
|
# MySQL
|
||||||
|
MYSQL_HOST=db
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER=itasset_user
|
||||||
|
MYSQL_PASSWORD=itasset_pass
|
||||||
|
MYSQL_DB=itasset_db
|
||||||
|
MYSQL_ROOT_PASSWORD=rootpassword
|
||||||
|
|
||||||
|
# LDAP / Active Directory (leave blank to disable LDAP sync)
|
||||||
|
LDAP_SERVER=ldap://your-dc.company.local
|
||||||
|
LDAP_PORT=389
|
||||||
|
LDAP_USE_SSL=false
|
||||||
|
LDAP_BIND_USER=CN=svc-itasset,OU=Service Accounts,DC=company,DC=local
|
||||||
|
LDAP_BIND_PASSWORD=service-account-password
|
||||||
|
LDAP_BASE_DN=OU=Users,DC=company,DC=local
|
||||||
|
LDAP_USER_SEARCH_FILTER=(&(objectClass=person)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))
|
||||||
|
# Attribute in AD that stores the numeric Windows ID (employeeID is common)
|
||||||
|
LDAP_WINDOWS_ID_ATTR=employeeID
|
||||||
|
|
||||||
|
# Company info for PDF generation
|
||||||
|
COMPANY_NAME=Your Company Name
|
||||||
|
COMPANY_ADDRESS=123 Street, City, Country
|
||||||
|
|
||||||
|
# Dell TechDirect API (for automatic service-tag lookup)
|
||||||
|
# Register at https://tdm.dell.com → API Services → Create an API key pair
|
||||||
|
DELL_CLIENT_ID=
|
||||||
|
DELL_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# File storage (relative to project root)
|
||||||
|
UPLOAD_FOLDER=uploads
|
||||||
|
PDF_FOLDER=pdfs
|
||||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.env
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
uploads/
|
||||||
|
pdfs/
|
||||||
|
instance/
|
||||||
|
migrations/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# System dependencies (none needed for reportlab + PyMySQL)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
default-libmysqlclient-dev \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create storage directories
|
||||||
|
RUN mkdir -p uploads pdfs
|
||||||
|
|
||||||
|
ENV FLASK_ENV=production
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["python", "run.py"]
|
||||||
132
README.md
Normal file
132
README.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# IT Hardware Asset Management System
|
||||||
|
|
||||||
|
A full-featured web application built with **Python / Flask + MySQL**, containerised with **Docker Compose**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
| Module | Capabilities |
|
||||||
|
|---|---|
|
||||||
|
| **Users** | Manual, CSV, and LDAP/AD import · search · GDPR masking |
|
||||||
|
| **Assets** | Track by Serial Number + Service Tag · full history |
|
||||||
|
| **Assignments** | Assign/return assets · complete audit trail |
|
||||||
|
| **Paperwork** | Generate PDF documents (handover, assignment, return, custom) |
|
||||||
|
| **Audit Log** | Immutable log of every create / update / delete / mask action |
|
||||||
|
| **Settings** | Admin user management · LDAP config view |
|
||||||
|
|
||||||
|
### User masking (GDPR / off-boarding)
|
||||||
|
When an employee leaves, press **Mask User** to permanently erase PII (name, email, phone).
|
||||||
|
The record is kept — linked by the permanent **Windows ID** — so full asset history is preserved for audits, without exposing personal data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start (Docker Compose)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Copy the example env file and fill in your values
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env
|
||||||
|
|
||||||
|
# 2. Build and start
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 3. Open the app
|
||||||
|
http://localhost:5000
|
||||||
|
|
||||||
|
# Default credentials (change immediately in Settings!)
|
||||||
|
Username: admin
|
||||||
|
Password: ChangeMe123!
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running locally (without Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create a virtual environment
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # Linux/macOS
|
||||||
|
# venv\Scripts\activate # Windows
|
||||||
|
|
||||||
|
# 2. Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 3. Configure environment
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env — set MYSQL_HOST=localhost and your DB credentials
|
||||||
|
|
||||||
|
# 4. Initialise the database
|
||||||
|
flask db init
|
||||||
|
flask db migrate -m "initial"
|
||||||
|
flask db upgrade
|
||||||
|
python init_db.py
|
||||||
|
|
||||||
|
# 5. Run
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables (`.env`)
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `SECRET_KEY` | Flask secret key | *(must change)* |
|
||||||
|
| `MYSQL_HOST` | MySQL host | `db` (Docker) / `localhost` |
|
||||||
|
| `MYSQL_USER` | MySQL user | `itasset_user` |
|
||||||
|
| `MYSQL_PASSWORD` | MySQL password | `itasset_pass` |
|
||||||
|
| `MYSQL_DB` | Database name | `itasset_db` |
|
||||||
|
| `LDAP_SERVER` | LDAP server URL | *(blank = disabled)* |
|
||||||
|
| `LDAP_BIND_USER` | Service account DN | — |
|
||||||
|
| `LDAP_BIND_PASSWORD` | Service account password | — |
|
||||||
|
| `LDAP_BASE_DN` | Search base | — |
|
||||||
|
| `LDAP_WINDOWS_ID_ATTR` | AD attribute for numeric ID | `employeeID` |
|
||||||
|
| `COMPANY_NAME` | Shown on PDF headers | `Your Company Name` |
|
||||||
|
| `COMPANY_ADDRESS` | Shown on PDF headers | — |
|
||||||
|
| `DEFAULT_ADMIN_USER` | First-run admin username | `admin` |
|
||||||
|
| `DEFAULT_ADMIN_PASS` | First-run admin password | `ChangeMe123!` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSV Import Format
|
||||||
|
|
||||||
|
```csv
|
||||||
|
windows_id,first_name,last_name,email,department,job_title,location
|
||||||
|
408525,John,Doe,john.doe@company.com,IT,Engineer,HQ
|
||||||
|
408526,Jane,Smith,jane.smith@company.com,HR,Manager,HQ
|
||||||
|
```
|
||||||
|
|
||||||
|
Column names are matched case-insensitively. `windows_id` is required; all others are optional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
IT_asset_management/
|
||||||
|
├── app/
|
||||||
|
│ ├── __init__.py # App factory
|
||||||
|
│ ├── extensions.py # SQLAlchemy, Flask-Login, Migrate
|
||||||
|
│ ├── models/ # User, Asset, Assignment, Paperwork, AuditLog, AdminUser
|
||||||
|
│ ├── routes/ # auth, dashboard, users, assets, assignments, paperwork, audit, settings
|
||||||
|
│ ├── services/ # csv_service, ldap_service, pdf_service
|
||||||
|
│ └── templates/ # Jinja2 + Bootstrap 5
|
||||||
|
├── config.py
|
||||||
|
├── run.py
|
||||||
|
├── init_db.py
|
||||||
|
├── requirements.txt
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Change `DEFAULT_ADMIN_PASS` immediately after first login.
|
||||||
|
- Set a strong random `SECRET_KEY` in `.env`.
|
||||||
|
- The LDAP bind password is read from the environment — never commit `.env` to source control.
|
||||||
|
- PDF files are stored server-side in the `pdfs/` directory (Docker volume). Restrict access as needed.
|
||||||
|
- Masked user records permanently destroy PII and cannot be reversed.
|
||||||
57
app/__init__.py
Normal file
57
app/__init__.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import os
|
||||||
|
from datetime import datetime, date
|
||||||
|
from flask import Flask
|
||||||
|
from config import config
|
||||||
|
from app.extensions import db, migrate, login_manager
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_name='default'):
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config.from_object(config[config_name])
|
||||||
|
|
||||||
|
# Ensure storage directories exist
|
||||||
|
for folder_key in ('UPLOAD_FOLDER', 'PDF_FOLDER', 'TEMPLATE_FOLDER', 'DOCX_FOLDER'):
|
||||||
|
folder = os.path.join(app.root_path, '..', app.config[folder_key])
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
|
||||||
|
# Initialize extensions
|
||||||
|
db.init_app(app)
|
||||||
|
migrate.init_app(app, db)
|
||||||
|
login_manager.init_app(app)
|
||||||
|
|
||||||
|
# Import models so Flask-Migrate detects them
|
||||||
|
from app.models import admin_user, user, asset, assignment, paperwork, audit_log, document_template # noqa: F401
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
from app.routes.auth import bp as auth_bp
|
||||||
|
from app.routes.dashboard import bp as dashboard_bp
|
||||||
|
from app.routes.users import bp as users_bp
|
||||||
|
from app.routes.assets import bp as assets_bp
|
||||||
|
from app.routes.assignments import bp as assignments_bp
|
||||||
|
from app.routes.paperwork import bp as paperwork_bp
|
||||||
|
from app.routes.audit import bp as audit_bp
|
||||||
|
from app.routes.settings import bp as settings_bp
|
||||||
|
from app.routes.doc_templates import bp as doc_templates_bp
|
||||||
|
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(dashboard_bp)
|
||||||
|
app.register_blueprint(users_bp)
|
||||||
|
app.register_blueprint(assets_bp)
|
||||||
|
app.register_blueprint(assignments_bp)
|
||||||
|
app.register_blueprint(paperwork_bp)
|
||||||
|
app.register_blueprint(audit_bp)
|
||||||
|
app.register_blueprint(settings_bp)
|
||||||
|
app.register_blueprint(doc_templates_bp)
|
||||||
|
|
||||||
|
# Inject common template variables
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_globals():
|
||||||
|
return {
|
||||||
|
'now': datetime.utcnow(),
|
||||||
|
'today': date.today(),
|
||||||
|
'today_date': date.today().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return app
|
||||||
10
app/extensions.py
Normal file
10
app/extensions.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
from flask_login import LoginManager
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
migrate = Migrate()
|
||||||
|
login_manager = LoginManager()
|
||||||
|
login_manager.login_view = 'auth.login'
|
||||||
|
login_manager.login_message = 'Please log in to access this page.'
|
||||||
|
login_manager.login_message_category = 'warning'
|
||||||
9
app/models/__init__.py
Normal file
9
app/models/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.asset import Asset
|
||||||
|
from app.models.assignment import Assignment
|
||||||
|
from app.models.paperwork import Paperwork
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.compliance_check import ComplianceCheck
|
||||||
|
|
||||||
|
__all__ = ['AdminUser', 'User', 'Asset', 'Assignment', 'Paperwork', 'AuditLog', 'ComplianceCheck']
|
||||||
33
app/models/admin_user.py
Normal file
33
app/models/admin_user.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from flask_login import UserMixin
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
from app.extensions import db, login_manager
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUser(UserMixin, db.Model):
|
||||||
|
"""IT staff accounts that manage this application."""
|
||||||
|
__tablename__ = 'admin_users'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
username = db.Column(db.String(100), unique=True, nullable=False)
|
||||||
|
full_name = db.Column(db.String(200), nullable=True)
|
||||||
|
email = db.Column(db.String(200), unique=True, nullable=False)
|
||||||
|
password_hash = db.Column(db.String(256), nullable=False)
|
||||||
|
role = db.Column(db.String(30), default='admin') # admin, readonly
|
||||||
|
is_active = db.Column(db.Boolean, default=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
last_login = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
def set_password(self, password):
|
||||||
|
self.password_hash = generate_password_hash(password)
|
||||||
|
|
||||||
|
def check_password(self, password):
|
||||||
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<AdminUser {self.username}>'
|
||||||
|
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
return AdminUser.query.get(int(user_id))
|
||||||
114
app/models/asset.py
Normal file
114
app/models/asset.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
ASSET_TYPES = [
|
||||||
|
'Laptop', 'Desktop', 'Monitor', 'Keyboard', 'Mouse',
|
||||||
|
'Headset', 'Docking Station', 'Printer', 'Scanner',
|
||||||
|
'Tablet', 'Phone', 'Server', 'Network Equipment', 'Other',
|
||||||
|
]
|
||||||
|
|
||||||
|
ASSET_STATUSES = [
|
||||||
|
('available', 'Available'),
|
||||||
|
('assigned', 'Assigned'),
|
||||||
|
('maintenance', 'In Maintenance'),
|
||||||
|
('retired', 'Retired'),
|
||||||
|
('lost', 'Lost / Stolen'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Asset(db.Model):
|
||||||
|
"""Hardware asset tracked by serial number and/or service tag."""
|
||||||
|
__tablename__ = 'assets'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
||||||
|
# Primary identifiers
|
||||||
|
serial_number = db.Column(db.String(200), unique=True, nullable=False, index=True)
|
||||||
|
service_tag = db.Column(db.String(200), unique=True, nullable=True, index=True)
|
||||||
|
asset_tag = db.Column(db.String(100), nullable=True) # internal barcode / tag
|
||||||
|
|
||||||
|
# Classification
|
||||||
|
asset_type = db.Column(db.String(50), nullable=False)
|
||||||
|
brand = db.Column(db.String(100), nullable=True)
|
||||||
|
model = db.Column(db.String(150), nullable=True)
|
||||||
|
|
||||||
|
# Technical specs (optional)
|
||||||
|
processor = db.Column(db.String(200), nullable=True)
|
||||||
|
ram_gb = db.Column(db.Integer, nullable=True)
|
||||||
|
storage_gb = db.Column(db.Integer, nullable=True)
|
||||||
|
operating_system = db.Column(db.String(100), nullable=True)
|
||||||
|
mac_address = db.Column(db.String(50), nullable=True)
|
||||||
|
|
||||||
|
# Procurement
|
||||||
|
purchase_date = db.Column(db.Date, nullable=True)
|
||||||
|
warranty_expiry = db.Column(db.Date, nullable=True)
|
||||||
|
purchase_price = db.Column(db.Numeric(10, 2), nullable=True)
|
||||||
|
supplier = db.Column(db.String(200), nullable=True)
|
||||||
|
po_number = db.Column(db.String(100), nullable=True)
|
||||||
|
|
||||||
|
# Current state
|
||||||
|
status = db.Column(db.String(30), default='available', nullable=False)
|
||||||
|
location = db.Column(db.String(200), nullable=True)
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Compliance / IT checks — Desktop & Laptop only
|
||||||
|
inventory_number = db.Column(db.String(100), nullable=True)
|
||||||
|
ad_device_name = db.Column(db.String(150), nullable=True)
|
||||||
|
location_note = db.Column(db.Text, nullable=True) # free-text location note
|
||||||
|
|
||||||
|
# Current boolean state
|
||||||
|
encryption_checked = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
backup_checked = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
hr_notified = db.Column(db.Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
# Who last changed each check and when
|
||||||
|
encryption_checked_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||||
|
encryption_checked_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
backup_checked_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||||
|
backup_checked_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
hr_notified_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||||
|
hr_notified_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
assignments = db.relationship(
|
||||||
|
'Assignment', backref='asset', lazy='dynamic', cascade='all, delete-orphan'
|
||||||
|
)
|
||||||
|
paperwork_docs = db.relationship(
|
||||||
|
'Paperwork', backref='asset', lazy='dynamic'
|
||||||
|
)
|
||||||
|
compliance_checks = db.relationship(
|
||||||
|
'ComplianceCheck', back_populates='asset', lazy='dynamic',
|
||||||
|
cascade='all, delete-orphan',
|
||||||
|
order_by='ComplianceCheck.performed_at.desc()',
|
||||||
|
)
|
||||||
|
created_by = db.relationship('AdminUser', foreign_keys=[created_by_id])
|
||||||
|
encryption_checked_by = db.relationship('AdminUser', foreign_keys=[encryption_checked_by_id])
|
||||||
|
backup_checked_by = db.relationship('AdminUser', foreign_keys=[backup_checked_by_id])
|
||||||
|
hr_notified_by = db.relationship('AdminUser', foreign_keys=[hr_notified_by_id])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_assignment(self):
|
||||||
|
return self.assignments.filter_by(is_active=True).first()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_user(self):
|
||||||
|
a = self.current_assignment
|
||||||
|
return a.user if a else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_badge(self):
|
||||||
|
colours = {
|
||||||
|
'available': 'success',
|
||||||
|
'assigned': 'primary',
|
||||||
|
'maintenance': 'warning',
|
||||||
|
'retired': 'secondary',
|
||||||
|
'lost': 'danger',
|
||||||
|
}
|
||||||
|
return colours.get(self.status, 'secondary')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Asset sn={self.serial_number} type={self.asset_type}>'
|
||||||
37
app/models/assignment.py
Normal file
37
app/models/assignment.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class Assignment(db.Model):
|
||||||
|
"""Records the assignment of an asset to a user.
|
||||||
|
|
||||||
|
Every assignment (including past ones) is kept permanently so that
|
||||||
|
asset history is preserved even after a user record is masked.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'assignments'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
||||||
|
asset_id = db.Column(db.Integer, db.ForeignKey('assets.id'), nullable=False)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
|
||||||
|
assigned_date = db.Column(db.Date, nullable=False, default=datetime.utcnow)
|
||||||
|
returned_date = db.Column(db.Date, nullable=True)
|
||||||
|
|
||||||
|
assigned_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||||
|
returned_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||||
|
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
assigned_by = db.relationship('AdminUser', foreign_keys=[assigned_by_id])
|
||||||
|
returned_by = db.relationship('AdminUser', foreign_keys=[returned_by_id])
|
||||||
|
|
||||||
|
# Paperwork linked to this assignment
|
||||||
|
paperwork_docs = db.relationship('Paperwork', backref='assignment', lazy='dynamic')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Assignment asset={self.asset_id} user={self.user_id} active={self.is_active}>'
|
||||||
27
app/models/audit_log.py
Normal file
27
app/models/audit_log.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(db.Model):
|
||||||
|
"""Immutable audit trail for all sensitive operations."""
|
||||||
|
__tablename__ = 'audit_log'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
||||||
|
table_name = db.Column(db.String(100), nullable=False)
|
||||||
|
record_id = db.Column(db.Integer, nullable=True)
|
||||||
|
action = db.Column(db.String(50), nullable=False) # create | update | delete | mask | assign | return | import
|
||||||
|
|
||||||
|
# JSON snapshots
|
||||||
|
old_values = db.Column(db.Text, nullable=True)
|
||||||
|
new_values = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
performed_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||||
|
performed_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
ip_address = db.Column(db.String(50), nullable=True)
|
||||||
|
description = db.Column(db.String(500), nullable=True)
|
||||||
|
|
||||||
|
performed_by = db.relationship('AdminUser', foreign_keys=[performed_by_id])
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<AuditLog {self.action} on {self.table_name}#{self.record_id}>'
|
||||||
56
app/models/compliance_check.py
Normal file
56
app/models/compliance_check.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
CHECK_TYPES = [
|
||||||
|
('encryption', 'Encryption Verified'),
|
||||||
|
('backup', 'Backup Configured'),
|
||||||
|
('hr', 'HR Notified'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ComplianceCheck(db.Model):
|
||||||
|
"""
|
||||||
|
Audit log for every compliance check/uncheck event on an asset.
|
||||||
|
|
||||||
|
One row is created each time a check field changes state, recording
|
||||||
|
who changed it, when, the new state, and an optional note explaining
|
||||||
|
the action (e.g. "Unverified – BitLocker disabled by user").
|
||||||
|
"""
|
||||||
|
__tablename__ = 'compliance_checks'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
||||||
|
asset_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey('assets.id', ondelete='CASCADE'),
|
||||||
|
nullable=False, index=True
|
||||||
|
)
|
||||||
|
# 'encryption' | 'backup' | 'hr'
|
||||||
|
check_type = db.Column(db.String(30), nullable=False)
|
||||||
|
|
||||||
|
# True = checked/verified, False = unchecked/cleared
|
||||||
|
checked = db.Column(db.Boolean, nullable=False)
|
||||||
|
|
||||||
|
performed_by_id = db.Column(
|
||||||
|
db.Integer, db.ForeignKey('admin_users.id'),
|
||||||
|
nullable=True
|
||||||
|
)
|
||||||
|
performed_at = db.Column(
|
||||||
|
db.DateTime, default=datetime.utcnow, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Free-text reason / note supplied at the time of check or uncheck
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
asset = db.relationship('Asset', back_populates='compliance_checks')
|
||||||
|
performed_by = db.relationship('AdminUser', foreign_keys=[performed_by_id])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def check_type_label(self):
|
||||||
|
return dict(CHECK_TYPES).get(self.check_type, self.check_type)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return (
|
||||||
|
f'<ComplianceCheck asset={self.asset_id} type={self.check_type} '
|
||||||
|
f'checked={self.checked} by={self.performed_by_id}>'
|
||||||
|
)
|
||||||
53
app/models/document_template.py
Normal file
53
app/models/document_template.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentTemplate(db.Model):
|
||||||
|
"""
|
||||||
|
Uploaded Word (.docx) templates with Jinja2-style placeholders.
|
||||||
|
|
||||||
|
Template authors write {{ variable_name }} in their .docx file.
|
||||||
|
When a document is generated the placeholders are replaced with actual
|
||||||
|
context values (user, asset, assignment data).
|
||||||
|
|
||||||
|
PII variables (those prefixed user_name, user_email, user_phone) are
|
||||||
|
re-rendered with masked values when a user's record is masked.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'document_templates'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(200), nullable=False)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Category maps to Paperwork.document_type so the UI can pre-filter
|
||||||
|
category = db.Column(db.String(30), nullable=True) # handover|assignment|return|offboarding|custom
|
||||||
|
|
||||||
|
# Stored filename relative to TEMPLATE_FOLDER
|
||||||
|
filename = db.Column(db.String(300), nullable=False)
|
||||||
|
|
||||||
|
# JSON list of placeholder names detected at upload time, e.g. ["user_name","asset_serial"]
|
||||||
|
variables_json = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
created_by = db.relationship('AdminUser', foreign_keys=[created_by_id])
|
||||||
|
paperwork_docs = db.relationship('Paperwork', back_populates='template', lazy='dynamic')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def variables(self):
|
||||||
|
if self.variables_json:
|
||||||
|
try:
|
||||||
|
return json.loads(self.variables_json)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return []
|
||||||
|
return []
|
||||||
|
|
||||||
|
@variables.setter
|
||||||
|
def variables(self, val):
|
||||||
|
self.variables_json = json.dumps(val)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<DocumentTemplate id={self.id} name={self.name!r}>'
|
||||||
72
app/models/paperwork.py
Normal file
72
app/models/paperwork.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
DOC_TYPES = [
|
||||||
|
('handover', 'Equipment Handover Receipt'),
|
||||||
|
('assignment', 'Asset Assignment Agreement'),
|
||||||
|
('return', 'Equipment Return Form'),
|
||||||
|
('offboarding', 'Off-Boarding Checklist'),
|
||||||
|
('custom', 'Custom Document'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Paperwork(db.Model):
|
||||||
|
"""Generated paperwork documents tied to a user and optionally an asset."""
|
||||||
|
__tablename__ = 'paperwork'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
||||||
|
document_type = db.Column(db.String(30), nullable=False) # see DOC_TYPES
|
||||||
|
title = db.Column(db.String(200), nullable=False)
|
||||||
|
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||||
|
asset_id = db.Column(db.Integer, db.ForeignKey('assets.id'), nullable=True)
|
||||||
|
assignment_id = db.Column(db.Integer, db.ForeignKey('assignments.id'), nullable=True)
|
||||||
|
|
||||||
|
# FK to DocumentTemplate — null for legacy ReportLab-generated docs
|
||||||
|
template_id = db.Column(db.Integer, db.ForeignKey('document_templates.id'), nullable=True)
|
||||||
|
|
||||||
|
# JSON snapshot of merge variables used at generation time.
|
||||||
|
# Kept forever so the document can be re-rendered (e.g. after PII masking).
|
||||||
|
merge_vars = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Legacy free-text / JSON snapshot (kept for backwards compat)
|
||||||
|
template_data = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Generated output files (relative to their respective folders)
|
||||||
|
pdf_filename = db.Column(db.String(300), nullable=True)
|
||||||
|
docx_filename = db.Column(db.String(300), nullable=True)
|
||||||
|
|
||||||
|
notes = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Signature
|
||||||
|
signed_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
signed_by_name = db.Column(db.String(200), nullable=True) # printed name
|
||||||
|
signature_data = db.Column(db.Text, nullable=True) # base64 PNG
|
||||||
|
|
||||||
|
created_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
created_by = db.relationship('AdminUser', foreign_keys=[created_by_id])
|
||||||
|
template = db.relationship('DocumentTemplate', back_populates='paperwork_docs',
|
||||||
|
foreign_keys=[template_id])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def doc_type_label(self):
|
||||||
|
return dict(DOC_TYPES).get(self.document_type, self.document_type)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_signed(self):
|
||||||
|
return self.signed_at is not None
|
||||||
|
|
||||||
|
def get_merge_vars(self):
|
||||||
|
if self.merge_vars:
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
return json.loads(self.merge_vars)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Paperwork id={self.id} type={self.document_type}>'
|
||||||
96
app/models/user.py
Normal file
96
app/models/user.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class User(db.Model):
|
||||||
|
"""
|
||||||
|
Tracked employees / users of IT assets.
|
||||||
|
|
||||||
|
Privacy / GDPR masking:
|
||||||
|
When a user leaves the company, an admin can mask the record.
|
||||||
|
All PII fields are cleared and replaced with a reference to the
|
||||||
|
permanent windows_id so asset history is preserved without
|
||||||
|
exposing personal data during audits.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'users'
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
|
||||||
|
# Permanent, non-PII identifier — used as the anchor for history after masking
|
||||||
|
windows_id = db.Column(db.String(50), unique=True, nullable=False, index=True)
|
||||||
|
|
||||||
|
# PII fields — nulled out when masked
|
||||||
|
first_name = db.Column(db.String(100), nullable=True)
|
||||||
|
last_name = db.Column(db.String(100), nullable=True)
|
||||||
|
email = db.Column(db.String(200), nullable=True)
|
||||||
|
phone = db.Column(db.String(50), nullable=True)
|
||||||
|
|
||||||
|
# Non-PII organisational data — retained after masking
|
||||||
|
department = db.Column(db.String(100), nullable=True)
|
||||||
|
job_title = db.Column(db.String(100), nullable=True)
|
||||||
|
location = db.Column(db.String(100), nullable=True)
|
||||||
|
manager_windows_id = db.Column(db.String(50), nullable=True)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active = db.Column(db.Boolean, default=True) # employed / active in company
|
||||||
|
is_masked = db.Column(db.Boolean, default=False) # PII erased
|
||||||
|
|
||||||
|
masked_at = db.Column(db.DateTime, nullable=True)
|
||||||
|
masked_by_id = db.Column(db.Integer, db.ForeignKey('admin_users.id'), nullable=True)
|
||||||
|
|
||||||
|
# Import metadata
|
||||||
|
import_source = db.Column(db.String(20), default='manual') # manual | ldap | csv
|
||||||
|
ldap_dn = db.Column(db.String(500), nullable=True) # AD Distinguished Name
|
||||||
|
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
assignments = db.relationship(
|
||||||
|
'Assignment', foreign_keys='Assignment.user_id',
|
||||||
|
backref='user', lazy='dynamic', cascade='all, delete-orphan'
|
||||||
|
)
|
||||||
|
paperwork_docs = db.relationship(
|
||||||
|
'Paperwork', foreign_keys='Paperwork.user_id',
|
||||||
|
backref='user', lazy='dynamic', cascade='all, delete-orphan'
|
||||||
|
)
|
||||||
|
masked_by = db.relationship('AdminUser', foreign_keys=[masked_by_id])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Display helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self):
|
||||||
|
if self.is_masked:
|
||||||
|
return f'[MASKED – WID: {self.windows_id}]'
|
||||||
|
parts = [self.first_name, self.last_name]
|
||||||
|
full = ' '.join(p for p in parts if p)
|
||||||
|
return full or self.windows_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_email(self):
|
||||||
|
return '[MASKED]' if self.is_masked else (self.email or '—')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_phone(self):
|
||||||
|
return '[MASKED]' if self.is_masked else (self.phone or '—')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_assets(self):
|
||||||
|
"""Returns active assignments."""
|
||||||
|
return self.assignments.filter_by(is_active=True).all()
|
||||||
|
|
||||||
|
def mask(self, admin_user_id):
|
||||||
|
"""Erase PII while preserving the record for asset-history purposes."""
|
||||||
|
self.first_name = None
|
||||||
|
self.last_name = None
|
||||||
|
self.email = None
|
||||||
|
self.phone = None
|
||||||
|
self.is_active = False
|
||||||
|
self.is_masked = True
|
||||||
|
self.masked_at = datetime.utcnow()
|
||||||
|
self.masked_by_id = admin_user_id
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<User wid={self.windows_id} masked={self.is_masked}>'
|
||||||
13
app/routes/__init__.py
Normal file
13
app/routes/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from app.routes.auth import bp as auth_bp
|
||||||
|
from app.routes.dashboard import bp as dashboard_bp
|
||||||
|
from app.routes.users import bp as users_bp
|
||||||
|
from app.routes.assets import bp as assets_bp
|
||||||
|
from app.routes.assignments import bp as assignments_bp
|
||||||
|
from app.routes.paperwork import bp as paperwork_bp
|
||||||
|
from app.routes.audit import bp as audit_bp
|
||||||
|
from app.routes.settings import bp as settings_bp
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'auth_bp', 'dashboard_bp', 'users_bp', 'assets_bp',
|
||||||
|
'assignments_bp', 'paperwork_bp', 'audit_bp', 'settings_bp',
|
||||||
|
]
|
||||||
389
app/routes/assets.py
Normal file
389
app/routes/assets.py
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import json
|
||||||
|
from datetime import date, datetime
|
||||||
|
from flask import (Blueprint, render_template, redirect, url_for,
|
||||||
|
flash, request, current_app, jsonify)
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.asset import Asset, ASSET_TYPES, ASSET_STATUSES
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.compliance_check import ComplianceCheck
|
||||||
|
from app.services.dell_service import lookup_service_tag
|
||||||
|
|
||||||
|
bp = Blueprint('assets', __name__, url_prefix='/assets')
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value):
|
||||||
|
"""Convert a 'YYYY-MM-DD' string to a date object; return None if blank."""
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value.strip())
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _log(action, record_id, description, old=None, new=None):
|
||||||
|
entry = AuditLog(
|
||||||
|
table_name='assets',
|
||||||
|
record_id=record_id,
|
||||||
|
action=action,
|
||||||
|
old_values=json.dumps(old) if old else None,
|
||||||
|
new_values=json.dumps(new) if new else None,
|
||||||
|
performed_by_id=current_user.id,
|
||||||
|
ip_address=request.remote_addr,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# List
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
q = request.args.get('q', '').strip()
|
||||||
|
status_filter = request.args.get('status', '')
|
||||||
|
type_filter = request.args.get('asset_type', '')
|
||||||
|
|
||||||
|
query = Asset.query
|
||||||
|
if q:
|
||||||
|
like = f'%{q}%'
|
||||||
|
query = query.filter(
|
||||||
|
db.or_(
|
||||||
|
Asset.serial_number.like(like),
|
||||||
|
Asset.service_tag.like(like),
|
||||||
|
Asset.asset_tag.like(like),
|
||||||
|
Asset.brand.like(like),
|
||||||
|
Asset.model.like(like),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if status_filter:
|
||||||
|
query = query.filter_by(status=status_filter)
|
||||||
|
if type_filter:
|
||||||
|
query = query.filter_by(asset_type=type_filter)
|
||||||
|
|
||||||
|
pagination = query.order_by(Asset.created_at.desc()).paginate(
|
||||||
|
page=page, per_page=current_app.config['ITEMS_PER_PAGE'], error_out=False
|
||||||
|
)
|
||||||
|
return render_template(
|
||||||
|
'assets/index.html',
|
||||||
|
pagination=pagination, q=q,
|
||||||
|
status_filter=status_filter, type_filter=type_filter,
|
||||||
|
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Create
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/new', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def create():
|
||||||
|
if request.method == 'POST':
|
||||||
|
sn = request.form.get('serial_number', '').strip()
|
||||||
|
if not sn:
|
||||||
|
flash('Serial Number is required.', 'danger')
|
||||||
|
return render_template('assets/form.html', asset=None,
|
||||||
|
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES)
|
||||||
|
|
||||||
|
if Asset.query.filter_by(serial_number=sn).first():
|
||||||
|
flash(f'An asset with serial number {sn} already exists.', 'danger')
|
||||||
|
return render_template('assets/form.html', asset=None,
|
||||||
|
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES)
|
||||||
|
|
||||||
|
service_tag = request.form.get('service_tag', '').strip() or None
|
||||||
|
if service_tag and Asset.query.filter_by(service_tag=service_tag).first():
|
||||||
|
flash(f'An asset with service tag {service_tag} already exists.', 'danger')
|
||||||
|
return render_template('assets/form.html', asset=None,
|
||||||
|
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES)
|
||||||
|
|
||||||
|
asset = Asset(
|
||||||
|
serial_number=sn,
|
||||||
|
service_tag=service_tag,
|
||||||
|
asset_tag=request.form.get('asset_tag', '').strip() or None,
|
||||||
|
asset_type=request.form.get('asset_type', 'Laptop'),
|
||||||
|
brand=request.form.get('brand', '').strip() or None,
|
||||||
|
model=request.form.get('model', '').strip() or None,
|
||||||
|
processor=request.form.get('processor', '').strip() or None,
|
||||||
|
ram_gb=request.form.get('ram_gb', type=int),
|
||||||
|
storage_gb=request.form.get('storage_gb', type=int),
|
||||||
|
operating_system=request.form.get('operating_system', '').strip() or None,
|
||||||
|
mac_address=request.form.get('mac_address', '').strip() or None,
|
||||||
|
purchase_date=_parse_date(request.form.get('purchase_date')),
|
||||||
|
warranty_expiry=_parse_date(request.form.get('warranty_expiry')),
|
||||||
|
purchase_price=request.form.get('purchase_price', type=float),
|
||||||
|
supplier=request.form.get('supplier', '').strip() or None,
|
||||||
|
po_number=request.form.get('po_number', '').strip() or None,
|
||||||
|
status=request.form.get('status', 'available'),
|
||||||
|
location=request.form.get('location', '').strip() or None,
|
||||||
|
notes=request.form.get('notes', '').strip() or None,
|
||||||
|
created_by_id=current_user.id,
|
||||||
|
)
|
||||||
|
db.session.add(asset)
|
||||||
|
db.session.flush()
|
||||||
|
_log('create', asset.id, f'Created asset SN={sn}',
|
||||||
|
new={'serial_number': sn, 'asset_type': asset.asset_type})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Asset {sn} created.', 'success')
|
||||||
|
return redirect(url_for('assets.detail', asset_id=asset.id))
|
||||||
|
|
||||||
|
# Pre-fill values from Dell lookup (passed as query string params)
|
||||||
|
prefill = {
|
||||||
|
'service_tag': request.args.get('service_tag', ''),
|
||||||
|
'serial_number': request.args.get('serial_number', ''),
|
||||||
|
'brand': request.args.get('brand', ''),
|
||||||
|
'model': request.args.get('model', ''),
|
||||||
|
'asset_type': request.args.get('asset_type', ''),
|
||||||
|
'operating_system': request.args.get('operating_system', ''),
|
||||||
|
'warranty_expiry': request.args.get('warranty_expiry', ''),
|
||||||
|
'purchase_date': request.args.get('purchase_date', ''),
|
||||||
|
}
|
||||||
|
return render_template('assets/form.html', asset=None,
|
||||||
|
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES,
|
||||||
|
prefill=prefill)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Dell service-tag lookup (AJAX)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/dell-lookup')
|
||||||
|
@login_required
|
||||||
|
def dell_lookup():
|
||||||
|
tag = request.args.get('tag', '').strip()
|
||||||
|
if not tag:
|
||||||
|
return jsonify({'error': 'Service tag is required.'}), 400
|
||||||
|
|
||||||
|
# Check for duplicates first
|
||||||
|
existing = Asset.query.filter_by(service_tag=tag.upper()).first()
|
||||||
|
if existing:
|
||||||
|
return jsonify({
|
||||||
|
'error': f'An asset with service tag {tag.upper()} already exists.',
|
||||||
|
'existing_id': existing.id,
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = lookup_service_tag(tag)
|
||||||
|
return jsonify(data)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return jsonify({'error': str(exc)}), 502
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Detail
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/<int:asset_id>')
|
||||||
|
@login_required
|
||||||
|
def detail(asset_id):
|
||||||
|
asset = Asset.query.get_or_404(asset_id)
|
||||||
|
history = asset.assignments.order_by(db.text('assigned_date DESC')).all()
|
||||||
|
docs = asset.paperwork_docs.order_by(db.text('created_at DESC')).all()
|
||||||
|
compliance_log = (
|
||||||
|
AuditLog.query
|
||||||
|
.filter_by(table_name='assets', record_id=asset_id, action='compliance_update')
|
||||||
|
.order_by(AuditLog.performed_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
check_history = (
|
||||||
|
ComplianceCheck.query
|
||||||
|
.filter_by(asset_id=asset_id)
|
||||||
|
.order_by(ComplianceCheck.performed_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return render_template('assets/detail.html', asset=asset, history=history,
|
||||||
|
docs=docs, compliance_log=compliance_log,
|
||||||
|
check_history=check_history)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Edit
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/<int:asset_id>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def edit(asset_id):
|
||||||
|
asset = Asset.query.get_or_404(asset_id)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
old = {'serial_number': asset.serial_number, 'status': asset.status}
|
||||||
|
|
||||||
|
new_sn = request.form.get('serial_number', '').strip()
|
||||||
|
if not new_sn:
|
||||||
|
flash('Serial Number is required.', 'danger')
|
||||||
|
return render_template('assets/form.html', asset=asset,
|
||||||
|
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES)
|
||||||
|
|
||||||
|
# Check uniqueness only if SN changed
|
||||||
|
if new_sn != asset.serial_number:
|
||||||
|
if Asset.query.filter(Asset.serial_number == new_sn, Asset.id != asset_id).first():
|
||||||
|
flash(f'Serial number {new_sn} is already used by another asset.', 'danger')
|
||||||
|
return render_template('assets/form.html', asset=asset,
|
||||||
|
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES)
|
||||||
|
|
||||||
|
asset.serial_number = new_sn
|
||||||
|
asset.service_tag = request.form.get('service_tag', '').strip() or None
|
||||||
|
asset.asset_tag = request.form.get('asset_tag', '').strip() or None
|
||||||
|
asset.asset_type = request.form.get('asset_type', asset.asset_type)
|
||||||
|
asset.brand = request.form.get('brand', '').strip() or None
|
||||||
|
asset.model = request.form.get('model', '').strip() or None
|
||||||
|
asset.processor = request.form.get('processor', '').strip() or None
|
||||||
|
asset.ram_gb = request.form.get('ram_gb', type=int)
|
||||||
|
asset.storage_gb = request.form.get('storage_gb', type=int)
|
||||||
|
asset.operating_system = request.form.get('operating_system', '').strip() or None
|
||||||
|
asset.mac_address = request.form.get('mac_address', '').strip() or None
|
||||||
|
asset.purchase_date = _parse_date(request.form.get('purchase_date'))
|
||||||
|
asset.warranty_expiry = _parse_date(request.form.get('warranty_expiry'))
|
||||||
|
asset.purchase_price = request.form.get('purchase_price', type=float)
|
||||||
|
asset.supplier = request.form.get('supplier', '').strip() or None
|
||||||
|
asset.po_number = request.form.get('po_number', '').strip() or None
|
||||||
|
asset.status = request.form.get('status', asset.status)
|
||||||
|
asset.location = request.form.get('location', '').strip() or None
|
||||||
|
asset.notes = request.form.get('notes', '').strip() or None
|
||||||
|
|
||||||
|
_log('update', asset.id, f'Updated asset SN={asset.serial_number}',
|
||||||
|
old=old, new={'serial_number': asset.serial_number, 'status': asset.status})
|
||||||
|
db.session.commit()
|
||||||
|
flash('Asset updated.', 'success')
|
||||||
|
return redirect(url_for('assets.detail', asset_id=asset_id))
|
||||||
|
|
||||||
|
return render_template('assets/form.html', asset=asset,
|
||||||
|
asset_types=ASSET_TYPES, asset_statuses=ASSET_STATUSES)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Compliance update (Desktop / Laptop specific fields)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
_COMPLIANCE_FIELDS = {
|
||||||
|
'inventory_number': 'Inventory Number',
|
||||||
|
'ad_device_name': 'AD Device Name',
|
||||||
|
'location_note': 'Location Note',
|
||||||
|
'encryption_checked': 'Encryption Checked',
|
||||||
|
'backup_checked': 'Backup Checked',
|
||||||
|
'hr_notified': 'HR Notified',
|
||||||
|
}
|
||||||
|
|
||||||
|
@bp.route('/<int:asset_id>/compliance', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def update_compliance(asset_id):
|
||||||
|
asset = Asset.query.get_or_404(asset_id)
|
||||||
|
|
||||||
|
old = {
|
||||||
|
'inventory_number': asset.inventory_number,
|
||||||
|
'ad_device_name': asset.ad_device_name,
|
||||||
|
'location_note': asset.location_note,
|
||||||
|
'encryption_checked': asset.encryption_checked,
|
||||||
|
'backup_checked': asset.backup_checked,
|
||||||
|
'hr_notified': asset.hr_notified,
|
||||||
|
}
|
||||||
|
|
||||||
|
asset.inventory_number = request.form.get('inventory_number', '').strip() or None
|
||||||
|
asset.ad_device_name = request.form.get('ad_device_name', '').strip() or None
|
||||||
|
asset.location_note = request.form.get('location_note', '').strip() or None
|
||||||
|
|
||||||
|
new_encryption = bool(request.form.get('encryption_checked'))
|
||||||
|
new_backup = bool(request.form.get('backup_checked'))
|
||||||
|
new_hr = bool(request.form.get('hr_notified'))
|
||||||
|
|
||||||
|
notes = request.form.get('compliance_notes', '').strip() or None
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Record a ComplianceCheck event for each boolean that changed
|
||||||
|
_check_map = [
|
||||||
|
('encryption', 'encryption_checked', new_encryption,
|
||||||
|
'encryption_checked_by_id', 'encryption_checked_at'),
|
||||||
|
('backup', 'backup_checked', new_backup,
|
||||||
|
'backup_checked_by_id', 'backup_checked_at'),
|
||||||
|
('hr', 'hr_notified', new_hr,
|
||||||
|
'hr_notified_by_id', 'hr_notified_at'),
|
||||||
|
]
|
||||||
|
for check_type, field, new_val, by_field, at_field in _check_map:
|
||||||
|
if old[field] != new_val:
|
||||||
|
setattr(asset, field, new_val)
|
||||||
|
setattr(asset, by_field, current_user.id)
|
||||||
|
setattr(asset, at_field, now)
|
||||||
|
db.session.add(ComplianceCheck(
|
||||||
|
asset_id=asset_id,
|
||||||
|
check_type=check_type,
|
||||||
|
checked=new_val,
|
||||||
|
performed_by_id=current_user.id,
|
||||||
|
performed_at=now,
|
||||||
|
notes=notes,
|
||||||
|
))
|
||||||
|
|
||||||
|
new = {
|
||||||
|
'inventory_number': asset.inventory_number,
|
||||||
|
'ad_device_name': asset.ad_device_name,
|
||||||
|
'location_note': asset.location_note,
|
||||||
|
'encryption_checked': asset.encryption_checked,
|
||||||
|
'backup_checked': asset.backup_checked,
|
||||||
|
'hr_notified': asset.hr_notified,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build human-readable description of what changed
|
||||||
|
changed = [
|
||||||
|
f'{_COMPLIANCE_FIELDS[k]}: {repr(old[k])} → {repr(new[k])}'
|
||||||
|
for k in _COMPLIANCE_FIELDS if old[k] != new[k]
|
||||||
|
]
|
||||||
|
if changed:
|
||||||
|
_log('compliance_update', asset.id,
|
||||||
|
f'Compliance updated: {"; ".join(changed)}',
|
||||||
|
old=old, new=new)
|
||||||
|
db.session.commit()
|
||||||
|
flash('Compliance fields updated.', 'success')
|
||||||
|
else:
|
||||||
|
flash('No changes detected.', 'info')
|
||||||
|
|
||||||
|
return redirect(url_for('assets.detail', asset_id=asset_id))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Quick lookup by SN or service tag (AJAX)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/lookup')
|
||||||
|
@login_required
|
||||||
|
def lookup():
|
||||||
|
q = request.args.get('q', '').strip()
|
||||||
|
if not q:
|
||||||
|
return jsonify(None)
|
||||||
|
asset = Asset.query.filter(
|
||||||
|
db.or_(Asset.serial_number == q, Asset.service_tag == q)
|
||||||
|
).first()
|
||||||
|
if not asset:
|
||||||
|
return jsonify(None)
|
||||||
|
return jsonify({
|
||||||
|
'id': asset.id,
|
||||||
|
'serial_number': asset.serial_number,
|
||||||
|
'service_tag': asset.service_tag,
|
||||||
|
'brand': asset.brand,
|
||||||
|
'model': asset.model,
|
||||||
|
'asset_type': asset.asset_type,
|
||||||
|
'status': asset.status,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Search (AJAX dropdown)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/search')
|
||||||
|
@login_required
|
||||||
|
def search():
|
||||||
|
q = request.args.get('q', '').strip()
|
||||||
|
if len(q) < 2:
|
||||||
|
return jsonify([])
|
||||||
|
like = f'%{q}%'
|
||||||
|
assets = Asset.query.filter(
|
||||||
|
db.or_(
|
||||||
|
Asset.serial_number.like(like),
|
||||||
|
Asset.service_tag.like(like),
|
||||||
|
Asset.asset_tag.like(like),
|
||||||
|
)
|
||||||
|
).limit(15).all()
|
||||||
|
return jsonify([{
|
||||||
|
'id': a.id,
|
||||||
|
'text': f'{a.brand or ""} {a.model or ""} — SN: {a.serial_number}'.strip(' —'),
|
||||||
|
'serial_number': a.serial_number,
|
||||||
|
'service_tag': a.service_tag,
|
||||||
|
'status': a.status,
|
||||||
|
} for a in assets])
|
||||||
145
app/routes/assignments.py
Normal file
145
app/routes/assignments.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
from flask import (Blueprint, render_template, redirect, url_for,
|
||||||
|
flash, request, current_app)
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.assignment import Assignment
|
||||||
|
from app.models.asset import Asset
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
|
||||||
|
bp = Blueprint('assignments', __name__, url_prefix='/assignments')
|
||||||
|
|
||||||
|
|
||||||
|
def _log(action, record_id, description, old=None, new=None):
|
||||||
|
entry = AuditLog(
|
||||||
|
table_name='assignments',
|
||||||
|
record_id=record_id,
|
||||||
|
action=action,
|
||||||
|
old_values=json.dumps(old) if old else None,
|
||||||
|
new_values=json.dumps(new) if new else None,
|
||||||
|
performed_by_id=current_user.id,
|
||||||
|
ip_address=request.remote_addr,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
active_only = request.args.get('active', '1') == '1'
|
||||||
|
q = request.args.get('q', '').strip()
|
||||||
|
|
||||||
|
query = Assignment.query
|
||||||
|
if active_only:
|
||||||
|
query = query.filter_by(is_active=True)
|
||||||
|
|
||||||
|
pagination = query.order_by(Assignment.assigned_date.desc()).paginate(
|
||||||
|
page=page, per_page=current_app.config['ITEMS_PER_PAGE'], error_out=False
|
||||||
|
)
|
||||||
|
return render_template('assignments/index.html',
|
||||||
|
pagination=pagination, active_only=active_only, q=q)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/new', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def create():
|
||||||
|
# Pre-fill from query params (used from asset / user detail pages)
|
||||||
|
preselect_asset_id = request.args.get('asset_id', type=int)
|
||||||
|
preselect_user_id = request.args.get('user_id', type=int)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
user_id = request.form.get('user_id', type=int)
|
||||||
|
asset_id = request.form.get('asset_id', type=int)
|
||||||
|
assigned_date_str = request.form.get('assigned_date', '')
|
||||||
|
notes = request.form.get('notes', '').strip() or None
|
||||||
|
|
||||||
|
user = User.query.get(user_id) if user_id else None
|
||||||
|
asset = Asset.query.get(asset_id) if asset_id else None
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
if not user:
|
||||||
|
errors.append('User is required.')
|
||||||
|
if not asset:
|
||||||
|
errors.append('Asset is required.')
|
||||||
|
if user and user.is_masked:
|
||||||
|
errors.append('Cannot assign assets to a masked user.')
|
||||||
|
if asset and asset.status == 'assigned':
|
||||||
|
errors.append(f'Asset {asset.serial_number} is already assigned.')
|
||||||
|
if asset and asset.status in ('retired', 'lost'):
|
||||||
|
errors.append(f'Asset {asset.serial_number} is {asset.status} and cannot be assigned.')
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for e in errors:
|
||||||
|
flash(e, 'danger')
|
||||||
|
return render_template('assignments/form.html',
|
||||||
|
preselect_asset_id=asset_id,
|
||||||
|
preselect_user_id=user_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
assigned_date = date.fromisoformat(assigned_date_str) if assigned_date_str else date.today()
|
||||||
|
except ValueError:
|
||||||
|
assigned_date = date.today()
|
||||||
|
|
||||||
|
assignment = Assignment(
|
||||||
|
asset_id=asset.id,
|
||||||
|
user_id=user.id,
|
||||||
|
assigned_date=assigned_date,
|
||||||
|
assigned_by_id=current_user.id,
|
||||||
|
notes=notes,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
asset.status = 'assigned'
|
||||||
|
db.session.add(assignment)
|
||||||
|
db.session.flush()
|
||||||
|
_log('assign', assignment.id,
|
||||||
|
f'Assigned asset SN={asset.serial_number} to WID={user.windows_id}',
|
||||||
|
new={'asset_sn': asset.serial_number, 'user_wid': user.windows_id,
|
||||||
|
'date': str(assigned_date)})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Asset {asset.serial_number} assigned to {user.display_name}.', 'success')
|
||||||
|
return redirect(url_for('assignments.index'))
|
||||||
|
|
||||||
|
return render_template('assignments/form.html',
|
||||||
|
preselect_asset_id=preselect_asset_id,
|
||||||
|
preselect_user_id=preselect_user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:assignment_id>/return', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def return_asset(assignment_id):
|
||||||
|
assignment = Assignment.query.get_or_404(assignment_id)
|
||||||
|
|
||||||
|
if not assignment.is_active:
|
||||||
|
flash('This assignment is already closed.', 'info')
|
||||||
|
return redirect(url_for('assignments.index'))
|
||||||
|
|
||||||
|
returned_date_str = request.form.get('returned_date', '')
|
||||||
|
try:
|
||||||
|
returned_date = date.fromisoformat(returned_date_str) if returned_date_str else date.today()
|
||||||
|
except ValueError:
|
||||||
|
returned_date = date.today()
|
||||||
|
|
||||||
|
assignment.returned_date = returned_date
|
||||||
|
assignment.returned_by_id = current_user.id
|
||||||
|
assignment.is_active = False
|
||||||
|
assignment.notes = (assignment.notes or '') + ('\n' + request.form.get('return_notes', '').strip() if request.form.get('return_notes') else '')
|
||||||
|
|
||||||
|
# Only set asset back to available if no other active assignment (safety check)
|
||||||
|
other_active = Assignment.query.filter(
|
||||||
|
Assignment.asset_id == assignment.asset_id,
|
||||||
|
Assignment.is_active == True, # noqa: E712
|
||||||
|
Assignment.id != assignment.id,
|
||||||
|
).first()
|
||||||
|
if not other_active:
|
||||||
|
assignment.asset.status = 'available'
|
||||||
|
|
||||||
|
_log('return', assignment.id,
|
||||||
|
f'Returned asset SN={assignment.asset.serial_number} from WID={assignment.user.windows_id}',
|
||||||
|
new={'returned_date': str(returned_date)})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Asset {assignment.asset.serial_number} returned.', 'success')
|
||||||
|
return redirect(url_for('assignments.index'))
|
||||||
30
app/routes/audit.py
Normal file
30
app/routes/audit.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from flask import Blueprint, render_template, request, current_app
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
|
||||||
|
bp = Blueprint('audit', __name__, url_prefix='/audit')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
table_filter = request.args.get('table', '')
|
||||||
|
action_filter = request.args.get('action', '')
|
||||||
|
|
||||||
|
query = AuditLog.query
|
||||||
|
if table_filter:
|
||||||
|
query = query.filter_by(table_name=table_filter)
|
||||||
|
if action_filter:
|
||||||
|
query = query.filter_by(action=action_filter)
|
||||||
|
|
||||||
|
pagination = query.order_by(AuditLog.performed_at.desc()).paginate(
|
||||||
|
page=page, per_page=current_app.config['ITEMS_PER_PAGE'], error_out=False
|
||||||
|
)
|
||||||
|
tables = ['users', 'assets', 'assignments', 'paperwork']
|
||||||
|
actions = ['create', 'update', 'delete', 'mask', 'assign', 'return', 'import']
|
||||||
|
return render_template('audit/index.html',
|
||||||
|
pagination=pagination,
|
||||||
|
table_filter=table_filter,
|
||||||
|
action_filter=action_filter,
|
||||||
|
tables=tables, actions=actions)
|
||||||
41
app/routes/auth.py
Normal file
41
app/routes/auth.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
||||||
|
from flask_login import login_user, logout_user, login_required, current_user
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
|
||||||
|
bp = Blueprint('auth', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('dashboard.index'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
username = request.form.get('username', '').strip()
|
||||||
|
password = request.form.get('password', '')
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
flash('Please enter username and password.', 'danger')
|
||||||
|
return render_template('auth/login.html')
|
||||||
|
|
||||||
|
user = AdminUser.query.filter_by(username=username).first()
|
||||||
|
if user and user.is_active and user.check_password(password):
|
||||||
|
user.last_login = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
login_user(user, remember=False)
|
||||||
|
next_page = request.args.get('next')
|
||||||
|
return redirect(next_page or url_for('dashboard.index'))
|
||||||
|
|
||||||
|
flash('Invalid username or password.', 'danger')
|
||||||
|
|
||||||
|
return render_template('auth/login.html')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/logout')
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
logout_user()
|
||||||
|
flash('You have been logged out.', 'info')
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
35
app/routes/dashboard.py
Normal file
35
app/routes/dashboard.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from flask import Blueprint, render_template
|
||||||
|
from flask_login import login_required
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.asset import Asset
|
||||||
|
from app.models.assignment import Assignment
|
||||||
|
from app.models.paperwork import Paperwork
|
||||||
|
|
||||||
|
bp = Blueprint('dashboard', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
stats = {
|
||||||
|
'total_users': User.query.count(),
|
||||||
|
'active_users': User.query.filter_by(is_active=True, is_masked=False).count(),
|
||||||
|
'masked_users': User.query.filter_by(is_masked=True).count(),
|
||||||
|
'total_assets': Asset.query.count(),
|
||||||
|
'available_assets': Asset.query.filter_by(status='available').count(),
|
||||||
|
'assigned_assets': Asset.query.filter_by(status='assigned').count(),
|
||||||
|
'maintenance_assets': Asset.query.filter_by(status='maintenance').count(),
|
||||||
|
'active_assignments': Assignment.query.filter_by(is_active=True).count(),
|
||||||
|
'total_paperwork': Paperwork.query.count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Recent assignments
|
||||||
|
recent_assignments = (
|
||||||
|
Assignment.query
|
||||||
|
.filter_by(is_active=True)
|
||||||
|
.order_by(Assignment.created_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return render_template('dashboard/index.html', stats=stats, recent_assignments=recent_assignments)
|
||||||
202
app/routes/doc_templates.py
Normal file
202
app/routes/doc_templates.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from flask import (Blueprint, render_template, redirect, url_for,
|
||||||
|
flash, request, current_app, send_from_directory, jsonify)
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.document_template import DocumentTemplate
|
||||||
|
from app.models.paperwork import DOC_TYPES
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.services.template_service import extract_variables
|
||||||
|
|
||||||
|
bp = Blueprint('doc_templates', __name__, url_prefix='/doc-templates')
|
||||||
|
|
||||||
|
ALLOWED_EXT = {'docx'}
|
||||||
|
|
||||||
|
|
||||||
|
def _log(action, record_id, description, new=None):
|
||||||
|
entry = AuditLog(
|
||||||
|
table_name='document_templates',
|
||||||
|
record_id=record_id,
|
||||||
|
action=action,
|
||||||
|
new_values=json.dumps(new) if new else None,
|
||||||
|
performed_by_id=current_user.id,
|
||||||
|
ip_address=request.remote_addr,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
|
||||||
|
|
||||||
|
def _template_folder(app):
|
||||||
|
folder = os.path.join(app.root_path, '..', app.config.get('TEMPLATE_FOLDER', 'doc_templates'))
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
return folder
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed(filename):
|
||||||
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXT
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# List
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
templates = DocumentTemplate.query.order_by(DocumentTemplate.name).all()
|
||||||
|
return render_template('doc_templates/index.html',
|
||||||
|
templates=templates, doc_types=DOC_TYPES)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Upload
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/upload', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def upload():
|
||||||
|
if request.method == 'POST':
|
||||||
|
name = request.form.get('name', '').strip()
|
||||||
|
description = request.form.get('description', '').strip() or None
|
||||||
|
category = request.form.get('category', '') or None
|
||||||
|
f = request.files.get('docx_file')
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
flash('Template name is required.', 'danger')
|
||||||
|
return render_template('doc_templates/upload.html', doc_types=DOC_TYPES)
|
||||||
|
|
||||||
|
if not f or not f.filename:
|
||||||
|
flash('Please select a .docx file.', 'danger')
|
||||||
|
return render_template('doc_templates/upload.html', doc_types=DOC_TYPES)
|
||||||
|
|
||||||
|
if not _allowed(f.filename):
|
||||||
|
flash('Only .docx files are accepted.', 'danger')
|
||||||
|
return render_template('doc_templates/upload.html', doc_types=DOC_TYPES)
|
||||||
|
|
||||||
|
folder = _template_folder(current_app)
|
||||||
|
safe_name = secure_filename(f.filename)
|
||||||
|
# Prefix with timestamp to avoid collisions
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
prefix = _dt.utcnow().strftime('%Y%m%d_%H%M%S_')
|
||||||
|
filename = prefix + safe_name
|
||||||
|
save_path = os.path.join(folder, filename)
|
||||||
|
f.save(save_path)
|
||||||
|
|
||||||
|
# Extract variables from the uploaded template
|
||||||
|
try:
|
||||||
|
variables = extract_variables(save_path)
|
||||||
|
except Exception as exc:
|
||||||
|
current_app.logger.warning('Variable extraction failed: %s', exc)
|
||||||
|
variables = []
|
||||||
|
|
||||||
|
tpl = DocumentTemplate(
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
category=category,
|
||||||
|
filename=filename,
|
||||||
|
created_by_id=current_user.id,
|
||||||
|
)
|
||||||
|
tpl.variables = variables
|
||||||
|
db.session.add(tpl)
|
||||||
|
db.session.flush()
|
||||||
|
_log('create', tpl.id, f'Uploaded template "{name}"', new={'name': name, 'filename': filename})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Template "{name}" uploaded. Found {len(variables)} variable(s).', 'success')
|
||||||
|
return redirect(url_for('doc_templates.detail', tpl_id=tpl.id))
|
||||||
|
|
||||||
|
return render_template('doc_templates/upload.html', doc_types=DOC_TYPES)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Detail / variable list
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/<int:tpl_id>')
|
||||||
|
@login_required
|
||||||
|
def detail(tpl_id):
|
||||||
|
tpl = DocumentTemplate.query.get_or_404(tpl_id)
|
||||||
|
return render_template('doc_templates/detail.html', tpl=tpl, doc_types=DOC_TYPES)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Download original template file
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/<int:tpl_id>/download')
|
||||||
|
@login_required
|
||||||
|
def download(tpl_id):
|
||||||
|
tpl = DocumentTemplate.query.get_or_404(tpl_id)
|
||||||
|
folder = _template_folder(current_app)
|
||||||
|
return send_from_directory(folder, tpl.filename, as_attachment=True,
|
||||||
|
download_name=tpl.name + '.docx')
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Re-scan variables (after template is replaced / edited externally)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/<int:tpl_id>/rescan', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def rescan(tpl_id):
|
||||||
|
tpl = DocumentTemplate.query.get_or_404(tpl_id)
|
||||||
|
folder = _template_folder(current_app)
|
||||||
|
path = os.path.join(folder, tpl.filename)
|
||||||
|
try:
|
||||||
|
variables = extract_variables(path)
|
||||||
|
tpl.variables = variables
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Rescanned: found {len(variables)} variable(s).', 'success')
|
||||||
|
except Exception as exc:
|
||||||
|
flash(f'Rescan failed: {exc}', 'danger')
|
||||||
|
return redirect(url_for('doc_templates.detail', tpl_id=tpl_id))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Edit metadata
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/<int:tpl_id>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def edit(tpl_id):
|
||||||
|
tpl = DocumentTemplate.query.get_or_404(tpl_id)
|
||||||
|
if request.method == 'POST':
|
||||||
|
tpl.name = request.form.get('name', tpl.name).strip()
|
||||||
|
tpl.description = request.form.get('description', '').strip() or None
|
||||||
|
tpl.category = request.form.get('category', '') or None
|
||||||
|
db.session.commit()
|
||||||
|
flash('Template updated.', 'success')
|
||||||
|
return redirect(url_for('doc_templates.detail', tpl_id=tpl_id))
|
||||||
|
return render_template('doc_templates/edit.html', tpl=tpl, doc_types=DOC_TYPES)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Delete
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/<int:tpl_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def delete(tpl_id):
|
||||||
|
tpl = DocumentTemplate.query.get_or_404(tpl_id)
|
||||||
|
# Check if any documents were generated from this template
|
||||||
|
if tpl.paperwork_docs.count() > 0:
|
||||||
|
flash(f'Cannot delete — {tpl.paperwork_docs.count()} document(s) were generated from this template.', 'danger')
|
||||||
|
return redirect(url_for('doc_templates.detail', tpl_id=tpl_id))
|
||||||
|
|
||||||
|
folder = _template_folder(current_app)
|
||||||
|
file_path = os.path.join(folder, tpl.filename)
|
||||||
|
try:
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_log('delete', tpl.id, f'Deleted template "{tpl.name}"')
|
||||||
|
db.session.delete(tpl)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Template "{tpl.name}" deleted.', 'success')
|
||||||
|
return redirect(url_for('doc_templates.index'))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# AJAX: return variables for a template (used by paperwork form)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/<int:tpl_id>/variables.json')
|
||||||
|
@login_required
|
||||||
|
def variables_json(tpl_id):
|
||||||
|
tpl = DocumentTemplate.query.get_or_404(tpl_id)
|
||||||
|
return jsonify({'variables': tpl.variables, 'name': tpl.name})
|
||||||
245
app/routes/paperwork.py
Normal file
245
app/routes/paperwork.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import (Blueprint, render_template, redirect, url_for,
|
||||||
|
flash, request, current_app, send_from_directory, abort, jsonify)
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.paperwork import Paperwork, DOC_TYPES
|
||||||
|
from app.models.document_template import DocumentTemplate
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.asset import Asset
|
||||||
|
from app.models.assignment import Assignment
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.services.pdf_service import generate_paperwork_pdf
|
||||||
|
from app.services.template_service import (
|
||||||
|
build_context, render_template_to_docx, _template_path
|
||||||
|
)
|
||||||
|
|
||||||
|
bp = Blueprint('paperwork', __name__, url_prefix='/paperwork')
|
||||||
|
|
||||||
|
|
||||||
|
def _log(action, record_id, description, new=None):
|
||||||
|
entry = AuditLog(
|
||||||
|
table_name='paperwork',
|
||||||
|
record_id=record_id,
|
||||||
|
action=action,
|
||||||
|
new_values=json.dumps(new) if new else None,
|
||||||
|
performed_by_id=current_user.id,
|
||||||
|
ip_address=request.remote_addr,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
q = request.args.get('q', '').strip()
|
||||||
|
doc_type_filter = request.args.get('doc_type', '')
|
||||||
|
|
||||||
|
query = Paperwork.query
|
||||||
|
if doc_type_filter:
|
||||||
|
query = query.filter_by(document_type=doc_type_filter)
|
||||||
|
|
||||||
|
pagination = query.order_by(Paperwork.created_at.desc()).paginate(
|
||||||
|
page=page, per_page=current_app.config['ITEMS_PER_PAGE'], error_out=False
|
||||||
|
)
|
||||||
|
return render_template('paperwork/index.html',
|
||||||
|
pagination=pagination, q=q,
|
||||||
|
doc_type_filter=doc_type_filter,
|
||||||
|
doc_types=DOC_TYPES)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/new', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def create():
|
||||||
|
preselect_user_id = request.args.get('user_id', type=int)
|
||||||
|
preselect_asset_id = request.args.get('asset_id', type=int)
|
||||||
|
preselect_assignment_id = request.args.get('assignment_id', type=int)
|
||||||
|
all_templates = DocumentTemplate.query.order_by(DocumentTemplate.name).all()
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
user_id = request.form.get('user_id', type=int)
|
||||||
|
asset_id = request.form.get('asset_id', type=int) or None
|
||||||
|
assignment_id = request.form.get('assignment_id', type=int) or None
|
||||||
|
doc_type = request.form.get('document_type', 'handover')
|
||||||
|
title = request.form.get('title', '').strip()
|
||||||
|
notes = request.form.get('notes', '').strip() or None
|
||||||
|
template_id = request.form.get('template_id', type=int) or None
|
||||||
|
|
||||||
|
user = User.query.get(user_id) if user_id else None
|
||||||
|
if not user:
|
||||||
|
flash('User is required.', 'danger')
|
||||||
|
return render_template('paperwork/form.html',
|
||||||
|
doc_types=DOC_TYPES, all_templates=all_templates,
|
||||||
|
preselect_user_id=preselect_user_id,
|
||||||
|
preselect_asset_id=preselect_asset_id)
|
||||||
|
|
||||||
|
asset = Asset.query.get(asset_id) if asset_id else None
|
||||||
|
assignment = Assignment.query.get(assignment_id) if assignment_id else None
|
||||||
|
|
||||||
|
if not title:
|
||||||
|
title = f'{dict(DOC_TYPES).get(doc_type, doc_type)} — {user.display_name}'
|
||||||
|
|
||||||
|
doc = Paperwork(
|
||||||
|
document_type=doc_type,
|
||||||
|
title=title,
|
||||||
|
user_id=user.id,
|
||||||
|
asset_id=asset_id,
|
||||||
|
assignment_id=assignment_id,
|
||||||
|
template_id=template_id,
|
||||||
|
notes=notes,
|
||||||
|
created_by_id=current_user.id,
|
||||||
|
)
|
||||||
|
db.session.add(doc)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# Build and store merge variables
|
||||||
|
ctx = build_context(user, asset=asset, assignment=assignment,
|
||||||
|
paperwork=doc, app=current_app._get_current_object())
|
||||||
|
# Allow form overrides for any variable (textarea fields named var_*)
|
||||||
|
for k, v in request.form.items():
|
||||||
|
if k.startswith('var_'):
|
||||||
|
ctx[k[4:]] = v
|
||||||
|
ctx['admin_name'] = current_user.username
|
||||||
|
doc.merge_vars = json.dumps(ctx)
|
||||||
|
|
||||||
|
# Generate .docx from template if one is selected
|
||||||
|
if template_id:
|
||||||
|
tpl_obj = DocumentTemplate.query.get(template_id)
|
||||||
|
if tpl_obj:
|
||||||
|
tpl_file = _template_path(current_app._get_current_object(), tpl_obj.filename)
|
||||||
|
if os.path.exists(tpl_file):
|
||||||
|
out_name = f'doc_{doc.id}_{tpl_obj.id}.docx'
|
||||||
|
try:
|
||||||
|
render_template_to_docx(tpl_file, ctx, out_name)
|
||||||
|
doc.docx_filename = out_name
|
||||||
|
except Exception as exc:
|
||||||
|
current_app.logger.error('docx render failed: %s', exc)
|
||||||
|
flash(f'Word document generation failed: {exc}', 'warning')
|
||||||
|
|
||||||
|
# Always generate PDF
|
||||||
|
try:
|
||||||
|
pdf_filename = generate_paperwork_pdf(doc, current_app._get_current_object())
|
||||||
|
doc.pdf_filename = pdf_filename
|
||||||
|
except Exception as exc:
|
||||||
|
current_app.logger.error(f'PDF generation failed: {exc}')
|
||||||
|
flash(f'Document saved but PDF generation failed: {exc}', 'warning')
|
||||||
|
|
||||||
|
_log('create', doc.id, f'Created paperwork "{title}" type={doc_type}',
|
||||||
|
new={'type': doc_type, 'user_id': user_id, 'asset_id': asset_id,
|
||||||
|
'template_id': template_id})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Document "{title}" created.', 'success')
|
||||||
|
return redirect(url_for('paperwork.detail', doc_id=doc.id))
|
||||||
|
|
||||||
|
return render_template('paperwork/form.html',
|
||||||
|
doc_types=DOC_TYPES,
|
||||||
|
all_templates=all_templates,
|
||||||
|
preselect_user_id=preselect_user_id,
|
||||||
|
preselect_asset_id=preselect_asset_id,
|
||||||
|
preselect_assignment_id=preselect_assignment_id)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:doc_id>')
|
||||||
|
@login_required
|
||||||
|
def detail(doc_id):
|
||||||
|
doc = Paperwork.query.get_or_404(doc_id)
|
||||||
|
return render_template('paperwork/detail.html', doc=doc,
|
||||||
|
merge_vars=doc.get_merge_vars())
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:doc_id>/download')
|
||||||
|
@login_required
|
||||||
|
def download(doc_id):
|
||||||
|
doc = Paperwork.query.get_or_404(doc_id)
|
||||||
|
if not doc.pdf_filename:
|
||||||
|
flash('No PDF available for this document.', 'warning')
|
||||||
|
return redirect(url_for('paperwork.detail', doc_id=doc_id))
|
||||||
|
pdf_dir = os.path.join(current_app.root_path, '..', current_app.config['PDF_FOLDER'])
|
||||||
|
return send_from_directory(pdf_dir, doc.pdf_filename, as_attachment=True)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:doc_id>/download-docx')
|
||||||
|
@login_required
|
||||||
|
def download_docx(doc_id):
|
||||||
|
doc = Paperwork.query.get_or_404(doc_id)
|
||||||
|
if not doc.docx_filename:
|
||||||
|
flash('No Word document available for this record.', 'warning')
|
||||||
|
return redirect(url_for('paperwork.detail', doc_id=doc_id))
|
||||||
|
docx_dir = os.path.join(current_app.root_path, '..', current_app.config.get('DOCX_FOLDER', 'docx_output'))
|
||||||
|
safe_name = (doc.title or f'document_{doc.id}') + '.docx'
|
||||||
|
return send_from_directory(docx_dir, doc.docx_filename, as_attachment=True,
|
||||||
|
download_name=safe_name)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:doc_id>/regenerate', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def regenerate(doc_id):
|
||||||
|
doc = Paperwork.query.get_or_404(doc_id)
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
|
||||||
|
# Regenerate .docx if template-based
|
||||||
|
if doc.template_id and doc.template:
|
||||||
|
tpl_file = _template_path(app, doc.template.filename)
|
||||||
|
ctx = doc.get_merge_vars()
|
||||||
|
out_name = doc.docx_filename or f'doc_{doc.id}_{doc.template_id}.docx'
|
||||||
|
try:
|
||||||
|
render_template_to_docx(tpl_file, ctx, out_name)
|
||||||
|
doc.docx_filename = out_name
|
||||||
|
except Exception as exc:
|
||||||
|
flash(f'Word regeneration failed: {exc}', 'warning')
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf_filename = generate_paperwork_pdf(doc, app)
|
||||||
|
doc.pdf_filename = pdf_filename
|
||||||
|
db.session.commit()
|
||||||
|
flash('Document regenerated.', 'success')
|
||||||
|
except Exception as exc:
|
||||||
|
flash(f'PDF generation failed: {exc}', 'danger')
|
||||||
|
return redirect(url_for('paperwork.detail', doc_id=doc_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:doc_id>/sign', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def sign(doc_id):
|
||||||
|
"""Record a signature on a document."""
|
||||||
|
doc = Paperwork.query.get_or_404(doc_id)
|
||||||
|
signed_by_name = request.form.get('signed_by_name', '').strip()
|
||||||
|
signature_data = request.form.get('signature_data', '').strip() # base64 PNG from canvas
|
||||||
|
|
||||||
|
if not signed_by_name:
|
||||||
|
flash('Signer name is required.', 'danger')
|
||||||
|
return redirect(url_for('paperwork.detail', doc_id=doc_id))
|
||||||
|
|
||||||
|
doc.signed_at = datetime.utcnow()
|
||||||
|
doc.signed_by_name = signed_by_name
|
||||||
|
if signature_data:
|
||||||
|
doc.signature_data = signature_data
|
||||||
|
|
||||||
|
# Regenerate PDF to embed signature
|
||||||
|
try:
|
||||||
|
pdf_filename = generate_paperwork_pdf(doc, current_app._get_current_object())
|
||||||
|
doc.pdf_filename = pdf_filename
|
||||||
|
except Exception as exc:
|
||||||
|
current_app.logger.error('PDF re-gen after sign failed: %s', exc)
|
||||||
|
|
||||||
|
_log('sign', doc.id, f'Document signed by {signed_by_name}')
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Document signed by {signed_by_name}.', 'success')
|
||||||
|
return redirect(url_for('paperwork.detail', doc_id=doc_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:doc_id>/unsign', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def unsign(doc_id):
|
||||||
|
doc = Paperwork.query.get_or_404(doc_id)
|
||||||
|
doc.signed_at = None
|
||||||
|
doc.signed_by_name = None
|
||||||
|
doc.signature_data = None
|
||||||
|
db.session.commit()
|
||||||
|
flash('Signature removed.', 'info')
|
||||||
|
return redirect(url_for('paperwork.detail', doc_id=doc_id))
|
||||||
|
|
||||||
52
app/routes/settings.py
Normal file
52
app/routes/settings.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, current_app
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
|
||||||
|
bp = Blueprint('settings', __name__, url_prefix='/settings')
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
admins = AdminUser.query.order_by(AdminUser.username).all()
|
||||||
|
return render_template('settings/index.html', admins=admins, config=current_app.config)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/admin/new', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def create_admin():
|
||||||
|
username = request.form.get('username', '').strip()
|
||||||
|
email = request.form.get('email', '').strip()
|
||||||
|
full_name = request.form.get('full_name', '').strip()
|
||||||
|
password = request.form.get('password', '')
|
||||||
|
role = request.form.get('role', 'admin')
|
||||||
|
|
||||||
|
if not username or not email or not password:
|
||||||
|
flash('Username, email and password are required.', 'danger')
|
||||||
|
return redirect(url_for('settings.index'))
|
||||||
|
|
||||||
|
if AdminUser.query.filter_by(username=username).first():
|
||||||
|
flash(f'Username "{username}" is already taken.', 'danger')
|
||||||
|
return redirect(url_for('settings.index'))
|
||||||
|
|
||||||
|
admin = AdminUser(username=username, email=email, full_name=full_name, role=role)
|
||||||
|
admin.set_password(password)
|
||||||
|
db.session.add(admin)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'Admin user "{username}" created.', 'success')
|
||||||
|
return redirect(url_for('settings.index'))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/admin/<int:admin_id>/toggle', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def toggle_admin(admin_id):
|
||||||
|
admin = AdminUser.query.get_or_404(admin_id)
|
||||||
|
if admin.id == current_user.id:
|
||||||
|
flash('You cannot deactivate your own account.', 'danger')
|
||||||
|
return redirect(url_for('settings.index'))
|
||||||
|
admin.is_active = not admin.is_active
|
||||||
|
db.session.commit()
|
||||||
|
status = 'activated' if admin.is_active else 'deactivated'
|
||||||
|
flash(f'Admin "{admin.username}" {status}.', 'success')
|
||||||
|
return redirect(url_for('settings.index'))
|
||||||
332
app/routes/users.py
Normal file
332
app/routes/users.py
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from flask import (Blueprint, render_template, redirect, url_for,
|
||||||
|
flash, request, current_app, jsonify)
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.services.csv_service import parse_users_csv
|
||||||
|
from app.services.ldap_service import LDAPService
|
||||||
|
from app.services.template_service import mask_variables, regenerate_for_paperwork
|
||||||
|
|
||||||
|
bp = Blueprint('users', __name__, url_prefix='/users')
|
||||||
|
|
||||||
|
|
||||||
|
def _log(action, record_id, description, old=None, new=None):
|
||||||
|
entry = AuditLog(
|
||||||
|
table_name='users',
|
||||||
|
record_id=record_id,
|
||||||
|
action=action,
|
||||||
|
old_values=json.dumps(old) if old else None,
|
||||||
|
new_values=json.dumps(new) if new else None,
|
||||||
|
performed_by_id=current_user.id,
|
||||||
|
ip_address=request.remote_addr,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# List
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
q = request.args.get('q', '').strip()
|
||||||
|
show_masked = request.args.get('masked', '0') == '1'
|
||||||
|
|
||||||
|
query = User.query
|
||||||
|
if not show_masked:
|
||||||
|
query = query.filter_by(is_masked=False)
|
||||||
|
if q:
|
||||||
|
like = f'%{q}%'
|
||||||
|
query = query.filter(
|
||||||
|
db.or_(
|
||||||
|
User.windows_id.like(like),
|
||||||
|
User.first_name.like(like),
|
||||||
|
User.last_name.like(like),
|
||||||
|
User.email.like(like),
|
||||||
|
User.department.like(like),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
pagination = query.order_by(User.last_name, User.first_name).paginate(
|
||||||
|
page=page, per_page=current_app.config['ITEMS_PER_PAGE'], error_out=False
|
||||||
|
)
|
||||||
|
return render_template('users/index.html', pagination=pagination, q=q, show_masked=show_masked)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Create
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/new', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def create():
|
||||||
|
if request.method == 'POST':
|
||||||
|
windows_id = request.form.get('windows_id', '').strip()
|
||||||
|
if not windows_id:
|
||||||
|
flash('Windows ID is required.', 'danger')
|
||||||
|
return render_template('users/form.html', user=None)
|
||||||
|
|
||||||
|
if User.query.filter_by(windows_id=windows_id).first():
|
||||||
|
flash(f'A user with Windows ID {windows_id} already exists.', 'danger')
|
||||||
|
return render_template('users/form.html', user=None)
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
windows_id=windows_id,
|
||||||
|
first_name=request.form.get('first_name', '').strip() or None,
|
||||||
|
last_name=request.form.get('last_name', '').strip() or None,
|
||||||
|
email=request.form.get('email', '').strip() or None,
|
||||||
|
phone=request.form.get('phone', '').strip() or None,
|
||||||
|
department=request.form.get('department', '').strip() or None,
|
||||||
|
job_title=request.form.get('job_title', '').strip() or None,
|
||||||
|
location=request.form.get('location', '').strip() or None,
|
||||||
|
import_source='manual',
|
||||||
|
)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.flush()
|
||||||
|
_log('create', user.id, f'Created user WID={windows_id}',
|
||||||
|
new={'windows_id': windows_id, 'email': user.email})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'User {user.display_name} created.', 'success')
|
||||||
|
return redirect(url_for('users.detail', user_id=user.id))
|
||||||
|
|
||||||
|
return render_template('users/form.html', user=None)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Detail
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/<int:user_id>')
|
||||||
|
@login_required
|
||||||
|
def detail(user_id):
|
||||||
|
user = User.query.get_or_404(user_id)
|
||||||
|
assignments = user.assignments.order_by(db.text('assigned_date DESC')).all()
|
||||||
|
docs = user.paperwork_docs.order_by(db.text('created_at DESC')).all()
|
||||||
|
return render_template('users/detail.html', user=user, assignments=assignments, docs=docs)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Edit
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/<int:user_id>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def edit(user_id):
|
||||||
|
user = User.query.get_or_404(user_id)
|
||||||
|
|
||||||
|
if user.is_masked:
|
||||||
|
flash('Cannot edit a masked user record.', 'warning')
|
||||||
|
return redirect(url_for('users.detail', user_id=user_id))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
old = {
|
||||||
|
'first_name': user.first_name, 'last_name': user.last_name,
|
||||||
|
'email': user.email, 'department': user.department,
|
||||||
|
}
|
||||||
|
user.first_name = request.form.get('first_name', '').strip() or None
|
||||||
|
user.last_name = request.form.get('last_name', '').strip() or None
|
||||||
|
user.email = request.form.get('email', '').strip() or None
|
||||||
|
user.phone = request.form.get('phone', '').strip() or None
|
||||||
|
user.department = request.form.get('department', '').strip() or None
|
||||||
|
user.job_title = request.form.get('job_title', '').strip() or None
|
||||||
|
user.location = request.form.get('location', '').strip() or None
|
||||||
|
user.is_active = request.form.get('is_active') == 'on'
|
||||||
|
|
||||||
|
_log('update', user.id, f'Updated user WID={user.windows_id}',
|
||||||
|
old=old, new={'first_name': user.first_name, 'last_name': user.last_name})
|
||||||
|
db.session.commit()
|
||||||
|
flash('User updated.', 'success')
|
||||||
|
return redirect(url_for('users.detail', user_id=user_id))
|
||||||
|
|
||||||
|
return render_template('users/form.html', user=user)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Mask (GDPR / off-boarding)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/<int:user_id>/mask', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def mask(user_id):
|
||||||
|
user = User.query.get_or_404(user_id)
|
||||||
|
|
||||||
|
if user.is_masked:
|
||||||
|
flash('User is already masked.', 'info')
|
||||||
|
return redirect(url_for('users.detail', user_id=user_id))
|
||||||
|
|
||||||
|
old = {'first_name': user.first_name, 'last_name': user.last_name, 'email': user.email}
|
||||||
|
user.mask(current_user.id)
|
||||||
|
_log('mask', user.id,
|
||||||
|
f'Masked PII for WID={user.windows_id}',
|
||||||
|
old=old, new={'is_masked': True})
|
||||||
|
|
||||||
|
# Re-render all template-based documents with masked PII
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
for pw_doc in user.paperwork_docs:
|
||||||
|
if pw_doc.merge_vars:
|
||||||
|
try:
|
||||||
|
masked_ctx = mask_variables(pw_doc.get_merge_vars())
|
||||||
|
pw_doc.merge_vars = json.dumps(masked_ctx)
|
||||||
|
regenerate_for_paperwork(pw_doc, app)
|
||||||
|
except Exception as exc:
|
||||||
|
app.logger.error('Failed to re-render doc %s on mask: %s', pw_doc.id, exc)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'User WID={user.windows_id} has been masked. Asset history is preserved.', 'success')
|
||||||
|
return redirect(url_for('users.detail', user_id=user_id))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Import page
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/import')
|
||||||
|
@login_required
|
||||||
|
def import_page():
|
||||||
|
return render_template('users/import.html')
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# CSV import
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/import/csv', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def import_csv():
|
||||||
|
file = request.files.get('csv_file')
|
||||||
|
if not file or not file.filename.endswith('.csv'):
|
||||||
|
flash('Please upload a valid CSV file.', 'danger')
|
||||||
|
return redirect(url_for('users.import_page'))
|
||||||
|
|
||||||
|
users_data, errors = parse_users_csv(file.stream)
|
||||||
|
|
||||||
|
created = updated = skipped = 0
|
||||||
|
for row in users_data:
|
||||||
|
wid = row.get('windows_id', '').strip()
|
||||||
|
if not wid:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = User.query.filter_by(windows_id=wid).first()
|
||||||
|
if existing:
|
||||||
|
if not existing.is_masked:
|
||||||
|
existing.first_name = row.get('first_name') or existing.first_name
|
||||||
|
existing.last_name = row.get('last_name') or existing.last_name
|
||||||
|
existing.email = row.get('email') or existing.email
|
||||||
|
existing.department = row.get('department') or existing.department
|
||||||
|
existing.job_title = row.get('job_title') or existing.job_title
|
||||||
|
existing.location = row.get('location') or existing.location
|
||||||
|
existing.import_source = 'csv'
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
u = User(
|
||||||
|
windows_id=wid,
|
||||||
|
first_name=row.get('first_name') or None,
|
||||||
|
last_name=row.get('last_name') or None,
|
||||||
|
email=row.get('email') or None,
|
||||||
|
department=row.get('department') or None,
|
||||||
|
job_title=row.get('job_title') or None,
|
||||||
|
location=row.get('location') or None,
|
||||||
|
import_source='csv',
|
||||||
|
)
|
||||||
|
db.session.add(u)
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
_log('import', None,
|
||||||
|
f'CSV import: {created} created, {updated} updated, {skipped} skipped',
|
||||||
|
new={'created': created, 'updated': updated, 'skipped': skipped, 'errors': errors})
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
flash(f'Import completed with warnings: {"; ".join(errors[:5])}', 'warning')
|
||||||
|
flash(f'CSV import done — {created} created, {updated} updated, {skipped} skipped.', 'success')
|
||||||
|
return redirect(url_for('users.index'))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# LDAP / AD sync
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/import/ldap', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def import_ldap():
|
||||||
|
if not current_app.config.get('LDAP_SERVER'):
|
||||||
|
flash('LDAP server is not configured. Update Settings first.', 'danger')
|
||||||
|
return redirect(url_for('users.import_page'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = LDAPService()
|
||||||
|
users_data = service.sync_users()
|
||||||
|
except Exception as exc:
|
||||||
|
flash(f'LDAP connection failed: {exc}', 'danger')
|
||||||
|
return redirect(url_for('users.import_page'))
|
||||||
|
|
||||||
|
created = updated = skipped = 0
|
||||||
|
for row in users_data:
|
||||||
|
wid = row.get('windows_id', '').strip()
|
||||||
|
if not wid:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
existing = User.query.filter_by(windows_id=wid).first()
|
||||||
|
if existing:
|
||||||
|
if not existing.is_masked:
|
||||||
|
existing.first_name = row.get('first_name') or existing.first_name
|
||||||
|
existing.last_name = row.get('last_name') or existing.last_name
|
||||||
|
existing.email = row.get('email') or existing.email
|
||||||
|
existing.department = row.get('department') or existing.department
|
||||||
|
existing.job_title = row.get('job_title') or existing.job_title
|
||||||
|
existing.location = row.get('location') or existing.location
|
||||||
|
existing.ldap_dn = row.get('ldap_dn') or existing.ldap_dn
|
||||||
|
existing.is_active = row.get('is_active', True)
|
||||||
|
existing.import_source = 'ldap'
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
u = User(
|
||||||
|
windows_id=wid,
|
||||||
|
first_name=row.get('first_name') or None,
|
||||||
|
last_name=row.get('last_name') or None,
|
||||||
|
email=row.get('email') or None,
|
||||||
|
department=row.get('department') or None,
|
||||||
|
job_title=row.get('job_title') or None,
|
||||||
|
location=row.get('location') or None,
|
||||||
|
ldap_dn=row.get('ldap_dn') or None,
|
||||||
|
is_active=row.get('is_active', True),
|
||||||
|
import_source='ldap',
|
||||||
|
)
|
||||||
|
db.session.add(u)
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
_log('import', None,
|
||||||
|
f'LDAP sync: {created} created, {updated} updated, {skipped} skipped',
|
||||||
|
new={'created': created, 'updated': updated, 'skipped': skipped})
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'LDAP sync done — {created} created, {updated} updated, {skipped} skipped.', 'success')
|
||||||
|
return redirect(url_for('users.index'))
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Quick search (AJAX)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@bp.route('/search')
|
||||||
|
@login_required
|
||||||
|
def search():
|
||||||
|
q = request.args.get('q', '').strip()
|
||||||
|
if len(q) < 2:
|
||||||
|
return jsonify([])
|
||||||
|
like = f'%{q}%'
|
||||||
|
users = User.query.filter(
|
||||||
|
User.is_masked == False, # noqa: E712
|
||||||
|
db.or_(
|
||||||
|
User.windows_id.like(like),
|
||||||
|
User.first_name.like(like),
|
||||||
|
User.last_name.like(like),
|
||||||
|
)
|
||||||
|
).limit(15).all()
|
||||||
|
return jsonify([{
|
||||||
|
'id': u.id,
|
||||||
|
'text': f'{u.display_name} (WID: {u.windows_id})',
|
||||||
|
'windows_id': u.windows_id,
|
||||||
|
} for u in users])
|
||||||
5
app/services/__init__.py
Normal file
5
app/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from app.services.csv_service import parse_users_csv
|
||||||
|
from app.services.ldap_service import LDAPService
|
||||||
|
from app.services.pdf_service import generate_paperwork_pdf
|
||||||
|
|
||||||
|
__all__ = ['parse_users_csv', 'LDAPService', 'generate_paperwork_pdf']
|
||||||
60
app/services/csv_service.py
Normal file
60
app/services/csv_service.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
|
||||||
|
# Maps our field names to possible CSV column header aliases (case-insensitive)
|
||||||
|
FIELD_ALIASES = {
|
||||||
|
'windows_id': ['windows_id', 'employeeid', 'employee_id', 'wid', 'id', 'user_id', 'samaccountname'],
|
||||||
|
'first_name': ['first_name', 'firstname', 'givenname', 'given_name', 'prenom'],
|
||||||
|
'last_name': ['last_name', 'lastname', 'surname', 'sn', 'family_name', 'nom'],
|
||||||
|
'email': ['email', 'mail', 'email_address', 'emailaddress'],
|
||||||
|
'department': ['department', 'dept', 'division'],
|
||||||
|
'job_title': ['job_title', 'title', 'position', 'jobtitle', 'job_position'],
|
||||||
|
'phone': ['phone', 'telephone', 'mobile', 'phonenumber', 'telephonenumber'],
|
||||||
|
'location': ['location', 'office', 'site', 'physicaldeliveryofficename'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_users_csv(file_stream):
|
||||||
|
"""
|
||||||
|
Parse a CSV file stream and return (users_list, errors_list).
|
||||||
|
|
||||||
|
Accepts BOM-prefixed UTF-8 or plain UTF-8. Column headers are
|
||||||
|
matched case-insensitively against FIELD_ALIASES.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content = file_stream.read().decode('utf-8-sig')
|
||||||
|
except (UnicodeDecodeError, AttributeError):
|
||||||
|
return [], ['Could not decode file. Please use UTF-8 encoding.']
|
||||||
|
|
||||||
|
reader = csv.DictReader(io.StringIO(content))
|
||||||
|
|
||||||
|
if not reader.fieldnames:
|
||||||
|
return [], ['CSV file is empty or has no header row.']
|
||||||
|
|
||||||
|
# Build a lookup: normalised header -> our field name
|
||||||
|
norm_headers = {h.lower().strip().replace(' ', '_'): h for h in reader.fieldnames}
|
||||||
|
col_map = {} # our_field -> actual_csv_header
|
||||||
|
for field, aliases in FIELD_ALIASES.items():
|
||||||
|
for alias in aliases:
|
||||||
|
if alias in norm_headers:
|
||||||
|
col_map[field] = norm_headers[alias]
|
||||||
|
break
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
users = []
|
||||||
|
|
||||||
|
for row_num, row in enumerate(reader, start=2):
|
||||||
|
user = {}
|
||||||
|
for field in FIELD_ALIASES:
|
||||||
|
csv_col = col_map.get(field)
|
||||||
|
user[field] = (row.get(csv_col, '') or '').strip() if csv_col else ''
|
||||||
|
|
||||||
|
wid = user.get('windows_id', '').strip()
|
||||||
|
if not wid:
|
||||||
|
errors.append(f'Row {row_num}: missing windows_id — skipped.')
|
||||||
|
continue
|
||||||
|
|
||||||
|
users.append(user)
|
||||||
|
|
||||||
|
return users, errors
|
||||||
164
app/services/dell_service.py
Normal file
164
app/services/dell_service.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"""
|
||||||
|
Dell asset lookup service.
|
||||||
|
|
||||||
|
Two modes (chosen automatically):
|
||||||
|
|
||||||
|
1. **No credentials** – Returns a partial pre-fill (brand=Dell, OS default, service_tag).
|
||||||
|
Model and warranty must be filled in manually; a link to Dell's support page is provided.
|
||||||
|
Dell's public website is protected by Akamai and cannot be scraped reliably.
|
||||||
|
|
||||||
|
2. **TechDirect API** – Full data including model, warranty dates, serial number.
|
||||||
|
Register free at https://tdm.dell.com → API Services → Create an API key pair.
|
||||||
|
Set DELL_CLIENT_ID and DELL_CLIENT_SECRET in your .env file.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TOKEN_URL = "https://apigtwb2c.us.dell.com/auth/oauth/v2/token"
|
||||||
|
_ASSET_URL = "https://apigtwb2c.us.dell.com/PROD/sbil/eapi/v5/asset-entitlements"
|
||||||
|
_SUPPORT_PAGE = "https://www.dell.com/support/home/en-us/product-support/servicetag/{tag}/overview"
|
||||||
|
|
||||||
|
_TYPE_MAP = [
|
||||||
|
(["latitude", "inspiron", "xps", "vostro", "precision 5", "precision 7"], "Laptop"),
|
||||||
|
(["optiplex", "precision tower", "precision 3", "precision 9",
|
||||||
|
"optiplex micro", "optiplex small"], "Desktop"),
|
||||||
|
(["poweredge", "server"], "Server"),
|
||||||
|
(["wyse", "thin client"], "Other"),
|
||||||
|
(["monitor", "display", "screen", "s24", "s27", "p24", "u27"], "Monitor"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_type(description: str) -> str:
|
||||||
|
desc = description.lower()
|
||||||
|
for keywords, asset_type in _TYPE_MAP:
|
||||||
|
if any(kw in desc for kw in keywords):
|
||||||
|
return asset_type
|
||||||
|
return "Laptop" # sensible default for Dell business hardware
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Partial pre-fill (no credentials)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _partial_prefill(tag: str) -> dict:
|
||||||
|
"""
|
||||||
|
Return a minimal pre-fill dict using only what we know without querying Dell.
|
||||||
|
Includes a link to Dell's support page so the user can look up the rest.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"service_tag": tag,
|
||||||
|
"serial_number": "",
|
||||||
|
"brand": "Dell",
|
||||||
|
"model": "",
|
||||||
|
"asset_type": "Laptop",
|
||||||
|
"operating_system": "Windows 11 Pro",
|
||||||
|
"warranty_expiry": "",
|
||||||
|
"purchase_date": "",
|
||||||
|
"source": "partial",
|
||||||
|
"support_url": _SUPPORT_PAGE.format(tag=tag),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Official TechDirect API (requires credentials)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_token(client_id: str, client_secret: str) -> str:
|
||||||
|
resp = requests.post(
|
||||||
|
_TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_api(tag: str, client_id: str, client_secret: str) -> dict:
|
||||||
|
try:
|
||||||
|
token = _get_token(client_id, client_secret)
|
||||||
|
except Exception as exc:
|
||||||
|
raise RuntimeError(f"Failed to obtain Dell API token: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
_ASSET_URL,
|
||||||
|
params={"servicetags": tag},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except Exception as exc:
|
||||||
|
raise RuntimeError(f"Dell API request failed: {exc}") from exc
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
raise RuntimeError(f"No data returned for service tag '{tag}'.")
|
||||||
|
|
||||||
|
item = data[0] if isinstance(data, list) else data
|
||||||
|
system_desc = item.get("productLineDescription") or item.get("systemDescription") or ""
|
||||||
|
model = system_desc.strip()
|
||||||
|
serial_number = (item.get("serviceTag") or tag).upper()
|
||||||
|
|
||||||
|
warranty_expiry = None
|
||||||
|
for ent in (item.get("entitlements") or []):
|
||||||
|
end_str = ent.get("endDate") or ""
|
||||||
|
if end_str:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(end_str[:10])
|
||||||
|
if warranty_expiry is None or dt > warranty_expiry:
|
||||||
|
warranty_expiry = dt
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ship_date = item.get("shipDate") or ""
|
||||||
|
purchase_date = None
|
||||||
|
if ship_date:
|
||||||
|
try:
|
||||||
|
purchase_date = datetime.fromisoformat(ship_date[:10])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"service_tag": tag,
|
||||||
|
"serial_number": serial_number,
|
||||||
|
"brand": "Dell",
|
||||||
|
"model": model,
|
||||||
|
"asset_type": _detect_type(f"{system_desc} {item.get('productFamily', '')}"),
|
||||||
|
"operating_system": "Windows 11 Pro",
|
||||||
|
"warranty_expiry": warranty_expiry.strftime("%Y-%m-%d") if warranty_expiry else "",
|
||||||
|
"purchase_date": purchase_date.strftime("%Y-%m-%d") if purchase_date else "",
|
||||||
|
"source": "techdirect_api",
|
||||||
|
"support_url": _SUPPORT_PAGE.format(tag=tag),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public entry point
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def lookup_service_tag(service_tag: str) -> dict:
|
||||||
|
"""
|
||||||
|
Look up a Dell service tag.
|
||||||
|
|
||||||
|
Uses TechDirect API when DELL_CLIENT_ID + DELL_CLIENT_SECRET are configured;
|
||||||
|
otherwise returns a partial pre-fill with a link to Dell's support page.
|
||||||
|
"""
|
||||||
|
tag = service_tag.strip().upper()
|
||||||
|
client_id = current_app.config.get("DELL_CLIENT_ID", "")
|
||||||
|
client_secret = current_app.config.get("DELL_CLIENT_SECRET", "")
|
||||||
|
|
||||||
|
if client_id and client_secret:
|
||||||
|
log.debug("Dell lookup via TechDirect API for %s", tag)
|
||||||
|
return _lookup_api(tag, client_id, client_secret)
|
||||||
|
|
||||||
|
log.debug("Dell lookup returning partial pre-fill for %s (no API credentials)", tag)
|
||||||
|
return _partial_prefill(tag)
|
||||||
84
app/services/ldap_service.py
Normal file
84
app/services/ldap_service.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from ldap3 import Server, Connection, ALL, SUBTREE
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPService:
|
||||||
|
"""Wraps ldap3 to sync users from Active Directory."""
|
||||||
|
|
||||||
|
ATTRIBUTES = [
|
||||||
|
'employeeID', 'sAMAccountName', 'givenName', 'sn',
|
||||||
|
'mail', 'department', 'title', 'telephoneNumber',
|
||||||
|
'distinguishedName', 'physicalDeliveryOfficeName',
|
||||||
|
'userAccountControl',
|
||||||
|
]
|
||||||
|
|
||||||
|
def _connect(self):
|
||||||
|
cfg = current_app.config
|
||||||
|
server = Server(
|
||||||
|
cfg['LDAP_SERVER'],
|
||||||
|
port=cfg['LDAP_PORT'],
|
||||||
|
use_ssl=cfg['LDAP_USE_SSL'],
|
||||||
|
get_info=ALL,
|
||||||
|
)
|
||||||
|
conn = Connection(
|
||||||
|
server,
|
||||||
|
user=cfg['LDAP_BIND_USER'],
|
||||||
|
password=cfg['LDAP_BIND_PASSWORD'],
|
||||||
|
auto_bind=True,
|
||||||
|
)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def sync_users(self):
|
||||||
|
"""
|
||||||
|
Query AD and return a list of dicts ready to be upserted into the
|
||||||
|
User model. Raises an exception if the connection fails.
|
||||||
|
"""
|
||||||
|
cfg = current_app.config
|
||||||
|
conn = self._connect()
|
||||||
|
|
||||||
|
conn.search(
|
||||||
|
search_base=cfg['LDAP_BASE_DN'],
|
||||||
|
search_filter=cfg['LDAP_USER_SEARCH_FILTER'],
|
||||||
|
search_scope=SUBTREE,
|
||||||
|
attributes=self.ATTRIBUTES,
|
||||||
|
)
|
||||||
|
|
||||||
|
wid_attr = cfg['LDAP_WINDOWS_ID_ATTR']
|
||||||
|
users = []
|
||||||
|
for entry in conn.entries:
|
||||||
|
# Resolve windows_id from the configured attribute, fall back to sAMAccountName
|
||||||
|
raw_wid = str(getattr(entry, wid_attr, '') or '')
|
||||||
|
if not raw_wid:
|
||||||
|
raw_wid = str(entry.sAMAccountName or '')
|
||||||
|
if not raw_wid:
|
||||||
|
continue # skip entries with no identifier
|
||||||
|
|
||||||
|
# userAccountControl bit 2 = disabled account
|
||||||
|
uac = 0
|
||||||
|
try:
|
||||||
|
uac = int(str(entry.userAccountControl or 0))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
is_active = not bool(uac & 2)
|
||||||
|
|
||||||
|
users.append({
|
||||||
|
'windows_id': raw_wid.strip(),
|
||||||
|
'first_name': str(entry.givenName or '').strip(),
|
||||||
|
'last_name': str(entry.sn or '').strip(),
|
||||||
|
'email': str(entry.mail or '').strip(),
|
||||||
|
'department': str(entry.department or '').strip(),
|
||||||
|
'job_title': str(entry.title or '').strip(),
|
||||||
|
'phone': str(entry.telephoneNumber or '').strip(),
|
||||||
|
'location': str(entry.physicalDeliveryOfficeName or '').strip(),
|
||||||
|
'ldap_dn': str(entry.distinguishedName or '').strip(),
|
||||||
|
'is_active': is_active,
|
||||||
|
})
|
||||||
|
|
||||||
|
conn.unbind()
|
||||||
|
return users
|
||||||
|
|
||||||
|
def test_connection(self):
|
||||||
|
"""Returns True if a bind succeeds, raises on failure."""
|
||||||
|
conn = self._connect()
|
||||||
|
conn.unbind()
|
||||||
|
return True
|
||||||
238
app/services/pdf_service.py
Normal file
238
app/services/pdf_service.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||||
|
from reportlab.lib.units import cm
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
|
||||||
|
from reportlab.platypus import (
|
||||||
|
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _styles():
|
||||||
|
base = getSampleStyleSheet()
|
||||||
|
custom = {
|
||||||
|
'title': ParagraphStyle(
|
||||||
|
'DocTitle', parent=base['Title'],
|
||||||
|
fontSize=16, spaceAfter=6, alignment=TA_CENTER, textColor=colors.HexColor('#1a3a5c'),
|
||||||
|
),
|
||||||
|
'subtitle': ParagraphStyle(
|
||||||
|
'Subtitle', parent=base['Normal'],
|
||||||
|
fontSize=10, spaceAfter=4, alignment=TA_CENTER, textColor=colors.HexColor('#555555'),
|
||||||
|
),
|
||||||
|
'section': ParagraphStyle(
|
||||||
|
'Section', parent=base['Heading2'],
|
||||||
|
fontSize=11, spaceBefore=12, spaceAfter=4,
|
||||||
|
textColor=colors.HexColor('#1a3a5c'), borderPad=2,
|
||||||
|
),
|
||||||
|
'normal': base['Normal'],
|
||||||
|
'small': ParagraphStyle(
|
||||||
|
'Small', parent=base['Normal'], fontSize=8, textColor=colors.grey,
|
||||||
|
),
|
||||||
|
'footer': ParagraphStyle(
|
||||||
|
'Footer', parent=base['Normal'],
|
||||||
|
fontSize=8, alignment=TA_CENTER, textColor=colors.grey,
|
||||||
|
),
|
||||||
|
'signature_label': ParagraphStyle(
|
||||||
|
'SigLabel', parent=base['Normal'], fontSize=9, alignment=TA_CENTER,
|
||||||
|
),
|
||||||
|
'right': ParagraphStyle(
|
||||||
|
'Right', parent=base['Normal'], alignment=TA_RIGHT,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return custom
|
||||||
|
|
||||||
|
|
||||||
|
def _header_table(company_name, company_address, doc_type_label, doc_id, created_at, styles):
|
||||||
|
left_data = [
|
||||||
|
[Paragraph(f'<b>{company_name}</b>', styles['normal'])],
|
||||||
|
[Paragraph(company_address or '', styles['small'])],
|
||||||
|
]
|
||||||
|
right_data = [
|
||||||
|
[Paragraph(f'<b>{doc_type_label}</b>', styles['right'])],
|
||||||
|
[Paragraph(f'Doc #: {doc_id}', styles['right'])],
|
||||||
|
[Paragraph(f'Date: {created_at.strftime("%d/%m/%Y") if created_at else ""}', styles['right'])],
|
||||||
|
]
|
||||||
|
table = Table(
|
||||||
|
[[Table(left_data, colWidths=[9 * cm]), Table(right_data, colWidths=[8 * cm])]],
|
||||||
|
colWidths=[9 * cm, 8 * cm],
|
||||||
|
)
|
||||||
|
table.setStyle(TableStyle([('VALIGN', (0, 0), (-1, -1), 'TOP')]))
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def _field_table(rows, styles):
|
||||||
|
"""rows: list of (label, value) tuples."""
|
||||||
|
data = [[Paragraph(f'<b>{label}</b>', styles['normal']), Paragraph(str(value or '—'), styles['normal'])]
|
||||||
|
for label, value in rows]
|
||||||
|
t = Table(data, colWidths=[5 * cm, 12 * cm])
|
||||||
|
t.setStyle(TableStyle([
|
||||||
|
('BACKGROUND', (0, 0), (0, -1), colors.HexColor('#eaf0f8')),
|
||||||
|
('GRID', (0, 0), (-1, -1), 0.4, colors.HexColor('#cccccc')),
|
||||||
|
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||||
|
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||||||
|
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||||||
|
('LEFTPADDING', (0, 0), (-1, -1), 6),
|
||||||
|
]))
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _signature_block(styles):
|
||||||
|
data = [
|
||||||
|
[Paragraph('Issued by (IT Dept.)', styles['signature_label']),
|
||||||
|
Paragraph('Received by (Employee)', styles['signature_label'])],
|
||||||
|
[Spacer(1, 1.5 * cm), Spacer(1, 1.5 * cm)],
|
||||||
|
[HRFlowable(width='95%'), HRFlowable(width='95%')],
|
||||||
|
[Paragraph('Name / Signature / Date', styles['signature_label']),
|
||||||
|
Paragraph('Name / Signature / Date', styles['signature_label'])],
|
||||||
|
]
|
||||||
|
t = Table(data, colWidths=[8.5 * cm, 8.5 * cm])
|
||||||
|
t.setStyle(TableStyle([
|
||||||
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||||
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||||
|
]))
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def generate_paperwork_pdf(doc, app):
|
||||||
|
"""
|
||||||
|
Generate a PDF for a Paperwork document and save it to PDF_FOLDER.
|
||||||
|
Returns the filename (not the full path).
|
||||||
|
"""
|
||||||
|
pdf_dir = os.path.join(app.root_path, '..', app.config['PDF_FOLDER'])
|
||||||
|
os.makedirs(pdf_dir, exist_ok=True)
|
||||||
|
|
||||||
|
filename = f'doc_{doc.id}_{datetime.utcnow().strftime("%Y%m%d_%H%M%S")}.pdf'
|
||||||
|
filepath = os.path.join(pdf_dir, filename)
|
||||||
|
|
||||||
|
page_doc = SimpleDocTemplate(
|
||||||
|
filepath, pagesize=A4,
|
||||||
|
rightMargin=2 * cm, leftMargin=2 * cm,
|
||||||
|
topMargin=2 * cm, bottomMargin=2.5 * cm,
|
||||||
|
title=doc.title,
|
||||||
|
)
|
||||||
|
|
||||||
|
styles = _styles()
|
||||||
|
company_name = app.config.get('COMPANY_NAME', '')
|
||||||
|
company_address = app.config.get('COMPANY_ADDRESS', '')
|
||||||
|
|
||||||
|
user = doc.user
|
||||||
|
asset = doc.asset
|
||||||
|
assignment = doc.assignment
|
||||||
|
|
||||||
|
# Load extra template fields
|
||||||
|
extra_fields = {}
|
||||||
|
if doc.template_data:
|
||||||
|
try:
|
||||||
|
raw = json.loads(doc.template_data)
|
||||||
|
extra_fields = {k.replace('td_', '').replace('_', ' ').title(): v
|
||||||
|
for k, v in raw.items()}
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
story = []
|
||||||
|
|
||||||
|
# ── Header ──────────────────────────────────────────────────────────────
|
||||||
|
story.append(_header_table(company_name, company_address,
|
||||||
|
doc.doc_type_label, doc.id, doc.created_at, styles))
|
||||||
|
story.append(Spacer(1, 0.3 * cm))
|
||||||
|
story.append(HRFlowable(width='100%', thickness=1.5, color=colors.HexColor('#1a3a5c')))
|
||||||
|
story.append(Spacer(1, 0.4 * cm))
|
||||||
|
|
||||||
|
# ── Title ────────────────────────────────────────────────────────────────
|
||||||
|
story.append(Paragraph(doc.title, styles['title']))
|
||||||
|
story.append(Spacer(1, 0.5 * cm))
|
||||||
|
|
||||||
|
# ── User section ─────────────────────────────────────────────────────────
|
||||||
|
story.append(Paragraph('Employee Information', styles['section']))
|
||||||
|
user_rows = [
|
||||||
|
('Windows ID', user.windows_id),
|
||||||
|
('Full Name', user.display_name),
|
||||||
|
('Email', user.display_email),
|
||||||
|
('Department', user.department or '—'),
|
||||||
|
('Job Title', user.job_title or '—'),
|
||||||
|
('Location', user.location or '—'),
|
||||||
|
]
|
||||||
|
story.append(_field_table(user_rows, styles))
|
||||||
|
story.append(Spacer(1, 0.4 * cm))
|
||||||
|
|
||||||
|
# ── Asset section ─────────────────────────────────────────────────────────
|
||||||
|
if asset:
|
||||||
|
story.append(Paragraph('Asset Information', styles['section']))
|
||||||
|
asset_rows = [
|
||||||
|
('Asset Type', asset.asset_type),
|
||||||
|
('Brand / Model', f'{asset.brand or ""} {asset.model or ""}'.strip() or '—'),
|
||||||
|
('Serial Number', asset.serial_number),
|
||||||
|
('Service Tag', asset.service_tag or '—'),
|
||||||
|
('Asset Tag', asset.asset_tag or '—'),
|
||||||
|
('Operating System', asset.operating_system or '—'),
|
||||||
|
]
|
||||||
|
if asset.ram_gb:
|
||||||
|
asset_rows.append(('RAM', f'{asset.ram_gb} GB'))
|
||||||
|
if asset.storage_gb:
|
||||||
|
asset_rows.append(('Storage', f'{asset.storage_gb} GB'))
|
||||||
|
story.append(_field_table(asset_rows, styles))
|
||||||
|
story.append(Spacer(1, 0.4 * cm))
|
||||||
|
|
||||||
|
# ── Assignment section ────────────────────────────────────────────────────
|
||||||
|
if assignment:
|
||||||
|
story.append(Paragraph('Assignment Details', styles['section']))
|
||||||
|
assign_rows = [
|
||||||
|
('Assigned Date', str(assignment.assigned_date) if assignment.assigned_date else '—'),
|
||||||
|
('Returned Date', str(assignment.returned_date) if assignment.returned_date else 'Currently assigned'),
|
||||||
|
]
|
||||||
|
story.append(_field_table(assign_rows, styles))
|
||||||
|
story.append(Spacer(1, 0.4 * cm))
|
||||||
|
|
||||||
|
# ── Extra / custom fields ─────────────────────────────────────────────────
|
||||||
|
if extra_fields:
|
||||||
|
story.append(Paragraph('Additional Information', styles['section']))
|
||||||
|
story.append(_field_table(list(extra_fields.items()), styles))
|
||||||
|
story.append(Spacer(1, 0.4 * cm))
|
||||||
|
|
||||||
|
# ── Notes ─────────────────────────────────────────────────────────────────
|
||||||
|
if doc.notes:
|
||||||
|
story.append(Paragraph('Notes', styles['section']))
|
||||||
|
story.append(Paragraph(doc.notes, styles['normal']))
|
||||||
|
story.append(Spacer(1, 0.4 * cm))
|
||||||
|
|
||||||
|
# Type-specific clauses
|
||||||
|
if doc.document_type == 'assignment':
|
||||||
|
story.append(Paragraph('Terms & Conditions', styles['section']))
|
||||||
|
clause = (
|
||||||
|
'By signing below the employee acknowledges receipt of the above equipment in good '
|
||||||
|
'working condition and agrees to: (1) use it solely for company business, '
|
||||||
|
'(2) report any damage or loss immediately to the IT department, and '
|
||||||
|
'(3) return it upon request or at the end of employment.'
|
||||||
|
)
|
||||||
|
story.append(Paragraph(clause, styles['normal']))
|
||||||
|
story.append(Spacer(1, 0.4 * cm))
|
||||||
|
|
||||||
|
if doc.document_type == 'return':
|
||||||
|
story.append(Paragraph('Return Confirmation', styles['section']))
|
||||||
|
clause = (
|
||||||
|
'By signing below both parties confirm that the above equipment has been returned '
|
||||||
|
'to the IT department and has been inspected for completeness and condition.'
|
||||||
|
)
|
||||||
|
story.append(Paragraph(clause, styles['normal']))
|
||||||
|
story.append(Spacer(1, 0.4 * cm))
|
||||||
|
|
||||||
|
# ── Signatures ────────────────────────────────────────────────────────────
|
||||||
|
story.append(Paragraph('Signatures', styles['section']))
|
||||||
|
story.append(Spacer(1, 0.3 * cm))
|
||||||
|
story.append(_signature_block(styles))
|
||||||
|
story.append(Spacer(1, 0.5 * cm))
|
||||||
|
|
||||||
|
# ── Footer ────────────────────────────────────────────────────────────────
|
||||||
|
story.append(HRFlowable(width='100%', thickness=0.5, color=colors.grey))
|
||||||
|
story.append(Spacer(1, 0.2 * cm))
|
||||||
|
story.append(Paragraph(
|
||||||
|
f'Generated by IT Asset Management System · {datetime.utcnow().strftime("%d/%m/%Y %H:%M")} UTC',
|
||||||
|
styles['footer'],
|
||||||
|
))
|
||||||
|
|
||||||
|
page_doc.build(story)
|
||||||
|
return filename
|
||||||
221
app/services/template_service.py
Normal file
221
app/services/template_service.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"""
|
||||||
|
Template service: fill Word (.docx) templates and generate output files.
|
||||||
|
|
||||||
|
Variables available in templates (use {{ variable_name }} syntax):
|
||||||
|
|
||||||
|
User:
|
||||||
|
{{ user_name }} Full name (or [MASKED] after PII erasure)
|
||||||
|
{{ user_email }}
|
||||||
|
{{ user_phone }}
|
||||||
|
{{ user_department }}
|
||||||
|
{{ user_job_title }}
|
||||||
|
{{ user_location }}
|
||||||
|
{{ user_windows_id }} Always present — survives masking
|
||||||
|
{{ user_employee_id }} Same as windows_id (alias)
|
||||||
|
|
||||||
|
Asset:
|
||||||
|
{{ asset_serial }} Serial number
|
||||||
|
{{ asset_service_tag }}
|
||||||
|
{{ asset_tag }} Internal asset tag
|
||||||
|
{{ asset_brand }}
|
||||||
|
{{ asset_model }}
|
||||||
|
{{ asset_type }} e.g. Laptop / Desktop
|
||||||
|
{{ asset_os }}
|
||||||
|
{{ asset_warranty_expiry }}
|
||||||
|
{{ asset_location }}
|
||||||
|
|
||||||
|
Assignment:
|
||||||
|
{{ assignment_date }}
|
||||||
|
{{ assignment_id }}
|
||||||
|
{{ return_date }}
|
||||||
|
|
||||||
|
Document / company:
|
||||||
|
{{ document_date }} Date of generation (dd/mm/yyyy)
|
||||||
|
{{ document_number }} Paperwork record ID
|
||||||
|
{{ company_name }}
|
||||||
|
{{ company_address }}
|
||||||
|
{{ admin_name }} Logged-in admin who generated the doc
|
||||||
|
|
||||||
|
PII masking:
|
||||||
|
When User.mask() is called, all Paperwork records that were generated
|
||||||
|
from a template have their merge_vars updated (PII keys replaced with
|
||||||
|
[MASKED]) and the .docx/.pdf files are regenerated.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from docxtpl import DocxTemplate
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
# PII variable keys — these are blanked out on user mask
|
||||||
|
PII_VARS = {'user_name', 'user_email', 'user_phone'}
|
||||||
|
|
||||||
|
# Variables that survive masking (non-PII)
|
||||||
|
SAFE_VARS = {
|
||||||
|
'user_department', 'user_job_title', 'user_location',
|
||||||
|
'user_windows_id', 'user_employee_id',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_context(user, asset=None, assignment=None, paperwork=None, app=None):
|
||||||
|
"""
|
||||||
|
Build the Jinja2 context dict from ORM objects.
|
||||||
|
Used both at generation time and when re-rendering after masking.
|
||||||
|
"""
|
||||||
|
if app is None:
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
# User
|
||||||
|
'user_name': user.display_name,
|
||||||
|
'user_email': user.display_email,
|
||||||
|
'user_phone': user.display_phone,
|
||||||
|
'user_department': user.department or '',
|
||||||
|
'user_job_title': user.job_title or '',
|
||||||
|
'user_location': user.location or '',
|
||||||
|
'user_windows_id': user.windows_id or '',
|
||||||
|
'user_employee_id': user.windows_id or '',
|
||||||
|
|
||||||
|
# Asset
|
||||||
|
'asset_serial': '',
|
||||||
|
'asset_service_tag': '',
|
||||||
|
'asset_tag': '',
|
||||||
|
'asset_brand': '',
|
||||||
|
'asset_model': '',
|
||||||
|
'asset_type': '',
|
||||||
|
'asset_os': '',
|
||||||
|
'asset_warranty_expiry': '',
|
||||||
|
'asset_location': '',
|
||||||
|
|
||||||
|
# Assignment
|
||||||
|
'assignment_date': '',
|
||||||
|
'assignment_id': '',
|
||||||
|
'return_date': '',
|
||||||
|
|
||||||
|
# Document/company
|
||||||
|
'document_date': datetime.utcnow().strftime('%d/%m/%Y'),
|
||||||
|
'document_number': str(paperwork.id) if paperwork else '',
|
||||||
|
'company_name': app.config.get('COMPANY_NAME', ''),
|
||||||
|
'company_address': app.config.get('COMPANY_ADDRESS', ''),
|
||||||
|
'admin_name': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
if asset:
|
||||||
|
ctx.update({
|
||||||
|
'asset_serial': asset.serial_number or '',
|
||||||
|
'asset_service_tag': asset.service_tag or '',
|
||||||
|
'asset_tag': asset.asset_tag or '',
|
||||||
|
'asset_brand': asset.brand or '',
|
||||||
|
'asset_model': asset.model or '',
|
||||||
|
'asset_type': asset.asset_type or '',
|
||||||
|
'asset_os': asset.operating_system or '',
|
||||||
|
'asset_warranty_expiry': (asset.warranty_expiry.strftime('%d/%m/%Y')
|
||||||
|
if asset.warranty_expiry else ''),
|
||||||
|
'asset_location': asset.location or '',
|
||||||
|
})
|
||||||
|
|
||||||
|
if assignment:
|
||||||
|
ctx['assignment_date'] = (assignment.assigned_date.strftime('%d/%m/%Y')
|
||||||
|
if assignment.assigned_date else '')
|
||||||
|
ctx['assignment_id'] = str(assignment.id)
|
||||||
|
ctx['return_date'] = (assignment.returned_date.strftime('%d/%m/%Y')
|
||||||
|
if assignment.returned_date else '')
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def _docx_path(app, filename):
|
||||||
|
folder = os.path.join(app.root_path, '..', app.config.get('DOCX_FOLDER', 'docx_output'))
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
return os.path.join(folder, filename)
|
||||||
|
|
||||||
|
|
||||||
|
def _template_path(app, filename):
|
||||||
|
folder = os.path.join(app.root_path, '..', app.config.get('TEMPLATE_FOLDER', 'doc_templates'))
|
||||||
|
return os.path.join(folder, filename)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_variables(template_path):
|
||||||
|
"""
|
||||||
|
Parse a .docx file and return all unique Jinja2 variable names found
|
||||||
|
in the document text ({{ var_name }} syntax).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tpl = DocxTemplate(template_path)
|
||||||
|
return sorted(tpl.get_undeclared_template_variables())
|
||||||
|
except Exception:
|
||||||
|
# Fallback: open as zip and scan XML for {{ ... }}
|
||||||
|
import zipfile
|
||||||
|
vars_found = set()
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(template_path, 'r') as z:
|
||||||
|
for name in z.namelist():
|
||||||
|
if name.endswith('.xml'):
|
||||||
|
content = z.read(name).decode('utf-8', errors='ignore')
|
||||||
|
vars_found.update(re.findall(r'\{\{\s*(\w+)\s*\}\}', content))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return sorted(vars_found)
|
||||||
|
|
||||||
|
|
||||||
|
def render_template_to_docx(template_filepath, context, output_filename):
|
||||||
|
"""
|
||||||
|
Fill a .docx template with context values and save to DOCX_FOLDER.
|
||||||
|
Returns the saved filename.
|
||||||
|
"""
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
tpl = DocxTemplate(template_filepath)
|
||||||
|
tpl.render(context)
|
||||||
|
out_path = _docx_path(app, output_filename)
|
||||||
|
tpl.save(out_path)
|
||||||
|
return output_filename
|
||||||
|
|
||||||
|
|
||||||
|
def regenerate_for_paperwork(paperwork, app=None):
|
||||||
|
"""
|
||||||
|
Re-render the .docx for an existing Paperwork record using its stored
|
||||||
|
merge_vars. Called after PII masking to overwrite files with sanitised data.
|
||||||
|
|
||||||
|
If the record was generated from a template, regenerates .docx.
|
||||||
|
Also regenerates the PDF via pdf_service if a PDF exists.
|
||||||
|
|
||||||
|
Returns (docx_filename, pdf_filename) — either may be None.
|
||||||
|
"""
|
||||||
|
from app.services.pdf_service import generate_paperwork_pdf
|
||||||
|
|
||||||
|
if app is None:
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
|
||||||
|
docx_out = None
|
||||||
|
pdf_out = None
|
||||||
|
|
||||||
|
if paperwork.template_id and paperwork.template:
|
||||||
|
tpl_file = _template_path(app, paperwork.template.filename)
|
||||||
|
if os.path.exists(tpl_file):
|
||||||
|
ctx = paperwork.get_merge_vars()
|
||||||
|
out_name = paperwork.docx_filename or f'doc_{paperwork.id}.docx'
|
||||||
|
try:
|
||||||
|
render_template_to_docx(tpl_file, ctx, out_name)
|
||||||
|
docx_out = out_name
|
||||||
|
except Exception as exc:
|
||||||
|
app.logger.error('Template re-render failed for paperwork %s: %s', paperwork.id, exc)
|
||||||
|
|
||||||
|
# Always regenerate the PDF (uses pdf_service, reads from Paperwork + User model)
|
||||||
|
if paperwork.pdf_filename:
|
||||||
|
try:
|
||||||
|
pdf_out = generate_paperwork_pdf(paperwork, app)
|
||||||
|
except Exception as exc:
|
||||||
|
app.logger.error('PDF re-render failed for paperwork %s: %s', paperwork.id, exc)
|
||||||
|
|
||||||
|
return docx_out, pdf_out
|
||||||
|
|
||||||
|
|
||||||
|
def mask_variables(merge_vars: dict) -> dict:
|
||||||
|
"""Return a copy of merge_vars with PII values replaced by [MASKED]."""
|
||||||
|
masked = dict(merge_vars)
|
||||||
|
for key in PII_VARS:
|
||||||
|
if key in masked:
|
||||||
|
masked[key] = '[MASKED]'
|
||||||
|
return masked
|
||||||
453
app/templates/assets/detail.html
Normal file
453
app/templates/assets/detail.html
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}{{ asset.serial_number }} – IT Asset Management{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('assets.index') }}">Assets</a></li>
|
||||||
|
<li class="breadcrumb-item active">{{ asset.serial_number }}</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<h1>
|
||||||
|
<i class="bi bi-laptop me-2"></i>{{ asset.brand or '' }} {{ asset.model or '' }}
|
||||||
|
<span class="badge badge-{{ asset.status }} fs-6 align-middle ms-2">{{ asset.status | title }}</span>
|
||||||
|
</h1>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('assets.edit', asset_id=asset.id) }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Edit
|
||||||
|
</a>
|
||||||
|
{% if asset.status == 'available' %}
|
||||||
|
<a href="{{ url_for('assignments.create', asset_id=asset.id) }}" class="btn btn-sm btn-outline-success">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Assign
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if asset.current_user %}
|
||||||
|
<a href="{{ url_for('paperwork.create', asset_id=asset.id, user_id=asset.current_user.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-info">
|
||||||
|
<i class="bi bi-file-earmark-plus me-1"></i>New Doc
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Asset details -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3">
|
||||||
|
<i class="bi bi-info-circle me-2 text-primary"></i>Asset Details
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-5 text-muted small">Type</dt>
|
||||||
|
<dd class="col-7"><span class="badge bg-secondary">{{ asset.asset_type }}</span></dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Brand</dt>
|
||||||
|
<dd class="col-7">{{ asset.brand or '—' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Model</dt>
|
||||||
|
<dd class="col-7">{{ asset.model or '—' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Serial No.</dt>
|
||||||
|
<dd class="col-7"><code>{{ asset.serial_number }}</code></dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Service Tag</dt>
|
||||||
|
<dd class="col-7"><code>{{ asset.service_tag or '—' }}</code></dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Asset Tag</dt>
|
||||||
|
<dd class="col-7">{{ asset.asset_tag or '—' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">OS</dt>
|
||||||
|
<dd class="col-7">{{ asset.operating_system or '—' }}</dd>
|
||||||
|
|
||||||
|
{% if asset.processor %}
|
||||||
|
<dt class="col-5 text-muted small">CPU</dt>
|
||||||
|
<dd class="col-7">{{ asset.processor }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if asset.ram_gb %}
|
||||||
|
<dt class="col-5 text-muted small">RAM</dt>
|
||||||
|
<dd class="col-7">{{ asset.ram_gb }} GB</dd>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if asset.storage_gb %}
|
||||||
|
<dt class="col-5 text-muted small">Storage</dt>
|
||||||
|
<dd class="col-7">{{ asset.storage_gb }} GB</dd>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if asset.mac_address %}
|
||||||
|
<dt class="col-5 text-muted small">MAC</dt>
|
||||||
|
<dd class="col-7"><code>{{ asset.mac_address }}</code></dd>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3">
|
||||||
|
<i class="bi bi-receipt me-2 text-primary"></i>Procurement
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-5 text-muted small">Purchased</dt>
|
||||||
|
<dd class="col-7">{{ asset.purchase_date.strftime('%d/%m/%Y') if asset.purchase_date else '—' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Warranty</dt>
|
||||||
|
<dd class="col-7">{{ asset.warranty_expiry.strftime('%d/%m/%Y') if asset.warranty_expiry else '—' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Price</dt>
|
||||||
|
<dd class="col-7">{{ '%.2f'|format(asset.purchase_price) if asset.purchase_price else '—' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Supplier</dt>
|
||||||
|
<dd class="col-7">{{ asset.supplier or '—' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">PO #</dt>
|
||||||
|
<dd class="col-7">{{ asset.po_number or '—' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Location</dt>
|
||||||
|
<dd class="col-7">{{ asset.location or '—' }}</dd>
|
||||||
|
</dl>
|
||||||
|
{% if asset.notes %}
|
||||||
|
<hr class="my-2">
|
||||||
|
<p class="small text-muted mb-0">{{ asset.notes }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- History + Docs -->
|
||||||
|
<div class="col-md-8">
|
||||||
|
<!-- Assignment history -->
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-clock-history me-2 text-primary"></i>Assignment History</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Windows ID</th>
|
||||||
|
<th>From</th>
|
||||||
|
<th>Until</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in history %}
|
||||||
|
<tr {% if a.user.is_masked %}class="masked-row"{% endif %}>
|
||||||
|
<td>{{ a.user.display_name }}</td>
|
||||||
|
<td><code>{{ a.user.windows_id }}</code></td>
|
||||||
|
<td>{{ a.assigned_date.strftime('%d/%m/%Y') if a.assigned_date else '—' }}</td>
|
||||||
|
<td>{{ a.returned_date.strftime('%d/%m/%Y') if a.returned_date else '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if a.is_active %}
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Returned</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if a.is_active %}
|
||||||
|
<button class="btn btn-sm btn-outline-warning py-0 px-2"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#returnModal{{ a.id }}">
|
||||||
|
<i class="bi bi-arrow-return-left"></i> Return
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="text-center text-muted py-3">No assignment history.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Documents -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-file-earmark-text me-2 text-primary"></i>Documents</span>
|
||||||
|
{% if asset.current_user %}
|
||||||
|
<a href="{{ url_for('paperwork.create', asset_id=asset.id, user_id=asset.current_user.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-info py-0 px-2">
|
||||||
|
<i class="bi bi-plus"></i> New
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr><th>Title</th><th>Type</th><th>User</th><th>Date</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in docs %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('paperwork.detail', doc_id=d.id) }}">{{ d.title }}</a></td>
|
||||||
|
<td><span class="badge bg-info text-dark">{{ d.doc_type_label }}</span></td>
|
||||||
|
<td>{{ d.user.display_name }}</td>
|
||||||
|
<td>{{ d.created_at.strftime('%d/%m/%Y') if d.created_at else '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if d.pdf_filename %}
|
||||||
|
<a href="{{ url_for('paperwork.download', doc_id=d.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary py-0 px-2">
|
||||||
|
<i class="bi bi-download"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="text-center text-muted py-3">No documents.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if asset.asset_type in ('Laptop', 'Desktop') %}
|
||||||
|
<!-- Compliance card — Laptop / Desktop only -->
|
||||||
|
<div class="card border-0 shadow-sm mt-3">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-shield-check me-2 text-success"></i>IT Compliance & Inventory</span>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" type="button"
|
||||||
|
data-bs-toggle="collapse" data-bs-target="#complianceEdit">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Read-only summary -->
|
||||||
|
<div class="card-body pb-2">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-4 col-6">
|
||||||
|
<div class="small text-muted">Inventory #</div>
|
||||||
|
<div class="fw-semibold">{{ asset.inventory_number or '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-6">
|
||||||
|
<div class="small text-muted">AD Device Name</div>
|
||||||
|
<div class="fw-semibold"><code>{{ asset.ad_device_name or '—' }}</code></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-12">
|
||||||
|
<div class="small text-muted">Location Note</div>
|
||||||
|
<div class="fw-semibold">{{ asset.location_note or '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-4 mt-2">
|
||||||
|
{% if asset.encryption_checked %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-lock-fill me-1"></i>Encrypted</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger"><i class="bi bi-lock me-1"></i>Not Encrypted</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if asset.encryption_checked_by %}
|
||||||
|
<div class="small text-muted mt-1">
|
||||||
|
by <strong>{{ asset.encryption_checked_by.username }}</strong>
|
||||||
|
{% if asset.encryption_checked_at %}
|
||||||
|
— {{ asset.encryption_checked_at.strftime('%d/%m/%Y %H:%M') }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-4 mt-2">
|
||||||
|
{% if asset.backup_checked %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-cloud-check me-1"></i>Backup OK</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning text-dark"><i class="bi bi-cloud me-1"></i>No Backup</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if asset.backup_checked_by %}
|
||||||
|
<div class="small text-muted mt-1">
|
||||||
|
by <strong>{{ asset.backup_checked_by.username }}</strong>
|
||||||
|
{% if asset.backup_checked_at %}
|
||||||
|
— {{ asset.backup_checked_at.strftime('%d/%m/%Y %H:%M') }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-4 mt-2">
|
||||||
|
{% if asset.hr_notified %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-person-check me-1"></i>HR Notified</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary"><i class="bi bi-person me-1"></i>HR Pending</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if asset.hr_notified_by %}
|
||||||
|
<div class="small text-muted mt-1">
|
||||||
|
by <strong>{{ asset.hr_notified_by.username }}</strong>
|
||||||
|
{% if asset.hr_notified_at %}
|
||||||
|
— {{ asset.hr_notified_at.strftime('%d/%m/%Y %H:%M') }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collapsible edit form -->
|
||||||
|
<div class="collapse" id="complianceEdit">
|
||||||
|
<div class="card-body border-top pt-3">
|
||||||
|
<form method="POST" action="{{ url_for('assets.update_compliance', asset_id=asset.id) }}">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small fw-semibold">Inventory Number</label>
|
||||||
|
<input type="text" name="inventory_number" class="form-control form-control-sm"
|
||||||
|
value="{{ asset.inventory_number or '' }}" placeholder="INV-0001">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small fw-semibold">AD Device Name</label>
|
||||||
|
<input type="text" name="ad_device_name" class="form-control form-control-sm"
|
||||||
|
value="{{ asset.ad_device_name or '' }}" placeholder="DESKTOP-AB1234">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small fw-semibold">Current User in AD</label>
|
||||||
|
{% if asset.current_user %}
|
||||||
|
<div class="form-control form-control-sm bg-light text-muted">
|
||||||
|
{{ asset.current_user.display_name }} ({{ asset.current_user.windows_id }})
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="form-control form-control-sm bg-light text-muted">Not assigned</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label small fw-semibold">Location Note</label>
|
||||||
|
<textarea name="location_note" class="form-control form-control-sm" rows="2"
|
||||||
|
placeholder="e.g. Building A, Room 102, Desk 5">{{ asset.location_note or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="d-flex gap-4 flex-wrap mt-1">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" name="encryption_checked"
|
||||||
|
id="chkEncrypt" value="1"
|
||||||
|
{% if asset.encryption_checked %}checked{% endif %}>
|
||||||
|
<label class="form-check-label fw-semibold" for="chkEncrypt">
|
||||||
|
<i class="bi bi-lock-fill me-1 text-success"></i>Encryption Verified
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" name="backup_checked"
|
||||||
|
id="chkBackup" value="1"
|
||||||
|
{% if asset.backup_checked %}checked{% endif %}>
|
||||||
|
<label class="form-check-label fw-semibold" for="chkBackup">
|
||||||
|
<i class="bi bi-cloud-check me-1 text-primary"></i>Backup Configured
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" name="hr_notified"
|
||||||
|
id="chkHR" value="1"
|
||||||
|
{% if asset.hr_notified %}checked{% endif %}>
|
||||||
|
<label class="form-check-label fw-semibold" for="chkHR">
|
||||||
|
<i class="bi bi-person-check me-1 text-warning"></i>HR Send / Notified
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label small fw-semibold" for="compliance_notes">
|
||||||
|
<i class="bi bi-chat-left-text me-1 text-secondary"></i>Note
|
||||||
|
<span class="text-muted fw-normal">(reason for check / uncheck — saved with each change)</span>
|
||||||
|
</label>
|
||||||
|
<textarea name="compliance_notes" id="compliance_notes"
|
||||||
|
class="form-control form-control-sm" rows="2"
|
||||||
|
placeholder="e.g. BitLocker verified on site visit, backup re-enabled after restore…"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 mt-3">
|
||||||
|
<button type="submit" class="btn btn-sm btn-success">
|
||||||
|
<i class="bi bi-check2 me-1"></i>Save Changes
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
data-bs-toggle="collapse" data-bs-target="#complianceEdit">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compliance change history -->
|
||||||
|
{% if compliance_log %}
|
||||||
|
<div class="card border-0 shadow-sm mt-2">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<!-- Compliance per-check history -->
|
||||||
|
{% if check_history %}
|
||||||
|
<div class="card border-0 shadow-sm mt-2">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-shield-exclamation me-2 text-secondary"></i>Compliance Check History</span>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button"
|
||||||
|
data-bs-toggle="collapse" data-bs-target="#checkHistory">
|
||||||
|
Show / Hide
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="collapse" id="checkHistory">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0 small">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width:160px">Date & Time</th>
|
||||||
|
<th style="width:140px">Check</th>
|
||||||
|
<th style="width:90px">Result</th>
|
||||||
|
<th style="width:130px">Performed by</th>
|
||||||
|
<th>Note</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for entry in check_history %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-nowrap">{{ entry.performed_at.strftime('%d/%m/%Y %H:%M') }}</td>
|
||||||
|
<td>{{ entry.check_type_label }}</td>
|
||||||
|
<td>
|
||||||
|
{% if entry.checked %}
|
||||||
|
<span class="badge bg-success">Verified</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger">Cleared</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if entry.performed_by %}
|
||||||
|
<span class="fw-semibold">{{ entry.performed_by.username }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ entry.notes or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}{# end asset_type in Laptop/Desktop #}
|
||||||
|
|
||||||
|
<!-- Return modals -->
|
||||||
|
{% for a in history %}{% if a.is_active %}
|
||||||
|
<div class="modal fade" id="returnModal{{ a.id }}" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Return Asset</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ url_for('assignments.return_asset', assignment_id=a.id) }}">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Return Date</label>
|
||||||
|
<input type="date" name="returned_date" class="form-control"
|
||||||
|
value="{{ today_date }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Notes (optional)</label>
|
||||||
|
<textarea name="return_notes" class="form-control" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-warning">Confirm Return</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
160
app/templates/assets/form.html
Normal file
160
app/templates/assets/form.html
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}{{ 'Edit Asset' if asset else 'New Asset' }} – IT Asset Management{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('assets.index') }}">Assets</a></li>
|
||||||
|
<li class="breadcrumb-item active">{{ 'Edit' if asset else 'New Asset' }}</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header mb-4">
|
||||||
|
<h1><i class="bi bi-laptop me-2"></i>{{ 'Edit Asset' if asset else 'Add Asset' }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm" style="max-width:800px;">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if not asset and prefill and prefill.service_tag %}
|
||||||
|
<div class="alert alert-info py-2 mb-3 small">
|
||||||
|
<i class="bi bi-cloud-check me-1"></i>
|
||||||
|
Pre-filled from Dell service tag <strong>{{ prefill.service_tag }}</strong>. Review the details below before saving.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST" action="{{ url_for('assets.edit', asset_id=asset.id) if asset else url_for('assets.create') }}">
|
||||||
|
|
||||||
|
<h6 class="text-uppercase text-muted mb-3 small">Identifiers</h6>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Serial Number <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" name="serial_number" class="form-control"
|
||||||
|
value="{{ asset.serial_number if asset else (prefill.serial_number if prefill else '') }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Service Tag</label>
|
||||||
|
<input type="text" name="service_tag" class="form-control"
|
||||||
|
value="{{ asset.service_tag or '' if asset else (prefill.service_tag if prefill else '') }}"
|
||||||
|
placeholder="e.g. Dell service tag">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Asset Tag</label>
|
||||||
|
<input type="text" name="asset_tag" class="form-control"
|
||||||
|
value="{{ asset.asset_tag or '' if asset else '' }}"
|
||||||
|
placeholder="Internal barcode/tag">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="text-uppercase text-muted mb-3 small">Classification</h6>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Asset Type <span class="text-danger">*</span></label>
|
||||||
|
<select name="asset_type" class="form-select" required>
|
||||||
|
{% for t in asset_types %}
|
||||||
|
<option value="{{ t }}"
|
||||||
|
{% if asset and asset.asset_type == t %}selected
|
||||||
|
{% elif not asset and prefill and prefill.asset_type == t %}selected
|
||||||
|
{% elif not asset and not prefill and t == 'Laptop' %}selected
|
||||||
|
{% endif %}>{{ t }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Brand</label>
|
||||||
|
<input type="text" name="brand" class="form-control"
|
||||||
|
value="{{ asset.brand or '' if asset else (prefill.brand if prefill else '') }}"
|
||||||
|
placeholder="e.g. Dell, Lenovo, HP">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Model</label>
|
||||||
|
<input type="text" name="model" class="form-control"
|
||||||
|
value="{{ asset.model or '' if asset else (prefill.model if prefill else '') }}"
|
||||||
|
placeholder="e.g. Latitude 5540">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="text-uppercase text-muted mb-3 small">Technical Specs</h6>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Processor</label>
|
||||||
|
<input type="text" name="processor" class="form-control"
|
||||||
|
value="{{ asset.processor or '' if asset else '' }}"
|
||||||
|
placeholder="e.g. Intel Core i5-1345U">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">RAM (GB)</label>
|
||||||
|
<input type="number" name="ram_gb" class="form-control" min="0"
|
||||||
|
value="{{ asset.ram_gb or '' if asset else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Storage (GB)</label>
|
||||||
|
<input type="number" name="storage_gb" class="form-control" min="0"
|
||||||
|
value="{{ asset.storage_gb or '' if asset else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Operating System</label>
|
||||||
|
<input type="text" name="operating_system" class="form-control"
|
||||||
|
value="{{ asset.operating_system or '' if asset else (prefill.operating_system if prefill else 'Windows 11 Pro') }}"
|
||||||
|
placeholder="e.g. Windows 11 Pro">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">MAC Address</label>
|
||||||
|
<input type="text" name="mac_address" class="form-control"
|
||||||
|
value="{{ asset.mac_address or '' if asset else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Location</label>
|
||||||
|
<input type="text" name="location" class="form-control"
|
||||||
|
value="{{ asset.location or '' if asset else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="text-uppercase text-muted mb-3 small">Procurement</h6>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Purchase Date</label>
|
||||||
|
<input type="date" name="purchase_date" class="form-control"
|
||||||
|
value="{{ asset.purchase_date.isoformat() if asset and asset.purchase_date else (prefill.purchase_date if prefill else '') }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Warranty Expiry</label>
|
||||||
|
<input type="date" name="warranty_expiry" class="form-control"
|
||||||
|
value="{{ asset.warranty_expiry.isoformat() if asset and asset.warranty_expiry else (prefill.warranty_expiry if prefill else '') }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Purchase Price</label>
|
||||||
|
<input type="number" name="purchase_price" class="form-control" step="0.01" min="0"
|
||||||
|
value="{{ asset.purchase_price or '' if asset else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Status</label>
|
||||||
|
<select name="status" class="form-select">
|
||||||
|
{% for val, label in asset_statuses %}
|
||||||
|
<option value="{{ val }}" {% if asset and asset.status == val %}selected{% elif not asset and val == 'available' %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Supplier</label>
|
||||||
|
<input type="text" name="supplier" class="form-control"
|
||||||
|
value="{{ asset.supplier or '' if asset else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">PO Number</label>
|
||||||
|
<input type="text" name="po_number" class="form-control"
|
||||||
|
value="{{ asset.po_number or '' if asset else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Notes</label>
|
||||||
|
<textarea name="notes" class="form-control" rows="2">{{ asset.notes or '' if asset else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>{{ 'Save Changes' if asset else 'Create Asset' }}
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('assets.detail', asset_id=asset.id) if asset else url_for('assets.index') }}"
|
||||||
|
class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
300
app/templates/assets/index.html
Normal file
300
app/templates/assets/index.html
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Assets – IT Asset Management{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item active">Assets</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<h1><i class="bi bi-laptop me-2"></i>Assets</h1>
|
||||||
|
<a href="{{ url_for('assets.create') }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Add Asset
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dell Service Tag Quick Import -->
|
||||||
|
<div class="card border-0 shadow-sm mb-4" id="dellLookupCard">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span class="fw-semibold text-nowrap">
|
||||||
|
<i class="bi bi-search me-1 text-primary"></i>Dell Quick Import
|
||||||
|
</span>
|
||||||
|
<span class="text-muted small text-nowrap">Enter a service tag to open the asset form pre-filled:</span>
|
||||||
|
<div class="input-group input-group-sm" style="max-width:220px;">
|
||||||
|
<input type="text" id="dellTagInput" class="form-control text-uppercase"
|
||||||
|
placeholder="e.g. ABC1234" maxlength="20"
|
||||||
|
style="text-transform:uppercase; letter-spacing:.05em;">
|
||||||
|
<button class="btn btn-outline-primary" id="dellLookupBtn" type="button">
|
||||||
|
<i class="bi bi-cloud-download me-1"></i>Lookup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="dellLookupSpinner" class="spinner-border spinner-border-sm text-primary d-none" role="status">
|
||||||
|
<span class="visually-hidden">Loading…</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-muted small ms-auto">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
Full auto-fill available with a
|
||||||
|
<a href="https://tdm.dell.com" target="_blank" class="text-decoration-none">free Dell TechDirect API key</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result preview (hidden until data arrives) -->
|
||||||
|
<div id="dellLookupResult" class="mt-3 d-none">
|
||||||
|
<div class="alert mb-2 py-2 d-flex align-items-start gap-3" id="dellResultBody">
|
||||||
|
<i class="bi bi-pc-display-horizontal fs-4 flex-shrink-0 mt-1" id="dellResultIcon"></i>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fw-semibold mb-1" id="dellResultTitle"></div>
|
||||||
|
<div class="row row-cols-2 row-cols-md-4 g-1 small" id="dellResultMeta"></div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column gap-1 flex-shrink-0">
|
||||||
|
<a id="dellCreateBtn" href="#" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Create Asset
|
||||||
|
</a>
|
||||||
|
<a id="dellSupportLink" href="#" target="_blank" class="btn btn-sm btn-outline-secondary d-none">
|
||||||
|
<i class="bi bi-box-arrow-up-right me-1"></i>View on Dell
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error area -->
|
||||||
|
<div id="dellLookupError" class="alert alert-warning mt-2 py-2 d-none small mb-0"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<form method="GET" class="row g-2 mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="text" name="q" class="form-control" placeholder="Search SN, service tag, brand, model…" value="{{ q }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="status" class="form-select form-select-sm">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
{% for val, label in asset_statuses %}
|
||||||
|
<option value="{{ val }}" {% if status_filter == val %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="asset_type" class="form-select form-select-sm">
|
||||||
|
<option value="">All types</option>
|
||||||
|
{% for t in asset_types %}
|
||||||
|
<option value="{{ t }}" {% if type_filter == t %}selected{% endif %}>{{ t }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">Filter</button>
|
||||||
|
<a href="{{ url_for('assets.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Brand / Model</th>
|
||||||
|
<th>Serial Number</th>
|
||||||
|
<th>Service Tag</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Assigned To</th>
|
||||||
|
<th>Warranty</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in pagination.items %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="badge bg-secondary">{{ a.asset_type }}</span></td>
|
||||||
|
<td>{{ a.brand or '' }} {{ a.model or '' }}</td>
|
||||||
|
<td><code>{{ a.serial_number }}</code></td>
|
||||||
|
<td><code>{{ a.service_tag or '—' }}</code></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-{{ a.status }}">{{ a.status | title }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if a.current_user %}
|
||||||
|
<a href="{{ url_for('users.detail', user_id=a.current_user.id) }}">
|
||||||
|
{{ a.current_user.display_name }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if a.warranty_expiry %}
|
||||||
|
<span class="{% if a.warranty_expiry < today %}text-danger{% else %}text-success{% endif %}">
|
||||||
|
{{ a.warranty_expiry.strftime('%d/%m/%Y') }}
|
||||||
|
</span>
|
||||||
|
{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('assets.detail', asset_id=a.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary py-0 px-2">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="8" class="text-center text-muted py-4">No assets found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if pagination.pages > 1 %}
|
||||||
|
<div class="card-footer bg-white d-flex justify-content-between align-items-center py-2">
|
||||||
|
<small class="text-muted">Showing {{ pagination.first }}–{{ pagination.last }} of {{ pagination.total }}</small>
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination pagination-sm mb-0">
|
||||||
|
{% if pagination.has_prev %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('assets.index', page=pagination.prev_num, q=q, status=status_filter, asset_type=type_filter) }}">‹</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||||
|
{% if p %}
|
||||||
|
<li class="page-item {% if p == pagination.page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('assets.index', page=p, q=q, status=status_filter, asset_type=type_filter) }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('assets.index', page=pagination.next_num, q=q, status=status_filter, asset_type=type_filter) }}">›</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<script>
|
||||||
|
// make today available for warranty colour
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const input = document.getElementById('dellTagInput');
|
||||||
|
const btn = document.getElementById('dellLookupBtn');
|
||||||
|
const spinner = document.getElementById('dellLookupSpinner');
|
||||||
|
const result = document.getElementById('dellLookupResult');
|
||||||
|
const errBox = document.getElementById('dellLookupError');
|
||||||
|
const title = document.getElementById('dellResultTitle');
|
||||||
|
const meta = document.getElementById('dellResultMeta');
|
||||||
|
const createBtn = document.getElementById('dellCreateBtn');
|
||||||
|
|
||||||
|
function setLoading(on) {
|
||||||
|
btn.disabled = on;
|
||||||
|
spinner.classList.toggle('d-none', !on);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errBox.textContent = msg;
|
||||||
|
errBox.classList.remove('d-none');
|
||||||
|
result.classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function doLookup() {
|
||||||
|
const tag = input.value.trim().toUpperCase();
|
||||||
|
if (!tag) { input.focus(); return; }
|
||||||
|
|
||||||
|
result.classList.add('d-none');
|
||||||
|
errBox.classList.add('d-none');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
fetch(`{{ url_for('assets.dell_lookup') }}?tag=${encodeURIComponent(tag)}`)
|
||||||
|
.then(r => r.json().then(d => ({ ok: r.ok, status: r.status, data: d })))
|
||||||
|
.then(({ ok, status, data }) => {
|
||||||
|
setLoading(false);
|
||||||
|
if (!ok) {
|
||||||
|
if (status === 409 && data.existing_id) {
|
||||||
|
errBox.innerHTML = `⚠ ${data.error} — <a href="/assets/${data.existing_id}">View asset</a>`;
|
||||||
|
errBox.classList.remove('d-none');
|
||||||
|
} else {
|
||||||
|
showError(data.error || 'Lookup failed.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build preview
|
||||||
|
const isPartial = data.source === 'partial';
|
||||||
|
const resultBody = document.getElementById('dellResultBody');
|
||||||
|
const resultIcon = document.getElementById('dellResultIcon');
|
||||||
|
const supportLink = document.getElementById('dellSupportLink');
|
||||||
|
|
||||||
|
resultBody.className = `alert mb-2 py-2 d-flex align-items-start gap-3 ${isPartial ? 'alert-warning' : 'alert-info'}`;
|
||||||
|
resultIcon.className = `bi bi-pc-display-horizontal fs-4 flex-shrink-0 mt-1 ${isPartial ? 'text-warning' : 'text-primary'}`;
|
||||||
|
|
||||||
|
if (isPartial) {
|
||||||
|
// Auto-open Dell's warranty page in a new tab so the user can read model + warranty
|
||||||
|
if (data.support_url) window.open(data.support_url, '_blank', 'noopener');
|
||||||
|
title.innerHTML = `Dell service tag <strong>${data.service_tag}</strong> — Dell’s page opened in a new tab. Copy model & warranty date into the form below.`;
|
||||||
|
meta.innerHTML = `
|
||||||
|
<div><span class="fw-medium">Brand:</span> Dell</div>
|
||||||
|
<div><span class="fw-medium">OS:</span> ${data.operating_system}</div>
|
||||||
|
<div><span class="fw-medium">Model:</span> <em class="text-muted">fill from Dell tab →</em></div>
|
||||||
|
<div><span class="fw-medium">Warranty:</span> <em class="text-muted">fill from Dell tab →</em></div>`;
|
||||||
|
supportLink.href = data.support_url;
|
||||||
|
supportLink.classList.remove('d-none');
|
||||||
|
createBtn.textContent = '';
|
||||||
|
createBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Open Form';
|
||||||
|
} else {
|
||||||
|
title.textContent = `Dell ${data.model || data.service_tag}`;
|
||||||
|
supportLink.href = data.support_url || '#';
|
||||||
|
supportLink.classList.remove('d-none');
|
||||||
|
const fields = [
|
||||||
|
['Type', data.asset_type],
|
||||||
|
['Service Tag', data.service_tag],
|
||||||
|
['Serial', data.serial_number || '—'],
|
||||||
|
['Warranty', data.warranty_expiry || '—'],
|
||||||
|
['Purchased', data.purchase_date || '—'],
|
||||||
|
['OS', data.operating_system],
|
||||||
|
];
|
||||||
|
meta.innerHTML = fields
|
||||||
|
.map(([k, v]) => `<div><span class="fw-medium">${k}:</span> ${v || '—'}</div>`)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build "Create Asset" URL with pre-filled params
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
service_tag: data.service_tag || '',
|
||||||
|
serial_number: data.serial_number || '',
|
||||||
|
brand: data.brand || 'Dell',
|
||||||
|
model: data.model || '',
|
||||||
|
asset_type: data.asset_type || '',
|
||||||
|
operating_system: data.operating_system || '',
|
||||||
|
warranty_expiry: data.warranty_expiry || '',
|
||||||
|
purchase_date: data.purchase_date || '',
|
||||||
|
});
|
||||||
|
createBtn.href = `{{ url_for('assets.create') }}?${params.toString()}`;
|
||||||
|
result.classList.remove('d-none');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLoading(false);
|
||||||
|
showError('Network error – could not reach the server.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', doLookup);
|
||||||
|
input.addEventListener('keydown', e => { if (e.key === 'Enter') doLookup(); });
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
input.value = input.value.toUpperCase();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
133
app/templates/assignments/form.html
Normal file
133
app/templates/assignments/form.html
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Assign Asset – IT Asset Management{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('assignments.index') }}">Assignments</a></li>
|
||||||
|
<li class="breadcrumb-item active">New Assignment</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header mb-4">
|
||||||
|
<h1><i class="bi bi-arrow-left-right me-2"></i>Assign Asset to User</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm" style="max-width:600px;">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('assignments.create') }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">User <span class="text-danger">*</span></label>
|
||||||
|
<input type="hidden" name="user_id" id="userId" value="{{ preselect_user_id or '' }}">
|
||||||
|
<input type="text" id="userSearch" class="form-control"
|
||||||
|
placeholder="Search by name or Windows ID…"
|
||||||
|
value="" autocomplete="off">
|
||||||
|
<div id="userDropdown" class="list-group position-absolute shadow" style="z-index:1000;display:none;min-width:350px;"></div>
|
||||||
|
<div id="userDisplay" class="form-text text-success fw-semibold"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Asset <span class="text-danger">*</span></label>
|
||||||
|
<input type="hidden" name="asset_id" id="assetId" value="{{ preselect_asset_id or '' }}">
|
||||||
|
<input type="text" id="assetSearch" class="form-control"
|
||||||
|
placeholder="Search by serial number or service tag…"
|
||||||
|
value="" autocomplete="off">
|
||||||
|
<div id="assetDropdown" class="list-group position-absolute shadow" style="z-index:1000;display:none;min-width:350px;"></div>
|
||||||
|
<div id="assetDisplay" class="form-text text-success fw-semibold"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Assigned Date</label>
|
||||||
|
<input type="date" name="assigned_date" class="form-control" id="assignedDate">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">Notes</label>
|
||||||
|
<textarea name="notes" class="form-control" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Create Assignment
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('assignments.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Set today's date as default
|
||||||
|
document.getElementById('assignedDate').value = new Date().toISOString().slice(0,10);
|
||||||
|
|
||||||
|
// ── Generic live-search helper ───────────────────────────────────
|
||||||
|
function liveSearch(inputId, dropdownId, hiddenId, displayId, endpoint, labelField) {
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
const dropdown = document.getElementById(dropdownId);
|
||||||
|
const hidden = document.getElementById(hiddenId);
|
||||||
|
const display = document.getElementById(displayId);
|
||||||
|
let timer;
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
const q = input.value.trim();
|
||||||
|
if (q.length < 2) { dropdown.style.display = 'none'; return; }
|
||||||
|
fetch(`${endpoint}?q=${encodeURIComponent(q)}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(items => {
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
if (!items.length) { dropdown.style.display = 'none'; return; }
|
||||||
|
items.forEach(item => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.className = 'list-group-item list-group-item-action py-2';
|
||||||
|
a.textContent = item.text || item[labelField];
|
||||||
|
// colour disabled assets
|
||||||
|
if (item.status && item.status !== 'available') {
|
||||||
|
a.className += ' text-muted';
|
||||||
|
a.textContent += ` [${item.status}]`;
|
||||||
|
}
|
||||||
|
a.addEventListener('click', () => {
|
||||||
|
hidden.value = item.id;
|
||||||
|
input.value = item.text || item[labelField];
|
||||||
|
display.textContent = '✓ Selected: ' + (item.windows_id || item.serial_number || '');
|
||||||
|
dropdown.style.display = 'none';
|
||||||
|
});
|
||||||
|
dropdown.appendChild(a);
|
||||||
|
});
|
||||||
|
dropdown.style.display = 'block';
|
||||||
|
});
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
if (!input.contains(e.target)) dropdown.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
liveSearch('userSearch', 'userDropdown', 'userId', 'userDisplay',
|
||||||
|
'{{ url_for("users.search") }}', 'text');
|
||||||
|
liveSearch('assetSearch', 'assetDropdown', 'assetId', 'assetDisplay',
|
||||||
|
'{{ url_for("assets.search") }}', 'text');
|
||||||
|
|
||||||
|
// Pre-fill labels if IDs were passed via URL
|
||||||
|
{% if preselect_user_id %}
|
||||||
|
fetch('{{ url_for("users.search") }}?q={{ preselect_user_id }}')
|
||||||
|
.then(r => r.json()).then(items => {
|
||||||
|
if (items.length) {
|
||||||
|
document.getElementById('userSearch').value = items[0].text;
|
||||||
|
document.getElementById('userDisplay').textContent = '✓ Pre-selected';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
{% if preselect_asset_id %}
|
||||||
|
fetch('{{ url_for("assets.search") }}?q={{ preselect_asset_id }}')
|
||||||
|
.then(r => r.json()).then(items => {
|
||||||
|
if (items.length) {
|
||||||
|
document.getElementById('assetSearch').value = items[0].text;
|
||||||
|
document.getElementById('assetDisplay').textContent = '✓ Pre-selected';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
144
app/templates/assignments/index.html
Normal file
144
app/templates/assignments/index.html
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Assignments – IT Asset Management{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item active">Assignments</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<h1><i class="bi bi-arrow-left-right me-2"></i>Assignments</h1>
|
||||||
|
<a href="{{ url_for('assignments.create') }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Assign Asset
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" class="row g-2 mb-3">
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="form-check form-check-inline mt-1">
|
||||||
|
<input class="form-check-input" type="checkbox" name="active" value="0" id="chkAll"
|
||||||
|
{% if not active_only %}checked{% endif %} onchange="this.form.submit()">
|
||||||
|
<label class="form-check-label" for="chkAll">Show returned</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Windows ID</th>
|
||||||
|
<th>Asset</th>
|
||||||
|
<th>Serial Number</th>
|
||||||
|
<th>Assigned</th>
|
||||||
|
<th>Returned</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in pagination.items %}
|
||||||
|
<tr {% if a.user.is_masked %}class="masked-row"{% endif %}>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('users.detail', user_id=a.user.id) }}">{{ a.user.display_name }}</a>
|
||||||
|
</td>
|
||||||
|
<td><code>{{ a.user.windows_id }}</code></td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('assets.detail', asset_id=a.asset.id) }}">
|
||||||
|
{{ a.asset.brand or '' }} {{ a.asset.model or '' }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td><code>{{ a.asset.serial_number }}</code></td>
|
||||||
|
<td>{{ a.assigned_date.strftime('%d/%m/%Y') if a.assigned_date else '—' }}</td>
|
||||||
|
<td>{{ a.returned_date.strftime('%d/%m/%Y') if a.returned_date else '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if a.is_active %}
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Returned</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if a.is_active %}
|
||||||
|
<button class="btn btn-sm btn-outline-warning py-0 px-2"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#returnModal{{ a.id }}">
|
||||||
|
<i class="bi bi-arrow-return-left"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('paperwork.create', assignment_id=a.id, user_id=a.user.id, asset_id=a.asset.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-info py-0 px-2" title="Create document">
|
||||||
|
<i class="bi bi-file-earmark-plus"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="8" class="text-center text-muted py-4">No assignments found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if pagination.pages > 1 %}
|
||||||
|
<div class="card-footer bg-white d-flex justify-content-between align-items-center py-2">
|
||||||
|
<small class="text-muted">Showing {{ pagination.first }}–{{ pagination.last }} of {{ pagination.total }}</small>
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination pagination-sm mb-0">
|
||||||
|
{% if pagination.has_prev %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('assignments.index', page=pagination.prev_num, active='0' if not active_only else '1') }}">‹</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% for p in pagination.iter_pages() %}
|
||||||
|
{% if p %}
|
||||||
|
<li class="page-item {% if p == pagination.page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('assignments.index', page=p, active='0' if not active_only else '1') }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('assignments.index', page=pagination.next_num, active='0' if not active_only else '1') }}">›</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Return modals -->
|
||||||
|
{% for a in pagination.items %}{% if a.is_active %}
|
||||||
|
<div class="modal fade" id="returnModal{{ a.id }}" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Return Asset</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ url_for('assignments.return_asset', assignment_id=a.id) }}">
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Returning <strong>{{ a.asset.serial_number }}</strong> from
|
||||||
|
<strong>{{ a.user.display_name }}</strong>.</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Return Date</label>
|
||||||
|
<input type="date" name="returned_date" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-0">
|
||||||
|
<label class="form-label">Notes</label>
|
||||||
|
<textarea name="return_notes" class="form-control" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-warning">Confirm Return</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
99
app/templates/audit/index.html
Normal file
99
app/templates/audit/index.html
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Audit Log – IT Asset Management{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item active">Audit Log</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header mb-4">
|
||||||
|
<h1><i class="bi bi-shield-check me-2"></i>Audit Log</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" class="row g-2 mb-3">
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="table" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="">All tables</option>
|
||||||
|
{% for t in tables %}
|
||||||
|
<option value="{{ t }}" {% if table_filter == t %}selected{% endif %}>{{ t }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<select name="action" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="">All actions</option>
|
||||||
|
{% for a in actions %}
|
||||||
|
<option value="{{ a }}" {% if action_filter == a %}selected{% endif %}>{{ a }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<a href="{{ url_for('audit.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Date/Time</th>
|
||||||
|
<th>Performed By</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Table</th>
|
||||||
|
<th>Record</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for e in pagination.items %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-nowrap">{{ e.performed_at.strftime('%d/%m/%Y %H:%M') if e.performed_at else '—' }}</td>
|
||||||
|
<td>{{ e.performed_by.username if e.performed_by else '<system>' }}</td>
|
||||||
|
<td>
|
||||||
|
{% set colours = {'create':'success','update':'primary','delete':'danger','mask':'purple','assign':'info','return':'warning','import':'secondary'} %}
|
||||||
|
<span class="badge bg-{{ colours.get(e.action, 'secondary') }}">{{ e.action }}</span>
|
||||||
|
</td>
|
||||||
|
<td><code>{{ e.table_name }}</code></td>
|
||||||
|
<td>{{ e.record_id or '—' }}</td>
|
||||||
|
<td>{{ e.description or '—' }}</td>
|
||||||
|
<td><small class="text-muted">{{ e.ip_address or '—' }}</small></td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="text-center text-muted py-4">No audit entries found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if pagination.pages > 1 %}
|
||||||
|
<div class="card-footer bg-white d-flex justify-content-between align-items-center py-2">
|
||||||
|
<small class="text-muted">Showing {{ pagination.first }}–{{ pagination.last }} of {{ pagination.total }}</small>
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination pagination-sm mb-0">
|
||||||
|
{% if pagination.has_prev %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('audit.index', page=pagination.prev_num, table=table_filter, action=action_filter) }}">‹</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% for p in pagination.iter_pages() %}
|
||||||
|
{% if p %}
|
||||||
|
<li class="page-item {% if p == pagination.page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('audit.index', page=p, table=table_filter, action=action_filter) }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('audit.index', page=pagination.next_num, table=table_filter, action=action_filter) }}">›</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
52
app/templates/auth/login.html
Normal file
52
app/templates/auth/login.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Login – IT Asset Management</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||||
|
<style>
|
||||||
|
body { background: #1a3a5c; min-height: 100vh; display:flex; align-items:center; justify-content:center; }
|
||||||
|
.login-card { width: 380px; border-radius: .8rem; border: none;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,.3); }
|
||||||
|
.login-brand { font-size: 1.1rem; font-weight: 700; color: #1a3a5c; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card login-card p-4">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<i class="bi bi-hdd-rack-fill text-primary" style="font-size:2.5rem;"></i>
|
||||||
|
<div class="login-brand mt-2">IT Asset Management</div>
|
||||||
|
<small class="text-muted">Sign in to continue</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}{% for cat, msg in messages %}
|
||||||
|
<div class="alert alert-{{ 'danger' if cat == 'error' else cat }} py-2">{{ msg }}</div>
|
||||||
|
{% endfor %}{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('auth.login') }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Username</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text"><i class="bi bi-person"></i></span>
|
||||||
|
<input type="text" name="username" class="form-control" required autofocus>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text"><i class="bi bi-lock"></i></span>
|
||||||
|
<input type="password" name="password" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-box-arrow-in-right me-1"></i> Sign In
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
223
app/templates/base.html
Normal file
223
app/templates/base.html
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}IT Asset Management{% endblock %}</title>
|
||||||
|
<!-- Bootstrap 5 -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||||
|
<!-- Bootstrap Icons -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--sidebar-bg: #1a3a5c;
|
||||||
|
--sidebar-text: #cce0f5;
|
||||||
|
--sidebar-active: #2e6da4;
|
||||||
|
--accent: #2e86de;
|
||||||
|
}
|
||||||
|
body { background: #f0f4f8; font-size: .92rem; }
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
#sidebar {
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 240px;
|
||||||
|
background: var(--sidebar-bg);
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
z-index: 1000;
|
||||||
|
transition: width .2s;
|
||||||
|
}
|
||||||
|
#sidebar .brand {
|
||||||
|
padding: 1.1rem 1.2rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.12);
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
#sidebar .brand small { font-weight: 400; font-size: .7rem; color: var(--sidebar-text); display:block; }
|
||||||
|
#sidebar .nav-section {
|
||||||
|
font-size: .68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
color: rgba(255,255,255,.4);
|
||||||
|
padding: .75rem 1.2rem .25rem;
|
||||||
|
}
|
||||||
|
#sidebar .nav-link {
|
||||||
|
color: var(--sidebar-text);
|
||||||
|
padding: .45rem 1.2rem;
|
||||||
|
border-radius: 0;
|
||||||
|
display: flex; align-items: center; gap: .6rem;
|
||||||
|
font-size: .88rem;
|
||||||
|
}
|
||||||
|
#sidebar .nav-link:hover,
|
||||||
|
#sidebar .nav-link.active {
|
||||||
|
background: var(--sidebar-active);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
#sidebar .nav-link i { font-size: 1rem; width: 1.2rem; text-align: center; }
|
||||||
|
#sidebar .sidebar-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: .8rem 1.2rem;
|
||||||
|
border-top: 1px solid rgba(255,255,255,.12);
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--sidebar-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content */
|
||||||
|
#main-wrapper { margin-left: 240px; min-height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
#topbar {
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #dde3ea;
|
||||||
|
padding: .55rem 1.5rem;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
position: sticky; top: 0; z-index: 900;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
#topbar .breadcrumb { margin: 0; background: none; padding: 0; font-size: .85rem; }
|
||||||
|
#page-content { flex: 1; padding: 1.5rem; }
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.stat-card { border: none; border-radius: .6rem; box-shadow: 0 2px 8px rgba(0,0,0,.07); }
|
||||||
|
.stat-card .card-body { padding: 1.1rem 1.3rem; }
|
||||||
|
.stat-card .stat-icon { font-size: 2rem; opacity: .85; }
|
||||||
|
.stat-card .stat-value { font-size: 2rem; font-weight: 700; line-height: 1; }
|
||||||
|
.stat-card .stat-label { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; opacity: .8; }
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge-available { background:#198754 !important; }
|
||||||
|
.badge-assigned { background:#0d6efd !important; }
|
||||||
|
.badge-maintenance{ background:#ffc107 !important; color:#000 !important; }
|
||||||
|
.badge-retired { background:#6c757d !important; }
|
||||||
|
.badge-lost { background:#dc3545 !important; }
|
||||||
|
.badge-masked { background:#6f42c1 !important; }
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table-hover tbody tr:hover { background:#f5f8ff; }
|
||||||
|
|
||||||
|
/* Masked row */
|
||||||
|
tr.masked-row { opacity: .65; font-style: italic; }
|
||||||
|
|
||||||
|
/* Search bar */
|
||||||
|
.search-bar { max-width: 360px; }
|
||||||
|
|
||||||
|
/* Page header */
|
||||||
|
.page-header h1 { font-size: 1.35rem; font-weight: 700; color: #1a3a5c; margin: 0; }
|
||||||
|
</style>
|
||||||
|
{% block extra_head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ===== SIDEBAR ===== -->
|
||||||
|
<nav id="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<i class="bi bi-hdd-rack-fill me-2"></i>IT Assets
|
||||||
|
<small>Hardware Management</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-section">Main</div>
|
||||||
|
<a href="{{ url_for('dashboard.index') }}"
|
||||||
|
class="nav-link {% if request.endpoint == 'dashboard.index' %}active{% endif %}">
|
||||||
|
<i class="bi bi-speedometer2"></i> Dashboard
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-section">People</div>
|
||||||
|
<a href="{{ url_for('users.index') }}"
|
||||||
|
class="nav-link {% if request.blueprint == 'users' %}active{% endif %}">
|
||||||
|
<i class="bi bi-people-fill"></i> Users
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('users.import_page') }}"
|
||||||
|
class="nav-link {% if request.endpoint == 'users.import_page' %}active{% endif %}">
|
||||||
|
<i class="bi bi-cloud-download"></i> Import Users
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-section">Hardware</div>
|
||||||
|
<a href="{{ url_for('assets.index') }}"
|
||||||
|
class="nav-link {% if request.blueprint == 'assets' %}active{% endif %}">
|
||||||
|
<i class="bi bi-laptop"></i> Assets
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('assets.create') }}"
|
||||||
|
class="nav-link {% if request.endpoint == 'assets.create' %}active{% endif %}">
|
||||||
|
<i class="bi bi-plus-circle"></i> Add Asset
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-section">Assignments</div>
|
||||||
|
<a href="{{ url_for('assignments.index') }}"
|
||||||
|
class="nav-link {% if request.blueprint == 'assignments' %}active{% endif %}">
|
||||||
|
<i class="bi bi-arrow-left-right"></i> Assignments
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('assignments.create') }}"
|
||||||
|
class="nav-link {% if request.endpoint == 'assignments.create' %}active{% endif %}">
|
||||||
|
<i class="bi bi-plus-circle"></i> Assign Asset
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-section">Documents</div>
|
||||||
|
<a href="{{ url_for('paperwork.index') }}"
|
||||||
|
class="nav-link {% if request.blueprint == 'paperwork' %}active{% endif %}">
|
||||||
|
<i class="bi bi-file-earmark-text"></i> Paperwork
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('paperwork.create') }}"
|
||||||
|
class="nav-link {% if request.endpoint == 'paperwork.create' %}active{% endif %}">
|
||||||
|
<i class="bi bi-file-earmark-plus"></i> New Document
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('doc_templates.index') }}"
|
||||||
|
class="nav-link {% if request.blueprint == 'doc_templates' %}active{% endif %}">
|
||||||
|
<i class="bi bi-file-earmark-word"></i> Templates
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-section">System</div>
|
||||||
|
<a href="{{ url_for('audit.index') }}"
|
||||||
|
class="nav-link {% if request.blueprint == 'audit' %}active{% endif %}">
|
||||||
|
<i class="bi bi-shield-check"></i> Audit Log
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('settings.index') }}"
|
||||||
|
class="nav-link {% if request.blueprint == 'settings' %}active{% endif %}">
|
||||||
|
<i class="bi bi-gear"></i> Settings
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<i class="bi bi-person-circle me-1"></i>
|
||||||
|
<strong>{{ current_user.username }}</strong>
|
||||||
|
<a href="{{ url_for('auth.logout') }}" class="ms-2 text-warning text-decoration-none">
|
||||||
|
<i class="bi bi-box-arrow-right"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ===== MAIN WRAPPER ===== -->
|
||||||
|
<div id="main-wrapper">
|
||||||
|
<!-- Topbar -->
|
||||||
|
<div id="topbar">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">{% block breadcrumb %}<li class="breadcrumb-item active">Home</li>{% endblock %}</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<span class="text-muted" style="font-size:.8rem;">
|
||||||
|
<i class="bi bi-calendar3"></i>
|
||||||
|
{{ now.strftime('%d %b %Y') if now else '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flash messages -->
|
||||||
|
<div id="page-content">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for cat, msg in messages %}
|
||||||
|
<div class="alert alert-{{ 'danger' if cat == 'error' else cat }} alert-dismissible fade show mb-3" role="alert">
|
||||||
|
{{ msg }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
169
app/templates/dashboard/index.html
Normal file
169
app/templates/dashboard/index.html
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Dashboard – IT Asset Management{% endblock %}
|
||||||
|
{% block breadcrumb %}<li class="breadcrumb-item active">Dashboard</li>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header mb-4">
|
||||||
|
<h1><i class="bi bi-speedometer2 me-2"></i>Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Stat Cards ──────────────────────────────────────────────── -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<!-- Users -->
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card stat-card text-white" style="background:#1a3a5c;">
|
||||||
|
<div class="card-body d-flex align-items-center gap-3">
|
||||||
|
<i class="bi bi-people-fill stat-icon"></i>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value">{{ stats.active_users }}</div>
|
||||||
|
<div class="stat-label">Active Users</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card stat-card text-white" style="background:#6f42c1;">
|
||||||
|
<div class="card-body d-flex align-items-center gap-3">
|
||||||
|
<i class="bi bi-eye-slash-fill stat-icon"></i>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value">{{ stats.masked_users }}</div>
|
||||||
|
<div class="stat-label">Masked Records</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Assets -->
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card stat-card text-white" style="background:#198754;">
|
||||||
|
<div class="card-body d-flex align-items-center gap-3">
|
||||||
|
<i class="bi bi-laptop stat-icon"></i>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value">{{ stats.available_assets }}</div>
|
||||||
|
<div class="stat-label">Available Assets</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card stat-card text-white" style="background:#0d6efd;">
|
||||||
|
<div class="card-body d-flex align-items-center gap-3">
|
||||||
|
<i class="bi bi-arrow-left-right stat-icon"></i>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value">{{ stats.assigned_assets }}</div>
|
||||||
|
<div class="stat-label">Assigned Assets</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card stat-card text-white" style="background:#ffc107; color:#000 !important;">
|
||||||
|
<div class="card-body d-flex align-items-center gap-3">
|
||||||
|
<i class="bi bi-tools stat-icon"></i>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value">{{ stats.maintenance_assets }}</div>
|
||||||
|
<div class="stat-label">In Maintenance</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card stat-card text-white" style="background:#6c757d;">
|
||||||
|
<div class="card-body d-flex align-items-center gap-3">
|
||||||
|
<i class="bi bi-hdd-fill stat-icon"></i>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value">{{ stats.total_assets }}</div>
|
||||||
|
<div class="stat-label">Total Assets</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card stat-card text-white" style="background:#0dcaf0; color:#000 !important;">
|
||||||
|
<div class="card-body d-flex align-items-center gap-3">
|
||||||
|
<i class="bi bi-file-earmark-text stat-icon"></i>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value">{{ stats.total_paperwork }}</div>
|
||||||
|
<div class="stat-label">Documents</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="card stat-card text-white" style="background:#fd7e14;">
|
||||||
|
<div class="card-body d-flex align-items-center gap-3">
|
||||||
|
<i class="bi bi-person-badge stat-icon"></i>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value">{{ stats.active_assignments }}</div>
|
||||||
|
<div class="stat-label">Open Assignments</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Quick Actions ──────────────────────────────────────────── -->
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<span class="text-muted me-3" style="font-size:.8rem;">QUICK ACTIONS</span>
|
||||||
|
<a href="{{ url_for('assets.create') }}" class="btn btn-sm btn-outline-primary me-2">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Add Asset
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('assignments.create') }}" class="btn btn-sm btn-outline-success me-2">
|
||||||
|
<i class="bi bi-arrow-left-right me-1"></i>Assign Asset
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('paperwork.create') }}" class="btn btn-sm btn-outline-info me-2">
|
||||||
|
<i class="bi bi-file-earmark-plus me-1"></i>New Document
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('users.import_page') }}" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-cloud-download me-1"></i>Import Users
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Recent Assignments ─────────────────────────────────────── -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3">
|
||||||
|
<i class="bi bi-clock-history me-2 text-primary"></i>Current Assignments (latest 10)
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Windows ID</th>
|
||||||
|
<th>Asset</th>
|
||||||
|
<th>Serial / Service Tag</th>
|
||||||
|
<th>Since</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in recent_assignments %}
|
||||||
|
<tr {% if a.user.is_masked %}class="masked-row"{% endif %}>
|
||||||
|
<td>{{ a.user.display_name }}</td>
|
||||||
|
<td><code>{{ a.user.windows_id }}</code></td>
|
||||||
|
<td>{{ a.asset.brand or '' }} {{ a.asset.model or '' }}</td>
|
||||||
|
<td>
|
||||||
|
<code>{{ a.asset.serial_number }}</code>
|
||||||
|
{% if a.asset.service_tag %}<br><small class="text-muted">{{ a.asset.service_tag }}</small>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ a.assigned_date.strftime('%d/%m/%Y') if a.assigned_date else '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('assets.detail', asset_id=a.asset.id) }}"
|
||||||
|
class="btn btn-xs btn-outline-secondary btn-sm py-0 px-2">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="text-center text-muted py-3">No active assignments.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
155
app/templates/doc_templates/detail.html
Normal file
155
app/templates/doc_templates/detail.html
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}{{ tpl.name }} – Templates{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('doc_templates.index') }}">Templates</a></li>
|
||||||
|
<li class="breadcrumb-item active">{{ tpl.name }}</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<h1><i class="bi bi-file-earmark-word me-2"></i>{{ tpl.name }}</h1>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('doc_templates.download', tpl_id=tpl.id) }}" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-download me-1"></i>Download .docx
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('doc_templates.edit', tpl_id=tpl.id) }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Edit details
|
||||||
|
</a>
|
||||||
|
<form method="POST" action="{{ url_for('doc_templates.rescan', tpl_id=tpl.id) }}" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-clockwise me-1"></i>Re-scan variables
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#deleteModal">
|
||||||
|
<i class="bi bi-trash me-1"></i>Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<!-- Metadata -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-header bg-white fw-semibold small text-uppercase text-muted">Details</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row small mb-0">
|
||||||
|
<dt class="col-5">Category</dt>
|
||||||
|
<dd class="col-7">
|
||||||
|
{% if tpl.category %}
|
||||||
|
<span class="badge bg-secondary">{{ dict(doc_types)[tpl.category] if tpl.category in dict(doc_types) else tpl.category }}</span>
|
||||||
|
{% else %}<span class="text-muted">—</span>{% endif %}
|
||||||
|
</dd>
|
||||||
|
<dt class="col-5">File</dt>
|
||||||
|
<dd class="col-7"><code>{{ tpl.filename }}</code></dd>
|
||||||
|
<dt class="col-5">Uploaded</dt>
|
||||||
|
<dd class="col-7">{{ tpl.created_at.strftime('%d %b %Y %H:%M') }}</dd>
|
||||||
|
<dt class="col-5">By</dt>
|
||||||
|
<dd class="col-7">{{ tpl.created_by.username if tpl.created_by else '—' }}</dd>
|
||||||
|
<dt class="col-5">Docs generated</dt>
|
||||||
|
<dd class="col-7">{{ tpl.paperwork_docs.count() }}</dd>
|
||||||
|
</dl>
|
||||||
|
{% if tpl.description %}
|
||||||
|
<hr class="my-2">
|
||||||
|
<p class="small text-muted mb-0">{{ tpl.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Variables -->
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white fw-semibold small text-uppercase text-muted d-flex justify-content-between align-items-center">
|
||||||
|
Detected Variables
|
||||||
|
<span class="badge bg-primary rounded-pill">{{ tpl.variables | length }}</span>
|
||||||
|
</div>
|
||||||
|
{% set vars = tpl.variables %}
|
||||||
|
{% if vars %}
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="small text-muted mb-3">
|
||||||
|
These placeholders were detected in the template file.
|
||||||
|
They will be filled automatically when generating a document.
|
||||||
|
<strong class="text-danger">PII variables</strong> (name, email, phone)
|
||||||
|
are replaced with <code>[MASKED]</code> when a user's record is erased.
|
||||||
|
</p>
|
||||||
|
{% set pii = ['user_name','user_email','user_phone'] %}
|
||||||
|
<div class="row row-cols-2 row-cols-md-3 g-2">
|
||||||
|
{% for v in vars %}
|
||||||
|
<div class="col">
|
||||||
|
<span class="badge {% if v in pii %}bg-danger{% else %}bg-light text-dark border{% endif %} w-100 text-start p-2">
|
||||||
|
{% if v in pii %}<i class="bi bi-shield-x me-1"></i>{% else %}<i class="bi bi-braces me-1"></i>{% endif %}
|
||||||
|
{{ v }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 small">
|
||||||
|
<span class="badge bg-danger me-1">PII</span> masked on departure
|
||||||
|
<span class="badge bg-light text-dark border me-1">other</span> retained
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body text-muted small">
|
||||||
|
No variables detected. Make sure your template uses <code>{{ variable_name }}</code> syntax
|
||||||
|
and click <strong>Re-scan</strong>.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Documents generated from this template -->
|
||||||
|
{% set recent_docs = tpl.paperwork_docs.order_by('created_at desc').limit(10).all() %}
|
||||||
|
{% if recent_docs %}
|
||||||
|
<div class="card border-0 shadow-sm mt-4">
|
||||||
|
<div class="card-header bg-white fw-semibold small text-uppercase text-muted">Recently Generated Documents</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0 small">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr><th>Title</th><th>User</th><th>Created</th><th>Signed</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for doc in recent_docs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ doc.title }}</td>
|
||||||
|
<td>{{ doc.user.display_name if doc.user else '—' }}</td>
|
||||||
|
<td>{{ doc.created_at.strftime('%d/%m/%Y') }}</td>
|
||||||
|
<td>{% if doc.is_signed %}<i class="bi bi-check-circle text-success"></i>{% else %}<span class="text-muted">—</span>{% endif %}</td>
|
||||||
|
<td><a href="{{ url_for('paperwork.detail', doc_id=doc.id) }}" class="btn btn-sm btn-outline-secondary py-0 px-2"><i class="bi bi-eye"></i></a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Delete modal -->
|
||||||
|
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Delete Template</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
Delete <strong>{{ tpl.name }}</strong>?
|
||||||
|
{% if tpl.paperwork_docs.count() > 0 %}
|
||||||
|
<div class="alert alert-danger mt-2 small">
|
||||||
|
Cannot delete — {{ tpl.paperwork_docs.count() }} document(s) were generated from this template.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
{% if tpl.paperwork_docs.count() == 0 %}
|
||||||
|
<form method="POST" action="{{ url_for('doc_templates.delete', tpl_id=tpl.id) }}">
|
||||||
|
<button class="btn btn-danger btn-sm" type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
41
app/templates/doc_templates/edit.html
Normal file
41
app/templates/doc_templates/edit.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Edit {{ tpl.name }} – Templates{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('doc_templates.index') }}">Templates</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('doc_templates.detail', tpl_id=tpl.id) }}">{{ tpl.name }}</a></li>
|
||||||
|
<li class="breadcrumb-item active">Edit</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header mb-4">
|
||||||
|
<h1><i class="bi bi-pencil me-2"></i>Edit Template</h1>
|
||||||
|
</div>
|
||||||
|
<div class="card border-0 shadow-sm" style="max-width:600px;">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" name="name" class="form-control" value="{{ tpl.name }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Category</label>
|
||||||
|
<select name="category" class="form-select">
|
||||||
|
<option value="">— no category —</option>
|
||||||
|
{% for val, label in doc_types %}
|
||||||
|
<option value="{{ val }}" {% if tpl.category == val %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-semibold">Description</label>
|
||||||
|
<textarea name="description" class="form-control" rows="3">{{ tpl.description or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="{{ url_for('doc_templates.detail', tpl_id=tpl.id) }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
77
app/templates/doc_templates/index.html
Normal file
77
app/templates/doc_templates/index.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Document Templates – IT Asset Management{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item active">Document Templates</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<h1><i class="bi bi-file-earmark-word me-2"></i>Document Templates</h1>
|
||||||
|
<a href="{{ url_for('doc_templates.upload') }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-upload me-1"></i>Upload Template
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info small mb-4">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
Upload <strong>.docx</strong> Word files with <code>{{ variable_name }}</code> placeholders.
|
||||||
|
When creating paperwork, the system fills them automatically from user / asset data.
|
||||||
|
All generated documents can be regenerated with masked PII if a user leaves the company.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if templates %}
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-3 g-3">
|
||||||
|
{% for tpl in templates %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100 border-0 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-start justify-content-between mb-2">
|
||||||
|
<h6 class="mb-0 fw-semibold">
|
||||||
|
<i class="bi bi-file-earmark-word text-primary me-1"></i>{{ tpl.name }}
|
||||||
|
</h6>
|
||||||
|
{% if tpl.category %}
|
||||||
|
<span class="badge bg-secondary ms-2">{{ dict(doc_types)[tpl.category] if tpl.category in dict(doc_types) else tpl.category }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if tpl.description %}
|
||||||
|
<p class="text-muted small mb-2">{{ tpl.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="small text-muted mb-3">
|
||||||
|
<i class="bi bi-braces me-1"></i>
|
||||||
|
{% set vars = tpl.variables %}
|
||||||
|
{% if vars %}
|
||||||
|
{{ vars | length }} variable(s):
|
||||||
|
{% for v in vars[:5] %}<code class="me-1">{{ v }}</code>{% endfor %}
|
||||||
|
{% if vars | length > 5 %}<em>+{{ vars | length - 5 }} more</em>{% endif %}
|
||||||
|
{% else %}
|
||||||
|
No variables detected
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-1 align-items-center">
|
||||||
|
<a href="{{ url_for('doc_templates.detail', tpl_id=tpl.id) }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-eye me-1"></i>View
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('doc_templates.download', tpl_id=tpl.id) }}" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-download me-1"></i>Download
|
||||||
|
</a>
|
||||||
|
<span class="ms-auto text-muted small">
|
||||||
|
{{ tpl.paperwork_docs.count() }} doc(s) generated
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-white text-muted small">
|
||||||
|
Uploaded {{ tpl.created_at.strftime('%d %b %Y') }}
|
||||||
|
{% if tpl.created_by %} by {{ tpl.created_by.username }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-muted py-5">
|
||||||
|
<i class="bi bi-file-earmark-word display-4 d-block mb-3"></i>
|
||||||
|
No templates yet. <a href="{{ url_for('doc_templates.upload') }}">Upload your first template</a>.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
96
app/templates/doc_templates/upload.html
Normal file
96
app/templates/doc_templates/upload.html
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Upload Template – IT Asset Management{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('doc_templates.index') }}">Templates</a></li>
|
||||||
|
<li class="breadcrumb-item active">Upload</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header mb-4">
|
||||||
|
<h1><i class="bi bi-upload me-2"></i>Upload Document Template</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="card border-0 shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" enctype="multipart/form-data">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Template Name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" name="name" class="form-control" placeholder="e.g. Equipment Handover Receipt" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Category</label>
|
||||||
|
<select name="category" class="form-select">
|
||||||
|
<option value="">— no category —</option>
|
||||||
|
{% for val, label in doc_types %}
|
||||||
|
<option value="{{ val }}">{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Used to pre-select this template when creating paperwork of that type.</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Description</label>
|
||||||
|
<textarea name="description" class="form-control" rows="2" placeholder="Optional notes about this template…"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-semibold">.docx Template File <span class="text-danger">*</span></label>
|
||||||
|
<input type="file" name="docx_file" class="form-control" accept=".docx" required>
|
||||||
|
<div class="form-text">Word document (.docx) with <code>{{ variable_name }}</code> placeholders.</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-upload me-1"></i>Upload
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('doc_templates.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header fw-semibold small text-uppercase text-muted bg-white">
|
||||||
|
Available Variables
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-sm table-hover mb-0 small">
|
||||||
|
<thead class="table-light"><tr><th>Variable</th><th>Value</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% set var_docs = [
|
||||||
|
('user_name','Full name (masked if user left)'),
|
||||||
|
('user_email','Email address'),
|
||||||
|
('user_phone','Phone number'),
|
||||||
|
('user_department','Department (retained after masking)'),
|
||||||
|
('user_job_title','Job title'),
|
||||||
|
('user_location','Office location'),
|
||||||
|
('user_windows_id','Windows / AD ID — never masked'),
|
||||||
|
('asset_serial','Asset serial number'),
|
||||||
|
('asset_service_tag','Dell / vendor service tag'),
|
||||||
|
('asset_brand','Brand (e.g. Dell)'),
|
||||||
|
('asset_model','Model name'),
|
||||||
|
('asset_type','Type (Laptop / Desktop / …)'),
|
||||||
|
('asset_os','Operating system'),
|
||||||
|
('asset_warranty_expiry','Warranty expiry date'),
|
||||||
|
('assignment_date','Date asset was assigned'),
|
||||||
|
('return_date','Date asset was returned'),
|
||||||
|
('document_date','Today\'s date'),
|
||||||
|
('document_number','Document / paperwork ID'),
|
||||||
|
('company_name','Your company name'),
|
||||||
|
('company_address','Your company address'),
|
||||||
|
] %}
|
||||||
|
{% for var, desc in var_docs %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ {{ var }} }}</code></td>
|
||||||
|
<td class="text-muted">{{ desc }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
238
app/templates/paperwork/detail.html
Normal file
238
app/templates/paperwork/detail.html
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}{{ doc.title }} – IT Asset Management{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('paperwork.index') }}">Paperwork</a></li>
|
||||||
|
<li class="breadcrumb-item active">{{ doc.title }}</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<h1><i class="bi bi-file-earmark-text me-2"></i>{{ doc.title }}</h1>
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
{% if doc.pdf_filename %}
|
||||||
|
<a href="{{ url_for('paperwork.download', doc_id=doc.id) }}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-filetype-pdf me-1"></i>Download PDF
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if doc.docx_filename %}
|
||||||
|
<a href="{{ url_for('paperwork.download_docx', doc_id=doc.id) }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-file-earmark-word me-1"></i>Download .docx
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<form method="POST" action="{{ url_for('paperwork.regenerate', doc_id=doc.id) }}" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-clockwise me-1"></i>Regenerate
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Left column: meta -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3">
|
||||||
|
<i class="bi bi-info-circle me-2 text-primary"></i>Document Info
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-5 text-muted small">Type</dt>
|
||||||
|
<dd class="col-7"><span class="badge bg-info text-dark">{{ doc.doc_type_label }}</span></dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">User</dt>
|
||||||
|
<dd class="col-7">
|
||||||
|
<a href="{{ url_for('users.detail', user_id=doc.user.id) }}">{{ doc.user.display_name }}</a>
|
||||||
|
<br><code class="small">WID: {{ doc.user.windows_id }}</code>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
{% if doc.asset %}
|
||||||
|
<dt class="col-5 text-muted small">Asset</dt>
|
||||||
|
<dd class="col-7">
|
||||||
|
<a href="{{ url_for('assets.detail', asset_id=doc.asset.id) }}">
|
||||||
|
{{ doc.asset.brand or '' }} {{ doc.asset.model or '' }}
|
||||||
|
</a>
|
||||||
|
<br><code class="small">{{ doc.asset.serial_number }}</code>
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if doc.template %}
|
||||||
|
<dt class="col-5 text-muted small">Template</dt>
|
||||||
|
<dd class="col-7">
|
||||||
|
<a href="{{ url_for('doc_templates.detail', template_id=doc.template.id) }}">{{ doc.template.name }}</a>
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Created</dt>
|
||||||
|
<dd class="col-7">{{ doc.created_at.strftime('%d/%m/%Y %H:%M') if doc.created_at else '—' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Created by</dt>
|
||||||
|
<dd class="col-7">{{ doc.created_by.username if doc.created_by else '—' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">PDF</dt>
|
||||||
|
<dd class="col-7">
|
||||||
|
{% if doc.pdf_filename %}<span class="badge bg-success">Generated</span>
|
||||||
|
{% else %}<span class="badge bg-secondary">Not generated</span>{% endif %}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Word doc</dt>
|
||||||
|
<dd class="col-7">
|
||||||
|
{% if doc.docx_filename %}<span class="badge bg-primary">Available</span>
|
||||||
|
{% else %}<span class="badge bg-secondary">None</span>{% endif %}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Signed</dt>
|
||||||
|
<dd class="col-7">
|
||||||
|
{% if doc.is_signed %}
|
||||||
|
<span class="badge bg-success">
|
||||||
|
<i class="bi bi-pen me-1"></i>{{ doc.signed_by_name }}
|
||||||
|
</span>
|
||||||
|
<br><span class="small text-muted">{{ doc.signed_at.strftime('%d/%m/%Y %H:%M') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning text-dark">Unsigned</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Signature card -->
|
||||||
|
{% if doc.is_signed %}
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between">
|
||||||
|
<span><i class="bi bi-pen me-2 text-success"></i>Signature</span>
|
||||||
|
<form method="POST" action="{{ url_for('paperwork.unsign', doc_id=doc.id) }}" class="d-inline">
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="return confirm('Remove signature?')">Remove</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="card-body text-center">
|
||||||
|
{% if doc.signature_data %}
|
||||||
|
<img src="{{ doc.signature_data }}" alt="Signature" class="img-fluid border rounded"
|
||||||
|
style="max-height:80px; background:#fff;">
|
||||||
|
{% endif %}
|
||||||
|
<p class="mb-0 mt-2 small text-muted">
|
||||||
|
Signed by <strong>{{ doc.signed_by_name }}</strong><br>
|
||||||
|
{{ doc.signed_at.strftime('%d/%m/%Y at %H:%M') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Sign document card -->
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3">
|
||||||
|
<i class="bi bi-pen me-2 text-warning"></i>Sign Document
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('paperwork.sign', doc_id=doc.id) }}">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Signer's full name <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" name="signed_by_name" class="form-control form-control-sm"
|
||||||
|
placeholder="{{ doc.user.display_name }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Draw signature (optional)</label>
|
||||||
|
<canvas id="sigCanvas" width="260" height="80"
|
||||||
|
class="border rounded d-block"
|
||||||
|
style="background:#fff; cursor:crosshair; touch-action:none;"></canvas>
|
||||||
|
<input type="hidden" name="signature_data" id="sigData">
|
||||||
|
<div class="d-flex gap-2 mt-1">
|
||||||
|
<button type="button" class="btn btn-xs btn-outline-secondary btn-sm" id="clearSig">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-sm btn-success w-100" id="signBtn">
|
||||||
|
<i class="bi bi-pen me-1"></i>Sign Document
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right column: notes + merge vars -->
|
||||||
|
<div class="col-md-8">
|
||||||
|
{% if doc.notes %}
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3">
|
||||||
|
<i class="bi bi-chat-left-text me-2 text-primary"></i>Notes
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-0">{{ doc.notes }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if merge_vars %}
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-braces me-2 text-primary"></i>Merge Variables Used</span>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button"
|
||||||
|
data-bs-toggle="collapse" data-bs-target="#mergeVarsBody">
|
||||||
|
Toggle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="mergeVarsBody" class="collapse show">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<table class="table table-sm table-striped mb-0 small">
|
||||||
|
<thead class="table-light"><tr><th>Variable</th><th>Value</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% set PII = ['user_name','user_email','user_phone'] %}
|
||||||
|
{% for k, v in merge_vars.items()|sort %}
|
||||||
|
<tr {% if k in PII %}class="table-danger"{% endif %}>
|
||||||
|
<td><code>{{ '{{' }} {{ k }} {{ '}}' }}</code>
|
||||||
|
{% if k in PII %}<span class="badge bg-danger ms-1 small">PII</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ v or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
{% if not doc.is_signed %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const canvas = document.getElementById('sigCanvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
let drawing = false;
|
||||||
|
|
||||||
|
function getPos(e) {
|
||||||
|
const r = canvas.getBoundingClientRect();
|
||||||
|
const src = e.touches ? e.touches[0] : e;
|
||||||
|
return { x: src.clientX - r.left, y: src.clientY - r.top };
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener('mousedown', e => { drawing = true; const p = getPos(e); ctx.beginPath(); ctx.moveTo(p.x, p.y); });
|
||||||
|
canvas.addEventListener('mousemove', e => { if (!drawing) return; const p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); });
|
||||||
|
canvas.addEventListener('mouseup', () => { drawing = false; });
|
||||||
|
canvas.addEventListener('touchstart', e => { e.preventDefault(); drawing = true; const p = getPos(e); ctx.beginPath(); ctx.moveTo(p.x, p.y); });
|
||||||
|
canvas.addEventListener('touchmove', e => { e.preventDefault(); if (!drawing) return; const p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); });
|
||||||
|
canvas.addEventListener('touchend', () => { drawing = false; });
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#1a1a1a';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
|
||||||
|
document.getElementById('clearSig').addEventListener('click', () => {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('form[action*="/sign"]').addEventListener('submit', () => {
|
||||||
|
// Only attach non-empty canvas
|
||||||
|
const blank = document.createElement('canvas');
|
||||||
|
blank.width = canvas.width; blank.height = canvas.height;
|
||||||
|
if (canvas.toDataURL() !== blank.toDataURL()) {
|
||||||
|
document.getElementById('sigData').value = canvas.toDataURL('image/png');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
168
app/templates/paperwork/form.html
Normal file
168
app/templates/paperwork/form.html
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}New Document – IT Asset Management{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('paperwork.index') }}">Paperwork</a></li>
|
||||||
|
<li class="breadcrumb-item active">New Document</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header mb-4">
|
||||||
|
<h1><i class="bi bi-file-earmark-plus me-2"></i>Create Document</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm" style="max-width:740px;">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('paperwork.create') }}">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Document Type <span class="text-danger">*</span></label>
|
||||||
|
<select name="document_type" class="form-select" id="docType">
|
||||||
|
{% for val, label in doc_types %}
|
||||||
|
<option value="{{ val }}">{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Title</label>
|
||||||
|
<input type="text" name="title" class="form-control"
|
||||||
|
placeholder="Leave blank to auto-generate">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Word Template selector -->
|
||||||
|
{% if all_templates %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">
|
||||||
|
Word Template
|
||||||
|
<span class="text-muted small">(optional — generates an editable .docx)</span>
|
||||||
|
</label>
|
||||||
|
<select name="template_id" id="templateSelect" class="form-select">
|
||||||
|
<option value="">— No template (PDF only) —</option>
|
||||||
|
{% for tpl in all_templates %}
|
||||||
|
<option value="{{ tpl.id }}">{{ tpl.name }}{% if tpl.category %} [{{ tpl.category }}]{% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<!-- Variable preview loaded via AJAX -->
|
||||||
|
<div id="tplVarsBox" class="mt-2" style="display:none">
|
||||||
|
<div class="small text-muted mb-1">Variables auto-filled from this template:</div>
|
||||||
|
<div id="tplVarsList" class="d-flex flex-wrap gap-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- User search -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">User <span class="text-danger">*</span></label>
|
||||||
|
<input type="hidden" name="user_id" id="userId" value="{{ preselect_user_id or '' }}">
|
||||||
|
<input type="text" id="userSearch" class="form-control"
|
||||||
|
placeholder="Type name or Windows ID…" autocomplete="off">
|
||||||
|
<div id="userDropdown" class="list-group position-absolute shadow" style="z-index:1000;display:none;min-width:350px;"></div>
|
||||||
|
<div id="userDisplay" class="form-text text-success fw-semibold"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Asset search (optional) -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Asset <span class="text-muted small">(optional)</span></label>
|
||||||
|
<input type="hidden" name="asset_id" id="assetId" value="{{ preselect_asset_id or '' }}">
|
||||||
|
<input type="text" id="assetSearch" class="form-control"
|
||||||
|
placeholder="Serial number or service tag…" autocomplete="off">
|
||||||
|
<div id="assetDropdown" class="list-group position-absolute shadow" style="z-index:1000;display:none;min-width:350px;"></div>
|
||||||
|
<div id="assetDisplay" class="form-text text-success fw-semibold"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if preselect_assignment_id %}
|
||||||
|
<input type="hidden" name="assignment_id" value="{{ preselect_assignment_id }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Notes</label>
|
||||||
|
<textarea name="notes" class="form-control" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-file-earmark-check me-1"></i>Generate Document
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('paperwork.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if all_templates %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<a href="{{ url_for('doc_templates.index') }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-file-earmark-word me-1"></i>Manage Templates
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
function liveSearch(inputId, dropdownId, hiddenId, displayId, endpoint) {
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
const dropdown = document.getElementById(dropdownId);
|
||||||
|
const hidden = document.getElementById(hiddenId);
|
||||||
|
const display = document.getElementById(displayId);
|
||||||
|
let timer;
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
const q = input.value.trim();
|
||||||
|
if (q.length < 2) { dropdown.style.display = 'none'; return; }
|
||||||
|
fetch(`${endpoint}?q=${encodeURIComponent(q)}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(items => {
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
if (!items.length) { dropdown.style.display = 'none'; return; }
|
||||||
|
items.forEach(item => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.className = 'list-group-item list-group-item-action py-2';
|
||||||
|
a.textContent = item.text;
|
||||||
|
a.addEventListener('click', () => {
|
||||||
|
hidden.value = item.id;
|
||||||
|
input.value = item.text;
|
||||||
|
display.textContent = '✓ Selected';
|
||||||
|
dropdown.style.display = 'none';
|
||||||
|
});
|
||||||
|
dropdown.appendChild(a);
|
||||||
|
});
|
||||||
|
dropdown.style.display = 'block';
|
||||||
|
});
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
document.addEventListener('click', e => { if (!input.contains(e.target)) dropdown.style.display = 'none'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
liveSearch('userSearch', 'userDropdown', 'userId', 'userDisplay', '{{ url_for("users.search") }}');
|
||||||
|
liveSearch('assetSearch', 'assetDropdown', 'assetId', 'assetDisplay', '{{ url_for("assets.search") }}');
|
||||||
|
|
||||||
|
// Template variable preview
|
||||||
|
const tplSelect = document.getElementById('templateSelect');
|
||||||
|
if (tplSelect) {
|
||||||
|
const PII_VARS = new Set(['user_name', 'user_email', 'user_phone']);
|
||||||
|
tplSelect.addEventListener('change', () => {
|
||||||
|
const id = tplSelect.value;
|
||||||
|
const box = document.getElementById('tplVarsBox');
|
||||||
|
const list = document.getElementById('tplVarsList');
|
||||||
|
if (!id) { box.style.display = 'none'; return; }
|
||||||
|
fetch(`/doc-templates/${id}/variables.json`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
list.innerHTML = '';
|
||||||
|
(data.variables || []).forEach(v => {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'badge ' + (PII_VARS.has(v) ? 'bg-danger' : 'bg-secondary');
|
||||||
|
badge.title = PII_VARS.has(v) ? 'PII — will be masked on user departure' : '';
|
||||||
|
badge.textContent = '{{ ' + v + ' }}';
|
||||||
|
list.appendChild(badge);
|
||||||
|
});
|
||||||
|
box.style.display = 'block';
|
||||||
|
})
|
||||||
|
.catch(() => { box.style.display = 'none'; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
110
app/templates/paperwork/index.html
Normal file
110
app/templates/paperwork/index.html
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Paperwork – IT Asset Management{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item active">Paperwork</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<h1><i class="bi bi-file-earmark-text me-2"></i>Paperwork</h1>
|
||||||
|
<a href="{{ url_for('paperwork.create') }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-file-earmark-plus me-1"></i>New Document
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" class="row g-2 mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<select name="doc_type" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||||
|
<option value="">All document types</option>
|
||||||
|
{% for val, label in doc_types %}
|
||||||
|
<option value="{{ val }}" {% if doc_type_filter == val %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<a href="{{ url_for('paperwork.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Asset SN</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>PDF</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in pagination.items %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('paperwork.detail', doc_id=d.id) }}">{{ d.title }}</a></td>
|
||||||
|
<td><span class="badge bg-info text-dark">{{ d.doc_type_label }}</span></td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('users.detail', user_id=d.user.id) }}">{{ d.user.display_name }}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ d.asset.serial_number if d.asset else '—' }}</td>
|
||||||
|
<td>{{ d.created_at.strftime('%d/%m/%Y') if d.created_at else '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if d.pdf_filename %}
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check"></i> Ready</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('paperwork.detail', doc_id=d.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary py-0 px-2">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
{% if d.pdf_filename %}
|
||||||
|
<a href="{{ url_for('paperwork.download', doc_id=d.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-primary py-0 px-2">
|
||||||
|
<i class="bi bi-download"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="7" class="text-center text-muted py-4">No documents found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if pagination.pages > 1 %}
|
||||||
|
<div class="card-footer bg-white d-flex justify-content-between align-items-center py-2">
|
||||||
|
<small class="text-muted">Showing {{ pagination.first }}–{{ pagination.last }} of {{ pagination.total }}</small>
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination pagination-sm mb-0">
|
||||||
|
{% if pagination.has_prev %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('paperwork.index', page=pagination.prev_num, doc_type=doc_type_filter) }}">‹</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% for p in pagination.iter_pages() %}
|
||||||
|
{% if p %}
|
||||||
|
<li class="page-item {% if p == pagination.page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('paperwork.index', page=p, doc_type=doc_type_filter) }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('paperwork.index', page=pagination.next_num, doc_type=doc_type_filter) }}">›</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
126
app/templates/settings/index.html
Normal file
126
app/templates/settings/index.html
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Settings – IT Asset Management{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item active">Settings</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header mb-4">
|
||||||
|
<h1><i class="bi bi-gear me-2"></i>Settings</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<!-- Admin users -->
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3">
|
||||||
|
<i class="bi bi-person-gear me-2 text-primary"></i>Admin Users
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr><th>Username</th><th>Full Name</th><th>Email</th><th>Role</th><th>Last Login</th><th>Active</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in admins %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ a.username }}</strong></td>
|
||||||
|
<td>{{ a.full_name or '—' }}</td>
|
||||||
|
<td>{{ a.email }}</td>
|
||||||
|
<td><span class="badge bg-secondary">{{ a.role }}</span></td>
|
||||||
|
<td>{{ a.last_login.strftime('%d/%m/%Y') if a.last_login else '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if a.is_active %}
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Inactive</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if a.id != current_user.id %}
|
||||||
|
<form method="POST" action="{{ url_for('settings.toggle_admin', admin_id=a.id) }}" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-xs btn-sm btn-outline-{{ 'warning' if a.is_active else 'success' }} py-0 px-2">
|
||||||
|
{{ 'Deactivate' if a.is_active else 'Activate' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add admin form -->
|
||||||
|
<div class="card-footer bg-white">
|
||||||
|
<h6 class="fw-semibold mb-3 mt-1">Add Admin User</h6>
|
||||||
|
<form method="POST" action="{{ url_for('settings.create_admin') }}">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="text" name="username" class="form-control form-control-sm" placeholder="Username" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="text" name="full_name" class="form-control form-control-sm" placeholder="Full Name">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="email" name="email" class="form-control form-control-sm" placeholder="Email" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<input type="password" name="password" class="form-control form-control-sm" placeholder="Password" required minlength="8">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary w-100">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LDAP config info -->
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3">
|
||||||
|
<i class="bi bi-diagram-3 me-2 text-primary"></i>LDAP / AD Configuration
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
LDAP settings are managed via environment variables (see <code>.env</code> file).
|
||||||
|
Restart the application after changing these values.
|
||||||
|
</p>
|
||||||
|
<table class="table table-sm table-bordered mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr><th class="bg-light">LDAP_SERVER</th><td><code>{{ config.LDAP_SERVER or '(not set)' }}</code></td></tr>
|
||||||
|
<tr><th class="bg-light">LDAP_PORT</th><td>{{ config.LDAP_PORT }}</td></tr>
|
||||||
|
<tr><th class="bg-light">LDAP_USE_SSL</th><td>{{ config.LDAP_USE_SSL }}</td></tr>
|
||||||
|
<tr><th class="bg-light">LDAP_BASE_DN</th><td><code>{{ config.LDAP_BASE_DN or '(not set)' }}</code></td></tr>
|
||||||
|
<tr><th class="bg-light">LDAP_BIND_USER</th><td>{{ config.LDAP_BIND_USER or '(not set)' }}</td></tr>
|
||||||
|
<tr><th class="bg-light">Windows ID attr</th><td><code>{{ config.LDAP_WINDOWS_ID_ATTR }}</code></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="mt-3">
|
||||||
|
<a href="{{ url_for('users.import_page') }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>Go to Import / Sync
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm mt-3">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3">
|
||||||
|
<i class="bi bi-building me-2 text-primary"></i>Company Info (for PDFs)
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-sm table-bordered mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr><th class="bg-light">COMPANY_NAME</th><td>{{ config.COMPANY_NAME or '(not set)' }}</td></tr>
|
||||||
|
<tr><th class="bg-light">COMPANY_ADDRESS</th><td>{{ config.COMPANY_ADDRESS or '(not set)' }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="small text-muted mt-2 mb-0">Edit these in <code>.env</code> and restart.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
211
app/templates/users/detail.html
Normal file
211
app/templates/users/detail.html
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}{{ user.display_name }} – IT Asset Management{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('users.index') }}">Users</a></li>
|
||||||
|
<li class="breadcrumb-item active">{{ user.display_name }}</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<h1>
|
||||||
|
<i class="bi bi-person-circle me-2"></i>
|
||||||
|
{{ user.display_name }}
|
||||||
|
{% if user.is_masked %}<span class="badge badge-masked fs-6 align-middle ms-2">MASKED</span>{% endif %}
|
||||||
|
</h1>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% if not user.is_masked %}
|
||||||
|
<a href="{{ url_for('users.edit', user_id=user.id) }}" class="btn btn-sm btn-outline-primary">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Edit
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('assignments.create', user_id=user.id) }}" class="btn btn-sm btn-outline-success">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Assign Asset
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('paperwork.create', user_id=user.id) }}" class="btn btn-sm btn-outline-info">
|
||||||
|
<i class="bi bi-file-earmark-plus me-1"></i>New Document
|
||||||
|
</a>
|
||||||
|
<!-- Mask button -->
|
||||||
|
<button class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#maskModal">
|
||||||
|
<i class="bi bi-eye-slash me-1"></i>Mask User
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Info card -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3">
|
||||||
|
<i class="bi bi-info-circle me-2 text-primary"></i>User Information
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-5 text-muted small">Windows ID</dt>
|
||||||
|
<dd class="col-7"><code>{{ user.windows_id }}</code></dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Full Name</dt>
|
||||||
|
<dd class="col-7">{{ user.display_name }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Email</dt>
|
||||||
|
<dd class="col-7" style="word-break:break-all;">{{ user.display_email }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Phone</dt>
|
||||||
|
<dd class="col-7">{{ user.display_phone }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Department</dt>
|
||||||
|
<dd class="col-7">{{ user.department or '—' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Job Title</dt>
|
||||||
|
<dd class="col-7">{{ user.job_title or '—' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Location</dt>
|
||||||
|
<dd class="col-7">{{ user.location or '—' }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Source</dt>
|
||||||
|
<dd class="col-7"><span class="badge bg-secondary">{{ user.import_source }}</span></dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Status</dt>
|
||||||
|
<dd class="col-7">
|
||||||
|
{% if user.is_masked %}
|
||||||
|
<span class="badge badge-masked">Masked</span>
|
||||||
|
<br><small class="text-muted">{{ user.masked_at.strftime('%d/%m/%Y') if user.masked_at else '' }}</small>
|
||||||
|
{% elif user.is_active %}
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-warning text-dark">Inactive</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-5 text-muted small">Added</dt>
|
||||||
|
<dd class="col-7">{{ user.created_at.strftime('%d/%m/%Y') if user.created_at else '—' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assignments -->
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card border-0 shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-arrow-left-right me-2 text-primary"></i>Asset History</span>
|
||||||
|
{% if not user.is_masked %}
|
||||||
|
<a href="{{ url_for('assignments.create', user_id=user.id) }}" class="btn btn-xs btn-sm btn-outline-success py-0 px-2">
|
||||||
|
<i class="bi bi-plus"></i> Assign
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Asset</th>
|
||||||
|
<th>SN / Service Tag</th>
|
||||||
|
<th>From</th>
|
||||||
|
<th>Until</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in assignments %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ a.asset.brand or '' }} {{ a.asset.model or '' }}</td>
|
||||||
|
<td>
|
||||||
|
<code>{{ a.asset.serial_number }}</code>
|
||||||
|
{% if a.asset.service_tag %}<br><small>{{ a.asset.service_tag }}</small>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ a.assigned_date.strftime('%d/%m/%Y') if a.assigned_date else '—' }}</td>
|
||||||
|
<td>{{ a.returned_date.strftime('%d/%m/%Y') if a.returned_date else '<span class="badge bg-primary">current</span>' | safe }}</td>
|
||||||
|
<td>
|
||||||
|
{% if a.is_active %}
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Returned</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('assets.detail', asset_id=a.asset.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary py-0 px-2">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="6" class="text-center text-muted py-3">No assignments.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Documents -->
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="bi bi-file-earmark-text me-2 text-primary"></i>Documents</span>
|
||||||
|
{% if not user.is_masked %}
|
||||||
|
<a href="{{ url_for('paperwork.create', user_id=user.id) }}"
|
||||||
|
class="btn btn-xs btn-sm btn-outline-info py-0 px-2">
|
||||||
|
<i class="bi bi-plus"></i> New
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr><th>Title</th><th>Type</th><th>Asset</th><th>Date</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in docs %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ url_for('paperwork.detail', doc_id=d.id) }}">{{ d.title }}</a></td>
|
||||||
|
<td><span class="badge bg-info text-dark">{{ d.doc_type_label }}</span></td>
|
||||||
|
<td>{{ d.asset.serial_number if d.asset else '—' }}</td>
|
||||||
|
<td>{{ d.created_at.strftime('%d/%m/%Y') if d.created_at else '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if d.pdf_filename %}
|
||||||
|
<a href="{{ url_for('paperwork.download', doc_id=d.id) }}"
|
||||||
|
class="btn btn-sm btn-outline-secondary py-0 px-2">
|
||||||
|
<i class="bi bi-download"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="5" class="text-center text-muted py-3">No documents.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mask confirmation modal -->
|
||||||
|
{% if not user.is_masked %}
|
||||||
|
<div class="modal fade" id="maskModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header border-0 pb-0">
|
||||||
|
<h5 class="modal-title text-danger"><i class="bi bi-eye-slash me-2"></i>Mask User Record</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>This will <strong>permanently erase all PII</strong> (name, email, phone) for
|
||||||
|
<strong>{{ user.display_name }}</strong> (WID: <code>{{ user.windows_id }}</code>).</p>
|
||||||
|
<p class="mb-0 text-muted">Asset history and assignments will be retained, linked only to the Windows ID.
|
||||||
|
This action <strong>cannot be undone</strong>.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer border-0">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<form method="POST" action="{{ url_for('users.mask', user_id=user.id) }}" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
<i class="bi bi-eye-slash me-1"></i>Confirm Mask
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
92
app/templates/users/form.html
Normal file
92
app/templates/users/form.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}{{ user.display_name if user else 'New User' }} – IT Asset Management{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('users.index') }}">Users</a></li>
|
||||||
|
<li class="breadcrumb-item active">{{ 'Edit' if user else 'New User' }}</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header mb-4">
|
||||||
|
<h1><i class="bi bi-person-{{ 'pencil' if user else 'plus' }} me-2"></i>
|
||||||
|
{{ 'Edit User' if user else 'Add User' }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm" style="max-width:700px;">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" action="{{ url_for('users.edit', user_id=user.id) if user else url_for('users.create') }}">
|
||||||
|
<h6 class="text-uppercase text-muted mb-3 small">Identity</h6>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Windows ID <span class="text-danger">*</span></label>
|
||||||
|
<input type="text" name="windows_id" class="form-control"
|
||||||
|
value="{{ user.windows_id if user else '' }}"
|
||||||
|
{% if user %}readonly{% endif %} required>
|
||||||
|
<div class="form-text">Numeric ID e.g. 408525</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">First Name</label>
|
||||||
|
<input type="text" name="first_name" class="form-control"
|
||||||
|
value="{{ user.first_name or '' if user else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Last Name</label>
|
||||||
|
<input type="text" name="last_name" class="form-control"
|
||||||
|
value="{{ user.last_name or '' if user else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
<input type="email" name="email" class="form-control"
|
||||||
|
value="{{ user.email or '' if user else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Phone</label>
|
||||||
|
<input type="text" name="phone" class="form-control"
|
||||||
|
value="{{ user.phone or '' if user else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-3">
|
||||||
|
<h6 class="text-uppercase text-muted mb-3 small">Organisation</h6>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Department</label>
|
||||||
|
<input type="text" name="department" class="form-control"
|
||||||
|
value="{{ user.department or '' if user else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Job Title</label>
|
||||||
|
<input type="text" name="job_title" class="form-control"
|
||||||
|
value="{{ user.job_title or '' if user else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">Location / Office</label>
|
||||||
|
<input type="text" name="location" class="form-control"
|
||||||
|
value="{{ user.location or '' if user else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if user %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" name="is_active" id="isActive"
|
||||||
|
{% if user.is_active %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="isActive">Active employee</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>{{ 'Save Changes' if user else 'Create User' }}
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('users.detail', user_id=user.id) if user else url_for('users.index') }}"
|
||||||
|
class="btn btn-outline-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
89
app/templates/users/import.html
Normal file
89
app/templates/users/import.html
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Import Users – IT Asset Management{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('users.index') }}">Users</a></li>
|
||||||
|
<li class="breadcrumb-item active">Import</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header mb-4">
|
||||||
|
<h1><i class="bi bi-cloud-download me-2"></i>Import Users</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<!-- CSV Import -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3">
|
||||||
|
<i class="bi bi-filetype-csv me-2 text-success"></i>Import from CSV
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Upload a CSV file with employee data. The file must include a
|
||||||
|
<code>windows_id</code> column. Additional columns are matched by common aliases.
|
||||||
|
</p>
|
||||||
|
<div class="bg-light rounded p-2 mb-3" style="font-size:.78rem;">
|
||||||
|
<strong>Recognised column names:</strong><br>
|
||||||
|
<code>windows_id</code>, <code>first_name</code>, <code>last_name</code>,
|
||||||
|
<code>email</code>, <code>department</code>, <code>job_title</code>,
|
||||||
|
<code>phone</code>, <code>location</code>
|
||||||
|
<br><span class="text-muted">(case-insensitive, spaces or underscores)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('users.import_csv') }}" enctype="multipart/form-data">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">CSV File</label>
|
||||||
|
<input type="file" name="csv_file" class="form-control" accept=".csv" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success w-100">
|
||||||
|
<i class="bi bi-upload me-1"></i>Import CSV
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LDAP / AD Sync -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card border-0 shadow-sm h-100">
|
||||||
|
<div class="card-header bg-white fw-semibold py-3">
|
||||||
|
<i class="bi bi-diagram-3 me-2 text-primary"></i>Sync from Active Directory
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Connects to the LDAP/AD server configured in Settings and upserts all
|
||||||
|
matching user accounts. Masked users are never overwritten.
|
||||||
|
</p>
|
||||||
|
<div class="alert alert-info py-2 small mb-3">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
Existing non-masked users will be updated with fresh AD data.
|
||||||
|
New accounts will be created. Masked records are skipped.
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ url_for('users.import_ldap') }}">
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>Sync from AD Now
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<small class="text-muted d-block">
|
||||||
|
Configure LDAP server, bind credentials and base DN in
|
||||||
|
<a href="{{ url_for('settings.index') }}">Settings</a>.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CSV template download hint -->
|
||||||
|
<div class="card border-0 shadow-sm mt-4" style="max-width:500px;">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<h6 class="fw-semibold mb-2"><i class="bi bi-file-earmark-spreadsheet me-2"></i>CSV Template</h6>
|
||||||
|
<p class="small text-muted mb-2">Your CSV should look like this:</p>
|
||||||
|
<pre class="bg-light rounded p-2 small mb-0">windows_id,first_name,last_name,email,department,job_title,location
|
||||||
|
408525,John,Doe,john.doe@company.com,IT,Engineer,HQ
|
||||||
|
408526,Jane,Smith,jane.smith@company.com,HR,Manager,HQ</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
125
app/templates/users/index.html
Normal file
125
app/templates/users/index.html
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block title %}Users – IT Asset Management{% endblock %}
|
||||||
|
{% block breadcrumb %}
|
||||||
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard.index') }}">Home</a></li>
|
||||||
|
<li class="breadcrumb-item active">Users</li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<h1><i class="bi bi-people-fill me-2"></i>Users</h1>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('users.import_page') }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-cloud-download me-1"></i>Import
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('users.create') }}" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-person-plus me-1"></i>Add User
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<form method="GET" class="row g-2 mb-3">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="text" name="q" class="form-control" placeholder="Search name, email, WID, dept…" value="{{ q }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<div class="form-check form-check-inline mt-1">
|
||||||
|
<input class="form-check-input" type="checkbox" name="masked" value="1" id="chkMasked"
|
||||||
|
{% if show_masked %}checked{% endif %} onchange="this.form.submit()">
|
||||||
|
<label class="form-check-label" for="chkMasked">Show masked users</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">Search</button>
|
||||||
|
<a href="{{ url_for('users.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Windows ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Department</th>
|
||||||
|
<th>Job Title</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in pagination.items %}
|
||||||
|
<tr {% if u.is_masked %}class="masked-row"{% endif %}>
|
||||||
|
<td><code>{{ u.windows_id }}</code></td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('users.detail', user_id=u.id) }}">{{ u.display_name }}</a>
|
||||||
|
{% if u.is_masked %}<span class="badge badge-masked ms-1">MASKED</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ u.display_email }}</td>
|
||||||
|
<td>{{ u.department or '—' }}</td>
|
||||||
|
<td>{{ u.job_title or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-secondary">{{ u.import_source }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if not u.is_masked and u.is_active %}
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
{% elif not u.is_masked %}
|
||||||
|
<span class="badge bg-warning text-dark">Inactive</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-masked">Masked</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('users.detail', user_id=u.id) }}" class="btn btn-sm btn-outline-secondary py-0 px-2">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr><td colspan="8" class="text-center text-muted py-4">No users found.</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if pagination.pages > 1 %}
|
||||||
|
<div class="card-footer bg-white d-flex justify-content-between align-items-center py-2">
|
||||||
|
<small class="text-muted">
|
||||||
|
Showing {{ pagination.first }}–{{ pagination.last }} of {{ pagination.total }}
|
||||||
|
</small>
|
||||||
|
<nav>
|
||||||
|
<ul class="pagination pagination-sm mb-0">
|
||||||
|
{% if pagination.has_prev %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('users.index', page=pagination.prev_num, q=q, masked='1' if show_masked else '0') }}">‹</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||||
|
{% if p %}
|
||||||
|
<li class="page-item {% if p == pagination.page %}active{% endif %}">
|
||||||
|
<a class="page-link" href="{{ url_for('users.index', page=p, q=q, masked='1' if show_masked else '0') }}">{{ p }}</a>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="page-item disabled"><span class="page-link">…</span></li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if pagination.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="{{ url_for('users.index', page=pagination.next_num, q=q, masked='1' if show_masked else '0') }}">›</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
74
config.py
Normal file
74
config.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
SECRET_KEY = os.environ.get('SECRET_KEY', 'change-this-secret-in-production-use-a-long-random-string')
|
||||||
|
|
||||||
|
# MySQL
|
||||||
|
MYSQL_HOST = os.environ.get('MYSQL_HOST', 'localhost')
|
||||||
|
MYSQL_PORT = int(os.environ.get('MYSQL_PORT', 3306))
|
||||||
|
MYSQL_USER = os.environ.get('MYSQL_USER', 'itasset_user')
|
||||||
|
MYSQL_PASSWORD = os.environ.get('MYSQL_PASSWORD', 'itasset_pass')
|
||||||
|
MYSQL_DB = os.environ.get('MYSQL_DB', 'itasset_db')
|
||||||
|
|
||||||
|
# Allow SQLALCHEMY_DATABASE_URI env var to override (used for SQLite in local dev)
|
||||||
|
_mysql_uri = (
|
||||||
|
f"mysql+pymysql://{os.environ.get('MYSQL_USER', 'itasset_user')}:"
|
||||||
|
f"{os.environ.get('MYSQL_PASSWORD', 'itasset_pass')}@"
|
||||||
|
f"{os.environ.get('MYSQL_HOST', 'localhost')}:"
|
||||||
|
f"{os.environ.get('MYSQL_PORT', 3306)}/"
|
||||||
|
f"{os.environ.get('MYSQL_DB', 'itasset_db')}?charset=utf8mb4"
|
||||||
|
)
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI', _mysql_uri)
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
|
||||||
|
# LDAP / Active Directory
|
||||||
|
LDAP_SERVER = os.environ.get('LDAP_SERVER', '')
|
||||||
|
LDAP_PORT = int(os.environ.get('LDAP_PORT', 389))
|
||||||
|
LDAP_USE_SSL = os.environ.get('LDAP_USE_SSL', 'false').lower() == 'true'
|
||||||
|
LDAP_BIND_USER = os.environ.get('LDAP_BIND_USER', '')
|
||||||
|
LDAP_BIND_PASSWORD = os.environ.get('LDAP_BIND_PASSWORD', '')
|
||||||
|
LDAP_BASE_DN = os.environ.get('LDAP_BASE_DN', '')
|
||||||
|
LDAP_USER_SEARCH_FILTER = os.environ.get(
|
||||||
|
'LDAP_USER_SEARCH_FILTER', '(&(objectClass=person)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))'
|
||||||
|
)
|
||||||
|
# AD attribute that stores the numeric Windows ID (e.g. employeeID)
|
||||||
|
LDAP_WINDOWS_ID_ATTR = os.environ.get('LDAP_WINDOWS_ID_ATTR', 'employeeID')
|
||||||
|
|
||||||
|
# File storage
|
||||||
|
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'uploads')
|
||||||
|
PDF_FOLDER = os.environ.get('PDF_FOLDER', 'pdfs')
|
||||||
|
TEMPLATE_FOLDER = os.environ.get('TEMPLATE_FOLDER', 'doc_templates')
|
||||||
|
DOCX_FOLDER = os.environ.get('DOCX_FOLDER', 'docx_output')
|
||||||
|
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16 MB
|
||||||
|
|
||||||
|
# Company info used in generated PDFs
|
||||||
|
COMPANY_NAME = os.environ.get('COMPANY_NAME', 'Your Company Name')
|
||||||
|
COMPANY_ADDRESS = os.environ.get('COMPANY_ADDRESS', '')
|
||||||
|
COMPANY_LOGO = os.environ.get('COMPANY_LOGO', '') # path to logo file
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
ITEMS_PER_PAGE = int(os.environ.get('ITEMS_PER_PAGE', 25))
|
||||||
|
|
||||||
|
# Dell TechDirect API (for service-tag auto-lookup)
|
||||||
|
# Register at https://tdm.dell.com → API Services → Create credentials
|
||||||
|
DELL_CLIENT_ID = os.environ.get('DELL_CLIENT_ID', '')
|
||||||
|
DELL_CLIENT_SECRET = os.environ.get('DELL_CLIENT_SECRET', '')
|
||||||
|
|
||||||
|
|
||||||
|
class DevelopmentConfig(Config):
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionConfig(Config):
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
|
|
||||||
|
config = {
|
||||||
|
'development': DevelopmentConfig,
|
||||||
|
'production': ProductionConfig,
|
||||||
|
'default': DevelopmentConfig,
|
||||||
|
}
|
||||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-rootpassword}
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DB:-itasset_db}
|
||||||
|
MYSQL_USER: ${MYSQL_USER:-itasset_user}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-itasset_pass}
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-rootpassword}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
MYSQL_HOST: db
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- uploads_data:/app/uploads
|
||||||
|
- pdfs_data:/app/pdfs
|
||||||
|
command: >
|
||||||
|
sh -c "flask db upgrade && python init_db.py && python run.py"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
|
uploads_data:
|
||||||
|
pdfs_data:
|
||||||
40
init_db.py
Normal file
40
init_db.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
init_db.py — Run once after `flask db upgrade` to:
|
||||||
|
1. Create all tables (idempotent)
|
||||||
|
2. Create the default admin user if no admin exists
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python init_db.py
|
||||||
|
(or it runs automatically via Docker Compose CMD)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from app import create_app
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.admin_user import AdminUser
|
||||||
|
|
||||||
|
app = create_app(os.environ.get('FLASK_ENV', 'development'))
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
# Only create default admin if none exists
|
||||||
|
if AdminUser.query.count() == 0:
|
||||||
|
default_user = os.environ.get('DEFAULT_ADMIN_USER', 'admin')
|
||||||
|
default_pass = os.environ.get('DEFAULT_ADMIN_PASS', 'ChangeMe123!')
|
||||||
|
default_email = os.environ.get('DEFAULT_ADMIN_EMAIL', 'admin@company.local')
|
||||||
|
|
||||||
|
admin = AdminUser(
|
||||||
|
username=default_user,
|
||||||
|
email=default_email,
|
||||||
|
full_name='System Administrator',
|
||||||
|
role='admin',
|
||||||
|
)
|
||||||
|
admin.set_password(default_pass)
|
||||||
|
db.session.add(admin)
|
||||||
|
db.session.commit()
|
||||||
|
print(f'[init_db] Default admin created — username: {default_user}')
|
||||||
|
print(f'[init_db] *** CHANGE THE DEFAULT PASSWORD IMMEDIATELY ***')
|
||||||
|
else:
|
||||||
|
print('[init_db] Admin users already exist — skipping default creation.')
|
||||||
|
|
||||||
|
print('[init_db] Database initialised.')
|
||||||
15
requirements.txt
Normal file
15
requirements.txt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
Flask==3.0.3
|
||||||
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
Flask-Migrate==4.0.7
|
||||||
|
Flask-Login==0.6.3
|
||||||
|
Flask-WTF==1.2.1
|
||||||
|
WTForms==3.1.2
|
||||||
|
email-validator==2.2.0
|
||||||
|
PyMySQL==1.1.1
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
ldap3==2.9.1
|
||||||
|
reportlab==4.2.2
|
||||||
|
Werkzeug==3.0.3
|
||||||
|
cryptography==42.0.8
|
||||||
|
requests==2.32.3
|
||||||
|
httpx[http2]
|
||||||
Reference in New Issue
Block a user