- Added migration 025 for the Ticket System, creating tables for tickets, comments, attachments, worklogs, prepaid cards, and audit logs. - Introduced migration 026 to add ticket-related permissions to the auth system and assign them to user groups. - Developed a test suite for the Ticket Module, validating database schema, ticket number generation, prepaid card constraints, service logic, worklog creation, audit logging, and views.
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")
|