""" 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): """ 🔍 Synkroniser data fra vTiger (READ-ONLY). Henter: - Accounts (kunder) - HelpDesk (cases) - ModComments (tidsregistreringer) Gemmes i tmodule_* tabeller (isoleret). """ try: logger.info("🚀 Starting vTiger sync...") result = await vtiger_service.full_sync(user_id=user_id) return result except Exception as e: logger.error(f"❌ Sync failed: {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): """ Hent næste pending tidsregistrering til godkendelse. Query params: - customer_id: Valgfri - filtrer til specifik kunde """ try: return wizard.get_next_pending_entry(customer_id=customer_id) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post("/wizard/approve", response_model=TModuleTimeWithContext, tags=["Wizard"]) async def approve_time_entry( approval: TModuleTimeApproval, user_id: Optional[int] = None ): """ Godkend en tidsregistrering. Body: - time_id: ID på tidsregistreringen - approved_hours: Timer efter godkendelse (kan være afrundet) - rounded_to: Afrundingsinterval (0.5, 1.0, etc.) - approval_note: Valgfri note - billable: Skal faktureres? (default: true) """ try: return wizard.approve_time_entry(approval, user_id=user_id) except Exception as 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.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=503, content={ "status": "unhealthy", "error": 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))