Files
TimeTracker/app/routes/timer.py
T
MacJediWizard 49a4a26b78 fix: two runtime bugs flagged by flake8 in v5.5.2
These were caught by the project's own flake8 step but the failing
checks have been red on a number of recent runs, suggesting it's worth
fixing the underlying defects rather than ignoring the rule.

1. app/routes/auth.py — F821: undefined name 'datetime'

   `current_user.two_factor_confirmed_at = datetime.utcnow()` (line ~620)
   used `datetime` without importing it. Confirming 2FA raises
   `NameError: name 'datetime' is not defined` at runtime.
   Adds `from datetime import datetime` to the imports.

2. app/routes/timer.py — F823: local variable '_' referenced before assignment

   `from flask_babel import gettext as _` is imported at module scope.
   Four functions then unpack `can_start, _ = TimeTrackingService().can_start_timer(...)`
   which makes `_` a function-local for the entire enclosing scope and
   shadows the i18n alias. Three earlier `flash(_("..."))` calls in the
   same functions (lines 171, 449, 2019) reference the local before it
   exists and raise `UnboundLocalError` at runtime.

   Fix: rename the throwaway slot from `_` to `_unused` in all four
   `can_start_timer` unpackings. The translation alias resolves cleanly
   in every flash() call again.

Total: +6 / -4 across two files.
2026-05-01 14:46:37 -04:00

3072 lines
119 KiB
Python

