490 lines
16 KiB
Python
490 lines
16 KiB
Python
|
|
"""
|
||
|
|
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))
|