Files
TimeTracker/app/routes/api.py
T
Dries Peeters 6c8e86cd01 fix(timer): respect Settings.single_active_timer at runtime
Timer starts always blocked a second running entry and never read the\nadmin-controlled Settings flag.\n\n- Add TimeTrackingService.can_start_timer() using Settings.get_settings()\n  and wire it into start_timer, web timer routes, kiosk start, and\n  legacy POST /api/timer/resume.\n- POST /api/v1/timer/start returns 409 with error_code\n  timer_already_running when single-active mode is on and a timer\n  is already running.\n- Deduplicate start_timer template handling in the service.\n\nTests: tests/test_single_active_timer_setting.py.\nDocs: REST_API (responses), GETTING_STARTED, REQUIREMENTS, Docker env\nnotes, TESTING_STRATEGY, env.example comment; CHANGELOG entry.
2026-04-27 19:16:25 +02:00

2154 lines
77 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import os
import uuid
from datetime import datetime, time, timedelta
from flask import Blueprint, current_app, jsonify, make_response, request, send_from_directory, session
from flask_babel import gettext as _
from flask_login import current_user, login_required
from sqlalchemy import func, or_
from sqlalchemy.exc import SQLAlchemyError
from werkzeug.utils import secure_filename
from app import db, socketio
from app.models import (
Client,
FocusSession,
Project,
RateOverride,
RecurringBlock,
SavedFilter,
Settings,
Task,
TimeEntry,
User,
)
from app.models.time_entry import local_now
from app.services.global_search_service import run_global_search
from app.services.llm_service import AIServiceError, LLMService
from app.services.time_tracking_service import TimeTrackingService
from app.utils.api_deprecation import deprecated_session_api
from app.utils.db import safe_commit
from app.utils.scope_filter import apply_client_scope, apply_project_scope, user_can_access_project
from app.utils.timezone import convert_app_datetime_to_user, parse_local_datetime, utc_to_local
api_bp = Blueprint("api", __name__)
def _ai_error_response(exc: AIServiceError):
return jsonify({"ok": False, "error": exc.message, "error_code": exc.code}), exc.status_code
@api_bp.route("/api/health")
@deprecated_session_api("/api/v1/health")
def health_check():
"""Health check endpoint for monitoring and error handling"""
return jsonify({"status": "ok", "timestamp": datetime.utcnow().isoformat()})
@api_bp.route("/api/ai/context-preview")
@login_required
def ai_context_preview():
"""Return the compact context preview that would be sent to the configured AI provider."""
try:
service = LLMService()
return jsonify({"ok": True, "context": service.context_preview(current_user), "provider": service.config.public_dict()})
except AIServiceError as exc:
return _ai_error_response(exc)
@api_bp.route("/api/ai/test", methods=["POST"])
@login_required
def ai_test_connection():
"""Test the configured AI provider. Admins/settings managers can use this from settings."""
if not (current_user.is_admin or current_user.has_permission("manage_settings")):
return jsonify({"ok": False, "error": "Admin permission required", "error_code": "forbidden"}), 403
try:
return jsonify(LLMService().test_connection())
except AIServiceError as exc:
return _ai_error_response(exc)
@api_bp.route("/api/ai/chat", methods=["POST"])
@login_required
def ai_chat():
"""Chat with the AI helper using server-built TimeTracker context."""
data = request.get_json(silent=True) or {}
try:
result = LLMService().chat(current_user, data.get("prompt") or "", data.get("history") or [])
return jsonify({"ok": True, **result})
except AIServiceError as exc:
return _ai_error_response(exc)
@api_bp.route("/api/ai/actions/confirm", methods=["POST"])
@login_required
def ai_confirm_action():
"""Execute a user-confirmed AI proposed action."""
data = request.get_json(silent=True) or {}
try:
result = LLMService().confirm_action(current_user, data.get("action") or {})
return jsonify(result)
except AIServiceError as exc:
return _ai_error_response(exc)
def _effective_user_for_version_api():
"""Session user, or API token user (Bearer / X-API-Key). Used for version check routes."""
if getattr(current_user, "is_authenticated", False):
return current_user
from app.utils.api_auth import authenticate_token, extract_token_from_request
token = extract_token_from_request()
if not token:
return None
user, _api_token, _err = authenticate_token(token, record_usage=False)
return user
@api_bp.route("/api/version/check")
def api_version_check():
"""Admin only: compare installed version to latest GitHub release (cached)."""
user = _effective_user_for_version_api()
if user is None:
return jsonify({"error": "unauthorized", "message": "Authentication required"}), 401
if not user.is_admin:
return jsonify({"error": "forbidden", "message": "Admin only"}), 403
from app.services.version_service import VersionService
return jsonify(VersionService.build_check_response(user))
@api_bp.route("/api/version/dismiss", methods=["POST"])
def api_version_dismiss():
"""Admin only: remember not to show update popup for this normalized release version."""
user = _effective_user_for_version_api()
if user is None:
return jsonify({"error": "unauthorized", "message": "Authentication required"}), 401
if not user.is_admin:
return jsonify({"error": "forbidden", "message": "Admin only"}), 403
data = request.get_json(silent=True) or {}
raw = data.get("latest_version")
if not isinstance(raw, str) or not raw.strip():
return jsonify({"error": "latest_version is required"}), 400
from app.utils.version_compare import normalize_version_tag
norm = normalize_version_tag(raw)
if not norm:
current_app.logger.warning(
"Version dismiss: invalid latest_version from admin user_id=%s: %r",
user.id,
raw,
)
return jsonify({"error": "invalid latest_version"}), 400
user.dismissed_release_version = norm
db.session.add(user)
if not safe_commit():
return jsonify({"error": "save_failed"}), 500
return jsonify({"ok": True})
@api_bp.route("/api/timer/status")
@login_required
@deprecated_session_api("/api/v1/timer/status")
def timer_status():
"""Get current timer status"""
active_timer = current_user.active_timer
if not active_timer:
return jsonify({"active": False, "timer": None})
return jsonify(
{
"active": True,
"timer": {
"id": active_timer.id,
"project_name": active_timer.project.name,
"project_id": active_timer.project_id,
"task_id": active_timer.task_id,
"start_time": active_timer.start_time.isoformat(),
"current_duration": active_timer.current_duration_seconds,
"duration_formatted": active_timer.duration_formatted,
},
}
)
@api_bp.route("/api/tags")
@login_required
def get_recent_tags():
"""Return distinct tags from current user's time entries for autocomplete (e.g. Start Timer modal)."""
limit = min(request.args.get("limit", 30, type=int), 100)
entries = (
TimeEntry.query.filter(
TimeEntry.user_id == current_user.id,
TimeEntry.tags.isnot(None),
TimeEntry.tags != "",
)
.order_by(TimeEntry.updated_at.desc())
.limit(500)
.all()
)
tags_set = set()
for e in entries:
if e.tags:
for part in e.tags.split(","):
t = part.strip()
if t:
tags_set.add(t)
if len(tags_set) >= limit:
break
if len(tags_set) >= limit:
break
return jsonify({"tags": sorted(tags_set)})
@api_bp.route("/api/search")
@login_required
@deprecated_session_api("/api/v1/search")
def search():
"""Global search endpoint for projects, tasks, clients, and time entries.
Query Parameters:
q (str): Search query (minimum 2 characters)
limit (int): Maximum number of results per category (default: 10, max: 50)
types (str): Comma-separated list of types to search (project, task, client, entry)
Returns:
JSON with ``results``, ``query``, ``count``, ``partial`` (true if any search domain failed),
and ``errors`` (map of domain key to message: ``projects``, ``tasks``, ``clients``, ``entries``).
Domains that hit a database error are omitted from ``results``; other domains still return hits.
"""
query = request.args.get("q", "").strip()
limit = min(request.args.get("limit", 10, type=int), 50) # Cap at 50
types_filter = request.args.get("types", "").strip().lower()
if not query or len(query) < 2:
return jsonify({"results": [], "query": query, "count": 0, "partial": False, "errors": {}})
results, errors = run_global_search(
current_user,
query,
limit=limit,
types_filter=types_filter,
)
return jsonify(
{
"results": results,
"query": query,
"count": len(results),
"partial": bool(errors),
"errors": errors,
}
)
@api_bp.route("/api/deadlines/upcoming")
@login_required
def upcoming_deadlines():
"""Return upcoming task deadlines for the current user."""
now_utc = datetime.utcnow()
today = now_utc.date()
horizon = (now_utc + timedelta(days=2)).date()
query = Task.query.join(Project).filter(
Project.status == "active",
Task.due_date.isnot(None),
Task.status.in_(("todo", "in_progress", "review")),
Task.due_date >= today,
Task.due_date <= horizon,
)
if not current_user.is_admin:
query = query.filter(or_(Task.assigned_to == current_user.id, Task.created_by == current_user.id))
tasks = query.order_by(Task.due_date.asc(), Task.priority.desc(), Task.name.asc()).limit(20).all()
end_of_day = time(hour=23, minute=59, second=59)
deadlines = []
for task in tasks:
due_dt = datetime.combine(task.due_date, end_of_day)
deadlines.append(
{
"task_id": task.id,
"task_name": task.name,
"project_id": task.project_id,
"project_name": task.project.name if task.project else None,
"due_date": due_dt.isoformat(),
"priority": task.priority,
"status": task.status,
}
)
return jsonify(deadlines)
@api_bp.route("/api/tasks")
@login_required
@deprecated_session_api("/api/v1/tasks")
def list_tasks_for_project():
"""List tasks for a given project (optionally filter by status)."""
project_id = request.args.get("project_id", type=int)
status = request.args.get("status")
if not project_id:
return jsonify({"error": "project_id is required"}), 400
# Validate project exists and is active
project = Project.query.filter_by(id=project_id, status="active").first()
if not project:
return jsonify({"error": "Invalid project"}), 400
query = Task.query.filter_by(project_id=project_id)
if status:
query = query.filter_by(status=status)
else:
# Default to tasks not done/cancelled
query = query.filter(Task.status.in_(["todo", "in_progress", "review"]))
tasks = query.order_by(Task.priority.desc(), Task.name.asc()).all()
return jsonify({"tasks": [{"id": t.id, "name": t.name, "status": t.status, "priority": t.priority} for t in tasks]})
@api_bp.route("/api/timer/start", methods=["POST"])
@login_required
@deprecated_session_api("/api/v1/timer/start")
def api_start_timer():
"""Start timer via API"""
data = request.get_json() or {}
project_id = data.get("project_id")
task_id = data.get("task_id")
notes = (data.get("notes") or "").strip() or None
if not project_id:
return jsonify({"error": "Project ID is required"}), 400
if not user_can_access_project(current_user, project_id):
return jsonify({"error": "You do not have access to this project"}), 403
service = TimeTrackingService()
result = service.start_timer(
user_id=current_user.id,
project_id=project_id,
task_id=task_id,
notes=notes,
template_id=None,
)
if not result.get("success"):
return jsonify({"error": result.get("message", "Could not start timer")}), 400
new_timer = result["timer"]
project = new_timer.project or Project.query.get(project_id)
tid = new_timer.task_id
socketio.emit(
"timer_started",
{
"user_id": current_user.id,
"timer_id": new_timer.id,
"project_name": project.name if project else "",
"task_id": tid,
"start_time": new_timer.start_time.isoformat(),
},
)
return jsonify(
{
"success": True,
"timer_id": new_timer.id,
"project_name": project.name if project else "",
"task_id": tid,
}
)
@api_bp.route("/api/timer/stop", methods=["POST"])
@login_required
@deprecated_session_api("/api/v1/timer/stop")
def api_stop_timer():
"""Stop timer via API"""
active_timer = current_user.active_timer
if not active_timer:
return jsonify({"error": "No active timer to stop"}), 400
service = TimeTrackingService()
result = service.stop_timer(user_id=current_user.id, entry_id=active_timer.id)
if not result.get("success"):
return jsonify({"error": result.get("message", "Could not stop timer")}), 400
entry = result["entry"]
socketio.emit(
"timer_stopped",
{"user_id": current_user.id, "timer_id": entry.id, "duration": entry.duration_formatted},
)
return jsonify(
{"success": True, "duration": entry.duration_formatted, "duration_hours": entry.duration_hours}
)
# --- Idle control: stop at specific time ---
@api_bp.route("/api/timer/stop_at", methods=["POST"])
@login_required
@deprecated_session_api("/api/v1/timer/stop")
def api_stop_timer_at():
"""Stop the active timer at a specific timestamp (idle adjustment)."""
active_timer = current_user.active_timer
if not active_timer:
return jsonify({"error": "No active timer to stop"}), 400
data = request.get_json() or {}
stop_time_str = data.get("stop_time") # ISO string
if not stop_time_str:
return jsonify({"error": "stop_time is required"}), 400
try:
# Accept ISO; handle trailing Z
ts = stop_time_str.strip()
if ts.endswith("Z"):
ts = ts[:-1] + "+00:00"
parsed = datetime.fromisoformat(ts)
# Convert to local naive for storage consistency
if parsed.tzinfo is not None:
parsed_local_aware = utc_to_local(parsed)
stop_time_local = parsed_local_aware.replace(tzinfo=None)
else:
stop_time_local = parsed
except Exception:
return jsonify({"error": "Invalid stop_time format"}), 400
if stop_time_local <= active_timer.start_time:
return jsonify({"error": "stop_time must be after start time"}), 400
# Do not allow stopping in the future
now_local = local_now()
if stop_time_local > now_local:
stop_time_local = now_local
try:
active_timer.stop_timer(end_time=stop_time_local)
except Exception as e:
current_app.logger.warning("Failed to stop timer at specific time: %s", e)
return jsonify({"error": "Failed to stop timer"}), 500
socketio.emit(
"timer_stopped",
{"user_id": current_user.id, "timer_id": active_timer.id, "duration": active_timer.duration_formatted},
)
return jsonify({"success": True, "duration": active_timer.duration_formatted})
# --- Resume last timer/project ---
@api_bp.route("/api/timer/resume", methods=["POST"])
@login_required
@deprecated_session_api("/api/v1/timer/start")
def api_resume_timer():
"""Resume timer for last used project/task or provided project/task."""
can_start, _ = TimeTrackingService().can_start_timer(current_user.id)
if not can_start:
return jsonify({"error": "Timer already running"}), 400
data = request.get_json() or {}
project_id = data.get("project_id")
task_id = data.get("task_id")
if not project_id:
# Find most recent finished entry
last = (
TimeEntry.query.filter(TimeEntry.user_id == current_user.id)
.order_by(TimeEntry.end_time.desc().nullslast(), TimeEntry.start_time.desc())
.first()
)
if not last:
return jsonify({"error": "No previous entry to resume"}), 404
project_id = last.project_id
task_id = last.task_id
# Validate project is active
project = Project.query.filter_by(id=project_id, status="active").first()
if not project:
return jsonify({"error": "Invalid or inactive project"}), 400
if task_id:
task = Task.query.filter_by(id=task_id, project_id=project_id).first()
if not task:
return jsonify({"error": "Invalid task for selected project"}), 400
service = TimeTrackingService()
result = service.start_timer(
user_id=current_user.id,
project_id=project_id,
task_id=task_id,
notes=None,
template_id=None,
)
if not result.get("success"):
return jsonify({"error": result.get("message", "Could not start timer")}), 400
new_timer = result["timer"]
socketio.emit(
"timer_started",
{
"user_id": current_user.id,
"timer_id": new_timer.id,
"project_name": project.name,
"task_id": task_id,
"start_time": new_timer.start_time.isoformat(),
},
)
return jsonify({"success": True, "timer_id": new_timer.id})
@api_bp.route("/api/entries")
@login_required
@deprecated_session_api("/api/v1/time-entries")
def get_entries():
"""Get time entries with pagination"""
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 20, type=int)
user_id = request.args.get("user_id", type=int)
project_id = request.args.get("project_id", type=int)
tag = (request.args.get("tag") or "").strip()
saved_filter_id = request.args.get("saved_filter_id", type=int)
query = TimeEntry.query.filter(TimeEntry.end_time.isnot(None))
# Apply saved filter if provided
if saved_filter_id:
filt = SavedFilter.query.get(saved_filter_id)
can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries")
if filt and (filt.user_id == current_user.id or (filt.is_shared and can_view_all)):
payload = filt.payload or {}
if "project_id" in payload:
query = query.filter(TimeEntry.project_id == int(payload["project_id"]))
if "user_id" in payload and can_view_all:
query = query.filter(TimeEntry.user_id == int(payload["user_id"]))
if "billable" in payload:
query = query.filter(TimeEntry.billable == bool(payload["billable"]))
if "tag" in payload and payload["tag"]:
query = query.filter(TimeEntry.tags.ilike(f"%{payload['tag']}%"))
# Filter by user (if has view_all_time_entries permission or own entries)
can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries")
if user_id and can_view_all:
query = query.filter(TimeEntry.user_id == user_id)
elif not can_view_all:
query = query.filter(TimeEntry.user_id == current_user.id)
# Filter by project
if project_id:
query = query.filter(TimeEntry.project_id == project_id)
# Filter by tag (simple contains search on comma-separated tags)
if tag:
like = f"%{tag}%"
query = query.filter(TimeEntry.tags.ilike(like))
entries = query.order_by(TimeEntry.start_time.desc()).paginate(page=page, per_page=per_page, error_out=False)
# Ensure frontend receives project_name like other endpoints
entries_payload = []
for entry in entries.items:
e = entry.to_dict()
e["project_name"] = e.get("project") or (entry.project.name if entry.project else None)
entries_payload.append(e)
return jsonify(
{
"entries": entries_payload,
"total": entries.total,
"pages": entries.pages,
"current_page": entries.page,
"has_next": entries.has_next,
"has_prev": entries.has_prev,
}
)
@api_bp.route("/api/projects/<int:project_id>/burndown")
@login_required
def project_burndown(project_id):
"""Return burn-down data for a given project.
Produces daily cumulative actual hours vs estimated hours line.
"""
project = Project.query.get_or_404(project_id)
# Permission: any authenticated can view if they have entries in project or are admin
if not current_user.is_admin:
has_entries = db.session.query(TimeEntry.id).filter_by(user_id=current_user.id, project_id=project_id).first()
if not has_entries:
return jsonify({"error": "Access denied"}), 403
# Date range: last 30 days up to today
end_date = datetime.utcnow().date()
start_date = end_date - timedelta(days=29)
# Fetch entries in range
entries = (
TimeEntry.query.filter(TimeEntry.project_id == project_id)
.filter(TimeEntry.end_time.isnot(None))
.filter(TimeEntry.start_time >= datetime.combine(start_date, datetime.min.time()))
.filter(TimeEntry.start_time <= datetime.combine(end_date, datetime.max.time()))
.order_by(TimeEntry.start_time.asc())
.all()
)
# Build daily buckets
labels = []
actual_cumulative = []
day_map = {}
cur = start_date
while cur <= end_date:
labels.append(cur.isoformat())
day_map[cur.isoformat()] = 0.0
cur = cur + timedelta(days=1)
for e in entries:
d = e.start_time.date().isoformat()
day_map[d] = day_map.get(d, 0.0) + (e.duration_seconds or 0) / 3600.0
running = 0.0
for d in labels:
running += day_map.get(d, 0.0)
actual_cumulative.append(round(running, 2))
# Estimated line: flat line of project.estimated_hours
estimated = float(project.estimated_hours or 0)
estimate_series = [estimated for _ in labels]
return jsonify(
{
"labels": labels,
"actual_cumulative": actual_cumulative,
"estimated": estimate_series,
"estimated_hours": estimated,
}
)
@api_bp.route("/api/focus-sessions/start", methods=["POST"])
@login_required
def start_focus_session():
data = request.get_json() or {}
project_id = data.get("project_id")
task_id = data.get("task_id")
pomodoro_length = int(data.get("pomodoro_length") or 25)
short_break_length = int(data.get("short_break_length") or 5)
long_break_length = int(data.get("long_break_length") or 15)
long_break_interval = int(data.get("long_break_interval") or 4)
link_active_timer = bool(data.get("link_active_timer", True))
time_entry_id = None
if link_active_timer and current_user.active_timer:
time_entry_id = current_user.active_timer.id
fs = FocusSession(
user_id=current_user.id,
project_id=project_id,
task_id=task_id,
time_entry_id=time_entry_id,
pomodoro_length=pomodoro_length,
short_break_length=short_break_length,
long_break_length=long_break_length,
long_break_interval=long_break_interval,
)
db.session.add(fs)
if not safe_commit("start_focus_session", {"user_id": current_user.id}):
return jsonify({"error": "Database error while starting focus session"}), 500
return jsonify({"success": True, "session": fs.to_dict()})
@api_bp.route("/api/focus-sessions/finish", methods=["POST"])
@login_required
def finish_focus_session():
data = request.get_json() or {}
session_id = data.get("session_id")
if not session_id:
return jsonify({"error": "session_id is required"}), 400
fs = FocusSession.query.get_or_404(session_id)
if fs.user_id != current_user.id and not current_user.is_admin:
return jsonify({"error": "Access denied"}), 403
fs.ended_at = datetime.utcnow()
fs.cycles_completed = int(data.get("cycles_completed") or 0)
fs.interruptions = int(data.get("interruptions") or 0)
notes = (data.get("notes") or "").strip()
fs.notes = notes or fs.notes
if not safe_commit("finish_focus_session", {"session_id": fs.id}):
return jsonify({"error": "Database error while finishing focus session"}), 500
return jsonify({"success": True, "session": fs.to_dict()})
@api_bp.route("/api/focus-sessions/summary")
@login_required
def focus_sessions_summary():
"""Return simple summary counts for recent focus sessions for the current user."""
days = int(request.args.get("days", 7))
since = datetime.utcnow() - timedelta(days=days)
q = FocusSession.query.filter(FocusSession.user_id == current_user.id, FocusSession.started_at >= since)
sessions = q.order_by(FocusSession.started_at.desc()).all()
total = len(sessions)
cycles = sum(s.cycles_completed or 0 for s in sessions)
interrupts = sum(s.interruptions or 0 for s in sessions)
return jsonify({"total_sessions": total, "cycles_completed": cycles, "interruptions": interrupts})
@api_bp.route("/api/recurring-blocks", methods=["GET", "POST"])
@login_required
def recurring_blocks_list_create():
if request.method == "GET":
blocks = (
RecurringBlock.query.filter_by(user_id=current_user.id).order_by(RecurringBlock.created_at.desc()).all()
)
return jsonify({"blocks": [b.to_dict() for b in blocks]})
data = request.get_json() or {}
name = (data.get("name") or "").strip()
project_id = data.get("project_id")
task_id = data.get("task_id")
recurrence = (data.get("recurrence") or "weekly").strip()
weekdays = (data.get("weekdays") or "").strip()
start_time_local = (data.get("start_time_local") or "").strip()
end_time_local = (data.get("end_time_local") or "").strip()
starts_on = data.get("starts_on")
ends_on = data.get("ends_on")
is_active = bool(data.get("is_active", True))
notes = (data.get("notes") or "").strip() or None
tags = (data.get("tags") or "").strip() or None
billable = bool(data.get("billable", True))
if not all([name, project_id, start_time_local, end_time_local]):
return jsonify({"error": "name, project_id, start_time_local, end_time_local are required"}), 400
block = RecurringBlock(
user_id=current_user.id,
project_id=project_id,
task_id=task_id,
name=name,
recurrence=recurrence,
weekdays=weekdays,
start_time_local=start_time_local,
end_time_local=end_time_local,
is_active=is_active,
notes=notes,
tags=tags,
billable=billable,
)
# Optional dates
try:
if starts_on:
block.starts_on = datetime.fromisoformat(starts_on).date()
if ends_on:
block.ends_on = datetime.fromisoformat(ends_on).date()
except Exception:
return jsonify({"error": "Invalid starts_on/ends_on date format"}), 400
db.session.add(block)
if not safe_commit("create_recurring_block", {"user_id": current_user.id}):
return jsonify({"error": "Database error while creating recurring block"}), 500
return jsonify({"success": True, "block": block.to_dict()})
@api_bp.route("/api/recurring-blocks/<int:block_id>", methods=["PUT", "DELETE"])
@login_required
def recurring_block_update_delete(block_id):
block = RecurringBlock.query.get_or_404(block_id)
if block.user_id != current_user.id and not current_user.is_admin:
return jsonify({"error": "Access denied"}), 403
if request.method == "DELETE":
db.session.delete(block)
if not safe_commit("delete_recurring_block", {"id": block.id}):
return jsonify({"error": "Database error while deleting recurring block"}), 500
return jsonify({"success": True})
data = request.get_json() or {}
for field in ["name", "recurrence", "weekdays", "start_time_local", "end_time_local", "notes", "tags"]:
if field in data:
setattr(block, field, (data.get(field) or "").strip())
for field in ["project_id", "task_id"]:
if field in data:
setattr(block, field, data.get(field))
if "is_active" in data:
block.is_active = bool(data.get("is_active"))
if "billable" in data:
block.billable = bool(data.get("billable"))
try:
if "starts_on" in data:
block.starts_on = datetime.fromisoformat(data.get("starts_on")).date() if data.get("starts_on") else None
if "ends_on" in data:
block.ends_on = datetime.fromisoformat(data.get("ends_on")).date() if data.get("ends_on") else None
except Exception:
return jsonify({"error": "Invalid starts_on/ends_on date format"}), 400
if not safe_commit("update_recurring_block", {"id": block.id}):
return jsonify({"error": "Database error while updating recurring block"}), 500
return jsonify({"success": True, "block": block.to_dict()})
@api_bp.route("/api/saved-filters", methods=["GET", "POST"])
@login_required
def saved_filters_list_create():
if request.method == "GET":
scope = (request.args.get("scope") or "global").strip()
items = SavedFilter.query.filter_by(user_id=current_user.id, scope=scope).order_by(SavedFilter.name.asc()).all()
return jsonify({"filters": [f.to_dict() for f in items]})
data = request.get_json() or {}
name = (data.get("name") or "").strip()
scope = (data.get("scope") or "global").strip()
payload = data.get("payload") or {}
is_shared = bool(data.get("is_shared", False))
if not name:
return jsonify({"error": "name is required"}), 400
filt = SavedFilter(user_id=current_user.id, name=name, scope=scope, payload=payload, is_shared=is_shared)
db.session.add(filt)
if not safe_commit("create_saved_filter", {"name": name, "scope": scope}):
return jsonify({"error": "Database error while creating saved filter"}), 500
return jsonify({"success": True, "filter": filt.to_dict()})
@api_bp.route("/api/saved-filters/<int:filter_id>", methods=["DELETE"])
@login_required
def delete_saved_filter(filter_id):
filt = SavedFilter.query.get_or_404(filter_id)
if filt.user_id != current_user.id and not current_user.is_admin:
return jsonify({"error": "Access denied"}), 403
db.session.delete(filt)
if not safe_commit("delete_saved_filter", {"id": filt.id}):
return jsonify({"error": "Database error while deleting saved filter"}), 500
return jsonify({"success": True})
@api_bp.route("/api/entries", methods=["POST"])
@login_required
@deprecated_session_api("/api/v1/time-entries")
def create_entry():
"""Create a finished time entry (used by calendar drag-create)."""
from app.models import Client
data = request.get_json() or {}
project_id = data.get("project_id")
client_id = data.get("client_id")
task_id = data.get("task_id")
start_time_str = data.get("start_time")
end_time_str = data.get("end_time")
notes = (data.get("notes") or "").strip() or None
tags = (data.get("tags") or "").strip() or None
billable = bool(data.get("billable", True))
if not (start_time_str and end_time_str):
return jsonify({"error": "start_time and end_time are required"}), 400
if not project_id and not client_id:
return jsonify({"error": "Either project_id or client_id is required"}), 400
def parse_iso_local(s: str):
try:
ts = s.strip()
if ts.endswith("Z"):
ts = ts[:-1] + "+00:00"
dt = datetime.fromisoformat(ts)
if dt.tzinfo is not None:
return utc_to_local(dt).replace(tzinfo=None)
return dt
except Exception:
return None
start_dt = parse_iso_local(start_time_str)
end_dt = parse_iso_local(end_time_str)
if not (start_dt and end_dt) or end_dt <= start_dt:
return jsonify({"error": "Invalid start/end time"}), 400
# Use service to create entry (handles validation)
time_tracking_service = TimeTrackingService()
result = time_tracking_service.create_manual_entry(
user_id=current_user.id if not current_user.is_admin else (data.get("user_id") or current_user.id),
project_id=project_id,
client_id=client_id,
start_time=start_dt,
end_time=end_dt,
task_id=task_id,
notes=notes,
tags=tags,
billable=billable,
)
if not result.get("success"):
return jsonify({"error": result.get("message", "Could not create time entry")}), 400
entry = result.get("entry")
# Log activity
if entry:
from app.models import Activity
entity_name = entry.project.name if entry.project else (entry.client.name if entry.client else "Unknown")
task_name = entry.task.name if entry.task else None
duration_formatted = entry.duration_formatted if hasattr(entry, "duration_formatted") else "0:00"
Activity.log(
user_id=entry.user_id,
action="created",
entity_type="time_entry",
entity_id=entry.id,
entity_name=f"{entity_name}" + (f" - {task_name}" if task_name else ""),
description=f"Created time entry for {entity_name}"
+ (f" - {task_name}" if task_name else "")
+ f" - {duration_formatted}",
extra_data={
"project_name": entry.project.name if entry.project else None,
"client_name": entry.client.name if entry.client else None,
"task_name": task_name,
"duration_formatted": duration_formatted,
"duration_hours": entry.duration_hours if hasattr(entry, "duration_hours") else None,
},
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
)
# Invalidate dashboard cache for the entry owner so new entry appears immediately
try:
from app.utils.cache import invalidate_dashboard_for_user
invalidate_dashboard_for_user(entry.user_id)
current_app.logger.debug("Invalidated dashboard cache for user %s after entry creation", entry.user_id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
payload = entry.to_dict()
payload["project_name"] = entry.project.name if entry.project else None
payload["client_name"] = entry.client.name if entry.client else None
return jsonify({"success": True, "entry": payload}), 201
@api_bp.route("/api/entries/bulk", methods=["POST"])
@login_required
@deprecated_session_api("/api/v1/time-entries/bulk")
def bulk_entries_action():
"""Perform bulk actions on time entries: delete, set billable, set paid, add/remove tag."""
from app.services.time_entry_bulk_service import apply_bulk_time_entry_actions
data = request.get_json() or {}
entry_ids = data.get("entry_ids") or []
action = (data.get("action") or "").strip()
value = data.get("value")
if not entry_ids or not isinstance(entry_ids, list):
return jsonify({"error": "entry_ids must be a non-empty list"}), 400
try:
ids_int = [int(eid) for eid in entry_ids]
except (TypeError, ValueError):
return jsonify({"error": "entry_ids must be integers"}), 400
result = apply_bulk_time_entry_actions(
ids_int, action, value, user_id=current_user.id, is_admin=current_user.is_admin
)
if not result.get("success"):
return jsonify({"error": result.get("error", "Bulk operation failed")}), result.get("http_status", 400)
return jsonify({"success": True, "affected": result.get("affected", 0)})
@api_bp.route("/api/calendar/events")
@login_required
def calendar_events():
"""Return calendar events, tasks, and time entries for the current user in a date range."""
from app.models import CalendarEvent as CalendarEventModel
start = request.args.get("start")
end = request.args.get("end")
include_tasks = request.args.get("include_tasks", "true").lower() == "true"
include_time_entries = request.args.get("include_time_entries", "true").lower() == "true"
project_id = request.args.get("project_id", type=int)
task_id = request.args.get("task_id", type=int)
tags = request.args.get("tags", "").strip()
# Get user_id from query param (admins only) or default to current user
if current_user.is_admin and request.args.get("user_id"):
user_id = request.args.get("user_id", type=int)
else:
user_id = current_user.id
if not (start and end):
return jsonify({"error": "start and end are required"}), 400
def parse_iso(s: str):
try:
ts = s.strip()
if ts.endswith("Z"):
ts = ts[:-1] + "+00:00"
dt = datetime.fromisoformat(ts)
if dt.tzinfo is not None:
return utc_to_local(dt).replace(tzinfo=None)
return dt
except Exception:
return None
start_dt = parse_iso(start)
end_dt = parse_iso(end)
if not (start_dt and end_dt):
return jsonify({"error": "Invalid date range"}), 400
# Get all calendar items using the new method
result = CalendarEventModel.get_events_in_range(
user_id=user_id,
start_date=start_dt,
end_date=end_dt,
include_tasks=include_tasks,
include_time_entries=include_time_entries,
)
# Color scheme for projects (deterministic based on project ID)
def get_project_color(project_id):
colors = [
"#3b82f6",
"#ef4444",
"#10b981",
"#f59e0b",
"#8b5cf6",
"#ec4899",
"#14b8a6",
"#f97316",
"#6366f1",
"#84cc16",
]
return colors[project_id % len(colors)] if project_id else "#6b7280"
# Helper function to convert ISO string from app timezone to user timezone and format for FullCalendar
def convert_time_for_calendar(iso_str):
"""Convert ISO time string from app timezone to user timezone for FullCalendar."""
if not iso_str:
return None
try:
# Parse the ISO string (format: YYYY-MM-DDTHH:mm:ss, no timezone)
dt = datetime.fromisoformat(iso_str)
# Convert from app timezone to user's local timezone
user_dt = convert_app_datetime_to_user(dt, user=current_user)
# Convert to naive datetime (remove timezone info) for FullCalendar
# FullCalendar with timeZone: 'local' expects times without timezone to be treated as local
naive_dt = user_dt.replace(tzinfo=None) if user_dt.tzinfo else user_dt
# Format as ISO string for FullCalendar
return naive_dt.strftime("%Y-%m-%dT%H:%M:%S")
except Exception:
# If parsing fails, return original
return iso_str
# Apply filters and format time entries
time_entries = []
for e in result.get("time_entries", []):
# Apply filters
if project_id and e.get("projectId") != project_id:
continue
if task_id and e.get("taskId") != task_id:
continue
if tags and tags.lower() not in (e.get("notes") or "").lower():
continue
time_entries.append(
{
"id": e["id"],
"title": e["title"],
"start": convert_time_for_calendar(e["start"]),
"end": convert_time_for_calendar(e["end"]),
"editable": True,
"allDay": False,
"backgroundColor": get_project_color(e.get("projectId")),
"borderColor": get_project_color(e.get("projectId")),
"extendedProps": {**e, "item_type": "time_entry"},
}
)
# Format tasks
tasks = []
for t in result.get("tasks", []):
tasks.append(
{
"id": t["id"],
"title": t["title"],
"start": t["dueDate"],
"end": t["dueDate"],
"allDay": True,
"editable": False,
"backgroundColor": "#f59e0b",
"borderColor": "#f59e0b",
"extendedProps": {**t, "item_type": "task"},
}
)
# Format calendar events
events = []
for ev in result.get("events", []):
# Only convert times for non-all-day events
event_start = ev["start"]
event_end = ev["end"]
if not ev.get("allDay", False):
event_start = convert_time_for_calendar(ev["start"])
event_end = convert_time_for_calendar(ev["end"])
events.append(
{
"id": ev["id"],
"title": ev["title"],
"start": event_start,
"end": event_end,
"allDay": ev.get("allDay", False),
"editable": True,
"backgroundColor": ev.get("color", "#3b82f6"),
"borderColor": ev.get("color", "#3b82f6"),
"extendedProps": {**ev, "item_type": "event"},
}
)
# Combine all items
all_items = events + tasks + time_entries
return jsonify(
{
"events": all_items,
"summary": {"calendar_events": len(events), "tasks": len(tasks), "time_entries": len(time_entries)},
}
)
@api_bp.route("/api/calendar/export")
@login_required
def calendar_export():
"""Export calendar events to iCal or CSV format."""
start = request.args.get("start")
end = request.args.get("end")
format_type = request.args.get("format", "ical").lower()
project_id = request.args.get("project_id", type=int)
if not (start and end):
return jsonify({"error": "start and end are required"}), 400
def parse_iso(s: str):
try:
ts = s.strip()
if ts.endswith("Z"):
ts = ts[:-1] + "+00:00"
dt = datetime.fromisoformat(ts)
if dt.tzinfo is not None:
return utc_to_local(dt).replace(tzinfo=None)
return dt
except Exception:
return None
start_dt = parse_iso(start)
end_dt = parse_iso(end)
if not (start_dt and end_dt):
return jsonify({"error": "Invalid date range"}), 400
# Build query
q = TimeEntry.query.filter(TimeEntry.user_id == current_user.id)
q = q.filter(TimeEntry.start_time < end_dt, (TimeEntry.end_time.is_(None)) | (TimeEntry.end_time > start_dt))
if project_id:
q = q.filter(TimeEntry.project_id == project_id)
items = q.order_by(TimeEntry.start_time.asc()).all()
if format_type == "csv":
import csv
from io import StringIO
output = StringIO()
writer = csv.writer(output)
writer.writerow(
["Date", "Start Time", "End Time", "Project", "Task", "Duration (hours)", "Notes", "Tags", "Billable"]
)
for entry in items:
start_local = convert_app_datetime_to_user(entry.start_time, user=current_user)
end_local = convert_app_datetime_to_user(entry.end_time, user=current_user) if entry.end_time else None
writer.writerow(
[
start_local.strftime("%Y-%m-%d") if start_local else "",
start_local.strftime("%H:%M") if start_local else "",
end_local.strftime("%H:%M") if end_local else "Active",
entry.project.name if entry.project else "",
entry.task.name if entry.task else "",
f"{entry.duration_hours:.2f}" if entry.duration_hours else "",
entry.notes or "",
entry.tags or "",
"Yes" if entry.billable else "No",
]
)
response = make_response(output.getvalue())
response.headers["Content-Type"] = "text/csv"
response.headers["Content-Disposition"] = (
f'attachment; filename=calendar_export_{start_dt.strftime("%Y%m%d")}_to_{end_dt.strftime("%Y%m%d")}.csv'
)
return response
elif format_type == "ical":
# Generate iCal format
ical_lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//TimeTracker//Calendar Export//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
]
for entry in items:
if not entry.end_time:
continue
start_local = convert_app_datetime_to_user(entry.start_time, user=current_user)
end_local = convert_app_datetime_to_user(entry.end_time, user=current_user)
title = entry.project.name if entry.project else "Time Entry"
if entry.task:
title += f" - {entry.task.name}"
description = []
if entry.notes:
description.append(f"Notes: {entry.notes}")
if entry.tags:
description.append(f"Tags: {entry.tags}")
description.append(f'Billable: {"Yes" if entry.billable else "No"}')
ical_lines.extend(
[
"BEGIN:VEVENT",
f"UID:{entry.id}@timetracker",
f'DTSTAMP:{datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")}',
f'DTSTART:{start_local.strftime("%Y%m%dT%H%M%S") if start_local else entry.start_time.strftime("%Y%m%dT%H%M%S")}',
f'DTEND:{end_local.strftime("%Y%m%dT%H%M%S") if end_local else entry.end_time.strftime("%Y%m%dT%H%M%S")}',
f"SUMMARY:{title}",
f'DESCRIPTION:{" | ".join(description)}',
"END:VEVENT",
]
)
ical_lines.append("END:VCALENDAR")
response = make_response("\r\n".join(ical_lines))
response.headers["Content-Type"] = "text/calendar"
response.headers["Content-Disposition"] = (
f'attachment; filename=calendar_export_{start_dt.strftime("%Y%m%d")}_to_{end_dt.strftime("%Y%m%d")}.ics'
)
return response
return jsonify({"error": 'Invalid format. Use "ical" or "csv"'}), 400
@api_bp.route("/api/projects")
@login_required
@deprecated_session_api("/api/v1/projects")
def get_projects():
"""Get active projects"""
projects = Project.query.filter_by(status="active").order_by(Project.name).all()
return jsonify({"projects": [project.to_dict() for project in projects]})
@api_bp.route("/api/projects/<int:project_id>/tasks")
@login_required
@deprecated_session_api("/api/v1/tasks")
def get_project_tasks(project_id):
"""Get tasks for a specific project"""
# Check if project exists and is active
project = Project.query.filter_by(id=project_id, status="active").first()
if not project:
return jsonify({"error": "Project not found or inactive"}), 404
# Return ALL tasks for the project (including done/cancelled).
# This is used by the manual time entry UI where users may need to log time
# against any task status.
tasks = Task.query.filter_by(project_id=project_id).order_by(Task.name).all()
return jsonify(
{
"success": True,
"tasks": [
{
"id": task.id,
"name": task.name,
"description": task.description,
"status": task.status,
"priority": task.priority,
}
for task in tasks
],
}
)
@api_bp.route("/api/tasks/create", methods=["POST"])
@login_required
def create_task_inline():
"""Create a new task via AJAX with default values"""
# Detect AJAX/JSON request
try:
is_classic_form = request.mimetype in ("application/x-www-form-urlencoded", "multipart/form-data")
except Exception:
is_classic_form = False
try:
wants_json = (
request.headers.get("X-Requested-With") == "XMLHttpRequest"
or request.is_json
or (
not is_classic_form
and (request.accept_mimetypes["application/json"] > request.accept_mimetypes["text/html"])
)
)
except Exception:
wants_json = False
if request.method == "POST":
# Get data from JSON or form
if request.is_json:
data = request.get_json()
name = data.get("name", "").strip()
project_id = data.get("project_id")
if project_id is not None:
project_id = int(project_id)
else:
name = request.form.get("name", "").strip()
project_id = request.form.get("project_id", type=int)
# Validate required fields
if not name or not project_id:
if wants_json:
return jsonify({"error": "name and project_id are required"}), 400
from flask import flash, redirect, url_for
flash(_("Task name and project are required"), "error")
return redirect(url_for("tasks.list_tasks"))
# Validate project exists and is active
project = Project.query.filter_by(id=project_id, status="active").first()
if not project:
if wants_json:
return jsonify({"error": "Project not found or inactive"}), 404
from flask import flash, redirect, url_for
flash(_("Selected project does not exist or is inactive"), "error")
return redirect(url_for("tasks.list_tasks"))
# Create task with defaults using TaskService
from app.services import TaskService
task_service = TaskService()
result = task_service.create_task(
name=name,
project_id=project_id,
created_by=current_user.id,
assignee_id=current_user.id, # Assign to current user
priority="medium", # Default priority
due_date=None, # No due date
description=None,
estimated_hours=None,
)
if not result["success"]:
if wants_json:
return jsonify({"error": result.get("message", "Failed to create task")}), 400
from flask import flash, redirect, url_for
flash(_(result["message"]), "error")
return redirect(url_for("tasks.list_tasks"))
task = result["task"]
# Log task creation
from app import log_event, track_event
from app.models import Activity
log_event(
"task.created",
user_id=current_user.id,
task_id=task.id,
project_id=project_id,
priority="medium",
)
track_event(
current_user.id, "task.created", {"task_id": task.id, "project_id": project_id, "priority": "medium"}
)
Activity.log(
user_id=current_user.id,
action="created",
entity_type="task",
entity_id=task.id,
entity_name=task.name,
description=f'Created task "{task.name}" in project "{project.name}"',
extra_data={"project_id": project_id, "priority": "medium"},
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
)
if wants_json:
return jsonify({"success": True, "id": task.id, "name": task.name, "task": task.to_dict()}), 201
from flask import flash, redirect, url_for
flash(_('Task "%(name)s" created successfully', name=name), "success")
return redirect(url_for("tasks.view_task", task_id=task.id))
# GET request - redirect to task list
return redirect(url_for("tasks.list_tasks"))
# Fetch a single time entry (details for edit modal)
@api_bp.route("/api/entry/<int:entry_id>", methods=["GET"])
@login_required
@deprecated_session_api("/api/v1/time-entries")
def get_entry(entry_id):
entry = TimeEntry.query.get_or_404(entry_id)
if entry.user_id != current_user.id and not current_user.is_admin:
return jsonify({"error": "Access denied"}), 403
payload = entry.to_dict()
payload["project_name"] = entry.project.name if entry.project else None
return jsonify(payload)
@api_bp.route("/api/users")
@login_required
@deprecated_session_api("/api/v1/users")
def get_users():
"""Get active users (admin only). Uses a single aggregate query for total_hours to avoid N+1."""
if not current_user.is_admin:
return jsonify({"error": "Access denied"}), 403
users = User.query.filter_by(is_active=True).order_by(User.username).all()
if not users:
return jsonify({"users": []})
user_ids = [u.id for u in users]
rows = (
db.session.query(TimeEntry.user_id, db.func.sum(TimeEntry.duration_seconds))
.filter(
TimeEntry.user_id.in_(user_ids),
TimeEntry.end_time.isnot(None),
)
.group_by(TimeEntry.user_id)
.all()
)
total_hours_by_user = {uid: round((total_seconds or 0) / 3600, 2) for uid, total_seconds in rows}
return jsonify({"users": [user.to_dict(total_hours_override=total_hours_by_user.get(user.id)) for user in users]})
@api_bp.route("/api/stats")
@login_required
def get_stats():
"""Get user statistics"""
from app.utils.overtime import calculate_period_overtime, get_week_start_for_date
# Get date range
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=30)
today = end_date.date()
week_start = get_week_start_for_date(today, current_user)
user_id = current_user.id if not current_user.is_admin else None
# Calculate statistics
today_hours = TimeEntry.get_total_hours_for_period(start_date=today, user_id=user_id)
week_hours = TimeEntry.get_total_hours_for_period(start_date=week_start, user_id=user_id)
month_hours = TimeEntry.get_total_hours_for_period(start_date=start_date.date(), user_id=user_id)
# Overtime for today, week, and YTD
from app.utils.overtime import get_overtime_ytd
today_overtime = calculate_period_overtime(current_user, today, today)
week_overtime = calculate_period_overtime(current_user, week_start, today)
overtime_ytd = get_overtime_ytd(current_user)
standard_hours = float(getattr(current_user, "standard_hours_per_day", 8.0) or 8.0)
return jsonify(
{
"today_hours": today_hours,
"week_hours": week_hours,
"month_hours": month_hours,
"total_hours": current_user.total_hours,
"standard_hours_per_day": standard_hours,
"today_regular_hours": today_overtime["regular_hours"],
"today_overtime_hours": today_overtime["overtime_hours"],
"week_regular_hours": week_overtime["regular_hours"],
"week_overtime_hours": week_overtime["overtime_hours"],
"overtime_ytd_hours": overtime_ytd["overtime_hours"],
}
)
@api_bp.route("/api/stats/value-dashboard")
@login_required
def value_dashboard_stats():
"""Productivity/value aggregates for the dashboard widget (short-TTL Redis cache)."""
from app.services.stats_service import StatsService
return jsonify(StatsService.get_value_dashboard(current_user))
@api_bp.route("/api/reports/week-comparison")
@login_required
def week_comparison():
"""This week (Mon 00:00now) vs same calendar weekdays last week; matches dashboard week hours."""
now = datetime.now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today_start - timedelta(days=today_start.weekday())
today_date = today_start.date()
week_start_date = week_start.date()
last_week_start_date = week_start_date - timedelta(days=7)
last_week_end_date = today_date - timedelta(days=7)
last_start_dt = datetime.combine(last_week_start_date, time.min)
last_end_exclusive = datetime.combine(last_week_end_date + timedelta(days=1), time.min)
user_id = current_user.id
def sum_hours_by_day(start_dt, end_dt, end_exclusive=False):
q = (
db.session.query(
func.date(TimeEntry.start_time).label("day"),
func.sum(TimeEntry.duration_seconds),
)
.filter(
TimeEntry.user_id == user_id,
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= start_dt,
)
)
if end_exclusive:
q = q.filter(TimeEntry.start_time < end_dt)
else:
q = q.filter(TimeEntry.start_time <= end_dt)
rows = q.group_by(func.date(TimeEntry.start_time)).all()
out = {}
for day_val, total_sec in rows:
if day_val is None:
continue
if hasattr(day_val, "isoformat"):
key = day_val.isoformat()
else:
key = str(day_val)
out[key] = round((total_sec or 0) / 3600.0, 2)
return out
current_map = sum_hours_by_day(week_start, now, end_exclusive=False)
last_map = sum_hours_by_day(last_start_dt, last_end_exclusive, end_exclusive=True)
def dense_series(start_d, end_d, hmap):
series = []
total = 0.0
d = start_d
while d <= end_d:
key = d.isoformat()
h = float(hmap.get(key, 0.0))
series.append({"day": key, "hours": h})
total += h
d += timedelta(days=1)
return series, round(total, 2)
current_by_day, current_total = dense_series(week_start_date, today_date, current_map)
last_by_day, last_total = dense_series(last_week_start_date, last_week_end_date, last_map)
if last_total > 0:
change_percent = round((current_total - last_total) / last_total * 100.0, 1)
else:
change_percent = None
return jsonify(
{
"current_week": {"total_hours": current_total, "by_day": current_by_day},
"last_week": {"total_hours": last_total, "by_day": last_by_day},
"change_percent": change_percent,
}
)
@api_bp.route("/api/entry/<int:entry_id>", methods=["PUT", "PATCH"])
@login_required
@deprecated_session_api("/api/v1/time-entries")
def update_entry(entry_id):
"""Update a time entry"""
entry = TimeEntry.query.get_or_404(entry_id)
# Check permissions
if entry.user_id != current_user.id and not current_user.is_admin:
return jsonify({"error": "Access denied"}), 403
data = request.get_json() or {}
reason = data.get("reason") # Optional reason for the change
can_edit_schedule = current_user.is_admin or (
entry.user_id == current_user.id and current_user.has_permission("edit_own_time_entries")
)
# Optional: start/end time and assignment updates (admin or edit_own_time_entries on own entry)
# Accept HTML datetime-local format: YYYY-MM-DDTHH:MM
def parse_dt_local(dt_str):
if not dt_str:
return None
try:
if "T" in dt_str:
date_part, time_part = dt_str.split("T", 1)
else:
date_part, time_part = dt_str.split(" ", 1)
# Parse as UTC-aware then convert to local naive to match model storage
parsed_utc = parse_local_datetime(date_part, time_part)
parsed_local_aware = utc_to_local(parsed_utc)
return parsed_local_aware.replace(tzinfo=None)
except Exception:
return None
# Use service layer for update to get enhanced audit logging
service = TimeTrackingService()
# Convert data to service parameters
result = service.update_entry(
entry_id=entry_id,
user_id=current_user.id,
is_admin=current_user.is_admin,
project_id=data.get("project_id") if can_edit_schedule else None,
client_id=data.get("client_id") if can_edit_schedule else None,
task_id=data.get("task_id") if can_edit_schedule else None,
start_time=parse_dt_local(data.get("start_time")) if can_edit_schedule and data.get("start_time") else None,
end_time=parse_dt_local(data.get("end_time")) if can_edit_schedule and data.get("end_time") else None,
break_seconds=data.get("break_seconds") if can_edit_schedule else None,
notes=data.get("notes"),
tags=data.get("tags"),
billable=data.get("billable"),
paid=data.get("paid"),
invoice_number=data.get("invoice_number"),
reason=reason,
)
if not result.get("success"):
return jsonify({"error": result.get("message", "Could not update entry")}), 400
entry = result.get("entry")
# Log activity
if entry:
from app.models import Activity
entity_name = entry.project.name if entry.project else (entry.client.name if entry.client else "Unknown")
task_name = entry.task.name if entry.task else None
Activity.log(
user_id=current_user.id,
action="updated",
entity_type="time_entry",
entity_id=entry.id,
entity_name=f"{entity_name}" + (f" - {task_name}" if task_name else ""),
description=f"Updated time entry for {entity_name}" + (f" - {task_name}" if task_name else ""),
extra_data={
"project_name": entry.project.name if entry.project else None,
"client_name": entry.client.name if entry.client else None,
"task_name": task_name,
},
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
)
# Invalidate dashboard cache for the entry owner so changes appear immediately
try:
from app.utils.cache import invalidate_dashboard_for_user
invalidate_dashboard_for_user(entry.user_id)
current_app.logger.debug("Invalidated dashboard cache for user %s after entry update", entry.user_id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
payload = entry.to_dict()
payload["project_name"] = entry.project.name if entry.project else None
return jsonify({"success": True, "entry": payload})
@api_bp.route("/api/entry/<int:entry_id>", methods=["DELETE"])
@login_required
def delete_entry(entry_id):
"""Delete a time entry"""
data = request.get_json() or {}
reason = data.get("reason") # Optional reason for deletion
# Use service layer for deletion to get enhanced audit logging
service = TimeTrackingService()
result = service.delete_entry(
user_id=current_user.id,
entry_id=entry_id,
is_admin=current_user.is_admin,
reason=reason,
)
if not result.get("success"):
return jsonify({"error": result.get("message", "Could not delete entry")}), 400
# Invalidate dashboard cache for the entry owner so changes appear immediately
try:
from app.utils.cache import invalidate_dashboard_for_user
invalidate_dashboard_for_user(current_user.id)
current_app.logger.debug("Invalidated dashboard cache for user %s after entry deletion", current_user.id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
return jsonify({"success": True})
# ================================
# Editor image uploads
# ================================
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
def allowed_image_file(filename: str) -> bool:
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
def get_editor_upload_folder() -> str:
upload_folder = os.path.join(current_app.root_path, "static", "uploads", "editor")
os.makedirs(upload_folder, exist_ok=True)
return upload_folder
@api_bp.route("/api/uploads/images", methods=["POST"])
@login_required
def upload_editor_image():
"""Handle image uploads from the markdown editor."""
if "image" not in request.files:
return jsonify({"error": "No image provided"}), 400
file = request.files["image"]
if not file or file.filename == "":
return jsonify({"error": "No image provided"}), 400
if not allowed_image_file(file.filename):
return jsonify({"error": "Invalid file type"}), 400
filename = secure_filename(file.filename)
ext = filename.rsplit(".", 1)[1].lower()
unique_name = f"editor_{uuid.uuid4().hex[:12]}.{ext}"
folder = get_editor_upload_folder()
path = os.path.join(folder, unique_name)
file.save(path)
url = f"/uploads/editor/{unique_name}"
return jsonify({"success": True, "url": url})
@api_bp.route("/api/uploads/images/bulk", methods=["POST"])
@login_required
def upload_editor_images_bulk():
"""Handle multiple image uploads from the markdown editor."""
if "images" not in request.files:
return jsonify({"error": "No images provided"}), 400
files = request.files.getlist("images")
if not files or all(f.filename == "" for f in files):
return jsonify({"error": "No images provided"}), 400
uploaded_urls = []
errors = []
for idx, file in enumerate(files):
if file.filename == "":
continue
if not allowed_image_file(file.filename):
errors.append(f"File {idx + 1} ({file.filename}): Invalid file type")
continue
try:
filename = secure_filename(file.filename)
ext = filename.rsplit(".", 1)[1].lower()
unique_name = f"editor_{uuid.uuid4().hex[:12]}.{ext}"
folder = get_editor_upload_folder()
path = os.path.join(folder, unique_name)
file.save(path)
url = f"/uploads/editor/{unique_name}"
uploaded_urls.append(url)
except Exception as e:
errors.append(f"File {idx + 1} ({file.filename}): {str(e)}")
if not uploaded_urls and errors:
return jsonify({"error": "All uploads failed", "details": errors}), 400
response = {"success": True, "urls": uploaded_urls}
if errors:
response["warnings"] = errors
return jsonify(response)
@api_bp.route("/uploads/editor/<path:filename>")
def serve_editor_image(filename):
"""Serve uploaded editor images from static/uploads/editor."""
folder = get_editor_upload_folder()
return send_from_directory(folder, filename)
# ================================
# Activity Feed API
# ================================
@api_bp.route("/api/activities")
@login_required
@deprecated_session_api("/api/v1/activities")
def get_activities():
"""Get recent activities with filtering"""
from sqlalchemy import and_
from app.models import Activity
# Get query parameters
limit = request.args.get("limit", 50, type=int)
page = request.args.get("page", 1, type=int)
user_id = request.args.get("user_id", type=int)
entity_type = request.args.get("entity_type", "").strip()
action = request.args.get("action", "").strip()
start_date = request.args.get("start_date", "").strip()
end_date = request.args.get("end_date", "").strip()
# Build query
query = Activity.query
# Filter by user (admins can see all, users see only their own)
if not current_user.is_admin:
query = query.filter_by(user_id=current_user.id)
elif user_id:
query = query.filter_by(user_id=user_id)
# Filter by entity type
if entity_type:
query = query.filter_by(entity_type=entity_type)
# Filter by action
if action:
query = query.filter_by(action=action)
# Filter by date range
if start_date:
try:
start_dt = datetime.fromisoformat(start_date)
query = query.filter(Activity.created_at >= start_dt)
except ValueError:
pass
if end_date:
try:
end_dt = datetime.fromisoformat(end_date)
query = query.filter(Activity.created_at <= end_dt)
except ValueError:
pass
# Get total count
total = query.count()
# Apply ordering and pagination
activities = query.order_by(Activity.created_at.desc()).paginate(page=page, per_page=limit, error_out=False)
return jsonify(
{
"activities": [a.to_dict() for a in activities.items],
"total": total,
"pages": activities.pages,
"current_page": activities.page,
"has_next": activities.has_next,
"has_prev": activities.has_prev,
}
)
@api_bp.route("/api/dashboard/stats")
@login_required
def dashboard_stats():
"""Get dashboard statistics for real-time updates"""
from datetime import datetime, timedelta
from app.models import TimeEntry
from app.utils.overtime import calculate_period_overtime, get_overtime_ytd, get_week_start_for_date
today = datetime.utcnow().date()
week_start = get_week_start_for_date(today, current_user)
month_start = today.replace(day=1)
today_hours = TimeEntry.get_total_hours_for_period(start_date=today, user_id=current_user.id)
week_hours = TimeEntry.get_total_hours_for_period(start_date=week_start, user_id=current_user.id)
month_hours = TimeEntry.get_total_hours_for_period(start_date=month_start, user_id=current_user.id)
# Overtime for today, week, and YTD (for dashboard cards)
today_overtime = calculate_period_overtime(current_user, today, today)
week_overtime = calculate_period_overtime(current_user, week_start, today)
overtime_ytd = get_overtime_ytd(current_user)
standard_hours = float(getattr(current_user, "standard_hours_per_day", 8.0) or 8.0)
return jsonify(
{
"success": True,
"today_hours": float(today_hours),
"week_hours": float(week_hours),
"month_hours": float(month_hours),
"standard_hours_per_day": standard_hours,
"today_regular_hours": today_overtime["regular_hours"],
"today_overtime_hours": today_overtime["overtime_hours"],
"week_regular_hours": week_overtime["regular_hours"],
"week_overtime_hours": week_overtime["overtime_hours"],
"overtime_ytd_hours": overtime_ytd["overtime_hours"],
}
)
@api_bp.route("/api/dashboard/sparklines")
@login_required
def dashboard_sparklines():
"""Get sparkline data for dashboard widgets"""
from datetime import datetime, timedelta
from sqlalchemy import func
from app.models import TimeEntry
# Get last 7 days of data
seven_days_ago = datetime.utcnow() - timedelta(days=7)
# Get daily totals for last 7 days
daily_totals = (
db.session.query(
func.date(TimeEntry.start_time).label("date"), func.sum(TimeEntry.duration_seconds).label("total_seconds")
)
.filter(
TimeEntry.user_id == current_user.id, TimeEntry.end_time.isnot(None), TimeEntry.start_time >= seven_days_ago
)
.group_by(func.date(TimeEntry.start_time))
.order_by(func.date(TimeEntry.start_time))
.all()
)
# Convert to hours and create array
hours_data = []
for i in range(7):
date = datetime.utcnow().date() - timedelta(days=6 - i)
matching = next((d for d in daily_totals if d.date == date), None)
if matching:
# total_seconds is already in seconds (Integer), convert to hours
hours = (matching.total_seconds or 0) / 3600.0
else:
hours = 0
hours_data.append(round(hours, 1))
return jsonify(
{
"success": True,
"today": hours_data,
"week": hours_data, # Same data for now
"month": hours_data, # Same data for now
}
)
@api_bp.route("/api/summary/today")
@login_required
def summary_today():
"""Get today's time tracking summary (user's local calendar day, not UTC midnight)."""
from app.services.notification_service import get_today_summary_for_user
payload = get_today_summary_for_user(current_user)
return jsonify(payload)
@api_bp.route("/api/notifications")
@login_required
def api_smart_notifications():
"""Smart in-app notification candidates (respects preferences, dismissals, caps)."""
from app.services.notification_service import NotificationService
return jsonify(NotificationService.build_for_user(current_user))
@api_bp.route("/api/notifications/dismiss", methods=["POST"])
@login_required
def api_smart_notifications_dismiss():
"""Record dismissal for a notification kind on a local calendar date."""
from app.services.notification_service import NotificationService, user_local_today_bounds_utc
data = request.get_json(silent=True) or {}
kind = data.get("kind")
local_date = data.get("local_date")
if not kind or not isinstance(kind, str):
return jsonify({"error": "kind is required"}), 400
if not local_date or not isinstance(local_date, str):
_, _, local_date = user_local_today_bounds_utc(current_user)
ok = NotificationService.dismiss(current_user, kind.strip(), local_date.strip()[:10])
if not ok:
return jsonify({"error": "invalid kind or local_date"}), 400
return jsonify({"success": True})
@api_bp.route("/api/activity/timeline")
@login_required
def activity_timeline():
"""Get activity timeline for dashboard"""
from datetime import datetime, timedelta
from app.models import Activity
# Get activities from last 7 days
seven_days_ago = datetime.utcnow() - timedelta(days=7)
query = Activity.query.filter(Activity.created_at >= seven_days_ago)
# Filter by user if not admin
if not current_user.is_admin:
query = query.filter_by(user_id=current_user.id)
activities = query.order_by(Activity.created_at.desc()).limit(20).all()
activities_data = []
for activity in activities:
activities_data.append(
{
"id": activity.id,
"type": activity.entity_type or "default",
"action": activity.action or "unknown",
"description": activity.description or "Activity",
"created_at": activity.created_at.isoformat() if activity.created_at else None,
}
)
return jsonify({"success": True, "activities": activities_data})
@api_bp.route("/api/activities/stats")
@login_required
def get_activity_stats():
"""Get activity statistics"""
from sqlalchemy import func
from app.models import Activity
# Get date range (default to last 7 days)
days = request.args.get("days", 7, type=int)
since = datetime.utcnow() - timedelta(days=days)
# Build base query
query = Activity.query.filter(Activity.created_at >= since)
# Filter by user if not admin
if not current_user.is_admin:
query = query.filter_by(user_id=current_user.id)
# Get counts by entity type
entity_counts = db.session.query(Activity.entity_type, func.count(Activity.id).label("count")).filter(
Activity.created_at >= since
)
if not current_user.is_admin:
entity_counts = entity_counts.filter_by(user_id=current_user.id)
entity_counts = entity_counts.group_by(Activity.entity_type).all()
# Get counts by action
action_counts = db.session.query(Activity.action, func.count(Activity.id).label("count")).filter(
Activity.created_at >= since
)
if not current_user.is_admin:
action_counts = action_counts.filter_by(user_id=current_user.id)
action_counts = action_counts.group_by(Activity.action).all()
# Get most active users (admins only)
user_activity = []
if current_user.is_admin:
user_activity = (
db.session.query(User.username, User.display_name, func.count(Activity.id).label("count"))
.join(Activity, User.id == Activity.user_id)
.filter(Activity.created_at >= since)
.group_by(User.id, User.username, User.display_name)
.order_by(func.count(Activity.id).desc())
.limit(10)
.all()
)
return jsonify(
{
"total_activities": query.count(),
"entity_counts": {entity: count for entity, count in entity_counts},
"action_counts": {action: count for action, count in action_counts},
"user_activity": [{"username": u[0], "display_name": u[1], "count": u[2]} for u in user_activity],
"period_days": days,
}
)
# WebSocket event handlers
@socketio.on("connect")
def handle_connect():
"""Handle WebSocket connection"""
print(f"Client connected: {request.sid}")
@socketio.on("disconnect")
def handle_disconnect():
"""Handle WebSocket disconnection"""
print(f"Client disconnected: {request.sid}")
@socketio.on("join_user_room")
def handle_join_user_room(data):
"""Join user-specific room for real-time updates"""
user_id = data.get("user_id")
if user_id and current_user.is_authenticated and current_user.id == user_id:
socketio.join_room(f"user_{user_id}")
print(f"User {user_id} joined room")
@socketio.on("leave_user_room")
def handle_leave_user_room(data):
"""Leave user-specific room"""
user_id = data.get("user_id")
if user_id:
socketio.leave_room(f"user_{user_id}")
print(f"User {user_id} left room")
# Client portal real-time: join/leave client-specific room (auth via session)
def _get_client_id_from_session():
"""Resolve client_id for client portal from session. Returns None if not a portal session."""
client_id = session.get("client_portal_id")
if client_id is not None:
return int(client_id) if client_id else None
user_id = session.get("_user_id")
if user_id is not None:
try:
uid = int(user_id) if isinstance(user_id, str) else user_id
user = User.query.get(uid)
if user and getattr(user, "client_portal_enabled", False) and getattr(user, "client_id", None):
return user.client_id
except (TypeError, ValueError):
pass
return None
@socketio.on("join_client_room")
def handle_join_client_room(data):
"""Join client portal room for real-time notifications. Client identity from session."""
client_id = _get_client_id_from_session()
if client_id is None:
return
room = f"client_portal_{client_id}"
socketio.join_room(room)
@socketio.on("leave_client_room")
def handle_leave_client_room(data):
"""Leave client portal room."""
client_id = _get_client_id_from_session()
if client_id is not None:
socketio.leave_room(f"client_portal_{client_id}")