2026-01-31 23:16:24 +01:00
|
|
|
"""
|
|
|
|
|
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(
|
|
|
|
|
...,
|
2026-02-09 15:30:07 +01:00
|
|
|
description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
|
2026-01-31 23:16:24 +01:00
|
|
|
)
|
|
|
|
|
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"""
|
2026-02-09 15:30:07 +01:00
|
|
|
allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
|
2026-01-31 23:16:24 +01:00
|
|
|
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,
|
2026-02-09 15:30:07 +01:00
|
|
|
description="Type: kompleks | bygning | etage | customer_site | rum | kantine | moedelokale | vehicle"
|
2026-01-31 23:16:24 +01:00
|
|
|
)
|
|
|
|
|
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
|
2026-02-09 15:30:07 +01:00
|
|
|
allowed = ['kompleks', 'bygning', 'etage', 'customer_site', 'rum', 'kantine', 'moedelokale', 'vehicle']
|
2026-01-31 23:16:24 +01:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
2026-02-09 15:30:07 +01:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-01-31 23:16:24 +01:00
|
|
|
# ============================================================================
|
|
|
|
|
# 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)
|