bmc_hub/app/timetracking/backend/router.py

1745 lines
62 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, Dict, Any
from datetime import datetime
from fastapi import APIRouter, HTTPException, Depends, Body
from fastapi.responses import JSONResponse
from app.core.database import execute_query, execute_update, execute_query_single
from app.timetracking.backend.models import (
TModuleSyncStats,
TModuleApprovalStats,
TModuleWizardNextEntry,
TModuleWizardProgress,
TModuleTimeApproval,
TModuleTimeWithContext,
TModuleOrder,
TModuleOrderWithLines,
TModuleEconomicExportRequest,
TModuleEconomicExportResult,
TModuleMetadata,
TModuleUninstallRequest,
TModuleUninstallResult,
TModuleBulkRateUpdate
)
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
from app.services.customer_consistency import CustomerConsistencyService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/timetracking")
# ============================================================================
# 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)
# Auto-link customers after sync to update links based on fresh vTiger data
logger.info("🔗 Auto-linking customers after sync...")
try:
link_query = "SELECT * FROM link_tmodule_customers_to_hub()"
link_results = execute_query(link_query)
logger.info(f"✅ Linked {len(link_results)} customers automatically")
except Exception as link_error:
logger.warning(f"⚠️ Customer linking failed (non-critical): {link_error}")
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_single(
"SELECT vtiger_id FROM tmodule_cases WHERE id = %s",
(case_id,))
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))
@router.post("/sync/fix-empty-case-titles", tags=["Sync"])
async def fix_empty_case_titles():
"""
🔧 Opdater tomme case titles fra vtiger_data.
Henter titles fra vtiger_data JSON for cases der har tom eller generisk title.
"""
try:
logger.info("🔧 Fixing empty case titles...")
# Find cases med tomme eller generiske titles
cases_query = """
SELECT id, vtiger_id, title,
vtiger_data->>'title' as vtiger_title,
vtiger_data->>'ticket_title' as vtiger_ticket_title
FROM tmodule_cases
WHERE title IS NULL
OR TRIM(title) = ''
OR LOWER(title) IN ('ingen titel', 'no title', 'none')
"""
cases = execute_query(cases_query)
if not cases:
return {
"success": True,
"message": "No cases with empty titles found",
"updated": 0
}
updated_count = 0
for case in cases:
# Prioriter title fra vtiger_data
new_title = case.get('vtiger_title') or case.get('vtiger_ticket_title')
if new_title and new_title.strip():
execute_query(
"UPDATE tmodule_cases SET title = %s WHERE id = %s",
(new_title, case['id'])
)
updated_count += 1
logger.info(f"✅ Updated case {case['vtiger_id']}: '{new_title}'")
return {
"success": True,
"message": f"Updated {updated_count} cases",
"total_checked": len(cases),
"updated": updated_count
}
except Exception as e:
logger.error(f"❌ Fix empty titles failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/sync/manual-link-customer", tags=["Sync"])
async def manual_link_customer(tmodule_customer_id: int, hub_customer_id: int):
"""
🔗 Manually link en tmodule_customer til en Hub customer.
Brug dette når auto-linking fejler pga. navn-forskelle eller andre edge cases.
"""
try:
logger.info(f"🔗 Manual linking tmodule {tmodule_customer_id} → Hub {hub_customer_id}")
# Get Hub customer's economic number
hub_query = "SELECT economic_customer_number FROM customers WHERE id = %s"
hub_result = execute_query(hub_query, (hub_customer_id,))
if not hub_result:
raise HTTPException(status_code=404, detail=f"Hub customer {hub_customer_id} not found")
economic_number = hub_result[0]['economic_customer_number']
# Update tmodule_customer
update_query = """
UPDATE tmodule_customers
SET hub_customer_id = %s,
economic_customer_number = %s,
updated_at = NOW()
WHERE id = %s
RETURNING id, name, hub_customer_id, economic_customer_number
"""
result = execute_query(update_query, (hub_customer_id, economic_number, tmodule_customer_id))
if not result:
raise HTTPException(status_code=404, detail=f"tmodule_customer {tmodule_customer_id} not found")
logger.info(f"✅ Manual link complete: {result[0]}")
return {
"success": True,
"linked_customer": result[0]
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Manual linking failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/sync/relink-customers", tags=["Sync"])
async def relink_customers():
"""
🔗 Re-link alle tmodule_customers til Hub customers.
Bruger linking funktion der prioriterer:
1. economic_customer_number match (mest præcis)
2. Navn match (fallback)
Kør efter vTiger sync for at opdatere kunde-links baseret
nyeste data fra vTiger (cf_854 economic numbers).
"""
try:
logger.info("🔗 Starting customer re-linking...")
# Call database function
query = "SELECT * FROM link_tmodule_customers_to_hub()"
results = execute_query(query)
# Count by action type
stats = {
"economic_matches": 0,
"name_matches": 0,
"total_linked": len(results)
}
for result in results:
if result['action'] == 'economic_number_match':
stats["economic_matches"] += 1
elif result['action'] == 'name_match':
stats["name_matches"] += 1
logger.info(f"✅ Customer linking complete: {stats}")
return {
"success": True,
"stats": stats,
"linked_customers": results
}
except Exception as e:
logger.error(f"❌ Customer linking failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/debug/raw-stats", tags=["Debug"])
async def get_debug_raw_stats():
"""
🔍 DEBUG: Vis statistik fra databasen uden filtering.
Bruges til at diagnosticere hvorfor timelogs ikke vises.
"""
try:
# Total counts without any filtering
total_query = """
SELECT
(SELECT COUNT(*) FROM tmodule_customers) as total_customers,
(SELECT COUNT(*) FROM tmodule_cases) as total_cases,
(SELECT COUNT(*) FROM tmodule_times) as total_times,
(SELECT COUNT(*) FROM tmodule_times WHERE billable = true) as billable_times,
(SELECT COUNT(*) FROM tmodule_times WHERE status = 'pending') as pending_times,
(SELECT COUNT(*) FROM tmodule_times WHERE vtiger_data->>'cf_timelog_invoiced' = '0') as not_invoiced_times,
(SELECT COUNT(*) FROM tmodule_times WHERE vtiger_data->>'cf_timelog_invoiced' IS NULL) as null_invoiced_times,
(SELECT COUNT(*) FROM tmodule_times WHERE billable = true AND status = 'pending') as billable_pending,
(SELECT COUNT(*) FROM tmodule_times WHERE billable = true AND status = 'pending' AND vtiger_data->>'cf_timelog_invoiced' = '0') as filtered_pending
"""
totals = execute_query_single(total_query)
# Sample timelogs to see actual data
sample_query = """
SELECT
id, vtiger_id, description, worked_date, original_hours,
status, billable,
vtiger_data->>'cf_timelog_invoiced' as cf_timelog_invoiced,
vtiger_data->>'isbillable' as is_billable_field,
customer_id, case_id
FROM tmodule_times
ORDER BY worked_date DESC
LIMIT 10
"""
samples = execute_query(sample_query)
# Check what invoice statuses actually exist
invoice_status_query = """
SELECT
vtiger_data->>'cf_timelog_invoiced' as invoice_status,
COUNT(*) as count
FROM tmodule_times
GROUP BY vtiger_data->>'cf_timelog_invoiced'
ORDER BY count DESC
"""
invoice_statuses = execute_query(invoice_status_query)
return {
"totals": totals,
"sample_timelogs": samples,
"invoice_statuses": invoice_statuses,
"explanation": {
"issue": "If not_invoiced_times is 0 but total_times > 0, then the cf_timelog_invoiced field is not '0' in vTiger",
"solution": "The SQL views filter on cf_timelog_invoiced = '0' but vTiger might use different values",
"check": "Look at invoice_statuses to see what values actually exist"
}
}
except Exception as e:
logger.error(f"❌ Debug query failed: {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,
request: Dict[str, Any] = Body(...),
user_id: Optional[int] = None
):
"""
Godkend en tidsregistrering.
Path params:
- time_id: ID tidsregistreringen
Body:
- billable_hours: Timer efter godkendelse (optional)
- hourly_rate: Timepris i DKK (optional)
- is_travel: True hvis kørsel (optional)
- approval_note: Godkendelsesnote (optional)
- rounding_method: "up", "down", "nearest" (optional)
"""
try:
from app.core.config import settings
from decimal import Decimal
# SPECIAL HANDLER FOR HUB WORKLOGS (Negative IDs)
if time_id < 0:
worklog_id = abs(time_id)
logger.info(f"🔄 Approving Hub Worklog {worklog_id}")
w_entry = execute_query_single("SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,))
if not w_entry:
raise HTTPException(status_code=404, detail="Worklog not found")
billable_hours = request.get('billable_hours')
approved_hours = Decimal(str(billable_hours)) if billable_hours is not None else Decimal(str(w_entry['hours']))
is_billable = request.get('billable', True)
new_billing = 'invoice' if is_billable else 'internal'
execute_query("""
UPDATE tticket_worklog
SET hours = %s, billing_method = %s, status = 'billable'
WHERE id = %s
""", (approved_hours, new_billing, worklog_id))
return {
"id": time_id,
"worked_date": w_entry['work_date'],
"original_hours": w_entry['hours'],
"status": "approved",
# Mock fields for schema validation
"customer_id": 0,
"case_id": 0,
"description": w_entry['description'],
"case_title": "Ticket Worklog",
"customer_name": "Hub Customer",
"created_at": w_entry['created_at'],
"last_synced_at": datetime.now(),
"approved_hours": approved_hours
}
# 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_single(query, (time_id,))
if not entry:
raise HTTPException(status_code=404, detail="Time entry not found")
# Beregn approved_hours
billable_hours = request.get('billable_hours')
rounding_method = request.get('rounding_method')
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
rounded_to = increment
else:
rounded_to = None
else:
approved_hours = Decimal(str(billable_hours))
rounded_to = None
# Note: hourly_rate is stored on customer level (tmodule_customers.hourly_rate), not on time entries
# Frontend sends it for calculation display but we don't store it per time entry
# Godkend med alle felter
logger.info(f"🔍 Creating approval for time_id={time_id}: approved_hours={approved_hours}, rounded_to={rounded_to}, is_travel={request.get('is_travel', False)}, billable={request.get('billable', True)}")
approval = TModuleTimeApproval(
time_id=time_id,
approved_hours=approved_hours,
rounded_to=rounded_to,
approval_note=request.get('approval_note'),
billable=request.get('billable', True), # Accept from request, default til fakturerbar
is_travel=request.get('is_travel', False)
)
logger.info(f"✅ Approval object created successfully")
return wizard.approve_time_entry(approval, user_id=user_id)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error approving entry {time_id}: {e}", exc_info=True)
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,
request: Dict[str, Any] = Body(None), # Allow body
reason: Optional[str] = None,
user_id: Optional[int] = None
):
"""Afvis en tidsregistrering"""
try:
# Handle body extraction if reason is missing from query
if not reason and request and 'rejection_note' in request:
reason = request['rejection_note']
if time_id < 0:
worklog_id = abs(time_id)
# Retrieve to confirm existence
w = execute_query_single("SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,))
if not w:
raise HTTPException(status_code=404, detail="Entry not found")
execute_query("UPDATE tticket_worklog SET status = 'rejected' WHERE id = %s", (worklog_id,))
return {
"id": time_id,
"status": "rejected",
"original_hours": w['hours'],
"worked_date": w['work_date'],
# Mock fields for schema validation
"customer_id": 0,
"case_id": 0,
"description": w.get('description', ''),
"case_title": "Ticket Worklog",
"customer_name": "Hub Customer",
"created_at": w['created_at'],
"last_synced_at": datetime.now(),
"billable": False
}
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))
from app.timetracking.backend.models import TModuleWizardEditRequest
@router.patch("/wizard/entry/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"])
async def update_entry_details(
time_id: int,
request: TModuleWizardEditRequest
):
"""
Opdater detaljer en tidsregistrering (før godkendelse).
Tillader ændring af beskrivelse, antal timer og faktureringsmetode.
"""
try:
from decimal import Decimal
# 1. Handling Hub Worklogs (Negative IDs)
if time_id < 0:
worklog_id = abs(time_id)
w = execute_query_single("SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,))
if not w:
raise HTTPException(status_code=404, detail="Worklog not found")
updates = []
params = []
if request.description is not None:
updates.append("description = %s")
params.append(request.description)
if request.original_hours is not None:
updates.append("hours = %s")
params.append(request.original_hours)
if request.billing_method is not None:
updates.append("billing_method = %s")
params.append(request.billing_method)
if updates:
params.append(worklog_id)
execute_query(f"UPDATE tticket_worklog SET {', '.join(updates)} WHERE id = %s", tuple(params))
w = execute_query_single("SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,))
return {
"id": time_id,
"worked_date": w['work_date'],
"original_hours": w['hours'],
"status": "pending", # Always return as pending/draft context here
"description": w['description'],
"customer_id": 0,
"case_id": 0,
"case_title": "Updated",
"customer_name": "Hub Customer",
"created_at": w['created_at'],
"last_synced_at": datetime.now(),
"billable": True
}
# 2. Handling Module Times (Positive IDs)
else:
t = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,))
if not t:
raise HTTPException(status_code=404, detail="Time entry not found")
updates = []
params = []
if request.description is not None:
updates.append("description = %s")
params.append(request.description)
if request.original_hours is not None:
updates.append("original_hours = %s")
params.append(request.original_hours)
if request.billable is not None:
updates.append("billable = %s")
params.append(request.billable)
if updates:
params.append(time_id)
execute_query(f"UPDATE tmodule_times SET {', '.join(updates)} WHERE id = %s", tuple(params))
# Fetch fresh with context for response
query = """
SELECT t.*, c.title as case_title, c.status as case_status,
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.id = %s
"""
return execute_query_single(query, (time_id,))
except Exception as e:
logger.error(f"❌ Failed to update entry {time_id}: {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.get("/customers/{customer_id}/data-consistency", tags=["Customers", "Data Consistency"])
async def check_tmodule_customer_data_consistency(customer_id: int):
"""
🔍 Check data consistency across Hub, vTiger, and e-conomic for tmodule_customer.
Before creating order, verify customer data is in sync across all systems.
Maps tmodule_customers.hub_customer_id to the consistency service.
Returns discrepancies found between the three systems.
"""
try:
from app.core.config import settings
if not settings.AUTO_CHECK_CONSISTENCY:
return {
"enabled": False,
"message": "Data consistency checking is disabled"
}
# Get tmodule_customer and find linked hub customer
tmodule_customer = execute_query_single(
"SELECT * FROM tmodule_customers WHERE id = %s",
(customer_id,)
)
if not tmodule_customer:
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found in tmodule_customers")
# Get linked hub customer ID
hub_customer_id = tmodule_customer.get('hub_customer_id')
if not hub_customer_id:
return {
"enabled": True,
"customer_id": customer_id,
"discrepancy_count": 0,
"discrepancies": {},
"systems_available": {
"hub": False,
"vtiger": bool(tmodule_customer.get('vtiger_id')),
"economic": False
},
"message": "Customer not linked to Hub - cannot check consistency"
}
# Use Hub customer ID for consistency check
consistency_service = CustomerConsistencyService()
# Fetch data from all systems
all_data = await consistency_service.fetch_all_data(hub_customer_id)
# Compare data
discrepancies = consistency_service.compare_data(all_data)
# Count actual discrepancies
discrepancy_count = sum(
1 for field_data in discrepancies.values()
if field_data['discrepancy']
)
return {
"enabled": True,
"customer_id": customer_id,
"hub_customer_id": hub_customer_id,
"discrepancy_count": discrepancy_count,
"discrepancies": discrepancies,
"systems_available": {
"hub": True,
"vtiger": all_data.get('vtiger') is not None,
"economic": all_data.get('economic') is not None
}
}
except Exception as e:
logger.error(f"❌ Failed to check consistency for tmodule_customer {customer_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/customers/{customer_id}/sync-field", tags=["Customers", "Data Consistency"])
async def sync_tmodule_customer_field(
customer_id: int,
field_name: str = Body(..., description="Hub field name to sync"),
source_system: str = Body(..., description="Source system: hub, vtiger, or economic"),
source_value: str = Body(..., description="The correct value to sync")
):
"""
🔄 Sync a single field across all systems for tmodule_customer.
Takes the correct value from one system and updates the others.
Maps tmodule_customers.hub_customer_id to the consistency service.
"""
try:
from app.core.config import settings
# Validate source system
if source_system not in ['hub', 'vtiger', 'economic']:
raise HTTPException(
status_code=400,
detail=f"Invalid source_system: {source_system}. Must be hub, vtiger, or economic"
)
# Get tmodule_customer and find linked hub customer
tmodule_customer = execute_query_single(
"SELECT * FROM tmodule_customers WHERE id = %s",
(customer_id,)
)
if not tmodule_customer:
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found in tmodule_customers")
# Get linked hub customer ID
hub_customer_id = tmodule_customer.get('hub_customer_id')
if not hub_customer_id:
raise HTTPException(
status_code=400,
detail="Customer not linked to Hub - cannot sync fields"
)
consistency_service = CustomerConsistencyService()
# Perform sync on the linked Hub customer
results = await consistency_service.sync_field(
customer_id=hub_customer_id,
field_name=field_name,
source_system=source_system,
source_value=source_value
)
logger.info(f"✅ Field '{field_name}' synced from {source_system}: {results}")
return {
"success": True,
"field": field_name,
"source": source_system,
"value": source_value,
"results": results
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"❌ Failed to sync field for tmodule_customer {customer_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@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_single(update_query, (order_id,))
# 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_single(
"SELECT * FROM tmodule_metadata ORDER BY id DESC LIMIT 1")
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_single(tables_query)
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_single(
f"SELECT COUNT(*) as count FROM tmodule_{table_name}")
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_single(
"SELECT id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
(customer_id,))
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.post("/customers/bulk-update-rate", tags=["Customers"])
async def bulk_update_customer_hourly_rates(
request: TModuleBulkRateUpdate,
user_id: Optional[int] = None
):
"""
Opdater timepris for flere kunder én gang.
Request body:
customer_ids: Liste af kunde-ID'er
hourly_rate: Ny timepris i DKK (f.eks. 850.00)
Returns:
Antal opdaterede kunder
"""
try:
# Validate inputs (already validated by Pydantic)
customer_ids = request.customer_ids
rate_decimal = request.hourly_rate
# Update all selected customers
execute_update(
"UPDATE tmodule_customers SET hourly_rate = %s, updated_at = CURRENT_TIMESTAMP WHERE id = ANY(%s)",
(rate_decimal, customer_ids)
)
# Count affected rows
updated_count = len(customer_ids)
# Audit log for each customer
for customer_id in customer_ids:
audit.log_event(
entity_type="customer",
entity_id=str(customer_id),
event_type="hourly_rate_updated",
details={"hourly_rate": float(rate_decimal), "bulk_update": True},
user_id=user_id
)
logger.info(f"✅ Bulk updated hourly rate for {updated_count} customers to {rate_decimal} DKK")
return {
"updated": updated_count,
"hourly_rate": float(rate_decimal)
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error in bulk hourly rate update: {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_single(
"SELECT * FROM tmodule_customers WHERE id = %s",
(customer_id,))
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_single(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,
c.priority AS case_priority,
c.module_type AS case_type,
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
AND t.billed_via_thehub_id IS NULL
AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL
OR t.vtiger_data->>'cf_timelog_invoiced' = '0'
OR t.vtiger_data->>'cf_timelog_invoiced' = '')
AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0'
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '')
AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0'
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '')
"""
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))
# 🔗 Combine with Hub Worklogs (tticket_worklog)
# Only if we can find the linked Hub Customer ID
try:
cust_res = execute_query_single(
"SELECT hub_customer_id, name FROM tmodule_customers WHERE id = %s",
(customer_id,)
)
if cust_res and cust_res.get('hub_customer_id'):
hub_id = cust_res['hub_customer_id']
hub_name = cust_res['name']
# Fetch worklogs
w_query = """
SELECT
(w.id * -1) as id,
w.work_date as worked_date,
w.hours as original_hours,
w.description,
CASE
WHEN w.status = 'draft' THEN 'pending'
ELSE w.status
END as status,
-- Ticket info as Case info
t.subject as case_title,
t.ticket_number as case_vtiger_id,
t.description as case_description,
CASE
WHEN t.priority = 'urgent' THEN 'Høj'
ELSE 'Normal'
END as case_priority,
w.work_type as case_type,
-- Customer info
%s as customer_name,
%s as customer_id,
-- Logic
CASE
WHEN w.billing_method IN ('internal', 'warranty') THEN false
ELSE true
END as billable,
false as is_travel,
-- Extra context for frontend flags if needed
w.billing_method as _billing_method
FROM tticket_worklog w
JOIN tticket_tickets t ON w.ticket_id = t.id
WHERE t.customer_id = %s
"""
w_params = [hub_name, customer_id, hub_id]
if status:
if status == 'pending':
w_query += " AND w.status = 'draft'"
else:
w_query += " AND w.status = %s"
w_params.append(status)
w_times = execute_query(w_query, tuple(w_params))
if w_times:
times.extend(w_times)
# Re-sort combined list
times.sort(key=lambda x: (x.get('worked_date') or '', x.get('id')), reverse=True)
except Exception as e:
logger.error(f"⚠️ Failed to fetch hub worklogs for wizard: {e}")
# Continue with just tmodule times
return {"times": times, "total": len(times)}
except Exception as e:
logger.error(f"Error fetching customer time entries: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/times", tags=["Times"])
async def list_time_entries(
limit: int = 100,
offset: int = 0,
status: Optional[str] = None,
customer_id: Optional[int] = None,
user_name: Optional[str] = None,
search: Optional[str] = None
):
"""
Hent liste af tidsregistreringer med filtre.
"""
try:
query = """
SELECT t.*,
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
c.priority AS case_priority,
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 1=1
"""
params = []
if status:
query += " AND t.status = %s"
params.append(status)
if customer_id:
query += " AND t.customer_id = %s"
params.append(customer_id)
if user_name:
query += " AND t.user_name ILIKE %s"
params.append(f"%{user_name}%")
if search:
query += """ AND (
t.description ILIKE %s OR
cust.name ILIKE %s OR
c.title ILIKE %s
)"""
wildcard = f"%{search}%"
params.extend([wildcard, wildcard, wildcard])
query += " ORDER BY t.worked_date DESC, t.id DESC LIMIT %s OFFSET %s"
params.extend([limit, offset])
times = execute_query(query, tuple(params))
return {"times": times}
except Exception as e:
logger.error(f"Error listing times: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/times/{time_id}", tags=["Times"])
async def get_time_entry(time_id: int):
"""
Hent en specifik tidsregistrering.
Path params:
- time_id: Time entry ID
"""
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.id = %s
"""
time_entry = execute_query_single(query, (time_id,))
if not time_entry:
raise HTTPException(status_code=404, detail="Time entry not found")
return time_entry
except HTTPException:
raise
except Exception as e:
logger.error(f"Error fetching time entry {time_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
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)
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))