Files
TimeTracker/app/models/weekly_time_goal.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

241 lines
9.5 KiB
Python

from datetime import datetime, timedelta
from sqlalchemy import func
from app import db
def local_now():
"""Get current time in local timezone"""
import os
from zoneinfo import ZoneInfo
# Get timezone from environment variable, default to Europe/Rome
timezone_name = os.getenv("TZ", "Europe/Rome")
tz = ZoneInfo(timezone_name)
now = datetime.now(tz)
return now.replace(tzinfo=None)
class WeeklyTimeGoal(db.Model):
"""Weekly time goal model for tracking user's weekly hour targets"""
__tablename__ = "weekly_time_goals"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
target_hours = db.Column(db.Float, nullable=False) # Target hours for the week
week_start_date = db.Column(db.Date, nullable=False, index=True) # Monday of the week
week_end_date = db.Column(db.Date, nullable=False) # Sunday of the week (or Friday if exclude_weekends is True)
exclude_weekends = db.Column(
db.Boolean, default=False, nullable=False
) # If True, only count weekdays (5-day work week)
status = db.Column(db.String(20), default="active", nullable=False) # 'active', 'completed', 'failed', 'cancelled'
notes = db.Column(db.Text, 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 = db.relationship("User", backref=db.backref("weekly_goals", lazy="dynamic", cascade="all, delete-orphan"))
def __init__(self, user_id, target_hours, week_start_date=None, notes=None, exclude_weekends=False, **kwargs):
"""Initialize a WeeklyTimeGoal instance.
Args:
user_id: ID of the user who created this goal
target_hours: Target hours for the week
week_start_date: Start date of the week (Monday). If None, uses current week.
notes: Optional notes about the goal
exclude_weekends: If True, only count weekdays (5-day work week). Default False.
**kwargs: Additional keyword arguments (for SQLAlchemy compatibility)
"""
self.user_id = user_id
self.target_hours = target_hours
self.exclude_weekends = exclude_weekends
# If no week_start_date provided, calculate the current week's Monday
if week_start_date is None:
from app.models.user import User
user = User.query.get(user_id)
week_start_day = (
user.week_start_day if user else 1
) # Default to Monday (user convention: 0=Sunday, 1=Monday)
today = local_now().date()
# Convert user convention (0=Sunday, 1=Monday) to Python weekday (0=Monday, 6=Sunday)
python_week_start_day = (week_start_day - 1) % 7
days_since_week_start = (today.weekday() - python_week_start_day) % 7
week_start_date = today - timedelta(days=days_since_week_start)
self.week_start_date = week_start_date
# If exclude_weekends is True, week ends on Friday (4 days after Monday), otherwise Sunday (6 days after Monday)
if exclude_weekends:
self.week_end_date = week_start_date + timedelta(days=4) # Monday to Friday
else:
self.week_end_date = week_start_date + timedelta(days=6) # Monday to Sunday
self.notes = notes
# Allow status override from kwargs
if "status" in kwargs:
self.status = kwargs["status"]
def __repr__(self):
return f"<WeeklyTimeGoal user_id={self.user_id} week={self.week_start_date} target={self.target_hours}h>"
@property
def actual_hours(self):
"""Calculate actual hours worked during this week"""
from app.models.time_entry import TimeEntry
# Query time entries for this user within the week range
entries = TimeEntry.query.filter(
TimeEntry.user_id == self.user_id,
TimeEntry.end_time.isnot(None),
func.date(TimeEntry.start_time) >= self.week_start_date,
func.date(TimeEntry.start_time) <= self.week_end_date,
).all()
# If exclude_weekends is True, filter out Saturday (5) and Sunday (6)
# Python weekday: Monday=0, Tuesday=1, ..., Sunday=6
if self.exclude_weekends:
entries = [e for e in entries if e.start_time.date().weekday() < 5]
total_seconds = sum(entry.duration_seconds for entry in entries)
return round(total_seconds / 3600, 2)
@property
def progress_percentage(self):
"""Calculate progress as a percentage"""
if self.target_hours <= 0:
return 0
percentage = (self.actual_hours / self.target_hours) * 100
return min(round(percentage, 1), 100) # Cap at 100%
@property
def remaining_hours(self):
"""Calculate remaining hours to reach the goal"""
remaining = self.target_hours - self.actual_hours
return max(round(remaining, 2), 0)
@property
def is_completed(self):
"""Check if the goal has been met"""
return self.actual_hours >= self.target_hours
@property
def is_overdue(self):
"""Check if the week has passed and goal is not completed"""
today = local_now().date()
return today > self.week_end_date and not self.is_completed
@property
def days_remaining(self):
"""Calculate days remaining in the week"""
today = local_now().date()
if today > self.week_end_date:
return 0
if self.exclude_weekends:
# Count only weekdays (Monday-Friday)
days = 0
current_date = today
while current_date <= self.week_end_date:
# Python weekday: Monday=0, Tuesday=1, ..., Sunday=6
if current_date.weekday() < 5: # Monday through Friday
days += 1
current_date += timedelta(days=1)
return days
else:
# Count all days
return (self.week_end_date - today).days + 1
@property
def average_hours_per_day(self):
"""Calculate average hours needed per day to reach goal"""
if self.days_remaining <= 0:
return 0
return round(self.remaining_hours / self.days_remaining, 2)
@property
def week_label(self):
"""Get a human-readable label for the week"""
if self.exclude_weekends:
return (
f"{self.week_start_date.strftime('%b %d')} - {self.week_end_date.strftime('%b %d, %Y')} (Weekdays only)"
)
return f"{self.week_start_date.strftime('%b %d')} - {self.week_end_date.strftime('%b %d, %Y')}"
def update_status(self):
"""Update the goal status based on current date and progress"""
today = local_now().date()
if self.status == "cancelled":
return # Don't auto-update cancelled goals
if today > self.week_end_date:
# Week has ended
if self.is_completed:
self.status = "completed"
else:
self.status = "failed"
elif self.is_completed and self.status == "active":
self.status = "completed"
db.session.commit()
def to_dict(self):
"""Convert goal to dictionary for API responses"""
return {
"id": self.id,
"user_id": self.user_id,
"target_hours": self.target_hours,
"actual_hours": self.actual_hours,
"week_start_date": self.week_start_date.isoformat(),
"week_end_date": self.week_end_date.isoformat(),
"exclude_weekends": self.exclude_weekends,
"week_label": self.week_label,
"status": self.status,
"notes": self.notes,
"progress_percentage": self.progress_percentage,
"remaining_hours": self.remaining_hours,
"is_completed": self.is_completed,
"is_overdue": self.is_overdue,
"days_remaining": self.days_remaining,
"average_hours_per_day": self.average_hours_per_day,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
@staticmethod
def get_current_week_goal(user_id):
"""Get the goal for the current week for a specific user"""
from app.models.user import User
user = User.query.get(user_id)
week_start_day = user.week_start_day if user else 1 # User convention: 0=Sunday, 1=Monday
today = local_now().date()
# Convert user convention (0=Sunday, 1=Monday) to Python weekday (0=Monday, 6=Sunday)
python_week_start_day = (week_start_day - 1) % 7
days_since_week_start = (today.weekday() - python_week_start_day) % 7
week_start = today - timedelta(days=days_since_week_start)
week_end = week_start + timedelta(days=6)
return WeeklyTimeGoal.query.filter(
WeeklyTimeGoal.user_id == user_id,
WeeklyTimeGoal.week_start_date == week_start,
WeeklyTimeGoal.status != "cancelled",
).first()
@staticmethod
def get_or_create_current_week(user_id, default_target_hours=40):
"""Get or create a goal for the current week"""
goal = WeeklyTimeGoal.get_current_week_goal(user_id)
if not goal:
goal = WeeklyTimeGoal(user_id=user_id, target_hours=default_target_hours)
db.session.add(goal)
db.session.commit()
return goal