import json
from datetime import datetime, timedelta
from flask import Blueprint, current_app, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _
from flask_login import current_user, login_required
from sqlalchemy import inspect, text
from sqlalchemy.exc import ProgrammingError
from app import db, log_event, socketio, track_event
from app.constants import TimeEntrySource
from app.models import Activity, Client, Project, Settings, Task, TimeEntry, User
from app.services.client_service import ClientService
from app.services.project_service import ProjectService
from app.services.time_tracking_service import TimeTrackingService
from app.utils.db import safe_commit
from app.utils.error_handling import safe_log
from app.utils.posthog_funnels import track_onboarding_first_time_entry, track_onboarding_first_timer
from app.utils.scope_filter import user_can_access_client, user_can_access_project
from app.utils.timezone import parse_local_datetime, parse_user_local_datetime, utc_to_local
_project_service = ProjectService()
_client_service = ClientService()
timer_bp = Blueprint("timer", __name__)
def _parse_optional_int(value):
"""Return int(value) if value is a non-empty string that converts to int, else None."""
if value is None or (isinstance(value, str) and not value.strip()):
return None
try:
return int(value)
except (ValueError, TypeError):
return None
def _edit_timer_form_projects_tasks(timer, can_edit_schedule):
"""Active projects/tasks for the edit form; scoped for subcontractors."""
from app.utils.scope_filter import apply_project_scope_to_model
projects = []
tasks = []
projects_query = Project.query.filter_by(status="active").order_by(Project.name)
scope_p = apply_project_scope_to_model(Project, current_user)
if scope_p is not None:
projects_query = projects_query.filter(scope_p)
if current_user.is_admin or scope_p is not None or can_edit_schedule:
projects = projects_query.all()
if timer.project_id:
tasks = Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all()
return projects, tasks
def _edit_timer_render_kwargs(timer, can_edit_schedule, show_source_dropdown):
projects, tasks = _edit_timer_form_projects_tasks(timer, can_edit_schedule)
return {
"timer": timer,
"projects": projects,
"tasks": tasks,
"can_edit_schedule": can_edit_schedule,
"show_source_dropdown": show_source_dropdown,
}
@timer_bp.route("/timer/start", methods=["POST"])
@login_required
def start_timer():
"""Start a new timer for the current user"""
from app.utils.client_lock import enforce_locked_client_id, get_locked_client_id
project_id = _parse_optional_int(request.form.get("project_id"))
client_id = _parse_optional_int(request.form.get("client_id"))
client_id = enforce_locked_client_id(client_id)
task_id = _parse_optional_int(request.form.get("task_id"))
notes = request.form.get("notes", "").strip()
template_id = _parse_optional_int(request.form.get("template_id"))
current_app.logger.info(
"POST /timer/start user=%s project_id=%s task_id=%s template_id=%s",
current_user.username,
project_id,
task_id,
template_id,
)
# Load template data if template_id is provided
if template_id:
from app.models import TimeEntryTemplate
template = TimeEntryTemplate.query.filter_by(id=template_id, user_id=current_user.id).first()
if template:
# Override with template values if not explicitly set
if not project_id and template.project_id:
project_id = template.project_id
if not task_id and template.task_id:
task_id = template.task_id
if not notes and template.default_notes:
notes = template.default_notes
# Mark template as used
template.record_usage()
db.session.commit()
# Require either project or client
if not project_id and not client_id:
flash(_("Either a project or a client is required"), "error")
current_app.logger.warning("Start timer failed: missing project_id and client_id")
return redirect(url_for("main.dashboard"))
project = None
client = None
# Validate project if provided
if project_id:
project = _project_service.get_by_id(project_id)
if not project:
flash(_("Invalid project selected"), "error")
current_app.logger.warning("Start timer failed: invalid project_id=%s", project_id)
return redirect(url_for("main.dashboard"))
locked_id = get_locked_client_id()
if locked_id and getattr(project, "client_id", None) and int(project.client_id) != int(locked_id):
flash(_("Selected project does not match the locked client."), "error")
return redirect(url_for("main.dashboard"))
# Check if project is active (not archived or inactive)
if project.status == "archived":
flash(_("Cannot start timer for an archived project. Please unarchive the project first."), "error")
current_app.logger.warning("Start timer failed: project_id=%s is archived", project_id)
return redirect(url_for("main.dashboard"))
elif project.status != "active":
flash(_("Cannot start timer for an inactive project"), "error")
current_app.logger.warning("Start timer failed: project_id=%s is not active", project_id)
return redirect(url_for("main.dashboard"))
# If a task is provided, validate it belongs to the project
if task_id:
task = Task.query.filter_by(id=task_id, project_id=project_id).first()
if not task:
flash(_("Selected task is invalid for the chosen project"), "error")
current_app.logger.warning(
"Start timer failed: task_id=%s does not belong to project_id=%s", task_id, project_id
)
return redirect(url_for("main.dashboard"))
else:
task = None
else:
task = None
# Validate client if provided (and no project)
if client_id and not project_id:
client = _client_service.get_by_id(client_id)
if not client or client.status != "active":
flash(_("Invalid client selected"), "error")
current_app.logger.warning("Start timer failed: invalid client_id=%s", client_id)
return redirect(url_for("main.dashboard"))
# Tasks are not allowed for client-only timers
if task_id:
flash(_("Tasks can only be selected for project-based timers"), "error")
current_app.logger.warning(
"Start timer failed: task_id=%s provided for client-only timer (client_id=%s)", task_id, client_id
)
return redirect(url_for("main.dashboard"))
# Subcontractor scope: only allow starting timer on assigned project/client
if project_id and not user_can_access_project(current_user, project_id):
flash(_("You do not have access to this project"), "error")
current_app.logger.warning("Start timer denied: user has no access to project_id=%s", project_id)
return redirect(url_for("main.dashboard"))
if client_id and not project_id and not user_can_access_client(current_user, client_id):
flash(_("You do not have access to this client"), "error")
current_app.logger.warning("Start timer denied: user has no access to client_id=%s", client_id)
return redirect(url_for("main.dashboard"))
can_start, _unused = TimeTrackingService().can_start_timer(current_user.id)
if not can_start:
flash(_("You already have an active timer. Stop it before starting a new one."), "error")
current_app.logger.info("Start timer blocked: user already has an active timer")
return redirect(url_for("main.dashboard"))
# Validate time entry requirements (task, description)
from app.utils.time_entry_validation import validate_time_entry_requirements
settings = Settings.get_settings()
err = validate_time_entry_requirements(
settings,
project_id=project_id,
client_id=client_id if client_id and not project_id else None,
task_id=task.id if task else task_id,
notes=notes if notes else None,
)
if err:
flash(_(err["message"]), "error")
return redirect(url_for("main.dashboard"))
# Create new timer
from app.models.time_entry import local_now
new_timer = TimeEntry(
user_id=current_user.id,
project_id=project_id if project_id else None,
client_id=client_id if client_id and not project_id else None,
task_id=task.id if task else None,
start_time=local_now(),
notes=notes if notes else None,
source="auto",
)
db.session.add(new_timer)
if not safe_commit(
"start_timer",
{
"user_id": current_user.id,
"project_id": project_id,
"client_id": client_id,
"task_id": task_id,
},
):
flash(_("Could not start timer due to a database error. Please check server logs."), "error")
return redirect(url_for("main.dashboard"))
current_app.logger.info(
"Started new timer id=%s for user=%s project_id=%s client_id=%s task_id=%s",
new_timer.id,
current_user.username,
project_id,
client_id,
task_id,
)
from app.telemetry.otel_setup import business_span
with business_span(
"timer.start",
user_id=current_user.id,
project_based=bool(project_id),
client_only=bool(client_id and not project_id),
has_task=bool(task_id),
):
pass
# Track timer started event
log_event(
"timer.started",
user_id=current_user.id,
project_id=project_id,
client_id=client_id,
task_id=task_id,
description=notes,
)
track_event(
current_user.id,
"timer.started",
{
"project_id": project_id,
"client_id": client_id,
"task_id": task_id,
"has_description": bool(notes),
},
)
# Log activity
Activity.log(
user_id=current_user.id,
action="started",
entity_type="time_entry",
entity_id=new_timer.id,
entity_name=(f"{project.name}" if project else f"{client.name if client else _('Unknown')}")
+ (f" - {task.name}" if task else ""),
description=(
f"Started timer for {project.name}"
if project
else f"Started timer for {client.name if client else _('Unknown')}"
)
+ (f" - {task.name}" if task else ""),
extra_data={"project_id": project_id, "client_id": client_id, "task_id": task_id},
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
)
# Check if this is user's first timer (onboarding milestone)
timer_count = TimeEntry.query.filter_by(user_id=current_user.id, source="auto").count()
if timer_count == 1: # First timer ever
track_onboarding_first_timer(
current_user.id,
{
"project_id": project_id,
"client_id": client_id,
"has_task": bool(task_id),
"has_notes": bool(notes),
},
)
# Emit WebSocket event for real-time updates
try:
payload = {
"user_id": current_user.id,
"timer_id": new_timer.id,
"project_name": project.name if project else None,
"client_name": client.name if client else None,
"start_time": new_timer.start_time.isoformat(),
}
if task:
payload["task_id"] = task.id
payload["task_name"] = task.name
socketio.emit("timer_started", payload)
except Exception as e:
current_app.logger.warning("Socket emit failed for timer_started: %s", e)
# Invalidate dashboard cache so timer appears 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", current_user.id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
if task:
flash(f"Timer started for {project.name} - {task.name}", "success")
elif project:
flash(f"Timer started for {project.name}", "success")
elif client:
flash(f"Timer started for {client.name}", "success")
else:
flash(_("Timer started"), "success")
return redirect(url_for("main.dashboard"))
@timer_bp.route("/timer/start/from-template/<int:template_id>", methods=["GET", "POST"])
@login_required
def start_timer_from_template(template_id):
"""Start a timer directly from a template"""
from app.models import TimeEntryTemplate
# Load template
template = TimeEntryTemplate.query.filter_by(id=template_id, user_id=current_user.id).first_or_404()
can_start, _unused = TimeTrackingService().can_start_timer(current_user.id)
if not can_start:
flash(_("You already have an active timer. Stop it before starting a new one."), "error")
return redirect(url_for("main.dashboard"))
# Validate template has required data
if not template.project_id:
flash(_("Template must have a project to start a timer"), "error")
return redirect(url_for("time_entry_templates.list_templates"))
# Check if project is active
project = _project_service.get_by_id(template.project_id)
if not project or project.status != "active":
flash(_("Cannot start timer for this project"), "error")
return redirect(url_for("time_entry_templates.list_templates"))
if not user_can_access_project(current_user, template.project_id):
flash(_("You do not have access to this project"), "error")
current_app.logger.warning(
"Start timer from template denied: user has no access to project_id=%s", template.project_id
)
return redirect(url_for("time_entry_templates.list_templates"))
# Create new timer from template
from app.models.time_entry import local_now
new_timer = TimeEntry(
user_id=current_user.id,
project_id=template.project_id,
task_id=template.task_id,
start_time=local_now(),
notes=template.default_notes,
tags=template.tags,
source="auto",
billable=template.billable,
)
db.session.add(new_timer)
# Mark template as used
template.record_usage()
if not safe_commit("start_timer_from_template", {"template_id": template_id}):
flash(_("Could not start timer due to a database error. Please check server logs."), "error")
return redirect(url_for("time_entry_templates.list_templates"))
from app.telemetry.otel_setup import business_span
with business_span(
"timer.start",
user_id=current_user.id,
source="template",
template_id=template_id,
project_id=template.project_id,
):
pass
# Track events
log_event(
"timer.started.from_template", user_id=current_user.id, template_id=template_id, project_id=template.project_id
)
track_event(
current_user.id,
"timer.started.from_template",
{
"template_id": template_id,
"template_name": template.name,
"project_id": template.project_id,
"has_task": bool(template.task_id),
},
)
# Invalidate dashboard cache so timer appears 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", current_user.id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
flash(f'Timer started from template "{template.name}"', "success")
return redirect(url_for("main.dashboard"))
@timer_bp.route("/timer/start/<int:project_id>")
@login_required
def start_timer_for_project(project_id):
"""Start a timer for a specific project (GET route for direct links)"""
task_id = request.args.get("task_id", type=int)
current_app.logger.info("GET /timer/start/%s user=%s task_id=%s", project_id, current_user.username, task_id)
# Check if project exists
project = _project_service.get_by_id(project_id)
if not project:
flash(_("Invalid project selected"), "error")
current_app.logger.warning("Start timer (GET) failed: invalid project_id=%s", project_id)
return redirect(url_for("main.dashboard"))
# Check if project is active (not archived or inactive)
if project.status == "archived":
flash(_("Cannot start timer for an archived project. Please unarchive the project first."), "error")
current_app.logger.warning("Start timer (GET) failed: project_id=%s is archived", project_id)
return redirect(url_for("main.dashboard"))
elif project.status != "active":
flash(_("Cannot start timer for an inactive project"), "error")
current_app.logger.warning("Start timer (GET) failed: project_id=%s is not active", project_id)
return redirect(url_for("main.dashboard"))
if not user_can_access_project(current_user, project_id):
flash(_("You do not have access to this project"), "error")
current_app.logger.warning("Start timer (GET) denied: user has no access to project_id=%s", project_id)
return redirect(url_for("main.dashboard"))
can_start, _unused = TimeTrackingService().can_start_timer(current_user.id)
if not can_start:
flash(_("You already have an active timer. Stop it before starting a new one."), "error")
current_app.logger.info("Start timer (GET) blocked: user already has an active timer")
return redirect(url_for("main.dashboard"))
# Create new timer
from app.models.time_entry import local_now
new_timer = TimeEntry(
user_id=current_user.id, project_id=project_id, task_id=task_id, start_time=local_now(), source="auto"
)
db.session.add(new_timer)
if not safe_commit(
"start_timer_for_project", {"user_id": current_user.id, "project_id": project_id, "task_id": task_id}
):
flash(_("Could not start timer due to a database error. Please check server logs."), "error")
return redirect(url_for("main.dashboard"))
current_app.logger.info(
"Started new timer id=%s for user=%s project_id=%s task_id=%s",
new_timer.id,
current_user.username,
project_id,
task_id,
)
from app.telemetry.otel_setup import business_span
with business_span(
"timer.start",
user_id=current_user.id,
source="project_link",
project_id=project_id,
has_task=bool(task_id),
):
pass
# Emit WebSocket event for real-time updates
try:
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(),
},
)
except Exception as e:
current_app.logger.warning("Socket emit failed for timer_started (GET): %s", e)
# Invalidate dashboard cache so timer appears 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", current_user.id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
if task_id:
task = Task.query.get(task_id)
task_name = task.name if task else "Unknown Task"
flash(f"Timer started for {project.name} - {task_name}", "success")
else:
flash(f"Timer started for {project.name}", "success")
return redirect(url_for("main.dashboard"))
@timer_bp.route("/timer/stop", methods=["POST"])
@login_required
def stop_timer():
"""Stop the current user's active timer"""
active_timer = current_user.active_timer
current_app.logger.info("POST /timer/stop user=%s active_timer=%s", current_user.username, bool(active_timer))
if not active_timer:
flash(_("No active timer to stop"), "error")
return redirect(url_for("main.dashboard"))
# Stop the timer
try:
active_timer.stop_timer()
current_app.logger.info("Stopped timer id=%s for user=%s", active_timer.id, current_user.username)
from app.telemetry.otel_setup import business_span
duration_seconds = active_timer.duration_seconds if active_timer.duration_seconds else 0
with business_span(
"timer.stop",
user_id=current_user.id,
duration_seconds=int(duration_seconds) if duration_seconds is not None else 0,
):
pass
# Track timer stopped event
log_event(
"timer.stopped",
user_id=current_user.id,
time_entry_id=active_timer.id,
project_id=active_timer.project_id,
task_id=active_timer.task_id,
duration_seconds=duration_seconds,
)
track_event(
current_user.id,
"timer.stopped",
{
"time_entry_id": active_timer.id,
"project_id": active_timer.project_id,
"task_id": active_timer.task_id,
"duration_seconds": duration_seconds,
},
)
# Log activity
project_name = active_timer.project.name if active_timer.project else "No project"
task_name = active_timer.task.name if active_timer.task else None
Activity.log(
user_id=current_user.id,
action="stopped",
entity_type="time_entry",
entity_id=active_timer.id,
entity_name=f"{project_name}" + (f" - {task_name}" if task_name else ""),
description=f"Stopped timer for {project_name}"
+ (f" - {task_name}" if task_name else "")
+ f" - Duration: {active_timer.duration_formatted}",
extra_data={
"duration_hours": active_timer.duration_hours,
"project_id": active_timer.project_id,
"task_id": active_timer.task_id,
},
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
)
# Check if this is user's first completed time entry (onboarding milestone)
entry_count = TimeEntry.query.filter_by(user_id=current_user.id).filter(TimeEntry.end_time.isnot(None)).count()
if entry_count == 1: # First completed time entry ever
track_onboarding_first_time_entry(
current_user.id,
{"source": "timer", "duration_seconds": duration_seconds, "has_task": bool(active_timer.task_id)},
)
# Emit WebSocket event for real-time updates
try:
socketio.emit(
"timer_stopped",
{"user_id": current_user.id, "timer_id": active_timer.id, "duration": active_timer.duration_formatted},
)
except Exception as e:
current_app.logger.warning("Socket emit failed for timer_stopped: %s", e)
# Invalidate dashboard cache so timer disappears 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", current_user.id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
# Pass data for post-timer toast (message + link to time entries; no flash to avoid duplicate)
project_name = (
active_timer.project.name
if active_timer.project
else (active_timer.client.name if active_timer.client else _("No project"))
)
session["timer_stopped_toast"] = {
"duration": active_timer.duration_formatted,
"project_name": project_name,
}
session.modified = True
return redirect(url_for("main.dashboard"))
except ValueError as e:
# Timer already stopped or invalid state
current_app.logger.warning("Cannot stop timer: %s", e)
flash(_("Cannot stop timer: %(error)s", error=str(e)), "error")
return redirect(url_for("main.dashboard"))
except Exception as e:
current_app.logger.exception("Error stopping timer: %s", e)
flash(
_("Could not stop timer due to an error. Please try again or contact support if the problem persists."),
"error",
)
return redirect(url_for("main.dashboard"))
@timer_bp.route("/timer/pause", methods=["POST"])
@login_required
def pause_timer():
"""Pause the current user's active timer (clock stops; break accumulates on resume)."""
active_timer = current_user.active_timer
if not active_timer:
flash(_("No active timer to pause"), "error")
return redirect(url_for("main.dashboard"))
try:
active_timer.pause_timer()
flash(_("Timer paused"), "success")
except ValueError as e:
flash(_(str(e)), "error")
except Exception as e:
current_app.logger.exception("Error pausing timer: %s", e)
flash(_("Could not pause timer. Please try again."), "error")
try:
from app.utils.cache import invalidate_dashboard_for_user
invalidate_dashboard_for_user(current_user.id)
except Exception as e:
safe_log(current_app.logger, "debug", "Dashboard cache invalidation failed: %s", e)
return redirect(url_for("main.dashboard"))
@timer_bp.route("/timer/resume", methods=["POST"])
@login_required
def resume_timer():
"""Resume a paused timer (time since pause is counted as break)."""
active_timer = current_user.active_timer
if not active_timer:
flash(_("No active timer to resume"), "error")
return redirect(url_for("main.dashboard"))
try:
active_timer.resume_timer()
flash(_("Timer resumed"), "success")
except ValueError as e:
flash(_(str(e)), "error")
except Exception as e:
current_app.logger.exception("Error resuming timer: %s", e)
flash(_("Could not resume timer. Please try again."), "error")
try:
from app.utils.cache import invalidate_dashboard_for_user
invalidate_dashboard_for_user(current_user.id)
except Exception as e:
safe_log(current_app.logger, "debug", "Dashboard cache invalidation failed: %s", e)
return redirect(url_for("main.dashboard"))
@timer_bp.route("/timer/adjust", methods=["POST"])
@login_required
def adjust_timer():
"""Adjust the active timer's start time by delta_minutes (positive = add time, negative = subtract)."""
active_timer = current_user.active_timer
if not active_timer:
flash(_("No active timer to adjust"), "error")
return redirect(url_for("main.dashboard"))
try:
delta_minutes = int(request.form.get("delta_minutes", 0))
except (TypeError, ValueError):
flash(_("Invalid adjustment value"), "error")
return redirect(url_for("main.dashboard"))
if delta_minutes == 0:
return redirect(url_for("main.dashboard"))
# Clamp to avoid extreme shifts (e.g. ±4 hours)
delta_minutes = max(-240, min(240, delta_minutes))
from app.models.time_entry import local_now
new_start = active_timer.start_time - timedelta(minutes=delta_minutes)
# Do not set start_time in the future
now_local = local_now()
if new_start > now_local:
new_start = now_local
active_timer.start_time = new_start
active_timer.updated_at = now_local
db.session.commit()
try:
from app.utils.cache import invalidate_dashboard_for_user
invalidate_dashboard_for_user(current_user.id)
except Exception as e:
safe_log(current_app.logger, "debug", "Dashboard cache invalidation failed: %s", e)
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return jsonify({"success": True, "start_time": active_timer.start_time.isoformat()})
return redirect(url_for("main.dashboard"))
@timer_bp.route("/timer/status")
@login_required
def timer_status():
"""Get current timer status as JSON"""
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 if active_timer.project else None,
"client_name": active_timer.client.name if active_timer.client else None,
"start_time": active_timer.start_time.isoformat(),
"current_duration": active_timer.current_duration_seconds,
"duration_formatted": active_timer.duration_formatted,
"paused": getattr(active_timer, "is_paused", False),
"paused_at": active_timer.paused_at.isoformat() if active_timer.paused_at else None,
"break_seconds": getattr(active_timer, "break_seconds", None) or 0,
"break_formatted": getattr(active_timer, "break_formatted", "00:00:00"),
},
}
)
@timer_bp.route("/timer/edit/<int:timer_id>", methods=["GET", "POST"])
@login_required
def edit_timer(timer_id):
"""Edit a completed timer entry"""
timer = TimeEntry.query.get_or_404(timer_id)
can_edit_schedule = current_user.is_admin or (
timer.user_id == current_user.id and current_user.has_permission("edit_own_time_entries")
)
show_source_dropdown = current_user.is_admin
# Check if user can edit this timer
if timer.user_id != current_user.id and not current_user.is_admin:
flash(_("You can only edit your own timers"), "error")
return redirect(url_for("main.dashboard"))
if request.method == "POST":
from app.utils.validation import sanitize_input
# Get reason for change
reason = sanitize_input(request.form.get("reason", "").strip(), max_length=500) or None
# Use service layer for update to get enhanced audit logging
from app.services import TimeTrackingService
service = TimeTrackingService()
# Prepare update parameters
notes_raw = request.form.get("notes", "").strip()
tags_raw = request.form.get("tags", "").strip()
update_params = {
"entry_id": timer_id,
"user_id": current_user.id,
"is_admin": current_user.is_admin,
"notes": sanitize_input(notes_raw, max_length=2000) if notes_raw else None,
"tags": sanitize_input(tags_raw, max_length=500) if tags_raw else None,
"billable": request.form.get("billable") == "on",
"paid": request.form.get("paid") == "on",
"reason": reason,
}
# Update invoice number
invoice_number = request.form.get("invoice_number", "").strip()
update_params["invoice_number"] = invoice_number if invoice_number else None
# Clear invoice number if marking as unpaid
if update_params["paid"] is False:
update_params["invoice_number"] = None
# Admins and users with edit_own_time_entries can edit schedule, project, and task
if can_edit_schedule:
# Update project if changed
new_project_id = request.form.get("project_id", type=int)
if new_project_id and new_project_id != timer.project_id:
new_project = Project.query.filter_by(id=new_project_id, status="active").first()
if new_project:
update_params["project_id"] = new_project_id
else:
flash(_("Invalid project selected"), "error")
return render_template(
"timer/edit_timer.html",
**_edit_timer_render_kwargs(timer, can_edit_schedule, show_source_dropdown),
)
else:
update_params["project_id"] = None # Don't change if not provided
# Update task if changed
new_task_id = request.form.get("task_id", type=int)
if new_task_id != timer.task_id:
if new_task_id:
new_task = Task.query.filter_by(
id=new_task_id, project_id=update_params.get("project_id") or timer.project_id
).first()
if new_task:
update_params["task_id"] = new_task_id
else:
flash(_("Invalid task selected for the chosen project"), "error")
return render_template(
"timer/edit_timer.html",
**_edit_timer_render_kwargs(timer, can_edit_schedule, show_source_dropdown),
)
else:
update_params["task_id"] = None
else:
update_params["task_id"] = None # Don't change if not provided
# Update start and end times if provided
start_date = request.form.get("start_date")
start_time = request.form.get("start_time")
end_date = request.form.get("end_date")
end_time = request.form.get("end_time")
break_time = (request.form.get("break_time") or "").strip()
if start_date and start_time:
try:
# Convert parsed UTC-aware to local naive to match model storage
parsed_start_utc = parse_local_datetime(start_date, start_time)
new_start_time = utc_to_local(parsed_start_utc).replace(tzinfo=None)
# Validate that start time is not in the future
from app.models.time_entry import local_now
current_time = local_now()
if new_start_time > current_time:
flash(_("Start time cannot be in the future"), "error")
return render_template(
"timer/edit_timer.html",
**_edit_timer_render_kwargs(timer, can_edit_schedule, show_source_dropdown),
)
update_params["start_time"] = new_start_time
except ValueError:
flash(_("Invalid start date/time format"), "error")
return render_template(
"timer/edit_timer.html",
**_edit_timer_render_kwargs(timer, can_edit_schedule, show_source_dropdown),
)
else:
update_params["start_time"] = None
if end_date and end_time:
try:
# Convert parsed UTC-aware to local naive to match model storage
parsed_end_utc = parse_local_datetime(end_date, end_time)
new_end_time = utc_to_local(parsed_end_utc).replace(tzinfo=None)
# Validate that end time is after start time
start_time_for_validation = update_params.get("start_time") or timer.start_time
if new_end_time <= start_time_for_validation:
flash(_("End time must be after start time"), "error")
return render_template(
"timer/edit_timer.html",
**_edit_timer_render_kwargs(timer, can_edit_schedule, show_source_dropdown),
)
update_params["end_time"] = new_end_time
except ValueError:
flash(_("Invalid end date/time format"), "error")
return render_template(
"timer/edit_timer.html",
**_edit_timer_render_kwargs(timer, can_edit_schedule, show_source_dropdown),
)
else:
update_params["end_time"] = None
# Parse break time (HH:MM) to seconds; empty clears break
import re
if break_time:
m = re.match(r"^(\d{1,3}):([0-5]\d)$", break_time.strip())
update_params["break_seconds"] = (int(m.group(1)) * 3600 + int(m.group(2)) * 60) if m else 0
else:
update_params["break_seconds"] = 0
# Call service layer to update
result = service.update_entry(**update_params)
if not result.get("success"):
flash(_(result.get("message", "Could not update timer")), "error")
return render_template(
"timer/edit_timer.html",
**_edit_timer_render_kwargs(timer, can_edit_schedule, show_source_dropdown),
)
entry = result.get("entry")
# Log activity
if entry:
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 timer owner so changes appear immediately
try:
from app.utils.cache import invalidate_dashboard_for_user
invalidate_dashboard_for_user(timer.user_id)
current_app.logger.debug("Invalidated dashboard cache for user %s after timer edit", timer.user_id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
flash(_("Timer updated successfully"), "success")
return redirect(url_for("main.dashboard"))
return render_template(
"timer/edit_timer.html",
**_edit_timer_render_kwargs(timer, can_edit_schedule, show_source_dropdown),
)
@timer_bp.route("/timer/view/<int:timer_id>")
@login_required
def view_timer(timer_id):
"""View a time entry (read-only)"""
timer = TimeEntry.query.get_or_404(timer_id)
# Check if user can view this timer
can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries")
if not can_view_all and timer.user_id != current_user.id:
flash(_("You do not have permission to view this time entry"), "error")
return redirect(url_for("main.dashboard"))
# Get link templates for invoice_number (for clickable values)
from sqlalchemy.exc import ProgrammingError
from app.models import LinkTemplate
link_templates_by_field = {}
try:
for template in LinkTemplate.get_active_templates():
if template.field_key == "invoice_number":
link_templates_by_field["invoice_number"] = template
except ProgrammingError as e:
# Handle case where link_templates table doesn't exist (migration not run)
if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower():
current_app.logger.warning("link_templates table does not exist. Run migration: flask db upgrade")
link_templates_by_field = {}
else:
raise
# Time entry approvals: can current user request approval for this entry?
from app.utils.module_helpers import is_module_enabled
time_approvals_enabled = is_module_enabled("time_approvals")
can_request_approval = False
if time_approvals_enabled and timer.user_id == current_user.id and timer.end_time:
try:
from app.models.time_entry_approval import ApprovalStatus, TimeEntryApproval
pending = TimeEntryApproval.query.filter_by(
time_entry_id=timer.id,
status=ApprovalStatus.PENDING,
).first()
can_request_approval = pending is None
except Exception:
can_request_approval = False
return render_template(
"timer/view_timer.html",
timer=timer,
link_templates_by_field=link_templates_by_field,
time_approvals_enabled=time_approvals_enabled,
can_request_approval=can_request_approval,
)
@timer_bp.route("/timer/delete/<int:timer_id>", methods=["POST"])
@login_required
def delete_timer(timer_id):
"""Delete a timer entry"""
timer = TimeEntry.query.get_or_404(timer_id)
# Check if user can delete this timer
if timer.user_id != current_user.id and not current_user.is_admin:
flash(_("You can only delete your own timers"), "error")
return redirect(url_for("main.dashboard"))
# Don't allow deletion of active timers
if timer.is_active:
flash(_("Cannot delete an active timer"), "error")
return redirect(url_for("main.dashboard"))
# Get the name for the success message (project or client)
if timer.project:
target_name = timer.project.name
elif timer.client:
target_name = timer.client.name
else:
target_name = _("Unknown")
# Capture entry info for logging before deletion
entry_id = timer.id
duration_formatted = timer.duration_formatted
project_name = timer.project.name if timer.project else None
client_name = timer.client.name if timer.client else None
entity_name = project_name or client_name or _("Unknown")
timer_user_id = timer.user_id # Capture user_id before deletion
# Check if time_entry_approvals table exists before deletion
# This prevents errors when the table doesn't exist but the relationship is defined
inspector = inspect(db.engine)
approvals_table_exists = "time_entry_approvals" in inspector.get_table_names()
# If the approvals table exists, manually delete related approvals first
# to avoid SQLAlchemy trying to query a non-existent table
if approvals_table_exists:
try:
# Delete related approvals if they exist
from app.models.time_entry_approval import TimeEntryApproval
TimeEntryApproval.query.filter_by(time_entry_id=entry_id).delete()
except Exception as e:
current_app.logger.warning(f"Could not delete related approvals for time entry {entry_id}: {e}")
# Continue with deletion anyway
# If the approvals table doesn't exist, we need to prevent SQLAlchemy from
# trying to query the relationship. We'll expunge the object and use a direct delete.
if not approvals_table_exists:
try:
# Expunge the object from the session to prevent relationship queries
db.session.expunge(timer)
# Use a direct SQL delete to avoid relationship queries
db.session.execute(text("DELETE FROM time_entries WHERE id = :id"), {"id": entry_id})
except Exception as e:
current_app.logger.error(f"Error deleting time entry {entry_id} with direct SQL: {e}")
flash(_("Could not delete timer due to a database error. Please check server logs."), "error")
return redirect(url_for("main.dashboard"))
else:
# Normal deletion path when the table exists
db.session.delete(timer)
if not safe_commit("delete_timer", {"timer_id": entry_id}):
flash(_("Could not delete timer due to a database error. Please check server logs."), "error")
return redirect(url_for("main.dashboard"))
# Invalidate dashboard cache for the timer owner so changes appear immediately
try:
from app.utils.cache import invalidate_dashboard_for_user
invalidate_dashboard_for_user(timer_user_id)
current_app.logger.debug("Invalidated dashboard cache for user %s after timer deletion", timer_user_id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
# Log activity
Activity.log(
user_id=current_user.id,
action="deleted",
entity_type="time_entry",
entity_id=entry_id,
entity_name=entity_name,
description=f"Deleted time entry for {entity_name} - {duration_formatted}",
extra_data={"project_name": project_name, "client_name": client_name, "duration_formatted": duration_formatted},
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
)
# Invalidate dashboard cache so deleted entry disappears 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 deleting timer", current_user.id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
flash(f"Timer for {target_name} deleted successfully", "success")
# Add cache-busting parameter to ensure fresh page load
import time
dashboard_url = url_for("main.dashboard")
separator = "&" if "?" in dashboard_url else "?"
redirect_url = f"{dashboard_url}{separator}_refresh={int(time.time())}"
return redirect(redirect_url)
@timer_bp.route("/time-entries/bulk-delete", methods=["POST"])
@login_required
def bulk_delete_time_entries():
"""Bulk delete time entries"""
from app.services import TimeTrackingService
entry_ids = request.form.getlist("entry_ids[]")
reason = request.form.get("reason", "").strip() or None # Optional reason for bulk deletion
if not entry_ids:
flash(_("No time entries selected"), "warning")
return redirect(url_for("timer.time_entries_overview"))
# Load entries
entry_ids_int = [int(eid) for eid in entry_ids if eid.isdigit()]
if not entry_ids_int:
flash(_("Invalid entry IDs"), "error")
return redirect(url_for("timer.time_entries_overview"))
entries = TimeEntry.query.filter(TimeEntry.id.in_(entry_ids_int)).all()
if not entries:
flash(_("No time entries found"), "error")
return redirect(url_for("timer.time_entries_overview"))
# Permission check
can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries")
deleted_count = 0
skipped_count = 0
# Use service layer for proper audit logging
service = TimeTrackingService()
for entry in entries:
# Check permissions
if not can_view_all and entry.user_id != current_user.id:
skipped_count += 1
continue
# Don't allow deletion of active timers
if entry.is_active:
skipped_count += 1
continue
# Delete using service layer to get enhanced audit logging
result = service.delete_entry(
user_id=current_user.id,
entry_id=entry.id,
is_admin=current_user.is_admin,
reason=reason, # Use same reason for all entries in bulk delete
)
if result.get("success"):
deleted_count += 1
else:
skipped_count += 1
if deleted_count > 0:
flash(_("Successfully deleted %(count)d time entry/entries", count=deleted_count), "success")
if skipped_count > 0:
flash(_("Skipped %(count)d time entry/entries (no permission or active timer)", count=skipped_count), "warning")
# Track event
track_event(current_user.id, "time_entries.bulk_delete", {"count": deleted_count})
# Preserve filters in redirect
redirect_url = url_for("timer.time_entries_overview")
filters = {}
for key in ["user_id", "project_id", "client_id", "start_date", "end_date", "paid", "billable", "search", "page"]:
value = request.form.get(key) or request.args.get(key)
if value:
filters[key] = value
if filters:
redirect_url += "?" + "&".join(f"{k}={v}" for k, v in filters.items())
return redirect(redirect_url)
@timer_bp.route("/timer/manual", methods=["GET", "POST"])
@login_required
def manual_entry():
"""Create a manual time entry"""
from app.models import Client
from app.services import TimeTrackingService
from app.utils.client_lock import enforce_locked_client_id, get_locked_client_id
# Get active projects and clients for dropdown (scoped for subcontractors)
from app.utils.scope_filter import apply_client_scope_to_model, apply_project_scope_to_model
projects_query = Project.query.filter_by(status="active").order_by(Project.name)
scope_p = apply_project_scope_to_model(Project, current_user)
if scope_p is not None:
projects_query = projects_query.filter(scope_p)
active_projects = projects_query.all()
clients_query = Client.query.filter_by(status="active").order_by(Client.name)
scope_c = apply_client_scope_to_model(Client, current_user)
if scope_c is not None:
clients_query = clients_query.filter(scope_c)
active_clients = clients_query.all()
only_one_client = len(active_clients) == 1
single_client = active_clients[0] if only_one_client else None
# Get project_id, client_id, and task_id from query parameters for pre-filling
project_id = request.args.get("project_id", type=int)
client_id = request.args.get("client_id", type=int)
client_id = enforce_locked_client_id(client_id)
task_id = request.args.get("task_id", type=int)
template_id = request.args.get("template", type=int)
# Load template data if template_id is provided
template_data = None
if template_id:
from app.models import TimeEntryTemplate
template = TimeEntryTemplate.query.filter_by(id=template_id, user_id=current_user.id).first()
if template:
template_data = {
"project_id": template.project_id,
"task_id": template.task_id,
"notes": template.default_notes,
"tags": template.tags,
"billable": template.billable,
}
# Override with template values if not explicitly set
if not project_id and template.project_id:
project_id = template.project_id
if not task_id and template.task_id:
task_id = template.task_id
if request.method == "POST":
from app.utils.validation import sanitize_input
project_id = request.form.get("project_id", type=int) or None
client_id = request.form.get("client_id", type=int) or None
client_id = enforce_locked_client_id(client_id)
task_id = request.form.get("task_id", type=int) or None
start_date = request.form.get("start_date")
start_time = request.form.get("start_time")
end_date = request.form.get("end_date")
end_time = request.form.get("end_time")
worked_time = (request.form.get("worked_time") or "").strip()
worked_time_mode = (request.form.get("worked_time_mode") or "").strip() # 'explicit' when user typed duration
break_time = (request.form.get("break_time") or "").strip()
notes = sanitize_input(request.form.get("notes", "").strip(), max_length=2000)
tags = sanitize_input(request.form.get("tags", "").strip(), max_length=500)
billable = request.form.get("billable") == "on"
def _parse_worked_time_minutes(raw: str):
s = (raw or "").strip()
if not s:
return None
import re
m = re.match(r"^(\d{1,3}):([0-5]\d)$", s)
if not m:
return None
hours = int(m.group(1))
minutes = int(m.group(2))
total = hours * 60 + minutes
return total if total > 0 else None
worked_minutes = _parse_worked_time_minutes(worked_time)
break_minutes = _parse_worked_time_minutes(break_time)
break_seconds = (break_minutes * 60) if break_minutes is not None else None
has_all_times = bool(start_date and start_time and end_date and end_time)
has_duration = worked_minutes is not None
# Validate time input: either full start/end, or duration-only.
if not has_all_times and not has_duration:
flash(_("Please provide either start/end date+time or a worked time duration (HH:MM)."), "error")
return render_template(
"timer/manual_entry.html",
projects=active_projects,
clients=active_clients,
only_one_client=only_one_client,
single_client=single_client,
selected_project_id=project_id,
selected_client_id=client_id,
selected_task_id=task_id,
template_data=template_data,
prefill_notes=notes,
prefill_tags=tags,
prefill_billable=billable,
prefill_start_date=start_date,
prefill_start_time=start_time,
prefill_end_date=end_date,
prefill_end_time=end_time,
prefill_worked_time=worked_time,
prefill_worked_time_mode=worked_time_mode,
prefill_break_time=break_time,
)
# Validate that either project or client is selected
if not project_id and not client_id:
flash(_("Either a project or a client must be selected"), "error")
return render_template(
"timer/manual_entry.html",
projects=active_projects,
clients=active_clients,
only_one_client=only_one_client,
single_client=single_client,
selected_project_id=project_id,
selected_client_id=client_id,
selected_task_id=task_id,
template_data=template_data,
prefill_notes=notes,
prefill_tags=tags,
prefill_billable=billable,
prefill_start_date=start_date,
prefill_start_time=start_time,
prefill_end_date=end_date,
prefill_end_time=end_time,
prefill_worked_time=worked_time,
prefill_worked_time_mode=worked_time_mode,
prefill_break_time=break_time,
)
# If a locked client is configured, ensure selected project matches it.
locked_id = get_locked_client_id()
if locked_id and project_id:
project = _project_service.get_by_id(project_id)
if project and getattr(project, "client_id", None) and int(project.client_id) != int(locked_id):
flash(_("Selected project does not match the locked client."), "error")
return redirect(url_for("timer.manual_entry"))
duration_seconds_override = None
# Parse datetime: treat form input as user's local time, store in app timezone.
# If duration + start date/time are provided: end = start + duration.
# If duration only (no start): end=now, start=end-duration.
# Break is subtracted from span to get worked duration.
from datetime import timedelta
try:
if has_all_times:
start_time_parsed = parse_user_local_datetime(start_date, start_time, current_user)
end_time_parsed = parse_user_local_datetime(end_date, end_time, current_user)
if worked_time_mode == "explicit" and has_duration:
duration_seconds_override = worked_minutes * 60
# When we have start/end and break, we pass break_seconds and do not override duration;
# calculate_duration() will compute (end - start) - break_seconds
elif has_duration and start_date and start_time:
# Combined: worked time + start date/time (user can set date and duration)
start_time_parsed = parse_user_local_datetime(start_date, start_time, current_user)
end_time_parsed = start_time_parsed + timedelta(minutes=worked_minutes)
duration_seconds_override = worked_minutes * 60
else:
# Duration-only: no start given → end=now, start=end-duration
from app.models.time_entry import local_now as _local_now_db
end_time_parsed = _local_now_db()
start_time_parsed = end_time_parsed - timedelta(minutes=worked_minutes)
duration_seconds_override = worked_minutes * 60
except ValueError:
flash(_("Invalid date/time format"), "error")
return render_template(
"timer/manual_entry.html",
projects=active_projects,
clients=active_clients,
only_one_client=only_one_client,
single_client=single_client,
selected_project_id=project_id,
selected_client_id=client_id,
selected_task_id=task_id,
template_data=template_data,
prefill_notes=notes,
prefill_tags=tags,
prefill_billable=billable,
prefill_start_date=start_date,
prefill_start_time=start_time,
prefill_end_date=end_date,
prefill_end_time=end_time,
prefill_worked_time=worked_time,
prefill_worked_time_mode=worked_time_mode,
prefill_break_time=break_time,
)
# When user entered both duration override and break, net duration = duration - break
if duration_seconds_override is not None and break_seconds is not None:
duration_seconds_override = max(0, duration_seconds_override - break_seconds)
# Validate time range
if end_time_parsed <= start_time_parsed:
flash(_("End time must be after start time"), "error")
return render_template(
"timer/manual_entry.html",
projects=active_projects,
clients=active_clients,
only_one_client=only_one_client,
single_client=single_client,
selected_project_id=project_id,
selected_client_id=client_id,
selected_task_id=task_id,
template_data=template_data,
prefill_notes=notes,
prefill_tags=tags,
prefill_billable=billable,
prefill_start_date=start_date,
prefill_start_time=start_time,
prefill_end_date=end_date,
prefill_end_time=end_time,
prefill_worked_time=worked_time,
prefill_worked_time_mode=worked_time_mode,
prefill_break_time=break_time,
)
# Use service to create entry (handles validation)
time_tracking_service = TimeTrackingService()
result = time_tracking_service.create_manual_entry(
user_id=current_user.id,
project_id=project_id,
client_id=client_id,
start_time=start_time_parsed,
end_time=end_time_parsed,
duration_seconds=duration_seconds_override,
break_seconds=break_seconds,
task_id=task_id,
notes=notes if notes else None,
tags=tags if tags else None,
billable=billable,
)
if not result.get("success"):
flash(_(result.get("message", "Could not create manual entry")), "error")
return render_template(
"timer/manual_entry.html",
projects=active_projects,
clients=active_clients,
only_one_client=only_one_client,
single_client=single_client,
selected_project_id=project_id,
selected_client_id=client_id,
selected_task_id=task_id,
template_data=template_data,
prefill_notes=notes,
prefill_tags=tags,
prefill_billable=billable,
prefill_start_date=start_date,
prefill_start_time=start_time,
prefill_end_date=end_date,
prefill_end_time=end_time,
prefill_worked_time=worked_time,
prefill_worked_time_mode=worked_time_mode,
prefill_break_time=break_time,
)
entry = result.get("entry")
# Create success message
if entry:
if entry.project:
target_name = entry.project.name
elif entry.client:
target_name = entry.client.name
else:
target_name = "Unknown"
if task_id and entry.project:
task = Task.query.get(task_id)
task_name = task.name if task else "Unknown Task"
flash(
_("Manual entry created for %(project)s - %(task)s", project=target_name, task=task_name), "success"
)
else:
flash(_("Manual entry created for %(target)s", target=target_name), "success")
# Log 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=current_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 so new entry appears 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 manual entry creation", current_user.id
)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
return redirect(url_for("main.dashboard"))
# Pre-fill start/end date with today in user's timezone (Issue #489)
from app.utils.timezone import now_in_user_timezone
today_local = now_in_user_timezone(current_user)
today_str = today_local.strftime("%Y-%m-%d")
return render_template(
"timer/manual_entry.html",
projects=active_projects,
clients=active_clients,
only_one_client=only_one_client,
single_client=single_client,
selected_project_id=project_id,
selected_client_id=client_id,
selected_task_id=task_id,
template_data=template_data,
prefill_start_date=today_str,
prefill_end_date=today_str,
)
@timer_bp.route("/timer/manual/<int:project_id>")
@login_required
def manual_entry_for_project(project_id):
"""Create a manual time entry for a specific project"""
from app.models import Client
task_id = request.args.get("task_id", type=int)
# Check if project exists and is active
project = Project.query.filter_by(id=project_id, status="active").first()
if not project:
flash("Invalid project selected", "error")
return redirect(url_for("main.dashboard"))
# Get active projects and clients for dropdown
active_projects = Project.query.filter_by(status="active").order_by(Project.name).all()
active_clients = Client.query.filter_by(status="active").order_by(Client.name).all()
only_one_client = len(active_clients) == 1
single_client = active_clients[0] if only_one_client else None
from app.utils.timezone import now_in_user_timezone
today_local = now_in_user_timezone(current_user)
today_str = today_local.strftime("%Y-%m-%d")
return render_template(
"timer/manual_entry.html",
projects=active_projects,
clients=active_clients,
only_one_client=only_one_client,
single_client=single_client,
selected_project_id=project_id,
selected_task_id=task_id,
prefill_start_date=today_str,
prefill_end_date=today_str,
)
@timer_bp.route("/timer/bulk", methods=["GET", "POST"])
@login_required
def bulk_entry():
"""Create bulk time entries for multiple days"""
# Get active projects for dropdown
active_projects = Project.query.filter_by(status="active").order_by(Project.name).all()
# Get project_id and task_id from query parameters for pre-filling
project_id = request.args.get("project_id", type=int)
task_id = request.args.get("task_id", type=int)
if request.method == "POST":
project_id = request.form.get("project_id", type=int)
task_id = request.form.get("task_id", type=int)
start_date = request.form.get("start_date")
end_date = request.form.get("end_date")
start_time = request.form.get("start_time")
end_time = request.form.get("end_time")
notes = request.form.get("notes", "").strip()
tags = request.form.get("tags", "").strip()
billable = request.form.get("billable") == "on"
skip_weekends = request.form.get("skip_weekends") == "on"
# Validate required fields
if not all([project_id, start_date, end_date, start_time, end_time]):
flash(_("All fields are required"), "error")
return render_template(
"timer/bulk_entry.html",
projects=active_projects,
selected_project_id=project_id,
selected_task_id=task_id,
)
# Check if project exists
project = _project_service.get_by_id(project_id)
if not project:
flash(_("Invalid project selected"), "error")
return render_template(
"timer/bulk_entry.html",
projects=active_projects,
selected_project_id=project_id,
selected_task_id=task_id,
)
# Check if project is active (not archived or inactive)
if project.status == "archived":
flash(_("Cannot create time entries for an archived project. Please unarchive the project first."), "error")
return render_template(
"timer/bulk_entry.html",
projects=active_projects,
selected_project_id=project_id,
selected_task_id=task_id,
)
elif project.status != "active":
flash(_("Cannot create time entries for an inactive project"), "error")
return render_template(
"timer/bulk_entry.html",
projects=active_projects,
selected_project_id=project_id,
selected_task_id=task_id,
)
# Validate task if provided
if task_id:
task = Task.query.filter_by(id=task_id, project_id=project_id).first()
if not task:
flash(_("Invalid task selected"), "error")
return render_template(
"timer/bulk_entry.html",
projects=active_projects,
selected_project_id=project_id,
selected_task_id=task_id,
)
# Parse and validate dates
try:
from datetime import datetime, timedelta
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d").date()
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d").date()
if end_date_obj < start_date_obj:
flash(_("End date must be after or equal to start date"), "error")
return render_template(
"timer/bulk_entry.html",
projects=active_projects,
selected_project_id=project_id,
selected_task_id=task_id,
)
# Check for reasonable date range (max 31 days)
if (end_date_obj - start_date_obj).days > 31:
flash(_("Date range cannot exceed 31 days"), "error")
return render_template(
"timer/bulk_entry.html",
projects=active_projects,
selected_project_id=project_id,
selected_task_id=task_id,
)
except ValueError:
flash(_("Invalid date format"), "error")
return render_template(
"timer/bulk_entry.html",
projects=active_projects,
selected_project_id=project_id,
selected_task_id=task_id,
)
# Parse and validate times
try:
start_time_obj = datetime.strptime(start_time, "%H:%M").time()
end_time_obj = datetime.strptime(end_time, "%H:%M").time()
if end_time_obj <= start_time_obj:
flash("End time must be after start time", "error")
return render_template(
"timer/bulk_entry.html",
projects=active_projects,
selected_project_id=project_id,
selected_task_id=task_id,
)
except ValueError:
flash(_("Invalid time format"), "error")
return render_template(
"timer/bulk_entry.html",
projects=active_projects,
selected_project_id=project_id,
selected_task_id=task_id,
)
# Generate date range
current_date = start_date_obj
dates_to_create = []
while current_date <= end_date_obj:
# Skip weekends if requested
if skip_weekends and current_date.weekday() >= 5: # Saturday = 5, Sunday = 6
current_date += timedelta(days=1)
continue
dates_to_create.append(current_date)
current_date += timedelta(days=1)
if not dates_to_create:
flash(_("No valid dates found in the selected range"), "error")
return render_template(
"timer/bulk_entry.html",
projects=active_projects,
selected_project_id=project_id,
selected_task_id=task_id,
)
# Check for existing entries on the same dates/times
from app.models.time_entry import local_now
existing_entries = []
for date_obj in dates_to_create:
start_datetime = datetime.combine(date_obj, start_time_obj)
end_datetime = datetime.combine(date_obj, end_time_obj)
# Check for overlapping entries
overlapping = TimeEntry.query.filter(
TimeEntry.user_id == current_user.id,
TimeEntry.start_time <= end_datetime,
TimeEntry.end_time >= start_datetime,
TimeEntry.end_time.isnot(None),
).first()
if overlapping:
existing_entries.append(date_obj.strftime("%Y-%m-%d"))
if existing_entries:
flash(
f'Time entries already exist for these dates: {", ".join(existing_entries[:5])}{"..." if len(existing_entries) > 5 else ""}',
"error",
)
return render_template(
"timer/bulk_entry.html",
projects=active_projects,
selected_project_id=project_id,
selected_task_id=task_id,
)
# Create bulk entries
created_entries = []
try:
for date_obj in dates_to_create:
start_datetime = datetime.combine(date_obj, start_time_obj)
end_datetime = datetime.combine(date_obj, end_time_obj)
entry = TimeEntry(
user_id=current_user.id,
project_id=project_id,
task_id=task_id,
start_time=start_datetime,
end_time=end_datetime,
notes=notes,
tags=tags,
source="manual",
billable=billable,
)
db.session.add(entry)
created_entries.append(entry)
if not safe_commit(
"bulk_entry", {"user_id": current_user.id, "project_id": project_id, "count": len(created_entries)}
):
flash(_("Could not create bulk entries due to a database error. Please check server logs."), "error")
return render_template(
"timer/bulk_entry.html",
projects=active_projects,
selected_project_id=project_id,
selected_task_id=task_id,
)
task_name = ""
if task_id:
task = Task.query.get(task_id)
task_name = f" - {task.name}" if task else ""
flash(f"Successfully created {len(created_entries)} time entries for {project.name}{task_name}", "success")
return redirect(url_for("main.dashboard"))
except Exception as e:
db.session.rollback()
current_app.logger.exception("Error creating bulk entries: %s", e)
flash(_("An error occurred while creating bulk entries. Please try again."), "error")
return render_template(
"timer/bulk_entry.html",
projects=active_projects,
selected_project_id=project_id,
selected_task_id=task_id,
)
return render_template(
"timer/bulk_entry.html", projects=active_projects, selected_project_id=project_id, selected_task_id=task_id
)
@timer_bp.route("/timer")
@login_required
def timer_page():
"""Dedicated timer page with visual progress ring and quick project selection"""
active_timer = current_user.active_timer
# Get active projects and clients for dropdown
active_projects = Project.query.filter_by(status="active").order_by(Project.name).all()
active_clients = Client.query.filter_by(status="active").order_by(Client.name).all()
only_one_client = len(active_clients) == 1
single_client = active_clients[0] if only_one_client else None
# Get recent projects (projects used in last 30 days)
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
recent_project_ids = (
db.session.query(TimeEntry.project_id)
.filter(
TimeEntry.user_id == current_user.id,
TimeEntry.start_time >= thirty_days_ago,
TimeEntry.end_time.isnot(None),
)
.group_by(TimeEntry.project_id)
.order_by(db.func.max(TimeEntry.start_time).desc())
.limit(5)
.all()
)
recent_project_ids_list = [pid[0] for pid in recent_project_ids]
if recent_project_ids_list:
# Create a dict to preserve order from recent_project_ids_list
order_map = {pid: idx for idx, pid in enumerate(recent_project_ids_list)}
recent_projects = Project.query.filter(
Project.id.in_(recent_project_ids_list), Project.status == "active"
).all()
# Sort by order in recent_project_ids_list
recent_projects.sort(key=lambda p: order_map.get(p.id, 999))
else:
recent_projects = []
# Get tasks for active timer's project if timer is active
tasks = []
if active_timer and active_timer.project_id:
tasks = (
Task.query.filter(
Task.project_id == active_timer.project_id, Task.status.in_(["todo", "in_progress", "review"])
)
.order_by(Task.name)
.all()
)
# Get user's time entry templates (most recently used first)
from sqlalchemy import desc
from sqlalchemy.orm import joinedload
from app.models import TimeEntryTemplate
templates = (
TimeEntryTemplate.query.options(joinedload(TimeEntryTemplate.project), joinedload(TimeEntryTemplate.task))
.filter_by(user_id=current_user.id)
.order_by(desc(TimeEntryTemplate.last_used_at))
.limit(5)
.all()
)
return render_template(
"timer/timer_page.html",
active_timer=active_timer,
projects=active_projects,
clients=active_clients,
only_one_client=only_one_client,
single_client=single_client,
recent_projects=recent_projects,
tasks=tasks,
templates=templates,
)
@timer_bp.route("/timer/calendar")
@login_required
def calendar_view():
"""Calendar UI combining day/week/month with list toggle."""
# Provide projects for quick assignment during drag-create
active_projects = Project.query.filter_by(status="active").order_by(Project.name).all()
return render_template("timer/calendar.html", projects=active_projects)
@timer_bp.route("/timer/bulk/<int:project_id>")
@login_required
def bulk_entry_for_project(project_id):
"""Create bulk time entries for a specific project"""
task_id = request.args.get("task_id", type=int)
# Check if project exists and is active
project = Project.query.filter_by(id=project_id, status="active").first()
if not project:
flash("Invalid project selected", "error")
return redirect(url_for("main.dashboard"))
# Get active projects for dropdown
active_projects = Project.query.filter_by(status="active").order_by(Project.name).all()
return render_template(
"timer/bulk_entry.html", projects=active_projects, selected_project_id=project_id, selected_task_id=task_id
)
@timer_bp.route("/timer/duplicate/<int:timer_id>")
@login_required
def duplicate_timer(timer_id):
"""Duplicate an existing time entry - opens manual entry form with pre-filled data"""
from app.models import Client
timer = TimeEntry.query.get_or_404(timer_id)
# Check if user can duplicate this timer
if timer.user_id != current_user.id and not current_user.is_admin:
flash(_("You can only duplicate your own timers"), "error")
return redirect(url_for("main.dashboard"))
# Get active projects and clients for dropdown
active_projects = Project.query.filter_by(status="active").order_by(Project.name).all()
active_clients = Client.query.filter_by(status="active").order_by(Client.name).all()
only_one_client = len(active_clients) == 1
single_client = active_clients[0] if only_one_client else None
# Track duplication event
log_event(
"timer.duplicated",
user_id=current_user.id,
time_entry_id=timer.id,
project_id=timer.project_id,
task_id=timer.task_id,
)
track_event(
current_user.id,
"timer.duplicated",
{
"time_entry_id": timer.id,
"project_id": timer.project_id,
"task_id": timer.task_id,
"has_notes": bool(timer.notes),
"has_tags": bool(timer.tags),
},
)
# Render the manual entry form with pre-filled data
break_sec = getattr(timer, "break_seconds", None) or 0
prefill_break = f"{break_sec // 3600}:{(break_sec % 3600) // 60:02d}" if break_sec else ""
return render_template(
"timer/manual_entry.html",
projects=active_projects,
clients=active_clients,
only_one_client=only_one_client,
single_client=single_client,
selected_project_id=timer.project_id,
selected_client_id=timer.client_id,
selected_task_id=timer.task_id,
prefill_notes=timer.notes,
prefill_tags=timer.tags,
prefill_billable=timer.billable,
prefill_break_time=prefill_break,
is_duplicate=True,
original_entry=timer,
)
@timer_bp.route("/timer/resume/<int:timer_id>", endpoint="resume_timer_by_id")
@login_required
def resume_timer_by_id(timer_id):
"""Resume an existing time entry - starts a new active timer with same properties"""
timer = TimeEntry.query.get_or_404(timer_id)
# Check if user can resume this timer
if timer.user_id != current_user.id and not current_user.is_admin:
flash(_("You can only resume your own timers"), "error")
return redirect(url_for("main.dashboard"))
can_start, _unused = TimeTrackingService().can_start_timer(current_user.id)
if not can_start:
flash("You already have an active timer. Stop it before resuming another one.", "error")
current_app.logger.info("Resume timer blocked: user already has an active timer")
return redirect(url_for("main.dashboard"))
project = None
client = None
project_id = None
client_id = None
# Check if timer is linked to a project or client
if timer.project_id:
# Timer is linked to a project
project = _project_service.get_by_id(timer.project_id)
if not project:
flash(_("Project no longer exists"), "error")
return redirect(url_for("main.dashboard"))
if project.status == "archived":
flash(_("Cannot start timer for an archived project. Please unarchive the project first."), "error")
return redirect(url_for("main.dashboard"))
elif project.status != "active":
flash(_("Cannot start timer for an inactive project"), "error")
return redirect(url_for("main.dashboard"))
project_id = timer.project_id
# Validate task if it exists
if timer.task_id:
task = Task.query.filter_by(id=timer.task_id, project_id=timer.project_id).first()
if not task:
# Task was deleted, continue without it
task_id = None
else:
task_id = timer.task_id
else:
task_id = None
elif timer.client_id:
# Timer is linked to a client
client = Client.query.filter_by(id=timer.client_id, status="active").first()
if not client:
flash(_("Client no longer exists or is inactive"), "error")
return redirect(url_for("main.dashboard"))
client_id = timer.client_id
task_id = None # Tasks are not allowed for client-only timers
else:
flash(_("Timer is not linked to a project or client"), "error")
return redirect(url_for("main.dashboard"))
# Create new timer with copied properties
from app.models.time_entry import local_now
new_timer = TimeEntry(
user_id=current_user.id,
project_id=project_id,
client_id=client_id,
task_id=task_id,
start_time=local_now(),
notes=timer.notes,
tags=timer.tags,
source="auto",
billable=timer.billable,
)
db.session.add(new_timer)
if not safe_commit(
"resume_timer",
{
"user_id": current_user.id,
"original_timer_id": timer_id,
"project_id": project_id,
"client_id": client_id,
},
):
flash(_("Could not resume timer due to a database error. Please check server logs."), "error")
return redirect(url_for("main.dashboard"))
current_app.logger.info(
"Resumed timer id=%s from original timer=%s for user=%s project_id=%s client_id=%s",
new_timer.id,
timer_id,
current_user.username,
project_id,
client_id,
)
# Track timer resumed event
log_event(
"timer.resumed",
user_id=current_user.id,
time_entry_id=new_timer.id,
original_timer_id=timer_id,
project_id=project_id,
client_id=client_id,
task_id=task_id,
description=timer.notes,
)
track_event(
current_user.id,
"timer.resumed",
{
"time_entry_id": new_timer.id,
"original_timer_id": timer_id,
"project_id": project_id,
"client_id": client_id,
"task_id": task_id,
"has_notes": bool(timer.notes),
"has_tags": bool(timer.tags),
},
)
# Log activity
if project:
project_name = project.name
task = Task.query.get(task_id) if task_id else None
task_name = task.name if task else None
entity_name = f"{project_name}" + (f" - {task_name}" if task_name else "")
description = f"Resumed timer for {project_name}" + (f" - {task_name}" if task_name else "")
elif client:
client_name = client.name
entity_name = client_name
description = f"Resumed timer for {client_name}"
task_name = None
else:
entity_name = _("Unknown")
description = _("Resumed timer")
task_name = None
Activity.log(
user_id=current_user.id,
action="started",
entity_type="time_entry",
entity_id=new_timer.id,
entity_name=entity_name,
description=description,
extra_data={"project_id": project_id, "client_id": client_id, "task_id": task_id, "resumed_from": timer_id},
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
)
# Emit WebSocket event for real-time updates
try:
payload = {
"user_id": current_user.id,
"timer_id": new_timer.id,
"start_time": new_timer.start_time.isoformat(),
}
if project:
payload["project_name"] = project.name
if client:
payload["client_name"] = client.name
if task_id:
task = Task.query.get(task_id)
if task:
payload["task_id"] = task_id
payload["task_name"] = task.name
socketio.emit("timer_started", payload)
except Exception as e:
current_app.logger.warning("Socket emit failed for timer_resumed: %s", e)
# Invalidate dashboard cache so timer appears 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", current_user.id)
except Exception as e:
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
# Create success message
if project:
if task_name:
flash(f"Timer resumed for {project_name} - {task_name}", "success")
else:
flash(f"Timer resumed for {project_name}", "success")
elif client:
flash(f"Timer resumed for {client_name}", "success")
else:
flash(_("Timer resumed"), "success")
return redirect(url_for("main.dashboard"))
@timer_bp.route("/time-entries")
@login_required
def time_entries_overview():
"""Overview page showing all time entries with filters and bulk actions"""
from sqlalchemy import desc, func, or_
from sqlalchemy.orm import joinedload
from app.repositories import ProjectRepository, TimeEntryRepository, UserRepository
from app.utils.client_lock import enforce_locked_client_id
# Get filter parameters
user_id = request.args.get("user_id", type=int)
project_id = request.args.get("project_id", type=int)
client_id = request.args.get("client_id", type=int)
client_id = enforce_locked_client_id(client_id)
start_date = request.args.get("start_date", "")
end_date = request.args.get("end_date", "")
paid_filter = request.args.get("paid", "") # "true", "false", or ""
billable_filter = request.args.get("billable", "") # "true", "false", or ""
search = request.args.get("search", "").strip()
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 50, type=int)
# Get custom field filters for clients
# Format: custom_field_<field_key>=value
client_custom_field = {}
from app.models import CustomFieldDefinition
active_definitions = CustomFieldDefinition.get_active_definitions()
for definition in active_definitions:
field_value = request.args.get(f"custom_field_{definition.field_key}", "").strip()
if field_value:
client_custom_field[definition.field_key] = field_value
# Permission check: can user view all entries?
can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries")
# Build query with eager loading to avoid N+1 queries
query = TimeEntry.query.options(
joinedload(TimeEntry.user),
joinedload(TimeEntry.project),
joinedload(TimeEntry.client),
joinedload(TimeEntry.task),
).filter(
# Completed entries OR duration-only entries (duration_seconds set but end_time missing).
# This keeps duration-only manual logs visible even if end_time is absent for any reason.
or_(
TimeEntry.end_time.isnot(None),
db.and_(TimeEntry.duration_seconds.isnot(None), TimeEntry.source == TimeEntrySource.MANUAL.value),
)
)
# Filter by user
if user_id:
if can_view_all:
query = query.filter(TimeEntry.user_id == user_id)
elif user_id == current_user.id:
query = query.filter(TimeEntry.user_id == current_user.id)
else:
flash(_("You do not have permission to view other users' time entries"), "error")
return redirect(url_for("timer.time_entries_overview"))
elif not can_view_all:
# Non-admin users can only see their own entries
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 client
if client_id:
query = query.filter(TimeEntry.client_id == client_id)
# Filter by client custom fields
if client_custom_field:
# Join Client table to filter by custom fields
query = query.join(Client, TimeEntry.client_id == Client.id)
# Determine database type for custom field filtering
is_postgres = False
try:
from sqlalchemy import inspect
engine = db.engine
is_postgres = "postgresql" in str(engine.url).lower()
except Exception as e:
# Log but continue - database type detection failure is not critical
current_app.logger.debug(f"Failed to detect database type: {e}")
# Build custom field filter conditions
custom_field_conditions = []
for field_key, field_value in client_custom_field.items():
if not field_key or not field_value:
continue
if is_postgres:
# PostgreSQL: Use JSONB operators
try:
from sqlalchemy import String, cast
# Match exact value in custom_fields JSONB
custom_field_conditions.append(
db.cast(Client.custom_fields[field_key].astext, String) == str(field_value)
)
except Exception as e:
# Fallback to Python filtering if JSONB fails
current_app.logger.debug(
f"JSONB filtering failed for field {field_key}, will use Python filtering: {e}"
)
if custom_field_conditions:
query = query.filter(db.or_(*custom_field_conditions))
# Filter by date range
if start_date:
try:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(TimeEntry.start_time >= start_dt)
except ValueError:
pass
if end_date:
try:
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
# Include the entire end date
end_dt = end_dt.replace(hour=23, minute=59, second=59)
query = query.filter(TimeEntry.start_time <= end_dt)
except ValueError:
pass
# Filter by paid status
if paid_filter == "true":
query = query.filter(TimeEntry.paid == True)
elif paid_filter == "false":
query = query.filter(TimeEntry.paid == False)
# Filter by billable status
if billable_filter == "true":
query = query.filter(TimeEntry.billable == True)
elif billable_filter == "false":
query = query.filter(TimeEntry.billable == False)
# Search in notes and tags
if search:
search_pattern = f"%{search}%"
query = query.filter(or_(TimeEntry.notes.ilike(search_pattern), TimeEntry.tags.ilike(search_pattern)))
# Order by start time (most recent first)
query = query.order_by(desc(TimeEntry.start_time))
# Pagination
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
time_entries = pagination.items
# For SQLite or if JSONB filtering didn't work, filter by custom fields in Python
if client_custom_field:
try:
from sqlalchemy import inspect
engine = db.engine
is_postgres = "postgresql" in str(engine.url).lower()
if not is_postgres:
# SQLite: Filter in Python
filtered_entries = []
for entry in time_entries:
if not entry.client:
continue
# Check if client matches all custom field filters
matches = True
for field_key, field_value in client_custom_field.items():
if not field_key or not field_value:
continue
client_value = entry.client.custom_fields.get(field_key) if entry.client.custom_fields else None
if str(client_value) != str(field_value):
matches = False
break
if matches:
filtered_entries.append(entry)
# Update pagination with filtered results
time_entries = filtered_entries
# Recalculate pagination manually
total = len(filtered_entries)
start = (page - 1) * per_page
end = start + per_page
time_entries = filtered_entries[start:end]
# Create a pagination-like object
from flask_sqlalchemy import Pagination
pagination = Pagination(query=None, page=page, per_page=per_page, total=total, items=time_entries)
except Exception as e:
current_app.logger.warning("Time entries list filtering failed, using original results: %s", e)
# Get filter options
projects = []
clients = []
users = []
if can_view_all:
project_repo = ProjectRepository()
projects = project_repo.get_active_projects()
clients = Client.query.filter_by(status="active").order_by(Client.name).all()
user_repo = UserRepository()
users = user_repo.get_active_users()
else:
# For non-admin users, only show their projects
# Get projects from user's time entries
time_entry_repo = TimeEntryRepository()
user_project_ids = time_entry_repo.get_distinct_project_ids_for_user(current_user.id)
if user_project_ids:
projects = (
Project.query.filter(Project.id.in_(user_project_ids), Project.status == "active")
.order_by(Project.name)
.all()
)
# Get clients from user's projects
client_ids = set(p.client_id for p in projects if p.client_id)
if client_ids:
clients = (
Client.query.filter(Client.id.in_(client_ids), Client.status == "active")
.order_by(Client.name)
.all()
)
users = [current_user]
only_one_client = len(clients) == 1
single_client = clients[0] if only_one_client else None
# Calculate totals
total_hours = sum(entry.duration_hours for entry in time_entries)
total_billable_hours = sum(entry.duration_hours for entry in time_entries if entry.billable)
total_paid_hours = sum(entry.duration_hours for entry in time_entries if entry.paid)
# Track page view
track_event(
current_user.id,
"time_entries_overview.viewed",
{
"has_filters": bool(
user_id or project_id or client_id or start_date or end_date or paid_filter or billable_filter or search
),
"page": page,
"per_page": per_page,
},
)
filters_dict = {
"user_id": user_id,
"project_id": project_id,
"client_id": client_id,
"start_date": start_date,
"end_date": end_date,
"paid": paid_filter,
"billable": billable_filter,
"search": search,
"client_custom_field": client_custom_field,
"page": page,
"per_page": per_page,
}
# Build URL-safe filters for url_for (exclude dict and page; expand client_custom_field).
# Passing client_custom_field (a dict) or page into url_for breaks URL building and can
# cause 500s. Pagination links pass page explicitly, so we omit it here.
url_filters = {
k: v for k, v in filters_dict.items() if k not in ("client_custom_field", "page") and v is not None and v != ""
}
for k, v in (filters_dict.get("client_custom_field") or {}).items():
if v:
url_filters[f"custom_field_{k}"] = v
# Get custom field definitions for filter UI
from app.models import CustomFieldDefinition
custom_field_definitions = CustomFieldDefinition.get_active_definitions()
# Get link templates for invoice_number (for clickable values)
from sqlalchemy.exc import ProgrammingError
from app.models import LinkTemplate
link_templates_by_field = {}
try:
for template in LinkTemplate.get_active_templates():
if template.field_key == "invoice_number":
link_templates_by_field["invoice_number"] = template
except ProgrammingError as e:
# Handle case where link_templates table doesn't exist (migration not run)
if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower():
current_app.logger.warning("link_templates table does not exist. Run migration: flask db upgrade")
link_templates_by_field = {}
else:
raise
# Time entry approvals: which entries on this page have a pending approval?
from app.utils.module_helpers import is_module_enabled
time_approvals_enabled = is_module_enabled("time_approvals")
entry_ids_with_pending_approval = set()
if time_approvals_enabled and time_entries:
try:
from app.models.time_entry_approval import ApprovalStatus, TimeEntryApproval
entry_ids = [e.id for e in time_entries]
pending = TimeEntryApproval.query.filter(
TimeEntryApproval.time_entry_id.in_(entry_ids),
TimeEntryApproval.status == ApprovalStatus.PENDING,
).all()
entry_ids_with_pending_approval = {a.time_entry_id for a in pending}
except Exception:
entry_ids_with_pending_approval = set()
# Check if this is an AJAX request
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
# Return only the time entries list HTML for AJAX requests
from flask import make_response
response = make_response(
render_template(
"timer/_time_entries_list.html",
time_entries=time_entries,
pagination=pagination,
can_view_all=can_view_all,
filters=filters_dict,
url_filters=url_filters,
custom_field_definitions=custom_field_definitions,
link_templates_by_field=link_templates_by_field,
time_approvals_enabled=time_approvals_enabled,
entry_ids_with_pending_approval=entry_ids_with_pending_approval,
)
)
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response
return render_template(
"timer/time_entries_overview.html",
time_entries=time_entries,
pagination=pagination,
projects=projects,
clients=clients,
only_one_client=only_one_client,
single_client=single_client,
users=users,
can_view_all=can_view_all,
filters=filters_dict,
url_filters=url_filters,
custom_field_definitions=custom_field_definitions,
link_templates_by_field=link_templates_by_field,
time_approvals_enabled=time_approvals_enabled,
entry_ids_with_pending_approval=entry_ids_with_pending_approval,
totals={
"total_hours": round(total_hours, 2),
"total_billable_hours": round(total_billable_hours, 2),
"total_paid_hours": round(total_paid_hours, 2),
"total_entries": len(time_entries),
},
)
@timer_bp.route("/time-entries/export/csv")
@login_required
def export_time_entries_csv():
"""Export (filtered) time entries as CSV. Mirrors the /time-entries filters."""
import csv
import io
from flask import abort, send_file
from sqlalchemy import desc, or_
from sqlalchemy.orm import joinedload
from app.utils.client_lock import enforce_locked_client_id
# Get filter parameters (same as time_entries_overview)
user_id = request.args.get("user_id", type=int)
project_id = request.args.get("project_id", type=int)
client_id = request.args.get("client_id", type=int)
client_id = enforce_locked_client_id(client_id)
start_date = request.args.get("start_date", "")
end_date = request.args.get("end_date", "")
paid_filter = request.args.get("paid", "") # "true", "false", or ""
billable_filter = request.args.get("billable", "") # "true", "false", or ""
search = request.args.get("search", "").strip()
# Custom client-field filters
client_custom_field = {}
from app.models import CustomFieldDefinition
active_definitions = CustomFieldDefinition.get_active_definitions()
for definition in active_definitions:
field_value = request.args.get(f"custom_field_{definition.field_key}", "").strip()
if field_value:
client_custom_field[definition.field_key] = field_value
can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries")
query = TimeEntry.query.options(
joinedload(TimeEntry.user),
joinedload(TimeEntry.project),
joinedload(TimeEntry.client),
joinedload(TimeEntry.task),
).filter(
or_(
TimeEntry.end_time.isnot(None),
db.and_(TimeEntry.duration_seconds.isnot(None), TimeEntry.source == TimeEntrySource.MANUAL.value),
)
)
# Permission / user scoping
if user_id:
if can_view_all:
query = query.filter(TimeEntry.user_id == user_id)
elif user_id == current_user.id:
query = query.filter(TimeEntry.user_id == current_user.id)
else:
abort(403)
elif not can_view_all:
query = query.filter(TimeEntry.user_id == current_user.id)
if project_id:
query = query.filter(TimeEntry.project_id == project_id)
if client_id:
query = query.filter(TimeEntry.client_id == client_id)
# Client custom field filtering (mirrors overview behavior)
is_postgres = False
if client_custom_field:
query = query.join(Client, TimeEntry.client_id == Client.id)
try:
engine = db.engine
is_postgres = "postgresql" in str(engine.url).lower()
except Exception as e:
current_app.logger.debug("Failed to detect database type: %s", e)
if is_postgres:
custom_field_conditions = []
for field_key, field_value in client_custom_field.items():
if not field_key or not field_value:
continue
try:
from sqlalchemy import String, cast
custom_field_conditions.append(
db.cast(Client.custom_fields[field_key].astext, String) == str(field_value)
)
except Exception as e:
current_app.logger.debug(
"JSONB filtering failed for field %s, will use Python filtering: %s", field_key, e
)
if custom_field_conditions:
query = query.filter(db.or_(*custom_field_conditions))
# Date range
if start_date:
try:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(TimeEntry.start_time >= start_dt)
except ValueError:
pass
if end_date:
try:
end_dt = datetime.strptime(end_date, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
query = query.filter(TimeEntry.start_time <= end_dt)
except ValueError:
pass
# Paid/billable
if paid_filter == "true":
query = query.filter(TimeEntry.paid == True)
elif paid_filter == "false":
query = query.filter(TimeEntry.paid == False)
if billable_filter == "true":
query = query.filter(TimeEntry.billable == True)
elif billable_filter == "false":
query = query.filter(TimeEntry.billable == False)
# Search in notes/tags
if search:
search_pattern = f"%{search}%"
query = query.filter(or_(TimeEntry.notes.ilike(search_pattern), TimeEntry.tags.ilike(search_pattern)))
query = query.order_by(desc(TimeEntry.start_time))
entries = query.all()
# SQLite (or non-JSONB) custom-field filtering fallback (same semantics as overview)
if client_custom_field and not is_postgres:
filtered = []
for entry in entries:
if not entry.client:
continue
matches = True
for field_key, field_value in client_custom_field.items():
if not field_key or not field_value:
continue
client_value = entry.client.custom_fields.get(field_key) if entry.client.custom_fields else None
if str(client_value) != str(field_value):
matches = False
break
if matches:
filtered.append(entry)
entries = filtered
# CSV output (null-safe: user/project/client can be missing; wrap in try/except for Docker log visibility)
try:
settings = Settings.get_settings()
delimiter = getattr(settings, "export_delimiter", ",") or ","
output = io.StringIO()
writer = csv.writer(output, delimiter=delimiter)
writer.writerow(
[
"ID",
"User",
"Project",
"Client",
"Task",
"Start Time",
"End Time",
"Duration (hours)",
"Duration (formatted)",
"Notes",
"Tags",
"Source",
"Billable",
"Paid",
"Created At",
"Updated At",
]
)
for entry in entries:
# Project.client is a property returning the client name string
client_name = (entry.client.name if entry.client else "") or (entry.project.client if entry.project else "")
writer.writerow(
[
entry.id,
(entry.user.display_name if entry.user else ""),
(entry.project.name if entry.project else ""),
client_name,
(entry.task.name if entry.task else ""),
entry.start_time.isoformat() if entry.start_time else "",
entry.end_time.isoformat() if entry.end_time else "",
getattr(entry, "duration_hours", ""),
getattr(entry, "duration_formatted", ""),
entry.notes or "",
entry.tags or "",
entry.source or "",
"Yes" if entry.billable else "No",
"Yes" if entry.paid else "No",
entry.created_at.isoformat() if entry.created_at else "",
entry.updated_at.isoformat() if entry.updated_at else "",
]
)
csv_bytes = output.getvalue().encode("utf-8")
# Filename includes optional date range
start_part = start_date or "all"
end_part = end_date or "all"
filename = f"time_entries_{start_part}_to_{end_part}.csv"
return send_file(
io.BytesIO(csv_bytes),
mimetype="text/csv",
as_attachment=True,
download_name=filename,
)
except Exception:
current_app.logger.exception("CSV export failed (timer.export_time_entries_csv)")
raise
@timer_bp.route("/time-entries/export/pdf")
@login_required
def export_time_entries_pdf():
"""Export (filtered) time entries as PDF. Mirrors the /time-entries filters."""
import io
from flask import abort, send_file
from sqlalchemy import desc, or_
from sqlalchemy.orm import joinedload
from app.utils.client_lock import enforce_locked_client_id
# Get filter parameters (same as time_entries_overview)
user_id = request.args.get("user_id", type=int)
project_id = request.args.get("project_id", type=int)
client_id = request.args.get("client_id", type=int)
client_id = enforce_locked_client_id(client_id)
start_date = request.args.get("start_date", "")
end_date = request.args.get("end_date", "")
paid_filter = request.args.get("paid", "") # "true", "false", or ""
billable_filter = request.args.get("billable", "") # "true", "false", or ""
search = request.args.get("search", "").strip()
# Custom client-field filters
client_custom_field = {}
from app.models import CustomFieldDefinition
active_definitions = CustomFieldDefinition.get_active_definitions()
for definition in active_definitions:
field_value = request.args.get(f"custom_field_{definition.field_key}", "").strip()
if field_value:
client_custom_field[definition.field_key] = field_value
can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries")
query = TimeEntry.query.options(
joinedload(TimeEntry.user),
joinedload(TimeEntry.project),
joinedload(TimeEntry.client),
joinedload(TimeEntry.task),
).filter(
or_(
TimeEntry.end_time.isnot(None),
db.and_(TimeEntry.duration_seconds.isnot(None), TimeEntry.source == TimeEntrySource.MANUAL.value),
)
)
# Permission / user scoping
if user_id:
if can_view_all:
query = query.filter(TimeEntry.user_id == user_id)
elif user_id == current_user.id:
query = query.filter(TimeEntry.user_id == current_user.id)
else:
abort(403)
elif not can_view_all:
query = query.filter(TimeEntry.user_id == current_user.id)
if project_id:
query = query.filter(TimeEntry.project_id == project_id)
if client_id:
query = query.filter(TimeEntry.client_id == client_id)
# Client custom field filtering (same as CSV export)
is_postgres = False
if client_custom_field:
query = query.join(Client, TimeEntry.client_id == Client.id)
try:
engine = db.engine
is_postgres = "postgresql" in str(engine.url).lower()
except Exception as e:
current_app.logger.debug("Failed to detect database type: %s", e)
if is_postgres:
custom_field_conditions = []
for field_key, field_value in client_custom_field.items():
if not field_key or not field_value:
continue
try:
from sqlalchemy import String, cast
custom_field_conditions.append(
db.cast(Client.custom_fields[field_key].astext, String) == str(field_value)
)
except Exception as e:
current_app.logger.debug(
"JSONB filtering failed for field %s, will use Python filtering: %s", field_key, e
)
if custom_field_conditions:
query = query.filter(db.or_(*custom_field_conditions))
# Date range
if start_date:
try:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(TimeEntry.start_time >= start_dt)
except ValueError:
pass
if end_date:
try:
end_dt = datetime.strptime(end_date, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
query = query.filter(TimeEntry.start_time <= end_dt)
except ValueError:
pass
# Paid/billable
if paid_filter == "true":
query = query.filter(TimeEntry.paid == True)
elif paid_filter == "false":
query = query.filter(TimeEntry.paid == False)
if billable_filter == "true":
query = query.filter(TimeEntry.billable == True)
elif billable_filter == "false":
query = query.filter(TimeEntry.billable == False)
# Search in notes/tags
if search:
search_pattern = f"%{search}%"
query = query.filter(or_(TimeEntry.notes.ilike(search_pattern), TimeEntry.tags.ilike(search_pattern)))
query = query.order_by(desc(TimeEntry.start_time))
entries = query.all()
# SQLite (or non-JSONB) custom-field filtering fallback
if client_custom_field and not is_postgres:
filtered = []
for entry in entries:
if not entry.client:
continue
matches = True
for field_key, field_value in client_custom_field.items():
if not field_key or not field_value:
continue
client_value = entry.client.custom_fields.get(field_key) if entry.client.custom_fields else None
if str(client_value) != str(field_value):
matches = False
break
if matches:
filtered.append(entry)
entries = filtered
# Build filter context for the PDF report header
pdf_filters = {}
if user_id:
_pdf_user = User.query.get(user_id)
if _pdf_user:
pdf_filters["User"] = _pdf_user.username
if project_id:
_pdf_project = _project_service.get_by_id(project_id)
if _pdf_project:
pdf_filters["Project"] = _pdf_project.name
if client_id:
_pdf_client = _client_service.get_by_id(client_id)
if _pdf_client:
pdf_filters["Client"] = _pdf_client.name
if billable_filter:
pdf_filters["Billable"] = billable_filter
if paid_filter:
pdf_filters["Paid"] = paid_filter
# Generate professional PDF report with ReportLab.
try:
from app.utils.time_entries_pdf import build_time_entries_pdf
pdf_bytes = build_time_entries_pdf(
entries,
start_date=start_date or None,
end_date=end_date or None,
filters=pdf_filters if pdf_filters else None,
)
except Exception as e:
current_app.logger.warning("Time entries PDF export failed: %s", e, exc_info=True)
flash(_("PDF export failed: %(error)s", error=str(e)), "error")
return redirect(url_for("timer.time_entries_overview"))
# Filename includes optional date range
start_part = start_date or "all"
end_part = end_date or "all"
filename = f"time_entries_{start_part}_to_{end_part}.pdf"
return send_file(
io.BytesIO(pdf_bytes),
mimetype="application/pdf",
as_attachment=True,
download_name=filename,
)
@timer_bp.route("/time-entries/bulk-paid", methods=["POST"])
@login_required
def bulk_mark_paid():
"""Bulk mark time entries as paid or unpaid"""
from app.utils.db import safe_commit
entry_ids = request.form.getlist("entry_ids[]")
paid_status = request.form.get("paid", "").strip().lower()
invoice_reference = request.form.get("invoice_reference", "").strip()
if not entry_ids:
flash(_("No time entries selected"), "warning")
return redirect(url_for("timer.time_entries_overview"))
if paid_status not in ("true", "false"):
flash(_("Invalid paid status"), "error")
return redirect(url_for("timer.time_entries_overview"))
is_paid = paid_status == "true"
# Load entries
entry_ids_int = [int(eid) for eid in entry_ids if eid.isdigit()]
if not entry_ids_int:
flash(_("Invalid entry IDs"), "error")
return redirect(url_for("timer.time_entries_overview"))
entries = TimeEntry.query.filter(TimeEntry.id.in_(entry_ids_int)).all()
if not entries:
flash(_("No time entries found"), "error")
return redirect(url_for("timer.time_entries_overview"))
# Permission check
can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries")
updated_count = 0
skipped_count = 0
for entry in entries:
# Check permissions
if not can_view_all and entry.user_id != current_user.id:
skipped_count += 1
continue
# Skip active timers
if entry.is_active:
skipped_count += 1
continue
# Update paid status with invoice reference if provided
if is_paid and invoice_reference:
entry.set_paid(is_paid, invoice_number=invoice_reference)
else:
entry.set_paid(is_paid)
updated_count += 1
# Log activity
Activity.log(
user_id=current_user.id,
action="updated",
entity_type="time_entry",
entity_id=entry.id,
entity_name=f"Time entry #{entry.id}",
description=f"Marked time entry as {'paid' if is_paid else 'unpaid'}",
extra_data={"paid": is_paid, "project_id": entry.project_id, "client_id": entry.client_id},
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
)
if updated_count > 0:
if not safe_commit("bulk_mark_paid", {"count": updated_count, "paid": is_paid}):
flash(_("Could not update time entries due to a database error. Please check server logs."), "error")
return redirect(url_for("timer.time_entries_overview"))
flash(
_(
"Successfully marked %(count)d time entry/entries as %(status)s",
count=updated_count,
status=_("paid") if is_paid else _("unpaid"),
),
"success",
)
if skipped_count > 0:
flash(_("Skipped %(count)d time entry/entries (no permission or active timer)", count=skipped_count), "warning")
# Track event
track_event(current_user.id, "time_entries.bulk_mark_paid", {"count": updated_count, "paid": is_paid})
# Preserve filters in redirect
redirect_url = url_for("timer.time_entries_overview")
filters = {}
for key in ["user_id", "project_id", "client_id", "start_date", "end_date", "paid", "billable", "search", "page"]:
value = request.form.get(key) or request.args.get(key)
if value:
filters[key] = value
if filters:
redirect_url += "?" + "&".join(f"{k}={v}" for k, v in filters.items())
return redirect(redirect_url)