Files
TimeTracker/app/models/time_entry.py
2025-10-09 06:49:56 +02:00

234 lines
8.8 KiB
Python

from datetime import datetime, timedelta, timezone
from app import db
from app.config import Config
from app.utils.timezone import utc_to_local, local_to_utc
def local_now():
"""Get current time in local timezone as naive datetime (for database storage)"""
from app.utils.timezone import get_timezone_obj
tz = get_timezone_obj()
now = datetime.now(tz)
return now.replace(tzinfo=None)
class TimeEntry(db.Model):
"""Time entry model for manual and automatic time tracking"""
__tablename__ = 'time_entries'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True)
task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=True, index=True)
start_time = db.Column(db.DateTime, nullable=False, index=True)
end_time = db.Column(db.DateTime, nullable=True, index=True)
duration_seconds = db.Column(db.Integer, nullable=True)
notes = db.Column(db.Text, nullable=True)
tags = db.Column(db.String(500), nullable=True) # Comma-separated tags
source = db.Column(db.String(20), default='manual', nullable=False) # 'manual' or 'auto'
billable = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=local_now, nullable=False)
updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False)
# Relationships
# user and project relationships are defined via backref in their respective models
# task relationship is defined via backref in Task model
def __init__(self, user_id, project_id, start_time, end_time=None, task_id=None, notes=None, tags=None, source='manual', billable=True):
self.user_id = user_id
self.project_id = project_id
self.task_id = task_id
self.start_time = start_time
self.end_time = end_time
self.notes = notes.strip() if notes else None
self.tags = tags.strip() if tags else None
self.source = source
self.billable = billable
# Calculate duration if end time is provided
if self.end_time:
self.calculate_duration()
def __repr__(self):
return f'<TimeEntry {self.id}: {self.user.username} on {self.project.name}>'
@property
def is_active(self):
"""Check if this is an active timer (no end time)"""
return self.end_time is None
@property
def duration_hours(self):
"""Get duration in hours"""
if not self.duration_seconds:
return 0
return round(self.duration_seconds / 3600, 2)
@property
def duration_formatted(self):
"""Get duration formatted as HH:MM:SS"""
if not self.duration_seconds:
return "00:00:00"
hours = self.duration_seconds // 3600
minutes = (self.duration_seconds % 3600) // 60
seconds = self.duration_seconds % 60
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
@property
def tag_list(self):
"""Get tags as a list"""
if not self.tags:
return []
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
@property
def current_duration_seconds(self):
"""Calculate current duration for active timers"""
if self.end_time:
return self.duration_seconds or 0
# For active timers, calculate from start time to now
# Since we store everything in local timezone, we can work with naive datetimes
# as long as we treat them as local time
# Get current time in local timezone (naive, matching database storage)
now_local = local_now()
# Calculate duration (both times are treated as local time)
duration = now_local - self.start_time
return int(duration.total_seconds())
def calculate_duration(self):
"""Calculate and set duration in seconds with rounding"""
if not self.end_time:
return
# Since we store everything in local timezone, we can work with naive datetimes
# as long as we treat them as local time
# Calculate raw duration (both times are treated as local time)
duration = self.end_time - self.start_time
raw_seconds = int(duration.total_seconds())
# Apply rounding
rounding_minutes = Config.ROUNDING_MINUTES
if rounding_minutes > 1:
# Round to nearest interval
minutes = raw_seconds / 60
rounded_minutes = round(minutes / rounding_minutes) * rounding_minutes
self.duration_seconds = int(rounded_minutes * 60)
else:
self.duration_seconds = raw_seconds
def stop_timer(self, end_time=None):
"""Stop an active timer"""
if self.end_time:
raise ValueError("Timer is already stopped")
# Use local timezone for consistency with database storage
if end_time:
self.end_time = end_time
else:
self.end_time = local_now()
self.calculate_duration()
self.updated_at = local_now()
db.session.commit()
def update_notes(self, notes):
"""Update notes for this entry"""
self.notes = notes.strip() if notes else None
self.updated_at = local_now()
db.session.commit()
def update_tags(self, tags):
"""Update tags for this entry"""
self.tags = tags.strip() if tags else None
self.updated_at = local_now()
db.session.commit()
def set_billable(self, billable):
"""Set billable status"""
self.billable = billable
self.updated_at = local_now()
db.session.commit()
def to_dict(self):
"""Convert time entry to dictionary for API responses"""
return {
'id': self.id,
'user_id': self.user_id,
'project_id': self.project_id,
'task_id': self.task_id,
'start_time': self.start_time.isoformat() if self.start_time else None,
'end_time': self.end_time.isoformat() if self.end_time else None,
'duration_seconds': self.duration_seconds,
'duration_hours': self.duration_hours,
'duration_formatted': self.duration_formatted,
'notes': self.notes,
'tags': self.tags,
'tag_list': self.tag_list,
'source': self.source,
'billable': self.billable,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'user': self.user.username if self.user else None,
'project': self.project.name if self.project else None,
'task': self.task.name if self.task else None
}
@classmethod
def get_active_timers(cls):
"""Get all active timers"""
return cls.query.filter_by(end_time=None).all()
@classmethod
def get_user_active_timer(cls, user_id):
"""Get active timer for a specific user"""
return cls.query.filter_by(user_id=user_id, end_time=None).first()
@classmethod
def get_entries_for_period(cls, start_date=None, end_date=None, user_id=None, project_id=None):
"""Get time entries for a specific period with optional filters"""
query = cls.query.filter(cls.end_time.isnot(None))
if start_date:
query = query.filter(cls.start_time >= start_date)
if end_date:
query = query.filter(cls.start_time <= end_date)
if user_id:
query = query.filter(cls.user_id == user_id)
if project_id:
query = query.filter(cls.project_id == project_id)
return query.order_by(cls.start_time.desc()).all()
@classmethod
def get_total_hours_for_period(cls, start_date=None, end_date=None, user_id=None, project_id=None, billable_only=False):
"""Calculate total hours for a period with optional filters"""
query = db.session.query(db.func.sum(cls.duration_seconds))
if start_date:
query = query.filter(cls.start_time >= start_date)
if end_date:
query = query.filter(cls.start_time <= end_date)
if user_id:
query = query.filter(cls.user_id == user_id)
if project_id:
query = query.filter(cls.project_id == project_id)
if billable_only:
query = query.filter(cls.billable == True)
total_seconds = query.scalar() or 0
return round(total_seconds / 3600, 2)