- Added a new column `subscriptions_locked` to the `customers` table to manage subscription access. - Implemented a script to create new modules from a template, including updates to various files (module.json, README.md, router.py, views.py, and migration SQL). - Developed a script to import BMC Office subscriptions from an Excel file into the database, including error handling and statistics reporting. - Created a script to lookup and update missing CVR numbers using the CVR.dk API. - Implemented a script to relink Hub customers to e-conomic customer numbers based on name matching. - Developed scripts to sync CVR numbers from Simply-CRM and vTiger to the local customers database.
876 lines
29 KiB
Python
876 lines
29 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,
|
|
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 på 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 på 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 på 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 på 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 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=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 på 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))
|