""" Ticket Module API Router ========================= REST API endpoints for ticket system. """ import logging 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.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 ) from app.core.database import execute_query, execute_insert, execute_update from datetime import date logger = logging.getLogger(__name__) router = APIRouter() # ============================================================================ # 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(total_query, tuple(params), fetchone=True) 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( "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)) # ============================================================================ # 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. """ try: from psycopg2.extras import Json worklog_id = execute_insert( """ INSERT INTO tticket_worklog (ticket_id, work_date, hours, work_type, description, billing_method, status, user_id, prepaid_card_id) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( 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, worklog_data.prepaid_card_id ) ) # 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} ) worklog = execute_query( "SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,), fetchone=True ) 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. """ try: # Get current worklog current = execute_query( "SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,), fetchone=True ) 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) 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( "SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,), fetchone=True ) 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(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,), fetchone=True ) 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( "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 """, fetchone=True ) 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))