Initial commit - production deployment
This commit is contained in:
0
services/tenant/app/schemas/__init__.py
Normal file
0
services/tenant/app/schemas/__init__.py
Normal file
89
services/tenant/app/schemas/tenant_locations.py
Normal file
89
services/tenant/app/schemas/tenant_locations.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Tenant Location Schemas
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class TenantLocationBase(BaseModel):
|
||||
"""Base schema for tenant location"""
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
location_type: str = Field(..., pattern=r'^(central_production|retail_outlet|warehouse|store|branch)$')
|
||||
address: str = Field(..., min_length=10, max_length=500)
|
||||
city: str = Field(default="Madrid", max_length=100)
|
||||
postal_code: str = Field(..., min_length=3, max_length=10)
|
||||
latitude: Optional[float] = Field(None, ge=-90, le=90)
|
||||
longitude: Optional[float] = Field(None, ge=-180, le=180)
|
||||
contact_person: Optional[str] = Field(None, max_length=200)
|
||||
contact_phone: Optional[str] = Field(None, max_length=20)
|
||||
contact_email: Optional[str] = Field(None, max_length=255)
|
||||
is_active: bool = True
|
||||
delivery_windows: Optional[Dict[str, Any]] = None
|
||||
operational_hours: Optional[Dict[str, Any]] = None
|
||||
capacity: Optional[int] = Field(None, ge=0)
|
||||
max_delivery_radius_km: Optional[float] = Field(None, ge=0)
|
||||
delivery_schedule_config: Optional[Dict[str, Any]] = None
|
||||
metadata: Optional[Dict[str, Any]] = Field(None)
|
||||
|
||||
|
||||
class TenantLocationCreate(TenantLocationBase):
|
||||
"""Schema for creating a tenant location"""
|
||||
tenant_id: str # This will be validated as UUID in the API layer
|
||||
|
||||
|
||||
class TenantLocationUpdate(BaseModel):
|
||||
"""Schema for updating a tenant location"""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
location_type: Optional[str] = Field(None, pattern=r'^(central_production|retail_outlet|warehouse|store|branch)$')
|
||||
address: Optional[str] = Field(None, min_length=10, max_length=500)
|
||||
city: Optional[str] = Field(None, max_length=100)
|
||||
postal_code: Optional[str] = Field(None, min_length=3, max_length=10)
|
||||
latitude: Optional[float] = Field(None, ge=-90, le=90)
|
||||
longitude: Optional[float] = Field(None, ge=-180, le=180)
|
||||
contact_person: Optional[str] = Field(None, max_length=200)
|
||||
contact_phone: Optional[str] = Field(None, max_length=20)
|
||||
contact_email: Optional[str] = Field(None, max_length=255)
|
||||
is_active: Optional[bool] = None
|
||||
delivery_windows: Optional[Dict[str, Any]] = None
|
||||
operational_hours: Optional[Dict[str, Any]] = None
|
||||
capacity: Optional[int] = Field(None, ge=0)
|
||||
max_delivery_radius_km: Optional[float] = Field(None, ge=0)
|
||||
delivery_schedule_config: Optional[Dict[str, Any]] = None
|
||||
metadata: Optional[Dict[str, Any]] = Field(None)
|
||||
|
||||
|
||||
class TenantLocationResponse(TenantLocationBase):
|
||||
"""Schema for tenant location response"""
|
||||
id: str
|
||||
tenant_id: str
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
@field_validator('id', 'tenant_id', mode='before')
|
||||
@classmethod
|
||||
def convert_uuid_to_string(cls, v):
|
||||
"""Convert UUID objects to strings for JSON serialization"""
|
||||
if isinstance(v, UUID):
|
||||
return str(v)
|
||||
return v
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class TenantLocationsResponse(BaseModel):
|
||||
"""Schema for multiple tenant locations response"""
|
||||
locations: List[TenantLocationResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class TenantLocationTypeFilter(BaseModel):
|
||||
"""Schema for filtering locations by type"""
|
||||
location_types: List[str] = Field(
|
||||
default=["central_production", "retail_outlet", "warehouse", "store", "branch"],
|
||||
description="List of location types to include"
|
||||
)
|
||||
316
services/tenant/app/schemas/tenant_settings.py
Normal file
316
services/tenant/app/schemas/tenant_settings.py
Normal file
@@ -0,0 +1,316 @@
|
||||
# services/tenant/app/schemas/tenant_settings.py
|
||||
"""
|
||||
Tenant Settings Schemas
|
||||
Pydantic models for API request/response validation
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
# ================================================================
|
||||
# SETTING CATEGORY SCHEMAS
|
||||
# ================================================================
|
||||
|
||||
class ProcurementSettings(BaseModel):
|
||||
"""Procurement and auto-approval settings"""
|
||||
auto_approve_enabled: bool = True
|
||||
auto_approve_threshold_eur: float = Field(500.0, ge=0, le=10000)
|
||||
auto_approve_min_supplier_score: float = Field(0.80, ge=0.0, le=1.0)
|
||||
require_approval_new_suppliers: bool = True
|
||||
require_approval_critical_items: bool = True
|
||||
procurement_lead_time_days: int = Field(3, ge=1, le=30)
|
||||
demand_forecast_days: int = Field(14, ge=1, le=90)
|
||||
safety_stock_percentage: float = Field(20.0, ge=0.0, le=100.0)
|
||||
po_approval_reminder_hours: int = Field(24, ge=1, le=168)
|
||||
po_critical_escalation_hours: int = Field(12, ge=1, le=72)
|
||||
use_reorder_rules: bool = Field(True, description="Use ingredient reorder point and reorder quantity in procurement calculations")
|
||||
economic_rounding: bool = Field(True, description="Round order quantities to economic multiples (reorder_quantity or supplier minimum_order_quantity)")
|
||||
respect_storage_limits: bool = Field(True, description="Enforce max_stock_level constraints on orders")
|
||||
use_supplier_minimums: bool = Field(True, description="Respect supplier minimum_order_quantity and minimum_order_amount")
|
||||
optimize_price_tiers: bool = Field(True, description="Optimize order quantities to capture volume discount price tiers")
|
||||
|
||||
|
||||
class InventorySettings(BaseModel):
|
||||
"""Inventory management settings"""
|
||||
low_stock_threshold: int = Field(10, ge=1, le=1000)
|
||||
reorder_point: int = Field(20, ge=1, le=1000)
|
||||
reorder_quantity: int = Field(50, ge=1, le=1000)
|
||||
expiring_soon_days: int = Field(7, ge=1, le=30)
|
||||
expiration_warning_days: int = Field(3, ge=1, le=14)
|
||||
quality_score_threshold: float = Field(8.0, ge=0.0, le=10.0)
|
||||
temperature_monitoring_enabled: bool = True
|
||||
refrigeration_temp_min: float = Field(1.0, ge=-5.0, le=10.0)
|
||||
refrigeration_temp_max: float = Field(4.0, ge=-5.0, le=10.0)
|
||||
freezer_temp_min: float = Field(-20.0, ge=-30.0, le=0.0)
|
||||
freezer_temp_max: float = Field(-15.0, ge=-30.0, le=0.0)
|
||||
room_temp_min: float = Field(18.0, ge=10.0, le=35.0)
|
||||
room_temp_max: float = Field(25.0, ge=10.0, le=35.0)
|
||||
temp_deviation_alert_minutes: int = Field(15, ge=1, le=60)
|
||||
critical_temp_deviation_minutes: int = Field(5, ge=1, le=30)
|
||||
|
||||
@validator('refrigeration_temp_max')
|
||||
def validate_refrigeration_range(cls, v, values):
|
||||
if 'refrigeration_temp_min' in values and v <= values['refrigeration_temp_min']:
|
||||
raise ValueError('refrigeration_temp_max must be greater than refrigeration_temp_min')
|
||||
return v
|
||||
|
||||
@validator('freezer_temp_max')
|
||||
def validate_freezer_range(cls, v, values):
|
||||
if 'freezer_temp_min' in values and v <= values['freezer_temp_min']:
|
||||
raise ValueError('freezer_temp_max must be greater than freezer_temp_min')
|
||||
return v
|
||||
|
||||
@validator('room_temp_max')
|
||||
def validate_room_range(cls, v, values):
|
||||
if 'room_temp_min' in values and v <= values['room_temp_min']:
|
||||
raise ValueError('room_temp_max must be greater than room_temp_min')
|
||||
return v
|
||||
|
||||
|
||||
class ProductionSettings(BaseModel):
|
||||
"""Production settings"""
|
||||
planning_horizon_days: int = Field(7, ge=1, le=30)
|
||||
minimum_batch_size: float = Field(1.0, ge=0.1, le=100.0)
|
||||
maximum_batch_size: float = Field(100.0, ge=1.0, le=1000.0)
|
||||
production_buffer_percentage: float = Field(10.0, ge=0.0, le=50.0)
|
||||
working_hours_per_day: int = Field(12, ge=1, le=24)
|
||||
max_overtime_hours: int = Field(4, ge=0, le=12)
|
||||
capacity_utilization_target: float = Field(0.85, ge=0.5, le=1.0)
|
||||
capacity_warning_threshold: float = Field(0.95, ge=0.7, le=1.0)
|
||||
quality_check_enabled: bool = True
|
||||
minimum_yield_percentage: float = Field(85.0, ge=50.0, le=100.0)
|
||||
quality_score_threshold: float = Field(8.0, ge=0.0, le=10.0)
|
||||
schedule_optimization_enabled: bool = True
|
||||
prep_time_buffer_minutes: int = Field(30, ge=0, le=120)
|
||||
cleanup_time_buffer_minutes: int = Field(15, ge=0, le=120)
|
||||
labor_cost_per_hour_eur: float = Field(15.0, ge=5.0, le=100.0)
|
||||
overhead_cost_percentage: float = Field(20.0, ge=0.0, le=50.0)
|
||||
|
||||
@validator('maximum_batch_size')
|
||||
def validate_batch_size_range(cls, v, values):
|
||||
if 'minimum_batch_size' in values and v <= values['minimum_batch_size']:
|
||||
raise ValueError('maximum_batch_size must be greater than minimum_batch_size')
|
||||
return v
|
||||
|
||||
@validator('capacity_warning_threshold')
|
||||
def validate_capacity_threshold(cls, v, values):
|
||||
if 'capacity_utilization_target' in values and v <= values['capacity_utilization_target']:
|
||||
raise ValueError('capacity_warning_threshold must be greater than capacity_utilization_target')
|
||||
return v
|
||||
|
||||
|
||||
class SupplierSettings(BaseModel):
|
||||
"""Supplier management settings"""
|
||||
default_payment_terms_days: int = Field(30, ge=1, le=90)
|
||||
default_delivery_days: int = Field(3, ge=1, le=30)
|
||||
excellent_delivery_rate: float = Field(95.0, ge=90.0, le=100.0)
|
||||
good_delivery_rate: float = Field(90.0, ge=80.0, le=99.0)
|
||||
excellent_quality_rate: float = Field(98.0, ge=90.0, le=100.0)
|
||||
good_quality_rate: float = Field(95.0, ge=80.0, le=99.0)
|
||||
critical_delivery_delay_hours: int = Field(24, ge=1, le=168)
|
||||
critical_quality_rejection_rate: float = Field(10.0, ge=0.0, le=50.0)
|
||||
high_cost_variance_percentage: float = Field(15.0, ge=0.0, le=100.0)
|
||||
|
||||
@validator('good_delivery_rate')
|
||||
def validate_delivery_rates(cls, v, values):
|
||||
if 'excellent_delivery_rate' in values and v >= values['excellent_delivery_rate']:
|
||||
raise ValueError('good_delivery_rate must be less than excellent_delivery_rate')
|
||||
return v
|
||||
|
||||
@validator('good_quality_rate')
|
||||
def validate_quality_rates(cls, v, values):
|
||||
if 'excellent_quality_rate' in values and v >= values['excellent_quality_rate']:
|
||||
raise ValueError('good_quality_rate must be less than excellent_quality_rate')
|
||||
return v
|
||||
|
||||
|
||||
class POSSettings(BaseModel):
|
||||
"""POS integration settings"""
|
||||
sync_interval_minutes: int = Field(5, ge=1, le=60)
|
||||
auto_sync_products: bool = True
|
||||
auto_sync_transactions: bool = True
|
||||
|
||||
|
||||
class OrderSettings(BaseModel):
|
||||
"""Order and business rules settings"""
|
||||
max_discount_percentage: float = Field(50.0, ge=0.0, le=100.0)
|
||||
default_delivery_window_hours: int = Field(48, ge=1, le=168)
|
||||
dynamic_pricing_enabled: bool = False
|
||||
discount_enabled: bool = True
|
||||
delivery_tracking_enabled: bool = True
|
||||
|
||||
|
||||
class ReplenishmentSettings(BaseModel):
|
||||
"""Replenishment planning settings"""
|
||||
projection_horizon_days: int = Field(7, ge=1, le=30)
|
||||
service_level: float = Field(0.95, ge=0.0, le=1.0)
|
||||
buffer_days: int = Field(1, ge=0, le=14)
|
||||
enable_auto_replenishment: bool = True
|
||||
min_order_quantity: float = Field(1.0, ge=0.1, le=1000.0)
|
||||
max_order_quantity: float = Field(1000.0, ge=1.0, le=10000.0)
|
||||
demand_forecast_days: int = Field(14, ge=1, le=90)
|
||||
|
||||
|
||||
class SafetyStockSettings(BaseModel):
|
||||
"""Safety stock settings"""
|
||||
service_level: float = Field(0.95, ge=0.0, le=1.0)
|
||||
method: str = Field("statistical", description="Method for safety stock calculation")
|
||||
min_safety_stock: float = Field(0.0, ge=0.0, le=1000.0)
|
||||
max_safety_stock: float = Field(100.0, ge=0.0, le=1000.0)
|
||||
reorder_point_calculation: str = Field("safety_stock_plus_lead_time_demand", description="Method for reorder point calculation")
|
||||
|
||||
|
||||
class MOQSettings(BaseModel):
|
||||
"""MOQ aggregation settings"""
|
||||
consolidation_window_days: int = Field(7, ge=1, le=30)
|
||||
allow_early_ordering: bool = True
|
||||
enable_batch_optimization: bool = True
|
||||
min_batch_size: float = Field(1.0, ge=0.1, le=1000.0)
|
||||
max_batch_size: float = Field(1000.0, ge=1.0, le=10000.0)
|
||||
|
||||
|
||||
class SupplierSelectionSettings(BaseModel):
|
||||
"""Supplier selection settings"""
|
||||
price_weight: float = Field(0.40, ge=0.0, le=1.0)
|
||||
lead_time_weight: float = Field(0.20, ge=0.0, le=1.0)
|
||||
quality_weight: float = Field(0.20, ge=0.0, le=1.0)
|
||||
reliability_weight: float = Field(0.20, ge=0.0, le=1.0)
|
||||
diversification_threshold: int = Field(1000, ge=0, le=1000)
|
||||
max_single_percentage: float = Field(0.70, ge=0.0, le=1.0)
|
||||
enable_supplier_score_optimization: bool = True
|
||||
|
||||
@validator('price_weight', 'lead_time_weight', 'quality_weight', 'reliability_weight')
|
||||
def validate_weights_sum(cls, v, values):
|
||||
weights = [values.get('price_weight', 0.40), values.get('lead_time_weight', 0.20),
|
||||
values.get('quality_weight', 0.20), values.get('reliability_weight', 0.20)]
|
||||
total = sum(weights)
|
||||
if total > 1.0:
|
||||
raise ValueError('Weights must sum to 1.0 or less')
|
||||
return v
|
||||
|
||||
|
||||
class MLInsightsSettings(BaseModel):
|
||||
"""ML Insights configuration settings"""
|
||||
# Inventory ML (Safety Stock Optimization)
|
||||
inventory_lookback_days: int = Field(90, ge=30, le=365, description="Days of demand history for safety stock analysis")
|
||||
inventory_min_history_days: int = Field(30, ge=7, le=180, description="Minimum days of history required")
|
||||
|
||||
# Production ML (Yield Prediction)
|
||||
production_lookback_days: int = Field(90, ge=30, le=365, description="Days of production history for yield analysis")
|
||||
production_min_history_runs: int = Field(30, ge=10, le=100, description="Minimum production runs required")
|
||||
|
||||
# Procurement ML (Supplier Analysis & Price Forecasting)
|
||||
supplier_analysis_lookback_days: int = Field(180, ge=30, le=730, description="Days of order history for supplier analysis")
|
||||
supplier_analysis_min_orders: int = Field(10, ge=5, le=100, description="Minimum orders required for analysis")
|
||||
price_forecast_lookback_days: int = Field(180, ge=90, le=730, description="Days of price history for forecasting")
|
||||
price_forecast_horizon_days: int = Field(30, ge=7, le=90, description="Days to forecast ahead")
|
||||
|
||||
# Forecasting ML (Dynamic Rules)
|
||||
rules_generation_lookback_days: int = Field(90, ge=30, le=365, description="Days of sales history for rule learning")
|
||||
rules_generation_min_samples: int = Field(10, ge=5, le=100, description="Minimum samples required for rule generation")
|
||||
|
||||
# Global ML Settings
|
||||
enable_ml_insights: bool = Field(True, description="Enable/disable ML insights generation")
|
||||
ml_insights_auto_trigger: bool = Field(False, description="Automatically trigger ML insights in daily workflow")
|
||||
ml_confidence_threshold: float = Field(0.80, ge=0.0, le=1.0, description="Minimum confidence threshold for ML recommendations")
|
||||
|
||||
|
||||
class NotificationSettings(BaseModel):
|
||||
"""Notification and communication settings"""
|
||||
# WhatsApp Configuration (Shared Account Model)
|
||||
whatsapp_enabled: bool = Field(False, description="Enable WhatsApp notifications for this tenant")
|
||||
whatsapp_phone_number_id: str = Field("", description="Meta WhatsApp Phone Number ID (from shared master account)")
|
||||
whatsapp_display_phone_number: str = Field("", description="Display format for UI (e.g., '+34 612 345 678')")
|
||||
whatsapp_default_language: str = Field("es", description="Default language for WhatsApp templates")
|
||||
|
||||
# Email Configuration
|
||||
email_enabled: bool = Field(True, description="Enable email notifications for this tenant")
|
||||
email_from_address: str = Field("", description="Custom from email address (optional)")
|
||||
email_from_name: str = Field("", description="Custom from name (optional)")
|
||||
email_reply_to: str = Field("", description="Reply-to email address (optional)")
|
||||
|
||||
# Notification Preferences
|
||||
enable_po_notifications: bool = Field(True, description="Enable purchase order notifications")
|
||||
enable_inventory_alerts: bool = Field(True, description="Enable inventory alerts")
|
||||
enable_production_alerts: bool = Field(True, description="Enable production alerts")
|
||||
enable_forecast_alerts: bool = Field(True, description="Enable forecast alerts")
|
||||
|
||||
# Notification Channels
|
||||
po_notification_channels: list[str] = Field(["email"], description="Channels for PO notifications (email, whatsapp)")
|
||||
inventory_alert_channels: list[str] = Field(["email"], description="Channels for inventory alerts")
|
||||
production_alert_channels: list[str] = Field(["email"], description="Channels for production alerts")
|
||||
forecast_alert_channels: list[str] = Field(["email"], description="Channels for forecast alerts")
|
||||
|
||||
@validator('po_notification_channels', 'inventory_alert_channels', 'production_alert_channels', 'forecast_alert_channels')
|
||||
def validate_channels(cls, v):
|
||||
"""Validate that channels are valid"""
|
||||
valid_channels = ["email", "whatsapp", "sms", "push"]
|
||||
for channel in v:
|
||||
if channel not in valid_channels:
|
||||
raise ValueError(f"Invalid channel: {channel}. Must be one of {valid_channels}")
|
||||
return v
|
||||
|
||||
@validator('whatsapp_phone_number_id')
|
||||
def validate_phone_number_id(cls, v, values):
|
||||
"""Validate phone number ID is provided if WhatsApp is enabled"""
|
||||
if values.get('whatsapp_enabled') and not v:
|
||||
raise ValueError("whatsapp_phone_number_id is required when WhatsApp is enabled")
|
||||
return v
|
||||
|
||||
|
||||
# ================================================================
|
||||
# REQUEST/RESPONSE SCHEMAS
|
||||
# ================================================================
|
||||
|
||||
class TenantSettingsResponse(BaseModel):
|
||||
"""Response schema for tenant settings"""
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
procurement_settings: ProcurementSettings
|
||||
inventory_settings: InventorySettings
|
||||
production_settings: ProductionSettings
|
||||
supplier_settings: SupplierSettings
|
||||
pos_settings: POSSettings
|
||||
order_settings: OrderSettings
|
||||
replenishment_settings: ReplenishmentSettings
|
||||
safety_stock_settings: SafetyStockSettings
|
||||
moq_settings: MOQSettings
|
||||
supplier_selection_settings: SupplierSelectionSettings
|
||||
ml_insights_settings: MLInsightsSettings
|
||||
notification_settings: NotificationSettings
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TenantSettingsUpdate(BaseModel):
|
||||
"""Schema for updating tenant settings"""
|
||||
procurement_settings: Optional[ProcurementSettings] = None
|
||||
inventory_settings: Optional[InventorySettings] = None
|
||||
production_settings: Optional[ProductionSettings] = None
|
||||
supplier_settings: Optional[SupplierSettings] = None
|
||||
pos_settings: Optional[POSSettings] = None
|
||||
order_settings: Optional[OrderSettings] = None
|
||||
replenishment_settings: Optional[ReplenishmentSettings] = None
|
||||
safety_stock_settings: Optional[SafetyStockSettings] = None
|
||||
moq_settings: Optional[MOQSettings] = None
|
||||
supplier_selection_settings: Optional[SupplierSelectionSettings] = None
|
||||
ml_insights_settings: Optional[MLInsightsSettings] = None
|
||||
notification_settings: Optional[NotificationSettings] = None
|
||||
|
||||
|
||||
class CategoryUpdateRequest(BaseModel):
|
||||
"""Schema for updating a single category"""
|
||||
settings: dict
|
||||
|
||||
|
||||
class CategoryResetResponse(BaseModel):
|
||||
"""Response schema for category reset"""
|
||||
category: str
|
||||
settings: dict
|
||||
message: str
|
||||
386
services/tenant/app/schemas/tenants.py
Normal file
386
services/tenant/app/schemas/tenants.py
Normal file
@@ -0,0 +1,386 @@
|
||||
# services/tenant/app/schemas/tenants.py
|
||||
"""
|
||||
Tenant schemas - FIXED VERSION
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, ValidationInfo
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
import re
|
||||
|
||||
class BakeryRegistration(BaseModel):
|
||||
"""Bakery registration schema"""
|
||||
name: str = Field(..., min_length=2, max_length=200)
|
||||
address: str = Field(..., min_length=10, max_length=500)
|
||||
city: str = Field(default="Madrid", max_length=100)
|
||||
postal_code: str = Field(..., pattern=r"^\d{5}$")
|
||||
phone: str = Field(..., min_length=9, max_length=20)
|
||||
business_type: str = Field(default="bakery")
|
||||
business_model: Optional[str] = Field(default="individual_bakery")
|
||||
coupon_code: Optional[str] = Field(None, max_length=50, description="Promotional coupon code")
|
||||
# Subscription linking fields (for new multi-phase registration architecture)
|
||||
subscription_id: Optional[str] = Field(None, description="Existing subscription ID to link to this tenant")
|
||||
link_existing_subscription: Optional[bool] = Field(False, description="Flag to link an existing subscription during tenant creation")
|
||||
|
||||
@field_validator('phone')
|
||||
@classmethod
|
||||
def validate_spanish_phone(cls, v):
|
||||
"""Validate Spanish phone number"""
|
||||
# Remove spaces and common separators
|
||||
phone = re.sub(r'[\s\-\(\)]', '', v)
|
||||
|
||||
# Spanish mobile: +34 6/7/8/9 + 8 digits
|
||||
# Spanish landline: +34 9 + 8 digits
|
||||
patterns = [
|
||||
r'^(\+34|0034|34)?[6789]\d{8}$', # Mobile
|
||||
r'^(\+34|0034|34)?9\d{8}$', # Landline
|
||||
]
|
||||
|
||||
if not any(re.match(pattern, phone) for pattern in patterns):
|
||||
raise ValueError('Invalid Spanish phone number')
|
||||
return v
|
||||
|
||||
@field_validator('business_type')
|
||||
@classmethod
|
||||
def validate_business_type(cls, v):
|
||||
valid_types = ['bakery', 'coffee_shop', 'pastry_shop', 'restaurant']
|
||||
if v not in valid_types:
|
||||
raise ValueError(f'Business type must be one of: {valid_types}')
|
||||
return v
|
||||
|
||||
@field_validator('business_model')
|
||||
@classmethod
|
||||
def validate_business_model(cls, v):
|
||||
if v is None:
|
||||
return v
|
||||
valid_models = ['individual_bakery', 'central_baker_satellite', 'retail_bakery', 'hybrid_bakery']
|
||||
if v not in valid_models:
|
||||
raise ValueError(f'Business model must be one of: {valid_models}')
|
||||
return v
|
||||
|
||||
class TenantResponse(BaseModel):
|
||||
"""Tenant response schema - Updated to use subscription relationship"""
|
||||
id: str # ✅ Keep as str for Pydantic validation
|
||||
name: str
|
||||
subdomain: Optional[str]
|
||||
business_type: str
|
||||
business_model: Optional[str]
|
||||
tenant_type: Optional[str] = "standalone" # standalone, parent, or child
|
||||
parent_tenant_id: Optional[str] = None # For child tenants
|
||||
address: str
|
||||
city: str
|
||||
postal_code: str
|
||||
# Regional/Localization settings
|
||||
timezone: Optional[str] = "Europe/Madrid"
|
||||
currency: Optional[str] = "EUR" # Currency code: EUR, USD, GBP
|
||||
language: Optional[str] = "es" # Language code: es, en, eu
|
||||
phone: Optional[str]
|
||||
is_active: bool
|
||||
subscription_plan: Optional[str] = None # Populated from subscription relationship or service
|
||||
ml_model_trained: bool
|
||||
last_training_date: Optional[datetime]
|
||||
owner_id: str # ✅ Keep as str for Pydantic validation
|
||||
created_at: datetime
|
||||
|
||||
# ✅ FIX: Add custom validator to convert UUID to string
|
||||
@field_validator('id', 'owner_id', 'parent_tenant_id', mode='before')
|
||||
@classmethod
|
||||
def convert_uuid_to_string(cls, v):
|
||||
"""Convert UUID objects to strings for JSON serialization"""
|
||||
if isinstance(v, UUID):
|
||||
return str(v)
|
||||
return v
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TenantAccessResponse(BaseModel):
|
||||
"""Tenant access verification response"""
|
||||
has_access: bool
|
||||
role: str
|
||||
permissions: List[str]
|
||||
|
||||
class TenantMemberResponse(BaseModel):
|
||||
"""Tenant member response - FIXED VERSION with enriched user data"""
|
||||
id: str
|
||||
user_id: str
|
||||
role: str
|
||||
is_active: bool
|
||||
joined_at: Optional[datetime]
|
||||
# Enriched user fields (populated via service layer)
|
||||
user_email: Optional[str] = None
|
||||
user_full_name: Optional[str] = None
|
||||
user: Optional[Dict[str, Any]] = None # Full user object for compatibility
|
||||
|
||||
# ✅ FIX: Add custom validator to convert UUID to string
|
||||
@field_validator('id', 'user_id', mode='before')
|
||||
@classmethod
|
||||
def convert_uuid_to_string(cls, v):
|
||||
"""Convert UUID objects to strings for JSON serialization"""
|
||||
if isinstance(v, UUID):
|
||||
return str(v)
|
||||
return v
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class TenantUpdate(BaseModel):
|
||||
"""Tenant update schema"""
|
||||
name: Optional[str] = Field(None, min_length=2, max_length=200)
|
||||
address: Optional[str] = Field(None, min_length=10, max_length=500)
|
||||
phone: Optional[str] = None
|
||||
business_type: Optional[str] = None
|
||||
business_model: Optional[str] = None
|
||||
# Regional/Localization settings
|
||||
timezone: Optional[str] = None
|
||||
currency: Optional[str] = Field(None, pattern=r'^(EUR|USD|GBP)$') # Currency code
|
||||
language: Optional[str] = Field(None, pattern=r'^(es|en|eu)$') # Language code
|
||||
|
||||
class TenantListResponse(BaseModel):
|
||||
"""Response schema for listing tenants"""
|
||||
tenants: List[TenantResponse]
|
||||
total: int
|
||||
page: int
|
||||
per_page: int
|
||||
has_next: bool
|
||||
has_prev: bool
|
||||
|
||||
class TenantMemberInvitation(BaseModel):
|
||||
"""Schema for inviting a member to a tenant"""
|
||||
email: str = Field(..., pattern=r'^[^@]+@[^@]+\.[^@]+$')
|
||||
role: str = Field(..., pattern=r'^(admin|member|viewer)$')
|
||||
message: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
class TenantMemberUpdate(BaseModel):
|
||||
"""Schema for updating tenant member"""
|
||||
role: Optional[str] = Field(None, pattern=r'^(owner|admin|member|viewer)$')
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
class AddMemberWithUserCreate(BaseModel):
|
||||
"""Schema for adding member with optional user creation (pilot phase)"""
|
||||
# For existing users
|
||||
user_id: Optional[str] = Field(None, description="ID of existing user to add")
|
||||
|
||||
# For new user creation
|
||||
create_user: bool = Field(False, description="Whether to create a new user")
|
||||
email: Optional[str] = Field(None, description="Email for new user (if create_user=True)")
|
||||
full_name: Optional[str] = Field(None, min_length=2, max_length=100, description="Full name for new user")
|
||||
password: Optional[str] = Field(None, min_length=8, max_length=128, description="Password for new user")
|
||||
phone: Optional[str] = Field(None, description="Phone number for new user")
|
||||
language: Optional[str] = Field("es", pattern="^(es|en|eu)$", description="Preferred language")
|
||||
timezone: Optional[str] = Field("Europe/Madrid", description="User timezone")
|
||||
|
||||
# Common fields
|
||||
role: str = Field(..., pattern=r'^(admin|member|viewer)$', description="Role in the tenant")
|
||||
|
||||
@field_validator('email', 'full_name', 'password')
|
||||
@classmethod
|
||||
def validate_user_creation_fields(cls, v, info: ValidationInfo):
|
||||
"""Validate that required fields are present when creating a user"""
|
||||
if info.data.get('create_user') and info.field_name in ['email', 'full_name', 'password']:
|
||||
if not v:
|
||||
raise ValueError(f"{info.field_name} is required when create_user is True")
|
||||
return v
|
||||
|
||||
@field_validator('user_id')
|
||||
@classmethod
|
||||
def validate_user_id_or_create(cls, v, info: ValidationInfo):
|
||||
"""Ensure either user_id or create_user is provided"""
|
||||
if not v and not info.data.get('create_user'):
|
||||
raise ValueError("Either user_id or create_user must be provided")
|
||||
if v and info.data.get('create_user'):
|
||||
raise ValueError("Cannot specify both user_id and create_user")
|
||||
return v
|
||||
|
||||
class TenantSubscriptionUpdate(BaseModel):
|
||||
"""Schema for updating tenant subscription"""
|
||||
plan: str = Field(..., pattern=r'^(basic|professional|enterprise)$')
|
||||
billing_cycle: str = Field(default="monthly", pattern=r'^(monthly|yearly)$')
|
||||
|
||||
class TenantStatsResponse(BaseModel):
|
||||
"""Tenant statistics response"""
|
||||
tenant_id: str
|
||||
total_members: int
|
||||
active_members: int
|
||||
total_predictions: int
|
||||
models_trained: int
|
||||
last_training_date: Optional[datetime]
|
||||
subscription_plan: str
|
||||
subscription_status: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ENTERPRISE CHILD TENANT SCHEMAS
|
||||
# ============================================================================
|
||||
|
||||
class ChildTenantCreate(BaseModel):
|
||||
"""Schema for creating a child tenant in enterprise hierarchy - Updated to match tenant model"""
|
||||
name: str = Field(..., min_length=2, max_length=200, description="Child tenant name (e.g., 'Madrid - Salamanca')")
|
||||
city: str = Field(..., min_length=2, max_length=100, description="City where the outlet is located")
|
||||
zone: Optional[str] = Field(None, max_length=100, description="Zone or neighborhood")
|
||||
address: str = Field(..., min_length=10, max_length=500, description="Full address of the outlet")
|
||||
postal_code: str = Field(..., pattern=r"^\d{5}$", description="5-digit postal code")
|
||||
location_code: str = Field(..., min_length=1, max_length=10, description="Short location code (e.g., MAD, BCN)")
|
||||
|
||||
# Coordinates (can be geocoded from address if not provided)
|
||||
latitude: Optional[float] = Field(None, ge=-90, le=90, description="Latitude coordinate")
|
||||
longitude: Optional[float] = Field(None, ge=-180, le=180, description="Longitude coordinate")
|
||||
|
||||
# Contact info (inherits from parent if not provided)
|
||||
phone: Optional[str] = Field(None, min_length=9, max_length=20, description="Contact phone")
|
||||
email: Optional[str] = Field(None, description="Contact email")
|
||||
|
||||
# Business info
|
||||
business_type: Optional[str] = Field(None, max_length=100, description="Type of business")
|
||||
business_model: Optional[str] = Field(None, max_length=100, description="Business model")
|
||||
|
||||
# Timezone configuration
|
||||
timezone: Optional[str] = Field(None, max_length=50, description="Timezone for scheduling")
|
||||
|
||||
# Additional metadata
|
||||
metadata: Optional[Dict[str, Any]] = Field(None, description="Additional metadata for the child tenant")
|
||||
|
||||
@field_validator('location_code')
|
||||
@classmethod
|
||||
def validate_location_code(cls, v):
|
||||
"""Ensure location code is uppercase and alphanumeric"""
|
||||
if not v.replace('-', '').replace('_', '').isalnum():
|
||||
raise ValueError('Location code must be alphanumeric (with optional hyphens/underscores)')
|
||||
return v.upper()
|
||||
|
||||
@field_validator('phone')
|
||||
@classmethod
|
||||
def validate_phone(cls, v):
|
||||
"""Validate Spanish phone number if provided"""
|
||||
if v is None:
|
||||
return v
|
||||
phone = re.sub(r'[\s\-\(\)]', '', v)
|
||||
patterns = [
|
||||
r'^(\+34|0034|34)?[6789]\d{8}$', # Mobile
|
||||
r'^(\+34|0034|34)?9\d{8}$', # Landline
|
||||
]
|
||||
if not any(re.match(pattern, phone) for pattern in patterns):
|
||||
raise ValueError('Invalid Spanish phone number')
|
||||
return v
|
||||
|
||||
@field_validator('business_type')
|
||||
@classmethod
|
||||
def validate_business_type(cls, v):
|
||||
"""Validate business type if provided"""
|
||||
if v is None:
|
||||
return v
|
||||
valid_types = ['bakery', 'coffee_shop', 'pastry_shop', 'restaurant']
|
||||
if v not in valid_types:
|
||||
raise ValueError(f'Business type must be one of: {valid_types}')
|
||||
return v
|
||||
|
||||
@field_validator('business_model')
|
||||
@classmethod
|
||||
def validate_business_model(cls, v):
|
||||
"""Validate business model if provided"""
|
||||
if v is None:
|
||||
return v
|
||||
valid_models = ['individual_bakery', 'central_baker_satellite', 'retail_bakery', 'hybrid_bakery']
|
||||
if v not in valid_models:
|
||||
raise ValueError(f'Business model must be one of: {valid_models}')
|
||||
return v
|
||||
|
||||
@field_validator('timezone')
|
||||
@classmethod
|
||||
def validate_timezone(cls, v):
|
||||
"""Validate timezone if provided"""
|
||||
if v is None:
|
||||
return v
|
||||
# Basic timezone validation - should match common timezone formats
|
||||
if not re.match(r'^[A-Za-z_+/]+$', v):
|
||||
raise ValueError('Invalid timezone format')
|
||||
return v
|
||||
|
||||
|
||||
class BulkChildTenantsCreate(BaseModel):
|
||||
"""Schema for bulk creating child tenants during onboarding"""
|
||||
child_tenants: List[ChildTenantCreate] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=50,
|
||||
description="List of child tenants to create (1-50)"
|
||||
)
|
||||
|
||||
# Optional: Auto-configure distribution routes
|
||||
auto_configure_distribution: bool = Field(
|
||||
True,
|
||||
description="Whether to automatically set up distribution routes between parent and children"
|
||||
)
|
||||
|
||||
@field_validator('child_tenants')
|
||||
@classmethod
|
||||
def validate_unique_location_codes(cls, v):
|
||||
"""Ensure all location codes are unique within the batch"""
|
||||
location_codes = [ct.location_code for ct in v]
|
||||
if len(location_codes) != len(set(location_codes)):
|
||||
raise ValueError('Location codes must be unique within the batch')
|
||||
return v
|
||||
|
||||
|
||||
class ChildTenantResponse(TenantResponse):
|
||||
"""Response schema for child tenant - extends TenantResponse"""
|
||||
location_code: Optional[str] = None
|
||||
zone: Optional[str] = None
|
||||
hierarchy_path: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BulkChildTenantsResponse(BaseModel):
|
||||
"""Response schema for bulk child tenant creation"""
|
||||
parent_tenant_id: str
|
||||
created_count: int
|
||||
failed_count: int
|
||||
created_tenants: List[ChildTenantResponse]
|
||||
failed_tenants: List[Dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
description="List of failed tenants with error details"
|
||||
)
|
||||
distribution_configured: bool = False
|
||||
|
||||
@field_validator('parent_tenant_id', mode='before')
|
||||
@classmethod
|
||||
def convert_uuid_to_string(cls, v):
|
||||
"""Convert UUID objects to strings for JSON serialization"""
|
||||
if isinstance(v, UUID):
|
||||
return str(v)
|
||||
return v
|
||||
|
||||
class TenantHierarchyResponse(BaseModel):
|
||||
"""Response schema for tenant hierarchy information"""
|
||||
tenant_id: str
|
||||
tenant_type: str = Field(..., description="Type: standalone, parent, or child")
|
||||
parent_tenant_id: Optional[str] = Field(None, description="Parent tenant ID if this is a child")
|
||||
hierarchy_path: Optional[str] = Field(None, description="Materialized path for hierarchy queries")
|
||||
child_count: int = Field(0, description="Number of child tenants (for parent tenants)")
|
||||
hierarchy_level: int = Field(0, description="Level in hierarchy: 0=parent, 1=child, 2=grandchild, etc.")
|
||||
|
||||
@field_validator('tenant_id', 'parent_tenant_id', mode='before')
|
||||
@classmethod
|
||||
def convert_uuid_to_string(cls, v):
|
||||
"""Convert UUID objects to strings for JSON serialization"""
|
||||
if v is None:
|
||||
return v
|
||||
if isinstance(v, UUID):
|
||||
return str(v)
|
||||
return v
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TenantSearchRequest(BaseModel):
|
||||
"""Tenant search request schema"""
|
||||
query: Optional[str] = None
|
||||
business_type: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
limit: int = Field(default=50, ge=1, le=100)
|
||||
offset: int = Field(default=0, ge=0)
|
||||
Reference in New Issue
Block a user