Files
LT_Janitorial_Quality_Control/app/models/user.py
T

164 lines
7.7 KiB
Python

from app import db, login_manager
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from app.utils.time_utils import now_eastern
# Display labels for the role ENUM. 'external_inspector' would otherwise title
# case to "External Inspector" anyway, but the map keeps every label in one
# place for templates that show a role name.
ROLE_LABELS = {
'admin': 'Admin',
'director': 'Director',
'project_manager': 'Project Manager',
'auditor': 'Auditor',
'inspector': 'Inspector',
'external_inspector': 'External Inspector',
'customer': 'Customer',
}
@login_manager.user_loader
def load_user(user_id):
from app import db
return db.session.get(User, int(user_id))
class User(UserMixin, db.Model):
__tablename__ = 'users'
# ── Inspector roles (phase49) ─────────────────────────────────────────────
# 'external_inspector' is an inspector employed by the customer or a third
# party rather than by us. It has exactly the same capabilities as the
# internal 'inspector' role and is scoped the same way — through
# InspectorAssignment rows, via get_inspector_scope().
#
# Every place that used to test `role == 'inspector'` must test membership
# of this tuple instead, or external inspectors silently fall into the
# privileged (org-wide) branch and see every contract. Use the
# `is_inspector` property below — it is an ordinary attribute, so it reads
# the same way in Python and in Jinja (`current_user.is_inspector`).
INSPECTOR_ROLES = ('inspector', 'external_inspector')
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), unique=True, nullable=False, index=True)
full_name = db.Column(db.String(150), nullable=True)
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(
# Phase 11 migration complete — 'supervisor' removed from both the DB
# ENUM and this Python-side declaration. Director is the canonical role.
db.Enum('admin', 'director', 'inspector', 'project_manager', 'customer',
'auditor', 'external_inspector'),
nullable=False
)
created_at = db.Column(db.DateTime, default=now_eastern)
active = db.Column(db.Boolean, default=True, nullable=False)
# ── Web portal design preference (phase48 — design A/B test) ──────────
# 'classic' = the original top-navbar design (default for every account).
# 'modern' = the sidebar design from the JQC_design deck.
# Drives base.html's layout dispatch via the inject_ui_theme() context
# processor. Persisted per user so the choice survives logout and can be
# tallied as a vote (see /ui/theme-votes).
# phase50 — 'modern' is the default for new accounts. Existing rows were
# migrated in phase50; anyone who switches keeps their own choice.
ui_theme = db.Column(db.String(16), nullable=False,
server_default='modern', default='modern')
# ── Customer password-setup workflow ──────────────────────────────────
# password_set: False for newly created customer accounts until they
# complete the set-password flow via emailed link.
# Always True for internal users created via UserForm.
password_set = db.Column(db.Boolean, nullable=False, default=True)
set_password_token = db.Column(db.String(64), nullable=True, index=True)
set_password_token_expires = db.Column(db.DateTime, nullable=True)
# Relationships
# Explicit foreign_keys: inspections now has a SECOND FK to users
# (follow_up_requested_by, phase46), so the join is otherwise ambiguous.
# This relationship means "inspections I performed" — inspector_id only.
inspections = db.relationship('Inspection', backref='inspector',
lazy='dynamic',
foreign_keys='Inspection.inspector_id')
# ── Flask-Login integration ────────────────────────────────────────────
# Override UserMixin.is_active so that disabled accounts are rejected
# automatically by login_required and login_user() without any extra code.
@property
def is_active(self):
return self.active
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)
@property
def is_inspector(self):
"""True for both the internal and the external inspector role.
Prefer this over `role == 'inspector'` for capability and scoping
checks. Use an explicit `role == 'external_inspector'` test only where
the two genuinely differ (currently: display labelling only).
"""
return self.role in self.INSPECTOR_ROLES
@property
def is_external_inspector(self):
"""True only for third-party / customer-employed inspectors."""
return self.role == 'external_inspector'
@property
def role_label(self):
"""Human-readable role name, used in staff-facing lists."""
return ROLE_LABELS.get(self.role, (self.role or '').replace('_', ' ').title())
@property
def display_name(self):
"""Return full name if set, otherwise fall back to username."""
return self.full_name.strip() if self.full_name and self.full_name.strip() else self.username
def generate_set_password_token(self, expires_hours=72):
"""Create a one-time set-password token valid for `expires_hours` hours."""
import secrets
from datetime import timedelta
self.set_password_token = secrets.token_hex(32) # 64 hex chars
self.set_password_token_expires = now_eastern() + timedelta(hours=expires_hours)
return self.set_password_token
def clear_set_password_token(self):
"""Invalidate the token after use."""
self.set_password_token = None
self.set_password_token_expires = None
@staticmethod
def verify_set_password_token(token):
"""Return the User whose token matches, or None if invalid/expired.
The final token comparison uses hmac.compare_digest so that the
comparison runs in constant time regardless of how many characters
match, preventing timing-based token enumeration attacks.
"""
import hmac
if not token:
return None
# Primary lookup is via DB index — compare_digest is a defense-in-depth
# guard applied after the row is retrieved to harden the string comparison.
user = User.query.filter_by(set_password_token=token).first()
if user is None:
return None
if user.set_password_token_expires is None:
return None
if now_eastern() > user.set_password_token_expires:
return None
# Constant-time comparison — prevents timing oracle on the stored token.
# Wrapped in try/except to guard against unexpected type mismatches.
try:
if not hmac.compare_digest(user.set_password_token, token):
return None
except (TypeError, ValueError):
return None
return user
def __repr__(self):
return f'<User {self.username}>'