feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
"""
|
|
|
|
|
|
Ticket Module API Router
|
|
|
|
|
|
=========================
|
|
|
|
|
|
|
|
|
|
|
|
REST API endpoints for ticket system.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import logging
|
2026-02-08 01:45:00 +01:00
|
|
|
|
import hashlib
|
|
|
|
|
|
import json
|
|
|
|
|
|
import re
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
from typing import List, Optional
|
|
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, status
|
|
|
|
|
|
from fastapi.responses import JSONResponse
|
|
|
|
|
|
|
|
|
|
|
|
from app.ticket.backend.ticket_service import TicketService
|
2026-02-08 01:45:00 +01:00
|
|
|
|
from app.services.simplycrm_service import SimplyCRMService
|
|
|
|
|
|
from app.core.config import settings
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
from app.ticket.backend.economic_export import ticket_economic_service
|
|
|
|
|
|
from app.ticket.backend.models import (
|
|
|
|
|
|
TTicket,
|
|
|
|
|
|
TTicketCreate,
|
|
|
|
|
|
TTicketUpdate,
|
|
|
|
|
|
TTicketWithStats,
|
|
|
|
|
|
TTicketComment,
|
|
|
|
|
|
TTicketCommentCreate,
|
|
|
|
|
|
TTicketWorklog,
|
|
|
|
|
|
TTicketWorklogCreate,
|
|
|
|
|
|
TTicketWorklogUpdate,
|
|
|
|
|
|
TTicketWorklogWithDetails,
|
|
|
|
|
|
TicketListResponse,
|
|
|
|
|
|
TicketStatusUpdateRequest,
|
|
|
|
|
|
WorklogReviewResponse,
|
2025-12-16 15:36:11 +01:00
|
|
|
|
WorklogBillingRequest,
|
|
|
|
|
|
# Migration 026 models
|
|
|
|
|
|
TTicketRelation,
|
|
|
|
|
|
TTicketRelationCreate,
|
|
|
|
|
|
TTicketCalendarEvent,
|
|
|
|
|
|
TTicketCalendarEventCreate,
|
|
|
|
|
|
CalendarEventStatus,
|
|
|
|
|
|
TTicketTemplate,
|
|
|
|
|
|
TTicketTemplateCreate,
|
|
|
|
|
|
TemplateRenderRequest,
|
|
|
|
|
|
TemplateRenderResponse,
|
|
|
|
|
|
TTicketAISuggestion,
|
|
|
|
|
|
TTicketAISuggestionCreate,
|
|
|
|
|
|
AISuggestionStatus,
|
|
|
|
|
|
AISuggestionType,
|
|
|
|
|
|
AISuggestionReviewRequest,
|
|
|
|
|
|
TTicketAuditLog,
|
|
|
|
|
|
TicketMergeRequest,
|
|
|
|
|
|
TicketSplitRequest,
|
|
|
|
|
|
TicketDeadlineUpdateRequest
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
)
|
2025-12-16 15:36:11 +01:00
|
|
|
|
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
|
2026-02-08 01:45:00 +01:00
|
|
|
|
from datetime import date, datetime
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-08 01:45:00 +01:00
|
|
|
|
def _get_first_value(data: dict, keys: List[str]) -> Optional[str]:
|
|
|
|
|
|
for key in keys:
|
|
|
|
|
|
value = data.get(key)
|
|
|
|
|
|
if value not in (None, ""):
|
|
|
|
|
|
return value
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_datetime(value: Optional[str]) -> Optional[datetime]:
|
|
|
|
|
|
if not value:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if isinstance(value, datetime):
|
|
|
|
|
|
return value
|
|
|
|
|
|
if isinstance(value, date):
|
|
|
|
|
|
return datetime.combine(value, datetime.min.time())
|
|
|
|
|
|
|
|
|
|
|
|
value_str = str(value).strip()
|
|
|
|
|
|
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
return datetime.strptime(value_str, fmt)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
return datetime.fromisoformat(value_str.replace("Z", "+00:00"))
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_hours(value: Optional[str]) -> Optional[float]:
|
|
|
|
|
|
if value in (None, ""):
|
|
|
|
|
|
return None
|
|
|
|
|
|
if isinstance(value, (int, float)):
|
|
|
|
|
|
return float(value)
|
|
|
|
|
|
|
|
|
|
|
|
value_str = str(value).strip()
|
|
|
|
|
|
if ":" in value_str:
|
|
|
|
|
|
parts = value_str.split(":")
|
|
|
|
|
|
if len(parts) == 2:
|
|
|
|
|
|
try:
|
|
|
|
|
|
hours = float(parts[0])
|
|
|
|
|
|
minutes = float(parts[1])
|
|
|
|
|
|
return hours + minutes / 60.0
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
return float(value_str)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _looks_like_external_id(value: Optional[str]) -> bool:
|
|
|
|
|
|
if not value:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return bool(re.match(r"^\d+x\d+$", str(value)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _calculate_hash(data: dict) -> str:
|
|
|
|
|
|
payload = json.dumps(data, sort_keys=True, default=str).encode("utf-8")
|
|
|
|
|
|
return hashlib.sha256(payload).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _escape_simply_value(value: str) -> str:
|
|
|
|
|
|
return value.replace("'", "''")
|
|
|
|
|
|
|
|
|
|
|
|
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# TICKET ENDPOINTS
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/tickets", response_model=TicketListResponse, tags=["Tickets"])
|
|
|
|
|
|
async def list_tickets(
|
|
|
|
|
|
status: Optional[str] = Query(None, description="Filter by status"),
|
|
|
|
|
|
priority: Optional[str] = Query(None, description="Filter by priority"),
|
|
|
|
|
|
customer_id: Optional[int] = Query(None, description="Filter by customer"),
|
|
|
|
|
|
assigned_to_user_id: Optional[int] = Query(None, description="Filter by assigned user"),
|
|
|
|
|
|
search: Optional[str] = Query(None, description="Search in subject/description"),
|
|
|
|
|
|
limit: int = Query(50, ge=1, le=100, description="Number of results"),
|
|
|
|
|
|
offset: int = Query(0, ge=0, description="Offset for pagination")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
List tickets with optional filters
|
|
|
|
|
|
|
|
|
|
|
|
- **status**: Filter by ticket status
|
|
|
|
|
|
- **priority**: Filter by priority level
|
|
|
|
|
|
- **customer_id**: Show tickets for specific customer
|
|
|
|
|
|
- **assigned_to_user_id**: Show tickets assigned to user
|
|
|
|
|
|
- **search**: Search in subject and description
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
tickets = TicketService.list_tickets(
|
|
|
|
|
|
status=status,
|
|
|
|
|
|
priority=priority,
|
|
|
|
|
|
customer_id=customer_id,
|
|
|
|
|
|
assigned_to_user_id=assigned_to_user_id,
|
|
|
|
|
|
search=search,
|
|
|
|
|
|
limit=limit,
|
|
|
|
|
|
offset=offset
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Get total count for pagination
|
|
|
|
|
|
total_query = "SELECT COUNT(*) as count FROM tticket_tickets WHERE 1=1"
|
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
|
|
if status:
|
|
|
|
|
|
total_query += " AND status = %s"
|
|
|
|
|
|
params.append(status)
|
|
|
|
|
|
if customer_id:
|
|
|
|
|
|
total_query += " AND customer_id = %s"
|
|
|
|
|
|
params.append(customer_id)
|
|
|
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
|
total_result = execute_query_single(total_query, tuple(params))
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
total = total_result['count'] if total_result else 0
|
|
|
|
|
|
|
|
|
|
|
|
return TicketListResponse(
|
|
|
|
|
|
tickets=tickets,
|
|
|
|
|
|
total=total,
|
|
|
|
|
|
page=offset // limit + 1,
|
|
|
|
|
|
page_size=limit
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error listing tickets: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/tickets/{ticket_id}", response_model=TTicketWithStats, tags=["Tickets"])
|
|
|
|
|
|
async def get_ticket(ticket_id: int):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Get single ticket with statistics
|
|
|
|
|
|
|
|
|
|
|
|
Returns ticket with comment count, worklog hours, etc.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
ticket = TicketService.get_ticket_with_stats(ticket_id)
|
|
|
|
|
|
|
|
|
|
|
|
if not ticket:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found")
|
|
|
|
|
|
|
|
|
|
|
|
return ticket
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error getting ticket {ticket_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/tickets", response_model=TTicket, status_code=status.HTTP_201_CREATED, tags=["Tickets"])
|
|
|
|
|
|
async def create_ticket(
|
|
|
|
|
|
ticket_data: TTicketCreate,
|
|
|
|
|
|
user_id: Optional[int] = Query(None, description="User creating ticket")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Create new ticket
|
|
|
|
|
|
|
|
|
|
|
|
Ticket number will be auto-generated if not provided.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
ticket = TicketService.create_ticket(ticket_data, user_id=user_id)
|
|
|
|
|
|
logger.info(f"✅ Created ticket {ticket['ticket_number']}")
|
|
|
|
|
|
return ticket
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error creating ticket: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/tickets/{ticket_id}", response_model=TTicket, tags=["Tickets"])
|
|
|
|
|
|
async def update_ticket(
|
|
|
|
|
|
ticket_id: int,
|
|
|
|
|
|
update_data: TTicketUpdate,
|
|
|
|
|
|
user_id: Optional[int] = Query(None, description="User making update")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Update ticket (partial update)
|
|
|
|
|
|
|
|
|
|
|
|
Only provided fields will be updated.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
ticket = TicketService.update_ticket(ticket_id, update_data, user_id=user_id)
|
|
|
|
|
|
return ticket
|
|
|
|
|
|
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error updating ticket {ticket_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/tickets/{ticket_id}/status", response_model=TTicket, tags=["Tickets"])
|
|
|
|
|
|
async def update_ticket_status(
|
|
|
|
|
|
ticket_id: int,
|
|
|
|
|
|
request: TicketStatusUpdateRequest,
|
|
|
|
|
|
user_id: Optional[int] = Query(None, description="User changing status")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Update ticket status with validation
|
|
|
|
|
|
|
|
|
|
|
|
Status transitions are validated according to workflow rules.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
ticket = TicketService.update_ticket_status(
|
|
|
|
|
|
ticket_id,
|
|
|
|
|
|
request.status.value,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
note=request.note
|
|
|
|
|
|
)
|
|
|
|
|
|
return ticket
|
|
|
|
|
|
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error updating status for ticket {ticket_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/tickets/{ticket_id}/assign", response_model=TTicket, tags=["Tickets"])
|
|
|
|
|
|
async def assign_ticket(
|
|
|
|
|
|
ticket_id: int,
|
|
|
|
|
|
assigned_to_user_id: int = Query(..., description="User to assign to"),
|
|
|
|
|
|
user_id: Optional[int] = Query(None, description="User making assignment")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Assign ticket to a user
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
ticket = TicketService.assign_ticket(ticket_id, assigned_to_user_id, user_id=user_id)
|
|
|
|
|
|
return ticket
|
|
|
|
|
|
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error assigning ticket {ticket_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# COMMENT ENDPOINTS
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/tickets/{ticket_id}/comments", response_model=List[TTicketComment], tags=["Comments"])
|
|
|
|
|
|
async def list_comments(ticket_id: int):
|
|
|
|
|
|
"""
|
|
|
|
|
|
List all comments for a ticket
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2025-12-16 15:36:11 +01:00
|
|
|
|
comments = execute_query_single(
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
"SELECT * FROM tticket_comments WHERE ticket_id = %s ORDER BY created_at ASC",
|
|
|
|
|
|
(ticket_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
return comments or []
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error listing comments for ticket {ticket_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/tickets/{ticket_id}/comments", response_model=TTicketComment, status_code=status.HTTP_201_CREATED, tags=["Comments"])
|
|
|
|
|
|
async def add_comment(
|
|
|
|
|
|
ticket_id: int,
|
|
|
|
|
|
comment_text: str = Query(..., min_length=1, description="Comment text"),
|
|
|
|
|
|
is_internal: bool = Query(False, description="Is internal note"),
|
|
|
|
|
|
user_id: Optional[int] = Query(None, description="User adding comment")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Add comment to ticket
|
|
|
|
|
|
|
|
|
|
|
|
- **is_internal**: If true, comment is only visible to staff
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
comment = TicketService.add_comment(
|
|
|
|
|
|
ticket_id,
|
|
|
|
|
|
comment_text,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
is_internal=is_internal
|
|
|
|
|
|
)
|
|
|
|
|
|
return comment
|
|
|
|
|
|
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error adding comment to ticket {ticket_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-17 16:38:08 +01:00
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# TICKET CONTACTS ENDPOINTS
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/tickets/{ticket_id}/contacts", tags=["Contacts"])
|
|
|
|
|
|
async def get_ticket_contacts(ticket_id: int):
|
|
|
|
|
|
"""Get all contacts for a ticket with their roles"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
query = """
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
tc.id,
|
|
|
|
|
|
tc.contact_id,
|
|
|
|
|
|
c.first_name,
|
|
|
|
|
|
c.last_name,
|
|
|
|
|
|
c.email,
|
|
|
|
|
|
c.phone,
|
|
|
|
|
|
c.mobile,
|
|
|
|
|
|
c.title,
|
|
|
|
|
|
tc.role,
|
|
|
|
|
|
tc.added_at,
|
|
|
|
|
|
tc.notes
|
|
|
|
|
|
FROM tticket_contacts tc
|
|
|
|
|
|
JOIN contacts c ON tc.contact_id = c.id
|
|
|
|
|
|
WHERE tc.ticket_id = %s
|
|
|
|
|
|
ORDER BY
|
|
|
|
|
|
CASE tc.role
|
|
|
|
|
|
WHEN 'primary' THEN 1
|
|
|
|
|
|
WHEN 'requester' THEN 2
|
|
|
|
|
|
WHEN 'assignee' THEN 3
|
|
|
|
|
|
WHEN 'cc' THEN 4
|
|
|
|
|
|
WHEN 'observer' THEN 5
|
|
|
|
|
|
ELSE 6
|
|
|
|
|
|
END,
|
|
|
|
|
|
tc.added_at
|
|
|
|
|
|
"""
|
|
|
|
|
|
contacts = execute_query(query, (ticket_id,))
|
|
|
|
|
|
return {"contacts": contacts}
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error fetching contacts for ticket {ticket_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/tickets/{ticket_id}/contacts", status_code=status.HTTP_201_CREATED, tags=["Contacts"])
|
|
|
|
|
|
async def add_ticket_contact(
|
|
|
|
|
|
ticket_id: int,
|
|
|
|
|
|
contact_id: int = Query(..., description="Contact ID to add"),
|
|
|
|
|
|
role: str = Query("observer", description="Role: primary, cc, observer, assignee, requester, eller custom (ekstern_it, third_party, electrician, etc.)"),
|
|
|
|
|
|
notes: Optional[str] = Query(None, description="Optional notes about this contact's role"),
|
|
|
|
|
|
user_id: Optional[int] = Query(None, description="User adding the contact")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Add a contact to a ticket with a specific role
|
|
|
|
|
|
|
|
|
|
|
|
Standard Roles:
|
|
|
|
|
|
- **primary**: Main contact person
|
|
|
|
|
|
- **requester**: Original person who requested the ticket
|
|
|
|
|
|
- **assignee**: Person assigned to work on it
|
|
|
|
|
|
- **cc**: Should be kept in the loop (carbon copy)
|
|
|
|
|
|
- **observer**: Passively following the ticket
|
|
|
|
|
|
|
|
|
|
|
|
Custom Roles (eksempler):
|
|
|
|
|
|
- **ekstern_it**: Ekstern IT konsulent
|
|
|
|
|
|
- **third_party**: 3. parts leverandør
|
|
|
|
|
|
- **electrician**: Elektriker
|
|
|
|
|
|
- **consultant**: Konsulent
|
|
|
|
|
|
- Eller hvilken som helst custom rolle
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Normalize role (lowercase, underscores)
|
|
|
|
|
|
role = role.lower().replace(' ', '_').replace('-', '_')
|
|
|
|
|
|
|
|
|
|
|
|
# Check if ticket exists
|
|
|
|
|
|
ticket_check = execute_query_single("SELECT id FROM tticket_tickets WHERE id = %s", (ticket_id,))
|
|
|
|
|
|
if not ticket_check:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Ticket not found")
|
|
|
|
|
|
|
|
|
|
|
|
# Check if contact exists
|
|
|
|
|
|
contact_check = execute_query_single("SELECT id, first_name, last_name FROM contacts WHERE id = %s", (contact_id,))
|
|
|
|
|
|
if not contact_check:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Contact not found")
|
|
|
|
|
|
|
|
|
|
|
|
# Check if this is the first contact - if so, force role to 'primary'
|
|
|
|
|
|
existing_contacts = execute_query("SELECT COUNT(*) as count FROM tticket_contacts WHERE ticket_id = %s", (ticket_id,))
|
|
|
|
|
|
if existing_contacts and existing_contacts[0]['count'] == 0:
|
|
|
|
|
|
role = 'primary'
|
|
|
|
|
|
logger.info(f"✨ First contact on ticket {ticket_id} - auto-setting role to 'primary'")
|
|
|
|
|
|
|
|
|
|
|
|
# Insert (will fail if duplicate due to UNIQUE constraint)
|
|
|
|
|
|
query = """
|
|
|
|
|
|
INSERT INTO tticket_contacts (ticket_id, contact_id, role, notes, added_by_user_id)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
"""
|
|
|
|
|
|
result = execute_insert(query, (ticket_id, contact_id, role, notes, user_id))
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Added contact {contact_id} ({contact_check['first_name']} {contact_check['last_name']}) to ticket {ticket_id} as {role}")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": result,
|
|
|
|
|
|
"ticket_id": ticket_id,
|
|
|
|
|
|
"contact_id": contact_id,
|
|
|
|
|
|
"role": role,
|
|
|
|
|
|
"contact_name": f"{contact_check['first_name']} {contact_check['last_name']}"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
if "duplicate key" in str(e).lower():
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Contact already added to this ticket")
|
|
|
|
|
|
logger.error(f"❌ Error adding contact to ticket: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/tickets/{ticket_id}/contacts/{contact_id}", tags=["Contacts"])
|
|
|
|
|
|
async def update_ticket_contact_role(
|
|
|
|
|
|
ticket_id: int,
|
|
|
|
|
|
contact_id: int,
|
|
|
|
|
|
role: str = Query(..., description="New role (standard eller custom)"),
|
|
|
|
|
|
notes: Optional[str] = Query(None, description="Updated notes")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Update a contact's role on a ticket. Accepts both standard and custom roles."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Normalize role
|
|
|
|
|
|
role = role.lower().replace(' ', '_').replace('-', '_')
|
|
|
|
|
|
|
|
|
|
|
|
query = """
|
|
|
|
|
|
UPDATE tticket_contacts
|
|
|
|
|
|
SET role = %s, notes = %s
|
|
|
|
|
|
WHERE ticket_id = %s AND contact_id = %s
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
"""
|
|
|
|
|
|
result = execute_update(query, (role, notes, ticket_id, contact_id))
|
|
|
|
|
|
|
|
|
|
|
|
if not result:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Contact not found on this ticket")
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Updated contact {contact_id} role to {role} on ticket {ticket_id}")
|
|
|
|
|
|
return {"success": True, "role": role}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error updating contact role: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/tickets/{ticket_id}/contacts/{contact_id}", tags=["Contacts"])
|
|
|
|
|
|
async def remove_ticket_contact(ticket_id: int, contact_id: int):
|
|
|
|
|
|
"""Remove a contact from a ticket"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
query = "DELETE FROM tticket_contacts WHERE ticket_id = %s AND contact_id = %s RETURNING id"
|
|
|
|
|
|
result = execute_query_single(query, (ticket_id, contact_id))
|
|
|
|
|
|
|
|
|
|
|
|
if not result:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Contact not found on this ticket")
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Removed contact {contact_id} from ticket {ticket_id}")
|
|
|
|
|
|
return {"success": True, "removed": True}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error removing contact: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/contacts/roles", tags=["Contacts"])
|
|
|
|
|
|
async def get_contact_roles():
|
|
|
|
|
|
"""Get all used contact roles with usage statistics"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
query = "SELECT * FROM vw_ticket_contact_roles"
|
|
|
|
|
|
roles = execute_query(query)
|
|
|
|
|
|
|
|
|
|
|
|
# Add standard roles with 0 count if not used yet
|
|
|
|
|
|
standard_roles = [
|
|
|
|
|
|
{'role': 'primary', 'label': '⭐ Primær kontakt', 'category': 'standard'},
|
|
|
|
|
|
{'role': 'requester', 'label': '📝 Anmoder', 'category': 'standard'},
|
|
|
|
|
|
{'role': 'assignee', 'label': '👤 Ansvarlig', 'category': 'standard'},
|
|
|
|
|
|
{'role': 'cc', 'label': '📧 CC', 'category': 'standard'},
|
|
|
|
|
|
{'role': 'observer', 'label': '👁 Observer', 'category': 'standard'},
|
|
|
|
|
|
{'role': 'ekstern_it', 'label': '💻 Ekstern IT', 'category': 'common'},
|
|
|
|
|
|
{'role': 'third_party', 'label': '🤝 3. part', 'category': 'common'},
|
|
|
|
|
|
{'role': 'electrician', 'label': '⚡ Elektriker', 'category': 'common'},
|
|
|
|
|
|
{'role': 'consultant', 'label': '🎓 Konsulent', 'category': 'common'},
|
|
|
|
|
|
{'role': 'vendor', 'label': '🏢 Leverandør', 'category': 'common'},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
# Merge with used roles
|
|
|
|
|
|
used_role_names = {r['role'] for r in roles}
|
|
|
|
|
|
all_roles = standard_roles.copy()
|
|
|
|
|
|
|
|
|
|
|
|
# Add custom roles that are actually in use
|
|
|
|
|
|
for role in roles:
|
|
|
|
|
|
if role['role'] not in {r['role'] for r in standard_roles}:
|
|
|
|
|
|
all_roles.append({
|
|
|
|
|
|
'role': role['role'],
|
|
|
|
|
|
'label': role['role'].replace('_', ' ').title(),
|
|
|
|
|
|
'category': 'custom',
|
|
|
|
|
|
'usage_count': role['usage_count'],
|
|
|
|
|
|
'tickets_count': role['tickets_count']
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return {"roles": all_roles}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error fetching contact roles: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# WORKLOG ENDPOINTS
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/tickets/{ticket_id}/worklog", response_model=List[TTicketWorklog], tags=["Worklog"])
|
|
|
|
|
|
async def list_worklog(ticket_id: int):
|
|
|
|
|
|
"""
|
|
|
|
|
|
List all worklog entries for a ticket
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
worklog = execute_query(
|
|
|
|
|
|
"SELECT * FROM tticket_worklog WHERE ticket_id = %s ORDER BY work_date DESC",
|
|
|
|
|
|
(ticket_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
return worklog or []
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error listing worklog for ticket {ticket_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/tickets/{ticket_id}/worklog", response_model=TTicketWorklog, status_code=status.HTTP_201_CREATED, tags=["Worklog"])
|
|
|
|
|
|
async def create_worklog(
|
|
|
|
|
|
ticket_id: int,
|
|
|
|
|
|
worklog_data: TTicketWorklogCreate,
|
|
|
|
|
|
user_id: Optional[int] = Query(None, description="User creating worklog")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Create worklog entry for ticket
|
|
|
|
|
|
|
|
|
|
|
|
Creates time entry in draft status.
|
2026-01-10 21:09:29 +01:00
|
|
|
|
If billing_method is 'prepaid_card', validates and auto-selects card when only 1 active.
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from psycopg2.extras import Json
|
|
|
|
|
|
|
2026-01-10 21:09:29 +01:00
|
|
|
|
# Handle prepaid card selection/validation
|
|
|
|
|
|
prepaid_card_id = worklog_data.prepaid_card_id
|
|
|
|
|
|
if worklog_data.billing_method.value == 'prepaid_card':
|
|
|
|
|
|
# Get customer_id from ticket
|
|
|
|
|
|
ticket = execute_query_single(
|
|
|
|
|
|
"SELECT customer_id FROM tticket_tickets WHERE id = %s",
|
|
|
|
|
|
(ticket_id,))
|
|
|
|
|
|
if not ticket:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Ticket not found")
|
|
|
|
|
|
|
|
|
|
|
|
customer_id = ticket['customer_id']
|
|
|
|
|
|
|
|
|
|
|
|
# Get active prepaid cards for customer
|
|
|
|
|
|
active_cards = execute_query(
|
|
|
|
|
|
"""SELECT id, remaining_hours, expires_at
|
|
|
|
|
|
FROM tticket_prepaid_cards
|
|
|
|
|
|
WHERE customer_id = %s AND status = 'active'
|
|
|
|
|
|
ORDER BY expires_at ASC NULLS LAST, created_at ASC""",
|
|
|
|
|
|
(customer_id,))
|
|
|
|
|
|
|
|
|
|
|
|
if not active_cards:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail="Kunden har ingen aktive klippekort")
|
|
|
|
|
|
|
|
|
|
|
|
if len(active_cards) == 1:
|
|
|
|
|
|
# Auto-select if only 1 active
|
|
|
|
|
|
if not prepaid_card_id:
|
|
|
|
|
|
prepaid_card_id = active_cards[0]['id']
|
|
|
|
|
|
logger.info(f"🎫 Auto-selected prepaid card {prepaid_card_id} (only active card)")
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Multiple active cards: require explicit selection
|
|
|
|
|
|
if not prepaid_card_id:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"Kunden har {len(active_cards)} aktive klippekort. Vælg et konkret kort.")
|
|
|
|
|
|
|
|
|
|
|
|
# Validate selected card is active and belongs to customer
|
|
|
|
|
|
selected = next((c for c in active_cards if c['id'] == prepaid_card_id), None)
|
|
|
|
|
|
if not selected:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail="Valgt klippekort er ikke aktivt eller tilhører ikke kunden")
|
|
|
|
|
|
|
2026-02-08 01:45:00 +01:00
|
|
|
|
# Calculate rounded hours if prepaid
|
|
|
|
|
|
rounded_hours = None
|
|
|
|
|
|
if prepaid_card_id:
|
|
|
|
|
|
card = execute_query_single(
|
|
|
|
|
|
"SELECT rounding_minutes FROM tticket_prepaid_cards WHERE id = %s",
|
|
|
|
|
|
(prepaid_card_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
if card:
|
|
|
|
|
|
rounding_minutes = int(card.get('rounding_minutes') or 0)
|
|
|
|
|
|
if rounding_minutes > 0:
|
|
|
|
|
|
from decimal import Decimal, ROUND_CEILING
|
|
|
|
|
|
interval = Decimal(rounding_minutes) / Decimal(60)
|
|
|
|
|
|
rounded_hours = float(
|
|
|
|
|
|
(Decimal(str(worklog_data.hours)) / interval).to_integral_value(rounding=ROUND_CEILING) * interval
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
rounded_hours = float(worklog_data.hours)
|
|
|
|
|
|
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
worklog_id = execute_insert(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO tticket_worklog
|
2026-02-08 01:45:00 +01:00
|
|
|
|
(ticket_id, work_date, hours, work_type, description, billing_method, status, user_id, prepaid_card_id, is_internal, rounded_hours)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
2026-01-10 21:09:29 +01:00
|
|
|
|
RETURNING id
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
ticket_id,
|
|
|
|
|
|
worklog_data.work_date,
|
|
|
|
|
|
worklog_data.hours,
|
|
|
|
|
|
worklog_data.work_type.value,
|
|
|
|
|
|
worklog_data.description,
|
|
|
|
|
|
worklog_data.billing_method.value,
|
|
|
|
|
|
'draft',
|
|
|
|
|
|
user_id or worklog_data.user_id,
|
2026-01-10 21:09:29 +01:00
|
|
|
|
prepaid_card_id,
|
2026-02-08 01:45:00 +01:00
|
|
|
|
worklog_data.is_internal,
|
|
|
|
|
|
rounded_hours
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Log audit
|
|
|
|
|
|
TicketService.log_audit(
|
|
|
|
|
|
ticket_id=ticket_id,
|
|
|
|
|
|
entity_type="worklog",
|
|
|
|
|
|
entity_id=worklog_id,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
action="created",
|
2026-01-10 21:09:29 +01:00
|
|
|
|
details={
|
|
|
|
|
|
"hours": float(worklog_data.hours),
|
|
|
|
|
|
"work_type": worklog_data.work_type.value,
|
|
|
|
|
|
"is_internal": worklog_data.is_internal
|
|
|
|
|
|
}
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-10 21:09:29 +01:00
|
|
|
|
worklog = execute_query_single(
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
"SELECT * FROM tticket_worklog WHERE id = %s",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(worklog_id,))
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Created worklog entry {worklog_id} for ticket {ticket_id}")
|
|
|
|
|
|
return worklog
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error creating worklog: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/worklog/{worklog_id}", response_model=TTicketWorklog, tags=["Worklog"])
|
|
|
|
|
|
async def update_worklog(
|
|
|
|
|
|
worklog_id: int,
|
|
|
|
|
|
update_data: TTicketWorklogUpdate,
|
|
|
|
|
|
user_id: Optional[int] = Query(None, description="User updating worklog")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Update worklog entry (partial update)
|
|
|
|
|
|
|
|
|
|
|
|
Only draft entries can be fully edited.
|
2026-01-10 21:09:29 +01:00
|
|
|
|
If billing_method changes to 'prepaid_card', validates and auto-selects card when only 1 active.
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Get current worklog
|
2025-12-16 15:36:11 +01:00
|
|
|
|
current = execute_query_single(
|
2026-01-10 21:09:29 +01:00
|
|
|
|
"SELECT w.*, t.customer_id FROM tticket_worklog w JOIN tticket_tickets t ON w.ticket_id = t.id WHERE w.id = %s",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(worklog_id,))
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
|
|
|
|
|
|
if not current:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Worklog {worklog_id} not found")
|
|
|
|
|
|
|
|
|
|
|
|
# Build update query
|
|
|
|
|
|
updates = []
|
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
|
|
update_dict = update_data.model_dump(exclude_unset=True)
|
|
|
|
|
|
|
2026-01-10 21:09:29 +01:00
|
|
|
|
# Handle prepaid card selection/validation if billing_method is being set to prepaid_card
|
|
|
|
|
|
if 'billing_method' in update_dict and update_dict['billing_method'] == 'prepaid_card':
|
|
|
|
|
|
customer_id = current['customer_id']
|
|
|
|
|
|
|
|
|
|
|
|
# Get active prepaid cards for customer
|
|
|
|
|
|
active_cards = execute_query(
|
|
|
|
|
|
"""SELECT id, remaining_hours, expires_at
|
|
|
|
|
|
FROM tticket_prepaid_cards
|
|
|
|
|
|
WHERE customer_id = %s AND status = 'active'
|
|
|
|
|
|
ORDER BY expires_at ASC NULLS LAST, created_at ASC""",
|
|
|
|
|
|
(customer_id,))
|
|
|
|
|
|
|
|
|
|
|
|
if not active_cards:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail="Kunden har ingen aktive klippekort")
|
|
|
|
|
|
|
|
|
|
|
|
if len(active_cards) == 1:
|
|
|
|
|
|
# Auto-select if only 1 active and not explicitly provided
|
|
|
|
|
|
if 'prepaid_card_id' not in update_dict or not update_dict['prepaid_card_id']:
|
|
|
|
|
|
update_dict['prepaid_card_id'] = active_cards[0]['id']
|
|
|
|
|
|
logger.info(f"🎫 Auto-selected prepaid card {update_dict['prepaid_card_id']} (only active card)")
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Multiple active cards: require explicit selection
|
|
|
|
|
|
if 'prepaid_card_id' not in update_dict or not update_dict['prepaid_card_id']:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"Kunden har {len(active_cards)} aktive klippekort. Vælg et konkret kort.")
|
|
|
|
|
|
|
|
|
|
|
|
# Validate selected card if provided
|
|
|
|
|
|
if 'prepaid_card_id' in update_dict and update_dict['prepaid_card_id']:
|
|
|
|
|
|
selected = next((c for c in active_cards if c['id'] == update_dict['prepaid_card_id']), None)
|
|
|
|
|
|
if not selected:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail="Valgt klippekort er ikke aktivt eller tilhører ikke kunden")
|
|
|
|
|
|
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
for field, value in update_dict.items():
|
|
|
|
|
|
if hasattr(value, 'value'):
|
|
|
|
|
|
value = value.value
|
|
|
|
|
|
updates.append(f"{field} = %s")
|
|
|
|
|
|
params.append(value)
|
|
|
|
|
|
|
|
|
|
|
|
if updates:
|
|
|
|
|
|
params.append(worklog_id)
|
|
|
|
|
|
query = f"UPDATE tticket_worklog SET {', '.join(updates)} WHERE id = %s"
|
|
|
|
|
|
execute_update(query, tuple(params))
|
|
|
|
|
|
|
|
|
|
|
|
# Log audit
|
|
|
|
|
|
TicketService.log_audit(
|
|
|
|
|
|
ticket_id=current['ticket_id'],
|
|
|
|
|
|
entity_type="worklog",
|
|
|
|
|
|
entity_id=worklog_id,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
action="updated",
|
|
|
|
|
|
details=update_dict
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Fetch updated
|
2025-12-16 15:36:11 +01:00
|
|
|
|
worklog = execute_query_single(
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
"SELECT * FROM tticket_worklog WHERE id = %s",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(worklog_id,))
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
|
|
|
|
|
|
return worklog
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error updating worklog {worklog_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/worklog/review", response_model=WorklogReviewResponse, tags=["Worklog"])
|
|
|
|
|
|
async def review_worklog(
|
|
|
|
|
|
customer_id: Optional[int] = Query(None, description="Filter by customer"),
|
|
|
|
|
|
status: str = Query("draft", description="Filter by status (default: draft)")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Get worklog entries for review/billing
|
|
|
|
|
|
|
|
|
|
|
|
Returns entries ready for review with ticket context.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
|
|
|
|
|
|
query = """
|
|
|
|
|
|
SELECT w.*, t.ticket_number, t.subject AS ticket_subject,
|
|
|
|
|
|
t.customer_id, t.status AS ticket_status
|
|
|
|
|
|
FROM tticket_worklog w
|
|
|
|
|
|
JOIN tticket_tickets t ON w.ticket_id = t.id
|
|
|
|
|
|
WHERE w.status = %s
|
|
|
|
|
|
"""
|
|
|
|
|
|
params = [status]
|
|
|
|
|
|
|
|
|
|
|
|
if customer_id:
|
|
|
|
|
|
query += " AND t.customer_id = %s"
|
|
|
|
|
|
params.append(customer_id)
|
|
|
|
|
|
|
|
|
|
|
|
query += " ORDER BY w.work_date DESC, t.customer_id"
|
|
|
|
|
|
|
2025-12-16 15:36:11 +01:00
|
|
|
|
worklogs = execute_query_single(query, tuple(params))
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
|
|
|
|
|
|
# Calculate totals
|
|
|
|
|
|
total_hours = Decimal('0')
|
|
|
|
|
|
total_billable_hours = Decimal('0')
|
|
|
|
|
|
|
|
|
|
|
|
for w in worklogs or []:
|
|
|
|
|
|
total_hours += Decimal(str(w['hours']))
|
|
|
|
|
|
if w['status'] in ['draft', 'billable']:
|
|
|
|
|
|
total_billable_hours += Decimal(str(w['hours']))
|
|
|
|
|
|
|
|
|
|
|
|
return WorklogReviewResponse(
|
|
|
|
|
|
worklogs=worklogs or [],
|
|
|
|
|
|
total=len(worklogs) if worklogs else 0,
|
|
|
|
|
|
total_hours=total_hours,
|
|
|
|
|
|
total_billable_hours=total_billable_hours
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error getting worklog review: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/worklog/mark-billable", tags=["Worklog"])
|
|
|
|
|
|
async def mark_worklog_billable(
|
|
|
|
|
|
request: WorklogBillingRequest,
|
|
|
|
|
|
user_id: Optional[int] = Query(None, description="User marking as billable")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Mark worklog entries as billable
|
|
|
|
|
|
|
|
|
|
|
|
Changes status from draft to billable for selected entries.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
updated_count = 0
|
|
|
|
|
|
|
|
|
|
|
|
for worklog_id in request.worklog_ids:
|
|
|
|
|
|
# Get worklog
|
|
|
|
|
|
worklog = execute_query(
|
|
|
|
|
|
"SELECT * FROM tticket_worklog WHERE id = %s",
|
2025-12-16 15:36:11 +01:00
|
|
|
|
(worklog_id,))
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
|
|
|
|
|
|
if not worklog:
|
|
|
|
|
|
logger.warning(f"⚠️ Worklog {worklog_id} not found, skipping")
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if worklog['status'] != 'draft':
|
|
|
|
|
|
logger.warning(f"⚠️ Worklog {worklog_id} not in draft status, skipping")
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Update to billable
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"UPDATE tticket_worklog SET status = 'billable' WHERE id = %s",
|
|
|
|
|
|
(worklog_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Log audit
|
|
|
|
|
|
TicketService.log_audit(
|
|
|
|
|
|
ticket_id=worklog['ticket_id'],
|
|
|
|
|
|
entity_type="worklog",
|
|
|
|
|
|
entity_id=worklog_id,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
action="marked_billable",
|
|
|
|
|
|
old_value="draft",
|
|
|
|
|
|
new_value="billable",
|
|
|
|
|
|
details={"note": request.note} if request.note else None
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
updated_count += 1
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Marked {updated_count} worklog entries as billable")
|
|
|
|
|
|
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
content={
|
|
|
|
|
|
"success": True,
|
|
|
|
|
|
"updated_count": updated_count,
|
|
|
|
|
|
"message": f"Marked {updated_count} entries as billable"
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error marking worklog as billable: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# PREPAID CARD (KLIPPEKORT) ENDPOINTS
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
from app.ticket.backend.klippekort_service import KlippekortService
|
|
|
|
|
|
from app.ticket.backend.models import (
|
|
|
|
|
|
TPrepaidCard,
|
|
|
|
|
|
TPrepaidCardCreate,
|
|
|
|
|
|
TPrepaidCardUpdate,
|
|
|
|
|
|
TPrepaidCardWithStats,
|
|
|
|
|
|
TPrepaidTransaction,
|
|
|
|
|
|
PrepaidCardBalanceResponse,
|
|
|
|
|
|
PrepaidCardTopUpRequest
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/prepaid-cards", response_model=List[TPrepaidCard], tags=["Prepaid Cards"])
|
|
|
|
|
|
async def list_prepaid_cards(
|
|
|
|
|
|
customer_id: Optional[int] = Query(None, description="Filter by customer"),
|
|
|
|
|
|
status: Optional[str] = Query(None, description="Filter by status"),
|
|
|
|
|
|
limit: int = Query(50, ge=1, le=100),
|
|
|
|
|
|
offset: int = Query(0, ge=0)
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
List prepaid cards with optional filters
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
cards = KlippekortService.list_cards(
|
|
|
|
|
|
customer_id=customer_id,
|
|
|
|
|
|
status=status,
|
|
|
|
|
|
limit=limit,
|
|
|
|
|
|
offset=offset
|
|
|
|
|
|
)
|
|
|
|
|
|
return cards
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error listing prepaid cards: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/prepaid-cards/{card_id}", response_model=TPrepaidCardWithStats, tags=["Prepaid Cards"])
|
|
|
|
|
|
async def get_prepaid_card(card_id: int):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Get prepaid card with usage statistics
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
card = KlippekortService.get_card_with_stats(card_id)
|
|
|
|
|
|
|
|
|
|
|
|
if not card:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Prepaid card {card_id} not found")
|
|
|
|
|
|
|
|
|
|
|
|
return card
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error getting prepaid card {card_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/prepaid-cards", response_model=TPrepaidCard, status_code=status.HTTP_201_CREATED, tags=["Prepaid Cards"])
|
|
|
|
|
|
async def purchase_prepaid_card(
|
|
|
|
|
|
card_data: TPrepaidCardCreate,
|
|
|
|
|
|
user_id: Optional[int] = Query(None, description="User purchasing card")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Purchase new prepaid card
|
|
|
|
|
|
|
|
|
|
|
|
CONSTRAINT: Only 1 active card allowed per customer.
|
|
|
|
|
|
Will fail if customer already has an active card.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
card = KlippekortService.purchase_card(card_data, user_id=user_id)
|
|
|
|
|
|
logger.info(f"✅ Purchased prepaid card {card['card_number']}")
|
|
|
|
|
|
return card
|
|
|
|
|
|
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error purchasing prepaid card: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/prepaid-cards/customer/{customer_id}/balance", response_model=PrepaidCardBalanceResponse, tags=["Prepaid Cards"])
|
|
|
|
|
|
async def check_customer_balance(customer_id: int):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Check prepaid card balance for customer
|
|
|
|
|
|
|
|
|
|
|
|
Returns balance info for customer's active card.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
balance_info = KlippekortService.check_balance(customer_id)
|
|
|
|
|
|
|
|
|
|
|
|
if not balance_info['has_card']:
|
|
|
|
|
|
return PrepaidCardBalanceResponse(
|
|
|
|
|
|
card=None,
|
|
|
|
|
|
can_deduct=False,
|
|
|
|
|
|
message=f"Customer {customer_id} has no active prepaid card"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Get card details
|
|
|
|
|
|
card = KlippekortService.get_card_with_stats(balance_info['card_id'])
|
|
|
|
|
|
|
|
|
|
|
|
return PrepaidCardBalanceResponse(
|
|
|
|
|
|
card=card,
|
|
|
|
|
|
can_deduct=balance_info['balance_hours'] > 0,
|
|
|
|
|
|
message=f"Balance: {balance_info['balance_hours']}h"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error checking balance for customer {customer_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/prepaid-cards/{card_id}/top-up", response_model=TPrepaidTransaction, tags=["Prepaid Cards"])
|
|
|
|
|
|
async def top_up_prepaid_card(
|
|
|
|
|
|
card_id: int,
|
|
|
|
|
|
request: PrepaidCardTopUpRequest,
|
|
|
|
|
|
user_id: Optional[int] = Query(None, description="User performing top-up")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Top up prepaid card with additional hours
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
transaction = KlippekortService.top_up_card(
|
|
|
|
|
|
card_id,
|
|
|
|
|
|
request.hours,
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
note=request.note
|
|
|
|
|
|
)
|
|
|
|
|
|
return transaction
|
|
|
|
|
|
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error topping up card {card_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/prepaid-cards/{card_id}/transactions", response_model=List[TPrepaidTransaction], tags=["Prepaid Cards"])
|
|
|
|
|
|
async def get_card_transactions(
|
|
|
|
|
|
card_id: int,
|
|
|
|
|
|
limit: int = Query(100, ge=1, le=500)
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Get transaction history for prepaid card
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
transactions = KlippekortService.get_transactions(card_id, limit=limit)
|
|
|
|
|
|
return transactions
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error getting transactions for card {card_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/prepaid-cards/{card_id}", response_model=TPrepaidCard, tags=["Prepaid Cards"])
|
|
|
|
|
|
async def cancel_prepaid_card(
|
|
|
|
|
|
card_id: int,
|
|
|
|
|
|
reason: Optional[str] = Query(None, description="Cancellation reason"),
|
|
|
|
|
|
user_id: Optional[int] = Query(None, description="User cancelling card")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Cancel/deactivate prepaid card
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
card = KlippekortService.cancel_card(card_id, user_id=user_id, reason=reason)
|
|
|
|
|
|
return card
|
|
|
|
|
|
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error cancelling card {card_id}: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# STATISTICS ENDPOINTS
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/tickets/stats/by-status", tags=["Statistics"])
|
|
|
|
|
|
async def get_stats_by_status():
|
|
|
|
|
|
"""
|
|
|
|
|
|
Get ticket statistics grouped by status
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2025-12-16 15:36:11 +01:00
|
|
|
|
stats = execute_query_single(
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
"SELECT * FROM tticket_stats_by_status ORDER BY status"
|
|
|
|
|
|
)
|
|
|
|
|
|
return stats or []
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error getting stats: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/tickets/stats/open", tags=["Statistics"])
|
|
|
|
|
|
async def get_open_tickets_stats():
|
|
|
|
|
|
"""
|
|
|
|
|
|
Get statistics for open tickets
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
stats = execute_query(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
COUNT(*) as total_open,
|
|
|
|
|
|
COUNT(*) FILTER (WHERE status = 'open') as new_tickets,
|
|
|
|
|
|
COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress,
|
|
|
|
|
|
COUNT(*) FILTER (WHERE priority = 'urgent') as urgent_count,
|
|
|
|
|
|
AVG(age_hours) as avg_age_hours
|
|
|
|
|
|
FROM tticket_open_tickets
|
2025-12-16 15:36:11 +01:00
|
|
|
|
""")
|
feat(ticket-module): Implement ticket system with comprehensive database schema, permissions, and testing suite
- 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.
2025-12-15 23:40:23 +01:00
|
|
|
|
|
|
|
|
|
|
return stats or {}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error getting open tickets stats: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# E-CONOMIC EXPORT ENDPOINTS
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/worklog/export/preview", tags=["E-conomic Export"])
|
|
|
|
|
|
async def preview_economic_export(
|
|
|
|
|
|
customer_id: int = Query(..., description="Customer ID"),
|
|
|
|
|
|
worklog_ids: Optional[List[int]] = Query(None, description="Specific worklog IDs to export"),
|
|
|
|
|
|
date_from: Optional[date] = Query(None, description="Start date filter"),
|
|
|
|
|
|
date_to: Optional[date] = Query(None, description="End date filter")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Preview what would be exported to e-conomic without actually exporting
|
|
|
|
|
|
|
|
|
|
|
|
**Safety**: This is read-only and safe to call
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
preview = await ticket_economic_service.get_export_preview(
|
|
|
|
|
|
customer_id=customer_id,
|
|
|
|
|
|
worklog_ids=worklog_ids,
|
|
|
|
|
|
date_from=date_from,
|
|
|
|
|
|
date_to=date_to
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return preview
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error generating export preview: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/worklog/export/execute", tags=["E-conomic Export"])
|
|
|
|
|
|
async def execute_economic_export(
|
|
|
|
|
|
customer_id: int = Query(..., description="Customer ID"),
|
|
|
|
|
|
worklog_ids: Optional[List[int]] = Query(None, description="Specific worklog IDs to export"),
|
|
|
|
|
|
date_from: Optional[date] = Query(None, description="Start date filter"),
|
|
|
|
|
|
date_to: Optional[date] = Query(None, description="End date filter")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Export billable worklog entries to e-conomic as draft invoice
|
|
|
|
|
|
|
|
|
|
|
|
**⚠️ WARNING**: This creates invoices in e-conomic (subject to safety switches)
|
|
|
|
|
|
|
|
|
|
|
|
**Safety Switches**:
|
|
|
|
|
|
- `TICKET_ECONOMIC_READ_ONLY=true`: Blocks execution
|
|
|
|
|
|
- `TICKET_ECONOMIC_DRY_RUN=true`: Logs but doesn't execute
|
|
|
|
|
|
- Both must be `false` to actually export
|
|
|
|
|
|
|
|
|
|
|
|
**Process**:
|
|
|
|
|
|
1. Validates customer has e-conomic mapping
|
|
|
|
|
|
2. Collects billable worklog entries
|
|
|
|
|
|
3. Creates draft invoice in e-conomic
|
|
|
|
|
|
4. Marks worklog entries as "billed"
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = await ticket_economic_service.export_billable_worklog_batch(
|
|
|
|
|
|
customer_id=customer_id,
|
|
|
|
|
|
worklog_ids=worklog_ids,
|
|
|
|
|
|
date_from=date_from,
|
|
|
|
|
|
date_to=date_to
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if result['status'] == 'blocked':
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
status_code=403,
|
|
|
|
|
|
content={
|
|
|
|
|
|
'error': 'Export blocked by safety switches',
|
|
|
|
|
|
'read_only': result.get('read_only'),
|
|
|
|
|
|
'dry_run': result.get('dry_run'),
|
|
|
|
|
|
'message': 'Set TICKET_ECONOMIC_READ_ONLY=false and TICKET_ECONOMIC_DRY_RUN=false to enable'
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error executing export: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
2025-12-16 15:36:11 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# TICKET RELATIONS ENDPOINTS (Migration 026)
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/tickets/{ticket_id}/merge", tags=["Ticket Relations"])
|
|
|
|
|
|
async def merge_tickets(ticket_id: int, request: TicketMergeRequest):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Flet flere tickets sammen til én primær ticket
|
|
|
|
|
|
|
|
|
|
|
|
**Process**:
|
|
|
|
|
|
1. Validerer at alle source tickets eksisterer
|
|
|
|
|
|
2. Kopierer kommentarer og worklogs til target ticket
|
|
|
|
|
|
3. Opretter relation records
|
|
|
|
|
|
4. Markerer source tickets som merged
|
|
|
|
|
|
5. Logger i audit trail
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Validate target ticket exists
|
|
|
|
|
|
target_ticket = execute_query_single(
|
|
|
|
|
|
"SELECT id, ticket_number, subject FROM tticket_tickets WHERE id = %s",
|
|
|
|
|
|
(request.target_ticket_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
if not target_ticket:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Target ticket {request.target_ticket_id} not found")
|
|
|
|
|
|
|
|
|
|
|
|
merged_count = 0
|
|
|
|
|
|
for source_id in request.source_ticket_ids:
|
|
|
|
|
|
# Validate source ticket
|
|
|
|
|
|
source_ticket = execute_query_single(
|
|
|
|
|
|
"SELECT id, ticket_number FROM tticket_tickets WHERE id = %s",
|
|
|
|
|
|
(source_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
if not source_ticket:
|
|
|
|
|
|
logger.warning(f"⚠️ Source ticket {source_id} not found, skipping")
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Create relation
|
|
|
|
|
|
execute_query(
|
|
|
|
|
|
"""INSERT INTO tticket_relations (ticket_id, related_ticket_id, relation_type, reason, created_by_user_id)
|
|
|
|
|
|
VALUES (%s, %s, 'merged_into', %s, 1)
|
|
|
|
|
|
ON CONFLICT (ticket_id, related_ticket_id, relation_type) DO NOTHING""",
|
|
|
|
|
|
(source_id, request.target_ticket_id, request.reason)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Mark source as merged
|
|
|
|
|
|
execute_query(
|
|
|
|
|
|
"""UPDATE tticket_tickets
|
|
|
|
|
|
SET is_merged = true, merged_into_ticket_id = %s, status = 'closed'
|
|
|
|
|
|
WHERE id = %s""",
|
|
|
|
|
|
(request.target_ticket_id, source_id),
|
|
|
|
|
|
fetch=False
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Log audit
|
|
|
|
|
|
execute_query(
|
|
|
|
|
|
"""INSERT INTO tticket_audit_log (ticket_id, action, new_value, reason)
|
|
|
|
|
|
VALUES (%s, 'merged_into', %s, %s)""",
|
|
|
|
|
|
(source_id, str(request.target_ticket_id), request.reason)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
merged_count += 1
|
|
|
|
|
|
logger.info(f"✅ Merged ticket {source_id} into {request.target_ticket_id}")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "success",
|
|
|
|
|
|
"merged_count": merged_count,
|
|
|
|
|
|
"target_ticket": target_ticket,
|
|
|
|
|
|
"message": f"Successfully merged {merged_count} ticket(s)"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error merging tickets: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/tickets/{ticket_id}/split", tags=["Ticket Relations"])
|
|
|
|
|
|
async def split_ticket(ticket_id: int, request: TicketSplitRequest):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Opdel en ticket i to - flyt kommentarer til ny ticket
|
|
|
|
|
|
|
|
|
|
|
|
**Process**:
|
|
|
|
|
|
1. Opretter ny ticket med nyt subject
|
|
|
|
|
|
2. Flytter valgte kommentarer til ny ticket
|
|
|
|
|
|
3. Opretter relation
|
|
|
|
|
|
4. Logger i audit trail
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Validate source ticket
|
|
|
|
|
|
source_ticket = execute_query_single(
|
|
|
|
|
|
"SELECT * FROM tticket_tickets WHERE id = %s",
|
|
|
|
|
|
(request.source_ticket_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
if not source_ticket:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Source ticket {request.source_ticket_id} not found")
|
|
|
|
|
|
|
|
|
|
|
|
# Create new ticket (inherit customer, contact, priority)
|
|
|
|
|
|
new_ticket_id = execute_insert(
|
|
|
|
|
|
"""INSERT INTO tticket_tickets
|
|
|
|
|
|
(subject, description, status, priority, customer_id, contact_id, source, created_by_user_id)
|
|
|
|
|
|
VALUES (%s, %s, 'open', %s, %s, %s, 'manual', 1)
|
|
|
|
|
|
RETURNING id""",
|
|
|
|
|
|
(request.new_subject, request.new_description, source_ticket['priority'],
|
|
|
|
|
|
source_ticket['customer_id'], source_ticket['contact_id'])
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
new_ticket_number = execute_query_single(
|
|
|
|
|
|
"SELECT ticket_number FROM tticket_tickets WHERE id = %s",
|
|
|
|
|
|
(new_ticket_id,)
|
|
|
|
|
|
)['ticket_number']
|
|
|
|
|
|
|
|
|
|
|
|
# Move comments
|
|
|
|
|
|
moved_comments = 0
|
|
|
|
|
|
for comment_id in request.comment_ids:
|
|
|
|
|
|
result = execute_query(
|
|
|
|
|
|
"UPDATE tticket_comments SET ticket_id = %s WHERE id = %s AND ticket_id = %s",
|
|
|
|
|
|
(new_ticket_id, comment_id, request.source_ticket_id),
|
|
|
|
|
|
fetch=False
|
|
|
|
|
|
)
|
|
|
|
|
|
if result:
|
|
|
|
|
|
moved_comments += 1
|
|
|
|
|
|
|
|
|
|
|
|
# Create relation
|
|
|
|
|
|
execute_query(
|
|
|
|
|
|
"""INSERT INTO tticket_relations (ticket_id, related_ticket_id, relation_type, reason, created_by_user_id)
|
|
|
|
|
|
VALUES (%s, %s, 'split_from', %s, 1)""",
|
|
|
|
|
|
(new_ticket_id, request.source_ticket_id, request.reason)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Log audit
|
|
|
|
|
|
execute_query(
|
|
|
|
|
|
"""INSERT INTO tticket_audit_log (ticket_id, action, new_value, reason)
|
|
|
|
|
|
VALUES (%s, 'split_into', %s, %s)""",
|
|
|
|
|
|
(request.source_ticket_id, str(new_ticket_id), request.reason)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Split ticket {request.source_ticket_id} into {new_ticket_id}, moved {moved_comments} comments")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "success",
|
|
|
|
|
|
"new_ticket_id": new_ticket_id,
|
|
|
|
|
|
"new_ticket_number": new_ticket_number,
|
|
|
|
|
|
"moved_comments": moved_comments,
|
|
|
|
|
|
"message": f"Successfully split ticket into {new_ticket_number}"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error splitting ticket: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/tickets/{ticket_id}/relations", tags=["Ticket Relations"])
|
|
|
|
|
|
async def get_ticket_relations(ticket_id: int):
|
|
|
|
|
|
"""Hent alle relationer for en ticket (begge retninger)"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
relations = execute_query(
|
|
|
|
|
|
"""SELECT r.*,
|
|
|
|
|
|
t.ticket_number as related_ticket_number,
|
|
|
|
|
|
t.subject as related_subject,
|
|
|
|
|
|
t.status as related_status
|
|
|
|
|
|
FROM tticket_all_relations r
|
|
|
|
|
|
LEFT JOIN tticket_tickets t ON r.related_ticket_id = t.id
|
|
|
|
|
|
WHERE r.ticket_id = %s
|
|
|
|
|
|
ORDER BY r.created_at DESC""",
|
|
|
|
|
|
(ticket_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {"relations": relations, "total": len(relations)}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error fetching relations: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/tickets/{ticket_id}/relations", tags=["Ticket Relations"])
|
|
|
|
|
|
async def create_ticket_relation(ticket_id: int, relation: TTicketRelationCreate):
|
|
|
|
|
|
"""Opret en relation mellem to tickets"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Validate both tickets exist
|
|
|
|
|
|
for tid in [relation.ticket_id, relation.related_ticket_id]:
|
|
|
|
|
|
ticket = execute_query_single("SELECT id FROM tticket_tickets WHERE id = %s", (tid,))
|
|
|
|
|
|
if not ticket:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Ticket {tid} not found")
|
|
|
|
|
|
|
|
|
|
|
|
execute_query(
|
|
|
|
|
|
"""INSERT INTO tticket_relations (ticket_id, related_ticket_id, relation_type, reason, created_by_user_id)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, 1)""",
|
|
|
|
|
|
(relation.ticket_id, relation.related_ticket_id, relation.relation_type, relation.reason)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {"status": "success", "message": "Relation created"}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error creating relation: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# CALENDAR EVENTS ENDPOINTS
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/tickets/{ticket_id}/calendar-events", tags=["Calendar"])
|
|
|
|
|
|
async def get_calendar_events(ticket_id: int):
|
|
|
|
|
|
"""Hent alle kalender events for en ticket"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
events = execute_query(
|
|
|
|
|
|
"""SELECT * FROM tticket_calendar_events
|
|
|
|
|
|
WHERE ticket_id = %s
|
|
|
|
|
|
ORDER BY event_date DESC, event_time DESC NULLS LAST""",
|
|
|
|
|
|
(ticket_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {"events": events, "total": len(events)}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error fetching calendar events: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/tickets/{ticket_id}/calendar-events", tags=["Calendar"])
|
|
|
|
|
|
async def create_calendar_event(ticket_id: int, event: TTicketCalendarEventCreate):
|
|
|
|
|
|
"""Opret kalender event (manual eller AI-foreslået)"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
event_id = execute_insert(
|
|
|
|
|
|
"""INSERT INTO tticket_calendar_events
|
|
|
|
|
|
(ticket_id, title, description, event_type, event_date, event_time,
|
|
|
|
|
|
duration_minutes, all_day, status, suggested_by_ai, ai_confidence,
|
|
|
|
|
|
ai_source_text, created_by_user_id)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 1)
|
|
|
|
|
|
RETURNING id""",
|
|
|
|
|
|
(ticket_id, event.title, event.description, event.event_type,
|
|
|
|
|
|
event.event_date, event.event_time, event.duration_minutes,
|
|
|
|
|
|
event.all_day, event.status, event.suggested_by_ai,
|
|
|
|
|
|
event.ai_confidence, event.ai_source_text)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Created calendar event {event_id} for ticket {ticket_id}")
|
|
|
|
|
|
|
|
|
|
|
|
return {"status": "success", "event_id": event_id, "message": "Calendar event created"}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error creating calendar event: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/tickets/{ticket_id}/calendar-events/{event_id}", tags=["Calendar"])
|
|
|
|
|
|
async def update_calendar_event(ticket_id: int, event_id: int, status: CalendarEventStatus):
|
|
|
|
|
|
"""Opdater calendar event status"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
execute_query(
|
|
|
|
|
|
"""UPDATE tticket_calendar_events
|
|
|
|
|
|
SET status = %s, updated_at = CURRENT_TIMESTAMP,
|
|
|
|
|
|
completed_at = CASE WHEN %s = 'completed' THEN CURRENT_TIMESTAMP ELSE completed_at END
|
|
|
|
|
|
WHERE id = %s AND ticket_id = %s""",
|
|
|
|
|
|
(status, status, event_id, ticket_id),
|
|
|
|
|
|
fetch=False
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {"status": "success", "message": "Event updated"}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error updating calendar event: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/tickets/{ticket_id}/calendar-events/{event_id}", tags=["Calendar"])
|
|
|
|
|
|
async def delete_calendar_event(ticket_id: int, event_id: int):
|
|
|
|
|
|
"""Slet calendar event"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
execute_query(
|
|
|
|
|
|
"DELETE FROM tticket_calendar_events WHERE id = %s AND ticket_id = %s",
|
|
|
|
|
|
(event_id, ticket_id),
|
|
|
|
|
|
fetch=False
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {"status": "success", "message": "Event deleted"}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error deleting calendar event: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# TEMPLATES ENDPOINTS
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/templates", response_model=List[TTicketTemplate], tags=["Templates"])
|
|
|
|
|
|
async def list_templates(
|
|
|
|
|
|
category: Optional[str] = Query(None, description="Filter by category"),
|
|
|
|
|
|
active_only: bool = Query(True, description="Only show active templates")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""List alle tilgængelige templates"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
query = "SELECT * FROM tticket_templates WHERE 1=1"
|
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
|
|
if category:
|
|
|
|
|
|
query += " AND category = %s"
|
|
|
|
|
|
params.append(category)
|
|
|
|
|
|
|
|
|
|
|
|
if active_only:
|
|
|
|
|
|
query += " AND is_active = true"
|
|
|
|
|
|
|
|
|
|
|
|
query += " ORDER BY category, name"
|
|
|
|
|
|
|
|
|
|
|
|
templates = execute_query(query, tuple(params) if params else None)
|
|
|
|
|
|
|
|
|
|
|
|
return templates
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error listing templates: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/templates", tags=["Templates"])
|
|
|
|
|
|
async def create_template(template: TTicketTemplateCreate):
|
|
|
|
|
|
"""Opret ny template"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
template_id = execute_insert(
|
|
|
|
|
|
"""INSERT INTO tticket_templates
|
|
|
|
|
|
(name, description, category, subject_template, body_template,
|
|
|
|
|
|
available_placeholders, default_attachments, is_active,
|
|
|
|
|
|
requires_approval, created_by_user_id)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 1)
|
|
|
|
|
|
RETURNING id""",
|
|
|
|
|
|
(template.name, template.description, template.category,
|
|
|
|
|
|
template.subject_template, template.body_template,
|
|
|
|
|
|
template.available_placeholders, template.default_attachments,
|
|
|
|
|
|
template.is_active, template.requires_approval)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Created template {template_id}: {template.name}")
|
|
|
|
|
|
|
|
|
|
|
|
return {"status": "success", "template_id": template_id, "message": "Template created"}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error creating template: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/tickets/{ticket_id}/render-template", response_model=TemplateRenderResponse, tags=["Templates"])
|
|
|
|
|
|
async def render_template(ticket_id: int, request: TemplateRenderRequest):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Render template med ticket data
|
|
|
|
|
|
|
|
|
|
|
|
Erstatter placeholders med faktiske værdier:
|
|
|
|
|
|
- {{ticket_number}}
|
|
|
|
|
|
- {{ticket_subject}}
|
|
|
|
|
|
- {{customer_name}}
|
|
|
|
|
|
- {{contact_name}}
|
|
|
|
|
|
- etc.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Get template
|
|
|
|
|
|
template = execute_query_single(
|
|
|
|
|
|
"SELECT * FROM tticket_templates WHERE id = %s",
|
|
|
|
|
|
(request.template_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
if not template:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Template not found")
|
|
|
|
|
|
|
|
|
|
|
|
# Get ticket with customer and contact data
|
|
|
|
|
|
ticket_data = execute_query_single(
|
|
|
|
|
|
"""SELECT t.*,
|
|
|
|
|
|
c.name as customer_name,
|
|
|
|
|
|
con.name as contact_name,
|
|
|
|
|
|
con.email as contact_email
|
|
|
|
|
|
FROM tticket_tickets t
|
|
|
|
|
|
LEFT JOIN customers c ON t.customer_id = c.id
|
|
|
|
|
|
LEFT JOIN contacts con ON t.contact_id = con.id
|
|
|
|
|
|
WHERE t.id = %s""",
|
|
|
|
|
|
(ticket_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
if not ticket_data:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Ticket not found")
|
|
|
|
|
|
|
|
|
|
|
|
# Build replacement dict
|
|
|
|
|
|
replacements = {
|
|
|
|
|
|
'{{ticket_number}}': ticket_data.get('ticket_number', ''),
|
|
|
|
|
|
'{{ticket_subject}}': ticket_data.get('subject', ''),
|
|
|
|
|
|
'{{customer_name}}': ticket_data.get('customer_name', ''),
|
|
|
|
|
|
'{{contact_name}}': ticket_data.get('contact_name', ''),
|
|
|
|
|
|
'{{contact_email}}': ticket_data.get('contact_email', ''),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Add custom data
|
|
|
|
|
|
if request.custom_data:
|
|
|
|
|
|
for key, value in request.custom_data.items():
|
|
|
|
|
|
replacements[f'{{{{{key}}}}}'] = str(value)
|
|
|
|
|
|
|
|
|
|
|
|
# Render subject and body
|
|
|
|
|
|
rendered_subject = template['subject_template']
|
|
|
|
|
|
rendered_body = template['body_template']
|
|
|
|
|
|
placeholders_used = []
|
|
|
|
|
|
|
|
|
|
|
|
for placeholder, value in replacements.items():
|
|
|
|
|
|
if placeholder in rendered_body or (rendered_subject and placeholder in rendered_subject):
|
|
|
|
|
|
placeholders_used.append(placeholder)
|
|
|
|
|
|
if rendered_subject:
|
|
|
|
|
|
rendered_subject = rendered_subject.replace(placeholder, value)
|
|
|
|
|
|
rendered_body = rendered_body.replace(placeholder, value)
|
|
|
|
|
|
|
|
|
|
|
|
return TemplateRenderResponse(
|
|
|
|
|
|
subject=rendered_subject,
|
|
|
|
|
|
body=rendered_body,
|
|
|
|
|
|
placeholders_used=placeholders_used
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error rendering template: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# AI SUGGESTIONS ENDPOINTS
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/tickets/{ticket_id}/suggestions", response_model=List[TTicketAISuggestion], tags=["AI Suggestions"])
|
|
|
|
|
|
async def get_ai_suggestions(
|
|
|
|
|
|
ticket_id: int,
|
|
|
|
|
|
status: Optional[AISuggestionStatus] = Query(None, description="Filter by status"),
|
|
|
|
|
|
suggestion_type: Optional[AISuggestionType] = Query(None, description="Filter by type")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Hent AI forslag for ticket"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
query = "SELECT * FROM tticket_ai_suggestions WHERE ticket_id = %s"
|
|
|
|
|
|
params = [ticket_id]
|
|
|
|
|
|
|
|
|
|
|
|
if status:
|
|
|
|
|
|
query += " AND status = %s"
|
|
|
|
|
|
params.append(status)
|
|
|
|
|
|
|
|
|
|
|
|
if suggestion_type:
|
|
|
|
|
|
query += " AND suggestion_type = %s"
|
|
|
|
|
|
params.append(suggestion_type)
|
|
|
|
|
|
|
|
|
|
|
|
query += " ORDER BY created_at DESC"
|
|
|
|
|
|
|
|
|
|
|
|
suggestions = execute_query(query, tuple(params))
|
|
|
|
|
|
|
|
|
|
|
|
return suggestions
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error fetching AI suggestions: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/tickets/{ticket_id}/suggestions/{suggestion_id}/review", tags=["AI Suggestions"])
|
|
|
|
|
|
async def review_ai_suggestion(ticket_id: int, suggestion_id: int, review: AISuggestionReviewRequest):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Accepter eller afvis AI forslag
|
|
|
|
|
|
|
|
|
|
|
|
**VIGTIGT**: Denne endpoint ændrer KUN suggestion status.
|
|
|
|
|
|
Den udfører IKKE automatisk den foreslåede handling.
|
|
|
|
|
|
Brugeren skal selv implementere ændringen efter accept.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Get suggestion
|
|
|
|
|
|
suggestion = execute_query_single(
|
|
|
|
|
|
"SELECT * FROM tticket_ai_suggestions WHERE id = %s AND ticket_id = %s",
|
|
|
|
|
|
(suggestion_id, ticket_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
if not suggestion:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Suggestion not found")
|
|
|
|
|
|
|
|
|
|
|
|
if suggestion['status'] != 'pending':
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Suggestion already {suggestion['status']}")
|
|
|
|
|
|
|
|
|
|
|
|
# Update status
|
|
|
|
|
|
new_status = 'accepted' if review.action == 'accept' else 'rejected'
|
|
|
|
|
|
execute_query(
|
|
|
|
|
|
"""UPDATE tticket_ai_suggestions
|
|
|
|
|
|
SET status = %s, reviewed_by_user_id = 1, reviewed_at = CURRENT_TIMESTAMP
|
|
|
|
|
|
WHERE id = %s""",
|
|
|
|
|
|
(new_status, suggestion_id),
|
|
|
|
|
|
fetch=False
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Log audit
|
|
|
|
|
|
execute_query(
|
|
|
|
|
|
"""INSERT INTO tticket_audit_log (ticket_id, action, new_value, reason)
|
|
|
|
|
|
VALUES (%s, %s, %s, %s)""",
|
|
|
|
|
|
(ticket_id, f'ai_suggestion_{review.action}ed',
|
|
|
|
|
|
f"{suggestion['suggestion_type']}: {suggestion_id}", review.note)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ AI suggestion {suggestion_id} {review.action}ed for ticket {ticket_id}")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "success",
|
|
|
|
|
|
"action": review.action,
|
|
|
|
|
|
"suggestion_type": suggestion['suggestion_type'],
|
|
|
|
|
|
"message": f"Suggestion {review.action}ed. Manual implementation required if accepted."
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error reviewing AI suggestion: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# DEADLINE ENDPOINT
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/tickets/{ticket_id}/deadline", tags=["Tickets"])
|
|
|
|
|
|
async def update_ticket_deadline(ticket_id: int, request: TicketDeadlineUpdateRequest):
|
|
|
|
|
|
"""Opdater ticket deadline"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Get current deadline
|
|
|
|
|
|
current = execute_query_single(
|
|
|
|
|
|
"SELECT deadline FROM tticket_tickets WHERE id = %s",
|
|
|
|
|
|
(ticket_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
if not current:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Ticket not found")
|
|
|
|
|
|
|
|
|
|
|
|
# Update deadline
|
|
|
|
|
|
execute_query(
|
|
|
|
|
|
"UPDATE tticket_tickets SET deadline = %s WHERE id = %s",
|
|
|
|
|
|
(request.deadline, ticket_id),
|
|
|
|
|
|
fetch=False
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Log audit (handled by trigger automatically)
|
|
|
|
|
|
if request.reason:
|
|
|
|
|
|
execute_query(
|
|
|
|
|
|
"""INSERT INTO tticket_audit_log (ticket_id, action, field_name, old_value, new_value, reason)
|
|
|
|
|
|
VALUES (%s, 'deadline_change', 'deadline', %s, %s, %s)""",
|
|
|
|
|
|
(ticket_id, str(current.get('deadline')), str(request.deadline), request.reason)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Updated deadline for ticket {ticket_id}: {request.deadline}")
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "success",
|
|
|
|
|
|
"old_deadline": current.get('deadline'),
|
|
|
|
|
|
"new_deadline": request.deadline,
|
|
|
|
|
|
"message": "Deadline updated"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
except HTTPException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error updating deadline: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# AUDIT LOG ENDPOINT
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/tickets/{ticket_id}/audit-log", response_model=List[TTicketAuditLog], tags=["Audit"])
|
|
|
|
|
|
async def get_audit_log(
|
|
|
|
|
|
ticket_id: int,
|
|
|
|
|
|
limit: int = Query(50, ge=1, le=200, description="Number of entries"),
|
|
|
|
|
|
offset: int = Query(0, ge=0, description="Offset for pagination")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Hent audit log for ticket (sporbarhed)"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
logs = execute_query(
|
|
|
|
|
|
"""SELECT * FROM tticket_audit_log
|
|
|
|
|
|
WHERE ticket_id = %s
|
|
|
|
|
|
ORDER BY performed_at DESC
|
|
|
|
|
|
LIMIT %s OFFSET %s""",
|
|
|
|
|
|
(ticket_id, limit, offset)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return logs
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error fetching audit log: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
2026-02-08 01:45:00 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
# ARCHIVED TICKETS (SIMPLY-CRM IMPORT)
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/archived/simply/import", tags=["Archived Tickets"])
|
|
|
|
|
|
async def import_simply_archived_tickets(
|
|
|
|
|
|
limit: int = Query(5000, ge=1, le=50000, description="Maximum tickets to import"),
|
|
|
|
|
|
include_messages: bool = Query(True, description="Include comments and emails"),
|
|
|
|
|
|
ticket_number: Optional[str] = Query(None, description="Import a single ticket by number"),
|
|
|
|
|
|
force: bool = Query(False, description="Update even if sync hash matches")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
One-time import of archived tickets from Simply-CRM.
|
|
|
|
|
|
"""
|
|
|
|
|
|
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0}
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with SimplyCRMService() as service:
|
|
|
|
|
|
if ticket_number:
|
|
|
|
|
|
module_name = getattr(settings, "SIMPLYCRM_TICKET_MODULE", "HelpDesk")
|
|
|
|
|
|
sanitized = _escape_simply_value(ticket_number)
|
|
|
|
|
|
tickets = []
|
|
|
|
|
|
for field in ("ticket_no", "ticketnumber", "ticket_number"):
|
|
|
|
|
|
query = f"SELECT * FROM {module_name} WHERE {field} = '{sanitized}';"
|
|
|
|
|
|
tickets = await service.query(query)
|
|
|
|
|
|
if tickets:
|
|
|
|
|
|
break
|
|
|
|
|
|
else:
|
|
|
|
|
|
tickets = await service.fetch_tickets(limit=limit)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"🔍 Importing {len(tickets)} archived tickets from Simply-CRM")
|
|
|
|
|
|
|
|
|
|
|
|
account_cache: dict[str, Optional[str]] = {}
|
|
|
|
|
|
contact_cache: dict[str, Optional[str]] = {}
|
|
|
|
|
|
|
|
|
|
|
|
for ticket in tickets:
|
|
|
|
|
|
try:
|
|
|
|
|
|
external_id = _get_first_value(ticket, ["id", "ticketid", "ticket_id"])
|
|
|
|
|
|
if not external_id:
|
|
|
|
|
|
stats["skipped"] += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
data_hash = _calculate_hash(ticket)
|
|
|
|
|
|
existing = execute_query_single(
|
|
|
|
|
|
"""SELECT id, sync_hash
|
|
|
|
|
|
FROM tticket_archived_tickets
|
|
|
|
|
|
WHERE source_system = %s AND external_id = %s""",
|
|
|
|
|
|
("simplycrm", external_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
ticket_number = _get_first_value(
|
|
|
|
|
|
ticket,
|
|
|
|
|
|
["ticket_no", "ticketnumber", "ticket_number", "ticketid", "ticket_id", "id"]
|
|
|
|
|
|
)
|
|
|
|
|
|
title = _get_first_value(
|
|
|
|
|
|
ticket,
|
|
|
|
|
|
["title", "subject", "ticket_title", "tickettitle", "summary"]
|
|
|
|
|
|
)
|
|
|
|
|
|
organization_name = _get_first_value(
|
|
|
|
|
|
ticket,
|
|
|
|
|
|
["accountname", "account_name", "organization", "company"]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
account_id = _get_first_value(
|
|
|
|
|
|
ticket,
|
|
|
|
|
|
["parent_id", "account_id", "accountid", "account"]
|
|
|
|
|
|
)
|
|
|
|
|
|
if not organization_name and _looks_like_external_id(account_id):
|
|
|
|
|
|
if account_id not in account_cache:
|
|
|
|
|
|
related = await service.retrieve(account_id)
|
|
|
|
|
|
account_cache[account_id] = _get_first_value(
|
|
|
|
|
|
related or {},
|
|
|
|
|
|
["accountname", "account_name", "name"]
|
|
|
|
|
|
)
|
|
|
|
|
|
if not account_cache[account_id]:
|
|
|
|
|
|
first_name = _get_first_value(related or {}, ["firstname", "first_name", "first"])
|
|
|
|
|
|
last_name = _get_first_value(related or {}, ["lastname", "last_name", "last"])
|
|
|
|
|
|
combined = " ".join([name for name in [first_name, last_name] if name])
|
|
|
|
|
|
if combined:
|
|
|
|
|
|
account_cache[account_id] = None
|
|
|
|
|
|
if not contact_name:
|
|
|
|
|
|
contact_name = combined
|
|
|
|
|
|
related_account_id = _get_first_value(
|
|
|
|
|
|
related or {},
|
|
|
|
|
|
["account_id", "accountid", "account"]
|
|
|
|
|
|
)
|
|
|
|
|
|
if related_account_id and _looks_like_external_id(related_account_id):
|
|
|
|
|
|
if related_account_id not in account_cache:
|
|
|
|
|
|
account = await service.retrieve(related_account_id)
|
|
|
|
|
|
account_cache[related_account_id] = _get_first_value(
|
|
|
|
|
|
account or {},
|
|
|
|
|
|
["accountname", "account_name", "name"]
|
|
|
|
|
|
)
|
|
|
|
|
|
organization_name = account_cache.get(related_account_id)
|
|
|
|
|
|
if not organization_name:
|
|
|
|
|
|
organization_name = account_cache.get(account_id)
|
|
|
|
|
|
|
|
|
|
|
|
contact_name = _get_first_value(
|
|
|
|
|
|
ticket,
|
|
|
|
|
|
["contactname", "contact_name", "contact"]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
contact_id = _get_first_value(
|
|
|
|
|
|
ticket,
|
|
|
|
|
|
["contact_id", "contactid"]
|
|
|
|
|
|
)
|
|
|
|
|
|
if not contact_name and _looks_like_external_id(contact_id):
|
|
|
|
|
|
if contact_id not in contact_cache:
|
|
|
|
|
|
contact = await service.retrieve(contact_id)
|
|
|
|
|
|
first_name = _get_first_value(contact or {}, ["firstname", "first_name", "first"])
|
|
|
|
|
|
last_name = _get_first_value(contact or {}, ["lastname", "last_name", "last"])
|
|
|
|
|
|
combined = " ".join([name for name in [first_name, last_name] if name])
|
|
|
|
|
|
contact_cache[contact_id] = combined or _get_first_value(
|
|
|
|
|
|
contact or {},
|
|
|
|
|
|
["contactname", "name"]
|
|
|
|
|
|
)
|
|
|
|
|
|
if not organization_name:
|
|
|
|
|
|
related_account_id = _get_first_value(
|
|
|
|
|
|
contact or {},
|
|
|
|
|
|
["account_id", "accountid", "account"]
|
|
|
|
|
|
)
|
|
|
|
|
|
if related_account_id and _looks_like_external_id(related_account_id):
|
|
|
|
|
|
if related_account_id not in account_cache:
|
|
|
|
|
|
account = await service.retrieve(related_account_id)
|
|
|
|
|
|
account_cache[related_account_id] = _get_first_value(
|
|
|
|
|
|
account or {},
|
|
|
|
|
|
["accountname", "account_name", "name"]
|
|
|
|
|
|
)
|
|
|
|
|
|
organization_name = account_cache.get(related_account_id)
|
|
|
|
|
|
contact_name = contact_cache.get(contact_id)
|
|
|
|
|
|
|
|
|
|
|
|
email_from = _get_first_value(
|
|
|
|
|
|
ticket,
|
|
|
|
|
|
["email_from", "from_email", "from", "email", "email_from_address"]
|
|
|
|
|
|
)
|
|
|
|
|
|
time_spent_hours = _parse_hours(
|
|
|
|
|
|
_get_first_value(ticket, ["time_spent", "hours", "time_spent_hours", "spent_time", "cf_time_spent", "cf_tid_brugt"])
|
|
|
|
|
|
)
|
|
|
|
|
|
description = _get_first_value(
|
|
|
|
|
|
ticket,
|
|
|
|
|
|
["description", "ticket_description", "comments", "issue"]
|
|
|
|
|
|
)
|
|
|
|
|
|
solution = _get_first_value(
|
|
|
|
|
|
ticket,
|
|
|
|
|
|
["solution", "resolution", "solutiontext", "resolution_text"]
|
|
|
|
|
|
)
|
|
|
|
|
|
status = _get_first_value(
|
|
|
|
|
|
ticket,
|
|
|
|
|
|
["status", "ticketstatus", "state"]
|
|
|
|
|
|
)
|
|
|
|
|
|
priority = _get_first_value(
|
|
|
|
|
|
ticket,
|
|
|
|
|
|
["priority", "ticketpriorities", "ticketpriority"]
|
|
|
|
|
|
)
|
|
|
|
|
|
source_created_at = _parse_datetime(
|
|
|
|
|
|
_get_first_value(ticket, ["createdtime", "created_at", "createdon", "created_time"])
|
|
|
|
|
|
)
|
|
|
|
|
|
source_updated_at = _parse_datetime(
|
|
|
|
|
|
_get_first_value(ticket, ["modifiedtime", "updated_at", "modified_time", "updatedtime"])
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if existing:
|
|
|
|
|
|
if not force and existing.get("sync_hash") == data_hash:
|
|
|
|
|
|
stats["skipped"] += 1
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"""
|
|
|
|
|
|
UPDATE tticket_archived_tickets
|
|
|
|
|
|
SET ticket_number = %s,
|
|
|
|
|
|
title = %s,
|
|
|
|
|
|
organization_name = %s,
|
|
|
|
|
|
contact_name = %s,
|
|
|
|
|
|
email_from = %s,
|
|
|
|
|
|
time_spent_hours = %s,
|
|
|
|
|
|
description = %s,
|
|
|
|
|
|
solution = %s,
|
|
|
|
|
|
status = %s,
|
|
|
|
|
|
priority = %s,
|
|
|
|
|
|
source_created_at = %s,
|
|
|
|
|
|
source_updated_at = %s,
|
|
|
|
|
|
last_synced_at = CURRENT_TIMESTAMP,
|
|
|
|
|
|
sync_hash = %s,
|
|
|
|
|
|
raw_data = %s::jsonb
|
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
ticket_number,
|
|
|
|
|
|
title,
|
|
|
|
|
|
organization_name,
|
|
|
|
|
|
contact_name,
|
|
|
|
|
|
email_from,
|
|
|
|
|
|
time_spent_hours,
|
|
|
|
|
|
description,
|
|
|
|
|
|
solution,
|
|
|
|
|
|
status,
|
|
|
|
|
|
priority,
|
|
|
|
|
|
source_created_at,
|
|
|
|
|
|
source_updated_at,
|
|
|
|
|
|
data_hash,
|
|
|
|
|
|
json.dumps(ticket, default=str),
|
|
|
|
|
|
existing["id"]
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
archived_ticket_id = existing["id"]
|
|
|
|
|
|
stats["updated"] += 1
|
|
|
|
|
|
else:
|
|
|
|
|
|
archived_ticket_id = execute_insert(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO tticket_archived_tickets (
|
|
|
|
|
|
source_system,
|
|
|
|
|
|
external_id,
|
|
|
|
|
|
ticket_number,
|
|
|
|
|
|
title,
|
|
|
|
|
|
organization_name,
|
|
|
|
|
|
contact_name,
|
|
|
|
|
|
email_from,
|
|
|
|
|
|
time_spent_hours,
|
|
|
|
|
|
description,
|
|
|
|
|
|
solution,
|
|
|
|
|
|
status,
|
|
|
|
|
|
priority,
|
|
|
|
|
|
source_created_at,
|
|
|
|
|
|
source_updated_at,
|
|
|
|
|
|
last_synced_at,
|
|
|
|
|
|
sync_hash,
|
|
|
|
|
|
raw_data
|
|
|
|
|
|
) VALUES (
|
|
|
|
|
|
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
|
|
|
|
|
CURRENT_TIMESTAMP, %s, %s::jsonb
|
|
|
|
|
|
)
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
"simplycrm",
|
|
|
|
|
|
external_id,
|
|
|
|
|
|
ticket_number,
|
|
|
|
|
|
title,
|
|
|
|
|
|
organization_name,
|
|
|
|
|
|
contact_name,
|
|
|
|
|
|
email_from,
|
|
|
|
|
|
time_spent_hours,
|
|
|
|
|
|
description,
|
|
|
|
|
|
solution,
|
|
|
|
|
|
status,
|
|
|
|
|
|
priority,
|
|
|
|
|
|
source_created_at,
|
|
|
|
|
|
source_updated_at,
|
|
|
|
|
|
data_hash,
|
|
|
|
|
|
json.dumps(ticket, default=str)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
stats["imported"] += 1
|
|
|
|
|
|
|
|
|
|
|
|
if include_messages and archived_ticket_id:
|
|
|
|
|
|
execute_update(
|
|
|
|
|
|
"DELETE FROM tticket_archived_messages WHERE archived_ticket_id = %s",
|
|
|
|
|
|
(archived_ticket_id,)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
comments = await service.fetch_ticket_comments(external_id)
|
|
|
|
|
|
emails = await service.fetch_ticket_emails(external_id)
|
|
|
|
|
|
|
|
|
|
|
|
for comment in comments:
|
|
|
|
|
|
execute_insert(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO tticket_archived_messages (
|
|
|
|
|
|
archived_ticket_id,
|
|
|
|
|
|
message_type,
|
|
|
|
|
|
subject,
|
|
|
|
|
|
body,
|
|
|
|
|
|
author_name,
|
|
|
|
|
|
author_email,
|
|
|
|
|
|
source_created_at,
|
|
|
|
|
|
raw_data
|
|
|
|
|
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
archived_ticket_id,
|
|
|
|
|
|
"comment",
|
|
|
|
|
|
None,
|
|
|
|
|
|
_get_first_value(comment, ["commentcontent", "comment", "content", "description"]),
|
|
|
|
|
|
_get_first_value(comment, ["author", "assigned_user_id", "created_by", "creator"]),
|
|
|
|
|
|
_get_first_value(comment, ["email", "author_email", "from_email"]),
|
|
|
|
|
|
_parse_datetime(_get_first_value(comment, ["createdtime", "created_at", "created_time"])),
|
|
|
|
|
|
json.dumps(comment, default=str)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
for email in emails:
|
|
|
|
|
|
execute_insert(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO tticket_archived_messages (
|
|
|
|
|
|
archived_ticket_id,
|
|
|
|
|
|
message_type,
|
|
|
|
|
|
subject,
|
|
|
|
|
|
body,
|
|
|
|
|
|
author_name,
|
|
|
|
|
|
author_email,
|
|
|
|
|
|
source_created_at,
|
|
|
|
|
|
raw_data
|
|
|
|
|
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
archived_ticket_id,
|
|
|
|
|
|
"email",
|
|
|
|
|
|
_get_first_value(email, ["subject", "title"]),
|
|
|
|
|
|
_get_first_value(email, ["description", "body", "email_body", "content"]),
|
|
|
|
|
|
_get_first_value(email, ["from_name", "sender", "assigned_user_id"]),
|
|
|
|
|
|
_get_first_value(email, ["from_email", "email", "sender_email"]),
|
|
|
|
|
|
_parse_datetime(_get_first_value(email, ["createdtime", "created_at", "created_time"])),
|
|
|
|
|
|
json.dumps(email, default=str)
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Archived ticket import failed: {e}")
|
|
|
|
|
|
stats["errors"] += 1
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Archived ticket import complete: {stats}")
|
|
|
|
|
|
return stats
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Archived ticket import failed: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/archived/simply/modules", tags=["Archived Tickets"])
|
|
|
|
|
|
async def list_simply_modules():
|
|
|
|
|
|
"""
|
|
|
|
|
|
List available Simply-CRM modules (debug helper).
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with SimplyCRMService() as service:
|
|
|
|
|
|
modules = await service.list_types()
|
|
|
|
|
|
return {"modules": modules}
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to list Simply-CRM modules: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/archived/simply/ticket", tags=["Archived Tickets"])
|
|
|
|
|
|
async def fetch_simply_ticket(
|
|
|
|
|
|
ticket_number: Optional[str] = Query(None, description="Ticket number, e.g. TT934"),
|
|
|
|
|
|
external_id: Optional[str] = Query(None, description="VTiger record ID, e.g. 17x1234")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Fetch a single HelpDesk ticket from Simply-CRM by ticket number or record id.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not ticket_number and not external_id:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Provide ticket_number or external_id")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with SimplyCRMService() as service:
|
|
|
|
|
|
module_name = getattr(settings, "SIMPLYCRM_TICKET_MODULE", "HelpDesk")
|
|
|
|
|
|
|
|
|
|
|
|
if external_id:
|
|
|
|
|
|
record = await service.retrieve(external_id)
|
|
|
|
|
|
return {"module": module_name, "records": [record] if record else []}
|
|
|
|
|
|
|
|
|
|
|
|
sanitized = _escape_simply_value(ticket_number or "")
|
|
|
|
|
|
fields = ["ticket_no", "ticketnumber", "ticket_number"]
|
|
|
|
|
|
for field in fields:
|
|
|
|
|
|
query = f"SELECT * FROM {module_name} WHERE {field} = '{sanitized}';"
|
|
|
|
|
|
records = await service.query(query)
|
|
|
|
|
|
if records:
|
|
|
|
|
|
return {"module": module_name, "match_field": field, "records": records}
|
|
|
|
|
|
|
|
|
|
|
|
return {"module": module_name, "records": []}
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to fetch Simply-CRM ticket: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/archived/simply/record", tags=["Archived Tickets"])
|
|
|
|
|
|
async def fetch_simply_record(
|
|
|
|
|
|
record_id: str = Query(..., description="VTiger record ID, e.g. 11x2601"),
|
|
|
|
|
|
module: Optional[str] = Query(None, description="Optional module name for context")
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Fetch a single record from Simply-CRM by record id.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with SimplyCRMService() as service:
|
|
|
|
|
|
record = await service.retrieve(record_id)
|
|
|
|
|
|
return {"module": module, "record": record}
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Failed to fetch Simply-CRM record: {e}")
|
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|