bmc_hub/app/timetracking/backend/router.py

490 lines
16 KiB
Python
Raw Normal View History

"""
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 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 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 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))