""" Main API Router for Time Tracking Module ========================================= Samler alle endpoints for modulet. Isoleret routing uden påvirkning af existing Hub endpoints. """ import logging from typing import Optional, List from fastapi import APIRouter, HTTPException, Depends from fastapi.responses import JSONResponse from app.core.database import execute_query, execute_update from app.timetracking.backend.models import ( TModuleSyncStats, TModuleApprovalStats, TModuleWizardNextEntry, TModuleWizardProgress, TModuleTimeApproval, TModuleTimeWithContext, TModuleOrder, TModuleOrderWithLines, TModuleEconomicExportRequest, TModuleEconomicExportResult, TModuleMetadata, TModuleUninstallRequest, TModuleUninstallResult ) from app.timetracking.backend.vtiger_sync import vtiger_service from app.timetracking.backend.wizard import wizard from app.timetracking.backend.order_service import order_service from app.timetracking.backend.economic_export import economic_service from app.timetracking.backend.audit import audit logger = logging.getLogger(__name__) router = APIRouter() # ============================================================================ # SYNC ENDPOINTS # ============================================================================ @router.post("/sync", response_model=TModuleSyncStats, tags=["Sync"]) async def sync_from_vtiger( user_id: Optional[int] = None, fetch_comments: bool = False ): """ 🔍 Synkroniser data fra vTiger (READ-ONLY). Henter: - Accounts (kunder) - HelpDesk (cases) - ModComments (tidsregistreringer) Gemmes i tmodule_* tabeller (isoleret). Args: user_id: ID på bruger der kører sync fetch_comments: Hent også interne kommentarer (langsomt - ~0.4s pr case) """ try: logger.info("🚀 Starting vTiger sync...") result = await vtiger_service.full_sync(user_id=user_id, fetch_comments=fetch_comments) return result except Exception as e: logger.error(f"❌ Sync failed: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/sync/case/{case_id}/comments", tags=["Sync"]) async def sync_case_comments(case_id: int): """ 🔍 Synkroniser kommentarer for en specifik case fra vTiger. Bruges til on-demand opdatering når man ser på en case i wizard. """ try: # Hent case fra database case = execute_query( "SELECT vtiger_id FROM tmodule_cases WHERE id = %s", (case_id,), fetchone=True ) if not case: raise HTTPException(status_code=404, detail="Case not found") # Sync comments result = await vtiger_service.sync_case_comments(case['vtiger_id']) if not result['success']: raise HTTPException(status_code=500, detail=result.get('error', 'Failed to sync comments')) return result except HTTPException: raise except Exception as e: logger.error(f"❌ Failed to sync comments for case {case_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/sync/test-connection", tags=["Sync"]) async def test_vtiger_connection(): """Test forbindelse til vTiger""" try: is_connected = await vtiger_service.test_connection() return { "connected": is_connected, "service": "vTiger CRM", "read_only": vtiger_service.read_only, "dry_run": vtiger_service.dry_run } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # ============================================================================ # WIZARD / APPROVAL ENDPOINTS # ============================================================================ @router.get("/wizard/stats", response_model=List[TModuleApprovalStats], tags=["Wizard"]) async def get_all_customer_stats(): """Hent approval statistik for alle kunder""" try: return wizard.get_all_customers_stats() except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.get("/wizard/next", response_model=TModuleWizardNextEntry, tags=["Wizard"]) async def get_next_pending_entry( customer_id: Optional[int] = None, exclude_time_card: bool = True ): """ Hent næste pending tidsregistrering til godkendelse. Query params: - customer_id: Filtrer til specifik kunde (optional) - exclude_time_card: Ekskluder klippekort-kunder (default: true) """ try: return wizard.get_next_pending_entry( customer_id=customer_id, exclude_time_card=exclude_time_card ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post("/wizard/approve/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"]) async def approve_time_entry( time_id: int, billable_hours: Optional[float] = None, hourly_rate: Optional[float] = None, rounding_method: Optional[str] = None, user_id: Optional[int] = None ): """ Godkend en tidsregistrering. Path params: - time_id: ID på tidsregistreringen Body (optional): - billable_hours: Timer efter godkendelse (hvis ikke angivet, bruges original_hours med auto-rounding) - hourly_rate: Timepris i DKK (override customer rate) - rounding_method: "up", "down", "nearest" (override default) """ try: from app.core.config import settings from decimal import Decimal # Hent timelog query = """ SELECT t.*, c.title as case_title, c.status as case_status, cust.name as customer_name, cust.hourly_rate as customer_rate FROM tmodule_times t JOIN tmodule_cases c ON t.case_id = c.id JOIN tmodule_customers cust ON t.customer_id = cust.id WHERE t.id = %s """ entry = execute_query(query, (time_id,), fetchone=True) if not entry: raise HTTPException(status_code=404, detail="Time entry not found") # Beregn approved_hours if billable_hours is None: approved_hours = Decimal(str(entry['original_hours'])) # Auto-afrund hvis enabled if settings.TIMETRACKING_AUTO_ROUND: increment = Decimal(str(settings.TIMETRACKING_ROUND_INCREMENT)) method = rounding_method or settings.TIMETRACKING_ROUND_METHOD if method == "up": approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_UP') * increment elif method == "down": approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_DOWN') * increment else: approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_HALF_UP') * increment else: approved_hours = Decimal(str(billable_hours)) # Opdater med hourly_rate hvis angivet if hourly_rate is not None: execute_update( "UPDATE tmodule_times SET hourly_rate = %s WHERE id = %s", (Decimal(str(hourly_rate)), time_id) ) # Godkend approval = TModuleTimeApproval( time_id=time_id, approved_hours=float(approved_hours) ) return wizard.approve_time_entry(approval, user_id=user_id) except HTTPException: raise except Exception as e: logger.error(f"❌ Error approving entry: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/wizard/reject/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"]) async def reject_time_entry( time_id: int, reason: Optional[str] = None, user_id: Optional[int] = None ): """Afvis en tidsregistrering""" try: return wizard.reject_time_entry(time_id, reason=reason, user_id=user_id) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post("/wizard/reset/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"]) async def reset_to_pending( time_id: int, reason: Optional[str] = None, user_id: Optional[int] = None ): """ Nulstil en godkendt/afvist tidsregistrering tilbage til pending. Query params: - reason: Årsag til nulstilling (optional) - user_id: ID på brugeren der nulstiller (optional) """ try: return wizard.reset_to_pending(time_id, reason=reason, user_id=user_id) except HTTPException: raise except Exception as e: logger.error(f"❌ Reset failed: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/wizard/case/{case_id}/entries", response_model=List[TModuleTimeWithContext], tags=["Wizard"]) async def get_case_entries( case_id: int, exclude_time_card: bool = True ): """ Hent alle pending timelogs for en case. Bruges til at vise alle tidsregistreringer i samme case grupperet. """ try: return wizard.get_case_entries(case_id, exclude_time_card=exclude_time_card) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.get("/wizard/case/{case_id}/details", tags=["Wizard"]) async def get_case_details(case_id: int): """ Hent komplet case information inkl. alle timelogs og kommentarer. Returnerer: - case_id, case_title, case_description, case_status - timelogs: ALLE tidsregistreringer (pending, approved, rejected) - case_comments: Kommentarer fra vTiger """ try: return wizard.get_case_details(case_id) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post("/wizard/case/{case_id}/approve-all", tags=["Wizard"]) async def approve_all_case_entries( case_id: int, user_id: Optional[int] = None, exclude_time_card: bool = True ): """ Bulk-godkend alle pending timelogs for en case. Afr under automatisk efter configured settings. """ try: return wizard.approve_case_entries( case_id=case_id, user_id=user_id, exclude_time_card=exclude_time_card ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.get("/wizard/progress/{customer_id}", response_model=TModuleWizardProgress, tags=["Wizard"]) async def get_customer_progress(customer_id: int): """Hent wizard progress for en kunde""" try: return wizard.get_customer_progress(customer_id) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # ============================================================================ # ORDER ENDPOINTS # ============================================================================ @router.post("/orders/generate/{customer_id}", response_model=TModuleOrderWithLines, tags=["Orders"]) async def generate_order(customer_id: int, user_id: Optional[int] = None): """ Generer ordre for alle godkendte tider for en kunde. Aggregerer: - Alle godkendte tidsregistreringer - Grupperet efter case - Beregner totals med moms Markerer tidsregistreringer som 'billed'. """ try: return order_service.generate_order_for_customer(customer_id, user_id=user_id) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.get("/orders", response_model=List[TModuleOrder], tags=["Orders"]) async def list_orders( customer_id: Optional[int] = None, status: Optional[str] = None, limit: int = 100 ): """ List ordrer med filtrering. Query params: - customer_id: Filtrer til specifik kunde - status: Filtrer på status (draft, exported, sent, cancelled) - limit: Max antal resultater (default: 100) """ try: return order_service.list_orders( customer_id=customer_id, status=status, limit=limit ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.get("/orders/{order_id}", response_model=TModuleOrderWithLines, tags=["Orders"]) async def get_order(order_id: int): """Hent ordre med linjer""" try: return order_service.get_order_with_lines(order_id) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post("/orders/{order_id}/cancel", response_model=TModuleOrder, tags=["Orders"]) async def cancel_order( order_id: int, reason: Optional[str] = None, user_id: Optional[int] = None ): """ Annuller en ordre. Kun muligt for draft orders (ikke exported). Resetter tidsregistreringer tilbage til 'approved'. """ try: return order_service.cancel_order(order_id, reason=reason, user_id=user_id) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # ============================================================================ # e-conomic EXPORT ENDPOINTS # ============================================================================ @router.post("/export", response_model=TModuleEconomicExportResult, tags=["Export"]) async def export_to_economic( request: TModuleEconomicExportRequest, user_id: Optional[int] = None ): """ 🚨 Eksporter ordre til e-conomic som draft order. SAFETY FLAGS: - TIMETRACKING_ECONOMIC_READ_ONLY (default: True) - TIMETRACKING_ECONOMIC_DRY_RUN (default: True) Hvis begge er enabled, køres kun dry-run simulation. Body: - order_id: ID på ordren - force: Re-eksporter selvom allerede eksporteret (default: false) """ try: return await economic_service.export_order(request, user_id=user_id) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.get("/export/test-connection", tags=["Export"]) async def test_economic_connection(): """Test forbindelse til e-conomic""" try: is_connected = await economic_service.test_connection() return { "connected": is_connected, "service": "e-conomic", "read_only": economic_service.read_only, "dry_run": economic_service.dry_run, "export_type": economic_service.export_type } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # ============================================================================ # MODULE METADATA & ADMIN ENDPOINTS # ============================================================================ @router.get("/metadata", response_model=TModuleMetadata, tags=["Admin"]) async def get_module_metadata(): """Hent modul metadata""" try: result = execute_query( "SELECT * FROM tmodule_metadata ORDER BY id DESC LIMIT 1", fetchone=True ) if not result: raise HTTPException(status_code=404, detail="Module metadata not found") return TModuleMetadata(**result) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.get("/health", tags=["Admin"]) async def module_health(): """Module health check""" try: # Check database tables exist tables_query = """ SELECT COUNT(*) as count FROM information_schema.tables WHERE table_name LIKE 'tmodule_%' """ result = execute_query(tables_query, fetchone=True) table_count = result['count'] if result else 0 # Get stats - count each table separately try: stats = { "customers": 0, "cases": 0, "times": 0, "orders": 0 } for table_name in ["customers", "cases", "times", "orders"]: count_result = execute_query( f"SELECT COUNT(*) as count FROM tmodule_{table_name}", fetchone=True ) stats[table_name] = count_result['count'] if count_result else 0 except Exception as e: stats = {"error": str(e)} return { "status": "healthy" if table_count >= 6 else "degraded", "module": "Time Tracking & Billing", "version": "1.0.0", "tables": table_count, "statistics": stats, "safety": { "vtiger_read_only": vtiger_service.read_only, "vtiger_dry_run": vtiger_service.dry_run, "economic_read_only": economic_service.read_only, "economic_dry_run": economic_service.dry_run } } except Exception as e: logger.error(f"Health check error: {e}") return JSONResponse( status_code=500, content={"status": "error", "message": str(e)} ) @router.get("/config", tags=["Admin"]) async def get_config(): """Hent modul konfiguration""" from app.core.config import settings return { "default_hourly_rate": float(settings.TIMETRACKING_DEFAULT_HOURLY_RATE), "auto_round": settings.TIMETRACKING_AUTO_ROUND, "round_increment": float(settings.TIMETRACKING_ROUND_INCREMENT), "round_method": settings.TIMETRACKING_ROUND_METHOD, "vtiger_read_only": settings.TIMETRACKING_VTIGER_READ_ONLY, "vtiger_dry_run": settings.TIMETRACKING_VTIGER_DRY_RUN, "economic_read_only": settings.TIMETRACKING_ECONOMIC_READ_ONLY, "economic_dry_run": settings.TIMETRACKING_ECONOMIC_DRY_RUN } # ============================================================================ # CUSTOMER MANAGEMENT ENDPOINTS # ============================================================================ @router.patch("/customers/{customer_id}/hourly-rate", tags=["Customers"]) async def update_customer_hourly_rate(customer_id: int, hourly_rate: float, user_id: Optional[int] = None): """ Opdater timepris for en kunde. Args: customer_id: Kunde ID hourly_rate: Ny timepris i DKK (f.eks. 850.00) """ try: from decimal import Decimal # Validate rate if hourly_rate < 0: raise HTTPException(status_code=400, detail="Hourly rate must be positive") rate_decimal = Decimal(str(hourly_rate)) # Update customer hourly rate execute_update( "UPDATE tmodule_customers SET hourly_rate = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s", (rate_decimal, customer_id) ) # Audit log audit.log_event( entity_type="customer", entity_id=str(customer_id), event_type="hourly_rate_updated", details={"hourly_rate": float(hourly_rate)}, user_id=user_id ) # Return updated customer customer = execute_query( "SELECT id, name, hourly_rate FROM tmodule_customers WHERE id = %s", (customer_id,), fetchone=True ) if not customer: raise HTTPException(status_code=404, detail="Customer not found") return { "customer_id": customer_id, "name": customer['name'], "hourly_rate": float(customer['hourly_rate']) if customer['hourly_rate'] else None, "updated": True } except HTTPException: raise except Exception as e: logger.error(f"❌ Error updating hourly rate: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.patch("/customers/{customer_id}/time-card", tags=["Customers"]) async def toggle_customer_time_card(customer_id: int, enabled: bool, user_id: Optional[int] = None): """ Skift klippekort-status for kunde. Klippekort-kunder faktureres eksternt og skal kunne skjules i godkendelsesflow. """ try: # Update customer time card flag execute_update( "UPDATE tmodule_customers SET uses_time_card = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s", (enabled, customer_id) ) # Audit log audit.log_event( entity_type="customer", entity_id=str(customer_id), event_type="time_card_toggled", details={"enabled": enabled}, user_id=user_id ) # Return updated customer customer = execute_query( "SELECT * FROM tmodule_customers WHERE id = %s", (customer_id,), fetchone=True ) if not customer: raise HTTPException(status_code=404, detail="Customer not found") return { "customer_id": customer_id, "name": customer['name'], "uses_time_card": customer['uses_time_card'], "updated": True } except HTTPException: raise except Exception as e: logger.error(f"❌ Error toggling time card: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/customers", tags=["Customers"]) async def list_customers( include_time_card: bool = True, only_with_entries: bool = False ): """ List kunder med filtrering. Query params: - include_time_card: Inkluder klippekort-kunder (default: true) - only_with_entries: Kun kunder med pending tidsregistreringer (default: false) """ try: if only_with_entries: # Use view that includes entry counts query = """ SELECT customer_id, customer_name, customer_vtiger_id, uses_time_card, total_entries, pending_count FROM tmodule_approval_stats WHERE total_entries > 0 """ if not include_time_card: query += " AND uses_time_card = false" query += " ORDER BY customer_name" customers = execute_query(query) else: # Simple customer list query = "SELECT * FROM tmodule_customers" if not include_time_card: query += " WHERE uses_time_card = false" query += " ORDER BY name" customers = execute_query(query) return {"customers": customers, "total": len(customers)} except Exception as e: logger.error(f"❌ Error listing customers: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/customers/{customer_id}/times", tags=["Customers"]) async def get_customer_time_entries(customer_id: int, status: Optional[str] = None): """ Hent alle tidsregistreringer for en kunde. Path params: - customer_id: Kunde ID Query params: - status: Filtrer på status (pending, approved, rejected, billed) """ try: query = """ SELECT t.*, COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title, c.vtiger_id AS case_vtiger_id, c.description AS case_description, cust.name AS customer_name FROM tmodule_times t LEFT JOIN tmodule_cases c ON t.case_id = c.id LEFT JOIN tmodule_customers cust ON t.customer_id = cust.id WHERE t.customer_id = %s """ params = [customer_id] if status: query += " AND t.status = %s" params.append(status) query += " ORDER BY t.worked_date DESC, t.id DESC" times = execute_query(query, tuple(params)) return {"times": times, "total": len(times)} except Exception as e: logger.error(f"❌ Error getting customer time entries: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.delete("/admin/uninstall", response_model=TModuleUninstallResult, tags=["Admin"]) async def uninstall_module( request: TModuleUninstallRequest, user_id: Optional[int] = None ): """ 🚨 SLET MODULET FULDSTÆNDIGT. ADVARSEL: Dette sletter ALLE data i modulet! Kan ikke fortrydes. Body: - confirm: SKAL være true - delete_all_data: SKAL være true """ try: # Validate request (Pydantic validators already check this) if not request.confirm or not request.delete_all_data: raise HTTPException( status_code=400, detail="You must confirm uninstall and data deletion" ) logger.warning(f"⚠️ UNINSTALLING TIME TRACKING MODULE (user_id: {user_id})") # Log uninstall audit.log_module_uninstalled(user_id=user_id) # Execute DROP script uninstall_script = """ DROP VIEW IF EXISTS tmodule_order_details CASCADE; DROP VIEW IF EXISTS tmodule_next_pending CASCADE; DROP VIEW IF EXISTS tmodule_approval_stats CASCADE; DROP TRIGGER IF EXISTS tmodule_orders_generate_number ON tmodule_orders; DROP TRIGGER IF EXISTS tmodule_orders_update ON tmodule_orders; DROP TRIGGER IF EXISTS tmodule_times_update ON tmodule_times; DROP TRIGGER IF EXISTS tmodule_cases_update ON tmodule_cases; DROP TRIGGER IF EXISTS tmodule_customers_update ON tmodule_customers; DROP FUNCTION IF EXISTS tmodule_generate_order_number() CASCADE; DROP FUNCTION IF EXISTS tmodule_update_timestamp() CASCADE; DROP TABLE IF EXISTS tmodule_sync_log CASCADE; DROP TABLE IF EXISTS tmodule_order_lines CASCADE; DROP TABLE IF EXISTS tmodule_orders CASCADE; DROP TABLE IF EXISTS tmodule_times CASCADE; DROP TABLE IF EXISTS tmodule_cases CASCADE; DROP TABLE IF EXISTS tmodule_customers CASCADE; DROP TABLE IF EXISTS tmodule_metadata CASCADE; """ # Count rows before deletion try: count_query = """ SELECT (SELECT COUNT(*) FROM tmodule_customers) + (SELECT COUNT(*) FROM tmodule_cases) + (SELECT COUNT(*) FROM tmodule_times) + (SELECT COUNT(*) FROM tmodule_orders) + (SELECT COUNT(*) FROM tmodule_order_lines) + (SELECT COUNT(*) FROM tmodule_sync_log) as total """ count_result = execute_query(count_query, fetchone=True) total_rows = count_result['total'] if count_result else 0 except: total_rows = 0 # Execute uninstall (split into separate statements) from app.core.database import get_db_connection import psycopg2 conn = get_db_connection() cursor = conn.cursor() dropped_items = { "views": [], "triggers": [], "functions": [], "tables": [] } try: # Drop views for view in ["tmodule_order_details", "tmodule_next_pending", "tmodule_approval_stats"]: cursor.execute(f"DROP VIEW IF EXISTS {view} CASCADE") dropped_items["views"].append(view) # Drop triggers triggers = [ ("tmodule_orders_generate_number", "tmodule_orders"), ("tmodule_orders_update", "tmodule_orders"), ("tmodule_times_update", "tmodule_times"), ("tmodule_cases_update", "tmodule_cases"), ("tmodule_customers_update", "tmodule_customers") ] for trigger_name, table_name in triggers: cursor.execute(f"DROP TRIGGER IF EXISTS {trigger_name} ON {table_name}") dropped_items["triggers"].append(trigger_name) # Drop functions for func in ["tmodule_generate_order_number", "tmodule_update_timestamp"]: cursor.execute(f"DROP FUNCTION IF EXISTS {func}() CASCADE") dropped_items["functions"].append(func) # Drop tables for table in [ "tmodule_sync_log", "tmodule_order_lines", "tmodule_orders", "tmodule_times", "tmodule_cases", "tmodule_customers", "tmodule_metadata" ]: cursor.execute(f"DROP TABLE IF EXISTS {table} CASCADE") dropped_items["tables"].append(table) conn.commit() logger.warning(f"✅ Module uninstalled - deleted {total_rows} rows") return TModuleUninstallResult( success=True, message=f"Time Tracking Module successfully uninstalled. Deleted {total_rows} rows.", tables_dropped=dropped_items["tables"], views_dropped=dropped_items["views"], functions_dropped=dropped_items["functions"], rows_deleted=total_rows ) except Exception as e: conn.rollback() raise e finally: cursor.close() conn.close() except HTTPException: raise except Exception as e: logger.error(f"❌ Uninstall failed: {e}") raise HTTPException(status_code=500, detail=str(e))