bmc_hub/app/modules/locations/models/schemas.py
Christian 693ac4cfd6 feat: Add case types to settings if not found
feat: Update frontend navigation and links for support and CRM sections

fix: Modify subscription listing and stats endpoints to support 'all' status

feat: Implement subscription status filter in the subscriptions list view

feat: Redirect ticket routes to the new sag path

feat: Integrate devportal routes into the main application

feat: Create a wizard for location creation with nested floors and rooms

feat: Add product suppliers table to track multiple suppliers per product

feat: Implement product audit log to track changes in products

feat: Extend location types to include kantine and moedelokale

feat: Add last_2fa_at column to users table for 2FA grace period tracking
2026-02-09 15:30:07 +01:00

397 lines
13 KiB
Python

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