work-tracing/app/api/events.py
2025-05-16 17:55:30 +04:00

213 lines
7.6 KiB
Python

"""
API endpoints for reporting user activity events.
This module provides endpoints for clients to report state changes (working/stopped).
"""
from datetime import datetime, timezone # Removed timedelta, timezone
from flask import Blueprint, current_app, jsonify, request
from sqlalchemy.exc import SQLAlchemyError
from app import db
from app.models import WorkEvent
# Create a blueprint for event-related API endpoints
events_bp = Blueprint("events", __name__, url_prefix="/api")
@events_bp.route("/report", methods=["POST"])
def report_event():
"""
Endpoint for clients to report activity state changes.
All timestamps are expected to be in UTC if provided by the client,
and will be stored as UTC (or DB equivalent default).
Expected JSON payload:
{
"user": "username",
"state": "working|stopped",
"ts": "2023-07-08T12:30:45Z" (optional, ISO 8601 UTC)
}
"""
data = request.get_json()
current_app.logger.info(f"Received report request: {data}")
if not data or "user" not in data or "state" not in data:
current_app.logger.warning(
"Invalid report request payload: Missing required fields."
)
return (
jsonify(
{"success": False, "message": "Missing required fields: user, state"}
),
400,
)
if data["state"] not in ["working", "stopped"]:
current_app.logger.warning(
f"Invalid state value '{data['state']}' in report request."
)
return (
jsonify(
{
"success": False,
"message": 'Invalid state value. Must be "working" or "stopped"',
}
),
400,
)
user = data["user"]
state = data["state"]
ts_str = data.get("ts")
event_ts = None
if ts_str:
try:
# Ensure 'Z' is replaced with +00:00 for full ISO compatibility if needed by fromisoformat version
if ts_str.endswith("Z"):
ts_str = ts_str[:-1] + "+00:00"
event_ts = datetime.fromisoformat(ts_str)
# Ensure the parsed timestamp is UTC. If it has an offset, convert to UTC.
if (
event_ts.tzinfo is not None
and event_ts.tzinfo.utcoffset(event_ts) is not None
):
event_ts = event_ts.astimezone(timezone.utc)
else:
# If timezone naive, assume UTC as per API contract (or handle as error)
# For this implementation, we'll assume naive means UTC if client sends it.
# Alternatively, enforce client sends tz-aware string, or reject naive.
current_app.logger.debug(
f"Received naive timestamp {ts_str}, assuming UTC."
)
current_app.logger.info(
f"Using client-provided timestamp (UTC): {event_ts}"
)
except ValueError:
current_app.logger.warning(
f"Invalid timestamp format received: {ts_str}. Returning error."
)
return (
jsonify(
{
"success": False,
"message": "Invalid timestamp format. Please use ISO 8601 UTC (e.g., YYYY-MM-DDTHH:MM:SSZ).",
}
),
400,
)
else:
event_ts = datetime.utcnow()
current_app.logger.info(
f"No client timestamp provided, using current UTC time: {event_ts}"
)
# At this point, event_ts should be an aware UTC datetime object or naive UTC to be stored.
# If database stores naive timestamps and assumes UTC (common for SQLite), ensure event_ts is naive UTC.
# If database stores aware timestamps (common for PostgreSQL with timestamptz), event_ts should be aware UTC.
#
# IMPORTANT NOTE ABOUT TIMEZONE HANDLING:
# - Client-side scripts send timestamps in UTC (with Z suffix in ISO format)
# - Backend processes all timestamps in UTC format
# - PostgreSQL database session is set to Asia/Dubai timezone (UTC+4)
# - When storing timestamps, PostgreSQL converts from UTC to Asia/Dubai
# - When retrieving timestamps, PostgreSQL returns in Asia/Dubai timezone
# - The frontend displays all times in GMT+4 (Asia/Dubai) using formatTimeToGMT4()
#
# This ensures consistent timezone handling throughout the application stack.
current_app.logger.debug(f"Storing event with timestamp (UTC): {event_ts}")
new_event = WorkEvent(user=user, state=state, ts=event_ts)
try:
current_app.logger.info(
f"Attempting to add event to database: User={user}, State={state}, TS={event_ts}"
)
db.session.add(new_event)
db.session.commit()
current_app.logger.info(
f"Successfully recorded event: User={user}, State={state}"
)
return jsonify({"success": True}), 201
except SQLAlchemyError as e:
db.session.rollback()
current_app.logger.error(f"Database error while recording event: {e}")
return jsonify({"success": False, "message": "Database error"}), 500
@events_bp.route("/user_status_update", methods=["POST"])
def update_user_status():
"""
Endpoint for updating a user's status, typically when a timeout occurs.
Expects JSON: {"user_id": "username", "status": "not working"}
This will result in a 'stopped' event being logged for the user.
"""
data = request.get_json()
current_app.logger.info(f"Received user status update request: {data}")
if not data or "user_id" not in data or "status" not in data:
current_app.logger.warning(
"Invalid user status update payload: Missing required fields."
)
return (
jsonify(
{
"success": False,
"message": "Missing required fields: user_id, status",
}
),
400,
)
user_id = data["user_id"]
status = data["status"]
if status != "not working":
current_app.logger.warning(
f"Invalid status value '{status}' in user status update for user {user_id}. Expected 'not working'."
)
return (
jsonify(
{
"success": False,
"message": 'Invalid status value. Must be "not working"',
}
),
400,
)
# Map "not working" to "stopped" for WorkEvent consistency
event_state = "stopped"
event_ts = datetime.utcnow()
new_event = WorkEvent(user=user_id, state=event_state, ts=event_ts)
try:
current_app.logger.info(
f"Attempting to add '{event_state}' event to database for user {user_id} from status update."
)
db.session.add(new_event)
db.session.commit()
current_app.logger.info(
f"Successfully recorded '{event_state}' event for user {user_id} from status update."
)
return (
jsonify(
{
"success": True,
"message": "User status successfully updated to stopped.",
}
),
201,
)
except SQLAlchemyError as e:
db.session.rollback()
current_app.logger.error(
f"Database error while recording '{event_state}' event for user {user_id} from status update: {e}"
)
return (
jsonify({"success": False, "message": "Database error processing request"}),
500,
)