bmc_hub/app/timetracking/backend/router.py

976 lines
33 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,
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 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 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 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 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 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))
@router.post("/orders/{order_id}/unlock", tags=["Orders"])
async def unlock_order(
order_id: int,
admin_code: str,
user_id: Optional[int] = None
):
"""
🔓 Lås en eksporteret ordre op for ændringer (ADMIN ONLY).
Kræver:
1. Korrekt admin unlock code (fra TIMETRACKING_ADMIN_UNLOCK_CODE)
2. Ordren skal være slettet fra e-conomic først
Query params:
- admin_code: Admin unlock kode
"""
try:
from app.core.config import settings
# Verify admin code
if not settings.TIMETRACKING_ADMIN_UNLOCK_CODE:
raise HTTPException(
status_code=500,
detail="Admin unlock code ikke konfigureret i systemet"
)
if admin_code != settings.TIMETRACKING_ADMIN_UNLOCK_CODE:
logger.warning(f"⚠️ Ugyldig unlock code forsøg for ordre {order_id}")
raise HTTPException(status_code=403, detail="Ugyldig admin kode")
# Get order
order = order_service.get_order_with_lines(order_id)
if order.status != 'exported':
raise HTTPException(
status_code=400,
detail="Kun eksporterede ordrer kan låses op"
)
if not order.economic_draft_id:
raise HTTPException(
status_code=400,
detail="Ordre har ingen e-conomic ID"
)
# Check if order still exists in e-conomic
try:
draft_exists = await economic_service.check_draft_exists(order.economic_draft_id)
if draft_exists:
raise HTTPException(
status_code=400,
detail=f"⚠️ Ordren findes stadig i e-conomic (Draft #{order.economic_draft_id}). Slet den i e-conomic først!"
)
except Exception as e:
logger.error(f"❌ Kunne ikke tjekke e-conomic status: {e}")
raise HTTPException(
status_code=500,
detail=f"Kunne ikke verificere e-conomic status: {str(e)}"
)
# Unlock order - set status back to draft
update_query = """
UPDATE tmodule_orders
SET status = 'draft',
economic_draft_id = NULL,
exported_at = NULL
WHERE id = %s
RETURNING *
"""
result = execute_query(update_query, (order_id,), fetchone=True)
# Log unlock
audit.log_event(
event_type="order_unlocked",
entity_type="order",
entity_id=order_id,
user_id=user_id,
details={
"previous_economic_id": order.economic_draft_id,
"unlocked_by_admin": True
}
)
logger.info(f"🔓 Order {order_id} unlocked by admin (user {user_id})")
return {
"success": True,
"message": "Ordre låst op - kan nu redigeres eller slettes",
"order_id": order_id
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Unlock failed: {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=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 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))