Files
JQC_multi_tenant/control/models.py
T
2026-07-04 13:40:03 -04:00

237 lines
10 KiB
Python

"""
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, JSON,
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'<Plan {self.code}>'
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'<PlanFeature plan={self.plan_id} {self.feature_key}={self.enabled}>'
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)
# ── Dunning (payment failure reminders) ────────────────────────────────
past_due_since = Column(DateTime, nullable=True) # set when payment first fails
dunning_stage = Column(Integer, nullable=False, default=0) # 0=none 1=day3 2=day7 3=day14
dunning_sent_at = Column(DateTime, nullable=True) # last dunning email timestamp
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'<Tenant {self.slug}>'
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'<TenantDomain {self.domain} ({self.kind})>'
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)
# ── Two-factor (control0005) — opt-in TOTP, mirrors the data-plane User ──
mfa_enabled = Column(Boolean, nullable=False, default=False)
mfa_secret = Column(String(64), nullable=True)
mfa_recovery_codes = Column(JSON, 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'<Superadmin {self.username}>'
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'<ProvisioningJob {self.action} tenant={self.tenant_id} {self.status}>'
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'<TenantAudit {self.action} tenant={self.tenant_id}>'