bmc_hub/app/timetracking/backend/wizard.py
Christian cbc05b52ce fix: Use execute_query_single for case details (v1.3.83)
- Fix get_case_details using execute_query instead of execute_query_single
- execute_query returns list, execute_query_single returns dict
- Prevents 500 error when loading case details
2026-01-02 13:01:20 +01:00

671 lines
26 KiB
Python

"""
Wizard Service for Time Tracking Module
========================================
Step-by-step approval flow for time entries.
Brugeren godkender én tidsregistrering ad gangen.
"""
import logging
from typing import Optional, List, Dict, Any
from decimal import Decimal
from datetime import datetime
from fastapi import HTTPException
from app.core.database import execute_query, execute_update, execute_query_single
from app.timetracking.backend.models import (
TModuleTimeWithContext,
TModuleTimeApproval,
TModuleWizardProgress,
TModuleWizardNextEntry,
TModuleApprovalStats
)
from app.timetracking.backend.audit import audit
logger = logging.getLogger(__name__)
class WizardService:
"""Service for managing wizard-based approval flow"""
@staticmethod
def get_customer_stats(customer_id: int) -> Optional[TModuleApprovalStats]:
"""Hent approval statistics for en kunde"""
try:
query = """
SELECT * FROM tmodule_approval_stats
WHERE customer_id = %s
"""
result = execute_query_single(query, (customer_id,))
if not result:
return None
return TModuleApprovalStats(**result)
except Exception as e:
logger.error(f"❌ Error getting customer stats: {e}")
return None
@staticmethod
def get_all_customers_stats() -> list[TModuleApprovalStats]:
"""Hent approval statistics for alle kunder"""
try:
query = "SELECT * FROM tmodule_approval_stats ORDER BY customer_name"
results = execute_query(query)
return [TModuleApprovalStats(**row) for row in results]
except Exception as e:
logger.error(f"❌ Error getting all customer stats: {e}")
return []
@staticmethod
def get_next_pending_entry(
customer_id: Optional[int] = None,
exclude_time_card: bool = True
) -> TModuleWizardNextEntry:
"""
Hent næste pending tidsregistrering til godkendelse.
Args:
customer_id: Valgfri - filtrer til specifik kunde
exclude_time_card: Ekskluder klippekort-kunder (default: true)
Returns:
TModuleWizardNextEntry med has_next=True hvis der er flere
"""
try:
if customer_id:
# Hent næste for specifik kunde
query = """
SELECT * FROM tmodule_next_pending
WHERE customer_id = %s
LIMIT 1
"""
result = execute_query_single(query, (customer_id,))
else:
# Hent næste generelt
if exclude_time_card:
query = """
SELECT np.* FROM tmodule_next_pending np
JOIN tmodule_customers c ON np.customer_id = c.id
WHERE c.uses_time_card = false
LIMIT 1
"""
else:
query = "SELECT * FROM tmodule_next_pending LIMIT 1"
result = execute_query_single(query)
if not result:
# Ingen flere entries
return TModuleWizardNextEntry(
has_next=False,
time_entry=None,
progress=None
)
# Build entry with context
entry = TModuleTimeWithContext(**result)
# Get progress if customer_id known
progress = None
cust_id = customer_id or entry.customer_id
if cust_id:
stats = WizardService.get_customer_stats(cust_id)
if stats:
progress = TModuleWizardProgress(
customer_id=stats.customer_id,
customer_name=stats.customer_name,
total_entries=stats.total_entries,
approved_entries=stats.approved_count,
pending_entries=stats.pending_count,
rejected_entries=stats.rejected_count,
current_case_id=entry.case_id,
current_case_title=entry.case_title
)
return TModuleWizardNextEntry(
has_next=True,
time_entry=entry,
progress=progress
)
except Exception as e:
logger.error(f"❌ Error getting next entry: {e}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def approve_time_entry(
approval: TModuleTimeApproval,
user_id: Optional[int] = None
) -> TModuleTimeWithContext:
"""
Godkend en tidsregistrering.
Args:
approval: Approval data med time_id og approved_hours
user_id: ID på brugeren der godkender
Returns:
Opdateret tidsregistrering
"""
try:
# Hent original entry
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, (approval.time_id,))
if not entry:
raise HTTPException(status_code=404, detail="Time entry not found")
if entry['status'] != 'pending':
raise HTTPException(
status_code=400,
detail=f"Time entry already {entry['status']}"
)
# Update entry
logger.info(f"🔄 Updating time entry {approval.time_id} in database")
update_query = """
UPDATE tmodule_times
SET status = 'approved',
approved_hours = %s,
rounded_to = %s,
approval_note = %s,
billable = %s,
is_travel = %s,
approved_at = CURRENT_TIMESTAMP,
approved_by = %s
WHERE id = %s
"""
execute_update(
update_query,
(
approval.approved_hours,
approval.rounded_to,
approval.approval_note,
approval.billable,
approval.is_travel,
user_id,
approval.time_id
)
)
logger.info(f"✅ Database update successful for time entry {approval.time_id}")
# Log approval
audit.log_approval(
time_id=approval.time_id,
original_hours=float(entry['original_hours']),
approved_hours=float(approval.approved_hours),
rounded_to=float(approval.rounded_to) if approval.rounded_to else None,
note=approval.approval_note,
user_id=user_id
)
logger.info(
f"✅ Approved time entry {approval.time_id}: "
f"{entry['original_hours']}h → {approval.approved_hours}h"
)
# Return updated entry
updated = execute_query_single(query, (approval.time_id,))
return TModuleTimeWithContext(**updated)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error approving time entry: {e}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def reject_time_entry(
time_id: int,
reason: Optional[str] = None,
user_id: Optional[int] = None
) -> TModuleTimeWithContext:
"""
Afvis en tidsregistrering.
Args:
time_id: ID på tidsregistreringen
reason: Årsag til afvisning
user_id: ID på brugeren der afviser
Returns:
Opdateret tidsregistrering
"""
try:
# Check exists
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")
if entry['status'] != 'pending':
raise HTTPException(
status_code=400,
detail=f"Time entry already {entry['status']}"
)
# Update to rejected
update_query = """
UPDATE tmodule_times
SET status = 'rejected',
approval_note = %s,
billable = false,
approved_at = CURRENT_TIMESTAMP,
approved_by = %s
WHERE id = %s
"""
execute_update(update_query, (reason, user_id, time_id))
# Log rejection
audit.log_rejection(
time_id=time_id,
reason=reason,
user_id=user_id
)
logger.info(f"❌ Rejected time entry {time_id}: {reason}")
# Return updated
updated = execute_query_single(query, (time_id,))
return TModuleTimeWithContext(**updated)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error rejecting time entry: {e}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def reset_to_pending(
time_id: int,
reason: Optional[str] = None,
user_id: Optional[int] = None
) -> TModuleTimeWithContext:
"""
Nulstil en godkendt/afvist tidsregistrering tilbage til pending.
Args:
time_id: ID på tidsregistreringen
reason: Årsag til nulstilling
user_id: ID på brugeren der nulstiller
Returns:
Opdateret tidsregistrering
"""
try:
# Check exists
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")
if entry['status'] == 'pending':
raise HTTPException(
status_code=400,
detail="Time entry is already pending"
)
if entry['status'] == 'billed':
raise HTTPException(
status_code=400,
detail="Cannot reset billed entries"
)
# Reset to pending - clear all approval data
update_query = """
UPDATE tmodule_times
SET status = 'pending',
approved_hours = NULL,
rounded_to = NULL,
approval_note = %s,
billable = true,
approved_at = NULL,
approved_by = NULL
WHERE id = %s
"""
execute_update(update_query, (reason, time_id))
# Log reset
audit.log_event(
event_type="reset_to_pending",
entity_type="time_entry",
entity_id=time_id,
user_id=user_id,
details={
"reason": reason or "Reset to pending",
"timestamp": datetime.now().isoformat()
}
)
logger.info(f"🔄 Reset time entry {time_id} to pending: {reason}")
# Return updated
updated = execute_query_single(query, (time_id,))
return TModuleTimeWithContext(**updated)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error resetting time entry: {e}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def approve_case_entries(
case_id: int,
user_id: Optional[int] = None,
exclude_time_card: bool = True
) -> Dict[str, Any]:
"""
Bulk-godkend alle pending tidsregistreringer for en case.
Args:
case_id: Case ID
user_id: ID på brugeren der godkender
exclude_time_card: Ekskluder klippekort-kunder
Returns:
Dict med statistik: approved_count, total_hours, etc.
"""
try:
# Hent alle pending entries for case
entries = WizardService.get_case_entries(case_id, exclude_time_card)
if not entries:
return {
"approved_count": 0,
"total_hours": 0.0,
"case_id": case_id,
"entries": []
}
approved_entries = []
total_hours = 0.0
for entry in entries:
# Auto-approve med samme timer som original (eller afrundet hvis enabled)
from app.core.config import settings
from decimal import Decimal
approved_hours = Decimal(str(entry.original_hours))
# Afrund hvis enabled
if settings.TIMETRACKING_AUTO_ROUND:
increment = Decimal(str(settings.TIMETRACKING_ROUND_INCREMENT))
if settings.TIMETRACKING_ROUND_METHOD == "up":
# Afrund op
approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_UP') * increment
elif settings.TIMETRACKING_ROUND_METHOD == "down":
# Afrund ned
approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_DOWN') * increment
else:
# Nærmeste
approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_HALF_UP') * increment
# Godkend entry
approval = TModuleTimeApproval(
time_id=entry.id,
approved_hours=float(approved_hours)
)
approved = WizardService.approve_time_entry(approval, user_id)
approved_entries.append({
"id": approved.id,
"original_hours": float(approved.original_hours),
"approved_hours": float(approved.approved_hours)
})
total_hours += float(approved.approved_hours)
# Log bulk approval
audit.log_event(
entity_type="case",
entity_id=str(case_id),
event_type="bulk_approval",
details={
"approved_count": len(approved_entries),
"total_hours": total_hours
},
user_id=user_id
)
return {
"approved_count": len(approved_entries),
"total_hours": total_hours,
"case_id": case_id,
"entries": approved_entries
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error bulk approving case: {e}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def get_customer_progress(customer_id: int) -> TModuleWizardProgress:
"""Hent wizard progress for en kunde"""
try:
stats = WizardService.get_customer_stats(customer_id)
if not stats:
raise HTTPException(status_code=404, detail="Customer not found")
# Get current case if any pending entries
current_case_id = None
current_case_title = None
if stats.pending_count > 0:
query = """
SELECT c.id, c.title
FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id
WHERE t.customer_id = %s AND t.status = 'pending'
ORDER BY t.worked_date
LIMIT 1
"""
case = execute_query_single(query, (customer_id,))
if case:
current_case_id = case['id']
current_case_title = case['title']
return TModuleWizardProgress(
customer_id=stats.customer_id,
customer_name=stats.customer_name,
total_entries=stats.total_entries,
approved_entries=stats.approved_count,
pending_entries=stats.pending_count,
rejected_entries=stats.rejected_count,
current_case_id=current_case_id,
current_case_title=current_case_title
)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error getting customer progress: {e}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def get_case_entries(
case_id: int,
exclude_time_card: bool = True
) -> List[TModuleTimeWithContext]:
"""
Hent alle pending tidsregistreringer for en specifik case.
Bruges til at vise alle timelogs i samme case samtidig.
Args:
case_id: Case ID
exclude_time_card: Ekskluder klippekort-kunder
Returns:
Liste af tidsregistreringer for casen
"""
try:
if exclude_time_card:
query = """
SELECT t.id, t.vtiger_id, t.case_id, t.customer_id, t.description,
t.original_hours, t.worked_date, t.user_name, t.status,
t.approved_hours, t.rounded_to, t.approval_note, t.billable,
t.approved_at, t.approved_by, t.vtiger_data, t.sync_hash,
t.created_at, t.updated_at, t.last_synced_at,
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
c.description AS case_description,
c.status AS case_status,
c.vtiger_id AS case_vtiger_id,
cust.name AS customer_name,
cust.hourly_rate AS customer_rate,
CONCAT(cont.first_name, ' ', cont.last_name) AS contact_name,
cont.user_company AS contact_company,
c.vtiger_data AS case_vtiger_data
FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id
JOIN tmodule_customers cust ON t.customer_id = cust.id
LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id'
WHERE t.case_id = %s
AND t.status = 'pending'
AND t.billable = true
AND t.vtiger_data->>'cf_timelog_invoiced' = '0'
AND cust.uses_time_card = false
ORDER BY t.worked_date, t.id
"""
else:
query = """
SELECT t.id, t.vtiger_id, t.case_id, t.customer_id, t.description,
t.original_hours, t.worked_date, t.user_name, t.status,
t.approved_hours, t.rounded_to, t.approval_note, t.billable,
t.approved_at, t.approved_by, t.vtiger_data, t.sync_hash,
t.created_at, t.updated_at, t.last_synced_at,
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
c.description AS case_description,
c.status AS case_status,
c.vtiger_id AS case_vtiger_id,
cust.name AS customer_name,
cust.hourly_rate AS customer_rate,
CONCAT(cont.first_name, ' ', cont.last_name) AS contact_name,
cont.user_company AS contact_company,
c.vtiger_data AS case_vtiger_data
FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id
JOIN tmodule_customers cust ON t.customer_id = cust.id
LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id'
WHERE t.case_id = %s
AND t.status = 'pending'
AND t.billable = true
AND t.vtiger_data->>'cf_timelog_invoiced' = '0'
ORDER BY t.worked_date, t.id
"""
results = execute_query(query, (case_id,))
return [TModuleTimeWithContext(**row) for row in results]
except Exception as e:
logger.error(f"❌ Error getting case entries: {e}")
raise HTTPException(status_code=500, detail=str(e))
@staticmethod
def get_case_details(case_id: int) -> Dict[str, Any]:
"""
Hent komplet case information inkl. alle timelogs og kommentarer.
Returns:
Dict med case info, timelogs (alle statuses), og kommentarer fra vtiger_data
"""
try:
# Hent case info
case_query = """
SELECT id, vtiger_id, title, description, status,
vtiger_data, customer_id
FROM tmodule_cases
WHERE id = %s
"""
case = execute_query_single(case_query, (case_id,))
if not case:
raise HTTPException(status_code=404, detail="Case not found")
# Hent ALLE timelogs for casen (ikke kun pending)
timelogs_query = """
SELECT t.*,
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
c.status AS case_status,
c.vtiger_id AS case_vtiger_id,
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.case_id = %s
ORDER BY t.worked_date DESC, t.created_at DESC
"""
timelogs = execute_query(timelogs_query, (case_id,))
# Parse case comments from vtiger_data JSON
case_comments = []
if case.get('vtiger_data'):
vtiger_data = case['vtiger_data']
# vTiger gemmer comments som en array i JSON
if isinstance(vtiger_data, dict):
raw_comments = vtiger_data.get('comments', []) or vtiger_data.get('modcomments', [])
for comment in raw_comments:
if isinstance(comment, dict):
case_comments.append({
'id': comment.get('modcommentsid', comment.get('id')),
'comment_text': comment.get('commentcontent', comment.get('comment', '')),
'creator_name': comment.get('assigned_user_id', comment.get('creator', 'Unknown')),
'created_at': comment.get('createdtime', comment.get('created_at', ''))
})
return {
'case_id': case['id'],
'case_vtiger_id': case['vtiger_id'],
'case_title': case['title'],
'case_description': case['description'],
'case_status': case['status'],
'timelogs': [TModuleTimeWithContext(**t) for t in timelogs],
'case_comments': case_comments
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error getting case details: {e}")
raise HTTPException(status_code=500, detail=str(e))
# Singleton instance
wizard = WizardService()