530 lines
19 KiB
Python
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
|
|
} |