bmc_hub/app/ticket/backend/models.py

797 lines
25 KiB
Python
Raw Normal View History

"""
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"
UNKNOWN = "unknown"
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"
class TicketType(str, Enum):
"""Ticket kategorisering"""
INCIDENT = "incident" # Fejl der skal fixes (Høj urgens)
REQUEST = "request" # Bestilling / Ønske (Planlægges)
PROBLEM = "problem" # Root cause (Fejlfinding)
PROJECT = "project" # Større projektarbejde
# ============================================================================
# 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)
ticket_type: TicketType = Field(default=TicketType.INCIDENT, description="Type af sag")
internal_note: Optional[str] = Field(default=None, description="Intern note der vises prominent til medarbejdere")
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)
is_internal: bool = Field(default=False, description="Skjul for kunde (vises ikke på faktura/portal)")
@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
is_internal: Optional[bool] = 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