mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 20:29:44 -05:00
5df748b30e
- 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.
78 lines
2.2 KiB
Python
78 lines
2.2 KiB
Python
"""Idempotency-Key header handling for API v1 writes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from typing import Any, Optional, Tuple
|
|
|
|
from flask import Response, jsonify
|
|
|
|
from app import db
|
|
from app.models.api_idempotency_key import ApiIdempotencyKey
|
|
from app.utils.db import safe_commit
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
IDEMPOTENCY_TTL_HOURS = 24
|
|
MAX_KEY_LEN = 128
|
|
SCOPE_POST_TIME_ENTRY = "post:time_entries"
|
|
|
|
|
|
def _hash_key(raw: str) -> str:
|
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def normalize_idempotency_key(header_value: Optional[str]) -> Optional[str]:
|
|
if not header_value or not isinstance(header_value, str):
|
|
return None
|
|
s = header_value.strip()
|
|
if not s or len(s) > MAX_KEY_LEN:
|
|
return None
|
|
return s
|
|
|
|
|
|
def lookup_idempotent_response(api_token_id: int, scope: str, key: str) -> Optional[Tuple[int, str]]:
|
|
row = ApiIdempotencyKey.query.filter_by(
|
|
api_token_id=api_token_id,
|
|
scope=scope,
|
|
key_hash=_hash_key(key),
|
|
).first()
|
|
if not row:
|
|
return None
|
|
cutoff = datetime.utcnow() - timedelta(hours=IDEMPOTENCY_TTL_HOURS)
|
|
if row.created_at < cutoff:
|
|
try:
|
|
db.session.delete(row)
|
|
safe_commit("idempotency_expired_cleanup", {})
|
|
except Exception as e:
|
|
logger.debug("Idempotency cleanup: %s", e)
|
|
return None
|
|
return row.response_status, row.response_body
|
|
|
|
|
|
def store_idempotent_response(api_token_id: int, scope: str, key: str, status_code: int, body_dict: Any) -> None:
|
|
body_json = json.dumps(body_dict, default=str)
|
|
row = ApiIdempotencyKey(
|
|
api_token_id=api_token_id,
|
|
scope=scope,
|
|
key_hash=_hash_key(key),
|
|
response_status=status_code,
|
|
response_body=body_json,
|
|
)
|
|
db.session.add(row)
|
|
if not safe_commit("api_idempotency_store", {"scope": scope}):
|
|
logger.warning("Failed to store idempotency key for token %s", api_token_id)
|
|
|
|
|
|
def replay_response(status_code: int, body_json: str) -> Response:
|
|
try:
|
|
data = json.loads(body_json)
|
|
except Exception:
|
|
data = {"message": body_json}
|
|
resp = jsonify(data)
|
|
resp.status_code = status_code
|
|
return resp
|