- Created migration scripts for AnyDesk sessions and hardware assets. - Implemented apply_migration_115.py to execute migration for AnyDesk sessions. - Added set_customer_wiki_slugs.py script to update customer wiki slugs based on a predefined folder list. - Developed run_migration.py to apply AnyDesk migration schema. - Added tests for Service Contract Wizard to ensure functionality and dry-run mode.
528 lines
18 KiB
Python
528 lines
18 KiB
Python
"""
|
|
Pydantic Models for Time Tracking Module
|
|
=========================================
|
|
|
|
Alle models repræsenterer data fra tmodule_* tabeller.
|
|
Ingen afhængigheder til eksisterende Hub-models.
|
|
"""
|
|
|
|
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
from typing import List, Optional
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
|
|
# ============================================================================
|
|
# KUNDE MODELS
|
|
# ============================================================================
|
|
|
|
class TModuleCustomerBase(BaseModel):
|
|
"""Base model for customer"""
|
|
vtiger_id: str = Field(..., description="vTiger Account ID")
|
|
name: str = Field(..., min_length=1, max_length=255)
|
|
email: Optional[str] = Field(None, max_length=255)
|
|
hourly_rate: Optional[Decimal] = Field(None, ge=0, description="DKK pr. time")
|
|
hub_customer_id: Optional[int] = Field(None, description="Reference til customers.id (read-only)")
|
|
|
|
|
|
class TModuleCustomerCreate(TModuleCustomerBase):
|
|
"""Model for creating a customer"""
|
|
vtiger_data: Optional[dict] = Field(default_factory=dict)
|
|
|
|
|
|
class TModuleBulkRateUpdate(BaseModel):
|
|
"""Model for bulk customer hourly rate update"""
|
|
customer_ids: List[int] = Field(..., min_length=1, description="List of customer IDs to update")
|
|
hourly_rate: Decimal = Field(..., ge=0, description="New hourly rate in DKK")
|
|
|
|
|
|
class TModuleCustomer(TModuleCustomerBase):
|
|
"""Full customer model with DB fields"""
|
|
id: int
|
|
sync_hash: Optional[str] = None
|
|
created_at: datetime
|
|
updated_at: Optional[datetime] = None
|
|
last_synced_at: datetime
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
# ============================================================================
|
|
# CASE MODELS
|
|
# ============================================================================
|
|
|
|
class TModuleCaseBase(BaseModel):
|
|
"""Base model for case/project"""
|
|
vtiger_id: str = Field(..., description="vTiger HelpDesk/ProjectTask ID")
|
|
customer_id: int = Field(..., gt=0)
|
|
title: str = Field(..., min_length=1, max_length=500)
|
|
description: Optional[str] = None
|
|
status: Optional[str] = Field(None, max_length=50)
|
|
priority: Optional[str] = Field(None, max_length=50)
|
|
module_type: Optional[str] = Field(None, max_length=50, description="HelpDesk, ProjectTask, etc.")
|
|
|
|
|
|
class TModuleCaseCreate(TModuleCaseBase):
|
|
"""Model for creating a case"""
|
|
vtiger_data: Optional[dict] = Field(default_factory=dict)
|
|
|
|
|
|
class TModuleCase(TModuleCaseBase):
|
|
"""Full case model with DB fields"""
|
|
id: int
|
|
sync_hash: Optional[str] = None
|
|
created_at: datetime
|
|
updated_at: Optional[datetime] = None
|
|
last_synced_at: datetime
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
# ============================================================================
|
|
# TIDSREGISTRERING MODELS
|
|
# ============================================================================
|
|
|
|
class TModuleTimeBase(BaseModel):
|
|
"""Base model for time entry"""
|
|
vtiger_id: Optional[str] = Field(None, description="vTiger ModComments ID (Optional)")
|
|
case_id: Optional[int] = Field(None, gt=0, description="vTiger Case ID (Optional)")
|
|
sag_id: Optional[int] = Field(None, gt=0, description="Hub Sag ID (Optional)")
|
|
solution_id: Optional[int] = Field(None, gt=0, description="Hub Solution ID (Optional)")
|
|
customer_id: int = Field(..., gt=0)
|
|
description: Optional[str] = None
|
|
original_hours: Decimal = Field(..., gt=0, description="Original timer")
|
|
worked_date: Optional[date] = None
|
|
user_name: Optional[str] = Field(None, max_length=255, description="Bruger")
|
|
|
|
|
|
class TModuleTimeCreate(TModuleTimeBase):
|
|
"""Model for creating a time entry"""
|
|
vtiger_data: Optional[dict] = Field(default_factory=dict)
|
|
|
|
|
|
class TModuleTimeUpdate(BaseModel):
|
|
"""Model for updating time entry (godkendelse)"""
|
|
approved_hours: Optional[Decimal] = Field(None, gt=0)
|
|
rounded_to: Optional[Decimal] = Field(None, ge=0.25, description="Afrundingsinterval")
|
|
approval_note: Optional[str] = None
|
|
billable: Optional[bool] = None
|
|
is_travel: Optional[bool] = None
|
|
status: Optional[str] = Field(None, pattern="^(pending|approved|rejected|billed)$")
|
|
|
|
|
|
class TModuleTimeApproval(BaseModel):
|
|
"""Model for wizard approval action"""
|
|
time_id: int = Field(..., gt=0)
|
|
approved_hours: Decimal = Field(..., gt=0, description="Timer efter godkendelse")
|
|
rounded_to: Optional[Decimal] = Field(None, description="Afrundingsinterval brugt (0.25, 0.5, 1.0)")
|
|
approval_note: Optional[str] = Field(None, description="Brugerens note")
|
|
billable: bool = Field(True, description="Skal faktureres?")
|
|
is_travel: bool = Field(False, description="Indeholder kørsel?")
|
|
|
|
@field_validator('approved_hours')
|
|
@classmethod
|
|
def validate_approved_hours(cls, v: Decimal) -> Decimal:
|
|
if v <= 0:
|
|
raise ValueError("Approved hours must be positive")
|
|
# Max 24 timer pr. dag er rimeligt
|
|
if v > 24:
|
|
raise ValueError("Approved hours cannot exceed 24 per entry")
|
|
return v
|
|
|
|
|
|
class TModuleTime(TModuleTimeBase):
|
|
"""Full time entry model with DB fields"""
|
|
id: int
|
|
status: str = Field("pending", pattern="^(pending|approved|rejected|billed)$")
|
|
approved_hours: Optional[Decimal] = None
|
|
rounded_to: Optional[Decimal] = None
|
|
approval_note: Optional[str] = None
|
|
billable: bool = True
|
|
is_travel: bool = False
|
|
approved_at: Optional[datetime] = None
|
|
approved_by: Optional[int] = None
|
|
billed_via_thehub_id: Optional[int] = Field(None, description="Hub order ID this time was billed through")
|
|
sync_hash: Optional[str] = None
|
|
created_at: datetime
|
|
updated_at: Optional[datetime] = None
|
|
last_synced_at: datetime
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class TModuleTimeWithContext(TModuleTime):
|
|
"""Time entry with case and customer context (for wizard)"""
|
|
case_title: str
|
|
case_description: Optional[str] = None
|
|
case_status: Optional[str] = None
|
|
case_vtiger_id: Optional[str] = None
|
|
case_vtiger_data: Optional[dict] = None
|
|
customer_name: str
|
|
customer_rate: Optional[Decimal] = None
|
|
contact_name: Optional[str] = None
|
|
contact_company: Optional[str] = None
|
|
|
|
|
|
# ============================================================================
|
|
# ORDRE MODELS
|
|
# ============================================================================
|
|
|
|
class TModuleOrderLineBase(BaseModel):
|
|
"""Base model for order line"""
|
|
line_number: int = Field(..., gt=0)
|
|
description: str = Field(..., min_length=1)
|
|
quantity: Decimal = Field(..., gt=0, description="Timer")
|
|
unit_price: Decimal = Field(..., ge=0, description="DKK pr. time")
|
|
line_total: Decimal = Field(..., ge=0, description="Total for linje")
|
|
case_id: Optional[int] = Field(None, gt=0)
|
|
sag_id: Optional[int] = Field(None, gt=0)
|
|
time_entry_ids: List[int] = Field(default_factory=list)
|
|
product_number: Optional[str] = Field(None, max_length=50)
|
|
case_contact: Optional[str] = Field(None, max_length=255)
|
|
time_date: Optional[date] = None
|
|
is_travel: bool = False
|
|
account_number: Optional[str] = Field(None, max_length=50)
|
|
|
|
|
|
class TModuleOrderLineCreate(TModuleOrderLineBase):
|
|
"""Model for creating order line"""
|
|
pass
|
|
|
|
|
|
class TModuleOrderLine(TModuleOrderLineBase):
|
|
"""Full order line model"""
|
|
id: int
|
|
order_id: int
|
|
created_at: datetime
|
|
case_contact: Optional[str] = None # Contact name from case
|
|
time_date: Optional[date] = None # Date from time entries
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class TModuleOrderBase(BaseModel):
|
|
"""Base model for order"""
|
|
customer_id: int = Field(..., gt=0)
|
|
hub_customer_id: Optional[int] = Field(None, gt=0)
|
|
order_date: date = Field(default_factory=date.today)
|
|
total_hours: Decimal = Field(0, ge=0)
|
|
hourly_rate: Decimal = Field(..., gt=0, description="DKK pr. time")
|
|
subtotal: Decimal = Field(0, ge=0)
|
|
vat_rate: Decimal = Field(Decimal("25.00"), ge=0, le=100, description="Moms %")
|
|
vat_amount: Decimal = Field(0, ge=0)
|
|
total_amount: Decimal = Field(0, ge=0)
|
|
notes: Optional[str] = None
|
|
|
|
|
|
class TModuleOrderCreate(TModuleOrderBase):
|
|
"""Model for creating order"""
|
|
lines: List[TModuleOrderLineCreate] = Field(default_factory=list)
|
|
|
|
|
|
class TModuleOrderUpdate(BaseModel):
|
|
"""Model for updating order"""
|
|
status: Optional[str] = Field(None, pattern="^(draft|exported|posted|sent|cancelled)$")
|
|
notes: Optional[str] = None
|
|
|
|
|
|
class TModuleOrder(TModuleOrderBase):
|
|
"""Full order model with DB fields"""
|
|
id: int
|
|
order_number: Optional[str] = None
|
|
customer_name: Optional[str] = None # From JOIN med customers table
|
|
status: str = Field("draft", pattern="^(draft|exported|posted|sent|cancelled)$")
|
|
economic_draft_id: Optional[int] = None
|
|
economic_order_number: Optional[str] = None
|
|
exported_at: Optional[datetime] = None
|
|
exported_by: Optional[int] = None
|
|
created_at: datetime
|
|
updated_at: Optional[datetime] = None
|
|
created_by: Optional[int] = None
|
|
line_count: Optional[int] = Field(None, ge=0)
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class TModuleOrderWithLines(TModuleOrder):
|
|
"""Order with lines included"""
|
|
lines: List[TModuleOrderLine] = Field(default_factory=list)
|
|
|
|
|
|
class TModuleOrderDetails(BaseModel):
|
|
"""Order details from view (aggregated data)"""
|
|
order_id: int
|
|
order_number: Optional[str] = None
|
|
order_date: date
|
|
order_status: str
|
|
total_hours: Decimal
|
|
total_amount: Decimal
|
|
economic_draft_id: Optional[int] = None
|
|
customer_name: str
|
|
customer_vtiger_id: str
|
|
line_count: int
|
|
time_entry_count: int
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
# ============================================================================
|
|
# STATISTIK & WIZARD MODELS
|
|
# ============================================================================
|
|
|
|
class TModuleApprovalStats(BaseModel):
|
|
"""Approval statistics per customer (from view)"""
|
|
customer_id: int
|
|
hub_customer_id: Optional[int] = None
|
|
customer_name: str
|
|
customer_vtiger_id: str
|
|
uses_time_card: bool = False
|
|
total_entries: int
|
|
pending_count: int
|
|
approved_count: int
|
|
rejected_count: int
|
|
billed_count: int
|
|
total_original_hours: Optional[Decimal] = None
|
|
total_approved_hours: Optional[Decimal] = None
|
|
latest_work_date: Optional[date] = None
|
|
last_sync: Optional[datetime] = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class TModuleWizardProgress(BaseModel):
|
|
"""Progress for wizard flow"""
|
|
customer_id: int
|
|
customer_name: str
|
|
total_entries: int
|
|
approved_entries: int
|
|
pending_entries: int
|
|
rejected_entries: int
|
|
current_case_id: Optional[int] = None
|
|
current_case_title: Optional[str] = None
|
|
progress_percent: float = Field(0, ge=0, le=100)
|
|
|
|
@field_validator('progress_percent', mode='before')
|
|
@classmethod
|
|
def calculate_progress(cls, v, info):
|
|
"""Auto-calculate progress if not provided"""
|
|
if v == 0 and 'total_entries' in info.data and info.data['total_entries'] > 0:
|
|
approved = info.data.get('approved_entries', 0)
|
|
rejected = info.data.get('rejected_entries', 0)
|
|
total = info.data['total_entries']
|
|
return round(((approved + rejected) / total) * 100, 2)
|
|
return v
|
|
|
|
|
|
class TModuleWizardEditRequest(BaseModel):
|
|
"""Request model for editing a time entry via Wizard"""
|
|
description: Optional[str] = None
|
|
original_hours: Optional[Decimal] = None # Editing raw hours before approval
|
|
billing_method: Optional[str] = None # For Hub Worklogs (invoice, prepaid, etc)
|
|
billable: Optional[bool] = None # For Module Times
|
|
|
|
class TModuleWizardNextEntry(BaseModel):
|
|
"""Next entry for wizard approval"""
|
|
has_next: bool
|
|
time_entry: Optional[TModuleTimeWithContext] = None
|
|
progress: Optional[TModuleWizardProgress] = None
|
|
|
|
|
|
# ============================================================================
|
|
# SYNC & AUDIT MODELS
|
|
# ============================================================================
|
|
|
|
class TModuleSyncStats(BaseModel):
|
|
"""Statistics from sync operation"""
|
|
customers_imported: int = 0
|
|
customers_updated: int = 0
|
|
customers_skipped: int = 0
|
|
cases_imported: int = 0
|
|
cases_updated: int = 0
|
|
cases_skipped: int = 0
|
|
times_imported: int = 0
|
|
times_updated: int = 0
|
|
times_skipped: int = 0
|
|
errors: int = 0
|
|
duration_seconds: float = 0.0
|
|
started_at: datetime
|
|
completed_at: Optional[datetime] = None
|
|
|
|
|
|
class TModuleSyncLogCreate(BaseModel):
|
|
"""Model for creating audit log entry"""
|
|
event_type: str = Field(..., max_length=50)
|
|
entity_type: Optional[str] = Field(None, max_length=50)
|
|
entity_id: Optional[int] = None
|
|
user_id: Optional[int] = None
|
|
details: Optional[dict] = Field(default_factory=dict)
|
|
ip_address: Optional[str] = None
|
|
user_agent: Optional[str] = None
|
|
|
|
|
|
class TModuleSyncLog(TModuleSyncLogCreate):
|
|
"""Full audit log model"""
|
|
id: int
|
|
created_at: datetime
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
# ============================================================================
|
|
# e-conomic EXPORT MODELS
|
|
# ============================================================================
|
|
|
|
class TModuleEconomicExportRequest(BaseModel):
|
|
"""Request for exporting order to e-conomic"""
|
|
order_id: int = Field(..., gt=0)
|
|
force: bool = Field(False, description="Force export even if already exported")
|
|
|
|
|
|
class TModuleEconomicExportResult(BaseModel):
|
|
"""Result of e-conomic export"""
|
|
success: bool
|
|
dry_run: bool = False
|
|
order_id: int
|
|
economic_draft_id: Optional[int] = None
|
|
economic_order_number: Optional[str] = None
|
|
message: str
|
|
details: Optional[dict] = None
|
|
|
|
|
|
# ============================================================================
|
|
# MODULE METADATA
|
|
# ============================================================================
|
|
|
|
class TModuleMetadata(BaseModel):
|
|
"""Module metadata"""
|
|
id: int
|
|
module_version: str
|
|
installed_at: datetime
|
|
installed_by: Optional[int] = None
|
|
last_sync_at: Optional[datetime] = None
|
|
is_active: bool
|
|
settings: dict = Field(default_factory=dict)
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class TModuleUninstallRequest(BaseModel):
|
|
"""Request for module uninstall"""
|
|
confirm: bool = Field(..., description="Must be True to proceed")
|
|
delete_all_data: bool = Field(..., description="Confirm deletion of ALL module data")
|
|
|
|
@field_validator('confirm')
|
|
@classmethod
|
|
def validate_confirm(cls, v: bool) -> bool:
|
|
if not v:
|
|
raise ValueError("You must confirm uninstall by setting confirm=True")
|
|
return v
|
|
|
|
@field_validator('delete_all_data')
|
|
@classmethod
|
|
def validate_delete(cls, v: bool) -> bool:
|
|
if not v:
|
|
raise ValueError("You must confirm data deletion by setting delete_all_data=True")
|
|
return v
|
|
|
|
|
|
class TModuleUninstallResult(BaseModel):
|
|
"""Result of module uninstall"""
|
|
success: bool
|
|
message: str
|
|
tables_dropped: List[str] = Field(default_factory=list)
|
|
views_dropped: List[str] = Field(default_factory=list)
|
|
functions_dropped: List[str] = Field(default_factory=list)
|
|
rows_deleted: int = 0
|
|
|
|
|
|
# ============================================================================
|
|
# SERVICE CONTRACT MIGRATION WIZARD MODELS
|
|
# ============================================================================
|
|
|
|
class ServiceContractBase(BaseModel):
|
|
"""Base service contract model"""
|
|
id: str = Field(..., description="vTiger service contract ID")
|
|
contract_number: str
|
|
subject: str
|
|
account_id: str = Field(..., description="vTiger account ID")
|
|
|
|
|
|
class ServiceContractItem(BaseModel):
|
|
"""Single item (case or timelog) to process in wizard"""
|
|
type: str = Field(..., description="'case' or 'timelog'")
|
|
id: str = Field(..., description="vTiger ID")
|
|
title: str = Field(..., description="Display title")
|
|
description: Optional[str] = None
|
|
hours: Optional[Decimal] = None # For timelogs only
|
|
work_date: Optional[str] = None # For timelogs only
|
|
priority: Optional[str] = None # For cases only
|
|
status: Optional[str] = None
|
|
raw_data: dict = Field(default_factory=dict)
|
|
|
|
|
|
class ServiceContractWizardData(BaseModel):
|
|
"""Complete contract data for wizard"""
|
|
contract_id: str
|
|
contract_number: str
|
|
subject: str
|
|
account_id: str
|
|
account_name: Optional[str] = None
|
|
customer_id: Optional[int] = None
|
|
cases: List[dict] = Field(default_factory=list)
|
|
timelogs: List[dict] = Field(default_factory=list)
|
|
available_cards: List[dict] = Field(default_factory=list)
|
|
total_items: int
|
|
|
|
|
|
class ServiceContractWizardAction(BaseModel):
|
|
"""Action result from a wizard step"""
|
|
type: str = Field(..., description="'archive' or 'transfer'")
|
|
item_id: str = Field(..., description="vTiger item ID")
|
|
title: str
|
|
success: bool
|
|
message: str
|
|
dry_run: bool
|
|
result_id: Optional[int] = None # ID of archived/transferred item in Hub
|
|
timestamp: datetime = Field(default_factory=datetime.now)
|
|
|
|
|
|
class ServiceContractWizardSummary(BaseModel):
|
|
"""Summary of wizard execution"""
|
|
contract_id: str
|
|
contract_number: str
|
|
subject: str
|
|
dry_run: bool
|
|
total_items_processed: int
|
|
cases_archived: int
|
|
timelogs_transferred: int
|
|
failed_items: int
|
|
status: str = Field(..., description="'completed' or 'completed_with_errors'")
|
|
timestamp: datetime
|
|
|
|
|
|
class TimologTransferRequest(BaseModel):
|
|
"""Request to transfer single timelog to klippekort"""
|
|
timelog_id: str = Field(..., description="vTiger timelog ID")
|
|
card_id: int = Field(..., description="Hub klippekort card ID")
|
|
customer_id: int = Field(..., description="Hub customer ID")
|
|
contract_id: str = Field(..., description="Service contract ID for reference")
|
|
dry_run: bool = Field(default=False)
|
|
|
|
|
|
class TimologTransferResult(BaseModel):
|
|
"""Result of timelog transfer"""
|
|
success: bool
|
|
message: str
|
|
transaction_id: Optional[int] = None
|
|
new_card_balance: Optional[Decimal] = None
|
|
dry_run: bool
|