Initial commit - production deployment

This commit is contained in:
2026-01-21 17:17:16 +01:00
commit c23d00dd92
2289 changed files with 638440 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
"""
Tenant Service Models Package
Import all models to ensure they are registered with SQLAlchemy Base.
"""
# Import AuditLog model for this service
from shared.security import create_audit_log_model
from shared.database.base import Base
# Create audit log model for this service
AuditLog = create_audit_log_model(Base)
# Import all models to register them with the Base metadata
from .tenants import Tenant, TenantMember, Subscription
from .tenant_location import TenantLocation
from .coupon import CouponModel, CouponRedemptionModel
from .events import Event, EventTemplate
# List all models for easier access
__all__ = [
"Tenant",
"TenantMember",
"Subscription",
"TenantLocation",
"AuditLog",
"CouponModel",
"CouponRedemptionModel",
"Event",
"EventTemplate",
]

View File

@@ -0,0 +1,64 @@
"""
SQLAlchemy models for coupon system
"""
from datetime import datetime
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, JSON, Index
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID
import uuid
from shared.database import Base
class CouponModel(Base):
"""Coupon configuration table"""
__tablename__ = "coupons"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
code = Column(String(50), unique=True, nullable=False, index=True)
discount_type = Column(String(20), nullable=False) # trial_extension, percentage, fixed_amount
discount_value = Column(Integer, nullable=False) # Days/percentage/cents depending on type
max_redemptions = Column(Integer, nullable=True) # None = unlimited
current_redemptions = Column(Integer, nullable=False, default=0)
valid_from = Column(DateTime(timezone=True), nullable=False)
valid_until = Column(DateTime(timezone=True), nullable=True) # None = no expiry
active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
extra_data = Column(JSON, nullable=True) # Renamed from metadata to avoid SQLAlchemy reserved name
# Relationships
redemptions = relationship("CouponRedemptionModel", back_populates="coupon")
# Indexes for performance
__table_args__ = (
Index('idx_coupon_code_active', 'code', 'active'),
Index('idx_coupon_valid_dates', 'valid_from', 'valid_until'),
)
def __repr__(self):
return f"<Coupon(code='{self.code}', type='{self.discount_type}', value={self.discount_value})>"
class CouponRedemptionModel(Base):
"""Coupon redemption history table"""
__tablename__ = "coupon_redemptions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(String(255), nullable=False, index=True)
coupon_code = Column(String(50), ForeignKey('coupons.code'), nullable=False)
redeemed_at = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
discount_applied = Column(JSON, nullable=False) # Details of discount applied
extra_data = Column(JSON, nullable=True) # Renamed from metadata to avoid SQLAlchemy reserved name
# Relationships
coupon = relationship("CouponModel", back_populates="redemptions")
# Constraints
__table_args__ = (
Index('idx_redemption_tenant', 'tenant_id'),
Index('idx_redemption_coupon', 'coupon_code'),
Index('idx_redemption_tenant_coupon', 'tenant_id', 'coupon_code'), # Prevent duplicate redemptions
)
def __repr__(self):
return f"<CouponRedemption(tenant_id='{self.tenant_id}', code='{self.coupon_code}')>"

View File

