bmc_hub/app/modules/locations/models/schemas.py

352 lines
12 KiB
Python
Raw Normal View History

"""
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 | 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', '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 | 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', '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")
# ============================================================================
# 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)