Files
TimeTracker/app/services/time_entry_bulk_service.py
T
Dries Peeters 5df748b30e feat(api): v1 CSV import, bulk time entries, idempotency, and rate limits
- Add POST /api/v1/time-entries/import-csv and POST /api/v1/time-entries/bulk.
- Support Idempotency-Key on POST /api/v1/time-entries with 24h replay per token.
- Enforce per-token minute/hour limits (Redis when available, local fallback otherwise).
- Extract bulk and CSV import logic into dedicated services; trim legacy api route surface.
2026-04-05 08:39:14 +02:00

94 lines
3.1 KiB
Python

"""Bulk time entry actions shared by legacy session API and API v1."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from app import db
from app.models import TimeEntry
from app.models.time_entry import local_now
from app.utils.db import safe_commit
def apply_bulk_time_entry_actions(
entry_ids: List[int],
action: str,
value: Any,
*,
user_id: int,
is_admin: bool,
) -> Dict[str, Any]:
"""
Apply bulk action to time entries. Same rules as legacy /api/entries/bulk.
Returns dict with keys: success (bool), affected (int), error (optional str),
http_status (int).
"""
if not entry_ids:
return {"success": False, "error": "entry_ids must be a non-empty list", "http_status": 400}
if action not in {"delete", "set_billable", "set_paid", "add_tag", "remove_tag"}:
return {"success": False, "error": "Unsupported action", "http_status": 400}
q = TimeEntry.query.filter(TimeEntry.id.in_(entry_ids))
entries = q.all()
if not entries:
return {"success": False, "error": "No entries found", "http_status": 404}
if not is_admin:
for e in entries:
if e.user_id != user_id:
return {"success": False, "error": "Access denied for one or more entries", "http_status": 403}
affected = 0
if action == "delete":
for e in entries:
if e.is_active:
continue
db.session.delete(e)
affected += 1
elif action == "set_billable":
flag = bool(value)
for e in entries:
if e.is_active:
continue
e.billable = flag
e.updated_at = local_now()
affected += 1
elif action == "set_paid":
flag = bool(value)
for e in entries:
if e.is_active:
continue
e.set_paid(flag)
affected += 1
elif action in {"add_tag", "remove_tag"}:
tag = (value or "").strip() if value is not None else ""
if not tag:
return {"success": False, "error": "Tag value is required", "http_status": 400}
for e in entries:
if e.is_active:
continue
tags = set(e.tag_list)
if action == "add_tag":
tags.add(tag)
else:
tags.discard(tag)
e.tags = ", ".join(sorted(tags)) if tags else None
e.updated_at = local_now()
affected += 1
if affected > 0:
if not safe_commit("bulk_time_entries", {"action": action, "count": affected}):
return {"success": False, "error": "Database error during bulk operation", "http_status": 500}
else:
db.session.rollback()
if entries:
return {
"success": False,
"error": "No entries were updated; active (running) time entries cannot be changed with this bulk action",
"http_status": 400,
"affected": 0,
}
return {"success": True, "affected": affected, "http_status": 200}