Jun 26 MT-0 phase
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
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 (future — MT-8)
|
||||
price_cents = Column(Integer, nullable=True)
|
||||
billing_period = Column(String(16), 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)
|
||||
|
||||
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)
|
||||
|
||||
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}>'
|
||||
Reference in New Issue
Block a user