Files
shelfmark/shelfmark/core/activity_routes.py
T
Alex 4b0d1aef13 Download history refactor pt3 (#706)
- Added canonical per-user visibility of requests and downloads via new
activity view table. Users get fully independent activity and history
views, while admins still see all.
- Replaces janky frontend + backend combination
2026-03-05 19:53:22 +00:00

701 lines
25 KiB
Python

"""Activity API routes (snapshot, dismiss, history)."""
from __future__ import annotations
from typing import Any, Callable, NamedTuple
from flask import Flask, jsonify, request, session
from shelfmark.core.activity_view_state_service import (
ADMIN_VIEWER_SCOPE,
NOAUTH_VIEWER_SCOPE,
ActivityViewStateService,
user_viewer_scope,
)
from shelfmark.core.download_history_service import ACTIVE_DOWNLOAD_STATUS, DownloadHistoryService, VALID_TERMINAL_STATUSES
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import ACTIVE_QUEUE_STATUSES, QueueStatus, TERMINAL_QUEUE_STATUSES
from shelfmark.core.request_validation import RequestStatus
from shelfmark.core.request_helpers import (
emit_ws_event,
extract_release_source_id,
normalize_positive_int,
populate_request_usernames,
)
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
def _require_authenticated(resolve_auth_mode: Callable[[], str]):
auth_mode = resolve_auth_mode()
if auth_mode == "none":
return None
if "user_id" not in session:
return jsonify({"error": "Unauthorized"}), 401
return None
def _resolve_db_user_id(
require_in_auth_mode: bool = True,
*,
user_db: UserDB | None = None,
):
raw_db_user_id = session.get("db_user_id")
if raw_db_user_id is None:
if not require_in_auth_mode:
return None, None
return None, (
jsonify(
{
"error": "User identity unavailable for activity workflow",
"code": "user_identity_unavailable",
}
),
403,
)
try:
parsed_db_user_id = int(raw_db_user_id)
except (TypeError, ValueError):
if not require_in_auth_mode:
return None, None
return None, (
jsonify(
{
"error": "User identity unavailable for activity workflow",
"code": "user_identity_unavailable",
}
),
403,
)
if parsed_db_user_id < 1:
if not require_in_auth_mode:
return None, None
return None, (
jsonify(
{
"error": "User identity unavailable for activity workflow",
"code": "user_identity_unavailable",
}
),
403,
)
if user_db is not None:
try:
db_user = user_db.get_user(user_id=parsed_db_user_id)
except Exception as exc:
logger.warning("Failed to validate activity db identity %s: %s", parsed_db_user_id, exc)
db_user = None
if db_user is None:
if not require_in_auth_mode:
return None, None
return None, (
jsonify(
{
"error": "User identity unavailable for activity workflow",
"code": "user_identity_unavailable",
}
),
403,
)
return parsed_db_user_id, None
class _ActorContext(NamedTuple):
db_user_id: int | None
is_no_auth: bool
is_admin: bool
owner_scope: int | None
viewer_scope: str
def _resolve_activity_actor(
*,
user_db: UserDB,
resolve_auth_mode: Callable[[], str],
) -> tuple[_ActorContext | None, Any | None]:
"""Resolve acting user identity for activity mutations.
Returns (actor, error_response). On success actor is non-None.
"""
if resolve_auth_mode() == "none":
return _ActorContext(
db_user_id=None,
is_no_auth=True,
is_admin=True,
owner_scope=None,
viewer_scope=NOAUTH_VIEWER_SCOPE,
), None
db_user_id, db_gate = _resolve_db_user_id(user_db=user_db)
if db_user_id is None:
return None, db_gate
is_admin = bool(session.get("is_admin"))
viewer_scope = ADMIN_VIEWER_SCOPE if is_admin else user_viewer_scope(db_user_id)
return _ActorContext(
db_user_id=db_user_id,
is_no_auth=False,
is_admin=is_admin,
owner_scope=None if is_admin else db_user_id,
viewer_scope=viewer_scope,
), None
def _activity_ws_room(actor: _ActorContext) -> str:
"""Resolve the WebSocket room for activity events."""
if actor.is_no_auth or actor.is_admin:
return "admins"
if actor.db_user_id is not None:
return f"user_{actor.db_user_id}"
return "admins"
def _check_item_ownership(actor: _ActorContext, row: dict[str, Any]) -> Any | None:
"""Return a 403 response if the actor doesn't own the item, else None."""
if actor.is_admin:
return None
owner_user_id = normalize_positive_int(row.get("user_id"))
if owner_user_id != actor.db_user_id:
return jsonify({"error": "Forbidden"}), 403
return None
def _check_terminal_download(row: dict[str, Any]) -> Any | None:
final_status = str(row.get("final_status") or "").strip().lower()
if final_status not in VALID_TERMINAL_STATUSES:
return jsonify({"error": "Only terminal downloads can be dismissed"}), 409
return None
def _check_terminal_request(row: dict[str, Any]) -> Any | None:
if _request_terminal_status(row) is None:
return jsonify({"error": "Only terminal requests can be dismissed"}), 409
return None
def _list_visible_requests(user_db: UserDB, *, is_admin: bool, db_user_id: int | None) -> list[dict[str, Any]]:
if is_admin:
request_rows = user_db.list_requests()
populate_request_usernames(request_rows, user_db)
return request_rows
if db_user_id is None:
return []
return user_db.list_requests(user_id=db_user_id)
def _parse_item_key(item_key: Any, prefix: str) -> str | None:
"""Extract the value after 'prefix:' from an item_key string."""
if not isinstance(item_key, str) or not item_key.startswith(f"{prefix}:"):
return None
value = item_key.split(":", 1)[1].strip()
return value or None
_ALL_BUCKET_KEYS = (*ACTIVE_QUEUE_STATUSES, *TERMINAL_QUEUE_STATUSES)
def _build_download_status_from_db(
*,
db_rows: list[dict[str, Any]],
queue_status: dict[str, dict[str, Any]],
) -> dict[str, dict[str, Any]]:
"""Build the download status dict from DB rows, overlaying live queue data.
Active DB rows are matched against the queue for live progress.
Terminal DB rows go directly into their final bucket.
Stale active rows (no queue entry) are treated as interrupted errors.
"""
status: dict[str, dict[str, Any]] = {key: {} for key in _ALL_BUCKET_KEYS}
# Index queue items by task_id for fast lookup: task_id -> (bucket_key, payload)
queue_index: dict[str, tuple[str, dict[str, Any]]] = {}
for bucket_key in _ALL_BUCKET_KEYS:
bucket = queue_status.get(bucket_key)
if not isinstance(bucket, dict):
continue
for task_id, payload in bucket.items():
queue_index[str(task_id)] = (bucket_key, payload)
for row in db_rows:
task_id = str(row.get("task_id") or "").strip()
if not task_id:
continue
final_status = row.get("final_status")
if final_status == ACTIVE_DOWNLOAD_STATUS:
queue_entry = queue_index.pop(task_id, None)
if queue_entry is not None:
bucket_key, queue_payload = queue_entry
status[bucket_key][task_id] = queue_payload
else:
# Stale active row — no queue entry means it was interrupted
download_payload = DownloadHistoryService.to_download_payload(row)
download_payload["status_message"] = "Interrupted"
status[QueueStatus.ERROR][task_id] = download_payload
elif final_status in VALID_TERMINAL_STATUSES:
download_payload = DownloadHistoryService.to_download_payload(row)
# For complete/cancelled the saved status_message is a stale
# progress string (e.g. "Fetching download sources") — clear it
# so the frontend only shows its own status label. Error rows
# keep theirs since the message describes the failure.
if final_status in ("complete", "cancelled"):
download_payload["status_message"] = None
status[final_status][task_id] = download_payload
return status
def _request_terminal_status(row: dict[str, Any]) -> str | None:
request_status = row.get("status")
if request_status == RequestStatus.PENDING:
return None
if request_status == RequestStatus.REJECTED:
return RequestStatus.REJECTED
if request_status == RequestStatus.CANCELLED:
return RequestStatus.CANCELLED
if request_status != RequestStatus.FULFILLED:
return None
delivery_state = str(row.get("delivery_state") or "").strip().lower()
if delivery_state in {QueueStatus.ERROR, QueueStatus.CANCELLED}:
return delivery_state
return QueueStatus.COMPLETE
def _minimal_request_snapshot(request_row: dict[str, Any], request_id: int) -> dict[str, Any]:
book_data = request_row.get("book_data")
release_data = request_row.get("release_data")
if not isinstance(book_data, dict):
book_data = {}
if not isinstance(release_data, dict):
release_data = {}
minimal_request = {
"id": request_id,
"user_id": request_row.get("user_id"),
"status": request_row.get("status"),
"request_level": request_row.get("request_level"),
"delivery_state": request_row.get("delivery_state"),
"book_data": book_data,
"release_data": release_data,
"note": request_row.get("note"),
"admin_note": request_row.get("admin_note"),
"created_at": request_row.get("created_at"),
"updated_at": request_row.get("reviewed_at") or request_row.get("created_at"),
}
username = request_row.get("username")
if isinstance(username, str):
minimal_request["username"] = username
return {"kind": "request", "request": minimal_request}
def _request_history_entry(
request_row: dict[str, Any],
*,
dismissed_at: str | None,
) -> dict[str, Any] | None:
request_id = normalize_positive_int(request_row.get("id"))
if request_id is None:
return None
final_status = _request_terminal_status(request_row)
item_key = f"request:{request_id}"
return {
"id": item_key,
"user_id": request_row.get("user_id"),
"item_type": "request",
"item_key": item_key,
"dismissed_at": dismissed_at,
"snapshot": _minimal_request_snapshot(request_row, request_id),
"origin": "request",
"final_status": final_status,
"terminal_at": request_row.get("reviewed_at") or request_row.get("created_at"),
"request_id": request_id,
"source_id": extract_release_source_id(request_row.get("release_data")),
}
def register_activity_routes(
app: Flask,
user_db: UserDB,
*,
activity_view_state_service: ActivityViewStateService,
download_history_service: DownloadHistoryService,
resolve_auth_mode: Callable[[], str],
queue_status: Callable[..., dict[str, dict[str, Any]]],
sync_request_delivery_states: Callable[..., list[dict[str, Any]]],
emit_request_updates: Callable[[list[dict[str, Any]]], None],
ws_manager: Any | None = None,
) -> None:
"""Register activity routes."""
@app.route("/api/activity/snapshot", methods=["GET"])
def api_activity_snapshot():
auth_gate = _require_authenticated(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
actor, actor_error = _resolve_activity_actor(
user_db=user_db,
resolve_auth_mode=resolve_auth_mode,
)
if actor_error is not None:
return actor_error
hidden_rows = activity_view_state_service.list_hidden(viewer_scope=actor.viewer_scope)
hidden_item_keys = {str(row.get("item_key") or "").strip() for row in hidden_rows}
dismissed_entries = [
{
"item_type": str(row.get("item_type") or "").strip().lower(),
"item_key": str(row.get("item_key") or "").strip(),
}
for row in hidden_rows
if str(row.get("item_type") or "").strip().lower() in {"download", "request"}
and str(row.get("item_key") or "").strip()
]
live_queue = queue_status(user_id=actor.owner_scope)
db_rows = download_history_service.list_recent(
user_id=actor.owner_scope,
limit=200,
)
visible_db_rows = [
row
for row in db_rows
if f"download:{str(row.get('task_id') or '').strip()}" not in hidden_item_keys
]
status = _build_download_status_from_db(
db_rows=visible_db_rows,
queue_status=live_queue,
)
updated_requests = sync_request_delivery_states(
user_db,
queue_status=status,
user_id=actor.owner_scope,
)
emit_request_updates(updated_requests)
request_rows = _list_visible_requests(
user_db,
is_admin=actor.is_admin,
db_user_id=actor.db_user_id,
)
visible_request_rows: list[dict[str, Any]] = []
for row in request_rows:
request_id = normalize_positive_int(row.get("id"))
if request_id is None:
continue
if f"request:{request_id}" in hidden_item_keys:
continue
visible_request_rows.append(row)
return jsonify(
{
"status": status,
"requests": visible_request_rows,
"dismissed": dismissed_entries,
}
)
@app.route("/api/activity/dismiss", methods=["POST"])
def api_activity_dismiss():
auth_gate = _require_authenticated(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
actor, actor_error = _resolve_activity_actor(
user_db=user_db,
resolve_auth_mode=resolve_auth_mode,
)
if actor_error is not None:
return actor_error
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"error": "Invalid payload"}), 400
item_type = str(data.get("item_type") or "").strip().lower()
item_key = data.get("item_key")
dismissal_item: dict[str, str] | None = None
if item_type == "download":
task_id = _parse_item_key(item_key, "download")
if task_id is None:
return jsonify({"error": "item_key must be in the format download:<task_id>"}), 400
existing = download_history_service.get_by_task_id(task_id)
if existing is None:
return jsonify({"error": "Download not found"}), 404
ownership_gate = _check_item_ownership(actor, existing)
if ownership_gate is not None:
return ownership_gate
terminal_gate = _check_terminal_download(existing)
if terminal_gate is not None:
return terminal_gate
activity_view_state_service.dismiss(
viewer_scope=actor.viewer_scope,
item_type="download",
item_key=f"download:{task_id}",
)
dismissal_item = {"item_type": "download", "item_key": f"download:{task_id}"}
elif item_type == "request":
request_id = normalize_positive_int(_parse_item_key(item_key, "request"))
if request_id is None:
return jsonify({"error": "item_key must be in the format request:<id>"}), 400
request_row = user_db.get_request(request_id)
if request_row is None:
return jsonify({"error": "Request not found"}), 404
ownership_gate = _check_item_ownership(actor, request_row)
if ownership_gate is not None:
return ownership_gate
terminal_gate = _check_terminal_request(request_row)
if terminal_gate is not None:
return terminal_gate
activity_view_state_service.dismiss(
viewer_scope=actor.viewer_scope,
item_type="request",
item_key=f"request:{request_id}",
)
dismissal_item = {"item_type": "request", "item_key": f"request:{request_id}"}
else:
return jsonify({"error": "item_type must be one of: download, request"}), 400
room = _activity_ws_room(actor)
emit_ws_event(
ws_manager,
event_name="activity_update",
room=room,
payload={
"kind": "dismiss",
"item_type": dismissal_item["item_type"],
"item_key": dismissal_item["item_key"],
},
)
return jsonify({"status": "dismissed", "item": dismissal_item})
@app.route("/api/activity/dismiss-many", methods=["POST"])
def api_activity_dismiss_many():
auth_gate = _require_authenticated(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
actor, actor_error = _resolve_activity_actor(
user_db=user_db,
resolve_auth_mode=resolve_auth_mode,
)
if actor_error is not None:
return actor_error
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"error": "Invalid payload"}), 400
items = data.get("items")
if not isinstance(items, list):
return jsonify({"error": "items must be an array"}), 400
dismissal_items: list[dict[str, str]] = []
missing_item_keys: list[str] = []
for item in items:
if not isinstance(item, dict):
return jsonify({"error": "items must contain objects"}), 400
item_type = str(item.get("item_type") or "").strip().lower()
item_key = item.get("item_key")
if item_type == "download":
task_id = _parse_item_key(item_key, "download")
if task_id is None:
return jsonify({"error": "download item_key must be in the format download:<task_id>"}), 400
existing = download_history_service.get_by_task_id(task_id)
if existing is None:
missing_item_keys.append(f"download:{task_id}")
continue
ownership_gate = _check_item_ownership(actor, existing)
if ownership_gate is not None:
return ownership_gate
terminal_gate = _check_terminal_download(existing)
if terminal_gate is not None:
return terminal_gate
dismissal_items.append({"item_type": "download", "item_key": f"download:{task_id}"})
continue
if item_type == "request":
request_id = normalize_positive_int(_parse_item_key(item_key, "request"))
if request_id is None:
return jsonify({"error": "request item_key must be in the format request:<id>"}), 400
request_row = user_db.get_request(request_id)
if request_row is None:
missing_item_keys.append(f"request:{request_id}")
continue
ownership_gate = _check_item_ownership(actor, request_row)
if ownership_gate is not None:
return ownership_gate
terminal_gate = _check_terminal_request(request_row)
if terminal_gate is not None:
return terminal_gate
dismissal_items.append({"item_type": "request", "item_key": f"request:{request_id}"})
continue
return jsonify({"error": "item_type must be one of: download, request"}), 400
if missing_item_keys:
return (
jsonify(
{
"error": "One or more activity items were not found",
"missing_item_keys": missing_item_keys,
}
),
404,
)
dismissed_count = activity_view_state_service.dismiss_many(
viewer_scope=actor.viewer_scope,
items=dismissal_items,
)
room = _activity_ws_room(actor)
emit_ws_event(
ws_manager,
event_name="activity_update",
room=room,
payload={
"kind": "dismiss_many",
"count": dismissed_count,
},
)
return jsonify({"status": "dismissed", "count": dismissed_count})
@app.route("/api/activity/history", methods=["GET"])
def api_activity_history():
auth_gate = _require_authenticated(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
actor, actor_error = _resolve_activity_actor(
user_db=user_db,
resolve_auth_mode=resolve_auth_mode,
)
if actor_error is not None:
return actor_error
limit = request.args.get("limit", type=int, default=50)
offset = request.args.get("offset", type=int, default=0)
if limit is None:
limit = 50
if offset is None:
offset = 0
if limit < 1:
return jsonify({"error": "limit must be a positive integer"}), 400
if offset < 0:
return jsonify({"error": "offset must be a non-negative integer"}), 400
history_rows = activity_view_state_service.list_history(
viewer_scope=actor.viewer_scope,
limit=limit,
offset=offset,
)
payload: list[dict[str, Any]] = []
for history_row in history_rows:
item_type = str(history_row.get("item_type") or "").strip().lower()
item_key = str(history_row.get("item_key") or "").strip()
dismissed_at = history_row.get("dismissed_at")
if not isinstance(dismissed_at, str) or not dismissed_at.strip():
raise RuntimeError(f"Activity history state missing dismissed_at for {item_key}")
if item_type == "download":
task_id = _parse_item_key(item_key, "download")
if task_id is None:
raise RuntimeError(f"Invalid activity history item_key: {item_key}")
download_row = download_history_service.get_by_task_id(task_id)
if download_row is None:
raise RuntimeError(f"Download history row not found for {item_key}")
if not actor.is_admin:
owner_user_id = normalize_positive_int(download_row.get("user_id"))
if owner_user_id != actor.db_user_id:
raise RuntimeError(f"Viewer state out of scope for {item_key}")
payload.append(
DownloadHistoryService.to_history_row(
download_row,
dismissed_at=dismissed_at,
)
)
continue
if item_type == "request":
request_id = normalize_positive_int(_parse_item_key(item_key, "request"))
if request_id is None:
raise RuntimeError(f"Invalid activity history item_key: {item_key}")
request_row = user_db.get_request(request_id)
if request_row is None:
raise RuntimeError(f"Request row not found for {item_key}")
if not actor.is_admin:
owner_user_id = normalize_positive_int(request_row.get("user_id"))
if owner_user_id != actor.db_user_id:
raise RuntimeError(f"Viewer state out of scope for {item_key}")
populate_request_usernames([request_row], user_db)
entry = _request_history_entry(
request_row,
dismissed_at=dismissed_at,
)
if entry is None:
raise RuntimeError(f"Failed to build request history entry for {item_key}")
payload.append(entry)
continue
raise RuntimeError(f"Unknown activity history item_type: {item_type}")
return jsonify(payload)
@app.route("/api/activity/history", methods=["DELETE"])
def api_activity_history_clear():
auth_gate = _require_authenticated(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
actor, actor_error = _resolve_activity_actor(
user_db=user_db,
resolve_auth_mode=resolve_auth_mode,
)
if actor_error is not None:
return actor_error
cleared_count = activity_view_state_service.clear_history(
viewer_scope=actor.viewer_scope,
)
room = _activity_ws_room(actor)
emit_ws_event(
ws_manager,
event_name="activity_update",
room=room,
payload={
"kind": "history_cleared",
"count": cleared_count,
},
)
return jsonify({"status": "cleared", "cleared_count": cleared_count})