mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 12:50:11 -05:00
49a4a26b78
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.
3072 lines
119 KiB
Python
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)
|