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"" @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)