""" control/models.py ----------------- Control-plane ORM models. See MULTI_TENANT_PLAN.md §3 for the schema and §6 for the plan-tier matrix. ENUM columns use raw string members to match the data-plane house style (db.Enum('a','b',...)). """ from urllib.parse import quote_plus from sqlalchemy import ( Column, Integer, String, Boolean, DateTime, Text, ForeignKey, Enum, UniqueConstraint, ) from sqlalchemy.orm import relationship from werkzeug.security import generate_password_hash, check_password_hash from control.base import ControlBase from control.time_utils import now_eastern from control import crypto class Plan(ControlBase): """A subscription tier. Quota columns are NULL = unlimited.""" __tablename__ = 'plans' id = Column(Integer, primary_key=True) code = Column(String(32), unique=True, nullable=False, index=True) name = Column(String(100), nullable=False) active = Column(Boolean, nullable=False, default=True) # Quota axes (NULL = unlimited) max_users = Column(Integer, nullable=True) max_facilities = Column(Integer, nullable=True) max_inspections_month = Column(Integer, nullable=True) max_issues_month = Column(Integer, nullable=True) # Feature gates allow_custom_domain = Column(Boolean, nullable=False, default=False) allow_mobile_api = Column(Boolean, nullable=False, default=False) allow_scheduled_reports = Column(Boolean, nullable=False, default=False) allow_branding = Column(Boolean, nullable=False, default=False) # Billing (MT-8) price_cents = Column(Integer, nullable=True) billing_period = Column(String(16), nullable=True) stripe_price_id = Column(String(100), nullable=True) created_at = Column(DateTime, nullable=False, default=now_eastern) features = relationship('PlanFeature', back_populates='plan', cascade='all, delete-orphan') tenants = relationship('Tenant', back_populates='plan') def __repr__(self): return f'' class PlanFeature(ControlBase): """EAV escape hatch for boolean feature flags added after launch.""" __tablename__ = 'plan_features' id = Column(Integer, primary_key=True) plan_id = Column(Integer, ForeignKey('plans.id', ondelete='CASCADE'), nullable=False, index=True) feature_key = Column(String(64), nullable=False) enabled = Column(Boolean, nullable=False, default=False) __table_args__ = ( UniqueConstraint('plan_id', 'feature_key', name='uq_plan_feature'), ) plan = relationship('Plan', back_populates='features') def __repr__(self): return f'' class Tenant(ControlBase): """A customer business. Owns its own MySQL database (db-per-tenant).""" __tablename__ = 'tenants' id = Column(Integer, primary_key=True) slug = Column(String(63), unique=True, nullable=False, index=True) # DNS label name = Column(String(150), nullable=False) plan_id = Column(Integer, ForeignKey('plans.id'), nullable=False, index=True) status = Column( Enum('provisioning', 'active', 'suspended', 'deleted', name='tenant_status'), nullable=False, default='provisioning', ) # Per-tenant database connection (MySQL user + password per tenant) db_host = Column(String(255), nullable=False) db_port = Column(Integer, nullable=False, default=3306) db_name = Column(String(64), unique=True, nullable=False) db_user = Column(String(64), nullable=False) db_password_enc = Column(Text, nullable=True) # Fernet token alembic_head = Column(String(64), nullable=True) # last tenant-schema rev applied created_at = Column(DateTime, nullable=False, default=now_eastern) suspended_at = Column(DateTime, nullable=True) notes = Column(Text, nullable=True) # ── Billing (MT-8) ───────────────────────────────────────────────────── stripe_customer_id = Column(String(64), nullable=True, index=True) stripe_subscription_id = Column(String(64), nullable=True, index=True) subscription_status = Column( Enum('trial', 'active', 'past_due', 'cancelled', name='subscription_status'), nullable=True, ) trial_ends_at = Column(DateTime, nullable=True) trial_reminder_sent_at = Column(DateTime, nullable=True) # set after trial warning email current_period_end = Column(DateTime, nullable=True) billing_email = Column(String(255), nullable=True) plan = relationship('Plan', back_populates='tenants') domains = relationship('TenantDomain', back_populates='tenant', cascade='all, delete-orphan') # ── Encrypted credential handling ────────────────────────────────────── def set_db_password(self, plaintext): """Store an encrypted tenant DB password.""" self.db_password_enc = crypto.encrypt(plaintext) @property def db_password(self): """Decrypted tenant DB password (None if unset).""" return crypto.decrypt(self.db_password_enc) if self.db_password_enc else None @property def db_uri(self): """SQLAlchemy URI for this tenant's database (password URL-encoded).""" pw = self.db_password or '' return ( f"mysql+pymysql://{self.db_user}:{quote_plus(pw)}" f"@{self.db_host}:{self.db_port}/{self.db_name}" ) def __repr__(self): return f'' class TenantDomain(ControlBase): """A hostname mapped to a tenant — subdomain (*.jqc.app) or custom domain.""" __tablename__ = 'tenant_domains' id = Column(Integer, primary_key=True) tenant_id = Column(Integer, ForeignKey('tenants.id', ondelete='CASCADE'), nullable=False, index=True) domain = Column(String(255), unique=True, nullable=False, index=True) kind = Column(Enum('subdomain', 'custom', name='domain_kind'), nullable=False) is_primary = Column(Boolean, nullable=False, default=False) verified = Column(Boolean, nullable=False, default=False) verification_token = Column(String(64), nullable=True) tls_status = Column(Enum('pending', 'active', 'failed', name='tls_status'), nullable=False, default='pending') created_at = Column(DateTime, nullable=False, default=now_eastern) tenant = relationship('Tenant', back_populates='domains') def __repr__(self): return f'' class Superadmin(ControlBase): """Cross-tenant operator account. Lives only in the control DB.""" __tablename__ = 'superadmins' id = Column(Integer, primary_key=True) username = Column(String(100), unique=True, nullable=False, index=True) email = Column(String(255), unique=True, nullable=False, index=True) password_hash = Column(String(255), nullable=False) active = Column(Boolean, nullable=False, default=True) created_at = Column(DateTime, nullable=False, default=now_eastern) 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'' class ProvisioningJob(ControlBase): """Record of a provisioning action (create_db / migrate / seed / ...).""" __tablename__ = 'provisioning_jobs' id = Column(Integer, primary_key=True) tenant_id = Column(Integer, ForeignKey('tenants.id', ondelete='SET NULL'), nullable=True, index=True) action = Column( Enum('create_db', 'migrate', 'seed', 'suspend', 'resume', 'delete', name='provisioning_action'), nullable=False, ) status = Column( Enum('queued', 'running', 'ok', 'failed', name='provisioning_status'), nullable=False, default='queued', ) log = Column(Text, nullable=True) created_at = Column(DateTime, nullable=False, default=now_eastern) finished_at = Column(DateTime, nullable=True) def __repr__(self): return f'' class TenantAudit(ControlBase): """Immutable log of superadmin actions in the control panel (MT-4).""" __tablename__ = 'tenant_audit' id = Column(Integer, primary_key=True) superadmin_id = Column(Integer, ForeignKey('superadmins.id', ondelete='SET NULL'), nullable=True, index=True) action = Column(String(50), nullable=False) tenant_id = Column(Integer, ForeignKey('tenants.id', ondelete='SET NULL'), nullable=True, index=True) details = Column(Text, nullable=True) ip_address = Column(String(45), nullable=True) created_at = Column(DateTime, nullable=False, default=now_eastern, index=True) def __repr__(self): return f''