@@ -0,0 +1,136 @@
"""
Event Calendar Models
Database models for tracking local events, promotions, and special occasions
"""
from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, Float, Date
from sqlalchemy.dialects.postgresql import UUID
from shared.database.base import Base
from datetime import datetime, timezone
import uuid
class Event(Base):
"""
Table to track events that affect bakery demand.
Events include:
- Local events (festivals, markets, concerts)
- Promotions and sales
- Weather events (heat waves, storms)
- School holidays and breaks
- Special occasions
"""
__tablename__ = "events"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Event information
event_name = Column(String(500), nullable=False)
event_type = Column(String(100), nullable=False, index=True) # promotion, festival, holiday, weather, school_break, sport_event, etc.
description = Column(Text, nullable=True)
# Date and time
event_date = Column(Date, nullable=False, index=True)
start_time = Column(DateTime(timezone=True), nullable=True)
end_time = Column(DateTime(timezone=True), nullable=True)
is_all_day = Column(Boolean, default=True)
# Impact estimation
expected_impact = Column(String(50), nullable=True) # low, medium, high, very_high
impact_multiplier = Column(Float, nullable=True) # Expected demand multiplier (e.g., 1.5 = 50% increase)
affected_product_categories = Column(String(500), nullable=True) # Comma-separated categories
# Location
location = Column(String(500), nullable=True)
is_local = Column(Boolean, default=True) # True if event is near bakery
# Status
is_confirmed = Column(Boolean, default=False)
is_recurring = Column(Boolean, default=False)
recurrence_pattern = Column(String(200), nullable=True) # e.g., "weekly:monday", "monthly:first_saturday"
# Actual impact (filled after event)
actual_impact_multiplier = Column(Float, nullable=True)
actual_sales_increase_percent = Column(Float, nullable=True)
# Metadata
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))
created_by = Column(String(255), nullable=True)
notes = Column(Text, nullable=True)
def to_dict(self):
return {
"id": str(self.id),
"tenant_id": str(self.tenant_id),
"event_name": self.event_name,
"event_type": self.event_type,
"description": self.description,
"event_date": self.event_date.isoformat() if self.event_date else None,
"start_time": self.start_time.isoformat() if self.start_time else None,
"end_time": self.end_time.isoformat() if self.end_time else None,
"is_all_day": self.is_all_day,
"expected_impact": self.expected_impact,
"impact_multiplier": self.impact_multiplier,
"affected_product_categories": self.affected_product_categories,
"location": self.location,
"is_local": self.is_local,
"is_confirmed": self.is_confirmed,
"is_recurring": self.is_recurring,
"recurrence_pattern": self.recurrence_pattern,
"actual_impact_multiplier": self.actual_impact_multiplier,
"actual_sales_increase_percent": self.actual_sales_increase_percent,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"created_by": self.created_by,
"notes": self.notes
}
class EventTemplate(Base):
"""
Template for recurring events.
Allows easy creation of events based on patterns.
"""
__tablename__ = "event_templates"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Template information
template_name = Column(String(500), nullable=False)
event_type = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
# Default values
default_impact = Column(String(50), nullable=True)
default_impact_multiplier = Column(Float, nullable=True)
default_affected_categories = Column(String(500), nullable=True)
# Recurrence
recurrence_pattern = Column(String(200), nullable=False) # e.g., "weekly:saturday", "monthly:last_sunday"
is_active = Column(Boolean, default=True)
# Metadata
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))
def to_dict(self):
return {
"id": str(self.id),
"tenant_id": str(self.tenant_id),
"template_name": self.template_name,
"event_type": self.event_type,
"description": self.description,
"default_impact": self.default_impact,
"default_impact_multiplier": self.default_impact_multiplier,
"default_affected_categories": self.default_affected_categories,
"recurrence_pattern": self.recurrence_pattern,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None
}

View File

@@ -0,0 +1,59 @@
"""
Tenant Location Model
Represents physical locations for enterprise tenants (central production, retail outlets)
"""
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 TenantLocation(Base):
"""TenantLocation model - represents physical locations for enterprise tenants"""
__tablename__ = "tenant_locations"
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, index=True)
# Location information
name = Column(String(200), nullable=False)
location_type = Column(String(50), nullable=False) # central_production, retail_outlet
address = Column(Text, nullable=False)
city = Column(String(100), default="Madrid")
postal_code = Column(String(10), nullable=False)
latitude = Column(Float, nullable=True)
longitude = Column(Float, nullable=True)
# Location-specific configuration
delivery_windows = Column(JSON, nullable=True) # { "monday": "08:00-12:00,14:00-18:00", ... }
capacity = Column(Integer, nullable=True) # For production capacity in kg/day or storage capacity
max_delivery_radius_km = Column(Float, nullable=True, default=50.0)
# Operational hours
operational_hours = Column(JSON, nullable=True) # { "monday": "06:00-20:00", ... }
is_active = Column(Boolean, default=True)
# Contact information
contact_person = Column(String(200), nullable=True)
contact_phone = Column(String(20), nullable=True)
contact_email = Column(String(255), nullable=True)
# Custom delivery scheduling configuration per location
delivery_schedule_config = Column(JSON, nullable=True) # { "delivery_days": "Mon,Wed,Fri", "time_window": "07:00-10:00" }
# Metadata
metadata_ = Column(JSON, nullable=True)
# 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", back_populates="locations")
def __repr__(self):
return f"<TenantLocation(id={self.id}, tenant_id={self.tenant_id}, name={self.name}, type={self.location_type})>"

View File

