bmc_hub/app/ticket/backend/router.py

2191 lines
85 KiB
Python
Raw Normal View History

"""
Ticket Module API Router
=========================
REST API endpoints for ticket system.
"""
import logging
import hashlib
import json
import re
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
from app.services.simplycrm_service import SimplyCRMService
from app.core.config import settings
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,
WorklogBillingRequest,
# Migration 026 models
TTicketRelation,
TTicketRelationCreate,
TTicketCalendarEvent,
TTicketCalendarEventCreate,
CalendarEventStatus,
TTicketTemplate,
TTicketTemplateCreate,
TemplateRenderRequest,
TemplateRenderResponse,
TTicketAISuggestion,
TTicketAISuggestionCreate,
AISuggestionStatus,
AISuggestionType,
AISuggestionReviewRequest,
TTicketAuditLog,
TicketMergeRequest,
TicketSplitRequest,
TicketDeadlineUpdateRequest
)
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from datetime import date, datetime
logger = logging.getLogger(__name__)
router = APIRouter()
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("'", "''")
# ============================================================================
# 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)
total_result = execute_query_single(total_query, tuple(params))
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:
comments = execute_query_single(
"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))
# ============================================================================
# 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))
# ============================================================================
# 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.
If billing_method is 'prepaid_card', validates and auto-selects card when only 1 active.
"""
try:
from psycopg2.extras import Json
# 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")
# 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)
worklog_id = execute_insert(
"""
INSERT INTO tticket_worklog
(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)
RETURNING id
""",
(
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,
prepaid_card_id,
worklog_data.is_internal,
rounded_hours
)
)
# Log audit
TicketService.log_audit(
ticket_id=ticket_id,
entity_type="worklog",
entity_id=worklog_id,
user_id=user_id,
action="created",
details={
"hours": float(worklog_data.hours),
"work_type": worklog_data.work_type.value,
"is_internal": worklog_data.is_internal
}
)
worklog = execute_query_single(
"SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,))
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.
If billing_method changes to 'prepaid_card', validates and auto-selects card when only 1 active.
"""
try:
# Get current worklog
current = execute_query_single(
"SELECT w.*, t.customer_id FROM tticket_worklog w JOIN tticket_tickets t ON w.ticket_id = t.id WHERE w.id = %s",
(worklog_id,))
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)
# 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")
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
worklog = execute_query_single(
"SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,))
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"
worklogs = execute_query_single(query, tuple(params))
# 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",
(worklog_id,))
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:
stats = execute_query_single(
"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
""")
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))
# ============================================================================
# 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))
# ============================================================================
# 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))