""" Pydantic Models for Ticket System & Klippekort Module ====================================================== Alle models repræsenterer data fra tticket_* tabeller. Modulet er isoleret og har ingen afhængigheder til core Hub-models. """ from datetime import date, datetime from decimal import Decimal from typing import List, Optional from pydantic import BaseModel, Field, field_validator from enum import Enum # ============================================================================ # ENUMS # ============================================================================ class TicketStatus(str, Enum): """Ticket status workflow""" OPEN = "open" IN_PROGRESS = "in_progress" WAITING_CUSTOMER = "waiting_customer" WAITING_INTERNAL = "waiting_internal" RESOLVED = "resolved" CLOSED = "closed" class TicketPriority(str, Enum): """Ticket prioritet""" LOW = "low" NORMAL = "normal" HIGH = "high" URGENT = "urgent" class TicketSource(str, Enum): """Hvor ticket blev oprettet fra""" EMAIL = "email" PORTAL = "portal" PHONE = "phone" MANUAL = "manual" API = "api" class WorkType(str, Enum): """Type af arbejde""" SUPPORT = "support" DEVELOPMENT = "development" TROUBLESHOOTING = "troubleshooting" ON_SITE = "on_site" MEETING = "meeting" OTHER = "other" class BillingMethod(str, Enum): """Afregningsmetode""" PREPAID_CARD = "prepaid_card" INVOICE = "invoice" INTERNAL = "internal" WARRANTY = "warranty" class WorklogStatus(str, Enum): """Worklog status""" DRAFT = "draft" BILLABLE = "billable" BILLED = "billed" NON_BILLABLE = "non_billable" class PrepaidCardStatus(str, Enum): """Klippekort status""" ACTIVE = "active" DEPLETED = "depleted" EXPIRED = "expired" CANCELLED = "cancelled" class TransactionType(str, Enum): """Klippekort transaction type""" PURCHASE = "purchase" TOP_UP = "top_up" USAGE = "usage" REFUND = "refund" EXPIRATION = "expiration" CANCELLATION = "cancellation" # ============================================================================ # TICKET MODELS # ============================================================================ class TTicketBase(BaseModel): """Base model for ticket""" subject: str = Field(..., min_length=1, max_length=500) description: Optional[str] = None status: TicketStatus = Field(default=TicketStatus.OPEN) priority: TicketPriority = Field(default=TicketPriority.NORMAL) category: Optional[str] = Field(None, max_length=100) customer_id: Optional[int] = Field(None, description="Reference til customers.id") contact_id: Optional[int] = Field(None, description="Reference til contacts.id") assigned_to_user_id: Optional[int] = Field(None, description="Reference til users.user_id") source: TicketSource = Field(default=TicketSource.MANUAL) tags: Optional[List[str]] = Field(default_factory=list) custom_fields: Optional[dict] = Field(default_factory=dict) class TTicketCreate(TTicketBase): """Model for creating a ticket""" ticket_number: Optional[str] = Field(None, description="Auto-generated hvis ikke angivet") created_by_user_id: Optional[int] = Field(None, description="User der opretter ticket") class TTicketUpdate(BaseModel): """Model for updating a ticket (partial updates)""" subject: Optional[str] = Field(None, min_length=1, max_length=500) description: Optional[str] = None status: Optional[TicketStatus] = None priority: Optional[TicketPriority] = None category: Optional[str] = Field(None, max_length=100) customer_id: Optional[int] = None contact_id: Optional[int] = None assigned_to_user_id: Optional[int] = None tags: Optional[List[str]] = None custom_fields: Optional[dict] = None class TTicket(TTicketBase): """Full ticket model with DB fields""" id: int ticket_number: str created_by_user_id: Optional[int] = None created_at: datetime updated_at: Optional[datetime] = None first_response_at: Optional[datetime] = None resolved_at: Optional[datetime] = None closed_at: Optional[datetime] = None class Config: from_attributes = True class TTicketWithStats(TTicket): """Ticket med statistik (fra view)""" comment_count: Optional[int] = 0 attachment_count: Optional[int] = 0 pending_hours: Optional[Decimal] = None billed_hours: Optional[Decimal] = None last_comment_at: Optional[datetime] = None age_hours: Optional[float] = None class Config: from_attributes = True # ============================================================================ # COMMENT MODELS # ============================================================================ class TTicketCommentBase(BaseModel): """Base model for comment""" comment_text: str = Field(..., min_length=1) is_internal: bool = Field(default=False, description="Intern note (ikke kunde-synlig)") class TTicketCommentCreate(TTicketCommentBase): """Model for creating a comment""" ticket_id: int = Field(..., gt=0) user_id: Optional[int] = Field(None, description="User der opretter kommentar") class TTicketComment(TTicketCommentBase): """Full comment model with DB fields""" id: int ticket_id: int user_id: Optional[int] = None created_at: datetime updated_at: Optional[datetime] = None class Config: from_attributes = True # ============================================================================ # ATTACHMENT MODELS # ============================================================================ class TTicketAttachmentBase(BaseModel): """Base model for attachment""" file_name: str = Field(..., min_length=1, max_length=255) file_path: str = Field(..., min_length=1, max_length=500) file_size: Optional[int] = Field(None, ge=0) mime_type: Optional[str] = Field(None, max_length=100) class TTicketAttachmentCreate(TTicketAttachmentBase): """Model for creating an attachment""" ticket_id: int = Field(..., gt=0) uploaded_by_user_id: Optional[int] = None class TTicketAttachment(TTicketAttachmentBase): """Full attachment model with DB fields""" id: int ticket_id: int uploaded_by_user_id: Optional[int] = None created_at: datetime class Config: from_attributes = True # ============================================================================ # WORKLOG MODELS # ============================================================================ class TTicketWorklogBase(BaseModel): """Base model for worklog entry""" work_date: date = Field(..., description="Dato arbejdet blev udført") hours: Decimal = Field(..., gt=0, le=24, description="Timer brugt") work_type: WorkType = Field(default=WorkType.SUPPORT) description: Optional[str] = None billing_method: BillingMethod = Field(default=BillingMethod.INVOICE) @field_validator('hours') @classmethod def validate_hours(cls, v): """Validate hours is reasonable (max 24 hours per day)""" if v <= 0: raise ValueError('Hours must be greater than 0') if v > 24: raise ValueError('Hours cannot exceed 24 per entry') return v class TTicketWorklogCreate(TTicketWorklogBase): """Model for creating a worklog entry""" ticket_id: int = Field(..., gt=0) user_id: Optional[int] = Field(None, description="User der opretter worklog") prepaid_card_id: Optional[int] = Field(None, description="Klippekort ID hvis billing_method=prepaid_card") class TTicketWorklogUpdate(BaseModel): """Model for updating a worklog entry (partial updates)""" work_date: Optional[date] = None hours: Optional[Decimal] = Field(None, gt=0, le=24) work_type: Optional[WorkType] = None description: Optional[str] = None billing_method: Optional[BillingMethod] = None status: Optional[WorklogStatus] = None prepaid_card_id: Optional[int] = None class TTicketWorklog(TTicketWorklogBase): """Full worklog model with DB fields""" id: int ticket_id: int user_id: Optional[int] = None status: WorklogStatus = Field(default=WorklogStatus.DRAFT) prepaid_card_id: Optional[int] = None created_at: datetime updated_at: Optional[datetime] = None billed_at: Optional[datetime] = None class Config: from_attributes = True class TTicketWorklogWithDetails(TTicketWorklog): """Worklog med ticket detaljer (til review UI)""" ticket_number: Optional[str] = None ticket_subject: Optional[str] = None customer_id: Optional[int] = None ticket_status: Optional[str] = None has_sufficient_balance: Optional[bool] = True class Config: from_attributes = True # ============================================================================ # PREPAID CARD (KLIPPEKORT) MODELS # ============================================================================ class TPrepaidCardBase(BaseModel): """Base model for prepaid card""" customer_id: int = Field(..., gt=0, description="Reference til customers.id") purchased_hours: Decimal = Field(..., gt=0, description="Timer købt") price_per_hour: Decimal = Field(..., gt=0, description="DKK pr. time") total_amount: Decimal = Field(..., gt=0, description="Total pris DKK") expires_at: Optional[datetime] = Field(None, description="Udløbsdato (NULL = ingen udløb)") notes: Optional[str] = None @field_validator('total_amount') @classmethod def validate_total(cls, v, info): """Validate that total_amount matches purchased_hours * price_per_hour""" # Note: This validator runs after all fields are set in Pydantic v2 # If we need cross-field validation, use model_validator instead if v <= 0: raise ValueError('Total amount must be greater than 0') return v class TPrepaidCardCreate(TPrepaidCardBase): """Model for creating a prepaid card""" card_number: Optional[str] = Field(None, description="Auto-generated hvis ikke angivet") created_by_user_id: Optional[int] = Field(None, description="User der opretter kort") economic_invoice_number: Optional[str] = Field(None, max_length=50) economic_product_number: Optional[str] = Field(None, max_length=50) class TPrepaidCardUpdate(BaseModel): """Model for updating a prepaid card (partial updates)""" status: Optional[PrepaidCardStatus] = None expires_at: Optional[datetime] = None notes: Optional[str] = None economic_invoice_number: Optional[str] = Field(None, max_length=50) economic_product_number: Optional[str] = Field(None, max_length=50) class TPrepaidCard(TPrepaidCardBase): """Full prepaid card model with DB fields""" id: int card_number: str used_hours: Decimal = Field(default=Decimal('0')) remaining_hours: Decimal # Generated column status: PrepaidCardStatus = Field(default=PrepaidCardStatus.ACTIVE) purchased_at: datetime economic_invoice_number: Optional[str] = None economic_product_number: Optional[str] = None created_by_user_id: Optional[int] = None created_at: datetime updated_at: Optional[datetime] = None class Config: from_attributes = True class TPrepaidCardWithStats(TPrepaidCard): """Prepaid card med statistik (fra view)""" usage_count: Optional[int] = 0 total_hours_used: Optional[Decimal] = None billed_usage_count: Optional[int] = 0 class Config: from_attributes = True # ============================================================================ # PREPAID TRANSACTION MODELS # ============================================================================ class TPrepaidTransactionBase(BaseModel): """Base model for prepaid transaction""" transaction_type: TransactionType hours: Decimal = Field(..., description="Timer (positiv=tilføj, negativ=træk)") description: Optional[str] = None class TPrepaidTransactionCreate(TPrepaidTransactionBase): """Model for creating a transaction""" card_id: int = Field(..., gt=0) worklog_id: Optional[int] = Field(None, description="Reference til worklog (NULL for køb/top-up)") balance_after: Decimal = Field(..., ge=0, description="Saldo efter transaction") created_by_user_id: Optional[int] = None class TPrepaidTransaction(TPrepaidTransactionBase): """Full transaction model with DB fields""" id: int card_id: int worklog_id: Optional[int] = None balance_after: Decimal created_at: datetime created_by_user_id: Optional[int] = None class Config: from_attributes = True # ============================================================================ # EMAIL INTEGRATION MODELS # ============================================================================ class TTicketEmailLogBase(BaseModel): """Base model for email log""" action: str = Field(..., max_length=50) class TTicketEmailLogCreate(TTicketEmailLogBase): """Model for creating an email log entry""" ticket_id: Optional[int] = None email_id: Optional[int] = Field(None, description="Reference til email_messages.id") email_message_id: Optional[str] = Field(None, max_length=500, description="Email Message-ID header") class TTicketEmailLog(TTicketEmailLogBase): """Full email log model with DB fields""" id: int ticket_id: Optional[int] = None email_id: Optional[int] = None email_message_id: Optional[str] = None created_at: datetime class Config: from_attributes = True # ============================================================================ # AUDIT LOG MODELS # ============================================================================ class TTicketAuditLogBase(BaseModel): """Base model for audit log""" entity_type: str = Field(..., max_length=50) action: str = Field(..., max_length=50) old_value: Optional[str] = None new_value: Optional[str] = None details: Optional[dict] = Field(default_factory=dict) class TTicketAuditLogCreate(TTicketAuditLogBase): """Model for creating an audit log entry""" ticket_id: Optional[int] = None entity_id: Optional[int] = None user_id: Optional[int] = None class TTicketAuditLog(TTicketAuditLogBase): """Full audit log model with DB fields""" id: int ticket_id: Optional[int] = None entity_id: Optional[int] = None user_id: Optional[int] = None created_at: datetime class Config: from_attributes = True # ============================================================================ # RESPONSE MODELS (for API responses) # ============================================================================ class TicketListResponse(BaseModel): """Response model for ticket lists""" tickets: List[TTicketWithStats] total: int page: int = 1 page_size: int = 50 class WorklogReviewResponse(BaseModel): """Response model for worklog review page""" worklogs: List[TTicketWorklogWithDetails] total: int total_hours: Decimal total_billable_hours: Decimal class PrepaidCardBalanceResponse(BaseModel): """Response model for prepaid card balance check""" card: TPrepaidCardWithStats can_deduct: bool required_hours: Optional[Decimal] = None message: Optional[str] = None # ============================================================================ # REQUEST MODELS (for specific actions) # ============================================================================ class TicketStatusUpdateRequest(BaseModel): """Request model for updating ticket status""" status: TicketStatus note: Optional[str] = Field(None, description="Note til audit log") class WorklogBillingRequest(BaseModel): """Request model for marking worklogs as billable""" worklog_ids: List[int] = Field(..., min_length=1) note: Optional[str] = Field(None, description="Note til audit log") class PrepaidCardTopUpRequest(BaseModel): """Request model for topping up prepaid card""" hours: Decimal = Field(..., gt=0, description="Timer at tilføje") note: Optional[str] = Field(None, description="Beskrivelse af top-up") class PrepaidCardDeductRequest(BaseModel): """Request model for deducting hours from prepaid card""" worklog_id: int = Field(..., gt=0, description="Worklog ID der skal trækkes fra kort") hours: Decimal = Field(..., gt=0, description="Timer at trække")