""" 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 }