""" Pydantic data models for Location (Lokaliteter) Module. Provides request/response validation for all location-related endpoints. Includes models for locations, contacts, operating hours, services, capacity, and audit logs. """ from pydantic import BaseModel, Field, field_validator from typing import Optional, List from datetime import datetime, time from decimal import Decimal # ============================================================================ # 1. LOCATION MODELS # ============================================================================ class LocationBase(BaseModel): """Shared fields for location models""" name: str = Field(..., min_length=1, max_length=255, description="Location name (unique)") location_type: str = Field( ..., description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle" ) parent_location_id: Optional[int] = Field( None, description="Parent location ID for hierarchy (e.g., building -> floor -> room)" ) customer_id: Optional[int] = Field( None, description="Related customer ID (optional, can be used for any location type)" ) address_street: Optional[str] = Field(None, max_length=255) address_city: Optional[str] = Field(None, max_length=100) address_postal_code: Optional[str] = Field(None, max_length=20) address_country: str = Field("DK", max_length=100) latitude: Optional[float] = Field(None, ge=-90, le=90) longitude: Optional[float] = Field(None, ge=-180, le=180) phone: Optional[str] = Field(None, max_length=20) email: Optional[str] = None notes: Optional[str] = None is_active: bool = Field(True, description="Whether location is active") @field_validator('location_type') @classmethod def validate_location_type(cls, v): """Validate location_type is one of allowed values""" allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle'] if v not in allowed: raise ValueError(f'location_type must be one of {allowed}') return v class LocationCreate(LocationBase): """Request model for creating a new location""" pass class LocationUpdate(BaseModel): """Request model for updating location (all fields optional)""" name: Optional[str] = Field(None, min_length=1, max_length=255) location_type: Optional[str] = Field( None, description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle" ) parent_location_id: Optional[int] = None customer_id: Optional[int] = None address_street: Optional[str] = Field(None, max_length=255) address_city: Optional[str] = Field(None, max_length=100) address_postal_code: Optional[str] = Field(None, max_length=20) address_country: Optional[str] = Field(None, max_length=100) latitude: Optional[float] = Field(None, ge=-90, le=90) longitude: Optional[float] = Field(None, ge=-180, le=180) phone: Optional[str] = Field(None, max_length=20) email: Optional[str] = None notes: Optional[str] = None is_active: Optional[bool] = None @field_validator('location_type') @classmethod def validate_location_type(cls, v): if v is None: return v allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle'] if v not in allowed: raise ValueError(f'location_type must be one of {allowed}') return v class Location(LocationBase): """Full location response model with database fields""" id: int parent_location_name: Optional[str] = None customer_name: Optional[str] = None created_at: datetime updated_at: datetime deleted_at: Optional[datetime] = None created_by_user_id: Optional[int] = None class Config: from_attributes = True # ============================================================================ # 2. CONTACT MODELS # ============================================================================ class ContactBase(BaseModel): """Shared fields for contact models""" contact_name: str = Field(..., min_length=1, max_length=255) contact_email: Optional[str] = None contact_phone: Optional[str] = Field(None, max_length=20) role: Optional[str] = Field( None, max_length=100, description="e.g., Manager, Technician, Administrator" ) class ContactCreate(ContactBase): """Request model for creating contact""" is_primary: bool = Field(False, description="Set as primary contact for location") class ContactUpdate(BaseModel): """Request model for updating contact (all fields optional)""" contact_name: Optional[str] = Field(None, min_length=1, max_length=255) contact_email: Optional[str] = None contact_phone: Optional[str] = Field(None, max_length=20) role: Optional[str] = Field(None, max_length=100) is_primary: Optional[bool] = None class Contact(ContactBase): """Full contact response model""" id: int location_id: int is_primary: bool created_at: datetime class Config: from_attributes = True # ============================================================================ # 3. OPERATING HOURS MODELS # ============================================================================ class OperatingHoursBase(BaseModel): """Shared fields for operating hours models""" day_of_week: int = Field( ..., ge=0, le=6, description="0=Monday...6=Sunday" ) open_time: Optional[time] = Field(None, description="HH:MM format") close_time: Optional[time] = Field(None, description="HH:MM format") is_open: bool = Field(True, description="Is location open on this day?") notes: Optional[str] = Field(None, max_length=255) class OperatingHoursCreate(OperatingHoursBase): """Request model for creating operating hours""" pass class OperatingHoursUpdate(BaseModel): """Request model for updating operating hours (all fields optional)""" open_time: Optional[time] = None close_time: Optional[time] = None is_open: Optional[bool] = None notes: Optional[str] = None class OperatingHours(OperatingHoursBase): """Full operating hours response model""" id: int location_id: int class Config: from_attributes = True @property def day_name(self) -> str: """Get day name from day_of_week (0=Monday, 6=Sunday)""" days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] return days[self.day_of_week] # ============================================================================ # 4. SERVICE MODELS # ============================================================================ class ServiceBase(BaseModel): """Shared fields for service models""" service_name: str = Field(..., min_length=1, max_length=255) is_available: bool = Field(True) class ServiceCreate(ServiceBase): """Request model for creating service""" pass class ServiceUpdate(BaseModel): """Request model for updating service (all fields optional)""" service_name: Optional[str] = Field(None, min_length=1, max_length=255) is_available: Optional[bool] = None class Service(ServiceBase): """Full service response model""" id: int location_id: int created_at: datetime class Config: from_attributes = True # ============================================================================ # 5. CAPACITY MODELS # ============================================================================ class CapacityBase(BaseModel): """Shared fields for capacity models""" capacity_type: str = Field( ..., max_length=100, description="e.g., rack_units, square_meters, storage_boxes" ) total_capacity: Decimal = Field(..., decimal_places=2, gt=0) used_capacity: Decimal = Field(0, decimal_places=2, ge=0) @field_validator('used_capacity') @classmethod def validate_used_capacity(cls, v, info): """Ensure used_capacity doesn't exceed total_capacity""" if 'total_capacity' in info.data: if v > info.data['total_capacity']: raise ValueError('used_capacity cannot exceed total_capacity') return v class CapacityCreate(CapacityBase): """Request model for creating capacity""" pass class CapacityUpdate(BaseModel): """Request model for updating capacity (all fields optional)""" total_capacity: Optional[Decimal] = Field(None, decimal_places=2, gt=0) used_capacity: Optional[Decimal] = Field(None, decimal_places=2, ge=0) class Capacity(CapacityBase): """Full capacity response model""" id: int location_id: int last_updated: datetime class Config: from_attributes = True @property def usage_percentage(self) -> float: """Calculate usage percentage (0-100)""" if self.total_capacity == 0: return 0.0 return float((self.used_capacity / self.total_capacity) * 100) @property def available_capacity(self) -> Decimal: """Calculate remaining capacity""" return self.total_capacity - self.used_capacity # ============================================================================ # 6. BULK OPERATION MODELS # ============================================================================ class BulkUpdateRequest(BaseModel): """Request for bulk updating multiple locations""" ids: List[int] = Field(..., min_items=1, description="Location IDs to update") updates: dict = Field(..., description="Fields to update (name, is_active, etc.)") class BulkDeleteRequest(BaseModel): """Request for bulk deleting multiple locations""" ids: List[int] = Field(..., min_items=1, description="Location IDs to soft-delete") class LocationWizardRoom(BaseModel): """Room definition for location wizard""" name: str = Field(..., min_length=1, max_length=255) location_type: str = Field("rum", description="Type: rum | kantine | moedelokale") is_active: bool = Field(True, description="Whether room is active") @field_validator('location_type') @classmethod def validate_room_type(cls, v): allowed = ['rum', 'kantine', 'moedelokale'] if v not in allowed: raise ValueError(f'location_type must be one of {allowed}') return v class LocationWizardFloor(BaseModel): """Floor definition for location wizard""" name: str = Field(..., min_length=1, max_length=255) location_type: str = Field("etage", description="Type: etage") rooms: List[LocationWizardRoom] = Field(default_factory=list) is_active: bool = Field(True, description="Whether floor is active") @field_validator('location_type') @classmethod def validate_floor_type(cls, v): if v != 'etage': raise ValueError('location_type must be etage for floors') return v class LocationWizardCreateRequest(BaseModel): """Request for creating a location with floors and rooms""" root: LocationCreate floors: List[LocationWizardFloor] = Field(..., min_items=1) auto_suffix: bool = Field(True, description="Auto-suffix names if duplicates exist") class LocationWizardCreateResponse(BaseModel): """Response for location wizard creation""" root_id: int floor_ids: List[int] = Field(default_factory=list) room_ids: List[int] = Field(default_factory=list) created_total: int # ============================================================================ # 7. RESPONSE MODELS # ============================================================================ class LocationDetail(Location): """Full location response including all related data""" hierarchy: List[dict] = Field(default_factory=list, description="Ancestors from root to parent") children: List[dict] = Field(default_factory=list, description="Direct child locations") hardware: List[dict] = Field(default_factory=list, description="Hardware assigned to this location") contacts: List[Contact] = Field(default_factory=list) hours: List[OperatingHours] = Field(default_factory=list) services: List[Service] = Field(default_factory=list) capacity: List[Capacity] = Field(default_factory=list) class AuditLogEntry(BaseModel): """Audit log entry for location changes""" id: int location_id: int event_type: str user_id: Optional[int] = None changes: dict = Field( default_factory=dict, description="JSON object with before/after values" ) created_at: datetime class Config: from_attributes = True class LocationStats(BaseModel): """Statistics about locations""" total_locations: int active_locations: int by_type: dict = Field(default_factory=dict, description="Count by location_type") total_contacts: int total_services: int average_capacity_utilization: float = Field(0.0, ge=0, le=100) # ============================================================================ # 8. SEARCH & FILTER MODELS # ============================================================================ class LocationSearchResponse(BaseModel): """Response model for location search results""" results: List[Location] total: int query: str class LocationFilterParams(BaseModel): """Query parameters for filtering locations""" location_type: Optional[str] = None is_active: Optional[bool] = None skip: int = Field(0, ge=0) limit: int = Field(50, ge=1, le=1000)