Files
TimeTracker/app/services/stats_service.py
T
Dries Peeters 91127bf188 feat: smart in-app notifications, value dashboard stats, and search scope helpers
Smart notifications (opt-in under user settings): NotificationService builds candidates from the user's local day and active timers; GET /api/notifications and POST /api/notifications/dismiss; migration 150 adds user columns and user_smart_notification_dismissals. /api/summary/today uses the same local-day totals. Client polls from smart-notifications.js; toastManager.show gains onDismiss for server dismiss sync. Config and env.example document SMART_NOTIFY_* variables.

Value dashboard: StatsService with Redis-backed caching, GET /api/stats/value-dashboard, dashboard template and dashboard-enhancements polling alongside existing widgets.

API v1 token search now uses apply_project_scope and apply_client_scope on queries; scope_filter adds apply_project_scope; tests extended for the new helper.
2026-04-15 12:15:23 +02:00

217 lines
8.2 KiB
Python

"""Aggregated productivity stats for the Value Dashboard (cached, SQL-efficient)."""
from __future__ import annotations
from datetime import date, datetime, time, timedelta
from typing import Any, Dict, List, Optional
from sqlalchemy import Integer, and_, case, func
from sqlalchemy.orm import aliased
from app import db
from app.config import Config
from app.models import Client, Project, TimeEntry
from app.models.time_entry import local_now
from app.utils.cache_redis import cache_key, get_cache, set_cache
from app.utils.overtime import get_week_start_for_date
_CACHE_PREFIX = "value_dashboard"
_CACHE_TTL_SEC = 600
# SQLite strftime('%%w') and PostgreSQL EXTRACT(dow): 0=Sunday .. 6=Saturday
_DOW_ENGLISH = ("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday")
class StatsService:
"""Read-only aggregates over time entries for dashboard insights."""
@classmethod
def get_value_dashboard(cls, user) -> Dict[str, Any]:
"""Return value-dashboard payload for the given user (session user). Cached 10 min when Redis is up."""
uid = int(getattr(user, "id", 0) or 0)
key = cache_key(_CACHE_PREFIX, uid)
cached = get_cache(key, default=None)
if cached is not None:
return cached
payload = cls._compute_value_dashboard(user)
set_cache(key, payload, ttl=_CACHE_TTL_SEC)
return payload
@classmethod
def _compute_value_dashboard(cls, user) -> Dict[str, Any]:
from app.models.settings import Settings
user_id = int(user.id)
now = local_now()
today: date = now.date()
week_start = get_week_start_for_date(today, user)
month_start = today.replace(day=1)
day_after_today = today + timedelta(days=1)
range_start_date = today - timedelta(days=6)
end_exclusive = datetime.combine(day_after_today, time.min)
week_start_dt = datetime.combine(week_start, time.min)
month_start_dt = datetime.combine(month_start, time.min)
range_start_dt = datetime.combine(range_start_date, time.min)
base_filter = and_(TimeEntry.user_id == user_id, TimeEntry.end_time.isnot(None))
week_cond = and_(TimeEntry.start_time >= week_start_dt, TimeEntry.start_time < end_exclusive)
month_cond = and_(TimeEntry.start_time >= month_start_dt, TimeEntry.start_time < end_exclusive)
main_row = (
db.session.query(
func.coalesce(func.sum(TimeEntry.duration_seconds), 0).label("total_sec"),
func.count(TimeEntry.id).label("entry_count"),
func.count(func.distinct(func.date(TimeEntry.start_time))).label("active_days"),
func.coalesce(
func.sum(case((week_cond, TimeEntry.duration_seconds), else_=0)),
0,
).label("week_sec"),
func.coalesce(
func.sum(case((month_cond, TimeEntry.duration_seconds), else_=0)),
0,
).label("month_sec"),
)
.filter(base_filter)
.one()
)
total_sec = int(main_row.total_sec or 0)
entries_count = int(main_row.entry_count or 0)
active_days = int(main_row.active_days or 0)
total_hours = round(total_sec / 3600.0, 2)
this_week_hours = round(int(main_row.week_sec or 0) / 3600.0, 2)
this_month_hours = round(int(main_row.month_sec or 0) / 3600.0, 2)
avg_session_length = round(total_hours / entries_count, 2) if entries_count else 0.0
most_productive_day = cls._most_productive_day_english(base_filter)
last_7_days = cls._last_7_days_hours(base_filter, range_start_dt, end_exclusive, range_start_date, today)
estimated_value_tracked = cls._estimated_value_tracked(base_filter)
settings = Settings.get_settings()
currency = (getattr(settings, "currency", None) or Config.CURRENCY or "EUR").strip()[:3] or "EUR"
payload: Dict[str, Any] = {
"total_hours": total_hours,
"entries_count": entries_count,
"active_days": active_days,
"avg_session_length": avg_session_length,
"most_productive_day": most_productive_day,
"this_week_hours": this_week_hours,
"this_month_hours": this_month_hours,
"last_7_days": last_7_days,
"estimated_value_tracked": round(estimated_value_tracked, 2)
if estimated_value_tracked and estimated_value_tracked > 0
else None,
"estimated_value_currency": currency,
}
return payload
@classmethod
def _dow_expression(cls):
bind = db.session.get_bind()
dialect = (bind.dialect.name if bind else "") or ""
if dialect == "sqlite":
return func.strftime("%w", TimeEntry.start_time)
return func.cast(func.extract("dow", TimeEntry.start_time), Integer)
@classmethod
def _most_productive_day_english(cls, base_filter) -> Optional[str]:
dow_col = cls._dow_expression().label("dow")
rows = (
db.session.query(dow_col, func.coalesce(func.sum(TimeEntry.duration_seconds), 0).label("sec"))
.filter(base_filter)
.group_by(dow_col)
.all()
)
if not rows:
return None
best_dow: Optional[int] = None
best_sec = -1
for dow, sec in rows:
s = int(sec or 0)
if s > best_sec:
best_sec = s
try:
di = int(dow) if dow is not None else None
except (TypeError, ValueError):
di = None
best_dow = di
if best_dow is None or best_sec <= 0:
return None
if 0 <= best_dow <= 6:
return _DOW_ENGLISH[best_dow]
return None
@classmethod
def _last_7_days_hours(
cls,
base_filter,
range_start_dt: datetime,
end_exclusive: datetime,
range_start_date: date,
today: date,
) -> List[Dict[str, Any]]:
q = (
db.session.query(
func.date(TimeEntry.start_time).label("day"),
func.coalesce(func.sum(TimeEntry.duration_seconds), 0).label("sec"),
)
.filter(
base_filter,
TimeEntry.start_time >= range_start_dt,
TimeEntry.start_time < end_exclusive,
)
.group_by(func.date(TimeEntry.start_time))
)
by_day: Dict[date, float] = {}
for row in q:
d = row.day
if d is None:
continue
if hasattr(d, "date") and callable(getattr(d, "date")) and not isinstance(d, date):
try:
d = d.date()
except Exception:
continue
elif isinstance(d, str):
try:
d = date.fromisoformat(d[:10])
except ValueError:
continue
by_day[d] = round(int(row.sec or 0) / 3600.0, 2)
out: List[Dict[str, Any]] = []
cur = range_start_date
while cur <= today:
out.append({"date": cur.isoformat(), "hours": by_day.get(cur, 0.0)})
cur += timedelta(days=1)
return out
@classmethod
def _estimated_value_tracked(cls, base_filter) -> float:
"""Sum (hours * effective rate) using project rate, else client defaults."""
ClientDirect = aliased(Client)
ClientProj = aliased(Client)
hours = func.coalesce(TimeEntry.duration_seconds, 0) / 3600.0
rate = func.coalesce(Project.hourly_rate, ClientDirect.default_hourly_rate, ClientProj.default_hourly_rate, 0)
total = (
db.session.query(func.coalesce(func.sum(hours * rate), 0))
.select_from(TimeEntry)
.outerjoin(Project, Project.id == TimeEntry.project_id)
.outerjoin(ClientDirect, ClientDirect.id == TimeEntry.client_id)
.outerjoin(ClientProj, ClientProj.id == Project.client_id)
.filter(base_filter)
.scalar()
)
return float(total or 0)
# Expose for tests (bypass cache)
def compute_value_dashboard_for_tests(user) -> Dict[str, Any]:
return StatsService._compute_value_dashboard(user)