498 lines
16 KiB
Python
498 lines
16 KiB
Python
|
|
"""
|
||
|
|
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")
|