""" models/platform.py — Platform-level models (superadmin scope). Tables: system_users, plans, tenants, tenant_billing_history, tenant_setting_overrides, audit_log, jwt_blocklist """ import logging from datetime import datetime, timezone from app.extensions import db logger = logging.getLogger(__name__) class SystemUser(db.Model): __tablename__ = "system_users" id = db.Column(db.Integer, primary_key=True) email = db.Column(db.String(255), unique=True, nullable=False, index=True) password_hash = db.Column(db.String(255), nullable=False) name = db.Column(db.String(100), nullable=False) role = db.Column(db.String(50), nullable=False, default="superadmin") is_active = db.Column(db.Boolean, nullable=False, default=True) failed_login_attempts = db.Column(db.Integer, nullable=False, default=0) locked_until = db.Column(db.DateTime, nullable=True) password_reset_token = db.Column(db.String(255), nullable=True) password_reset_expires_at = db.Column(db.DateTime, nullable=True) last_login_at = db.Column(db.DateTime, nullable=True) created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) # Flask-Login interface @property def is_authenticated(self): return True @property def is_anonymous(self): return False def get_id(self): return f"system:{self.id}" def is_locked(self): if self.locked_until is None: return False return datetime.now(timezone.utc) < self.locked_until.replace(tzinfo=timezone.utc) def record_login(self): from datetime import datetime as _dt, timezone as _tz self.failed_login_attempts = 0 self.locked_until = None self.last_login_at = _dt.now(_tz.utc) def record_failed_login(self, max_attempts: int, lockout_minutes: int): from datetime import datetime as _dt, timezone as _tz, timedelta as _td self.failed_login_attempts = (self.failed_login_attempts or 0) + 1 if self.failed_login_attempts >= max_attempts: self.locked_until = _dt.now(_tz.utc) + _td(minutes=lockout_minutes) def __repr__(self): return f"" class Plan(db.Model): __tablename__ = "plans" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(50), unique=True, nullable=False) price_monthly = db.Column(db.Numeric(8, 2), nullable=False) max_staff = db.Column(db.Integer, nullable=True) # None = unlimited max_locations = db.Column(db.Integer, nullable=True) # None = unlimited features_json = db.Column(db.JSON, nullable=False, default=dict) is_active = db.Column(db.Boolean, nullable=False, default=True) tenants = db.relationship("Tenant", back_populates="plan", lazy="dynamic") def has_feature(self, flag: str) -> bool: return bool(self.features_json.get(flag, False)) def __repr__(self): return f"" class Tenant(db.Model): __tablename__ = "tenants" id = db.Column(db.Integer, primary_key=True) slug = db.Column(db.String(80), unique=True, nullable=False, index=True) name = db.Column(db.String(150), nullable=False) owner_email = db.Column(db.String(255), nullable=False) plan_id = db.Column(db.Integer, db.ForeignKey("plans.id"), nullable=False) status = db.Column(db.String(20), nullable=False, default="trial") # status: 'active' | 'trial' | 'suspended' | 'cancelled' trial_ends_at = db.Column(db.DateTime, nullable=True) subscription_expires_at = db.Column(db.DateTime, nullable=True) is_demo = db.Column(db.Boolean, nullable=False, default=False) created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) updated_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) plan = db.relationship("Plan", back_populates="tenants") billing_history = db.relationship( "TenantBillingHistory", back_populates="tenant", lazy="dynamic" ) setting_overrides = db.relationship( "TenantSettingOverride", back_populates="tenant", lazy="dynamic" ) def is_suspended(self): return self.status in ("suspended", "cancelled") def is_active_status(self): return self.status in ("active", "trial") def get_setting(self, key: str, default=None): """ Resolve a tenant setting, honouring superadmin overrides. Override takes precedence if lifted_at is NULL. Falls back to tenant_settings, then the supplied default. """ override = ( TenantSettingOverride.query .filter_by(tenant_id=self.id, setting_key=key) .filter(TenantSettingOverride.lifted_at.is_(None)) .first() ) if override: return override.setting_value from app.models.salon import TenantSetting setting = TenantSetting.query.filter_by( tenant_id=self.id, setting_key=key ).first() return setting.setting_value if setting else default def has_feature(self, flag: str) -> bool: """Check plan feature flag, allowing active overrides to force-enable/disable.""" override = ( TenantSettingOverride.query .filter_by(tenant_id=self.id, setting_key=f"feature_{flag}") .filter(TenantSettingOverride.lifted_at.is_(None)) .first() ) if override: return override.setting_value.lower() in ("true", "1", "yes") return self.plan.has_feature(flag) if self.plan else False def __repr__(self): return f"" class TenantBillingHistory(db.Model): __tablename__ = "tenant_billing_history" id = db.Column(db.Integer, primary_key=True) tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), nullable=False, index=True) amount = db.Column(db.Numeric(10, 2), nullable=False) description = db.Column(db.String(255), nullable=False) paid_at = db.Column(db.DateTime, nullable=True) invoice_ref = db.Column(db.String(100), nullable=True) recorded_by = db.Column(db.Integer, db.ForeignKey("system_users.id"), nullable=True) tenant = db.relationship("Tenant", back_populates="billing_history") recorder = db.relationship("SystemUser") def __repr__(self): return f"" class TenantSettingOverride(db.Model): __tablename__ = "tenant_setting_overrides" id = db.Column(db.Integer, primary_key=True) tenant_id = db.Column(db.Integer, db.ForeignKey("tenants.id"), nullable=False, index=True) setting_key = db.Column(db.String(100), nullable=False) setting_value = db.Column(db.Text, nullable=True) overridden_by = db.Column(db.Integer, db.ForeignKey("system_users.id"), nullable=False) overridden_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) lifted_at = db.Column(db.DateTime, nullable=True) note = db.Column(db.Text, nullable=True) tenant = db.relationship("Tenant", back_populates="setting_overrides") admin = db.relationship("SystemUser") @property def is_active(self): return self.lifted_at is None def __repr__(self): return f"" class AuditLog(db.Model): """ Immutable, append-only audit log. Never issue UPDATE or DELETE against this table from application code. Retention: records older than 365 days purged monthly via APScheduler. """ __tablename__ = "audit_log" id = db.Column(db.Integer, primary_key=True) actor_id = db.Column(db.Integer, nullable=False) actor_type = db.Column(db.String(50), nullable=False) # actor_type: 'system_user' | 'tenant_user' action = db.Column(db.String(100), nullable=False) target_type = db.Column(db.String(100), nullable=True) target_id = db.Column(db.Integer, nullable=True) before_json = db.Column(db.JSON, nullable=True) after_json = db.Column(db.JSON, nullable=True) ip_address = db.Column(db.String(45), nullable=True) created_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), index=True) @classmethod def log(cls, actor_id, actor_type, action, target_type=None, target_id=None, before=None, after=None, ip_address=None): """ Helper to append an audit entry and flush to DB. Usage: AuditLog.log( actor_id=current_user.id, actor_type='system_user', action='tenant.suspend', target_type='tenant', target_id=tenant.id, before={'status': 'active'}, after={'status': 'suspended'}, ip_address=request.remote_addr, ) """ entry = cls( actor_id=actor_id, actor_type=actor_type, action=action, target_type=target_type, target_id=target_id, before_json=before, after_json=after, ip_address=ip_address, ) db.session.add(entry) logger.info( "AUDIT | actor=%s(%s) action=%s target=%s/%s", actor_type, actor_id, action, target_type, target_id, ) return entry def __repr__(self): return f"" class JWTBlocklist(db.Model): """ Stores revoked JWT refresh token JTIs. Checked on every token refresh request. """ __tablename__ = "jwt_blocklist" id = db.Column(db.Integer, primary_key=True) jti = db.Column(db.String(36), nullable=False, unique=True, index=True) token_type = db.Column(db.String(20), nullable=False, default="refresh") revoked_at = db.Column(db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) def __repr__(self): return f""