""" 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") # ============================================================================ # TICKET RELATIONS MODELS (Migration 026) # ============================================================================ class TicketRelationType(str, Enum): """Ticket relation types""" MERGED_INTO = "merged_into" SPLIT_FROM = "split_from" PARENT_OF = "parent_of" CHILD_OF = "child_of" RELATED_TO = "related_to" class TTicketRelationBase(BaseModel): """Base model for ticket relation""" ticket_id: int related_ticket_id: int relation_type: TicketRelationType reason: Optional[str] = None class TTicketRelationCreate(TTicketRelationBase): """Create ticket relation""" pass class TTicketRelation(TTicketRelationBase): """Full ticket relation model""" id: int created_by_user_id: Optional[int] = None created_at: datetime class Config: from_attributes = True # ============================================================================ # CALENDAR EVENTS MODELS # ============================================================================ class CalendarEventType(str, Enum): """Calendar event types""" APPOINTMENT = "appointment" DEADLINE = "deadline" MILESTONE = "milestone" REMINDER = "reminder" FOLLOW_UP = "follow_up" class CalendarEventStatus(str, Enum): """Calendar event status""" PENDING = "pending" CONFIRMED = "confirmed" COMPLETED = "completed" CANCELLED = "cancelled" class TTicketCalendarEventBase(BaseModel): """Base model for calendar event""" ticket_id: int title: str = Field(..., min_length=1, max_length=200) description: Optional[str] = None event_type: CalendarEventType = Field(default=CalendarEventType.APPOINTMENT) event_date: date event_time: Optional[str] = None duration_minutes: Optional[int] = None all_day: bool = False status: CalendarEventStatus = Field(default=CalendarEventStatus.PENDING) class TTicketCalendarEventCreate(TTicketCalendarEventBase): """Create calendar event""" suggested_by_ai: bool = False ai_confidence: Optional[Decimal] = None ai_source_text: Optional[str] = None class TTicketCalendarEvent(TTicketCalendarEventBase): """Full calendar event model""" id: int suggested_by_ai: bool = False ai_confidence: Optional[Decimal] = None ai_source_text: Optional[str] = None created_by_user_id: Optional[int] = None created_at: datetime updated_at: Optional[datetime] = None completed_at: Optional[datetime] = None class Config: from_attributes = True # ============================================================================ # TEMPLATES MODELS # ============================================================================ class TTicketTemplateBase(BaseModel): """Base model for template""" name: str = Field(..., min_length=1, max_length=200) description: Optional[str] = None category: Optional[str] = None subject_template: Optional[str] = Field(None, max_length=500) body_template: str = Field(..., min_length=1) available_placeholders: Optional[List[str]] = None default_attachments: Optional[dict] = None is_active: bool = True requires_approval: bool = False class TTicketTemplateCreate(TTicketTemplateBase): """Create template""" pass class TTicketTemplate(TTicketTemplateBase): """Full template model""" id: int created_by_user_id: Optional[int] = None created_at: datetime updated_at: Optional[datetime] = None last_used_at: Optional[datetime] = None usage_count: int = 0 class Config: from_attributes = True class TemplateRenderRequest(BaseModel): """Request to render template with data""" template_id: int ticket_id: int custom_data: Optional[dict] = None class TemplateRenderResponse(BaseModel): """Rendered template""" subject: Optional[str] = None body: str placeholders_used: List[str] # ============================================================================ # AI SUGGESTIONS MODELS # ============================================================================ class AISuggestionType(str, Enum): """AI suggestion types""" CONTACT_UPDATE = "contact_update" NEW_CONTACT = "new_contact" CATEGORY = "category" TAG = "tag" PRIORITY = "priority" DEADLINE = "deadline" CALENDAR_EVENT = "calendar_event" TEMPLATE = "template" MERGE = "merge" RELATED_TICKET = "related_ticket" class AISuggestionStatus(str, Enum): """AI suggestion status""" PENDING = "pending" ACCEPTED = "accepted" REJECTED = "rejected" AUTO_EXPIRED = "auto_expired" class TTicketAISuggestionBase(BaseModel): """Base model for AI suggestion""" ticket_id: int suggestion_type: AISuggestionType suggestion_data: dict # Struktureret data om forslaget confidence: Optional[Decimal] = None reasoning: Optional[str] = None source_text: Optional[str] = None source_comment_id: Optional[int] = None class TTicketAISuggestionCreate(TTicketAISuggestionBase): """Create AI suggestion""" expires_at: Optional[datetime] = None class TTicketAISuggestion(TTicketAISuggestionBase): """Full AI suggestion model""" id: int status: AISuggestionStatus = Field(default=AISuggestionStatus.PENDING) reviewed_by_user_id: Optional[int] = None reviewed_at: Optional[datetime] = None created_at: datetime expires_at: Optional[datetime] = None class Config: from_attributes = True class AISuggestionReviewRequest(BaseModel): """Request to accept/reject AI suggestion""" action: str = Field(..., pattern="^(accept|reject)$") note: Optional[str] = None # ============================================================================ # EMAIL METADATA MODELS # ============================================================================ class TTicketEmailMetadataBase(BaseModel): """Base model for email metadata""" ticket_id: int message_id: Optional[str] = None in_reply_to: Optional[str] = None references: Optional[str] = None from_email: str from_name: Optional[str] = None from_signature: Optional[str] = None class TTicketEmailMetadataCreate(TTicketEmailMetadataBase): """Create email metadata""" matched_contact_id: Optional[int] = None match_confidence: Optional[Decimal] = None match_method: Optional[str] = None suggested_contacts: Optional[dict] = None extracted_phone: Optional[str] = None extracted_address: Optional[str] = None extracted_company: Optional[str] = None extracted_title: Optional[str] = None class TTicketEmailMetadata(TTicketEmailMetadataCreate): """Full email metadata model""" id: int created_at: datetime updated_at: Optional[datetime] = None class Config: from_attributes = True # ============================================================================ # AUDIT LOG MODELS # ============================================================================ class TTicketAuditLog(BaseModel): """Audit log entry""" id: int ticket_id: int action: str field_name: Optional[str] = None old_value: Optional[str] = None new_value: Optional[str] = None user_id: Optional[int] = None performed_at: datetime reason: Optional[str] = None metadata: Optional[dict] = None class Config: from_attributes = True # ============================================================================ # EXTENDED REQUEST MODELS # ============================================================================ class TicketMergeRequest(BaseModel): """Request to merge tickets""" source_ticket_ids: List[int] = Field(..., min_length=1, description="Tickets at lægge sammen") target_ticket_id: int = Field(..., description="Primær ticket der skal beholdes") reason: Optional[str] = Field(None, description="Hvorfor lægges de sammen") class TicketSplitRequest(BaseModel): """Request to split ticket""" source_ticket_id: int = Field(..., description="Ticket at splitte") comment_ids: List[int] = Field(..., min_length=1, description="Kommentarer til ny ticket") new_subject: str = Field(..., min_length=1, description="Emne på ny ticket") new_description: Optional[str] = Field(None, description="Beskrivelse på ny ticket") reason: Optional[str] = Field(None, description="Hvorfor splittes ticketen") class TicketDeadlineUpdateRequest(BaseModel): """Request to update ticket deadline""" deadline: Optional[datetime] = None reason: Optional[str] = None