mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 04:40:32 -05:00
b4486a627f
- 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
400 lines
15 KiB
Python
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)
|