progress
This commit is contained in:
530
app/services/timers.py
Normal file
530
app/services/timers.py
Normal file
@@ -0,0 +1,530 @@
|
||||
"""
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user