Files
delphi-database/app/services/timers.py
HotSwapp ae4484381f progress
2025-08-16 10:05:42 -05:00

530 lines
19 KiB
Python

"""
Timer service for time tracking functionality
Handles timer start/stop/pause operations and time entry creation
"""
from typing import List, Dict, Any, Optional, Tuple
from datetime import datetime, timezone, timedelta
from decimal import Decimal
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, func, or_
from app.models import (
Timer, TimeEntry, TimerSession, TimerTemplate, TimerStatus, TimerType,
User, File, Ledger, Rolodex
)
from app.utils.logging import app_logger
logger = app_logger
class TimerServiceError(Exception):
"""Exception raised when timer operations fail"""
pass
class TimerService:
"""Service for managing timers and time tracking"""
def __init__(self, db: Session):
self.db = db
def create_timer(
self,
user_id: int,
title: str,
description: Optional[str] = None,
file_no: Optional[str] = None,
customer_id: Optional[str] = None,
timer_type: TimerType = TimerType.BILLABLE,
hourly_rate: Optional[float] = None,
task_category: Optional[str] = None,
template_id: Optional[int] = None
) -> Timer:
"""Create a new timer"""
# Validate user exists
user = self.db.query(User).filter(User.id == user_id).first()
if not user:
raise TimerServiceError(f"User {user_id} not found")
# Validate file exists if provided
if file_no:
file_obj = self.db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise TimerServiceError(f"File {file_no} not found")
# Use file's hourly rate if not specified
if not hourly_rate and file_obj.rate_per_hour:
hourly_rate = file_obj.rate_per_hour
# Validate customer exists if provided
if customer_id:
customer = self.db.query(Rolodex).filter(Rolodex.id == customer_id).first()
if not customer:
raise TimerServiceError(f"Customer {customer_id} not found")
# Apply template if provided
if template_id:
template = self.db.query(TimerTemplate).filter(TimerTemplate.id == template_id).first()
if template:
if not title:
title = template.title_template
if not description:
description = template.description_template
if timer_type == TimerType.BILLABLE: # Only override if default
timer_type = template.timer_type
if not hourly_rate and template.default_rate:
hourly_rate = template.default_rate
if not task_category:
task_category = template.task_category
# Update template usage count
template.usage_count += 1
timer = Timer(
user_id=user_id,
file_no=file_no,
customer_id=customer_id,
title=title,
description=description,
timer_type=timer_type,
hourly_rate=hourly_rate,
task_category=task_category,
is_billable=(timer_type == TimerType.BILLABLE),
status=TimerStatus.STOPPED
)
self.db.add(timer)
self.db.commit()
self.db.refresh(timer)
logger.info(f"Created timer {timer.id} for user {user_id}: {title}")
return timer
def start_timer(self, timer_id: int, user_id: int) -> Timer:
"""Start a timer"""
timer = self._get_user_timer(timer_id, user_id)
if timer.status == TimerStatus.RUNNING:
raise TimerServiceError("Timer is already running")
now = datetime.now(timezone.utc)
# Stop any other running timers for this user
self._stop_other_timers(user_id, timer_id)
# Update timer status
timer.status = TimerStatus.RUNNING
timer.last_started_at = now
if not timer.started_at:
timer.started_at = now
# Create session record
session = TimerSession(
timer_id=timer.id,
started_at=now
)
self.db.add(session)
self.db.commit()
self.db.refresh(timer)
logger.info(f"Started timer {timer.id} for user {user_id}")
return timer
def pause_timer(self, timer_id: int, user_id: int) -> Timer:
"""Pause a running timer"""
timer = self._get_user_timer(timer_id, user_id)
if timer.status != TimerStatus.RUNNING:
raise TimerServiceError("Timer is not running")
now = datetime.now(timezone.utc)
# Calculate session time and add to total
if timer.last_started_at:
session_seconds = int((now - timer.last_started_at).total_seconds())
timer.total_seconds += session_seconds
# Update timer status
timer.status = TimerStatus.PAUSED
timer.last_paused_at = now
# Update current session
current_session = self.db.query(TimerSession).filter(
TimerSession.timer_id == timer.id,
TimerSession.ended_at.is_(None)
).order_by(TimerSession.started_at.desc()).first()
if current_session:
current_session.ended_at = now
current_session.duration_seconds = int((now - current_session.started_at).total_seconds())
self.db.commit()
self.db.refresh(timer)
logger.info(f"Paused timer {timer.id} for user {user_id}")
return timer
def stop_timer(self, timer_id: int, user_id: int) -> Timer:
"""Stop a timer completely"""
timer = self._get_user_timer(timer_id, user_id)
if timer.status == TimerStatus.STOPPED:
raise TimerServiceError("Timer is already stopped")
now = datetime.now(timezone.utc)
# If running, calculate final session time
if timer.status == TimerStatus.RUNNING and timer.last_started_at:
session_seconds = int((now - timer.last_started_at).total_seconds())
timer.total_seconds += session_seconds
# Update timer status
timer.status = TimerStatus.STOPPED
timer.stopped_at = now
# Update current session
current_session = self.db.query(TimerSession).filter(
TimerSession.timer_id == timer.id,
TimerSession.ended_at.is_(None)
).order_by(TimerSession.started_at.desc()).first()
if current_session:
current_session.ended_at = now
current_session.duration_seconds = int((now - current_session.started_at).total_seconds())
self.db.commit()
self.db.refresh(timer)
logger.info(f"Stopped timer {timer.id} for user {user_id}, total time: {timer.total_hours:.2f} hours")
return timer
def resume_timer(self, timer_id: int, user_id: int) -> Timer:
"""Resume a paused timer"""
timer = self._get_user_timer(timer_id, user_id)
if timer.status != TimerStatus.PAUSED:
raise TimerServiceError("Timer is not paused")
# Stop any other running timers for this user
self._stop_other_timers(user_id, timer_id)
return self.start_timer(timer_id, user_id)
def delete_timer(self, timer_id: int, user_id: int) -> bool:
"""Delete a timer (only if stopped)"""
timer = self._get_user_timer(timer_id, user_id)
if timer.status != TimerStatus.STOPPED:
raise TimerServiceError("Can only delete stopped timers")
# Check if timer has associated time entries
entry_count = self.db.query(TimeEntry).filter(TimeEntry.timer_id == timer_id).count()
if entry_count > 0:
raise TimerServiceError(f"Cannot delete timer: {entry_count} time entries are linked to this timer")
self.db.delete(timer)
self.db.commit()
logger.info(f"Deleted timer {timer_id} for user {user_id}")
return True
def get_active_timers(self, user_id: int) -> List[Timer]:
"""Get all active (running or paused) timers for a user"""
return self.db.query(Timer).filter(
Timer.user_id == user_id,
Timer.status.in_([TimerStatus.RUNNING, TimerStatus.PAUSED])
).options(
joinedload(Timer.file),
joinedload(Timer.customer)
).all()
def get_user_timers(
self,
user_id: int,
status_filter: Optional[TimerStatus] = None,
file_no: Optional[str] = None,
limit: int = 50
) -> List[Timer]:
"""Get timers for a user with optional filtering"""
query = self.db.query(Timer).filter(Timer.user_id == user_id)
if status_filter:
query = query.filter(Timer.status == status_filter)
if file_no:
query = query.filter(Timer.file_no == file_no)
return query.options(
joinedload(Timer.file),
joinedload(Timer.customer)
).order_by(Timer.updated_at.desc()).limit(limit).all()
def create_time_entry_from_timer(
self,
timer_id: int,
user_id: int,
title: Optional[str] = None,
description: Optional[str] = None,
hours_override: Optional[float] = None,
entry_date: Optional[datetime] = None
) -> TimeEntry:
"""Create a time entry from a completed timer"""
timer = self._get_user_timer(timer_id, user_id)
if timer.status != TimerStatus.STOPPED:
raise TimerServiceError("Timer must be stopped to create time entry")
if timer.total_seconds == 0:
raise TimerServiceError("Timer has no recorded time")
# Use timer details or overrides
entry_title = title or timer.title
entry_description = description or timer.description
entry_hours = hours_override or timer.total_hours
entry_date = entry_date or timer.stopped_at or datetime.now(timezone.utc)
time_entry = TimeEntry(
timer_id=timer.id,
user_id=user_id,
file_no=timer.file_no,
customer_id=timer.customer_id,
title=entry_title,
description=entry_description,
entry_type=timer.timer_type,
hours=entry_hours,
entry_date=entry_date,
hourly_rate=timer.hourly_rate,
is_billable=timer.is_billable,
task_category=timer.task_category,
created_by=f"user_{user_id}"
)
self.db.add(time_entry)
self.db.commit()
self.db.refresh(time_entry)
logger.info(f"Created time entry {time_entry.id} from timer {timer_id}: {entry_hours:.2f} hours")
return time_entry
def create_manual_time_entry(
self,
user_id: int,
title: str,
hours: float,
entry_date: datetime,
description: Optional[str] = None,
file_no: Optional[str] = None,
customer_id: Optional[str] = None,
hourly_rate: Optional[float] = None,
entry_type: TimerType = TimerType.BILLABLE,
task_category: Optional[str] = None
) -> TimeEntry:
"""Create a manual time entry (not from a timer)"""
# Validate user
user = self.db.query(User).filter(User.id == user_id).first()
if not user:
raise TimerServiceError(f"User {user_id} not found")
# Validate file if provided
if file_no:
file_obj = self.db.query(File).filter(File.file_no == file_no).first()
if not file_obj:
raise TimerServiceError(f"File {file_no} not found")
# Use file's rate if not specified
if not hourly_rate and file_obj.rate_per_hour:
hourly_rate = file_obj.rate_per_hour
# Validate customer if provided
if customer_id:
customer = self.db.query(Rolodex).filter(Rolodex.id == customer_id).first()
if not customer:
raise TimerServiceError(f"Customer {customer_id} not found")
time_entry = TimeEntry(
user_id=user_id,
file_no=file_no,
customer_id=customer_id,
title=title,
description=description,
entry_type=entry_type,
hours=hours,
entry_date=entry_date,
hourly_rate=hourly_rate,
is_billable=(entry_type == TimerType.BILLABLE),
task_category=task_category,
created_by=f"user_{user_id}"
)
self.db.add(time_entry)
self.db.commit()
self.db.refresh(time_entry)
logger.info(f"Created manual time entry {time_entry.id} for user {user_id}: {hours:.2f} hours")
return time_entry
def convert_time_entry_to_ledger(
self,
time_entry_id: int,
user_id: int,
transaction_code: str = "TIME",
notes: Optional[str] = None
) -> Ledger:
"""Convert a time entry to a billable ledger transaction"""
time_entry = self.db.query(TimeEntry).filter(
TimeEntry.id == time_entry_id,
TimeEntry.user_id == user_id
).first()
if not time_entry:
raise TimerServiceError(f"Time entry {time_entry_id} not found")
if time_entry.billed:
raise TimerServiceError("Time entry has already been billed")
if not time_entry.is_billable:
raise TimerServiceError("Time entry is not billable")
if not time_entry.file_no:
raise TimerServiceError("Time entry must have a file assignment for billing")
if not time_entry.hourly_rate or time_entry.hourly_rate <= 0:
raise TimerServiceError("Time entry must have a valid hourly rate for billing")
# Get next item number for this file
max_item = self.db.query(func.max(Ledger.item_no)).filter(
Ledger.file_no == time_entry.file_no
).scalar() or 0
# Calculate amount
amount = time_entry.hours * time_entry.hourly_rate
# Create ledger entry
ledger_entry = Ledger(
file_no=time_entry.file_no,
item_no=max_item + 1,
date=time_entry.entry_date.date() if hasattr(time_entry.entry_date, 'date') else time_entry.entry_date,
t_code=transaction_code,
t_type="1", # Type 1 = hourly fees
empl_num=f"user_{user_id}",
quantity=time_entry.hours,
rate=time_entry.hourly_rate,
amount=amount,
billed="N", # Will be marked as billed when statement is approved
note=notes or time_entry.description or time_entry.title
)
# Link time entry to ledger entry
time_entry.ledger_id = ledger_entry.id
time_entry.billed = True
self.db.add(ledger_entry)
self.db.commit()
self.db.refresh(ledger_entry)
# Update the time entry with the ledger ID
time_entry.ledger_id = ledger_entry.id
self.db.commit()
logger.info(f"Converted time entry {time_entry_id} to ledger entry {ledger_entry.id}: ${amount:.2f}")
return ledger_entry
def update_timer_total(self, timer_id: int) -> Timer:
"""Recalculate timer total from sessions (for data consistency)"""
timer = self.db.query(Timer).filter(Timer.id == timer_id).first()
if not timer:
raise TimerServiceError(f"Timer {timer_id} not found")
# Calculate total from completed sessions
total_seconds = self.db.query(func.sum(TimerSession.duration_seconds)).filter(
TimerSession.timer_id == timer_id,
TimerSession.ended_at.isnot(None)
).scalar() or 0
# Add current running session if applicable
if timer.status == TimerStatus.RUNNING:
total_seconds += timer.get_current_session_seconds()
timer.total_seconds = total_seconds
self.db.commit()
return timer
def _get_user_timer(self, timer_id: int, user_id: int) -> Timer:
"""Get timer and verify ownership"""
timer = self.db.query(Timer).filter(
Timer.id == timer_id,
Timer.user_id == user_id
).first()
if not timer:
raise TimerServiceError(f"Timer {timer_id} not found or access denied")
return timer
def _stop_other_timers(self, user_id: int, exclude_timer_id: int):
"""Stop all other running timers for a user"""
running_timers = self.db.query(Timer).filter(
Timer.user_id == user_id,
Timer.status == TimerStatus.RUNNING,
Timer.id != exclude_timer_id
).all()
for timer in running_timers:
try:
self.pause_timer(timer.id, user_id)
logger.info(f"Auto-paused timer {timer.id} when starting timer {exclude_timer_id}")
except Exception as e:
logger.warning(f"Failed to auto-pause timer {timer.id}: {str(e)}")
def get_timer_statistics(self, user_id: int, days: int = 30) -> Dict[str, Any]:
"""Get timer statistics for a user over the last N days"""
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
# Total time tracked
total_seconds = self.db.query(func.sum(Timer.total_seconds)).filter(
Timer.user_id == user_id,
Timer.created_at >= cutoff_date
).scalar() or 0
# Total billable time
billable_seconds = self.db.query(func.sum(Timer.total_seconds)).filter(
Timer.user_id == user_id,
Timer.is_billable == True,
Timer.created_at >= cutoff_date
).scalar() or 0
# Number of active timers
active_count = self.db.query(Timer).filter(
Timer.user_id == user_id,
Timer.status.in_([TimerStatus.RUNNING, TimerStatus.PAUSED])
).count()
# Number of time entries created
entries_count = self.db.query(TimeEntry).filter(
TimeEntry.user_id == user_id,
TimeEntry.created_at >= cutoff_date
).count()
# Entries converted to billing
billed_entries = self.db.query(TimeEntry).filter(
TimeEntry.user_id == user_id,
TimeEntry.billed == True,
TimeEntry.created_at >= cutoff_date
).count()
return {
"period_days": days,
"total_hours": total_seconds / 3600.0,
"billable_hours": billable_seconds / 3600.0,
"non_billable_hours": (total_seconds - billable_seconds) / 3600.0,
"active_timers": active_count,
"time_entries_created": entries_count,
"time_entries_billed": billed_entries,
"billable_percentage": (billable_seconds / total_seconds * 100) if total_seconds > 0 else 0
}