@@ -0,0 +1,370 @@
# services/tenant/app/models/tenant_settings.py
"""
Tenant Settings Model
Centralized configuration storage for all tenant-specific operational settings
"""
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, 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 TenantSettings(Base):
"""
Centralized tenant settings model
Stores all operational configurations for a tenant across all services
"""
__tablename__ = "tenant_settings"
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, unique=True, index=True)
# Procurement & Auto-Approval Settings (Orders Service)
procurement_settings = Column(JSON, nullable=False, default=lambda: {
"auto_approve_enabled": True,
"auto_approve_threshold_eur": 500.0,
"auto_approve_min_supplier_score": 0.80,
"require_approval_new_suppliers": True,
"require_approval_critical_items": True,
"procurement_lead_time_days": 3,
"demand_forecast_days": 14,
"safety_stock_percentage": 20.0,
"po_approval_reminder_hours": 24,
"po_critical_escalation_hours": 12,
"use_reorder_rules": True,
"economic_rounding": True,
"respect_storage_limits": True,
"use_supplier_minimums": True,
"optimize_price_tiers": True
})
# Inventory Management Settings (Inventory Service)
inventory_settings = Column(JSON, nullable=False, default=lambda: {
"low_stock_threshold": 10,
"reorder_point": 20,
"reorder_quantity": 50,
"expiring_soon_days": 7,
"expiration_warning_days": 3,
"quality_score_threshold": 8.0,
"temperature_monitoring_enabled": True,
"refrigeration_temp_min": 1.0,
"refrigeration_temp_max": 4.0,
"freezer_temp_min": -20.0,
"freezer_temp_max": -15.0,
"room_temp_min": 18.0,
"room_temp_max": 25.0,
"temp_deviation_alert_minutes": 15,
"critical_temp_deviation_minutes": 5
})
# Production Settings (Production Service)
production_settings = Column(JSON, nullable=False, default=lambda: {
"planning_horizon_days": 7,
"minimum_batch_size": 1.0,
"maximum_batch_size": 100.0,
"production_buffer_percentage": 10.0,
"working_hours_per_day": 12,
"max_overtime_hours": 4,
"capacity_utilization_target": 0.85,
"capacity_warning_threshold": 0.95,
"quality_check_enabled": True,
"minimum_yield_percentage": 85.0,
"quality_score_threshold": 8.0,
"schedule_optimization_enabled": True,
"prep_time_buffer_minutes": 30,
"cleanup_time_buffer_minutes": 15,
"labor_cost_per_hour_eur": 15.0,
"overhead_cost_percentage": 20.0
})
# Supplier Settings (Suppliers Service)
supplier_settings = Column(JSON, nullable=False, default=lambda: {
"default_payment_terms_days": 30,
"default_delivery_days": 3,
"excellent_delivery_rate": 95.0,
"good_delivery_rate": 90.0,
"excellent_quality_rate": 98.0,
"good_quality_rate": 95.0,
"critical_delivery_delay_hours": 24,
"critical_quality_rejection_rate": 10.0,
"high_cost_variance_percentage": 15.0,
# Supplier Approval Workflow Settings
"require_supplier_approval": True,
"auto_approve_for_admin_owner": True,
"approval_required_roles": ["member", "viewer"]
})
# POS Integration Settings (POS Service)
pos_settings = Column(JSON, nullable=False, default=lambda: {
"sync_interval_minutes": 5,
"auto_sync_products": True,
"auto_sync_transactions": True
})
# Order & Business Rules Settings (Orders Service)
order_settings = Column(JSON, nullable=False, default=lambda: {
"max_discount_percentage": 50.0,
"default_delivery_window_hours": 48,
"dynamic_pricing_enabled": False,
"discount_enabled": True,
"delivery_tracking_enabled": True
})
# Replenishment Planning Settings (Orchestrator Service)
replenishment_settings = Column(JSON, nullable=False, default=lambda: {
"projection_horizon_days": 7,
"service_level": 0.95,
"buffer_days": 1,
"enable_auto_replenishment": True,
"min_order_quantity": 1.0,
"max_order_quantity": 1000.0,
"demand_forecast_days": 14
})
# Safety Stock Settings (Orchestrator Service)
safety_stock_settings = Column(JSON, nullable=False, default=lambda: {
"service_level": 0.95,
"method": "statistical",
"min_safety_stock": 0.0,
"max_safety_stock": 100.0,
"reorder_point_calculation": "safety_stock_plus_lead_time_demand"
})
# MOQ Aggregation Settings (Orchestrator Service)
moq_settings = Column(JSON, nullable=False, default=lambda: {
"consolidation_window_days": 7,
"allow_early_ordering": True,
"enable_batch_optimization": True,
"min_batch_size": 1.0,
"max_batch_size": 1000.0
})
# Supplier Selection Settings (Orchestrator Service)
supplier_selection_settings = Column(JSON, nullable=False, default=lambda: {
"price_weight": 0.40,
"lead_time_weight": 0.20,
"quality_weight": 0.20,
"reliability_weight": 0.20,
"diversification_threshold": 1000,
"max_single_percentage": 0.70,
"enable_supplier_score_optimization": True
})
# ML Insights Settings (AI Insights Service)
ml_insights_settings = Column(JSON, nullable=False, default=lambda: {
# Inventory ML (Safety Stock Optimization)
"inventory_lookback_days": 90,
"inventory_min_history_days": 30,
# Production ML (Yield Prediction)
"production_lookback_days": 90,
"production_min_history_runs": 30,
# Procurement ML (Supplier Analysis & Price Forecasting)
"supplier_analysis_lookback_days": 180,
"supplier_analysis_min_orders": 10,
"price_forecast_lookback_days": 180,
"price_forecast_horizon_days": 30,
# Forecasting ML (Dynamic Rules)
"rules_generation_lookback_days": 90,
"rules_generation_min_samples": 10,
# Global ML Settings
"enable_ml_insights": True,
"ml_insights_auto_trigger": False,
"ml_confidence_threshold": 0.80
})
# Notification Settings (Notification Service)
notification_settings = Column(JSON, nullable=False, default=lambda: {
# WhatsApp Configuration (Shared Account Model)
"whatsapp_enabled": False,
"whatsapp_phone_number_id": "", # Meta WhatsApp Phone Number ID (from shared master account)
"whatsapp_display_phone_number": "", # Display format for UI (e.g., "+34 612 345 678")
"whatsapp_default_language": "es",
# Email Configuration
"email_enabled": True,
"email_from_address": "",
"email_from_name": "",
"email_reply_to": "",
# Notification Preferences
"enable_po_notifications": True,
"enable_inventory_alerts": True,
"enable_production_alerts": True,
"enable_forecast_alerts": True,
# Notification Channels
"po_notification_channels": ["email"], # ["email", "whatsapp"]
"inventory_alert_channels": ["email"],
"production_alert_channels": ["email"],
"forecast_alert_channels": ["email"]
})
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
# Relationships
tenant = relationship("Tenant", backref="settings")
def __repr__(self):
return f"<TenantSettings(tenant_id={self.tenant_id})>"
@staticmethod
def get_default_settings() -> dict:
"""
Get default settings for all categories
Returns a dictionary with default values for all setting categories
"""
return {
"procurement_settings": {
"auto_approve_enabled": True,
"auto_approve_threshold_eur": 500.0,
"auto_approve_min_supplier_score": 0.80,
"require_approval_new_suppliers": True,
"require_approval_critical_items": True,
"procurement_lead_time_days": 3,
"demand_forecast_days": 14,
"safety_stock_percentage": 20.0,
"po_approval_reminder_hours": 24,
"po_critical_escalation_hours": 12,
"use_reorder_rules": True,
"economic_rounding": True,
"respect_storage_limits": True,
"use_supplier_minimums": True,
"optimize_price_tiers": True
},
"inventory_settings": {
"low_stock_threshold": 10,
"reorder_point": 20,
"reorder_quantity": 50,
"expiring_soon_days": 7,
"expiration_warning_days": 3,
"quality_score_threshold": 8.0,
"temperature_monitoring_enabled": True,
"refrigeration_temp_min": 1.0,
"refrigeration_temp_max": 4.0,
"freezer_temp_min": -20.0,
"freezer_temp_max": -15.0,
"room_temp_min": 18.0,
"room_temp_max": 25.0,
"temp_deviation_alert_minutes": 15,
"critical_temp_deviation_minutes": 5
},
"production_settings": {
"planning_horizon_days": 7,
"minimum_batch_size": 1.0,
"maximum_batch_size": 100.0,
"production_buffer_percentage": 10.0,
"working_hours_per_day": 12,
"max_overtime_hours": 4,
"capacity_utilization_target": 0.85,
"capacity_warning_threshold": 0.95,
"quality_check_enabled": True,
"minimum_yield_percentage": 85.0,
"quality_score_threshold": 8.0,
"schedule_optimization_enabled": True,
"prep_time_buffer_minutes": 30,
"cleanup_time_buffer_minutes": 15,
"labor_cost_per_hour_eur": 15.0,
"overhead_cost_percentage": 20.0
},
"supplier_settings": {
"default_payment_terms_days": 30,
"default_delivery_days": 3,
"excellent_delivery_rate": 95.0,
"good_delivery_rate": 90.0,
"excellent_quality_rate": 98.0,
"good_quality_rate": 95.0,
"critical_delivery_delay_hours": 24,
"critical_quality_rejection_rate": 10.0,
"high_cost_variance_percentage": 15.0,
"require_supplier_approval": True,
"auto_approve_for_admin_owner": True,
"approval_required_roles": ["member", "viewer"]
},
"pos_settings": {
"sync_interval_minutes": 5,
"auto_sync_products": True,
"auto_sync_transactions": True
},
"order_settings": {
"max_discount_percentage": 50.0,
"default_delivery_window_hours": 48,
"dynamic_pricing_enabled": False,
"discount_enabled": True,
"delivery_tracking_enabled": True
},
"replenishment_settings": {
"projection_horizon_days": 7,
"service_level": 0.95,
"buffer_days": 1,
"enable_auto_replenishment": True,
"min_order_quantity": 1.0,
"max_order_quantity": 1000.0,
"demand_forecast_days": 14
},
"safety_stock_settings": {
"service_level": 0.95,
"method": "statistical",
"min_safety_stock": 0.0,
"max_safety_stock": 100.0,
"reorder_point_calculation": "safety_stock_plus_lead_time_demand"
},
"moq_settings": {
"consolidation_window_days": 7,
"allow_early_ordering": True,
"enable_batch_optimization": True,
"min_batch_size": 1.0,
"max_batch_size": 1000.0
},
"supplier_selection_settings": {
"price_weight": 0.40,
"lead_time_weight": 0.20,
"quality_weight": 0.20,
"reliability_weight": 0.20,
"diversification_threshold": 1000,
"max_single_percentage": 0.70,
"enable_supplier_score_optimization": True
},
"ml_insights_settings": {
"inventory_lookback_days": 90,
"inventory_min_history_days": 30,
"production_lookback_days": 90,
"production_min_history_runs": 30,
"supplier_analysis_lookback_days": 180,
"supplier_analysis_min_orders": 10,
"price_forecast_lookback_days": 180,
"price_forecast_horizon_days": 30,
"rules_generation_lookback_days": 90,
"rules_generation_min_samples": 10,
"enable_ml_insights": True,
"ml_insights_auto_trigger": False,
"ml_confidence_threshold": 0.80
},
"notification_settings": {
"whatsapp_enabled": False,
"whatsapp_phone_number_id": "",
"whatsapp_display_phone_number": "",
"whatsapp_default_language": "es",
"email_enabled": True,
"email_from_address": "",
"email_from_name": "",
"email_reply_to": "",
"enable_po_notifications": True,
"enable_inventory_alerts": True,
"enable_production_alerts": True,
"enable_forecast_alerts": True,
"po_notification_channels": ["email"],
"inventory_alert_channels": ["email"],
"production_alert_channels": ["email"],
"forecast_alert_channels": ["email"]
}
}

View File

@@ -0,0 +1,221 @@
# 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))
# 3D Secure (3DS) tracking
threeds_authentication_required = Column(Boolean, default=False)
threeds_authentication_required_at = Column(DateTime(timezone=True), nullable=True)
threeds_authentication_completed = Column(Boolean, default=False)
threeds_authentication_completed_at = Column(DateTime(timezone=True), nullable=True)
last_threeds_setup_intent_id = Column(String(255), nullable=True)
threeds_action_type = Column(String(100), nullable=True)
# 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)
# Payment provider references (generic names for provider-agnostic design)
subscription_id = Column(String(255), nullable=True) # Payment provider subscription ID
customer_id = Column(String(255), nullable=True) # Payment provider customer ID
# 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))
# 3D Secure (3DS) tracking
threeds_authentication_required = Column(Boolean, default=False)
threeds_authentication_required_at = Column(DateTime(timezone=True), nullable=True)
threeds_authentication_completed = Column(Boolean, default=False)
threeds_authentication_completed_at = Column(DateTime(timezone=True), nullable=True)
last_threeds_setup_intent_id = Column(String(255), nullable=True)
threeds_action_type = Column(String(100), nullable=True)
# 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)