# 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)