Files
bakery-ia/services/tenant/app/models/tenants.py
2026-01-13 22:22:38 +01:00

204 lines
9.2 KiB
Python

# services/tenant/app/models/tenants.py - FIXED VERSION
"""
Tenant models for bakery management - FIXED
Removed cross-service User relationship to eliminate circular dependencies
"""
from sqlalchemy import Column, String, Boolean, DateTime, Float, ForeignKey, Text, Integer, JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
import uuid
from shared.database.base import Base
class Tenant(Base):
"""Tenant/Bakery model"""
__tablename__ = "tenants"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(200), nullable=False)
subdomain = Column(String(100), unique=True)
business_type = Column(String(100), default="bakery")
business_model = Column(String(100), default="individual_bakery") # individual_bakery, central_baker_satellite, retail_bakery, hybrid_bakery
# Location info
address = Column(Text, nullable=False)
city = Column(String(100), default="Madrid")
postal_code = Column(String(10), nullable=False)
latitude = Column(Float)
longitude = Column(Float)
# Regional/Localization configuration
timezone = Column(String(50), default="Europe/Madrid", nullable=False)
currency = Column(String(3), default="EUR", nullable=False) # Currency code: EUR, USD, GBP
language = Column(String(5), default="es", nullable=False) # Language code: es, en, eu
# Contact info
phone = Column(String(20))
email = Column(String(255))
# Status
is_active = Column(Boolean, default=True)
# Demo account flags
is_demo = Column(Boolean, default=False, index=True)
is_demo_template = Column(Boolean, default=False, index=True)
base_demo_tenant_id = Column(UUID(as_uuid=True), nullable=True, index=True)
demo_session_id = Column(String(100), nullable=True, index=True)
demo_expires_at = Column(DateTime(timezone=True), nullable=True)
# ML status
ml_model_trained = Column(Boolean, default=False)
last_training_date = Column(DateTime(timezone=True))
# Additional metadata (JSON field for flexible data storage)
metadata_ = Column(JSON, nullable=True)
# Ownership (user_id without FK - cross-service reference)
owner_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Enterprise tier hierarchy fields
parent_tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=True, index=True)
tenant_type = Column(String(50), default="standalone", nullable=False) # standalone, parent, child
hierarchy_path = Column(String(500), nullable=True) # Materialized path for queries
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# Relationships - only within tenant service
members = relationship("TenantMember", back_populates="tenant", cascade="all, delete-orphan")
subscriptions = relationship("Subscription", back_populates="tenant", cascade="all, delete-orphan")
locations = relationship("TenantLocation", back_populates="tenant", cascade="all, delete-orphan")
child_tenants = relationship("Tenant", back_populates="parent_tenant", remote_side=[id])
parent_tenant = relationship("Tenant", back_populates="child_tenants", remote_side=[parent_tenant_id])
# REMOVED: users relationship - no cross-service SQLAlchemy relationships
@property
def subscription_tier(self):
"""
Get current subscription tier from active subscription
Note: This is a computed property that requires subscription relationship to be loaded.
For performance-critical operations, use the subscription cache service directly.
"""
# Find active subscription
for subscription in self.subscriptions:
if subscription.status == 'active':
return subscription.plan
return "starter" # Default fallback
def __repr__(self):
return f"<Tenant(id={self.id}, name={self.name})>"
class TenantMember(Base):
"""
Tenant membership model for team access.
This model represents TENANT-SPECIFIC roles, which are distinct from global user roles.
TENANT ROLES (stored here):
- owner: Full control of the tenant, can transfer ownership, manage all aspects
- admin: Tenant administrator, can manage team members and most operations
- member: Standard team member, regular operational access
- viewer: Read-only observer, view-only access to tenant data
ROLE MAPPING TO GLOBAL ROLES:
When users are created through tenant management (pilot phase), their tenant role
is mapped to a global user role in the Auth service:
- tenant 'admin' → global 'admin' (system-wide admin access)
- tenant 'member' → global 'manager' (management-level access)
- tenant 'viewer' → global 'user' (basic user access)
- tenant 'owner' → No automatic global role (owner is tenant-specific)
This mapping is implemented in app/api/tenant_members.py lines 68-76.
Note: user_id is a cross-service reference (no FK) to avoid circular dependencies.
User enrichment is handled at the service layer via Auth service calls.
"""
__tablename__ = "tenant_members"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # No FK - cross-service reference
# Role and permissions specific to this tenant
# Valid values: 'owner', 'admin', 'member', 'viewer', 'network_admin'
role = Column(String(50), default="member")
permissions = Column(Text) # JSON string of permissions
# Status
is_active = Column(Boolean, default=True)
invited_by = Column(UUID(as_uuid=True)) # No FK - cross-service reference
invited_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
joined_at = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
# Relationships - only within tenant service
tenant = relationship("Tenant", back_populates="members")
# REMOVED: user relationship - no cross-service SQLAlchemy relationships
def __repr__(self):
return f"<TenantMember(tenant_id={self.tenant_id}, user_id={self.user_id}, role={self.role})>"
# Additional models for subscriptions, plans, etc.
class Subscription(Base):
"""Subscription model for tenant billing with tenant linking support"""
__tablename__ = "subscriptions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True)
# User reference for tenant-independent subscriptions
user_id = Column(UUID(as_uuid=True), nullable=True, index=True)
# Tenant linking status
is_tenant_linked = Column(Boolean, default=False, nullable=False)
tenant_linking_status = Column(String(50), nullable=True) # pending, completed, failed
linked_at = Column(DateTime(timezone=True), nullable=True)
plan = Column(String(50), default="starter") # starter, professional, enterprise
status = Column(String(50), default="active") # active, pending_cancellation, inactive, suspended, pending_tenant_linking
# Billing
monthly_price = Column(Float, default=0.0)
billing_cycle = Column(String(20), default="monthly") # monthly, yearly
next_billing_date = Column(DateTime(timezone=True))
trial_ends_at = Column(DateTime(timezone=True))
cancelled_at = Column(DateTime(timezone=True), nullable=True)
cancellation_effective_date = Column(DateTime(timezone=True), nullable=True)
stripe_subscription_id = Column(String(255), nullable=True)
stripe_customer_id = Column(String(255), nullable=True)
# Limits
max_users = Column(Integer, default=5)
max_locations = Column(Integer, default=1)
max_products = Column(Integer, default=50)
# Features - Store plan features as JSON
features = Column(JSON)
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# Relationships
tenant = relationship("Tenant")
def __repr__(self):
return f"<Subscription(id={self.id}, tenant_id={self.tenant_id}, user_id={self.user_id}, plan={self.plan}, status={self.status})>"
def is_pending_tenant_linking(self) -> bool:
"""Check if subscription is waiting to be linked to a tenant"""
return self.tenant_linking_status == "pending" and not self.is_tenant_linked
def can_be_linked_to_tenant(self, user_id: str) -> bool:
"""Check if subscription can be linked to a tenant by the given user"""
return (self.is_pending_tenant_linking() and
str(self.user_id) == user_id and
self.tenant_id is None)