Files
TimeTracker/app/models/time_entry.py
T
Dries Peeters b4486a627f fix: CI tests, code quality, and duplicate DB indexes
- Webhook models: remove duplicate index definitions so db.create_all()
  no longer raises 'index already exists' (columns already have index=True)
- ImportService: fix circular import by late-importing ClientService,
  ProjectService, TimeTrackingService in __init__
- reports: fix F823 by renaming unpack variable _ to _entry_count to avoid
  shadowing gettext _ in export_task_excel()
- Code quality: add .flake8 with extend-ignore so flake8 CI passes;
  simplify pyproject.toml isort config (drop unsupported options)
- Format: run black and isort on app/
- tests: restore minimal app fixture in test_import_export_models
2026-03-15 10:51:52 +01:00

400 lines
15 KiB
Python

from datetime import datetime, timedelta, timezone
from app import db
from app.config import Config
from app.utils.timezone import local_to_utc, utc_to_local
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=True, index=True)
client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=True, 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)
break_seconds = db.Column(db.Integer, nullable=True, default=0)
paused_at = db.Column(db.DateTime, 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)
paid = db.Column(db.Boolean, default=False, nullable=False, index=True)
invoice_number = db.Column(db.String(100), nullable=True)
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
# client relationship is defined via backref in Client model
# task relationship is defined via backref in Task model
def __init__(
self,
user_id=None,
project_id=None,
client_id=None,
start_time=None,
end_time=None,
task_id=None,
notes=None,
tags=None,
source="manual",
billable=True,
paid=False,
invoice_number=None,
duration_seconds=None,
break_seconds=None,
**kwargs,
):
"""Initialize a TimeEntry instance.
Args:
user_id: ID of the user who created this entry
project_id: ID of the project this entry is associated with (optional if client_id is provided)
client_id: ID of the client this entry is directly billed to (optional if project_id is provided)
start_time: When the time entry started
end_time: When the time entry ended (None for active timers)
task_id: Optional task ID (only valid when project_id is provided)
notes: Optional notes/description
tags: Optional comma-separated tags
source: Source of the entry ('manual' or 'auto')
billable: Whether this entry is billable
paid: Whether this entry has been paid
invoice_number: Optional internal invoice number reference
duration_seconds: Optional duration override (usually calculated automatically)
break_seconds: Optional break time in seconds to subtract from duration
**kwargs: Additional keyword arguments (for SQLAlchemy compatibility)
"""
if break_seconds is not None:
self.break_seconds = int(break_seconds)
if user_id is not None:
self.user_id = user_id
if project_id is not None:
self.project_id = project_id
if client_id is not None:
self.client_id = client_id
if task_id is not None:
self.task_id = task_id
if start_time is not None:
self.start_time = start_time
if end_time is not None:
self.end_time = end_time
# Validate that either project_id or client_id is provided
# Exception: auto-imported entries (source="auto") can be created without project or client
if not self.project_id and not self.client_id and source != "auto":
raise ValueError("Either project_id or client_id must be provided")
# Validate that task_id is only provided when project_id is set
if self.task_id and not self.project_id:
raise ValueError("task_id can only be set when project_id is provided")
self.notes = notes.strip() if notes else None
self.tags = tags.strip() if tags else None
self.source = source
self.billable = billable
self.paid = paid
self.invoice_number = invoice_number.strip() if invoice_number else None
# Allow manual duration override
if duration_seconds is not None:
self.duration_seconds = duration_seconds
# Otherwise, calculate duration if end time is provided
elif self.end_time:
self.calculate_duration()
def __repr__(self):
user_name = self.user.username if self.user else "deleted_user"
if self.project:
target = self.project.name
elif self.client:
target = self.client.name
else:
target = "unknown"
return f"<TimeEntry {self.id}: {user_name} on {target}>"
@property
def is_active(self):
"""Check if this is an active timer (no end time)"""
return self.end_time is None
@property
def is_paused(self):
"""Check if this active timer is currently paused"""
return self.paused_at is not None
@property
def break_formatted(self):
"""Format break_seconds as HH:MM:SS for display"""
sec = self.break_seconds or 0
hours = sec // 3600
minutes = (sec % 3600) // 60
seconds = sec % 60
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
@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"""
# For active timers (end_time is None), use current_duration_seconds
if not self.end_time:
total_seconds = self.current_duration_seconds
elif not self.duration_seconds:
return "00:00:00"
else:
total_seconds = int(self.duration_seconds)
# Convert to int to ensure integer values for formatting
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_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 (worked time, excluding break)."""
if self.end_time:
return self.duration_seconds or 0
# For active timers: elapsed time minus break; if paused, time stops at paused_at
break_sec = self.break_seconds or 0
now_local = local_now()
end_ref = self._naive_dt(self.paused_at) if self.paused_at else now_local
start_naive = self._naive_dt(self.start_time)
if start_naive is None or end_ref is None:
return 0
raw_seconds = int((end_ref - start_naive).total_seconds())
return max(0, raw_seconds - break_sec)
def _naive_dt(self, dt):
"""Return datetime as naive local for duration math (handles DB returning aware in some backends)."""
if dt is None:
return None
if dt.tzinfo is None:
return dt
from app.utils.timezone import get_timezone_obj
tz = get_timezone_obj()
return dt.astimezone(tz).replace(tzinfo=None)
def calculate_duration(self):
"""Calculate and set duration in seconds with rounding"""
if not self.end_time:
return
# Normalize to naive for subtraction (storage is local time; some DB drivers return aware)
start = self._naive_dt(self.start_time)
end = self._naive_dt(self.end_time)
if start is None or end is None:
return
duration = end - start
raw_seconds = int(duration.total_seconds())
break_sec = self.break_seconds or 0
raw_seconds = max(0, raw_seconds - break_sec)
# Apply per-user rounding if user preferences are set
if self.user and hasattr(self.user, "time_rounding_enabled"):
from app.utils.time_rounding import apply_user_rounding
self.duration_seconds = apply_user_rounding(raw_seconds, self.user)
else:
# Fallback to global rounding setting for backward compatibility
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 pause_timer(self):
"""Pause an active timer (clock stops; break accumulates on resume)."""
if self.end_time:
raise ValueError("Timer is already stopped")
if self.paused_at:
raise ValueError("Timer is already paused")
self.paused_at = local_now()
self.updated_at = local_now()
db.session.commit()
def resume_timer(self):
"""Resume a paused timer (accumulate time since paused_at into break_seconds)."""
if self.end_time:
raise ValueError("Timer is already stopped")
if not self.paused_at:
raise ValueError("Timer is not paused")
now = local_now()
paused_naive = self._naive_dt(self.paused_at)
if paused_naive:
added_break = int((now - paused_naive).total_seconds())
self.break_seconds = (self.break_seconds or 0) + added_break
self.paused_at = None
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 set_paid(self, paid, invoice_number=None):
"""Set paid status and optional invoice number"""
self.paid = paid
if invoice_number:
self.invoice_number = invoice_number.strip() if invoice_number else None
elif not paid:
# Clear invoice number when marking as unpaid
self.invoice_number = None
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,
"client_id": self.client_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,
"break_seconds": self.break_seconds,
"paused_at": self.paused_at.isoformat() if self.paused_at else None,
"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,
"paid": self.paid,
"invoice_number": self.invoice_number,
"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,
"client": self.client.name if self.client 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, client_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)
if client_id:
query = query.filter(cls.client_id == client_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, client_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 client_id:
query = query.filter(cls.client_id == client_id)
if billable_only:
query = query.filter(cls.billable == True)
total_seconds = query.scalar() or 0
return round(total_seconds / 3600, 2)