diff --git a/shelfmark/config/users_settings.py b/shelfmark/config/users_settings.py index ec7343f..ad78560 100644 --- a/shelfmark/config/users_settings.py +++ b/shelfmark/config/users_settings.py @@ -26,22 +26,22 @@ _REQUEST_DEFAULT_MODE_OPTIONS = [ { "value": "download", "label": "Download", - "description": "Allow direct downloads.", + "description": "Everything can be downloaded directly.", }, { "value": "request_release", "label": "Request Release", - "description": "Block direct download; allow requesting a specific release.", + "description": "Users must request a specific release.", }, { "value": "request_book", "label": "Request Book", - "description": "Block direct download; allow book-level requests only.", + "description": "Users request a book, admin picks the release.", }, { "value": "blocked", "label": "Blocked", - "description": "Block both downloading and requesting.", + "description": "No downloads or requests allowed.", }, ] @@ -179,33 +179,18 @@ def users_settings(): key="users_management", component="users_management", ), - HeadingField( - key="users_access_heading", - title="Options", - ), - CheckboxField( - key="RESTRICT_SETTINGS_TO_ADMIN", - label="Restrict Settings and Onboarding to Admins", - description=( - "When enabled, only admin users can access Settings and Onboarding. " - "When disabled, any authenticated user can access them. " - "Security and Users are always admin-only." - ), - default=True, - env_supported=False, - ), HeadingField( key="requests_heading", - title="Request Policy", + title="Requests", description=( - "Configure when users can download directly and when they must create requests." + "Choose what users can download directly and what needs approval first." ), ), CheckboxField( key="REQUESTS_ENABLED", - label="Enable Request Workflow", + label="Enable Requests", description=( - "When disabled, request actions are hidden and only direct downloads are used." + "Turn this off to let everyone download directly without needing approval." ), default=False, user_overridable=True, @@ -213,9 +198,9 @@ def users_settings(): CustomComponentField( key="request_policy_editor", component="request_policy_grid", - label="Request Policy Rules", + label="Request Rules", description=( - "Source/content-type rules can only restrict the content-type default ceiling." + "Fine-tune access per source. Source rules can only be the same or more restrictive than the default above." ), show_when={"field": "REQUESTS_ENABLED", "value": True}, wrap_in_field_wrapper=True, @@ -224,7 +209,7 @@ def users_settings(): key="REQUEST_POLICY_DEFAULT_EBOOK", label="Default Ebook Mode", description=( - "Global ceiling for ebook actions. Source rules can only match or restrict this mode." + "Sets the baseline for all ebook sources." ), options=_REQUEST_DEFAULT_MODE_OPTIONS, default="download", @@ -234,7 +219,7 @@ def users_settings(): key="REQUEST_POLICY_DEFAULT_AUDIOBOOK", label="Default Audiobook Mode", description=( - "Global ceiling for audiobook actions. Source rules can only match or restrict this mode." + "Sets the baseline for all audiobook sources." ), options=_REQUEST_DEFAULT_MODE_OPTIONS, default="download", @@ -242,9 +227,9 @@ def users_settings(): ), TableField( key="REQUEST_POLICY_RULES", - label="Request Policy Rules", + label="Request Rules", description=( - "Source/content-type rules can only restrict the content-type default ceiling." + "Fine-tune access per source. Source rules can only be the same or more restrictive than the default above." ), columns=_get_request_policy_rule_columns, default=[], @@ -257,8 +242,8 @@ def users_settings(): ), NumberField( key="MAX_PENDING_REQUESTS_PER_USER", - label="Max Pending Requests Per User", - description="Maximum number of pending requests a user can have at once.", + label="Max pending requests per user", + description="How many open requests a user can have at a time.", default=20, min_value=1, max_value=1000, @@ -267,8 +252,8 @@ def users_settings(): ), CheckboxField( key="REQUESTS_ALLOW_NOTES", - label="Allow Request Notes", - description="Allow users to include notes when creating requests.", + label="Allow notes on requests", + description="Let users add a note when they submit a request.", default=True, user_overridable=True, show_when={"field": "REQUESTS_ENABLED", "value": True}, diff --git a/shelfmark/core/activity_routes.py b/shelfmark/core/activity_routes.py new file mode 100644 index 0000000..ce1a8f2 --- /dev/null +++ b/shelfmark/core/activity_routes.py @@ -0,0 +1,486 @@ +"""Activity API routes (snapshot, dismiss, history).""" + +from __future__ import annotations + +from typing import Any, Callable + +from flask import Flask, jsonify, request, session + +from shelfmark.core.activity_service import ActivityService +from shelfmark.core.logger import setup_logger +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): + 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: + return int(raw_db_user_id), None + except (TypeError, ValueError): + return None, ( + jsonify( + { + "error": "User identity unavailable for activity workflow", + "code": "user_identity_unavailable", + } + ), + 403, + ) + + +def _emit_activity_event(ws_manager: Any | None, *, room: str, payload: dict[str, Any]) -> None: + if ws_manager is None: + return + try: + socketio = getattr(ws_manager, "socketio", None) + is_enabled = getattr(ws_manager, "is_enabled", None) + if socketio is None or not callable(is_enabled) or not is_enabled(): + return + socketio.emit("activity_update", payload, to=room) + except Exception as exc: + logger.warning("Failed to emit activity_update event: %s", exc) + + +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() + user_cache: dict[int, str] = {} + for row in request_rows: + requester_id = row["user_id"] + if requester_id not in user_cache: + requester = user_db.get_user(user_id=requester_id) + user_cache[requester_id] = requester.get("username", "") if requester else "" + row["username"] = user_cache[requester_id] + return request_rows + + if db_user_id is None: + return [] + return user_db.list_requests(user_id=db_user_id) + + +def _parse_download_item_key(item_key: str) -> str | None: + if not isinstance(item_key, str) or not item_key.startswith("download:"): + return None + task_id = item_key.split(":", 1)[1].strip() + return task_id or None + + +def _parse_request_item_key(item_key: str) -> int | None: + if not isinstance(item_key, str) or not item_key.startswith("request:"): + return None + raw_id = item_key.split(":", 1)[1].strip() + try: + parsed = int(raw_id) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + +def _task_id_from_download_item_key(item_key: str) -> str | None: + task_id = _parse_download_item_key(item_key) + if task_id is None: + return None + return task_id + + +def _merge_terminal_snapshot_backfill( + *, + status: dict[str, dict[str, Any]], + terminal_rows: list[dict[str, Any]], +) -> None: + existing_task_ids: set[str] = set() + for bucket_key in ("queued", "resolving", "locating", "downloading", "complete", "error", "cancelled"): + bucket = status.get(bucket_key) + if not isinstance(bucket, dict): + continue + existing_task_ids.update(str(task_id) for task_id in bucket.keys()) + + for row in terminal_rows: + item_key = row.get("item_key") + if not isinstance(item_key, str): + continue + task_id = _task_id_from_download_item_key(item_key) + if not task_id or task_id in existing_task_ids: + continue + + final_status = row.get("final_status") + if final_status not in {"complete", "error", "cancelled"}: + continue + + snapshot = row.get("snapshot") + if not isinstance(snapshot, dict): + continue + raw_download = snapshot.get("download") + if not isinstance(raw_download, dict): + continue + + download_payload = dict(raw_download) + if not isinstance(download_payload.get("id"), str): + download_payload["id"] = task_id + + if final_status not in status or not isinstance(status.get(final_status), dict): + status[final_status] = {} + status[final_status][task_id] = download_payload + existing_task_ids.add(task_id) + + +def _collect_active_download_item_keys(status: dict[str, dict[str, Any]]) -> set[str]: + active_keys: set[str] = set() + for bucket_key in ("queued", "resolving", "locating", "downloading"): + bucket = status.get(bucket_key) + if not isinstance(bucket, dict): + continue + for task_id in bucket.keys(): + normalized_task_id = str(task_id).strip() + if not normalized_task_id: + continue + active_keys.add(f"download:{normalized_task_id}") + return active_keys + + +def _extract_request_source_id(row: dict[str, Any]) -> str | None: + release_data = row.get("release_data") + if not isinstance(release_data, dict): + return None + source_id = release_data.get("source_id") + if not isinstance(source_id, str): + return None + normalized = source_id.strip() + return normalized or None + + +def _request_terminal_status(row: dict[str, Any]) -> str | None: + request_status = row.get("status") + if request_status == "pending": + return None + if request_status == "rejected": + return "rejected" + if request_status == "cancelled": + return "cancelled" + if request_status != "fulfilled": + return None + + delivery_state = str(row.get("delivery_state") or "").strip().lower() + if delivery_state in {"error", "cancelled"}: + return delivery_state + return "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("updated_at"), + } + username = request_row.get("username") + if isinstance(username, str): + minimal_request["username"] = username + return {"kind": "request", "request": minimal_request} + + +def _get_existing_activity_log_id_for_item( + *, + activity_service: ActivityService, + user_db: UserDB, + item_type: str, + item_key: str, +) -> int | None: + if item_type not in {"request", "download"}: + return None + if not isinstance(item_key, str) or not item_key.strip(): + return None + + existing_log_id = activity_service.get_latest_activity_log_id( + item_type=item_type, + item_key=item_key, + ) + if existing_log_id is not None or item_type != "request": + return existing_log_id + + request_id = _parse_request_item_key(item_key) + if request_id is None: + return None + row = user_db.get_request(request_id) + if row is None: + return None + + final_status = _request_terminal_status(row) + if final_status is None: + return None + + source_id = _extract_request_source_id(row) + payload = activity_service.record_terminal_snapshot( + user_id=row.get("user_id"), + item_type="request", + item_key=item_key, + origin="request", + final_status=final_status, + snapshot=_minimal_request_snapshot(row, request_id), + request_id=request_id, + source_id=source_id, + ) + return int(payload["id"]) + + +def register_activity_routes( + app: Flask, + user_db: UserDB, + *, + activity_service: ActivityService, + resolve_auth_mode: Callable[[], str], + resolve_status_scope: Callable[[], tuple[bool, int | None, bool]], + 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 + + is_admin, db_user_id, can_access_status = resolve_status_scope() + if not can_access_status: + return ( + jsonify( + { + "error": "User identity unavailable for activity workflow", + "code": "user_identity_unavailable", + } + ), + 403, + ) + + viewer_db_user_id, _ = _resolve_db_user_id(require_in_auth_mode=False) + scoped_user_id = None if is_admin else db_user_id + status = queue_status(user_id=scoped_user_id) + updated_requests = sync_request_delivery_states( + user_db, + queue_status=status, + user_id=scoped_user_id, + ) + emit_request_updates(updated_requests) + request_rows = _list_visible_requests(user_db, is_admin=is_admin, db_user_id=db_user_id) + + if not is_admin and db_user_id is not None: + try: + terminal_rows = activity_service.get_undismissed_terminal_downloads(db_user_id, limit=200) + _merge_terminal_snapshot_backfill(status=status, terminal_rows=terminal_rows) + except Exception as exc: + logger.warning("Failed to merge terminal snapshot backfill rows: %s", exc) + + if viewer_db_user_id is not None: + active_download_keys = _collect_active_download_item_keys(status) + if active_download_keys: + try: + activity_service.clear_dismissals_for_item_keys( + user_id=viewer_db_user_id, + item_type="download", + item_keys=active_download_keys, + ) + except Exception as exc: + logger.warning("Failed to clear stale download dismissals for active tasks: %s", exc) + + dismissed: list[dict[str, str]] = [] + # Admins can view unscoped queue status, but dismissals remain per-viewer. + if viewer_db_user_id is not None: + dismissed = activity_service.get_dismissal_set(viewer_db_user_id) + + return jsonify( + { + "status": status, + "requests": request_rows, + "dismissed": dismissed, + } + ) + + @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 + + db_user_id, db_gate = _resolve_db_user_id() + if db_gate is not None or db_user_id is None: + return db_gate + + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"error": "Invalid payload"}), 400 + + activity_log_id = data.get("activity_log_id") + if activity_log_id is None: + try: + activity_log_id = _get_existing_activity_log_id_for_item( + activity_service=activity_service, + user_db=user_db, + item_type=data.get("item_type"), + item_key=data.get("item_key"), + ) + except Exception as exc: + logger.warning("Failed to resolve activity snapshot id for dismiss payload: %s", exc) + activity_log_id = None + + try: + dismissal = activity_service.dismiss_item( + user_id=db_user_id, + item_type=data.get("item_type"), + item_key=data.get("item_key"), + activity_log_id=activity_log_id, + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + + _emit_activity_event( + ws_manager, + room=f"user_{db_user_id}", + payload={ + "kind": "dismiss", + "user_id": db_user_id, + "item_type": dismissal["item_type"], + "item_key": dismissal["item_key"], + }, + ) + + return jsonify({"status": "dismissed", "item": dismissal}) + + @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 + + db_user_id, db_gate = _resolve_db_user_id() + if db_gate is not None or db_user_id is None: + return db_gate + + 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 + + normalized_items: list[dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict): + return jsonify({"error": "items must contain objects"}), 400 + + activity_log_id = item.get("activity_log_id") + if activity_log_id is None: + try: + activity_log_id = _get_existing_activity_log_id_for_item( + activity_service=activity_service, + user_db=user_db, + item_type=item.get("item_type"), + item_key=item.get("item_key"), + ) + except Exception as exc: + logger.warning("Failed to resolve activity snapshot id for dismiss-many item: %s", exc) + activity_log_id = None + + normalized_payload = { + "item_type": item.get("item_type"), + "item_key": item.get("item_key"), + } + if activity_log_id is not None: + normalized_payload["activity_log_id"] = activity_log_id + normalized_items.append(normalized_payload) + + try: + dismissed_count = activity_service.dismiss_many(user_id=db_user_id, items=normalized_items) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + + _emit_activity_event( + ws_manager, + room=f"user_{db_user_id}", + payload={ + "kind": "dismiss_many", + "user_id": db_user_id, + "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 + + db_user_id, db_gate = _resolve_db_user_id() + if db_gate is not None or db_user_id is None: + return db_gate + + limit = request.args.get("limit", type=int, default=50) or 50 + offset = request.args.get("offset", type=int, default=0) or 0 + + try: + history = activity_service.get_history(db_user_id, limit=limit, offset=offset) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify(history) + + @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 + + db_user_id, db_gate = _resolve_db_user_id() + if db_gate is not None or db_user_id is None: + return db_gate + + deleted_count = activity_service.clear_history(db_user_id) + _emit_activity_event( + ws_manager, + room=f"user_{db_user_id}", + payload={ + "kind": "history_cleared", + "user_id": db_user_id, + "count": deleted_count, + }, + ) + return jsonify({"status": "cleared", "deleted_count": deleted_count}) diff --git a/shelfmark/core/activity_service.py b/shelfmark/core/activity_service.py new file mode 100644 index 0000000..ebc0610 --- /dev/null +++ b/shelfmark/core/activity_service.py @@ -0,0 +1,618 @@ +"""Persistence helpers for Activity dismissals and terminal snapshots.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +import sqlite3 +from typing import Any, Iterable + + +VALID_ITEM_TYPES = frozenset({"download", "request"}) +VALID_ORIGINS = frozenset({"direct", "request", "requested"}) +VALID_FINAL_STATUSES = frozenset({"complete", "error", "cancelled", "rejected"}) + + +def _now_timestamp() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def _normalize_item_type(item_type: Any) -> str: + if not isinstance(item_type, str): + raise ValueError("item_type must be a string") + normalized = item_type.strip().lower() + if normalized not in VALID_ITEM_TYPES: + raise ValueError("item_type must be one of: download, request") + return normalized + + +def _normalize_item_key(item_key: Any) -> str: + if not isinstance(item_key, str): + raise ValueError("item_key must be a string") + normalized = item_key.strip() + if not normalized: + raise ValueError("item_key must not be empty") + return normalized + + +def _normalize_origin(origin: Any) -> str: + if not isinstance(origin, str): + raise ValueError("origin must be a string") + normalized = origin.strip().lower() + if normalized not in VALID_ORIGINS: + raise ValueError("origin must be one of: direct, request, requested") + return normalized + + +def _normalize_final_status(final_status: Any) -> str: + if not isinstance(final_status, str): + raise ValueError("final_status must be a string") + normalized = final_status.strip().lower() + if normalized not in VALID_FINAL_STATUSES: + raise ValueError("final_status must be one of: complete, error, cancelled, rejected") + return normalized + + +def build_item_key(item_type: str, raw_id: Any) -> str: + """Build a stable item key used by dismiss/history APIs.""" + normalized_type = _normalize_item_type(item_type) + if normalized_type == "request": + try: + request_id = int(raw_id) + except (TypeError, ValueError) as exc: + raise ValueError("request item IDs must be integers") from exc + if request_id < 1: + raise ValueError("request item IDs must be positive integers") + return f"request:{request_id}" + + if not isinstance(raw_id, str): + raise ValueError("download item IDs must be strings") + task_id = raw_id.strip() + if not task_id: + raise ValueError("download item IDs must not be empty") + return f"download:{task_id}" + + +def build_request_item_key(request_id: int) -> str: + """Build a request item key.""" + return build_item_key("request", request_id) + + +def build_download_item_key(task_id: str) -> str: + """Build a download item key.""" + return build_item_key("download", task_id) + + +def _parse_request_id_from_item_key(item_key: Any) -> int | None: + if not isinstance(item_key, str) or not item_key.startswith("request:"): + return None + raw_value = item_key.split(":", 1)[1].strip() + try: + parsed = int(raw_value) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + +def _request_final_status(request_status: Any, delivery_state: Any) -> str | None: + status = str(request_status or "").strip().lower() + if status == "pending": + return None + if status == "rejected": + return "rejected" + if status == "cancelled": + return "cancelled" + if status != "fulfilled": + return None + + delivery = str(delivery_state or "").strip().lower() + if delivery in {"error", "cancelled"}: + return delivery + return "complete" + + +class ActivityService: + """Service for per-user activity dismissals and terminal history snapshots.""" + + def __init__(self, db_path: str): + self._db_path = db_path + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self._db_path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + @staticmethod + def _coerce_positive_int(value: Any, field: str) -> int: + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{field} must be an integer") from exc + if parsed < 1: + raise ValueError(f"{field} must be a positive integer") + return parsed + + @staticmethod + def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None: + return dict(row) if row is not None else None + + @staticmethod + def _parse_json_column(value: Any) -> Any: + if not isinstance(value, str): + return None + try: + return json.loads(value) + except (ValueError, TypeError): + return None + + def _build_legacy_request_snapshot( + self, + conn: sqlite3.Connection, + request_id: int, + ) -> tuple[dict[str, Any] | None, str | None]: + request_row = conn.execute( + """ + SELECT + id, + user_id, + status, + delivery_state, + request_level, + book_data, + release_data, + note, + admin_note, + created_at, + reviewed_at + FROM download_requests + WHERE id = ? + """, + (request_id,), + ).fetchone() + if request_row is None: + return None, None + + row_dict = dict(request_row) + book_data = self._parse_json_column(row_dict.get("book_data")) + release_data = self._parse_json_column(row_dict.get("release_data")) + if not isinstance(book_data, dict): + book_data = {} + if not isinstance(release_data, dict): + release_data = {} + + snapshot = { + "kind": "request", + "request": { + "id": int(row_dict["id"]), + "user_id": row_dict.get("user_id"), + "status": row_dict.get("status"), + "delivery_state": row_dict.get("delivery_state"), + "request_level": row_dict.get("request_level"), + "book_data": book_data, + "release_data": release_data, + "note": row_dict.get("note"), + "admin_note": row_dict.get("admin_note"), + "created_at": row_dict.get("created_at"), + "updated_at": row_dict.get("reviewed_at") or row_dict.get("created_at"), + }, + } + final_status = _request_final_status(row_dict.get("status"), row_dict.get("delivery_state")) + return snapshot, final_status + + def record_terminal_snapshot( + self, + *, + user_id: int | None, + item_type: str, + item_key: str, + origin: str, + final_status: str, + snapshot: dict[str, Any], + request_id: int | None = None, + source_id: str | None = None, + terminal_at: str | None = None, + ) -> dict[str, Any]: + """Record a durable terminal-state snapshot for an activity item.""" + normalized_item_type = _normalize_item_type(item_type) + normalized_item_key = _normalize_item_key(item_key) + normalized_origin = _normalize_origin(origin) + normalized_final_status = _normalize_final_status(final_status) + if not isinstance(snapshot, dict): + raise ValueError("snapshot must be an object") + + if user_id is not None: + user_id = self._coerce_positive_int(user_id, "user_id") + if request_id is not None: + request_id = self._coerce_positive_int(request_id, "request_id") + if source_id is not None and not isinstance(source_id, str): + raise ValueError("source_id must be a string when provided") + if source_id is not None: + source_id = source_id.strip() or None + + effective_terminal_at = terminal_at if isinstance(terminal_at, str) and terminal_at.strip() else _now_timestamp() + serialized_snapshot = json.dumps(snapshot, separators=(",", ":"), ensure_ascii=False) + + conn = self._connect() + try: + cursor = conn.execute( + """ + INSERT INTO activity_log ( + user_id, + item_type, + item_key, + request_id, + source_id, + origin, + final_status, + snapshot_json, + terminal_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + normalized_item_type, + normalized_item_key, + request_id, + source_id, + normalized_origin, + normalized_final_status, + serialized_snapshot, + effective_terminal_at, + ), + ) + snapshot_id = int(cursor.lastrowid) + conn.commit() + row = conn.execute( + "SELECT * FROM activity_log WHERE id = ?", + (snapshot_id,), + ).fetchone() + payload = self._row_to_dict(row) + if payload is None: + raise ValueError("Failed to read back recorded activity snapshot") + return payload + finally: + conn.close() + + def get_latest_activity_log_id(self, *, item_type: str, item_key: str) -> int | None: + """Get the newest snapshot ID for an item key.""" + normalized_item_type = _normalize_item_type(item_type) + normalized_item_key = _normalize_item_key(item_key) + conn = self._connect() + try: + row = conn.execute( + """ + SELECT id + FROM activity_log + WHERE item_type = ? AND item_key = ? + ORDER BY terminal_at DESC, id DESC + LIMIT 1 + """, + (normalized_item_type, normalized_item_key), + ).fetchone() + if row is None: + return None + return int(row["id"]) + finally: + conn.close() + + def dismiss_item( + self, + *, + user_id: int, + item_type: str, + item_key: str, + activity_log_id: int | None = None, + ) -> dict[str, Any]: + """Dismiss an item for a specific user (upsert).""" + normalized_user_id = self._coerce_positive_int(user_id, "user_id") + normalized_item_type = _normalize_item_type(item_type) + normalized_item_key = _normalize_item_key(item_key) + normalized_log_id = ( + self._coerce_positive_int(activity_log_id, "activity_log_id") + if activity_log_id is not None + else self.get_latest_activity_log_id( + item_type=normalized_item_type, + item_key=normalized_item_key, + ) + ) + + conn = self._connect() + try: + conn.execute( + """ + INSERT INTO activity_dismissals ( + user_id, + item_type, + item_key, + activity_log_id, + dismissed_at + ) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(user_id, item_type, item_key) + DO UPDATE SET + activity_log_id = excluded.activity_log_id, + dismissed_at = excluded.dismissed_at + """, + ( + normalized_user_id, + normalized_item_type, + normalized_item_key, + normalized_log_id, + _now_timestamp(), + ), + ) + conn.commit() + row = conn.execute( + """ + SELECT * + FROM activity_dismissals + WHERE user_id = ? AND item_type = ? AND item_key = ? + """, + (normalized_user_id, normalized_item_type, normalized_item_key), + ).fetchone() + payload = self._row_to_dict(row) + if payload is None: + raise ValueError("Failed to read back dismissal row") + return payload + finally: + conn.close() + + def dismiss_many(self, *, user_id: int, items: Iterable[dict[str, Any]]) -> int: + """Dismiss many items for one user.""" + normalized_user_id = self._coerce_positive_int(user_id, "user_id") + normalized_items: list[tuple[str, str, int | None]] = [] + for item in items: + if not isinstance(item, dict): + raise ValueError("items must contain objects") + normalized_item_type = _normalize_item_type(item.get("item_type")) + normalized_item_key = _normalize_item_key(item.get("item_key")) + raw_log_id = item.get("activity_log_id") + normalized_log_id = ( + self._coerce_positive_int(raw_log_id, "activity_log_id") + if raw_log_id is not None + else self.get_latest_activity_log_id( + item_type=normalized_item_type, + item_key=normalized_item_key, + ) + ) + normalized_items.append((normalized_item_type, normalized_item_key, normalized_log_id)) + + if not normalized_items: + return 0 + + conn = self._connect() + try: + timestamp = _now_timestamp() + for item_type, item_key, activity_log_id in normalized_items: + conn.execute( + """ + INSERT INTO activity_dismissals ( + user_id, + item_type, + item_key, + activity_log_id, + dismissed_at + ) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(user_id, item_type, item_key) + DO UPDATE SET + activity_log_id = excluded.activity_log_id, + dismissed_at = excluded.dismissed_at + """, + ( + normalized_user_id, + item_type, + item_key, + activity_log_id, + timestamp, + ), + ) + conn.commit() + return len(normalized_items) + finally: + conn.close() + + def get_dismissal_set(self, user_id: int) -> list[dict[str, str]]: + """Return dismissed item keys for one user.""" + normalized_user_id = self._coerce_positive_int(user_id, "user_id") + conn = self._connect() + try: + rows = conn.execute( + """ + SELECT item_type, item_key + FROM activity_dismissals + WHERE user_id = ? + ORDER BY dismissed_at DESC, id DESC + """, + (normalized_user_id,), + ).fetchall() + return [ + { + "item_type": str(row["item_type"]), + "item_key": str(row["item_key"]), + } + for row in rows + ] + finally: + conn.close() + + def clear_dismissals_for_item_keys( + self, + *, + user_id: int, + item_type: str, + item_keys: Iterable[str], + ) -> int: + """Clear dismissals for one user + item type + item keys.""" + normalized_user_id = self._coerce_positive_int(user_id, "user_id") + normalized_item_type = _normalize_item_type(item_type) + normalized_keys = { + _normalize_item_key(item_key) + for item_key in item_keys + if isinstance(item_key, str) and item_key.strip() + } + if not normalized_keys: + return 0 + + conn = self._connect() + try: + cursor = conn.executemany( + """ + DELETE FROM activity_dismissals + WHERE user_id = ? AND item_type = ? AND item_key = ? + """, + ( + (normalized_user_id, normalized_item_type, item_key) + for item_key in normalized_keys + ), + ) + conn.commit() + return int(cursor.rowcount or 0) + finally: + conn.close() + + def get_history(self, user_id: int, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]: + """Return paged dismissal history for one user.""" + normalized_user_id = self._coerce_positive_int(user_id, "user_id") + normalized_limit = max(1, min(int(limit), 200)) + normalized_offset = max(0, int(offset)) + + conn = self._connect() + try: + rows = conn.execute( + """ + SELECT + d.id, + d.user_id, + d.item_type, + d.item_key, + d.activity_log_id, + d.dismissed_at, + l.snapshot_json, + l.origin, + l.final_status, + l.terminal_at, + l.request_id, + l.source_id + FROM activity_dismissals d + LEFT JOIN activity_log l ON l.id = d.activity_log_id + WHERE d.user_id = ? + ORDER BY d.dismissed_at DESC, d.id DESC + LIMIT ? OFFSET ? + """, + (normalized_user_id, normalized_limit, normalized_offset), + ).fetchall() + + payload: list[dict[str, Any]] = [] + for row in rows: + row_dict = dict(row) + raw_snapshot_json = row_dict.pop("snapshot_json", None) + snapshot_payload = None + if isinstance(raw_snapshot_json, str): + try: + snapshot_payload = json.loads(raw_snapshot_json) + except (ValueError, TypeError): + snapshot_payload = None + + if snapshot_payload is None and row_dict.get("item_type") == "request": + request_id = row_dict.get("request_id") + if request_id is None: + request_id = _parse_request_id_from_item_key(row_dict.get("item_key")) + try: + normalized_request_id = int(request_id) if request_id is not None else None + except (TypeError, ValueError): + normalized_request_id = None + + if normalized_request_id and normalized_request_id > 0: + fallback_snapshot, fallback_final_status = self._build_legacy_request_snapshot( + conn, + normalized_request_id, + ) + if fallback_snapshot is not None: + snapshot_payload = fallback_snapshot + if not row_dict.get("origin"): + row_dict["origin"] = "request" + if not row_dict.get("final_status") and fallback_final_status is not None: + row_dict["final_status"] = fallback_final_status + + row_dict["snapshot"] = snapshot_payload + payload.append(row_dict) + return payload + finally: + conn.close() + + def get_undismissed_terminal_downloads(self, user_id: int, *, limit: int = 200) -> list[dict[str, Any]]: + """Return latest undismissed terminal download snapshots for one user.""" + normalized_user_id = self._coerce_positive_int(user_id, "user_id") + normalized_limit = max(1, min(int(limit), 500)) + + conn = self._connect() + try: + rows = conn.execute( + """ + SELECT + l.id, + l.user_id, + l.item_type, + l.item_key, + l.request_id, + l.source_id, + l.origin, + l.final_status, + l.snapshot_json, + l.terminal_at + FROM activity_log l + LEFT JOIN activity_dismissals d + ON d.user_id = ? + AND d.item_type = l.item_type + AND d.item_key = l.item_key + WHERE l.user_id = ? + AND l.item_type = 'download' + AND l.final_status IN ('complete', 'error', 'cancelled') + AND d.id IS NULL + ORDER BY l.terminal_at DESC, l.id DESC + LIMIT ? + """, + (normalized_user_id, normalized_user_id, normalized_limit * 2), + ).fetchall() + + payload: list[dict[str, Any]] = [] + seen_item_keys: set[str] = set() + for row in rows: + row_dict = dict(row) + item_key = str(row_dict.get("item_key") or "") + if not item_key or item_key in seen_item_keys: + continue + seen_item_keys.add(item_key) + + raw_snapshot_json = row_dict.pop("snapshot_json", None) + snapshot_payload = None + if isinstance(raw_snapshot_json, str): + try: + snapshot_payload = json.loads(raw_snapshot_json) + except (ValueError, TypeError): + snapshot_payload = None + row_dict["snapshot"] = snapshot_payload + payload.append(row_dict) + if len(payload) >= normalized_limit: + break + + return payload + finally: + conn.close() + + def clear_history(self, user_id: int) -> int: + """Delete all dismissals for a user and return deleted row count.""" + normalized_user_id = self._coerce_positive_int(user_id, "user_id") + conn = self._connect() + try: + cursor = conn.execute( + "DELETE FROM activity_dismissals WHERE user_id = ?", + (normalized_user_id,), + ) + conn.commit() + return int(cursor.rowcount or 0) + finally: + conn.close() diff --git a/shelfmark/core/auth_modes.py b/shelfmark/core/auth_modes.py index 235fb2c..0cd86dd 100644 --- a/shelfmark/core/auth_modes.py +++ b/shelfmark/core/auth_modes.py @@ -96,10 +96,10 @@ def get_settings_tab_from_path(path: str) -> str | None: def should_restrict_settings_to_admin( - users_config: Mapping[str, Any], + _users_config: Mapping[str, Any], ) -> bool: - """Return whether settings/onboarding access is limited to admins.""" - return bool(users_config.get("RESTRICT_SETTINGS_TO_ADMIN", True)) + """Settings/onboarding is always admin-only.""" + return True def requires_admin_for_settings_access( @@ -116,14 +116,11 @@ def requires_admin_for_settings_access( def get_auth_check_admin_status( _auth_mode: str, - users_config: Mapping[str, Any], + _users_config: Mapping[str, Any], session_data: Mapping[str, Any], ) -> bool: - """Resolve /api/auth/check `is_admin` value for settings UI access control.""" + """Resolve /api/auth/check `is_admin` as the session's real admin role.""" if "user_id" not in session_data: return False - if not should_restrict_settings_to_admin(users_config): - return True - return bool(session_data.get("is_admin", False)) diff --git a/shelfmark/core/queue.py b/shelfmark/core/queue.py index c7d26db..efe4cdb 100644 --- a/shelfmark/core/queue.py +++ b/shelfmark/core/queue.py @@ -5,7 +5,7 @@ import time from datetime import datetime, timedelta from pathlib import Path from threading import Lock, Event -from typing import Dict, List, Optional, Tuple, Any +from typing import Dict, List, Optional, Tuple, Any, Callable from shelfmark.core.config import config as app_config from shelfmark.core.models import QueueStatus, QueueItem, DownloadTask @@ -22,6 +22,9 @@ class BookQueue: self._status_timestamps: dict[str, datetime] = {} # Track when each status was last updated self._cancel_flags: dict[str, Event] = {} # Cancellation flags for active downloads self._active_downloads: dict[str, bool] = {} # Track currently downloading tasks + self._terminal_status_hook: Optional[ + Callable[[str, QueueStatus, DownloadTask], None] + ] = None @property def _status_timeout(self) -> timedelta: @@ -79,16 +82,47 @@ class BookQueue: self._status[book_id] = status self._status_timestamps[book_id] = datetime.now() + def set_terminal_status_hook( + self, + hook: Optional[Callable[[str, QueueStatus, DownloadTask], None]], + ) -> None: + """Register a callback invoked when a task first enters a terminal status.""" + with self._lock: + self._terminal_status_hook = hook + def update_status(self, book_id: str, status: QueueStatus) -> None: """Update status of a book in the queue.""" + hook: Optional[Callable[[str, QueueStatus, DownloadTask], None]] = None + hook_task: Optional[DownloadTask] = None with self._lock: + previous_status = self._status.get(book_id) self._update_status(book_id, status) + terminal_statuses = { + QueueStatus.COMPLETE, + QueueStatus.AVAILABLE, + QueueStatus.ERROR, + QueueStatus.DONE, + QueueStatus.CANCELLED, + } + if ( + status in terminal_statuses + and previous_status != status + and self._terminal_status_hook is not None + ): + current_task = self._task_data.get(book_id) + if current_task is not None: + hook = self._terminal_status_hook + hook_task = current_task + # Clean up active download tracking when finished if status in [QueueStatus.COMPLETE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]: self._active_downloads.pop(book_id, None) self._cancel_flags.pop(book_id, None) + if hook is not None and hook_task is not None: + hook(book_id, status, hook_task) + def update_download_path(self, task_id: str, download_path: str) -> None: """Update the download path of a task in the queue.""" with self._lock: @@ -166,13 +200,7 @@ class BookQueue: # Signal active download to stop if task_id in self._cancel_flags: self._cancel_flags[task_id].set() - self._update_status(task_id, QueueStatus.CANCELLED) - return True - elif current_status == QueueStatus.QUEUED: - # Remove from queue and mark as cancelled - self._update_status(task_id, QueueStatus.CANCELLED) - return True - elif current_status in [QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED]: + if current_status in [QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED]: # Clear completed/errored/cancelled items from tracking self._status.pop(task_id, None) self._status_timestamps.pop(task_id, None) @@ -181,7 +209,11 @@ class BookQueue: self._active_downloads.pop(task_id, None) return True - return False + if current_status in [QueueStatus.RESOLVING, QueueStatus.LOCATING, QueueStatus.DOWNLOADING, QueueStatus.QUEUED]: + self.update_status(task_id, QueueStatus.CANCELLED) + return True + + return False def set_priority(self, task_id: str, new_priority: int) -> bool: """Change the priority of a queued task (lower = higher priority).""" @@ -253,11 +285,30 @@ class BookQueue: return True return any(status == QueueStatus.QUEUED for status in self._status.values()) - def clear_completed(self) -> int: - """Remove all completed, errored, or cancelled tasks from tracking.""" + def clear_completed(self, user_id: Optional[int] = None) -> int: + """Remove terminal tasks from tracking, optionally scoped to one user. + + Args: + user_id: If provided, only clear tasks belonging to this user, + plus legacy tasks with no user_id. If None, clear all. + """ terminal_statuses = {QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED} with self._lock: - to_remove = [task_id for task_id, status in self._status.items() if status in terminal_statuses] + to_remove: list[str] = [] + for task_id, status in self._status.items(): + if status not in terminal_statuses: + continue + + if user_id is None: + to_remove.append(task_id) + continue + + task = self._task_data.get(task_id) + if task is None: + # Without task ownership metadata we cannot safely scope removal. + continue + if task.user_id is None or task.user_id == user_id: + to_remove.append(task_id) for task_id in to_remove: self._status.pop(task_id, None) diff --git a/shelfmark/core/request_routes.py b/shelfmark/core/request_routes.py index 59807c0..3e0f76b 100644 --- a/shelfmark/core/request_routes.py +++ b/shelfmark/core/request_routes.py @@ -24,6 +24,7 @@ from shelfmark.core.requests_service import ( fulfil_request, reject_request, ) +from shelfmark.core.activity_service import ActivityService, build_request_item_key from shelfmark.core.settings_registry import load_config_file from shelfmark.core.user_db import UserDB @@ -134,12 +135,66 @@ def _emit_request_event( logger.warning(f"Failed to emit WebSocket event '{event_name}' to room '{room}': {exc}") +def _extract_release_source_id(release_data: Any) -> str | None: + if not isinstance(release_data, dict): + return None + source_id = release_data.get("source_id") + if not isinstance(source_id, str): + return None + normalized = source_id.strip() + return normalized or None + + +def _record_terminal_request_snapshot( + activity_service: ActivityService | None, + *, + request_row: dict[str, Any], +) -> None: + if activity_service is None: + return + + request_status = request_row.get("status") + if request_status not in {"rejected", "cancelled"}: + return + + raw_request_id = request_row.get("id") + try: + request_id = int(raw_request_id) + except (TypeError, ValueError): + return + if request_id < 1: + return + + raw_user_id = request_row.get("user_id") + try: + user_id = int(raw_user_id) + except (TypeError, ValueError): + user_id = None + + source_id = _extract_release_source_id(request_row.get("release_data")) + + try: + activity_service.record_terminal_snapshot( + user_id=user_id, + item_type="request", + item_key=build_request_item_key(request_id), + origin="request", + final_status=request_status, + snapshot={"kind": "request", "request": request_row}, + request_id=request_id, + source_id=source_id, + ) + except Exception as exc: + logger.warning("Failed to record terminal request snapshot for request %s: %s", request_id, exc) + + def register_request_routes( app: Flask, user_db: UserDB, *, resolve_auth_mode: Callable[[], str], queue_release: Callable[..., tuple[bool, str | None]], + activity_service: ActivityService | None = None, ws_manager: Any | None = None, ) -> None: """Register request policy and request lifecycle routes.""" @@ -408,6 +463,8 @@ def register_request_routes( except RequestServiceError as exc: return _error_response(str(exc), exc.status_code, code=exc.code) + _record_terminal_request_snapshot(activity_service, request_row=updated) + event_payload = { "request_id": updated["id"], "status": updated["status"], @@ -557,6 +614,8 @@ def register_request_routes( except RequestServiceError as exc: return _error_response(str(exc), exc.status_code, code=exc.code) + _record_terminal_request_snapshot(activity_service, request_row=updated) + event_payload = { "request_id": updated["id"], "status": updated["status"], diff --git a/shelfmark/core/requests_service.py b/shelfmark/core/requests_service.py index 54b1da2..629d5ed 100644 --- a/shelfmark/core/requests_service.py +++ b/shelfmark/core/requests_service.py @@ -12,6 +12,19 @@ from shelfmark.core.request_policy import normalize_content_type, parse_policy_m VALID_REQUEST_STATUSES = frozenset({"pending", "fulfilled", "rejected", "cancelled"}) TERMINAL_REQUEST_STATUSES = frozenset({"fulfilled", "rejected", "cancelled"}) VALID_REQUEST_LEVELS = frozenset({"book", "release"}) +VALID_DELIVERY_STATES = frozenset( + { + "none", + "unknown", + "queued", + "resolving", + "locating", + "downloading", + "complete", + "error", + "cancelled", + } +) MAX_REQUEST_NOTE_LENGTH = 1000 MAX_REQUEST_JSON_BLOB_BYTES = 10 * 1024 @@ -63,6 +76,16 @@ def normalize_request_level(request_level: Any) -> str: return normalized +def normalize_delivery_state(state: Any) -> str: + """Validate and normalize delivery-state values.""" + if not isinstance(state, str): + raise ValueError(f"Invalid delivery_state: {state}") + normalized = state.strip().lower() + if normalized not in VALID_DELIVERY_STATES: + raise ValueError(f"Invalid delivery_state: {state}") + return normalized + + def validate_request_level_payload(request_level: Any, release_data: Any) -> str: """Validate request_level and release_data shape coupling.""" normalized_level = normalize_request_level(request_level) @@ -163,6 +186,68 @@ def _now_timestamp() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") +def _extract_release_source_id(release_data: Any) -> str | None: + if not isinstance(release_data, dict): + return None + source_id = release_data.get("source_id") + if not isinstance(source_id, str): + return None + normalized = source_id.strip() + return normalized or None + + +def _existing_delivery_state(request_row: dict[str, Any]) -> str: + raw_state = request_row.get("delivery_state") + if not isinstance(raw_state, str): + return "none" + normalized = raw_state.strip().lower() + return normalized if normalized in VALID_DELIVERY_STATES else "none" + + +def sync_delivery_states_from_queue_status( + user_db: "UserDB", + *, + queue_status: dict[str, dict[str, Any]], + user_id: int | None = None, +) -> list[dict[str, Any]]: + """Persist delivery-state transitions for fulfilled requests based on queue status.""" + source_delivery_states: dict[str, str] = {} + for status_key in ("queued", "resolving", "locating", "downloading", "complete", "error", "cancelled"): + status_bucket = queue_status.get(status_key) + if not isinstance(status_bucket, dict): + continue + for source_id in status_bucket: + source_delivery_states[source_id] = status_key + + if not source_delivery_states: + return [] + + fulfilled_rows = user_db.list_requests(user_id=user_id, status="fulfilled") + updated: list[dict[str, Any]] = [] + + for row in fulfilled_rows: + source_id = _extract_release_source_id(row.get("release_data")) + if source_id is None: + continue + + delivery_state = source_delivery_states.get(source_id) + if delivery_state is None: + continue + + if _existing_delivery_state(row) == delivery_state: + continue + + updated.append( + user_db.update_request( + row["id"], + delivery_state=delivery_state, + delivery_updated_at=_now_timestamp(), + ) + ) + + return updated + + def create_request( user_db: "UserDB", *, @@ -383,6 +468,8 @@ def fulfil_request( request_id, status="fulfilled", release_data=selected_release_data, + delivery_state="queued", + delivery_updated_at=_now_timestamp(), admin_note=normalized_admin_note, reviewed_by=admin_user_id, reviewed_at=_now_timestamp(), diff --git a/shelfmark/core/self_user_routes.py b/shelfmark/core/self_user_routes.py new file mode 100644 index 0000000..039040d --- /dev/null +++ b/shelfmark/core/self_user_routes.py @@ -0,0 +1,333 @@ +"""Self-service user account routes.""" + +from functools import wraps +from typing import Any, Callable, Mapping + +from flask import Flask, jsonify, request, session +from werkzeug.security import generate_password_hash + +from shelfmark.config.env import CWA_DB_PATH +from shelfmark.core.admin_settings_routes import validate_user_settings +from shelfmark.core.auth_modes import ( + AUTH_SOURCE_BUILTIN, + AUTH_SOURCE_CWA, + AUTH_SOURCE_OIDC, + AUTH_SOURCE_PROXY, + determine_auth_mode, + has_local_password_admin, + normalize_auth_source, +) +from shelfmark.core.logger import setup_logger +from shelfmark.core.settings_registry import load_config_file +from shelfmark.core.user_db import UserDB + +logger = setup_logger(__name__) + +MIN_PASSWORD_LENGTH = 4 + + +def _get_auth_mode() -> str: + """Get current auth mode from config.""" + try: + config = load_config_file("security") + return determine_auth_mode( + config, + CWA_DB_PATH, + has_local_admin=has_local_password_admin(), + ) + except Exception: + return "none" + + +def _require_authenticated_user(f: Callable[..., Any]) -> Callable[..., Any]: + """Decorator requiring an authenticated session linked to a local user row.""" + + @wraps(f) + def decorated(*args, **kwargs): + auth_mode = _get_auth_mode() + if auth_mode != "none" and "user_id" not in session: + return jsonify({"error": "Authentication required"}), 401 + if "db_user_id" not in session: + return jsonify({"error": "Authenticated session is missing local user context"}), 403 + return f(*args, **kwargs) + + return decorated + + +def _get_current_user(user_db: UserDB) -> tuple[int | None, dict[str, Any] | None, tuple[Any, int] | None]: + raw_user_id = session.get("db_user_id") + try: + user_id = int(raw_user_id) + except (TypeError, ValueError): + return None, None, (jsonify({"error": "Invalid user context"}), 400) + + user = user_db.get_user(user_id=user_id) + if not user: + return None, None, (jsonify({"error": "User not found"}), 404) + return user_id, user, None + + +def _is_user_active(user: Mapping[str, Any], auth_method: str) -> bool: + source = normalize_auth_source(user.get("auth_source"), user.get("oidc_subject")) + if source == AUTH_SOURCE_BUILTIN: + return auth_method in (AUTH_SOURCE_BUILTIN, AUTH_SOURCE_OIDC) + return source == auth_method + + +def _get_self_edit_capabilities(user: Mapping[str, Any]) -> dict[str, Any]: + auth_source = normalize_auth_source( + user.get("auth_source"), + user.get("oidc_subject"), + ) + + return { + "authSource": auth_source, + "canSetPassword": auth_source == AUTH_SOURCE_BUILTIN, + "canEditRole": False, + "canEditEmail": auth_source in {AUTH_SOURCE_BUILTIN, AUTH_SOURCE_PROXY}, + "canEditDisplayName": auth_source != AUTH_SOURCE_OIDC, + } + + +def _serialize_self_user(user: Mapping[str, Any], auth_mode: str) -> dict[str, Any]: + payload = dict(user) + payload.pop("password_hash", None) + payload["auth_source"] = normalize_auth_source( + payload.get("auth_source"), + payload.get("oidc_subject"), + ) + payload["is_active"] = _is_user_active(payload, auth_mode) + payload["edit_capabilities"] = _get_self_edit_capabilities(payload) + return payload + + +def _get_settings_registry(): + # Ensure settings modules are loaded before reading registry metadata. + import shelfmark.config.settings # noqa: F401 + import shelfmark.config.security # noqa: F401 + import shelfmark.config.users_settings # noqa: F401 + from shelfmark.core import settings_registry + + return settings_registry + + +def _get_ordered_user_overridable_fields(tab_name: str) -> list[tuple[str, Any]]: + settings_registry = _get_settings_registry() + tab = settings_registry.get_settings_tab(tab_name) + if not tab: + return [] + overridable_map = settings_registry.get_user_overridable_fields(tab_name=tab_name) + return [(field.key, field) for field in tab.fields if field.key in overridable_map] + + +def _build_delivery_preferences_payload(user_db: UserDB, user_id: int) -> dict[str, Any]: + from shelfmark.core.config import config as app_config + + settings_registry = _get_settings_registry() + ordered_fields = _get_ordered_user_overridable_fields("downloads") + if not ordered_fields: + raise ValueError("Downloads settings tab not found") + + download_config = load_config_file("downloads") + user_settings = user_db.get_user_settings(user_id) + ordered_keys = [key for key, _ in ordered_fields] + + fields_payload: list[dict[str, Any]] = [] + global_values: dict[str, Any] = {} + effective: dict[str, dict[str, Any]] = {} + + for key, field in ordered_fields: + serialized = settings_registry.serialize_field(field, "downloads", include_value=False) + serialized["fromEnv"] = bool( + field.env_supported and settings_registry.is_value_from_env(field) + ) + fields_payload.append(serialized) + + global_values[key] = app_config.get(key, field.default) + + source = "default" + value = app_config.get(key, field.default, user_id=user_id) + if field.env_supported and settings_registry.is_value_from_env(field): + source = "env_var" + elif key in user_settings and user_settings[key] is not None: + source = "user_override" + value = user_settings[key] + elif key in download_config: + source = "global_config" + + effective[key] = {"value": value, "source": source} + + user_overrides = { + key: user_settings[key] + for key in ordered_keys + if key in user_settings and user_settings[key] is not None + } + + return { + "tab": "downloads", + "keys": ordered_keys, + "fields": fields_payload, + "globalValues": global_values, + "userOverrides": user_overrides, + "effective": effective, + } + + +def register_self_user_routes(app: Flask, user_db: UserDB) -> None: + """Register self-service user endpoints.""" + + @app.route("/api/users/me/edit-context", methods=["GET"]) + @_require_authenticated_user + def users_me_edit_context(): + user_id, user, user_error = _get_current_user(user_db) + if user_error: + return user_error + + auth_mode = _get_auth_mode() + serialized_user = _serialize_self_user(user, auth_mode) + serialized_user["settings"] = user_db.get_user_settings(user_id) + + try: + delivery_preferences = _build_delivery_preferences_payload(user_db, user_id) + except ValueError: + return jsonify({"error": "Downloads settings tab not found"}), 500 + except Exception as exc: + logger.warning(f"Failed to build user delivery preferences for user_id={user_id}: {exc}") + delivery_preferences = None + + user_overridable_keys = sorted( + set(delivery_preferences.get("keys", []) if delivery_preferences else []) + ) + + return jsonify( + { + "user": serialized_user, + "deliveryPreferences": delivery_preferences, + "userOverridableKeys": user_overridable_keys, + } + ) + + @app.route("/api/users/me", methods=["PUT"]) + @_require_authenticated_user + def users_me_update(): + user_id, user, user_error = _get_current_user(user_db) + if user_error: + return user_error + + data = request.get_json() or {} + if not isinstance(data, dict): + return jsonify({"error": "Request body must be a JSON object"}), 400 + + capabilities = _get_self_edit_capabilities(user) + auth_source = capabilities["authSource"] + + password = data.get("password", "") + if password: + if not capabilities["canSetPassword"]: + return jsonify( + { + "error": f"Cannot set password for {auth_source.upper()} users", + "message": "Password authentication is only available for local users.", + } + ), 400 + if len(password) < MIN_PASSWORD_LENGTH: + return jsonify({"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"}), 400 + user_db.update_user(user_id, password_hash=generate_password_hash(password)) + + user_fields: dict[str, Any] = {} + if "email" in data: + incoming_email = data.get("email") + if incoming_email is None: + user_fields["email"] = None + else: + user_fields["email"] = str(incoming_email).strip() or None + if "display_name" in data: + incoming_display_name = data.get("display_name") + user_fields["display_name"] = ( + str(incoming_display_name).strip() or None + if incoming_display_name is not None + else None + ) + + email_changed = "email" in user_fields and user_fields["email"] != user.get("email") + display_name_changed = ( + "display_name" in user_fields + and user_fields["display_name"] != user.get("display_name") + ) + + if email_changed and not capabilities["canEditEmail"]: + if auth_source == AUTH_SOURCE_CWA: + return jsonify( + { + "error": "Cannot change email for CWA users", + "message": "Email is synced from Calibre-Web.", + } + ), 400 + return jsonify( + { + "error": "Cannot change email for OIDC users", + "message": "Email is managed by your identity provider.", + } + ), 400 + + if display_name_changed and not capabilities["canEditDisplayName"]: + return jsonify( + { + "error": "Cannot change display name for OIDC users", + "message": "Display name is managed by your identity provider.", + } + ), 400 + + for field in ("email", "display_name"): + if field in user_fields and user_fields[field] == user.get(field): + user_fields.pop(field) + + if user_fields: + user_db.update_user(user_id, **user_fields) + + if "settings" in data: + settings_payload = data["settings"] + if not isinstance(settings_payload, dict): + return jsonify({"error": "Settings must be an object"}), 400 + + allowed_user_settings_keys = { + key for key, _field in _get_ordered_user_overridable_fields("downloads") + } + disallowed_keys = sorted( + key for key in settings_payload if key not in allowed_user_settings_keys + ) + if disallowed_keys: + return jsonify( + { + "error": "Some settings are admin-only", + "details": [ + f"Setting not user-overridable: {key}" for key in disallowed_keys + ], + } + ), 400 + + validated_settings, validation_errors = validate_user_settings(settings_payload) + if validation_errors: + return jsonify( + { + "error": "Invalid settings payload", + "details": validation_errors, + } + ), 400 + + user_db.set_user_settings(user_id, validated_settings) + try: + from shelfmark.core.config import config as app_config + + app_config.refresh() + except Exception: + pass + + updated = user_db.get_user(user_id=user_id) + if not updated: + return jsonify({"error": "User not found"}), 404 + + result = _serialize_self_user(updated, _get_auth_mode()) + result["settings"] = user_db.get_user_settings(user_id) + logger.info(f"User {user_id} updated their own account") + return jsonify(result) diff --git a/shelfmark/core/user_db.py b/shelfmark/core/user_db.py index 178d524..51f51e9 100644 --- a/shelfmark/core/user_db.py +++ b/shelfmark/core/user_db.py @@ -9,6 +9,7 @@ from typing import Any, Dict, List, Optional from shelfmark.core.auth_modes import AUTH_SOURCE_BUILTIN, AUTH_SOURCE_SET from shelfmark.core.logger import setup_logger from shelfmark.core.requests_service import ( + normalize_delivery_state, normalize_policy_mode, normalize_request_level, normalize_request_status, @@ -40,6 +41,7 @@ CREATE TABLE IF NOT EXISTS download_requests ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'pending', + delivery_state TEXT NOT NULL DEFAULT 'none', source_hint TEXT, content_type TEXT NOT NULL, request_level TEXT NOT NULL, @@ -50,7 +52,8 @@ CREATE TABLE IF NOT EXISTS download_requests ( admin_note TEXT, reviewed_by INTEGER REFERENCES users(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - reviewed_at TIMESTAMP + reviewed_at TIMESTAMP, + delivery_updated_at TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_download_requests_user_status_created_at @@ -58,6 +61,39 @@ ON download_requests (user_id, status, created_at DESC); CREATE INDEX IF NOT EXISTS idx_download_requests_status_created_at ON download_requests (status, created_at DESC); + +CREATE TABLE IF NOT EXISTS activity_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + item_type TEXT NOT NULL, + item_key TEXT NOT NULL, + request_id INTEGER, + source_id TEXT, + origin TEXT NOT NULL, + final_status TEXT NOT NULL, + snapshot_json TEXT NOT NULL, + terminal_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_activity_log_user_terminal +ON activity_log (user_id, terminal_at DESC); + +CREATE INDEX IF NOT EXISTS idx_activity_log_lookup +ON activity_log (user_id, item_type, item_key, id DESC); + +CREATE TABLE IF NOT EXISTS activity_dismissals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + item_type TEXT NOT NULL, + item_key TEXT NOT NULL, + activity_log_id INTEGER REFERENCES activity_log(id) ON DELETE SET NULL, + dismissed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, item_type, item_key) +); + +CREATE INDEX IF NOT EXISTS idx_activity_dismissals_user_dismissed_at +ON activity_dismissals (user_id, dismissed_at DESC); """ @@ -126,6 +162,8 @@ class UserDB: try: conn.executescript(_CREATE_TABLES_SQL) self._migrate_auth_source_column(conn) + self._migrate_request_delivery_columns(conn) + self._migrate_activity_tables(conn) conn.commit() # WAL mode must be changed outside an open transaction. conn.execute("PRAGMA journal_mode=WAL") @@ -152,6 +190,91 @@ class UserDB: "UPDATE users SET auth_source = 'builtin' WHERE auth_source IS NULL OR auth_source = ''" ) + def _migrate_request_delivery_columns(self, conn: sqlite3.Connection) -> None: + """Ensure request delivery-state columns exist and backfill historical rows.""" + columns = conn.execute("PRAGMA table_info(download_requests)").fetchall() + column_names = {str(col["name"]) for col in columns} + + if "delivery_state" not in column_names: + conn.execute( + "ALTER TABLE download_requests ADD COLUMN delivery_state TEXT NOT NULL DEFAULT 'none'" + ) + if "delivery_updated_at" not in column_names: + conn.execute("ALTER TABLE download_requests ADD COLUMN delivery_updated_at TIMESTAMP") + + conn.execute( + """ + UPDATE download_requests + SET delivery_state = 'unknown' + WHERE status = 'fulfilled' AND (delivery_state IS NULL OR TRIM(delivery_state) = '' OR delivery_state = 'none') + """ + ) + conn.execute( + """ + UPDATE download_requests + SET delivery_state = 'none' + WHERE status != 'fulfilled' AND (delivery_state IS NULL OR TRIM(delivery_state) = '') + """ + ) + conn.execute( + """ + UPDATE download_requests + SET delivery_updated_at = COALESCE(delivery_updated_at, reviewed_at, created_at) + WHERE delivery_state != 'none' AND delivery_updated_at IS NULL + """ + ) + conn.execute( + """ + UPDATE download_requests + SET delivery_state = 'complete' + WHERE delivery_state = 'cleared' + """ + ) + + def _migrate_activity_tables(self, conn: sqlite3.Connection) -> None: + """Ensure activity log and dismissal tables exist with current columns/indexes.""" + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS activity_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + item_type TEXT NOT NULL, + item_key TEXT NOT NULL, + request_id INTEGER, + source_id TEXT, + origin TEXT NOT NULL, + final_status TEXT NOT NULL, + snapshot_json TEXT NOT NULL, + terminal_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_activity_log_user_terminal + ON activity_log (user_id, terminal_at DESC); + + CREATE INDEX IF NOT EXISTS idx_activity_log_lookup + ON activity_log (user_id, item_type, item_key, id DESC); + + CREATE TABLE IF NOT EXISTS activity_dismissals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + item_type TEXT NOT NULL, + item_key TEXT NOT NULL, + activity_log_id INTEGER REFERENCES activity_log(id) ON DELETE SET NULL, + dismissed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, item_type, item_key) + ); + + CREATE INDEX IF NOT EXISTS idx_activity_dismissals_user_dismissed_at + ON activity_dismissals (user_id, dismissed_at DESC); + """ + ) + + dismissal_columns = conn.execute("PRAGMA table_info(activity_dismissals)").fetchall() + dismissal_column_names = {str(col["name"]) for col in dismissal_columns} + if "activity_log_id" not in dismissal_column_names: + conn.execute("ALTER TABLE activity_dismissals ADD COLUMN activity_log_id INTEGER") + def create_user( self, username: str, @@ -350,6 +473,8 @@ class UserDB: admin_note: Optional[str] = None, reviewed_by: Optional[int] = None, reviewed_at: Optional[str] = None, + delivery_state: str = "none", + delivery_updated_at: Optional[str] = None, ) -> Dict[str, Any]: """Create a download request row and return the created record.""" if not isinstance(book_data, dict): @@ -360,6 +485,7 @@ class UserDB: raise ValueError("content_type is required") normalized_status = normalize_request_status(status) + normalized_delivery_state = normalize_delivery_state(delivery_state) normalized_policy_mode = normalize_policy_mode(policy_mode) normalized_request_level = validate_request_level_payload(request_level, release_data) @@ -371,6 +497,7 @@ class UserDB: INSERT INTO download_requests ( user_id, status, + delivery_state, source_hint, content_type, request_level, @@ -380,13 +507,15 @@ class UserDB: note, admin_note, reviewed_by, - reviewed_at + reviewed_at, + delivery_updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( user_id, normalized_status, + normalized_delivery_state, source_hint, content_type, normalized_request_level, @@ -397,6 +526,7 @@ class UserDB: admin_note, reviewed_by, reviewed_at, + delivery_updated_at, ), ) conn.commit() @@ -483,6 +613,8 @@ class UserDB: "admin_note", "reviewed_by", "reviewed_at", + "delivery_state", + "delivery_updated_at", } def update_request(self, request_id: int, **kwargs) -> Dict[str, Any]: @@ -520,6 +652,14 @@ class UserDB: if "policy_mode" in updates: updates["policy_mode"] = normalize_policy_mode(updates["policy_mode"]) + if "delivery_state" in updates: + updates["delivery_state"] = normalize_delivery_state(updates["delivery_state"]) + + if "delivery_updated_at" in updates: + delivery_updated_at = updates["delivery_updated_at"] + if delivery_updated_at is not None and not isinstance(delivery_updated_at, str): + raise ValueError("delivery_updated_at must be a string when provided") + if "content_type" in updates and not updates["content_type"]: raise ValueError("content_type is required") diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index 09d5b45..bd5d253 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -325,6 +325,7 @@ def _task_to_dict(task: DownloadTask) -> Dict[str, Any]: 'status': task.status, 'status_message': task.status_message, 'download_path': task.download_path, + 'user_id': task.user_id, 'username': task.username, } @@ -492,12 +493,13 @@ def update_download_status(book_id: str, status: str, message: Optional[str] = N return _last_status_event[book_id] = status_event - book_queue.update_status(book_id, queue_status_enum) - - # Update status message if provided (empty string clears the message) + # Update status message first so terminal snapshots capture the final message + # (for example, "Complete" or "Sent to ...") instead of a stale in-progress one. if message is not None: book_queue.update_status_message(book_id, message) + book_queue.update_status(book_id, queue_status_enum) + # Broadcast status update via WebSocket if ws_manager: ws_manager.broadcast_status_update(queue_status()) @@ -528,9 +530,9 @@ def get_active_downloads() -> List[str]: """Get list of currently active downloads.""" return book_queue.get_active_downloads() -def clear_completed() -> int: - """Clear all completed downloads from tracking.""" - return book_queue.clear_completed() +def clear_completed(user_id: Optional[int] = None) -> int: + """Clear completed downloads from tracking (optionally user-scoped).""" + return book_queue.clear_completed(user_id=user_id) def _cleanup_progress_tracking(task_id: str) -> None: """Clean up progress tracking data for a completed/cancelled download.""" diff --git a/shelfmark/main.py b/shelfmark/main.py index adb9318..adec970 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -26,7 +26,7 @@ from shelfmark.config.env import ( ) from shelfmark.core.config import config as app_config from shelfmark.core.logger import setup_logger -from shelfmark.core.models import SearchFilters +from shelfmark.core.models import SearchFilters, QueueStatus from shelfmark.core.prefix_middleware import PrefixMiddleware from shelfmark.core.auth_modes import ( determine_auth_mode, @@ -45,6 +45,10 @@ from shelfmark.core.request_policy import ( normalize_source, resolve_policy_mode, ) +from shelfmark.core.requests_service import ( + sync_delivery_states_from_queue_status, +) +from shelfmark.core.activity_service import ActivityService, build_download_item_key from shelfmark.core.utils import normalize_base_path from shelfmark.api.websocket import ws_manager @@ -113,14 +117,18 @@ import os as _os from shelfmark.core.user_db import UserDB _user_db_path = _os.path.join(_os.environ.get("CONFIG_DIR", "/config"), "users.db") user_db: UserDB | None = None +activity_service: ActivityService | None = None try: user_db = UserDB(_user_db_path) user_db.initialize() + activity_service = ActivityService(_user_db_path) import shelfmark.config.users_settings as _ # noqa: F401 - registers users tab from shelfmark.core.oidc_routes import register_oidc_routes from shelfmark.core.admin_routes import register_admin_routes + from shelfmark.core.self_user_routes import register_self_user_routes register_oidc_routes(app, user_db) register_admin_routes(app, user_db) + register_self_user_routes(app, user_db) except (sqlite3.OperationalError, OSError) as e: logger.warning( f"User database initialization failed: {e}. " @@ -380,14 +388,28 @@ def _policy_block_response(mode: PolicyMode): if user_db is not None: try: from shelfmark.core.request_routes import register_request_routes + from shelfmark.core.activity_routes import register_activity_routes register_request_routes( app, user_db, resolve_auth_mode=lambda: get_auth_mode(), queue_release=lambda *args, **kwargs: backend.queue_release(*args, **kwargs), + activity_service=activity_service, ws_manager=ws_manager, ) + if activity_service is not None: + register_activity_routes( + app, + user_db, + activity_service=activity_service, + resolve_auth_mode=lambda: get_auth_mode(), + resolve_status_scope=lambda: _resolve_status_scope(), + queue_status=lambda user_id=None: backend.queue_status(user_id=user_id), + sync_request_delivery_states=sync_delivery_states_from_queue_status, + emit_request_updates=lambda rows: _emit_request_update_events(rows), + ws_manager=ws_manager, + ) except Exception as e: logger.warning(f"Failed to register request routes: {e}") @@ -970,6 +992,148 @@ def _resolve_status_scope(*, require_authenticated: bool = True) -> tuple[bool, return False, db_user_id, True +def _extract_release_source_id(release_data: Any) -> str | None: + if not isinstance(release_data, dict): + return None + source_id = release_data.get("source_id") + if not isinstance(source_id, str): + return None + normalized = source_id.strip() + return normalized or None + + +def _queue_status_to_final_activity_status(status: QueueStatus) -> str | None: + if status == QueueStatus.COMPLETE: + return "complete" + if status == QueueStatus.ERROR: + return "error" + if status == QueueStatus.CANCELLED: + return "cancelled" + return None + + +def _record_download_terminal_snapshot(task_id: str, status: QueueStatus, task: Any) -> None: + if activity_service is None: + return + + final_status = _queue_status_to_final_activity_status(status) + if final_status is None: + return + + raw_owner_user_id = getattr(task, "user_id", None) + try: + owner_user_id = int(raw_owner_user_id) if raw_owner_user_id is not None else None + except (TypeError, ValueError): + owner_user_id = None + + linked_request: dict[str, Any] | None = None + request_id: int | None = None + origin = "direct" + if user_db is not None and owner_user_id is not None: + fulfilled_rows = user_db.list_requests(user_id=owner_user_id, status="fulfilled") + for row in fulfilled_rows: + source_id = _extract_release_source_id(row.get("release_data")) + if source_id == task_id: + linked_request = row + origin = "requested" + try: + request_id = int(row.get("id")) + except (TypeError, ValueError): + request_id = None + break + + try: + download_payload = backend._task_to_dict(task) + except Exception as exc: + logger.warning("Failed to serialize task payload for terminal snapshot: %s", exc) + download_payload = { + "id": task_id, + "title": getattr(task, "title", "Unknown title"), + "author": getattr(task, "author", "Unknown author"), + "source": getattr(task, "source", "direct_download"), + "added_time": getattr(task, "added_time", 0), + "status_message": getattr(task, "status_message", None), + "download_path": getattr(task, "download_path", None), + "user_id": getattr(task, "user_id", None), + "username": getattr(task, "username", None), + } + + snapshot: dict[str, Any] = {"kind": "download", "download": download_payload} + if linked_request is not None: + snapshot["request"] = linked_request + + try: + activity_service.record_terminal_snapshot( + user_id=owner_user_id, + item_type="download", + item_key=build_download_item_key(task_id), + origin=origin, + final_status=final_status, + snapshot=snapshot, + request_id=request_id, + source_id=task_id, + ) + except Exception as exc: + logger.warning("Failed to record terminal download snapshot for task %s: %s", task_id, exc) + + +def _task_owned_by_actor(task: Any, *, actor_user_id: int | None, actor_username: str | None) -> bool: + raw_task_user_id = getattr(task, "user_id", None) + try: + task_user_id = int(raw_task_user_id) if raw_task_user_id is not None else None + except (TypeError, ValueError): + task_user_id = None + + if actor_user_id is not None and task_user_id is not None: + return task_user_id == actor_user_id + + task_username = getattr(task, "username", None) + if isinstance(task_username, str) and task_username.strip() and isinstance(actor_username, str): + return task_username.strip() == actor_username.strip() + + return False + + +def _is_graduated_request_download(task_id: str, *, user_id: int) -> bool: + if user_db is None: + return False + + fulfilled_rows = user_db.list_requests(user_id=user_id, status="fulfilled") + for row in fulfilled_rows: + source_id = _extract_release_source_id(row.get("release_data")) + if source_id == task_id: + return True + return False + + +if activity_service is not None: + backend.book_queue.set_terminal_status_hook(_record_download_terminal_snapshot) + + +def _emit_request_update_events(updated_requests: list[dict[str, Any]]) -> None: + """Broadcast request_update events for rows changed by delivery-state sync.""" + if not updated_requests or ws_manager is None: + return + + try: + socketio_ref = getattr(ws_manager, "socketio", None) + is_enabled = getattr(ws_manager, "is_enabled", None) + if socketio_ref is None or not callable(is_enabled) or not is_enabled(): + return + + for updated in updated_requests: + payload = { + "request_id": updated["id"], + "status": updated["status"], + "delivery_state": updated.get("delivery_state"), + "title": (updated.get("book_data") or {}).get("title") or "Unknown title", + } + socketio_ref.emit("request_update", payload, to=f"user_{updated['user_id']}") + socketio_ref.emit("request_update", payload, to="admins") + except Exception as exc: + logger.warning(f"Failed to emit delivery request_update events: {exc}") + + @app.route('/api/status', methods=['GET']) @login_required def api_status() -> Union[Response, Tuple[Response, int]]: @@ -986,6 +1150,13 @@ def api_status() -> Union[Response, Tuple[Response, int]]: user_id = None if is_admin else db_user_id status = backend.queue_status(user_id=user_id) + if user_db is not None: + updated_requests = sync_delivery_states_from_queue_status( + user_db, + queue_status=status, + user_id=user_id, + ) + _emit_request_update_events(updated_requests) return jsonify(status) except Exception as e: logger.error_trace(f"Status error: {e}") @@ -1110,6 +1281,27 @@ def api_cancel_download(book_id: str) -> Union[Response, Tuple[Response, int]]: flask.Response: JSON status indicating success or failure. """ try: + task = backend.book_queue.get_task(book_id) + if task is None: + return jsonify({"error": "Failed to cancel download or book not found"}), 404 + + is_admin, db_user_id, can_access_status = _resolve_status_scope() + if not is_admin: + if not can_access_status or db_user_id is None: + return jsonify({"error": "User identity unavailable", "code": "user_identity_unavailable"}), 403 + + actor_username = session.get("user_id") + normalized_actor_username = actor_username if isinstance(actor_username, str) else None + if not _task_owned_by_actor( + task, + actor_user_id=db_user_id, + actor_username=normalized_actor_username, + ): + return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403 + + if _is_graduated_request_download(book_id, user_id=db_user_id): + return jsonify({"error": "Forbidden", "code": "requested_download_cancel_forbidden"}), 403 + success = backend.cancel_download(book_id) if success: return jsonify({"status": "cancelled", "book_id": book_id}) @@ -1227,12 +1419,17 @@ def api_clear_completed() -> Union[Response, Tuple[Response, int]]: flask.Response: JSON with count of removed books. """ try: - removed_count = backend.clear_completed() - + is_admin, db_user_id, can_access_status = _resolve_status_scope() + if not can_access_status: + return jsonify({"error": "User identity unavailable", "code": "user_identity_unavailable"}), 403 + + scoped_user_id = None if is_admin else db_user_id + removed_count = backend.clear_completed(user_id=scoped_user_id) + # Broadcast status update after clearing if ws_manager: ws_manager.broadcast_status_update(backend.queue_status()) - + return jsonify({"status": "cleared", "removed_count": removed_count}) except Exception as e: logger.error_trace(f"Clear completed error: {e}") diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 4178b92..240ce5f 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -17,7 +17,6 @@ import { downloadBook, downloadRelease, cancelDownload, - clearCompleted, getConfig, createRequest, isApiResponseError, @@ -31,6 +30,7 @@ import { useDownloadTracking } from './hooks/useDownloadTracking'; import { useRequestPolicy } from './hooks/useRequestPolicy'; import { resolveDefaultModeFromPolicy, resolveSourceModeFromPolicy } from './hooks/requestPolicyCore'; import { useRequests } from './hooks/useRequests'; +import { useActivity } from './hooks/useActivity'; import { Header } from './components/Header'; import { SearchSection } from './components/SearchSection'; import { AdvancedFilters } from './components/AdvancedFilters'; @@ -40,9 +40,9 @@ import { ReleaseModal } from './components/ReleaseModal'; import { RequestConfirmationModal } from './components/RequestConfirmationModal'; import { ToastContainer } from './components/ToastContainer'; import { Footer } from './components/Footer'; -import { ActivitySidebar, requestToActivityItem } from './components/activity'; +import { ActivitySidebar } from './components/activity'; import { LoginPage } from './pages/LoginPage'; -import { SettingsModal } from './components/settings'; +import { SelfSettingsModal, SettingsModal } from './components/settings'; import { ConfigSetupBanner } from './components/ConfigSetupBanner'; import { OnboardingModal } from './components/OnboardingModal'; import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages'; @@ -62,6 +62,7 @@ import { import { bookFromRequestData } from './utils/requestFulfil'; import { policyTrace } from './utils/policyTrace'; import { SearchModeProvider } from './contexts/SearchModeContext'; +import { useSocket } from './contexts/SocketContext'; import './styles.css'; const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type'; @@ -79,7 +80,6 @@ const getInitialContentType = (): ContentType => { }; const POLICY_GUARD_ERROR_CODES = new Set(['policy_requires_request', 'policy_blocked']); - const isPolicyGuardError = (error: unknown): boolean => { return ( isApiResponseError(error) && @@ -117,6 +117,7 @@ const getErrorMessage = (error: unknown, fallback: string): string => { function App() { const { toasts, showToast, removeToast } = useToast(); + const { socket } = useSocket(); // Realtime status with WebSocket and polling fallback // Socket connection is managed by SocketProvider in main.tsx @@ -144,7 +145,7 @@ function App() { isAuthenticated, authRequired, authChecked, - isAdmin: authCanAccessSettings, + isAdmin: authIsAdmin, authMode, username, displayName, @@ -163,9 +164,9 @@ function App() { if (!authChecked || !isAuthenticated) { return; } - policyTrace('auth.status', { authChecked, isAuthenticated, isAdmin: authCanAccessSettings, username }); + policyTrace('auth.status', { authChecked, isAuthenticated, isAdmin: authIsAdmin, username }); void fetchStatus(); - }, [authChecked, isAuthenticated, authCanAccessSettings, username, fetchStatus]); + }, [authChecked, isAuthenticated, authIsAdmin, username, fetchStatus]); // Content type state (ebook vs audiobook) - defined before useSearch since it's passed to it const [contentType, setContentType] = useState(() => getInitialContentType()); @@ -187,14 +188,12 @@ function App() { refresh: refreshRequestPolicy, } = useRequestPolicy({ enabled: isAuthenticated, - isAdmin: authCanAccessSettings, + isAdmin: authIsAdmin, }); const requestRoleIsAdmin = requestPolicy ? Boolean(requestPolicy.is_admin) : false; const { - requests, - pendingCount: pendingRequestCount, isLoading: isRequestsLoading, cancelRequest: cancelUserRequest, fulfilRequest: fulfilSidebarRequest, @@ -204,58 +203,28 @@ function App() { enabled: isAuthenticated, }); - const dismissedRequestStorageKey = useMemo(() => { - const roleScope = requestRoleIsAdmin ? 'admin' : 'user'; - const userScope = username?.trim().toLowerCase() || 'anonymous'; - return `activity-dismissed-requests:${roleScope}:${userScope}`; - }, [requestRoleIsAdmin, username]); - - const [dismissedRequestIds, setDismissedRequestIds] = useState([]); - - useEffect(() => { - if (!isAuthenticated) { - setDismissedRequestIds([]); - return; - } - - try { - const raw = window.localStorage.getItem(dismissedRequestStorageKey); - if (!raw) { - setDismissedRequestIds([]); - return; - } - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) { - setDismissedRequestIds([]); - return; - } - - const ids = parsed.filter((value): value is number => typeof value === 'number' && Number.isFinite(value)); - setDismissedRequestIds(ids); - } catch { - setDismissedRequestIds([]); - } - }, [dismissedRequestStorageKey, isAuthenticated]); - - useEffect(() => { - if (!isAuthenticated) { - return; - } - try { - window.localStorage.setItem(dismissedRequestStorageKey, JSON.stringify(dismissedRequestIds)); - } catch { - // Ignore storage failures in restricted/private contexts. - } - }, [dismissedRequestIds, dismissedRequestStorageKey, isAuthenticated]); - - const requestItems = useMemo( - () => - requests - .filter((record) => !dismissedRequestIds.includes(record.id)) - .map((record) => requestToActivityItem(record, requestRoleIsAdmin ? 'admin' : 'user')) - .sort((left, right) => right.timestamp - left.timestamp), - [requests, requestRoleIsAdmin, dismissedRequestIds] - ); + const { + requestItems, + dismissedActivityKeys, + historyItems, + pendingRequestCount, + isActivitySnapshotLoading, + activityHistoryLoading, + activityHistoryHasMore, + refreshActivitySnapshot, + resetActivity, + handleActivityTabChange, + handleActivityHistoryLoadMore, + handleRequestDismiss, + handleDownloadDismiss, + handleClearCompleted, + handleClearHistory, + } = useActivity({ + isAuthenticated, + isAdmin: requestRoleIsAdmin, + showToast, + socket, + }); const showRequestsTab = useMemo(() => { if (requestRoleIsAdmin) { @@ -317,7 +286,10 @@ function App() { clearTracking(); setPendingRequestPayload(null); setFulfillingRequest(null); - }, [handleLogout, setBooks, clearTracking]); + resetActivity(); + setSettingsOpen(false); + setSelfSettingsOpen(false); + }, [handleLogout, setBooks, clearTracking, resetActivity]); // UI state const [selectedBook, setSelectedBook] = useState(null); @@ -341,6 +313,7 @@ function App() { headerObserverRef.current = observer; }, []); const [settingsOpen, setSettingsOpen] = useState(false); + const [selfSettingsOpen, setSelfSettingsOpen] = useState(false); const [configBannerOpen, setConfigBannerOpen] = useState(false); const [onboardingOpen, setOnboardingOpen] = useState(false); @@ -363,26 +336,45 @@ function App() { // Calculate status counts for header badges (memoized) const statusCounts = useMemo(() => { + const dismissedKeySet = new Set(dismissedActivityKeys); + const countVisibleDownloads = ( + bucket: Record | undefined, + options: { filterDismissed: boolean } + ): number => { + const { filterDismissed } = options; + if (!bucket) { + return 0; + } + if (!filterDismissed) { + return Object.keys(bucket).length; + } + return Object.keys(bucket).filter((taskId) => !dismissedKeySet.has(`download:${taskId}`)).length; + }; + const ongoing = [ currentStatus.queued, currentStatus.resolving, currentStatus.locating, currentStatus.downloading, - ].reduce((sum, status) => sum + (status ? Object.keys(status).length : 0), 0); + ].reduce((sum, status) => sum + countVisibleDownloads(status, { filterDismissed: false }), 0); - const completed = currentStatus.complete - ? Object.keys(currentStatus.complete).length - : 0; - - const errored = currentStatus.error ? Object.keys(currentStatus.error).length : 0; + const completed = countVisibleDownloads(currentStatus.complete, { filterDismissed: true }); + const errored = countVisibleDownloads(currentStatus.error, { filterDismissed: true }); + const pendingVisibleRequests = requestItems.filter((item) => { + const requestId = item.requestId; + if (!requestId || item.requestRecord?.status !== 'pending') { + return false; + } + return !dismissedKeySet.has(`request:${requestId}`); + }).length; return { ongoing, completed, errored, - pendingRequests: pendingRequestCount, + pendingRequests: pendingVisibleRequests, }; - }, [currentStatus, pendingRequestCount]); + }, [currentStatus, dismissedActivityKeys, requestItems]); // Compute visibility states @@ -651,6 +643,7 @@ function App() { async (payload: CreateRequestPayload, successMessage: string): Promise => { try { await createRequest(payload); + await refreshActivitySnapshot(); showToast(successMessage, 'success'); await refreshRequestPolicy({ force: true }); return true; @@ -663,7 +656,7 @@ function App() { return false; } }, - [showToast, refreshRequestPolicy] + [showToast, refreshRequestPolicy, refreshActivitySnapshot] ); const openRequestConfirmation = useCallback((payload: CreateRequestPayload) => { @@ -768,17 +761,6 @@ function App() { } }; - // Clear completed - const handleClearCompleted = async () => { - try { - await clearCompleted(); - await fetchStatus(); - } catch (error) { - console.error('Clear completed failed:', error); - showToast('Failed to clear finished downloads', 'error'); - } - }; - // Universal-mode "Get" action (open releases, request-book, or block by policy). const handleGetReleases = async (book: Book) => { let mode = getUniversalDefaultPolicyMode(); @@ -969,20 +951,15 @@ function App() { async (requestId: number) => { try { await cancelUserRequest(requestId); + await refreshActivitySnapshot(); showToast('Request cancelled', 'success'); } catch (error) { showToast(getErrorMessage(error, 'Failed to cancel request'), 'error'); } }, - [cancelUserRequest, showToast] + [cancelUserRequest, refreshActivitySnapshot, showToast] ); - const handleRequestDismiss = useCallback((requestId: number) => { - setDismissedRequestIds((previous) => - previous.includes(requestId) ? previous : [...previous, requestId] - ); - }, []); - const handleRequestReject = useCallback( async (requestId: number, adminNote?: string) => { if (!requestRoleIsAdmin) { @@ -991,12 +968,13 @@ function App() { try { await rejectSidebarRequest(requestId, adminNote); + await refreshActivitySnapshot(); showToast('Request rejected', 'success'); } catch (error) { showToast(getErrorMessage(error, 'Failed to reject request'), 'error'); } }, - [requestRoleIsAdmin, rejectSidebarRequest, showToast] + [refreshActivitySnapshot, requestRoleIsAdmin, rejectSidebarRequest, showToast] ); const handleRequestApprove = useCallback( @@ -1008,6 +986,7 @@ function App() { if (record.request_level === 'release') { try { await fulfilSidebarRequest(requestId, record.release_data || undefined); + await refreshActivitySnapshot(); showToast('Request approved', 'success'); await fetchStatus(); } catch (error) { @@ -1023,7 +1002,7 @@ function App() { contentType: record.content_type, }); }, - [requestRoleIsAdmin, fulfilSidebarRequest, showToast, fetchStatus] + [requestRoleIsAdmin, fulfilSidebarRequest, showToast, fetchStatus, refreshActivitySnapshot] ); const handleBrowseFulfilDownload = useCallback( @@ -1037,6 +1016,7 @@ function App() { fulfillingRequest.requestId, buildReleaseDataFromMetadataRelease(book, release, toContentType(releaseContentType)) ); + await refreshActivitySnapshot(); showToast(`Request approved: ${book.title || 'Untitled'}`, 'success'); setFulfillingRequest(null); await fetchStatus(); @@ -1046,7 +1026,7 @@ function App() { throw error; } }, - [fulfillingRequest, fulfilSidebarRequest, showToast, fetchStatus] + [fulfillingRequest, fulfilSidebarRequest, showToast, fetchStatus, refreshActivitySnapshot] ); const getDirectActionButtonState = useCallback( @@ -1128,13 +1108,17 @@ function App() { onDownloadsClick={() => setDownloadsSidebarOpen((prev) => !prev)} onSettingsClick={() => { if (config?.settings_enabled) { - setSettingsOpen(true); + if (authIsAdmin) { + setSettingsOpen(true); + } else { + setSelfSettingsOpen(true); + } } else { setConfigBannerOpen(true); } }} isAdmin={requestRoleIsAdmin} - canAccessSettings={authCanAccessSettings} + canAccessSettings={isAuthenticated} username={username} displayName={displayName} statusCounts={statusCounts} @@ -1176,6 +1160,7 @@ function App() { bottom: 0, left: 0, right: '25rem', + zIndex: 40, } : { paddingTop: `${headerHeight}px` } } @@ -1306,10 +1291,18 @@ function App() { isAdmin={requestRoleIsAdmin} onClearCompleted={handleClearCompleted} onCancel={handleCancel} + onDownloadDismiss={handleDownloadDismiss} requestItems={requestItems} + dismissedItemKeys={dismissedActivityKeys} + historyItems={historyItems} + historyHasMore={activityHistoryHasMore} + historyLoading={activityHistoryLoading} + onHistoryLoadMore={handleActivityHistoryLoadMore} + onClearHistory={handleClearHistory} + onActiveTabChange={handleActivityTabChange} pendingRequestCount={pendingRequestCount} showRequestsTab={showRequestsTab} - isRequestsLoading={isRequestsLoading} + isRequestsLoading={isRequestsLoading || isActivitySnapshotLoading} onRequestCancel={showRequestsTab ? handleRequestCancel : undefined} onRequestApprove={requestRoleIsAdmin ? handleRequestApprove : undefined} onRequestReject={requestRoleIsAdmin ? handleRequestReject : undefined} @@ -1328,6 +1321,12 @@ function App() { onSettingsSaved={handleSettingsSaved} /> + setSelfSettingsOpen(false)} + onShowToast={showToast} + /> + {/* Auto-show banner on startup for users without config */} {config && ( @@ -1339,7 +1338,11 @@ function App() { onClose={() => setConfigBannerOpen(false)} onContinue={() => { setConfigBannerOpen(false); - setSettingsOpen(true); + if (authIsAdmin) { + setSettingsOpen(true); + } else { + setSelfSettingsOpen(true); + } }} /> diff --git a/src/frontend/src/components/activity/ActivityCard.tsx b/src/frontend/src/components/activity/ActivityCard.tsx index 4101bb8..aab8493 100644 --- a/src/frontend/src/components/activity/ActivityCard.tsx +++ b/src/frontend/src/components/activity/ActivityCard.tsx @@ -14,6 +14,7 @@ interface ActivityCardProps { item: ActivityItem; isAdmin: boolean; onDownloadCancel?: (bookId: string) => void; + onDownloadDismiss?: (bookId: string, linkedRequestId?: number) => void; onRequestCancel?: (requestId: number) => void; onRequestApprove?: (requestId: number, record: RequestRecord) => void; onRequestReject?: (requestId: number) => void; @@ -145,6 +146,7 @@ export const ActivityCard = ({ item, isAdmin, onDownloadCancel, + onDownloadDismiss, onRequestCancel, onRequestApprove, onRequestReject, @@ -205,10 +207,7 @@ export const ActivityCard = ({ onDownloadCancel?.(action.bookId); break; case 'download-dismiss': - onDownloadCancel?.(action.bookId); - if (action.linkedRequestId) { - onRequestDismiss?.(action.linkedRequestId); - } + onDownloadDismiss?.(action.bookId, action.linkedRequestId); break; case 'request-approve': onRequestApprove?.(action.requestId, action.record); @@ -231,8 +230,9 @@ export const ActivityCard = ({ switch (action.kind) { case 'download-remove': case 'download-stop': - case 'download-dismiss': return Boolean(onDownloadCancel); + case 'download-dismiss': + return Boolean(onDownloadDismiss); case 'request-approve': return Boolean(onRequestApprove); case 'request-reject': @@ -309,7 +309,7 @@ export const ActivityCard = ({ -

+

{item.metaLine}

diff --git a/src/frontend/src/components/activity/ActivitySidebar.tsx b/src/frontend/src/components/activity/ActivitySidebar.tsx index 8f7399d..fbb9996 100644 --- a/src/frontend/src/components/activity/ActivitySidebar.tsx +++ b/src/frontend/src/components/activity/ActivitySidebar.tsx @@ -4,15 +4,24 @@ import { downloadToActivityItem, DownloadStatusKey } from './activityMappers'; import { ActivityItem } from './activityTypes'; import { ActivityCard } from './ActivityCard'; import { RejectDialog } from './RejectDialog'; +import { Dropdown } from '../Dropdown'; interface ActivitySidebarProps { isOpen: boolean; onClose: () => void; status: StatusData; isAdmin: boolean; - onClearCompleted: () => void; + onClearCompleted: (items: ActivityDismissTarget[]) => void; onCancel: (id: string) => void; + onDownloadDismiss?: (bookId: string, linkedRequestId?: number) => void; requestItems: ActivityItem[]; + dismissedItemKeys?: string[]; + historyItems?: ActivityItem[]; + historyHasMore?: boolean; + historyLoading?: boolean; + onHistoryLoadMore?: () => void; + onClearHistory?: () => void; + onActiveTabChange?: (tab: ActivityTabKey) => void; pendingRequestCount: number; showRequestsTab: boolean; isRequestsLoading?: boolean; @@ -24,6 +33,11 @@ interface ActivitySidebarProps { pinnedTopOffset?: number; } +export interface ActivityDismissTarget { + itemType: 'download' | 'request'; + itemKey: string; +} + export const ACTIVITY_SIDEBAR_PINNED_STORAGE_KEY = 'activity-sidebar-pinned'; const DOWNLOAD_STATUS_KEYS: DownloadStatusKey[] = [ @@ -37,53 +51,95 @@ const DOWNLOAD_STATUS_KEYS: DownloadStatusKey[] = [ ]; type ActivityCategoryKey = - | 'downloads' - | 'pending_requests' - | 'fulfilled_requests' - | 'other_requests'; + | 'needs_review' + | 'in_progress' + | 'complete' + | 'failed'; + +type ActivityTabKey = 'all' | 'downloads' | 'requests' | 'history'; +const ALL_USERS_FILTER = '__all_users__'; const getCategoryLabel = ( key: ActivityCategoryKey, isAdmin: boolean ): string => { - if (key === 'downloads') { - return 'Downloads'; + if (key === 'needs_review') { + return isAdmin ? 'Needs Review' : 'Waiting'; } - if (key === 'pending_requests') { - return 'Pending Requests'; + if (key === 'in_progress') { + return 'In Progress'; } - if (key === 'fulfilled_requests') { - return isAdmin ? 'Fulfilled Requests' : 'Completed Requests'; + if (key === 'complete') { + return 'Complete'; } - return 'Other Requests'; + return 'Failed'; }; const getVisibleCategoryOrder = ( - tab: 'all' | 'downloads' | 'requests' + tab: ActivityTabKey ): ActivityCategoryKey[] => { if (tab === 'downloads') { - return ['downloads']; + return ['in_progress', 'complete', 'failed']; } if (tab === 'requests') { - return ['pending_requests', 'fulfilled_requests', 'other_requests']; + return ['needs_review', 'in_progress', 'complete', 'failed']; } - return ['downloads', 'pending_requests', 'fulfilled_requests', 'other_requests']; + if (tab === 'history') { + return []; + } + return ['needs_review', 'in_progress', 'complete', 'failed']; }; const getActivityCategory = (item: ActivityItem): ActivityCategoryKey => { - if (!item.requestId) { - return 'downloads'; + if (item.kind === 'download') { + if ( + item.visualStatus === 'queued' || + item.visualStatus === 'resolving' || + item.visualStatus === 'locating' || + item.visualStatus === 'downloading' + ) { + return 'in_progress'; + } + if (item.visualStatus === 'complete') { + return 'complete'; + } + return 'failed'; } - if (item.requestRecord?.status === 'pending' || item.visualStatus === 'pending') { - return 'pending_requests'; + const requestStatus = item.requestRecord?.status; + if (requestStatus === 'pending' || item.visualStatus === 'pending') { + return 'needs_review'; } - if (item.requestRecord?.status === 'fulfilled' || item.visualStatus === 'fulfilled') { - return 'fulfilled_requests'; + if (requestStatus === 'rejected' || requestStatus === 'cancelled') { + return 'failed'; } - return 'other_requests'; + const deliveryState = item.requestRecord?.delivery_state; + if (requestStatus === 'fulfilled' || item.visualStatus === 'fulfilled') { + if ( + deliveryState === 'queued' || + deliveryState === 'resolving' || + deliveryState === 'locating' || + deliveryState === 'downloading' + ) { + return 'in_progress'; + } + if (deliveryState === 'error' || deliveryState === 'cancelled') { + return 'failed'; + } + // Legacy fulfilled requests often have unknown/none delivery state because the + // pre-refactor queue state was ephemeral. Treat as completed approval, not in-progress. + return 'complete'; + } + + if (deliveryState === 'complete') { + return 'complete'; + } + if (deliveryState === 'error' || deliveryState === 'cancelled') { + return 'failed'; + } + return 'in_progress'; }; const getLinkedDownloadIdFromRequestItem = (item: ActivityItem): string | null => { @@ -138,6 +194,15 @@ const dedupeById = (items: ActivityItem[]): ActivityItem[] => { return Array.from(byId.values()); }; +const getItemUsername = (item: ActivityItem): string | null => { + const candidate = item.username || item.requestRecord?.username; + if (typeof candidate !== 'string') { + return null; + } + const normalized = candidate.trim(); + return normalized || null; +}; + const parsePinned = (value: string | null): boolean => { if (!value) { return false; @@ -170,7 +235,15 @@ export const ActivitySidebar = ({ isAdmin, onClearCompleted, onCancel, + onDownloadDismiss, requestItems, + dismissedItemKeys = [], + historyItems = [], + historyHasMore = false, + historyLoading = false, + onHistoryLoadMore, + onClearHistory, + onActiveTabChange, pendingRequestCount, showRequestsTab, isRequestsLoading = false, @@ -183,10 +256,15 @@ export const ActivitySidebar = ({ }: ActivitySidebarProps) => { const [isPinned, setIsPinned] = useState(() => getInitialPinnedPreference()); const [isDesktop, setIsDesktop] = useState(() => getInitialDesktopState()); - const [activeTab, setActiveTab] = useState<'all' | 'downloads' | 'requests'>('all'); + const [activeTab, setActiveTab] = useState('all'); + const [selectedUser, setSelectedUser] = useState(ALL_USERS_FILTER); const [rejectingRequest, setRejectingRequest] = useState<{ requestId: number; bookTitle: string } | null>(null); const [collapsedGroups, setCollapsedGroups] = useState>({}); const scrollViewportRef = useRef(null); + const dismissedKeySet = useMemo( + () => new Set(dismissedItemKeys), + [dismissedItemKeys] + ); useEffect(() => { const mediaQuery = window.matchMedia('(min-width: 1024px)'); @@ -209,6 +287,10 @@ export const ActivitySidebar = ({ } }, [showRequestsTab, activeTab]); + useEffect(() => { + onActiveTabChange?.(activeTab); + }, [activeTab, onActiveTabChange]); + useEffect(() => { if (activeTab === 'downloads') { setRejectingRequest(null); @@ -245,12 +327,29 @@ export const ActivitySidebar = ({ return; } Object.values(bucket).forEach((book) => { + const itemKey = `download:${book.id}`; + const isTerminalStatus = + statusKey === 'complete' || statusKey === 'error' || statusKey === 'cancelled'; + if (isTerminalStatus && dismissedKeySet.has(itemKey)) { + return; + } items.push(downloadToActivityItem(book, statusKey)); }); }); return items.sort((left, right) => right.timestamp - left.timestamp); - }, [status]); + }, [dismissedKeySet, status]); + + const visibleRequestItems = useMemo( + () => + requestItems.filter((item) => { + if (!item.requestId) { + return true; + } + return !dismissedKeySet.has(`request:${item.requestId}`); + }), + [dismissedKeySet, requestItems] + ); const { mergedRequestItems, mergedDownloadItems } = useMemo(() => { const downloadsById = new Map(); @@ -261,7 +360,7 @@ export const ActivitySidebar = ({ }); const mergedByDownloadId = new Map(); - const nextRequestItems = requestItems.map((requestItem) => { + const nextRequestItems = visibleRequestItems.map((requestItem) => { const linkedDownloadId = getLinkedDownloadIdFromRequestItem(requestItem); if (!linkedDownloadId) { return requestItem; @@ -291,7 +390,7 @@ export const ActivitySidebar = ({ mergedRequestItems: nextRequestItems, mergedDownloadItems: nextDownloadItems, }; - }, [downloadItems, requestItems]); + }, [downloadItems, visibleRequestItems]); const hasTerminalDownloadItems = useMemo( () => @@ -307,11 +406,84 @@ export const ActivitySidebar = ({ return combined.sort((a, b) => b.timestamp - a.timestamp); }, [mergedDownloadItems, mergedRequestItems]); - const visibleItems = activeTab === 'all' + const baseVisibleItems = activeTab === 'all' ? allItems : activeTab === 'requests' - ? mergedRequestItems - : mergedDownloadItems; + ? mergedRequestItems.filter((item) => { + const requestStatus = item.requestRecord?.status; + if (requestStatus === 'pending' || requestStatus === 'rejected' || requestStatus === 'cancelled') { + return true; + } + return requestStatus === 'fulfilled' && item.kind === 'request'; + }) + : activeTab === 'history' + ? historyItems + : mergedDownloadItems; + + const availableUsers = useMemo(() => { + const userMap = new Map(); + baseVisibleItems.forEach((item) => { + const username = getItemUsername(item); + if (!username) { + return; + } + const lookupKey = username.toLowerCase(); + if (!userMap.has(lookupKey)) { + userMap.set(lookupKey, username); + } + }); + + return Array.from(userMap.values()).sort((left, right) => left.localeCompare(right)); + }, [baseVisibleItems]); + + useEffect(() => { + if (selectedUser === ALL_USERS_FILTER) { + return; + } + if (!availableUsers.includes(selectedUser)) { + setSelectedUser(ALL_USERS_FILTER); + } + }, [availableUsers, selectedUser]); + + const visibleItems = useMemo(() => { + if (selectedUser === ALL_USERS_FILTER) { + return baseVisibleItems; + } + return baseVisibleItems.filter((item) => getItemUsername(item) === selectedUser); + }, [baseVisibleItems, selectedUser]); + + const hasUserFilter = isAdmin && availableUsers.length > 1; + + const clearCompletedTargets = useMemo(() => { + const targets: ActivityDismissTarget[] = []; + const seen = new Set(); + + visibleItems.forEach((item) => { + const isTerminalDownload = + item.kind === 'download' && + (item.visualStatus === 'complete' || item.visualStatus === 'error' || item.visualStatus === 'cancelled'); + + if (!isTerminalDownload || !item.downloadBookId) { + return; + } + + const downloadKey = `download:${item.downloadBookId}`; + if (!seen.has(downloadKey)) { + seen.add(downloadKey); + targets.push({ itemType: 'download', itemKey: downloadKey }); + } + + if (item.requestId) { + const requestKey = `request:${item.requestId}`; + if (!seen.has(requestKey)) { + seen.add(requestKey); + targets.push({ itemType: 'request', itemKey: requestKey }); + } + } + }); + + return targets; + }, [visibleItems]); const visibleCategoryOrder = useMemo( () => getVisibleCategoryOrder(activeTab), @@ -319,11 +491,15 @@ export const ActivitySidebar = ({ ); const groupedVisibleItems = useMemo(() => { + if (activeTab === 'history') { + return []; + } + const grouped = new Map(); visibleCategoryOrder.forEach((key) => grouped.set(key, [])); visibleItems.forEach((item) => { - const category = activeTab === 'downloads' ? 'downloads' : getActivityCategory(item); + const category = getActivityCategory(item); if (!grouped.has(category)) { grouped.set(category, []); } @@ -355,31 +531,33 @@ export const ActivitySidebar = ({ useEffect(() => { const activeButton = tabRefs.current[activeTab]; - if (activeButton) { - const containerRect = activeButton.parentElement?.getBoundingClientRect(); - const buttonRect = activeButton.getBoundingClientRect(); - if (containerRect) { - setTabIndicatorStyle({ - left: buttonRect.left - containerRect.left, - width: buttonRect.width, - }); - } + if (!activeButton) { + setTabIndicatorStyle({ left: 0, width: 0 }); + return; + } + + const containerRect = activeButton.parentElement?.getBoundingClientRect(); + const buttonRect = activeButton.getBoundingClientRect(); + if (containerRect) { + setTabIndicatorStyle({ + left: buttonRect.left - containerRect.left, + width: buttonRect.width, + }); } }, [activeTab, showRequestsTab]); const panel = ( <>
-

Activity

- -
+
+

{activeTab === 'history' ? 'History' : 'Activity'}

+
+ +
+ {hasUserFilter && ( + ( + + )} + > + {({ close }) => ( +
+ {[ALL_USERS_FILTER, ...availableUsers].map((value) => { + const isSelected = selectedUser === value; + const label = value === ALL_USERS_FILTER ? 'All users' : value; + return ( + + ); + })} +
+ )} +
+ )} +
- {showRequestsTab && ( + {activeTab !== 'history' && (
{/* Sliding indicator */} @@ -452,24 +701,26 @@ export const ActivitySidebar = ({ )} - + {showRequestsTab && ( + + )}
)} @@ -484,11 +735,36 @@ export const ActivitySidebar = ({

{activeTab === 'requests' ? isRequestsLoading ? 'Loading requests...' : 'No requests' + : activeTab === 'history' + ? historyLoading ? 'Loading history...' : 'No history' : activeTab === 'downloads' ? 'No downloads' : 'No activity'}

) : ( + activeTab === 'history' ? ( +
+ {visibleItems.map((item) => ( + + ))} + {historyHasMore && ( +
+ +
+ )} +
+ ) : ( groupedVisibleItems.map((group) => (
{activeTab !== 'downloads' && ( @@ -527,6 +803,7 @@ export const ActivitySidebar = ({ item={item} isAdmin={isAdmin} onDownloadCancel={onCancel} + onDownloadDismiss={onDownloadDismiss} onRequestCancel={onRequestCancel} onRequestApprove={onRequestApprove} onRequestDismiss={onRequestDismiss} @@ -554,10 +831,11 @@ export const ActivitySidebar = ({ )}
)) + ) )}
- {(activeTab === 'downloads' || activeTab === 'all') && hasTerminalDownloadItems && ( + {(activeTab === 'downloads' || activeTab === 'all') && hasTerminalDownloadItems && clearCompletedTargets.length > 0 && (
)} + + {activeTab === 'history' && historyItems.length > 0 && ( +
+ +
+ )} ); diff --git a/src/frontend/src/components/activity/activityCardModel.ts b/src/frontend/src/components/activity/activityCardModel.ts index 8fd53a2..e0cc62b 100644 --- a/src/frontend/src/components/activity/activityCardModel.ts +++ b/src/frontend/src/components/activity/activityCardModel.ts @@ -53,10 +53,10 @@ const toRequestVisualStatus = (status: RequestRecord['status']): ActivityVisualS const getPendingRequestText = (item: ActivityItem, isAdmin: boolean): string => { if (!isAdmin) { - return 'Pending'; + return 'Awaiting review'; } const username = item.username?.trim() || item.requestRecord?.username?.trim(); - return username ? `Requested by ${username}` : 'Requested'; + return username ? `Needs review · ${username}` : 'Needs review'; }; const getRequestBadge = (item: ActivityItem, isAdmin: boolean): ActivityCardBadge => { @@ -72,15 +72,15 @@ const getRequestBadge = (item: ActivityItem, isAdmin: boolean): ActivityCardBadg let text = item.statusLabel; if (hasInFlightLinkedDownload) { - text = isAdmin ? 'Request approved' : 'Approved'; + text = 'Approved'; } else if (requestVisualStatus === 'pending') { text = getPendingRequestText(item, isAdmin); } else if (requestVisualStatus === 'fulfilled') { - text = isAdmin ? 'Request fulfilled' : 'Approved'; + text = 'Approved'; } else if (requestVisualStatus === 'rejected') { - text = 'Rejected'; + text = isAdmin ? 'Declined' : 'Not approved'; } else if (requestVisualStatus === 'cancelled') { - text = 'Cancelled'; + text = isAdmin ? 'Cancelled by requester' : 'Cancelled'; } return { @@ -109,6 +109,10 @@ const getDownloadBadge = (item: ActivityItem): ActivityCardBadge => { }; const buildBadges = (item: ActivityItem, isAdmin: boolean): ActivityCardBadge[] => { + if (item.kind === 'download' && item.visualStatus === 'complete') { + return [getDownloadBadge(item)]; + } + if (item.kind === 'download' && item.requestId && item.requestRecord) { return [getRequestBadge(item, isAdmin), getDownloadBadge(item)]; } diff --git a/src/frontend/src/components/activity/index.ts b/src/frontend/src/components/activity/index.ts index df4514e..4ffa07f 100644 --- a/src/frontend/src/components/activity/index.ts +++ b/src/frontend/src/components/activity/index.ts @@ -11,3 +11,4 @@ export { } from './activityStyles'; export type { ActivityItem, ActivityKind, ActivityVisualStatus } from './activityTypes'; export { ACTIVITY_SIDEBAR_PINNED_STORAGE_KEY } from './ActivitySidebar'; +export type { ActivityDismissTarget } from './ActivitySidebar'; diff --git a/src/frontend/src/components/settings/SelfSettingsModal.tsx b/src/frontend/src/components/settings/SelfSettingsModal.tsx new file mode 100644 index 0000000..0892282 --- /dev/null +++ b/src/frontend/src/components/settings/SelfSettingsModal.tsx @@ -0,0 +1,357 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + AdminUser, + DeliveryPreferencesResponse, + getSelfUserEditContext, + updateSelfUser, +} from '../../services/api'; +import { SelectField } from './fields'; +import { FieldWrapper } from './shared'; +import { UserAccountCardContent, UserEditActions, UserIdentityHeader } from './users/UserCard'; +import { UserOverridesSection } from './users/UserOverridesSection'; +import { buildUserSettingsPayload } from './users/settingsPayload'; +import { PerUserSettings } from './users/types'; +import { getStoredThemePreference, setThemePreference, THEME_FIELD } from '../../utils/themePreference'; + +interface SelfSettingsModalProps { + isOpen: boolean; + onClose: () => void; + onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void; +} + +const MIN_PASSWORD_LENGTH = 4; + +const normalizeUserSettings = (settings: PerUserSettings): PerUserSettings => { + const normalized: PerUserSettings = {}; + Object.keys(settings).sort().forEach((key) => { + const typedKey = key as keyof PerUserSettings; + const value = settings[typedKey]; + if (value !== null && value !== undefined) { + normalized[typedKey] = value; + } + }); + return normalized; +}; + +const getPasswordError = (password: string, passwordConfirm: string): string | null => { + if (!password && !passwordConfirm) { + return null; + } + if (!password) { + return 'Password is required'; + } + if (password.length < MIN_PASSWORD_LENGTH) { + return `Password must be at least ${MIN_PASSWORD_LENGTH} characters`; + } + return password === passwordConfirm ? null : 'Passwords do not match'; +}; + +const getErrorMessage = (error: unknown, fallback: string): string => { + if (error instanceof Error && error.message) { + return error.message; + } + return fallback; +}; + +export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettingsModalProps) => { + const [isClosing, setIsClosing] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + const [editingUser, setEditingUser] = useState(null); + const [originalUser, setOriginalUser] = useState(null); + const [deliveryPreferences, setDeliveryPreferences] = useState(null); + + const [editPassword, setEditPassword] = useState(''); + const [editPasswordConfirm, setEditPasswordConfirm] = useState(''); + + const [userSettings, setUserSettings] = useState({}); + const [originalUserSettings, setOriginalUserSettings] = useState({}); + const [userOverridableSettings, setUserOverridableSettings] = useState>(new Set()); + const [themeValue, setThemeValue] = useState(getStoredThemePreference()); + + const loadEditContext = useCallback(async () => { + setIsLoading(true); + setLoadError(null); + try { + const context = await getSelfUserEditContext(); + const normalizedSettings = normalizeUserSettings((context.user.settings || {}) as PerUserSettings); + + setEditingUser(context.user); + setOriginalUser(context.user); + setDeliveryPreferences(context.deliveryPreferences || null); + setUserSettings(normalizedSettings); + setOriginalUserSettings(normalizedSettings); + setUserOverridableSettings(new Set(context.userOverridableKeys || [])); + setEditPassword(''); + setEditPasswordConfirm(''); + } catch (error) { + setLoadError(getErrorMessage(error, 'Failed to load account settings')); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + if (!isOpen) { + return; + } + setIsClosing(false); + void loadEditContext(); + }, [isOpen, loadEditContext]); + + useEffect(() => { + if (!isOpen) { + return; + } + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [isOpen]); + + const handleClose = useCallback(() => { + if (isSaving) { + return; + } + setIsClosing(true); + setTimeout(() => { + onClose(); + setIsClosing(false); + }, 150); + }, [isSaving, onClose]); + + useEffect(() => { + if (!isOpen) { + return; + } + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + handleClose(); + } + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen, handleClose]); + + const isUserOverridable = useCallback( + (key: keyof PerUserSettings) => userOverridableSettings.has(String(key)), + [userOverridableSettings] + ); + + const currentSettingsPayload = useMemo( + () => buildUserSettingsPayload(userSettings, userOverridableSettings, deliveryPreferences), + [deliveryPreferences, userOverridableSettings, userSettings] + ); + + const originalSettingsPayload = useMemo( + () => buildUserSettingsPayload(originalUserSettings, userOverridableSettings, deliveryPreferences), + [deliveryPreferences, originalUserSettings, userOverridableSettings] + ); + + const hasSettingsChanges = + JSON.stringify(currentSettingsPayload) !== JSON.stringify(originalSettingsPayload); + + const hasProfileChanges = Boolean( + editingUser + && originalUser + && ( + editingUser.email !== originalUser.email + || editingUser.display_name !== originalUser.display_name + ) + ); + + const hasPasswordChanges = editPassword.length > 0 || editPasswordConfirm.length > 0; + const passwordError = getPasswordError(editPassword, editPasswordConfirm); + const hasChanges = hasSettingsChanges || hasProfileChanges || hasPasswordChanges; + + const handleSave = useCallback(async () => { + if (!editingUser || !originalUser) { + return; + } + if (passwordError) { + onShowToast?.(passwordError, 'error'); + return; + } + + const payload: { + email?: string | null; + display_name?: string | null; + password?: string; + settings?: Record; + } = {}; + + if ( + editingUser.edit_capabilities.canEditEmail + && editingUser.email !== originalUser.email + ) { + payload.email = editingUser.email; + } + if ( + editingUser.edit_capabilities.canEditDisplayName + && editingUser.display_name !== originalUser.display_name + ) { + payload.display_name = editingUser.display_name; + } + if (editingUser.edit_capabilities.canSetPassword && editPassword) { + payload.password = editPassword; + } + if (hasSettingsChanges) { + payload.settings = currentSettingsPayload; + } + + if (Object.keys(payload).length === 0) { + return; + } + + setIsSaving(true); + try { + await updateSelfUser(payload); + onShowToast?.('Account updated', 'success'); + await loadEditContext(); + } catch (error) { + onShowToast?.(getErrorMessage(error, 'Failed to update account'), 'error'); + } finally { + setIsSaving(false); + } + }, [ + currentSettingsPayload, + editingUser, + hasSettingsChanges, + loadEditContext, + onShowToast, + originalUser, + passwordError, + editPassword, + ]); + + if (!isOpen && !isClosing) { + return null; + } + + const titleId = 'self-settings-modal-title'; + const hasCachedEditContext = Boolean(editingUser); + const showInitialLoadingState = isLoading && !hasCachedEditContext; + const showInitialLoadErrorState = Boolean(loadError) && !hasCachedEditContext; + + return ( +
+
+ +
+
+

My Account

+ {editingUser ? ( + + ) : ( +
My Account
+ )} +
+ +
+
+ +
+ {showInitialLoadingState ? ( +
+ Loading account settings... +
+ ) : showInitialLoadErrorState ? ( +
+

{loadError}

+ +
+ ) : editingUser ? ( +
+ + { + setThemeValue(value); + setThemePreference(value); + }} + /> + + + {}} + saving={isSaving} + onCancel={handleClose} + hideEditActions + editPassword={editPassword} + onEditPasswordChange={setEditPassword} + editPasswordConfirm={editPasswordConfirm} + onEditPasswordConfirmChange={setEditPasswordConfirm} + preferencesPlacement="after" + preferencesPanel={{ + hideTitle: true, + children: ( +
+ setUserSettings(updater)} + /> +
+ ), + }} + /> +
+ ) : ( +
+ Unable to load account details. +
+ )} +
+ +
+ { + void handleSave(); + }} + saving={isSaving} + saveDisabled={!hasChanges || isSaving || isLoading} + onCancel={handleClose} + cancelDisabled={isSaving} + /> +
+
+
+ ); +}; diff --git a/src/frontend/src/components/settings/fields/HeadingField.tsx b/src/frontend/src/components/settings/fields/HeadingField.tsx index 2189bbd..43a3cae 100644 --- a/src/frontend/src/components/settings/fields/HeadingField.tsx +++ b/src/frontend/src/components/settings/fields/HeadingField.tsx @@ -5,7 +5,7 @@ interface HeadingFieldProps { } export const HeadingField = ({ field }: HeadingFieldProps) => ( -
+

{field.title}

{field.description && (

diff --git a/src/frontend/src/components/settings/index.ts b/src/frontend/src/components/settings/index.ts index c7833e5..ce87ac2 100644 --- a/src/frontend/src/components/settings/index.ts +++ b/src/frontend/src/components/settings/index.ts @@ -1,4 +1,5 @@ export { SettingsModal } from './SettingsModal'; +export { SelfSettingsModal } from './SelfSettingsModal'; export { SettingsHeader } from './SettingsHeader'; export { SettingsSidebar } from './SettingsSidebar'; export { SettingsContent } from './SettingsContent'; diff --git a/src/frontend/src/components/settings/users/RequestPolicyGrid.tsx b/src/frontend/src/components/settings/users/RequestPolicyGrid.tsx index 695b366..6bc5e73 100644 --- a/src/frontend/src/components/settings/users/RequestPolicyGrid.tsx +++ b/src/frontend/src/components/settings/users/RequestPolicyGrid.tsx @@ -45,10 +45,10 @@ const formatSourceLabel = (source: string): string => { const toRuleKey = (source: string, contentType: RequestPolicyContentType) => `${source}::${contentType}`; const modeDescriptions: Record = { - download: 'Direct downloads allowed.', - request_release: 'Specific release requests only.', - request_book: 'Book-level requests only.', - blocked: 'Unavailable.', + download: 'Users can download directly.', + request_release: 'Users pick a release and request it.', + request_book: 'Users can request a book, admin picks the release.', + blocked: 'Downloads and requests are blocked.', }; export const RequestPolicyGrid = ({ @@ -328,7 +328,7 @@ export const RequestPolicyGrid = ({ ) : (

- Per-source overrides are available when a default is set to Download or Request Release. + Per-source settings become available when a default is set to Download or Request Release.

)} diff --git a/src/frontend/src/components/settings/users/UserCard.tsx b/src/frontend/src/components/settings/users/UserCard.tsx index 8d7fccd..8fb884b 100644 --- a/src/frontend/src/components/settings/users/UserCard.tsx +++ b/src/frontend/src/components/settings/users/UserCard.tsx @@ -1,6 +1,9 @@ import { ReactNode } from 'react'; import { AdminUser } from '../../../services/api'; import { PasswordFieldConfig, SelectFieldConfig, SelectOption, TextFieldConfig } from '../../../types/settings'; +import { DropdownList } from '../../DropdownList'; +import { Tooltip } from '../../shared/Tooltip'; +import { UserAuthSourceBadge } from './UserAuthSourceBadge'; import { PasswordField, SelectField, TextField } from '../fields'; import { FieldWrapper } from '../shared'; import { CreateUserFormState } from './types'; @@ -17,6 +20,11 @@ const CREATE_ROLE_OPTIONS: SelectOption[] = [ { value: 'admin', label: 'Admin' }, ]; +const EDIT_ROLE_OPTIONS: SelectOption[] = [ + { value: 'admin', label: 'Admin' }, + { value: 'user', label: 'User' }, +]; + const createTextField = ( key: string, label: string, @@ -89,6 +97,240 @@ const renderPasswordField = ( ); +const getRoleLabel = (role: string) => role.charAt(0).toUpperCase() + role.slice(1); + +const getRoleBadgeClassName = (role: string, disabled = false) => ( + `inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium leading-none ${ + disabled ? 'cursor-not-allowed' : '' + } ${role === 'admin' ? 'bg-sky-500/15 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/10 opacity-70'}` +); + +const getRoleDisabledReason = (user: AdminUser, oidcAdminGroup?: string): string => { + if (user.edit_capabilities.authSource === 'oidc') { + if (oidcAdminGroup) { + return `Role is managed by the ${oidcAdminGroup} group in your identity provider.`; + } + return 'Role is managed by OIDC group authorization.'; + } + if (user.edit_capabilities.authSource === 'builtin') { + return 'Role can only be changed by admins.'; + } + return 'Role is managed by the external authentication source.'; +}; + +interface UserRoleControlProps { + user: AdminUser; + onUserChange?: (user: AdminUser) => void; + oidcAdminGroup?: string; + tooltipPosition?: 'top' | 'bottom' | 'left' | 'right'; +} + +export const UserRoleControl = ({ + user, + onUserChange, + oidcAdminGroup, + tooltipPosition = 'bottom', +}: UserRoleControlProps) => { + const roleLabel = getRoleLabel(user.role); + const canEditRole = Boolean(onUserChange) && user.edit_capabilities.canEditRole; + const roleDisabledReason = !user.edit_capabilities.canEditRole + ? getRoleDisabledReason(user, oidcAdminGroup) + : undefined; + + if (canEditRole && onUserChange) { + return ( + { + const nextRole = Array.isArray(value) ? value[0] ?? '' : value; + onUserChange({ ...user, role: nextRole }); + }} + widthClassName="w-28" + buttonClassName={`!py-1 !px-2.5 !text-xs !font-medium ${ + user.role === 'admin' + ? '!bg-sky-500/15 !text-sky-600 dark:!text-sky-400 !border-sky-500/30' + : '!bg-zinc-500/10 !opacity-70' + }`} + /> + ); + } + + if (onUserChange && !user.edit_capabilities.canEditRole) { + return ( + + + {roleLabel} + + + ); + } + + return ( + + {roleLabel} + + ); +}; + +interface UserIdentityHeaderProps { + user: AdminUser; + showAuthSource?: boolean; + showInactiveState?: boolean; +} + +export const UserIdentityHeader = ({ + user, + showAuthSource = true, + showInactiveState = true, +}: UserIdentityHeaderProps) => { + const active = user.is_active !== false; + + return ( +
+
+ {user.username.charAt(0).toUpperCase()} +
+
+
+ + {user.display_name || user.username} + + {user.display_name && ( + @{user.username} + )} + {showAuthSource && } +
+
+ {user.email || 'No email'} +
+ {showInactiveState && !active && ( +
+ Inactive for current authentication mode +
+ )} +
+
+ ); +}; + +interface UserEditActionsProps { + onSave: () => void; + saving: boolean; + saveDisabled?: boolean; + onCancel: () => void; + cancelDisabled?: boolean; + onDelete?: () => void; + onConfirmDelete?: () => void; + onCancelDelete?: () => void; + isDeletePending?: boolean; + deleting?: boolean; + variant?: 'card' | 'modalFooter'; +} + +export const UserEditActions = ({ + onSave, + saving, + saveDisabled = false, + onCancel, + cancelDisabled = false, + onDelete, + onConfirmDelete, + onCancelDelete, + isDeletePending = false, + deleting = false, + variant = 'card', +}: UserEditActionsProps) => { + if (variant === 'modalFooter') { + return ( +
+ + +
+ ); + } + + return ( +
+
+ + +
+ {onDelete && ( +
+ {isDeletePending ? ( + <> + + + + ) : ( + + )} +
+ )} +
+ ); +}; + interface UserCreateCardProps { form: CreateUserFormState; onChange: (form: CreateUserFormState) => void; @@ -168,6 +410,7 @@ interface UserEditFieldsProps { onSave: () => void; saving: boolean; onCancel: () => void; + hideActions?: boolean; editPassword: string; onEditPasswordChange: (value: string) => void; editPasswordConfirm: string; @@ -185,6 +428,7 @@ export const UserEditFields = ({ onSave, saving, onCancel, + hideActions = false, editPassword, onEditPasswordChange, editPasswordConfirm, @@ -241,55 +485,116 @@ export const UserEditFields = ({ )} -
-
- - -
- {onDelete && ( -
- {isDeletePending ? ( - <> - - - - ) : ( - - )} -
- )} -
+ {!hideActions && ( + + )} ); }; + +interface UserPreferencesPanelProps { + description?: string; + hideTitle?: boolean; + actionLabel?: string; + onAction?: () => void; + children?: ReactNode; +} + +interface UserAccountCardContentProps extends Omit { + hideEditActions?: boolean; + preferencesPanel?: UserPreferencesPanelProps; + preferencesPlacement?: 'before' | 'after'; +} + +const renderPreferencesPanel = (panel: UserPreferencesPanelProps) => ( +
+ {(!panel.hideTitle || panel.onAction) && ( +
+ {!panel.hideTitle && ( + + )} + {!panel.hideTitle && panel.description && ( +

{panel.description}

+ )} + {panel.onAction && ( + + )} +
+ )} + {panel.children} +
+); + +export const UserAccountCardContent = ({ + user, + onUserChange, + onSave, + saving, + onCancel, + hideEditActions = false, + editPassword, + onEditPasswordChange, + editPasswordConfirm, + onEditPasswordConfirmChange, + onDelete, + onConfirmDelete, + onCancelDelete, + isDeletePending, + deleting, + preferencesPanel, + preferencesPlacement = 'before', +}: UserAccountCardContentProps) => { + const preferencesContent = preferencesPanel ? renderPreferencesPanel(preferencesPanel) : null; + + return ( +
+ {preferencesContent && preferencesPlacement === 'before' && ( + <> + {preferencesContent} +
+ + )} + + + + {preferencesContent && preferencesPlacement === 'after' && ( + <> +
+ {preferencesContent} + + )} +
+ ); +}; diff --git a/src/frontend/src/components/settings/users/UserListView.tsx b/src/frontend/src/components/settings/users/UserListView.tsx index d29ca91..0287163 100644 --- a/src/frontend/src/components/settings/users/UserListView.tsx +++ b/src/frontend/src/components/settings/users/UserListView.tsx @@ -1,18 +1,10 @@ import { useState } from 'react'; import { AdminUser, DownloadDefaults } from '../../../services/api'; -import { DropdownList } from '../../DropdownList'; -import { Tooltip } from '../../shared/Tooltip'; import { canCreateLocalUsersForAuthMode, CreateUserFormState, } from './types'; -import { UserAuthSourceBadge } from './UserAuthSourceBadge'; -import { UserCreateCard, UserEditFields } from './UserCard'; - -const EDIT_ROLE_OPTIONS = [ - { value: 'admin', label: 'Admin' }, - { value: 'user', label: 'User' }, -]; +import { UserAccountCardContent, UserCreateCard, UserIdentityHeader, UserRoleControl } from './UserCard'; interface UserListViewProps { authMode: string; @@ -123,7 +115,6 @@ export const UserListView = ({ const active = user.is_active !== false; const isEditingRow = showEditForm && activeEditUserId === user.id; const hasLoadedEditUser = isEditingRow && editingUser?.id === user.id; - const roleLabel = user.role.charAt(0).toUpperCase() + user.role.slice(1); return (
-
-
- {user.username.charAt(0).toUpperCase()} -
-
-
- - {user.display_name || user.username} - - {user.display_name && ( - @{user.username} - )} - -
-
- {user.email || 'No email'} -
- {!active && ( -
- Inactive for current authentication mode -
- )} -
-
+
- {hasLoadedEditUser && editingUser ? (() => { - const caps = editingUser.edit_capabilities; - const canEditRole = caps.canEditRole; - const roleDisabledReason = !canEditRole - ? (caps.authSource === 'oidc' - ? (downloadDefaults?.OIDC_ADMIN_GROUP - ? `Role is managed by the ${downloadDefaults.OIDC_ADMIN_GROUP} group in your identity provider.` - : 'Role is managed by OIDC group authorization.') - : 'Role is managed by the external authentication source.') - : undefined; - - if (!canEditRole) { - return ( - - - {editingUser.role.charAt(0).toUpperCase() + editingUser.role.slice(1)} - - - ); - } - - return ( - { - const val = Array.isArray(value) ? value[0] ?? '' : value; - onEditingUserChange({ ...editingUser, role: val }); - }} - widthClassName="w-28" - buttonClassName={`!py-1 !px-2.5 !text-xs !font-medium ${ - editingUser.role === 'admin' - ? '!bg-sky-500/15 !text-sky-600 dark:!text-sky-400 !border-sky-500/30' - : '!bg-zinc-500/10 !opacity-70' - }`} - /> - ); - })() : ( - - {roleLabel} - + {hasLoadedEditUser && editingUser ? ( + + ) : ( + )} -
- -
- - setConfirmDelete(user.id)} - onConfirmDelete={() => handleDelete(user.id)} - onCancelDelete={() => setConfirmDelete(null)} - isDeletePending={confirmDelete === user.id} - deleting={deletingUserId === user.id} - /> - + setConfirmDelete(user.id)} + onConfirmDelete={() => handleDelete(user.id)} + onCancelDelete={() => setConfirmDelete(null)} + isDeletePending={confirmDelete === user.id} + deleting={deletingUserId === user.id} + preferencesPanel={{ + description: 'Customise delivery and request settings for this user.', + actionLabel: 'Open User Preferences', + onAction: onOpenOverrides, + }} + /> ) : (
Loading user details...
)} diff --git a/src/frontend/src/components/settings/users/UserRequestPolicyOverridesSection.tsx b/src/frontend/src/components/settings/users/UserRequestPolicyOverridesSection.tsx index a5ea541..493b059 100644 --- a/src/frontend/src/components/settings/users/UserRequestPolicyOverridesSection.tsx +++ b/src/frontend/src/components/settings/users/UserRequestPolicyOverridesSection.tsx @@ -35,8 +35,8 @@ const REQUEST_POLICY_OVERRIDE_KEYS: Array = [ const requestPolicyHeading: HeadingFieldConfig = { type: 'HeadingField', key: 'request_policy_overrides_heading', - title: 'Request Policy', - description: 'User-level request policy overrides. Reset to inherit global policy values.', + title: 'Requests', + description: 'Custom request settings for this user. Reset any to fall back to the global defaults.', }; const hasOwnNonNull = (settings: PerUserSettings, key: keyof PerUserSettings): boolean => { diff --git a/src/frontend/src/components/settings/users/index.ts b/src/frontend/src/components/settings/users/index.ts index 321134e..b58b37a 100644 --- a/src/frontend/src/components/settings/users/index.ts +++ b/src/frontend/src/components/settings/users/index.ts @@ -1,5 +1,12 @@ export { UserAuthSourceBadge } from './UserAuthSourceBadge'; -export { UserCreateCard, UserEditFields } from './UserCard'; +export { + UserAccountCardContent, + UserCreateCard, + UserEditActions, + UserEditFields, + UserIdentityHeader, + UserRoleControl, +} from './UserCard'; export { UserListView } from './UserListView'; export { RequestPolicyGrid } from './RequestPolicyGrid'; export { UserOverridesSection } from './UserOverridesSection'; diff --git a/src/frontend/src/components/settings/users/requestPolicyGridUtils.ts b/src/frontend/src/components/settings/users/requestPolicyGridUtils.ts index 64be067..407aaa3 100644 --- a/src/frontend/src/components/settings/users/requestPolicyGridUtils.ts +++ b/src/frontend/src/components/settings/users/requestPolicyGridUtils.ts @@ -29,22 +29,22 @@ export const REQUEST_POLICY_DEFAULT_OPTIONS: Array<{ { value: 'download', label: 'Download', - description: 'Allow direct downloads.', + description: 'Everything can be downloaded directly.', }, { value: 'request_release', label: 'Request Release', - description: 'Require requesting a specific release.', + description: 'Users must request a specific release.', }, { value: 'request_book', label: 'Request Book', - description: 'Allow book-level requests only.', + description: 'Users request a book, admin picks the release.', }, { value: 'blocked', label: 'Blocked', - description: 'Block downloads and requests.', + description: 'No downloads or requests allowed.', }, ]; diff --git a/src/frontend/src/hooks/useActivity.ts b/src/frontend/src/hooks/useActivity.ts new file mode 100644 index 0000000..fafce4b --- /dev/null +++ b/src/frontend/src/hooks/useActivity.ts @@ -0,0 +1,417 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Socket } from 'socket.io-client'; +import { Book, RequestRecord, StatusData } from '../types'; +import { + ActivityHistoryItem, + ActivityDismissPayload, + clearActivityHistory, + dismissActivityItem, + dismissManyActivityItems, + getActivitySnapshot, + listActivityHistory, +} from '../services/api'; +import { + ActivityDismissTarget, + ActivityItem, + downloadToActivityItem, + requestToActivityItem, +} from '../components/activity'; + +const HISTORY_PAGE_SIZE = 50; + +const parseTimestamp = (value: string | null | undefined, fallback: number = 0): number => { + if (!value) { + return fallback; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : fallback; +}; + +const mapHistoryRowToActivityItem = ( + row: ActivityHistoryItem, + viewerRole: 'user' | 'admin' +): ActivityItem => { + const dismissedAtTs = parseTimestamp(row.dismissed_at); + const snapshot = row.snapshot; + if (snapshot && typeof snapshot === 'object') { + const payload = snapshot as Record; + if (payload.kind === 'download' && payload.download && typeof payload.download === 'object') { + const statusKey = row.final_status === 'error' || row.final_status === 'cancelled' + ? row.final_status + : 'complete'; + const downloadItem = downloadToActivityItem(payload.download as Book, statusKey); + const requestPayload = payload.request; + if (requestPayload && typeof requestPayload === 'object') { + const requestRecord = requestPayload as RequestRecord; + return { + ...downloadItem, + id: `history-${row.id}`, + timestamp: dismissedAtTs || downloadItem.timestamp, + requestId: requestRecord.id, + requestLevel: requestRecord.request_level, + requestNote: requestRecord.note || undefined, + requestRecord, + adminNote: requestRecord.admin_note || undefined, + username: requestRecord.username || downloadItem.username, + }; + } + + return { + ...downloadItem, + id: `history-${row.id}`, + timestamp: dismissedAtTs || downloadItem.timestamp, + }; + } + + if (payload.kind === 'request' && payload.request && typeof payload.request === 'object') { + const requestItem = requestToActivityItem(payload.request as RequestRecord, viewerRole); + return { + ...requestItem, + id: `history-${row.id}`, + timestamp: dismissedAtTs || requestItem.timestamp, + }; + } + } + + const visualStatus: ActivityItem['visualStatus'] = + row.final_status === 'error' + ? 'error' + : row.final_status === 'cancelled' + ? 'cancelled' + : row.final_status === 'rejected' + ? 'rejected' + : 'complete'; + const statusLabel = + visualStatus === 'error' + ? 'Failed' + : visualStatus === 'cancelled' + ? 'Cancelled' + : visualStatus === 'rejected' + ? viewerRole === 'admin' + ? 'Declined' + : 'Not approved' + : 'Complete'; + + return { + id: `history-${row.id}`, + kind: row.item_type === 'request' ? 'request' : 'download', + visualStatus, + title: row.item_type === 'request' ? 'Request' : 'Download', + author: '', + metaLine: row.item_key, + statusLabel, + timestamp: dismissedAtTs, + }; +}; + +interface UseActivityParams { + isAuthenticated: boolean; + isAdmin: boolean; + showToast: ( + message: string, + type?: 'info' | 'success' | 'error', + persistent?: boolean + ) => string; + socket: Socket | null; +} + +interface UseActivityResult { + activityStatus: StatusData; + requestItems: ActivityItem[]; + dismissedActivityKeys: string[]; + historyItems: ActivityItem[]; + pendingRequestCount: number; + isActivitySnapshotLoading: boolean; + activityHistoryLoading: boolean; + activityHistoryHasMore: boolean; + refreshActivitySnapshot: () => Promise; + handleActivityTabChange: (tab: 'all' | 'downloads' | 'requests' | 'history') => void; + resetActivity: () => void; + handleActivityHistoryLoadMore: () => void; + handleRequestDismiss: (requestId: number) => void; + handleDownloadDismiss: (bookId: string, linkedRequestId?: number) => void; + handleClearCompleted: (items: ActivityDismissTarget[]) => void; + handleClearHistory: () => void; +} + +export const useActivity = ({ + isAuthenticated, + isAdmin, + showToast, + socket, +}: UseActivityParams): UseActivityResult => { + const [activityStatus, setActivityStatus] = useState({}); + const [activityRequests, setActivityRequests] = useState([]); + const [dismissedActivityKeys, setDismissedActivityKeys] = useState([]); + const [isActivitySnapshotLoading, setIsActivitySnapshotLoading] = useState(false); + + const [activityHistoryRows, setActivityHistoryRows] = useState([]); + const [activityHistoryOffset, setActivityHistoryOffset] = useState(0); + const [activityHistoryHasMore, setActivityHistoryHasMore] = useState(false); + const [activityHistoryLoading, setActivityHistoryLoading] = useState(false); + const [activityHistoryLoaded, setActivityHistoryLoaded] = useState(false); + + const resetActivityHistory = useCallback(() => { + setActivityHistoryRows([]); + setActivityHistoryOffset(0); + setActivityHistoryHasMore(false); + setActivityHistoryLoaded(false); + }, []); + + const resetActivity = useCallback(() => { + setActivityStatus({}); + setActivityRequests([]); + setDismissedActivityKeys([]); + resetActivityHistory(); + }, [resetActivityHistory]); + + const refreshActivitySnapshot = useCallback(async () => { + if (!isAuthenticated) { + resetActivity(); + return; + } + + setIsActivitySnapshotLoading(true); + try { + const snapshot = await getActivitySnapshot(); + setActivityStatus(snapshot.status || {}); + setActivityRequests(Array.isArray(snapshot.requests) ? snapshot.requests : []); + const keys = Array.isArray(snapshot.dismissed) + ? snapshot.dismissed + .map((entry) => entry.item_key) + .filter((key): key is string => typeof key === 'string' && key.trim().length > 0) + : []; + setDismissedActivityKeys(Array.from(new Set(keys))); + } catch (error) { + console.warn('Failed to refresh activity snapshot:', error); + } finally { + setIsActivitySnapshotLoading(false); + } + }, [isAuthenticated, resetActivity]); + + const refreshActivityHistory = useCallback(async () => { + if (!isAuthenticated) { + resetActivityHistory(); + return; + } + + setActivityHistoryLoading(true); + try { + const rows = await listActivityHistory(HISTORY_PAGE_SIZE, 0); + const normalizedRows = Array.isArray(rows) ? rows : []; + setActivityHistoryRows(normalizedRows); + setActivityHistoryOffset(normalizedRows.length); + setActivityHistoryHasMore(normalizedRows.length === HISTORY_PAGE_SIZE); + setActivityHistoryLoaded(true); + } catch (error) { + console.warn('Failed to refresh activity history:', error); + } finally { + setActivityHistoryLoading(false); + } + }, [isAuthenticated, resetActivityHistory]); + + const handleActivityTabChange = useCallback((tab: 'all' | 'downloads' | 'requests' | 'history') => { + if (tab !== 'history' || activityHistoryLoaded || activityHistoryLoading) { + return; + } + void refreshActivityHistory(); + }, [activityHistoryLoaded, activityHistoryLoading, refreshActivityHistory]); + + const handleActivityHistoryLoadMore = useCallback(() => { + if (!isAuthenticated || activityHistoryLoading || !activityHistoryHasMore) { + return; + } + + setActivityHistoryLoading(true); + void listActivityHistory(HISTORY_PAGE_SIZE, activityHistoryOffset) + .then((rows) => { + const normalizedRows = Array.isArray(rows) ? rows : []; + setActivityHistoryRows((current) => { + const existingIds = new Set(current.map((row) => row.id)); + const nextRows = normalizedRows.filter((row) => !existingIds.has(row.id)); + return [...current, ...nextRows]; + }); + setActivityHistoryOffset((current) => current + normalizedRows.length); + setActivityHistoryHasMore(normalizedRows.length === HISTORY_PAGE_SIZE); + }) + .catch((error) => { + console.warn('Failed to load more activity history:', error); + }) + .finally(() => { + setActivityHistoryLoading(false); + }); + }, [activityHistoryHasMore, activityHistoryLoading, activityHistoryOffset, isAuthenticated]); + + useEffect(() => { + void refreshActivitySnapshot(); + }, [refreshActivitySnapshot]); + + useEffect(() => { + if (!socket || !isAuthenticated) { + return; + } + + const refreshFromSocketEvent = () => { + void refreshActivitySnapshot(); + if (activityHistoryLoaded) { + void refreshActivityHistory(); + } + }; + + socket.on('activity_update', refreshFromSocketEvent); + socket.on('request_update', refreshFromSocketEvent); + socket.on('new_request', refreshFromSocketEvent); + return () => { + socket.off('activity_update', refreshFromSocketEvent); + socket.off('request_update', refreshFromSocketEvent); + socket.off('new_request', refreshFromSocketEvent); + }; + }, [activityHistoryLoaded, isAuthenticated, refreshActivitySnapshot, refreshActivityHistory, socket]); + + const requestItems = useMemo( + () => + activityRequests + .map((record) => requestToActivityItem(record, isAdmin ? 'admin' : 'user')) + .sort((left, right) => right.timestamp - left.timestamp), + [activityRequests, isAdmin] + ); + + const historyItems = useMemo( + () => { + const mappedItems = activityHistoryRows + .map((row) => mapHistoryRowToActivityItem(row, isAdmin ? 'admin' : 'user')) + .sort((left, right) => right.timestamp - left.timestamp); + + // Download dismissals already carry linked request context; hide redundant + // fulfilled-request history rows that would otherwise appear as "Approved". + const requestIdsWithDownloadRows = new Set(); + mappedItems.forEach((item) => { + if (item.kind === 'download' && typeof item.requestId === 'number') { + requestIdsWithDownloadRows.add(item.requestId); + } + }); + + if (!requestIdsWithDownloadRows.size) { + return mappedItems; + } + + return mappedItems.filter((item) => { + if (item.kind !== 'request' || typeof item.requestId !== 'number') { + return true; + } + if (!requestIdsWithDownloadRows.has(item.requestId)) { + return true; + } + const requestStatus = item.requestRecord?.status; + return requestStatus !== 'fulfilled' && item.visualStatus !== 'fulfilled'; + }); + }, + [activityHistoryRows, isAdmin] + ); + + const pendingRequestCount = useMemo( + () => activityRequests.filter((record) => record.status === 'pending').length, + [activityRequests] + ); + + const refreshHistoryIfLoaded = useCallback(() => { + if (!activityHistoryLoaded) { + return; + } + void refreshActivityHistory(); + }, [activityHistoryLoaded, refreshActivityHistory]); + + const dismissItems = useCallback((items: ActivityDismissPayload[], optimisticKeys: string[], errorMessage: string) => { + setDismissedActivityKeys((current) => Array.from(new Set([...current, ...optimisticKeys]))); + void dismissManyActivityItems(items) + .then(() => { + void refreshActivitySnapshot(); + refreshHistoryIfLoaded(); + }) + .catch((error) => { + console.error('Activity dismiss failed:', error); + void refreshActivitySnapshot(); + refreshHistoryIfLoaded(); + showToast(errorMessage, 'error'); + }); + }, [refreshActivitySnapshot, refreshHistoryIfLoaded, showToast]); + + const handleRequestDismiss = useCallback((requestId: number) => { + const requestKey = `request:${requestId}`; + setDismissedActivityKeys((current) => + current.includes(requestKey) ? current : [...current, requestKey] + ); + + void dismissActivityItem({ + item_type: 'request', + item_key: requestKey, + }).then(() => { + void refreshActivitySnapshot(); + refreshHistoryIfLoaded(); + }).catch((error) => { + console.error('Request dismiss failed:', error); + void refreshActivitySnapshot(); + refreshHistoryIfLoaded(); + showToast('Failed to clear request', 'error'); + }); + }, [refreshActivitySnapshot, refreshHistoryIfLoaded, showToast]); + + const handleDownloadDismiss = useCallback((bookId: string, linkedRequestId?: number) => { + const items: ActivityDismissTarget[] = [{ itemType: 'download', itemKey: `download:${bookId}` }]; + if (typeof linkedRequestId === 'number' && Number.isFinite(linkedRequestId)) { + items.push({ itemType: 'request', itemKey: `request:${linkedRequestId}` }); + } + + dismissItems( + items.map((item) => ({ + item_type: item.itemType, + item_key: item.itemKey, + })), + items.map((item) => item.itemKey), + 'Failed to clear item' + ); + }, [dismissItems]); + + const handleClearCompleted = useCallback((items: ActivityDismissTarget[]) => { + if (!items.length) { + return; + } + + dismissItems( + items.map((item) => ({ + item_type: item.itemType, + item_key: item.itemKey, + })), + Array.from(new Set(items.map((item) => item.itemKey))), + 'Failed to clear finished downloads' + ); + }, [dismissItems]); + + const handleClearHistory = useCallback(() => { + resetActivityHistory(); + void clearActivityHistory().catch((error) => { + console.error('Clear history failed:', error); + void refreshActivityHistory(); + showToast('Failed to clear history', 'error'); + }); + }, [refreshActivityHistory, resetActivityHistory, showToast]); + + return { + activityStatus, + requestItems, + dismissedActivityKeys, + historyItems, + pendingRequestCount, + isActivitySnapshotLoading, + activityHistoryLoading, + activityHistoryHasMore, + refreshActivitySnapshot, + handleActivityTabChange, + resetActivity, + handleActivityHistoryLoadMore, + handleRequestDismiss, + handleDownloadDismiss, + handleClearCompleted, + handleClearHistory, + }; +}; diff --git a/src/frontend/src/hooks/useSettings.ts b/src/frontend/src/hooks/useSettings.ts index 47eaa16..f5a3d5a 100644 --- a/src/frontend/src/hooks/useSettings.ts +++ b/src/frontend/src/hooks/useSettings.ts @@ -4,38 +4,20 @@ import { SettingsTab, SettingsGroup, SettingsField, - SelectFieldConfig, ActionResult, UpdateResult, } from '../types/settings'; +import { + getStoredThemePreference, + setThemePreference, + THEME_FIELD, +} from '../utils/themePreference'; type ValueBearingField = Exclude< SettingsField, { type: 'ActionButton' } | { type: 'HeadingField' } | { type: 'CustomComponentField' } >; -// Client-side only theme field that gets injected into the general tab -const THEME_FIELD: SelectFieldConfig = { - type: 'SelectField', - key: '_THEME', - label: 'Theme', - description: 'Choose your preferred color scheme.', - value: 'auto', // Placeholder, actual value comes from localStorage - options: [ - { value: 'light', label: 'Light' }, - { value: 'dark', label: 'Dark' }, - { value: 'auto', label: 'Auto (System)' }, - ], -}; - -// Apply theme to document -function applyTheme(theme: string): void { - const effectiveTheme = theme === 'auto' - ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') - : theme; - document.documentElement.setAttribute('data-theme', effectiveTheme); -} - // Extract value from a field based on its type function getFieldValue(field: SettingsField): unknown { // These field types have no value property @@ -138,7 +120,7 @@ export function useSettings(): UseSettingsReturn { getValueBearingFields(tab.fields).forEach((field) => { // Special handling for theme field - get from localStorage if (field.key === '_THEME') { - initialValues[tab.name][field.key] = localStorage.getItem('preferred-theme') || 'auto'; + initialValues[tab.name][field.key] = getStoredThemePreference(); } else { initialValues[tab.name][field.key] = getFieldValue(field); } @@ -169,8 +151,7 @@ export function useSettings(): UseSettingsReturn { const updateValue = useCallback((tabName: string, key: string, value: unknown) => { // Apply theme immediately when changed (no save button needed) if (key === '_THEME' && typeof value === 'string') { - localStorage.setItem('preferred-theme', value); - applyTheme(value); + setThemePreference(value); // Also update original value so it doesn't show as pending change setOriginalValues((prev) => ({ ...prev, diff --git a/src/frontend/src/services/api.ts b/src/frontend/src/services/api.ts index 1b2d33a..6cf0a75 100644 --- a/src/frontend/src/services/api.ts +++ b/src/frontend/src/services/api.ts @@ -44,6 +44,10 @@ const API = { requests: `${API_BASE}/requests`, adminRequests: `${API_BASE}/admin/requests`, adminRequestCounts: `${API_BASE}/admin/requests/count`, + activitySnapshot: `${API_BASE}/activity/snapshot`, + activityDismiss: `${API_BASE}/activity/dismiss`, + activityDismissMany: `${API_BASE}/activity/dismiss-many`, + activityHistory: `${API_BASE}/activity/history`, }; // Custom error class for authentication failures @@ -289,6 +293,38 @@ export const getStatus = async (): Promise => { return fetchJSON(API.status); }; +export const getActivitySnapshot = async (): Promise => { + return fetchJSON(API.activitySnapshot); +}; + +export const dismissActivityItem = async (payload: ActivityDismissPayload): Promise => { + await fetchJSON(API.activityDismiss, { + method: 'POST', + body: JSON.stringify(payload), + }); +}; + +export const dismissManyActivityItems = async (items: ActivityDismissPayload[]): Promise => { + await fetchJSON(API.activityDismissMany, { + method: 'POST', + body: JSON.stringify({ items }), + }); +}; + +export const listActivityHistory = async ( + limit: number = 50, + offset: number = 0 +): Promise => { + const params = new URLSearchParams(); + params.set('limit', String(limit)); + params.set('offset', String(offset)); + return fetchJSON(`${API.activityHistory}?${params.toString()}`); +}; + +export const clearActivityHistory = async (): Promise => { + await fetchJSON(API.activityHistory, { method: 'DELETE' }); +}; + export const cancelDownload = async (id: string): Promise => { await fetchJSON(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' }); }; @@ -309,6 +345,38 @@ export interface AdminRequestCounts { by_status: Record; } +export interface ActivityDismissedItem { + item_type: 'download' | 'request'; + item_key: string; +} + +export interface ActivitySnapshotResponse { + status: StatusData; + requests: RequestRecord[]; + dismissed: ActivityDismissedItem[]; +} + +export interface ActivityDismissPayload { + item_type: 'download' | 'request'; + item_key: string; + activity_log_id?: number; +} + +export interface ActivityHistoryItem { + id: number; + user_id: number; + item_type: 'download' | 'request'; + item_key: string; + activity_log_id: number | null; + dismissed_at: string; + snapshot: Record | null; + origin: 'direct' | 'request' | 'requested' | null; + final_status: string | null; + terminal_at: string | null; + request_id: number | null; + source_id: string | null; +} + export const fetchRequestPolicy = async (): Promise => { return fetchJSON(API.requestPolicy); }; @@ -527,6 +595,12 @@ export interface AdminUser { settings?: Record; } +export interface SelfUserEditContext { + user: AdminUser; + deliveryPreferences: DeliveryPreferencesResponse | null; + userOverridableKeys: string[]; +} + export const getAdminUsers = async (): Promise => { return fetchJSON(`${API_BASE}/admin/users`); }; @@ -644,3 +718,19 @@ export const getAdminSettingsOverridesSummary = async ( ): Promise => { return fetchJSON(`${API_BASE}/admin/settings/overrides-summary?tab=${encodeURIComponent(tabName)}`); }; + +export const getSelfUserEditContext = async (): Promise => { + return fetchJSON(`${API_BASE}/users/me/edit-context`); +}; + +export const updateSelfUser = async ( + data: Partial> & { + password?: string; + settings?: Record; + } +): Promise => { + return fetchJSON(`${API_BASE}/users/me`, { + method: 'PUT', + body: JSON.stringify(data), + }); +}; diff --git a/src/frontend/src/tests/activityCardModel.node.test.ts b/src/frontend/src/tests/activityCardModel.node.test.ts index 5a61c41..3c78c48 100644 --- a/src/frontend/src/tests/activityCardModel.node.test.ts +++ b/src/frontend/src/tests/activityCardModel.node.test.ts @@ -30,7 +30,7 @@ describe('activityCardModel', () => { ); assert.equal(model.badges.length, 1); - assert.equal(model.badges[0]?.text, 'Requested by testuser'); + assert.equal(model.badges[0]?.text, 'Needs review · testuser'); }); it('keeps pending label for requester-side pending requests', () => { @@ -45,7 +45,7 @@ describe('activityCardModel', () => { ); assert.equal(model.badges.length, 1); - assert.equal(model.badges[0]?.text, 'Pending'); + assert.equal(model.badges[0]?.text, 'Awaiting review'); }); it('uses requester-friendly approved wording for fulfilled requests', () => { @@ -98,7 +98,7 @@ describe('activityCardModel', () => { assert.equal(model.badges[0]?.visualStatus, 'resolving'); }); - it('shows request and download badges side-by-side for merged request downloads', () => { + it('shows a single download completion badge for completed merged request downloads', () => { const model = buildActivityCardModel( makeItem({ visualStatus: 'complete', @@ -127,11 +127,42 @@ describe('activityCardModel', () => { true ); - assert.equal(model.badges.length, 2); - assert.equal(model.badges[0]?.key, 'request'); - assert.equal(model.badges[0]?.text, 'Request fulfilled'); - assert.equal(model.badges[1]?.key, 'download'); - assert.equal(model.badges[1]?.text, 'Sent to Kindle'); + assert.equal(model.badges.length, 1); + assert.equal(model.badges[0]?.key, 'download'); + assert.equal(model.badges[0]?.text, 'Sent to Kindle'); + assert.equal(model.badges[0]?.visualStatus, 'complete'); + }); + + it('does not render a special note for fulfilled requests with terminal delivery state', () => { + const model = buildActivityCardModel( + makeItem({ + kind: 'request', + visualStatus: 'fulfilled', + requestId: 42, + requestRecord: { + id: 42, + user_id: 7, + status: 'fulfilled', + delivery_state: 'complete', + source_hint: 'prowlarr', + content_type: 'ebook', + request_level: 'release', + policy_mode: 'request_release', + book_data: { title: 'The Martian', author: 'Andy Weir' }, + release_data: { source_id: 'book-1' }, + note: null, + admin_note: null, + reviewed_by: null, + reviewed_at: null, + created_at: '2026-02-13T12:00:00Z', + updated_at: '2026-02-13T12:00:00Z', + username: 'testuser', + }, + }), + false + ); + + assert.equal(model.noteLine, undefined); }); it('builds pending admin request actions from one normalized source', () => { diff --git a/src/frontend/src/tests/activityMappers.node.test.ts b/src/frontend/src/tests/activityMappers.node.test.ts index c52f091..edd800a 100644 --- a/src/frontend/src/tests/activityMappers.node.test.ts +++ b/src/frontend/src/tests/activityMappers.node.test.ts @@ -74,7 +74,7 @@ describe('activityMappers.downloadToActivityItem', () => { assert.equal(item.kind, 'download'); assert.equal(item.visualStatus, 'queued'); assert.equal(item.statusLabel, 'Queued'); - assert.equal(item.metaLine, 'EPUB | 3 MB | Direct Download | alice'); + assert.equal(item.metaLine, 'EPUB · 3 MB · Direct Download · alice'); assert.equal(item.progress, 5); assert.equal(item.progressAnimated, true); assert.equal(item.timestamp, 123); @@ -117,7 +117,7 @@ describe('activityMappers.requestToActivityItem', () => { assert.equal(item.kind, 'request'); assert.equal(item.visualStatus, 'pending'); - assert.equal(item.metaLine, 'EPUB | 2 MB | Prowlarr | alice'); + assert.equal(item.metaLine, 'EPUB · 2 MB · Prowlarr · alice'); assert.equal(item.requestId, 42); assert.equal(item.requestLevel, 'release'); assert.equal(item.requestNote, 'please add this'); @@ -153,6 +153,6 @@ describe('activityMappers.requestToActivityItem', () => { it('does not append username to meta line for user viewer role', () => { const item = requestToActivityItem(makeRequest(), 'user'); - assert.equal(item.metaLine, 'EPUB | 2 MB | Prowlarr'); + assert.equal(item.metaLine, 'EPUB · 2 MB · Prowlarr'); }); }); diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index 2b6f650..d423d8e 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -197,6 +197,8 @@ export interface RequestRecord { id: number; user_id: number; status: 'pending' | 'fulfilled' | 'rejected' | 'cancelled'; + delivery_state?: 'none' | 'unknown' | 'queued' | 'resolving' | 'locating' | 'downloading' | 'complete' | 'error' | 'cancelled'; + delivery_updated_at?: string | null; source_hint: string | null; content_type: ContentType; request_level: 'book' | 'release'; diff --git a/src/frontend/src/utils/themePreference.ts b/src/frontend/src/utils/themePreference.ts new file mode 100644 index 0000000..ea4119d --- /dev/null +++ b/src/frontend/src/utils/themePreference.ts @@ -0,0 +1,41 @@ +import { SelectFieldConfig } from '../types/settings'; + +export const THEME_PREFERENCE_KEY = 'preferred-theme'; +export const DEFAULT_THEME_PREFERENCE = 'auto'; + +export const THEME_FIELD: SelectFieldConfig = { + type: 'SelectField', + key: '_THEME', + label: 'Theme', + description: 'Choose your preferred color scheme.', + value: DEFAULT_THEME_PREFERENCE, + options: [ + { value: 'light', label: 'Light' }, + { value: 'dark', label: 'Dark' }, + { value: 'auto', label: 'Auto (System)' }, + ], +}; + +export function getStoredThemePreference(): string { + try { + return localStorage.getItem(THEME_PREFERENCE_KEY) || DEFAULT_THEME_PREFERENCE; + } catch { + return DEFAULT_THEME_PREFERENCE; + } +} + +export function applyThemePreference(theme: string): void { + const effectiveTheme = theme === 'auto' + ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') + : theme; + document.documentElement.setAttribute('data-theme', effectiveTheme); +} + +export function setThemePreference(theme: string): void { + try { + localStorage.setItem(THEME_PREFERENCE_KEY, theme); + } catch { + // localStorage may be unavailable in private browsing + } + applyThemePreference(theme); +} diff --git a/tests/core/test_activity_routes_api.py b/tests/core/test_activity_routes_api.py new file mode 100644 index 0000000..9738cf7 --- /dev/null +++ b/tests/core/test_activity_routes_api.py @@ -0,0 +1,420 @@ +"""API tests for activity snapshot/dismiss/history routes.""" + +from __future__ import annotations + +import importlib +import uuid +from unittest.mock import ANY, patch + +import pytest + + +@pytest.fixture(scope="module") +def main_module(): + """Import `shelfmark.main` with background startup disabled.""" + with patch("shelfmark.download.orchestrator.start"): + import shelfmark.main as main + + importlib.reload(main) + return main + + +@pytest.fixture +def client(main_module): + return main_module.app.test_client() + + +def _set_session(client, *, user_id: str, db_user_id: int | None, is_admin: bool) -> None: + with client.session_transaction() as sess: + sess["user_id"] = user_id + sess["is_admin"] = is_admin + if db_user_id is not None: + sess["db_user_id"] = db_user_id + elif "db_user_id" in sess: + del sess["db_user_id"] + + +def _create_user(main_module, *, prefix: str, role: str = "user") -> dict: + username = f"{prefix}-{uuid.uuid4().hex[:8]}" + return main_module.user_db.create_user(username=username, role=role) + + +def _sample_status_payload() -> dict: + return { + "queued": {}, + "resolving": {}, + "locating": {}, + "downloading": {}, + "complete": {}, + "available": {}, + "done": {}, + "error": {}, + "cancelled": {}, + } + + +class TestActivityRoutes: + def test_snapshot_returns_status_requests_and_dismissed(self, main_module, client): + user = _create_user(main_module, prefix="reader") + _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) + + main_module.user_db.create_request( + user_id=user["id"], + content_type="ebook", + request_level="book", + policy_mode="request_book", + book_data={ + "title": "Snapshot Book", + "author": "Snapshot Author", + "provider": "openlibrary", + "provider_id": "snap-1", + }, + status="pending", + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()): + response = client.get("/api/activity/snapshot") + + assert response.status_code == 200 + assert "status" in response.json + assert "requests" in response.json + assert "dismissed" in response.json + assert response.json["dismissed"] == [] + assert any(item["user_id"] == user["id"] for item in response.json["requests"]) + + def test_dismiss_and_history_flow(self, main_module, client): + user = _create_user(main_module, prefix="reader") + _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) + + main_module.activity_service.record_terminal_snapshot( + user_id=user["id"], + item_type="download", + item_key="download:test-task", + origin="requested", + final_status="complete", + source_id="test-task", + snapshot={"title": "Dismiss Me"}, + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + dismiss_response = client.post( + "/api/activity/dismiss", + json={"item_type": "download", "item_key": "download:test-task"}, + ) + snapshot_response = client.get("/api/activity/snapshot") + history_response = client.get("/api/activity/history?limit=10&offset=0") + clear_history_response = client.delete("/api/activity/history") + history_after_clear = client.get("/api/activity/history?limit=10&offset=0") + + assert dismiss_response.status_code == 200 + assert dismiss_response.json["status"] == "dismissed" + + assert snapshot_response.status_code == 200 + assert {"item_type": "download", "item_key": "download:test-task"} in snapshot_response.json["dismissed"] + + assert history_response.status_code == 200 + assert len(history_response.json) == 1 + assert history_response.json[0]["item_key"] == "download:test-task" + assert history_response.json[0]["snapshot"] == {"title": "Dismiss Me"} + + assert clear_history_response.status_code == 200 + assert clear_history_response.json["status"] == "cleared" + assert clear_history_response.json["deleted_count"] == 1 + + assert history_after_clear.status_code == 200 + assert history_after_clear.json == [] + + def test_admin_snapshot_includes_admin_viewer_dismissals(self, main_module, client): + admin = _create_user(main_module, prefix="admin", role="admin") + _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + dismiss_response = client.post( + "/api/activity/dismiss", + json={"item_type": "download", "item_key": "download:admin-visible-task"}, + ) + with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()): + snapshot_response = client.get("/api/activity/snapshot") + + assert dismiss_response.status_code == 200 + assert snapshot_response.status_code == 200 + assert { + "item_type": "download", + "item_key": "download:admin-visible-task", + } in snapshot_response.json["dismissed"] + + def test_dismiss_legacy_fulfilled_request_creates_minimal_history_snapshot(self, main_module, client): + user = _create_user(main_module, prefix="reader") + _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) + + request_row = main_module.user_db.create_request( + user_id=user["id"], + content_type="ebook", + request_level="book", + policy_mode="request_book", + book_data={ + "title": "Legacy Fulfilled Request", + "author": "Legacy Author", + "provider": "openlibrary", + "provider_id": "legacy-fulfilled-1", + }, + status="fulfilled", + delivery_state="unknown", + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + dismiss_response = client.post( + "/api/activity/dismiss", + json={"item_type": "request", "item_key": f"request:{request_row['id']}"}, + ) + history_response = client.get("/api/activity/history?limit=10&offset=0") + + assert dismiss_response.status_code == 200 + assert history_response.status_code == 200 + assert len(history_response.json) == 1 + + history_entry = history_response.json[0] + assert history_entry["item_type"] == "request" + assert history_entry["item_key"] == f"request:{request_row['id']}" + assert history_entry["final_status"] == "complete" + assert history_entry["snapshot"]["kind"] == "request" + assert history_entry["snapshot"]["request"]["id"] == request_row["id"] + assert history_entry["snapshot"]["request"]["book_data"]["title"] == "Legacy Fulfilled Request" + + def test_dismiss_requires_db_identity(self, main_module, client): + user = _create_user(main_module, prefix="reader") + _set_session(client, user_id=user["username"], db_user_id=None, is_admin=False) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + response = client.post( + "/api/activity/dismiss", + json={"item_type": "download", "item_key": "download:test-task"}, + ) + + assert response.status_code == 403 + assert response.json["code"] == "user_identity_unavailable" + + def test_dismiss_emits_activity_update_to_user_room(self, main_module, client): + user = _create_user(main_module, prefix="reader") + _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.ws_manager, "is_enabled", return_value=True): + with patch.object(main_module.ws_manager.socketio, "emit") as mock_emit: + response = client.post( + "/api/activity/dismiss", + json={"item_type": "download", "item_key": "download:test-task"}, + ) + + assert response.status_code == 200 + mock_emit.assert_called_once_with( + "activity_update", + ANY, + to=f"user_{user['id']}", + ) + + def test_queue_clear_does_not_set_request_delivery_state_to_cleared(self, main_module, client): + user = _create_user(main_module, prefix="reader") + _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) + + request_row = main_module.user_db.create_request( + user_id=user["id"], + content_type="ebook", + request_level="release", + policy_mode="request_release", + book_data={ + "title": "Queue Clear Book", + "author": "Queue Clear Author", + "provider": "openlibrary", + "provider_id": "clear-1", + }, + release_data={ + "source": "prowlarr", + "source_id": "clear-task-1", + "title": "Queue Clear Book.epub", + }, + status="fulfilled", + delivery_state="complete", + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend, "clear_completed", return_value=1): + response = client.delete("/api/queue/clear") + + assert response.status_code == 200 + updated_request = main_module.user_db.get_request(request_row["id"]) + assert updated_request is not None + assert updated_request["delivery_state"] == "complete" + + def test_snapshot_backfills_undismissed_terminal_download_from_activity_log(self, main_module, client): + user = _create_user(main_module, prefix="reader") + _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) + + main_module.activity_service.record_terminal_snapshot( + user_id=user["id"], + item_type="download", + item_key="download:expired-task-1", + origin="direct", + final_status="complete", + source_id="expired-task-1", + snapshot={ + "kind": "download", + "download": { + "id": "expired-task-1", + "title": "Expired Task", + "author": "Expired Author", + "added_time": 123, + "status_message": "Finished", + "source": "direct_download", + }, + }, + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()): + response = client.get("/api/activity/snapshot") + + assert response.status_code == 200 + assert "expired-task-1" in response.json["status"]["complete"] + assert response.json["status"]["complete"]["expired-task-1"]["id"] == "expired-task-1" + + def test_snapshot_clears_stale_download_dismissal_when_same_task_is_active(self, main_module, client): + user = _create_user(main_module, prefix="reader") + _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + dismiss_response = client.post( + "/api/activity/dismiss", + json={"item_type": "download", "item_key": "download:task-reused-1"}, + ) + assert dismiss_response.status_code == 200 + + active_status = _sample_status_payload() + active_status["downloading"] = { + "task-reused-1": { + "id": "task-reused-1", + "title": "Reused Task", + "author": "Author", + "source": "direct_download", + "added_time": 1, + } + } + + with patch.object(main_module.backend, "queue_status", return_value=active_status): + snapshot_response = client.get("/api/activity/snapshot") + + assert snapshot_response.status_code == 200 + assert { + "item_type": "download", + "item_key": "download:task-reused-1", + } not in snapshot_response.json["dismissed"] + assert main_module.activity_service.get_dismissal_set(user["id"]) == [] + + def test_dismiss_state_is_isolated_per_user(self, main_module, client): + user_one = _create_user(main_module, prefix="reader-one") + user_two = _create_user(main_module, prefix="reader-two") + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + _set_session(client, user_id=user_one["username"], db_user_id=user_one["id"], is_admin=False) + dismiss_response = client.post( + "/api/activity/dismiss", + json={"item_type": "download", "item_key": "download:shared-task"}, + ) + assert dismiss_response.status_code == 200 + + snapshot_one = client.get("/api/activity/snapshot") + assert snapshot_one.status_code == 200 + assert {"item_type": "download", "item_key": "download:shared-task"} in snapshot_one.json["dismissed"] + + _set_session(client, user_id=user_two["username"], db_user_id=user_two["id"], is_admin=False) + snapshot_two = client.get("/api/activity/snapshot") + assert snapshot_two.status_code == 200 + assert {"item_type": "download", "item_key": "download:shared-task"} not in snapshot_two.json["dismissed"] + + def test_history_paging_is_stable_and_non_overlapping(self, main_module, client): + user = _create_user(main_module, prefix="history-user") + _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) + + for index in range(5): + item_key = f"download:history-task-{index}" + main_module.activity_service.record_terminal_snapshot( + user_id=user["id"], + item_type="download", + item_key=item_key, + origin="direct", + final_status="complete", + source_id=f"history-task-{index}", + snapshot={"kind": "download", "download": {"id": f"history-task-{index}"}}, + ) + main_module.activity_service.dismiss_item( + user_id=user["id"], + item_type="download", + item_key=item_key, + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + page_one = client.get("/api/activity/history?limit=2&offset=0") + page_two = client.get("/api/activity/history?limit=2&offset=2") + page_three = client.get("/api/activity/history?limit=2&offset=4") + full = client.get("/api/activity/history?limit=10&offset=0") + + assert page_one.status_code == 200 + assert page_two.status_code == 200 + assert page_three.status_code == 200 + assert full.status_code == 200 + + page_one_ids = [row["id"] for row in page_one.json] + page_two_ids = [row["id"] for row in page_two.json] + page_three_ids = [row["id"] for row in page_three.json] + combined_ids = page_one_ids + page_two_ids + page_three_ids + full_ids = [row["id"] for row in full.json] + + assert len(set(page_one_ids).intersection(page_two_ids)) == 0 + assert len(set(page_one_ids).intersection(page_three_ids)) == 0 + assert len(set(page_two_ids).intersection(page_three_ids)) == 0 + assert combined_ids == full_ids[: len(combined_ids)] + + def test_dismiss_many_emits_activity_update_only_to_acting_user_room(self, main_module, client): + user = _create_user(main_module, prefix="reader") + _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.ws_manager, "is_enabled", return_value=True): + with patch.object(main_module.ws_manager.socketio, "emit") as mock_emit: + response = client.post( + "/api/activity/dismiss-many", + json={ + "items": [ + {"item_type": "download", "item_key": "download:test-task-many"}, + ] + }, + ) + + assert response.status_code == 200 + mock_emit.assert_called_once_with( + "activity_update", + ANY, + to=f"user_{user['id']}", + ) + + def test_clear_history_emits_activity_update_only_to_acting_user_room(self, main_module, client): + user = _create_user(main_module, prefix="reader") + _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) + main_module.activity_service.dismiss_item( + user_id=user["id"], + item_type="download", + item_key="download:history-clear-task", + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.ws_manager, "is_enabled", return_value=True): + with patch.object(main_module.ws_manager.socketio, "emit") as mock_emit: + response = client.delete("/api/activity/history") + + assert response.status_code == 200 + mock_emit.assert_called_once_with( + "activity_update", + ANY, + to=f"user_{user['id']}", + ) diff --git a/tests/core/test_activity_service.py b/tests/core/test_activity_service.py new file mode 100644 index 0000000..2619ee8 --- /dev/null +++ b/tests/core/test_activity_service.py @@ -0,0 +1,245 @@ +"""Tests for activity service persistence helpers.""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + +from shelfmark.core.activity_service import ( + ActivityService, + build_download_item_key, + build_item_key, + build_request_item_key, +) +from shelfmark.core.user_db import UserDB + + +@pytest.fixture +def db_path(): + with tempfile.TemporaryDirectory() as tmpdir: + yield os.path.join(tmpdir, "users.db") + + +@pytest.fixture +def user_db(db_path): + db = UserDB(db_path) + db.initialize() + return db + + +@pytest.fixture +def activity_service(db_path): + return ActivityService(db_path) + + +class TestItemKeys: + def test_build_request_item_key(self): + assert build_request_item_key(42) == "request:42" + assert build_item_key("request", 7) == "request:7" + + def test_build_download_item_key(self): + assert build_download_item_key("abc123") == "download:abc123" + assert build_item_key("download", "xyz") == "download:xyz" + + def test_build_item_key_validation(self): + with pytest.raises(ValueError): + build_item_key("bad", "x") + with pytest.raises(ValueError): + build_item_key("request", "nope") + with pytest.raises(ValueError): + build_item_key("download", "") + + +class TestActivityService: + def test_record_snapshot_and_dismiss_and_history(self, user_db, activity_service): + user = user_db.create_user(username="activity-user") + snapshot = activity_service.record_terminal_snapshot( + user_id=user["id"], + item_type="download", + item_key="download:task-1", + origin="requested", + final_status="complete", + request_id=12, + source_id="task-1", + snapshot={"title": "My Book", "status": "complete"}, + ) + + assert snapshot["item_type"] == "download" + assert snapshot["item_key"] == "download:task-1" + assert snapshot["origin"] == "requested" + assert snapshot["final_status"] == "complete" + + dismissal = activity_service.dismiss_item( + user_id=user["id"], + item_type="download", + item_key="download:task-1", + ) + assert dismissal["item_type"] == "download" + assert dismissal["item_key"] == "download:task-1" + assert dismissal["activity_log_id"] == snapshot["id"] + + dismissed_set = activity_service.get_dismissal_set(user["id"]) + assert dismissed_set == [{"item_type": "download", "item_key": "download:task-1"}] + + history = activity_service.get_history(user["id"], limit=10, offset=0) + assert len(history) == 1 + assert history[0]["item_type"] == "download" + assert history[0]["item_key"] == "download:task-1" + assert history[0]["origin"] == "requested" + assert history[0]["final_status"] == "complete" + assert history[0]["snapshot"] == {"title": "My Book", "status": "complete"} + + def test_history_hydrates_legacy_request_dismissals_without_snapshot(self, user_db, activity_service): + user = user_db.create_user(username="legacy-reader") + request_row = user_db.create_request( + user_id=user["id"], + content_type="ebook", + request_level="book", + policy_mode="request_book", + book_data={ + "title": "Legacy Request", + "author": "Legacy Author", + "provider": "openlibrary", + "provider_id": "legacy-hydrate-1", + }, + status="fulfilled", + delivery_state="unknown", + ) + + activity_service.dismiss_item( + user_id=user["id"], + item_type="request", + item_key=f"request:{request_row['id']}", + ) + + history = activity_service.get_history(user["id"], limit=10, offset=0) + assert len(history) == 1 + assert history[0]["item_type"] == "request" + assert history[0]["item_key"] == f"request:{request_row['id']}" + assert history[0]["origin"] == "request" + assert history[0]["final_status"] == "complete" + assert history[0]["snapshot"] == { + "kind": "request", + "request": { + "id": request_row["id"], + "user_id": user["id"], + "status": "fulfilled", + "delivery_state": "unknown", + "request_level": "book", + "book_data": { + "title": "Legacy Request", + "author": "Legacy Author", + "provider": "openlibrary", + "provider_id": "legacy-hydrate-1", + }, + "release_data": {}, + "note": None, + "admin_note": None, + "created_at": request_row["created_at"], + "updated_at": request_row["created_at"], + }, + } + + def test_dismiss_many_and_clear_history(self, user_db, activity_service): + alice = user_db.create_user(username="alice") + bob = user_db.create_user(username="bob") + + activity_service.record_terminal_snapshot( + user_id=alice["id"], + item_type="request", + item_key="request:10", + origin="request", + final_status="rejected", + request_id=10, + snapshot={"title": "Rejected Book"}, + ) + activity_service.record_terminal_snapshot( + user_id=alice["id"], + item_type="download", + item_key="download:task-2", + origin="direct", + final_status="error", + source_id="task-2", + snapshot={"title": "Failed Download"}, + ) + + dismissed_count = activity_service.dismiss_many( + user_id=alice["id"], + items=[ + {"item_type": "request", "item_key": "request:10"}, + {"item_type": "download", "item_key": "download:task-2"}, + ], + ) + assert dismissed_count == 2 + + # Bob has independent dismiss state. + activity_service.dismiss_item( + user_id=bob["id"], + item_type="request", + item_key="request:10", + ) + + alice_history = activity_service.get_history(alice["id"]) + bob_history = activity_service.get_history(bob["id"]) + assert len(alice_history) == 2 + assert len(bob_history) == 1 + + cleared = activity_service.clear_history(alice["id"]) + assert cleared == 2 + assert activity_service.get_history(alice["id"]) == [] + assert len(activity_service.get_history(bob["id"])) == 1 + + def test_get_undismissed_terminal_downloads_returns_latest_per_item_and_excludes_dismissed( + self, + user_db, + activity_service, + ): + user = user_db.create_user(username="snapshot-user") + + activity_service.record_terminal_snapshot( + user_id=user["id"], + item_type="download", + item_key="download:task-1", + origin="direct", + final_status="error", + source_id="task-1", + terminal_at="2026-01-01T10:00:00+00:00", + snapshot={"kind": "download", "download": {"id": "task-1", "status_message": "failed"}}, + ) + activity_service.record_terminal_snapshot( + user_id=user["id"], + item_type="download", + item_key="download:task-1", + origin="direct", + final_status="complete", + source_id="task-1", + terminal_at="2026-01-01T11:00:00+00:00", + snapshot={"kind": "download", "download": {"id": "task-1", "status_message": "done"}}, + ) + activity_service.record_terminal_snapshot( + user_id=user["id"], + item_type="download", + item_key="download:task-2", + origin="direct", + final_status="cancelled", + source_id="task-2", + terminal_at="2026-01-01T09:00:00+00:00", + snapshot={"kind": "download", "download": {"id": "task-2", "status_message": "stopped"}}, + ) + + activity_service.dismiss_item( + user_id=user["id"], + item_type="download", + item_key="download:task-2", + ) + + rows = activity_service.get_undismissed_terminal_downloads(user["id"]) + assert len(rows) == 1 + assert rows[0]["item_key"] == "download:task-1" + assert rows[0]["final_status"] == "complete" + assert rows[0]["snapshot"] == { + "kind": "download", + "download": {"id": "task-1", "status_message": "done"}, + } diff --git a/tests/core/test_activity_terminal_snapshots.py b/tests/core/test_activity_terminal_snapshots.py new file mode 100644 index 0000000..33aa5d0 --- /dev/null +++ b/tests/core/test_activity_terminal_snapshots.py @@ -0,0 +1,156 @@ +"""Tests for terminal activity snapshot capture from queue transitions.""" + +from __future__ import annotations + +import importlib +import json +import uuid +from unittest.mock import patch + +import pytest + +from shelfmark.core.models import DownloadTask, QueueStatus + + +@pytest.fixture(scope="module") +def main_module(): + """Import `shelfmark.main` with background startup disabled.""" + with patch("shelfmark.download.orchestrator.start"): + import shelfmark.main as main + + importlib.reload(main) + return main + + +def _create_user(main_module, *, prefix: str) -> dict: + username = f"{prefix}-{uuid.uuid4().hex[:8]}" + return main_module.user_db.create_user(username=username, role="user") + + +def _read_activity_log_row(main_module, snapshot_id: int): + conn = main_module.user_db._connect() + try: + return conn.execute( + "SELECT * FROM activity_log WHERE id = ?", + (snapshot_id,), + ).fetchone() + finally: + conn.close() + + +class TestTerminalSnapshotCapture: + def test_complete_transition_records_direct_snapshot_and_survives_queue_clear(self, main_module): + user = _create_user(main_module, prefix="snap-direct") + task_id = f"direct-{uuid.uuid4().hex[:8]}" + task = DownloadTask( + task_id=task_id, + source="direct_download", + title="Direct Snapshot", + user_id=user["id"], + username=user["username"], + ) + assert main_module.backend.book_queue.add(task) is True + + try: + main_module.backend.book_queue.update_status(task_id, QueueStatus.COMPLETE) + item_key = f"download:{task_id}" + snapshot_id = main_module.activity_service.get_latest_activity_log_id( + item_type="download", + item_key=item_key, + ) + assert snapshot_id is not None + + removed = main_module.backend.book_queue.clear_completed(user_id=user["id"]) + assert removed >= 1 + + row = _read_activity_log_row(main_module, snapshot_id) + assert row is not None + assert row["user_id"] == user["id"] + assert row["item_key"] == item_key + assert row["origin"] == "direct" + assert row["final_status"] == "complete" + snapshot = json.loads(row["snapshot_json"]) + assert snapshot["kind"] == "download" + assert snapshot["download"]["id"] == task_id + finally: + main_module.backend.book_queue.cancel_download(task_id) + + def test_complete_transition_records_requested_origin_for_graduated_request(self, main_module): + user = _create_user(main_module, prefix="snap-requested") + task_id = f"requested-{uuid.uuid4().hex[:8]}" + request_row = main_module.user_db.create_request( + user_id=user["id"], + content_type="ebook", + request_level="release", + policy_mode="request_release", + book_data={ + "title": "Requested Snapshot", + "author": "Snapshot Author", + "provider": "openlibrary", + "provider_id": "snapshot-req", + }, + release_data={ + "source": "prowlarr", + "source_id": task_id, + "title": "Requested Snapshot.epub", + }, + status="fulfilled", + delivery_state="queued", + ) + task = DownloadTask( + task_id=task_id, + source="prowlarr", + title="Requested Snapshot", + user_id=user["id"], + username=user["username"], + ) + assert main_module.backend.book_queue.add(task) is True + + try: + main_module.backend.book_queue.update_status(task_id, QueueStatus.COMPLETE) + snapshot_id = main_module.activity_service.get_latest_activity_log_id( + item_type="download", + item_key=f"download:{task_id}", + ) + assert snapshot_id is not None + + row = _read_activity_log_row(main_module, snapshot_id) + assert row is not None + assert row["origin"] == "requested" + assert row["request_id"] == request_row["id"] + assert row["source_id"] == task_id + snapshot = json.loads(row["snapshot_json"]) + assert snapshot["download"]["id"] == task_id + assert snapshot["request"]["id"] == request_row["id"] + finally: + main_module.backend.book_queue.cancel_download(task_id) + + def test_complete_transition_snapshot_uses_latest_terminal_status_message(self, main_module): + user = _create_user(main_module, prefix="snap-message") + task_id = f"message-{uuid.uuid4().hex[:8]}" + task = DownloadTask( + task_id=task_id, + source="direct_download", + title="Message Snapshot", + user_id=user["id"], + username=user["username"], + ) + assert main_module.backend.book_queue.add(task) is True + + try: + # Simulate a stale in-progress message that used to leak into history snapshots. + main_module.backend.book_queue.update_status_message(task_id, "Moving file") + main_module.backend.update_download_status(task_id, "complete", "Complete") + + snapshot_id = main_module.activity_service.get_latest_activity_log_id( + item_type="download", + item_key=f"download:{task_id}", + ) + assert snapshot_id is not None + + row = _read_activity_log_row(main_module, snapshot_id) + assert row is not None + snapshot = json.loads(row["snapshot_json"]) + assert snapshot["download"]["status_message"] == "Complete" + finally: + main_module.backend.book_queue.cancel_download(task_id) diff --git a/tests/core/test_download_api_guardrails.py b/tests/core/test_download_api_guardrails.py index 74819a4..b6da224 100644 --- a/tests/core/test_download_api_guardrails.py +++ b/tests/core/test_download_api_guardrails.py @@ -8,10 +8,13 @@ existing contracts. from __future__ import annotations import importlib +import uuid from unittest.mock import patch import pytest +from shelfmark.core.models import DownloadTask + @pytest.fixture(scope="module") def main_module(): @@ -42,6 +45,11 @@ def _set_authenticated_session( sess["db_user_id"] = db_user_id +def _create_user(main_module, *, prefix: str, role: str = "user") -> dict: + username = f"{prefix}-{uuid.uuid4().hex[:8]}" + return main_module.user_db.create_user(username=username, role=role) + + class TestDownloadEndpointGuardrails: def test_missing_book_id_returns_400_and_does_not_queue(self, main_module, client): with patch.object(main_module, "get_auth_mode", return_value="none"): @@ -211,6 +219,148 @@ class TestReleaseDownloadEndpointGuardrails: mock_queue_release.assert_not_called() +class TestCancelDownloadEndpointGuardrails: + def test_owner_can_cancel_direct_download(self, main_module, client): + user = _create_user(main_module, prefix="reader") + _set_authenticated_session( + client, + user_id=user["username"], + db_user_id=user["id"], + is_admin=False, + ) + task = DownloadTask( + task_id="direct-task-1", + source="direct_download", + title="Direct Task", + user_id=user["id"], + username=user["username"], + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend.book_queue, "get_task", return_value=task): + with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel: + resp = client.delete("/api/download/direct-task-1/cancel") + + assert resp.status_code == 200 + assert resp.get_json() == {"status": "cancelled", "book_id": "direct-task-1"} + mock_cancel.assert_called_once_with("direct-task-1") + + def test_non_owner_cannot_cancel_download(self, main_module, client): + owner = _create_user(main_module, prefix="owner") + actor = _create_user(main_module, prefix="actor") + _set_authenticated_session( + client, + user_id=actor["username"], + db_user_id=actor["id"], + is_admin=False, + ) + task = DownloadTask( + task_id="owned-task-1", + source="direct_download", + title="Owned Task", + user_id=owner["id"], + username=owner["username"], + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend.book_queue, "get_task", return_value=task): + with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel: + resp = client.delete("/api/download/owned-task-1/cancel") + + assert resp.status_code == 403 + assert resp.get_json()["code"] == "download_not_owned" + mock_cancel.assert_not_called() + + def test_owner_cannot_cancel_graduated_request_download(self, main_module, client): + user = _create_user(main_module, prefix="requester") + _set_authenticated_session( + client, + user_id=user["username"], + db_user_id=user["id"], + is_admin=False, + ) + main_module.user_db.create_request( + user_id=user["id"], + content_type="ebook", + request_level="release", + policy_mode="request_release", + book_data={ + "title": "Requested Book", + "author": "Request Author", + "provider": "openlibrary", + "provider_id": "req-guard-1", + }, + release_data={ + "source": "prowlarr", + "source_id": "requested-task-1", + "title": "Requested Book.epub", + }, + status="fulfilled", + delivery_state="queued", + ) + task = DownloadTask( + task_id="requested-task-1", + source="prowlarr", + title="Requested Book", + user_id=user["id"], + username=user["username"], + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend.book_queue, "get_task", return_value=task): + with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel: + resp = client.delete("/api/download/requested-task-1/cancel") + + assert resp.status_code == 403 + assert resp.get_json()["code"] == "requested_download_cancel_forbidden" + mock_cancel.assert_not_called() + + def test_admin_can_cancel_graduated_request_download(self, main_module, client): + admin = _create_user(main_module, prefix="admin", role="admin") + requester = _create_user(main_module, prefix="requester") + _set_authenticated_session( + client, + user_id=admin["username"], + db_user_id=admin["id"], + is_admin=True, + ) + main_module.user_db.create_request( + user_id=requester["id"], + content_type="ebook", + request_level="release", + policy_mode="request_release", + book_data={ + "title": "Admin Requested Book", + "author": "Admin Request Author", + "provider": "openlibrary", + "provider_id": "req-guard-2", + }, + release_data={ + "source": "prowlarr", + "source_id": "requested-task-2", + "title": "Admin Requested Book.epub", + }, + status="fulfilled", + delivery_state="queued", + ) + task = DownloadTask( + task_id="requested-task-2", + source="prowlarr", + title="Admin Requested Book", + user_id=requester["id"], + username=requester["username"], + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend.book_queue, "get_task", return_value=task): + with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel: + resp = client.delete("/api/download/requested-task-2/cancel") + + assert resp.status_code == 200 + assert resp.get_json() == {"status": "cancelled", "book_id": "requested-task-2"} + mock_cancel.assert_called_once_with("requested-task-2") + + class TestStatusEndpointGuardrails: def test_no_auth_allows_without_session_and_returns_status(self, main_module, client): observed: dict[str, object] = {} diff --git a/tests/core/test_oidc_integration.py b/tests/core/test_oidc_integration.py index f2168ca..42b391e 100644 --- a/tests/core/test_oidc_integration.py +++ b/tests/core/test_oidc_integration.py @@ -70,9 +70,9 @@ class TestSettingsRestrictionPolicy: def test_default_is_admin_restricted(self): assert should_restrict_settings_to_admin({}) is True - def test_respects_global_users_toggle(self): + def test_restriction_is_always_enabled(self): assert should_restrict_settings_to_admin({"RESTRICT_SETTINGS_TO_ADMIN": True}) is True - assert should_restrict_settings_to_admin({"RESTRICT_SETTINGS_TO_ADMIN": False}) is False + assert should_restrict_settings_to_admin({"RESTRICT_SETTINGS_TO_ADMIN": False}) is True def test_extracts_settings_tab_from_path(self): assert get_settings_tab_from_path("/api/settings/security") == "security" @@ -84,11 +84,11 @@ class TestSettingsRestrictionPolicy: assert requires_admin_for_settings_access("/api/settings/security", users_config) is True assert requires_admin_for_settings_access("/api/settings/users", users_config) is True - def test_other_tabs_follow_global_toggle(self): + def test_other_tabs_also_require_admin(self): assert requires_admin_for_settings_access( "/api/settings/general", {"RESTRICT_SETTINGS_TO_ADMIN": False}, - ) is False + ) is True assert requires_admin_for_settings_access( "/api/settings/general", {"RESTRICT_SETTINGS_TO_ADMIN": True}, @@ -112,13 +112,13 @@ class TestAuthCheckAdminStatus: ) assert result is False - def test_authenticated_user_when_not_restricted(self): + def test_authenticated_non_admin_user_is_not_admin(self): result = get_auth_check_admin_status( "proxy", {"RESTRICT_SETTINGS_TO_ADMIN": False}, {"user_id": "user", "is_admin": False}, ) - assert result is True + assert result is False def test_unauthenticated_is_never_admin(self): result = get_auth_check_admin_status( diff --git a/tests/core/test_per_user_downloads.py b/tests/core/test_per_user_downloads.py index 02590d1..aa87b3a 100644 --- a/tests/core/test_per_user_downloads.py +++ b/tests/core/test_per_user_downloads.py @@ -5,7 +5,7 @@ Tests that DownloadTask has a user_id field and that the queue can be filtered by user. """ -from shelfmark.core.models import DownloadTask +from shelfmark.core.models import DownloadTask, QueueStatus from shelfmark.core.queue import BookQueue @@ -116,6 +116,39 @@ class TestQueueFilterByUser: # User 1 sees their own + legacy (no user_id) assert len(all_tasks) == 2 + def test_clear_completed_for_user_only_removes_user_terminal_tasks(self): + q = BookQueue() + q.add(self._make_task("book-1", user_id=1)) + q.add(self._make_task("book-2", user_id=2)) + q.add(self._make_task("book-3", user_id=1)) + + q.update_status("book-1", QueueStatus.COMPLETE) + q.update_status("book-2", QueueStatus.ERROR) + q.update_status("book-3", QueueStatus.QUEUED) + + removed = q.clear_completed(user_id=1) + assert removed == 1 + + status = q.get_status() + all_tasks = {} + for tasks_by_status in status.values(): + all_tasks.update(tasks_by_status) + + assert "book-1" not in all_tasks + assert "book-2" in all_tasks + assert "book-3" in all_tasks + + def test_clear_completed_for_user_includes_legacy_tasks(self): + q = BookQueue() + q.add(self._make_task("legacy-book", user_id=None)) + q.add(self._make_task("user-book", user_id=1)) + + q.update_status("legacy-book", QueueStatus.COMPLETE) + q.update_status("user-book", QueueStatus.COMPLETE) + + removed = q.clear_completed(user_id=1) + assert removed == 2 + # --------------------------------------------------------------------------- # Per-user destination override in get_final_destination diff --git a/tests/core/test_request_routes_api.py b/tests/core/test_request_routes_api.py index 8c11d13..a94db28 100644 --- a/tests/core/test_request_routes_api.py +++ b/tests/core/test_request_routes_api.py @@ -58,6 +58,14 @@ def _policy( } +def _read_activity_log_row(main_module, snapshot_id: int): + conn = main_module.user_db._connect() + try: + return conn.execute("SELECT * FROM activity_log WHERE id = ?", (snapshot_id,)).fetchone() + finally: + conn.close() + + class TestDownloadPolicyGuards: def test_download_endpoint_blocks_before_queue_when_policy_requires_request(self, main_module, client): user = _create_user(main_module, prefix="reader") @@ -194,6 +202,17 @@ class TestRequestRoutes: assert cancel_resp.status_code == 200 assert cancel_resp.json["status"] == "cancelled" + snapshot_id = main_module.activity_service.get_latest_activity_log_id( + item_type="request", + item_key=f"request:{request_id}", + ) + assert snapshot_id is not None + log_row = _read_activity_log_row(main_module, snapshot_id) + assert log_row is not None + assert log_row["user_id"] == user["id"] + assert log_row["final_status"] == "cancelled" + assert log_row["origin"] == "request" + def test_create_request_emits_websocket_events(self, main_module, client): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) @@ -498,6 +517,17 @@ class TestRequestRoutes: assert reject_again_resp.status_code == 409 assert reject_again_resp.json["code"] == "stale_transition" + snapshot_id = main_module.activity_service.get_latest_activity_log_id( + item_type="request", + item_key=f"request:{request_id}", + ) + assert snapshot_id is not None + log_row = _read_activity_log_row(main_module, snapshot_id) + assert log_row is not None + assert log_row["user_id"] == user["id"] + assert log_row["final_status"] == "rejected" + assert log_row["origin"] == "request" + def test_admin_reject_emits_update_to_user_and_admin_rooms(self, main_module, client): user = _create_user(main_module, prefix="reader") admin = _create_user(main_module, prefix="admin", role="admin") @@ -1638,3 +1668,123 @@ class TestDownloadPolicyGuardsExtended: }) assert resp.status_code == 200 + + +def test_clear_queue_does_not_mutate_fulfilled_request_delivery_state(main_module, client): + user = _create_user(main_module, prefix="reader") + admin = _create_user(main_module, prefix="admin", role="admin") + _set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True) + + created = main_module.user_db.create_request( + user_id=user["id"], + content_type="ebook", + request_level="release", + policy_mode="request_release", + book_data={ + "title": "Clear Delivery State", + "author": "QA", + "content_type": "ebook", + "provider": "openlibrary", + "provider_id": "ol-clear-delivery", + }, + release_data={ + "source": "prowlarr", + "source_id": "clear-delivery-source-id", + "title": "Clear Delivery State.epub", + }, + status="fulfilled", + delivery_state="complete", + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.ws_manager, "is_enabled", return_value=False): + with patch.object(main_module.ws_manager, "broadcast_status_update"): + with patch.object(main_module.backend, "queue_status", return_value={}) as mock_queue_status: + with patch.object(main_module.backend, "clear_completed", return_value=1) as mock_clear_completed: + resp = client.delete("/api/queue/clear") + + assert resp.status_code == 200 + assert resp.json["status"] == "cleared" + assert resp.json["removed_count"] == 1 + assert mock_queue_status.call_args_list[0].kwargs == {} + mock_clear_completed.assert_called_once_with(user_id=None) + + refreshed = main_module.user_db.get_request(created["id"]) + assert refreshed["delivery_state"] == "complete" + + +def test_non_admin_clear_queue_is_scoped_without_mutating_request_delivery_state(main_module, client): + alice = _create_user(main_module, prefix="alice") + bob = _create_user(main_module, prefix="bob") + _set_session(client, user_id=alice["username"], db_user_id=alice["id"], is_admin=False) + + alice_request = main_module.user_db.create_request( + user_id=alice["id"], + content_type="ebook", + request_level="release", + policy_mode="request_release", + book_data={ + "title": "Alice Clear Scope", + "author": "QA", + "content_type": "ebook", + "provider": "openlibrary", + "provider_id": "ol-alice-scope", + }, + release_data={ + "source": "prowlarr", + "source_id": "shared-clear-scope-source-id", + "title": "Alice Scope.epub", + }, + status="fulfilled", + delivery_state="complete", + ) + bob_request = main_module.user_db.create_request( + user_id=bob["id"], + content_type="ebook", + request_level="release", + policy_mode="request_release", + book_data={ + "title": "Bob Clear Scope", + "author": "QA", + "content_type": "ebook", + "provider": "openlibrary", + "provider_id": "ol-bob-scope", + }, + release_data={ + "source": "prowlarr", + "source_id": "shared-clear-scope-source-id", + "title": "Bob Scope.epub", + }, + status="fulfilled", + delivery_state="complete", + ) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.ws_manager, "is_enabled", return_value=False): + with patch.object(main_module.ws_manager, "broadcast_status_update"): + with patch.object(main_module.backend, "queue_status", return_value={}) as mock_queue_status: + with patch.object(main_module.backend, "clear_completed", return_value=1) as mock_clear_completed: + resp = client.delete("/api/queue/clear") + + assert resp.status_code == 200 + assert resp.json["status"] == "cleared" + assert resp.json["removed_count"] == 1 + assert mock_queue_status.call_args_list[0].kwargs == {} + mock_clear_completed.assert_called_once_with(user_id=alice["id"]) + + refreshed_alice = main_module.user_db.get_request(alice_request["id"]) + refreshed_bob = main_module.user_db.get_request(bob_request["id"]) + assert refreshed_alice["delivery_state"] == "complete" + assert refreshed_bob["delivery_state"] == "complete" + + +def test_non_admin_clear_queue_without_db_user_id_returns_403(main_module, client): + _set_session(client, user_id="reader-no-db", db_user_id=None, is_admin=False) + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module.backend, "clear_completed") as mock_clear_completed: + resp = client.delete("/api/queue/clear") + + assert resp.status_code == 403 + assert resp.json["code"] == "user_identity_unavailable" + mock_clear_completed.assert_not_called() diff --git a/tests/core/test_requests_service.py b/tests/core/test_requests_service.py index 7457e88..9181bb3 100644 --- a/tests/core/test_requests_service.py +++ b/tests/core/test_requests_service.py @@ -14,9 +14,11 @@ from shelfmark.core.requests_service import ( create_request, fulfil_request, normalize_policy_mode, + normalize_delivery_state, normalize_request_level, normalize_request_status, reject_request, + sync_delivery_states_from_queue_status, validate_request_level_payload, validate_status_transition, ) @@ -61,6 +63,16 @@ def test_normalize_request_status_rejects_unknown_values(): normalize_request_status("queued") +def test_normalize_delivery_state_accepts_known_values(): + assert normalize_delivery_state("none") == "none" + assert normalize_delivery_state(" QUEUED ") == "queued" + + +def test_normalize_delivery_state_rejects_unknown_values(): + with pytest.raises(ValueError, match="Invalid delivery_state"): + normalize_delivery_state("pending") + + def test_normalize_policy_mode_accepts_strings_and_enum(): assert normalize_policy_mode("download") == "download" assert normalize_policy_mode("REQUEST_BOOK") == "request_book" @@ -370,6 +382,8 @@ def test_fulfil_request_queues_as_requesting_user(user_db): ) assert fulfilled["status"] == "fulfilled" + assert fulfilled["delivery_state"] == "queued" + assert fulfilled["delivery_updated_at"] is not None assert fulfilled["reviewed_by"] == admin["id"] assert captured["priority"] == 0 assert captured["user_id"] == alice["id"] @@ -411,6 +425,8 @@ def test_fulfil_book_level_request_stores_selected_release_data(user_db): ) assert fulfilled["status"] == "fulfilled" + assert fulfilled["delivery_state"] == "queued" + assert fulfilled["delivery_updated_at"] is not None assert fulfilled["request_level"] == "book" assert fulfilled["release_data"]["source_id"] == "admin-picked-book-release" assert captured["release_data"]["source_id"] == "admin-picked-book-release" @@ -418,6 +434,50 @@ def test_fulfil_book_level_request_stores_selected_release_data(user_db): assert captured["username"] == "alice" +def test_sync_delivery_states_from_queue_status_updates_matching_fulfilled_requests(user_db): + alice = user_db.create_user(username="alice") + bob = user_db.create_user(username="bob") + + alice_request = user_db.create_request( + user_id=alice["id"], + source_hint="prowlarr", + content_type="ebook", + request_level="release", + policy_mode="request_release", + book_data=_book_data(), + release_data={"source": "prowlarr", "source_id": "alice-rel", "title": "Alice Release"}, + status="fulfilled", + delivery_state="queued", + ) + bob_request = user_db.create_request( + user_id=bob["id"], + source_hint="prowlarr", + content_type="ebook", + request_level="release", + policy_mode="request_release", + book_data=_book_data(), + release_data={"source": "prowlarr", "source_id": "bob-rel", "title": "Bob Release"}, + status="fulfilled", + delivery_state="queued", + ) + + updated = sync_delivery_states_from_queue_status( + user_db, + queue_status={ + "downloading": {"alice-rel": {"id": "alice-rel"}}, + "complete": {"bob-rel": {"id": "bob-rel"}}, + }, + user_id=alice["id"], + ) + + assert [row["id"] for row in updated] == [alice_request["id"]] + refreshed_alice = user_db.get_request(alice_request["id"]) + refreshed_bob = user_db.get_request(bob_request["id"]) + assert refreshed_alice["delivery_state"] == "downloading" + assert refreshed_alice["delivery_updated_at"] is not None + assert refreshed_bob["delivery_state"] == "queued" + + # --------------------------------------------------------------------------- # book_data validation # --------------------------------------------------------------------------- diff --git a/tests/core/test_user_db.py b/tests/core/test_user_db.py index 84e081f..252135d 100644 --- a/tests/core/test_user_db.py +++ b/tests/core/test_user_db.py @@ -62,6 +62,18 @@ class TestUserDBInitialization: assert cursor.fetchone() is not None conn.close() + def test_initialize_creates_activity_tables(self, user_db, db_path): + conn = sqlite3.connect(db_path) + activity_log = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='activity_log'" + ).fetchone() + dismissals = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='activity_dismissals'" + ).fetchone() + assert activity_log is not None + assert dismissals is not None + conn.close() + def test_initialize_creates_download_requests_indexes(self, user_db, db_path): conn = sqlite3.connect(db_path) rows = conn.execute( @@ -72,6 +84,22 @@ class TestUserDBInitialization: assert "idx_download_requests_status_created_at" in index_names conn.close() + def test_initialize_creates_activity_indexes(self, user_db, db_path): + conn = sqlite3.connect(db_path) + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='activity_log'" + ).fetchall() + log_index_names = {row[0] for row in rows} + assert "idx_activity_log_user_terminal" in log_index_names + assert "idx_activity_log_lookup" in log_index_names + + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='activity_dismissals'" + ).fetchall() + dismissal_index_names = {row[0] for row in rows} + assert "idx_activity_dismissals_user_dismissed_at" in dismissal_index_names + conn.close() + def test_initialize_enables_wal_mode(self, user_db, db_path): conn = sqlite3.connect(db_path) cursor = conn.execute("PRAGMA journal_mode") diff --git a/tests/e2e/test_proxy_auth_middleware.py b/tests/e2e/test_proxy_auth_middleware.py index 14282ae..685201e 100644 --- a/tests/e2e/test_proxy_auth_middleware.py +++ b/tests/e2e/test_proxy_auth_middleware.py @@ -191,7 +191,7 @@ class TestLoginRequiredDecorator: assert resp[0]["success"] is True - def test_settings_access_not_restricted_when_global_toggle_off(self, main_module, view): + def test_settings_access_requires_admin_even_when_legacy_toggle_off(self, main_module, view): with patch.object(main_module, "get_auth_mode", return_value="builtin"): with patch( "shelfmark.core.settings_registry.load_config_file", @@ -201,9 +201,11 @@ class TestLoginRequiredDecorator: main_module.session["user_id"] = "user" main_module.session["is_admin"] = False decorated = main_module.login_required(view) - resp = decorated() + resp = _as_response(decorated()) + data = resp.get_json() - assert resp[0]["success"] is True + assert resp.status_code == 403 + assert "Admin access required" in (data.get("error") or "") def test_security_tab_always_blocks_non_admin_even_when_toggle_off(self, main_module, view): with patch.object(main_module, "get_auth_mode", return_value="builtin"):