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 — the single place a role's user-facing name # is defined. # # The two customer-side roles are a LABEL-ONLY rename (same idea as rule 19, # "Project" -> "Contract"): the stored ENUM values are still 'customer' and # 'external_inspector', so no migration and no role check anywhere had to move. # 'customer' -> "Customer Director" (portal access, CustomerAssignment scope) # 'external_inspector' -> "Customer Inspector" (inspector powers, InspectorAssignment scope) ROLE_LABELS = { 'admin': 'Admin', 'director': 'Director', 'project_manager': 'Project Manager', 'auditor': 'Auditor', 'inspector': 'Inspector', 'external_inspector': 'Customer Inspector', 'customer': 'Customer Director', } @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 (MT-15) ─────────────────────────────────────────────── # 'external_inspector' is an inspector employed by the customer or a third # party rather than by the tenant. 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') # ── Customer-side roles (phase51) ──────────────────────────────── # Accounts that belong to the CUSTOMER, not to us. Both are created, # invited, assigned and switched from Customer Management (/customers) — # they never appear in User Management. # 'customer' = Customer Director — portal access, read-mostly, # scoped by CustomerAssignment. # 'external_inspector' = Customer Inspector — full inspector capabilities, # scoped by InspectorAssignment (see INSPECTOR_ROLES). # # CAUTION — this tuple is NOT interchangeable with `role == 'customer'`. # A Customer Inspector is an INSPECTOR everywhere it matters: portal # read-only gates, @customer_required, get_customer_scope(), support chat # and the customer branch of every API scope check must keep testing # `role == 'customer'` exactly. Use CUSTOMER_ROLES / is_customer_account # ONLY for account-management surfaces (who is listed, invited, edited, # assigned or switched under /customers). Widening a capability check to # this tuple hands a third-party inspector the customer portal; narrowing # an account-management check to 'customer' strands the inspectors in a # page that no longer manages them. CUSTOMER_ROLES = ('customer', '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. # MT-15 — 'external_inspector' added: a customer / third-party # inspector with identical capabilities to 'inspector'. 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 (MT-16) ────────────────────────────── # 'classic' = the original top-navbar design. 'modern' = the sidebar design. # Drives base.html's layout dispatch via the inject_ui_theme() context # processor. Persisted per user so the choice survives logout. # # Defaults to 'classic' so existing tenants see no change on deploy; the # effective fallback for accounts that never choose is config # DEFAULT_UI_THEME, which a tenant can be provisioned with as 'modern'. ui_theme = db.Column(db.String(16), nullable=False, server_default='classic', default='classic') # ── 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) # ── Two-factor auth (phase35) — opt-in TOTP ─────────────────────────── # mfa_enabled gates the second-factor step at login. mfa_secret is the # base32 TOTP shared secret. mfa_recovery_codes is a JSON list of hashed # one-time backup codes (never stored in plaintext). All default off so # existing accounts are unaffected until a user enrolls. mfa_enabled = db.Column(db.Boolean, nullable=False, default=False) mfa_secret = db.Column(db.String(64), nullable=True) mfa_recovery_codes = db.Column(db.JSON, nullable=True) # Relationships # phase49: inspections now has TWO foreign keys to users.id — inspector_id # and follow_up_requested_by — so the join is otherwise ambiguous and every # mapper configuration fails with AmbiguousForeignKeysError. 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. Display name: "Customer Inspector". The attribute keeps its MT-15 name so the existing call sites stay put (the rename is a label, never an identifier). """ return self.role == 'external_inspector' @property def is_customer_account(self): """True for BOTH customer-side roles — an account-management question. Answers "is this account managed under /customers?", NOT "does this account get the customer portal". For the latter keep testing `role == 'customer'`. See the CUSTOMER_ROLES note above. """ return self.role in self.CUSTOMER_ROLES @property def is_customer_director(self): """True for the portal-side customer role ('customer').""" return self.role == 'customer' @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''