mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-02-20 07:46:18 -05:00
Requests: Various fixes and improvements (#617)
- Refactored activity backend for full user-level management, using the db file - Revamped the activity sidebar UX and categorisation - Added download history and user filtering - Added User Preferences modal, giving limited configuration for non-admins - replaces the "restrict settings" config option. - Many many bug fixes - Many many new tests
This commit is contained in:
@@ -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},
|
||||
|
||||
486
shelfmark/core/activity_routes.py
Normal file
486
shelfmark/core/activity_routes.py
Normal file
@@ -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})
|
||||
618
shelfmark/core/activity_service.py
Normal file
618
shelfmark/core/activity_service.py
Normal file
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
333
shelfmark/core/self_user_routes.py
Normal file
333
shelfmark/core/self_user_routes.py
Normal file
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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<ContentType>(() => 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<number[]>([]);
|
||||
|
||||
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<Book | null>(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<string, Book> | 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<boolean> => {
|
||||
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}
|
||||
/>
|
||||
|
||||
<SelfSettingsModal
|
||||
isOpen={selfSettingsOpen}
|
||||
onClose={() => setSelfSettingsOpen(false)}
|
||||
onShowToast={showToast}
|
||||
/>
|
||||
|
||||
{/* Auto-show banner on startup for users without config */}
|
||||
{config && (
|
||||
<ConfigSetupBanner settingsEnabled={config.settings_enabled} />
|
||||
@@ -1339,7 +1338,11 @@ function App() {
|
||||
onClose={() => setConfigBannerOpen(false)}
|
||||
onContinue={() => {
|
||||
setConfigBannerOpen(false);
|
||||
setSettingsOpen(true);
|
||||
if (authIsAdmin) {
|
||||
setSettingsOpen(true);
|
||||
} else {
|
||||
setSelfSettingsOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs opacity-60 truncate mt-0.5" title={item.metaLine}>
|
||||
<p className="text-[11px] leading-tight opacity-60 truncate mt-0.5" title={item.metaLine}>
|
||||
{item.metaLine}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -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<boolean>(() => getInitialPinnedPreference());
|
||||
const [isDesktop, setIsDesktop] = useState<boolean>(() => getInitialDesktopState());
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'downloads' | 'requests'>('all');
|
||||
const [activeTab, setActiveTab] = useState<ActivityTabKey>('all');
|
||||
const [selectedUser, setSelectedUser] = useState<string>(ALL_USERS_FILTER);
|
||||
const [rejectingRequest, setRejectingRequest] = useState<{ requestId: number; bookTitle: string } | null>(null);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||
const scrollViewportRef = useRef<HTMLDivElement | null>(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<string, ActivityItem>();
|
||||
@@ -261,7 +360,7 @@ export const ActivitySidebar = ({
|
||||
});
|
||||
|
||||
const mergedByDownloadId = new Map<string, ActivityItem>();
|
||||
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<string, string>();
|
||||
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<string>();
|
||||
|
||||
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<ActivityCategoryKey, ActivityItem[]>();
|
||||
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 = (
|
||||
<>
|
||||
<div
|
||||
className={`px-4 pt-4 ${showRequestsTab ? 'pb-0' : 'pb-4 border-b'}`}
|
||||
className="px-4 pt-4 pb-0"
|
||||
style={{
|
||||
borderColor: 'var(--border-muted)',
|
||||
paddingTop: 'calc(1rem + env(safe-area-inset-top))',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold">Activity</h2>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{activeTab === 'history' ? 'History' : 'Activity'}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTogglePinned}
|
||||
@@ -392,25 +570,96 @@ export const ActivitySidebar = ({
|
||||
<path d="M15.804 2.276a.75.75 0 0 0-.336.195l-2 2a.75.75 0 0 0 0 1.062l.47.469-3.572 3.571c-.83-.534-1.773-.808-2.709-.691-1.183.148-2.32.72-3.187 1.587a.75.75 0 0 0 0 1.063L7.938 15l-5.467 5.467a.75.75 0 0 0 0 1.062.75.75 0 0 0 1.062 0L9 16.062l3.468 3.468a.75.75 0 0 0 1.062 0c.868-.868 1.44-2.004 1.588-3.187.117-.935-.158-1.879-.692-2.708L18 10.063l.469.469a.75.75 0 0 0 1.062 0l2-2a.75.75 0 0 0 0-1.062l-5-4.999a.75.75 0 0 0-.726-.195z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m9 15-6 6M15 6l-1-1 2-2 5 5-2 2-1-1-4.5 4.5c1.5 1.5 1 4-.5 5.5l-8-8c1.5-1.5 4-2 5.5-.5z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{hasUserFilter && (
|
||||
<Dropdown
|
||||
align="right"
|
||||
widthClassName="w-auto"
|
||||
panelClassName="min-w-[11rem]"
|
||||
renderTrigger={({ isOpen, toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className={`h-9 w-9 inline-flex items-center justify-center rounded-full hover-action transition-colors ${
|
||||
isOpen || selectedUser !== ALL_USERS_FILTER ? 'text-sky-600 dark:text-sky-400' : ''
|
||||
}`}
|
||||
title={selectedUser === ALL_USERS_FILTER ? 'Filter by user' : `Filtered: ${selectedUser}`}
|
||||
aria-label={selectedUser === ALL_USERS_FILTER ? 'Filter by user' : `Filtered by user ${selectedUser}`}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" strokeWidth="1.75" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{({ close }) => (
|
||||
<div role="listbox">
|
||||
{[ALL_USERS_FILTER, ...availableUsers].map((value) => {
|
||||
const isSelected = selectedUser === value;
|
||||
const label = value === ALL_USERS_FILTER ? 'All users' : value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={value}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover-surface flex items-center justify-between ${
|
||||
isSelected ? 'text-sky-600 dark:text-sky-400' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedUser(value);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{isSelected && (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m5 13 4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab((current) => (current === 'history' ? 'all' : 'history'))}
|
||||
className={`relative h-9 w-9 inline-flex items-center justify-center rounded-full hover-action transition-colors ${
|
||||
activeTab === 'history' ? 'text-sky-600 dark:text-sky-400' : ''
|
||||
}`}
|
||||
title={activeTab === 'history' ? 'Back to activity' : 'Open history'}
|
||||
aria-label={activeTab === 'history' ? 'Back to activity' : 'Open history'}
|
||||
aria-pressed={activeTab === 'history'}
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6l3.75 2.25" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3v4.5h4.5" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 12a8.25 8.25 0 1 0 3.37-6.63" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-9 w-9 inline-flex items-center justify-center rounded-full hover-action transition-colors"
|
||||
aria-label="Close activity sidebar"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showRequestsTab && (
|
||||
{activeTab !== 'history' && (
|
||||
<div className="mt-2 border-b border-[var(--border-muted)] -mx-4 px-4">
|
||||
<div className="relative flex gap-1">
|
||||
{/* Sliding indicator */}
|
||||
@@ -452,24 +701,26 @@ export const ActivitySidebar = ({
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
ref={(el) => { tabRefs.current.requests = el; }}
|
||||
onClick={() => setActiveTab('requests')}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${
|
||||
activeTab === 'requests'
|
||||
? 'text-sky-600 dark:text-sky-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
aria-current={activeTab === 'requests' ? 'page' : undefined}
|
||||
>
|
||||
Requests
|
||||
{pendingRequestCount > 0 && (
|
||||
<span className="ml-1.5 text-[11px] h-[18px] min-w-[18px] px-1 rounded-full bg-amber-500/15 text-amber-700 dark:text-amber-300 inline-flex items-center justify-center leading-none">
|
||||
{pendingRequestCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{showRequestsTab && (
|
||||
<button
|
||||
type="button"
|
||||
ref={(el) => { tabRefs.current.requests = el; }}
|
||||
onClick={() => setActiveTab('requests')}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${
|
||||
activeTab === 'requests'
|
||||
? 'text-sky-600 dark:text-sky-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
aria-current={activeTab === 'requests' ? 'page' : undefined}
|
||||
>
|
||||
Requests
|
||||
{pendingRequestCount > 0 && (
|
||||
<span className="ml-1.5 text-[11px] h-[18px] min-w-[18px] px-1 rounded-full bg-amber-500/15 text-amber-700 dark:text-amber-300 inline-flex items-center justify-center leading-none">
|
||||
{pendingRequestCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -484,11 +735,36 @@ export const ActivitySidebar = ({
|
||||
<p className="text-center text-sm opacity-70 mt-8">
|
||||
{activeTab === 'requests'
|
||||
? isRequestsLoading ? 'Loading requests...' : 'No requests'
|
||||
: activeTab === 'history'
|
||||
? historyLoading ? 'Loading history...' : 'No history'
|
||||
: activeTab === 'downloads'
|
||||
? 'No downloads'
|
||||
: 'No activity'}
|
||||
</p>
|
||||
) : (
|
||||
activeTab === 'history' ? (
|
||||
<div className="divide-y divide-[color-mix(in_srgb,var(--border-muted)_60%,transparent)]">
|
||||
{visibleItems.map((item) => (
|
||||
<ActivityCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
))}
|
||||
{historyHasMore && (
|
||||
<div className="pt-3 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onHistoryLoadMore}
|
||||
disabled={historyLoading}
|
||||
className="text-sm text-sky-600 dark:text-sky-400 hover:underline disabled:opacity-60"
|
||||
>
|
||||
{historyLoading ? 'Loading...' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
groupedVisibleItems.map((group) => (
|
||||
<section key={group.key} className="mb-4 last:mb-0">
|
||||
{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 = ({
|
||||
)}
|
||||
</section>
|
||||
))
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(activeTab === 'downloads' || activeTab === 'all') && hasTerminalDownloadItems && (
|
||||
{(activeTab === 'downloads' || activeTab === 'all') && hasTerminalDownloadItems && clearCompletedTargets.length > 0 && (
|
||||
<div
|
||||
className="p-3 border-t flex items-center justify-center"
|
||||
style={{
|
||||
@@ -567,13 +845,31 @@ export const ActivitySidebar = ({
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearCompleted}
|
||||
onClick={() => onClearCompleted(clearCompletedTargets)}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Clear Completed
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && historyItems.length > 0 && (
|
||||
<div
|
||||
className="p-3 border-t flex items-center justify-center"
|
||||
style={{
|
||||
borderColor: 'var(--border-muted)',
|
||||
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearHistory}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
Clear History
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
357
src/frontend/src/components/settings/SelfSettingsModal.tsx
Normal file
357
src/frontend/src/components/settings/SelfSettingsModal.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
|
||||
const [originalUser, setOriginalUser] = useState<AdminUser | null>(null);
|
||||
const [deliveryPreferences, setDeliveryPreferences] = useState<DeliveryPreferencesResponse | null>(null);
|
||||
|
||||
const [editPassword, setEditPassword] = useState('');
|
||||
const [editPasswordConfirm, setEditPasswordConfirm] = useState('');
|
||||
|
||||
const [userSettings, setUserSettings] = useState<PerUserSettings>({});
|
||||
const [originalUserSettings, setOriginalUserSettings] = useState<PerUserSettings>({});
|
||||
const [userOverridableSettings, setUserOverridableSettings] = useState<Set<string>>(new Set());
|
||||
const [themeValue, setThemeValue] = useState<string>(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<string, unknown>;
|
||||
} = {};
|
||||
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`relative w-full max-w-3xl h-[85vh] max-h-[750px] rounded-xl border border-[var(--border-muted)] shadow-2xl flex flex-col overflow-hidden ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
style={{ background: 'var(--bg)' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-[var(--border-muted)] px-6 py-4">
|
||||
<h3 id={titleId} className="sr-only">My Account</h3>
|
||||
{editingUser ? (
|
||||
<UserIdentityHeader
|
||||
user={editingUser}
|
||||
showAuthSource
|
||||
showInactiveState={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm font-medium">My Account</div>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="p-2 rounded-full hover-action transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Close account settings"
|
||||
disabled={isSaving}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
{showInitialLoadingState ? (
|
||||
<div className="h-full flex items-center justify-center text-sm opacity-60">
|
||||
Loading account settings...
|
||||
</div>
|
||||
) : showInitialLoadErrorState ? (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3 text-center">
|
||||
<p className="text-sm opacity-70">{loadError}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void loadEditContext(); }}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)] bg-[var(--bg-soft)] hover:bg-[var(--hover-surface)] transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : editingUser ? (
|
||||
<div className="space-y-5">
|
||||
<FieldWrapper field={THEME_FIELD}>
|
||||
<SelectField
|
||||
field={THEME_FIELD}
|
||||
value={themeValue}
|
||||
onChange={(value) => {
|
||||
setThemeValue(value);
|
||||
setThemePreference(value);
|
||||
}}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
|
||||
<UserAccountCardContent
|
||||
user={editingUser}
|
||||
onUserChange={setEditingUser}
|
||||
onSave={() => {}}
|
||||
saving={isSaving}
|
||||
onCancel={handleClose}
|
||||
hideEditActions
|
||||
editPassword={editPassword}
|
||||
onEditPasswordChange={setEditPassword}
|
||||
editPasswordConfirm={editPasswordConfirm}
|
||||
onEditPasswordConfirmChange={setEditPasswordConfirm}
|
||||
preferencesPlacement="after"
|
||||
preferencesPanel={{
|
||||
hideTitle: true,
|
||||
children: (
|
||||
<div className="space-y-5">
|
||||
<UserOverridesSection
|
||||
deliveryPreferences={deliveryPreferences}
|
||||
isUserOverridable={isUserOverridable}
|
||||
userSettings={userSettings}
|
||||
setUserSettings={(updater) => setUserSettings(updater)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-sm opacity-60">
|
||||
Unable to load account details.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end gap-3 border-t border-[var(--border-muted)] px-6 py-4">
|
||||
<UserEditActions
|
||||
variant="modalFooter"
|
||||
onSave={() => {
|
||||
void handleSave();
|
||||
}}
|
||||
saving={isSaving}
|
||||
saveDisabled={!hasChanges || isSaving || isLoading}
|
||||
onCancel={handleClose}
|
||||
cancelDisabled={isSaving}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,7 @@ interface HeadingFieldProps {
|
||||
}
|
||||
|
||||
export const HeadingField = ({ field }: HeadingFieldProps) => (
|
||||
<div className="pb-1 [&:not(:first-child)]:pt-5 [&:not(:first-child)]:mt-1 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-black/10 [&:not(:first-child)]:dark:border-white/10">
|
||||
<div className="pb-1 [&:not(:first-child)]:pt-5 [&:not(:first-child)]:mt-1 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-[var(--border-muted)]">
|
||||
<h3 className="text-base font-semibold mb-1">{field.title}</h3>
|
||||
{field.description && (
|
||||
<p className="text-sm opacity-70">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { SettingsModal } from './SettingsModal';
|
||||
export { SelfSettingsModal } from './SelfSettingsModal';
|
||||
export { SettingsHeader } from './SettingsHeader';
|
||||
export { SettingsSidebar } from './SettingsSidebar';
|
||||
export { SettingsContent } from './SettingsContent';
|
||||
|
||||
@@ -45,10 +45,10 @@ const formatSourceLabel = (source: string): string => {
|
||||
const toRuleKey = (source: string, contentType: RequestPolicyContentType) => `${source}::${contentType}`;
|
||||
|
||||
const modeDescriptions: Record<RequestPolicyMode, string> = {
|
||||
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 = ({
|
||||
) : (
|
||||
<div className="px-3 py-3">
|
||||
<p className="text-xs opacity-60">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 = (
|
||||
</FieldWrapper>
|
||||
);
|
||||
|
||||
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 (
|
||||
<DropdownList
|
||||
options={EDIT_ROLE_OPTIONS}
|
||||
value={user.role}
|
||||
onChange={(value) => {
|
||||
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 (
|
||||
<Tooltip content={roleDisabledReason || 'Role cannot be changed'} position={tooltipPosition}>
|
||||
<span className={getRoleBadgeClassName(user.role, true)}>
|
||||
{roleLabel}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={getRoleBadgeClassName(user.role)}>
|
||||
{roleLabel}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface UserIdentityHeaderProps {
|
||||
user: AdminUser;
|
||||
showAuthSource?: boolean;
|
||||
showInactiveState?: boolean;
|
||||
}
|
||||
|
||||
export const UserIdentityHeader = ({
|
||||
user,
|
||||
showAuthSource = true,
|
||||
showInactiveState = true,
|
||||
}: UserIdentityHeaderProps) => {
|
||||
const active = user.is_active !== false;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium shrink-0
|
||||
${user.role === 'admin' ? 'bg-sky-500/20 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/20'}`}
|
||||
>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{user.display_name || user.username}
|
||||
</span>
|
||||
{user.display_name && (
|
||||
<span className="text-xs opacity-40 truncate">@{user.username}</span>
|
||||
)}
|
||||
{showAuthSource && <UserAuthSourceBadge user={user} showInactive={false} />}
|
||||
</div>
|
||||
<div className="text-xs opacity-50 truncate">
|
||||
{user.email || 'No email'}
|
||||
</div>
|
||||
{showInactiveState && !active && (
|
||||
<div className="text-[11px] opacity-60 truncate">
|
||||
Inactive for current authentication mode
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={cancelDisabled}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--bg-soft)] border border-[var(--border-muted)] hover-action transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={saveDisabled}
|
||||
className="px-5 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed inline-flex items-center gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Changes'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pt-3 border-t border-[var(--border-muted)] sm:flex-row sm:items-center">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={saveDisabled}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={cancelDisabled}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
|
||||
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{onDelete && (
|
||||
<div className="flex flex-wrap gap-2 sm:ml-auto">
|
||||
{isDeletePending ? (
|
||||
<>
|
||||
<button
|
||||
onClick={onConfirmDelete}
|
||||
disabled={deleting}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Confirm Delete'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelDelete}
|
||||
disabled={deleting}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
|
||||
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
border border-red-500/40 text-red-600 hover:bg-red-500/10"
|
||||
>
|
||||
Delete User
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 pt-3 border-t border-[var(--border-muted)] sm:flex-row sm:items-center">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
|
||||
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{onDelete && (
|
||||
<div className="flex flex-wrap gap-2 sm:ml-auto">
|
||||
{isDeletePending ? (
|
||||
<>
|
||||
<button
|
||||
onClick={onConfirmDelete}
|
||||
disabled={deleting}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Confirm Delete'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancelDelete}
|
||||
disabled={deleting}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
|
||||
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors
|
||||
border border-red-500/40 text-red-600 hover:bg-red-500/10"
|
||||
>
|
||||
Delete User
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!hideActions && (
|
||||
<UserEditActions
|
||||
onSave={onSave}
|
||||
saving={saving}
|
||||
saveDisabled={saving}
|
||||
onCancel={onCancel}
|
||||
onDelete={onDelete}
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
onCancelDelete={onCancelDelete}
|
||||
isDeletePending={isDeletePending}
|
||||
deleting={deleting}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface UserPreferencesPanelProps {
|
||||
description?: string;
|
||||
hideTitle?: boolean;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
interface UserAccountCardContentProps extends Omit<UserEditFieldsProps, 'hideActions'> {
|
||||
hideEditActions?: boolean;
|
||||
preferencesPanel?: UserPreferencesPanelProps;
|
||||
preferencesPlacement?: 'before' | 'after';
|
||||
}
|
||||
|
||||
const renderPreferencesPanel = (panel: UserPreferencesPanelProps) => (
|
||||
<div className="space-y-3">
|
||||
{(!panel.hideTitle || panel.onAction) && (
|
||||
<div>
|
||||
{!panel.hideTitle && (
|
||||
<label className="text-sm font-medium">User Preferences</label>
|
||||
)}
|
||||
{!panel.hideTitle && panel.description && (
|
||||
<p className="text-xs opacity-60 mt-0.5">{panel.description}</p>
|
||||
)}
|
||||
{panel.onAction && (
|
||||
<button
|
||||
onClick={panel.onAction}
|
||||
className="mt-2 px-4 py-2 rounded-lg text-sm font-medium text-white
|
||||
bg-sky-600 hover:bg-sky-700 transition-colors"
|
||||
>
|
||||
{panel.actionLabel || 'Open User Preferences'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{panel.children}
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="space-y-5">
|
||||
{preferencesContent && preferencesPlacement === 'before' && (
|
||||
<>
|
||||
{preferencesContent}
|
||||
<div className="border-t border-[var(--border-muted)]" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<UserEditFields
|
||||
user={user}
|
||||
onUserChange={onUserChange}
|
||||
onSave={onSave}
|
||||
saving={saving}
|
||||
onCancel={onCancel}
|
||||
hideActions={hideEditActions}
|
||||
editPassword={editPassword}
|
||||
onEditPasswordChange={onEditPasswordChange}
|
||||
editPasswordConfirm={editPasswordConfirm}
|
||||
onEditPasswordConfirmChange={onEditPasswordConfirmChange}
|
||||
onDelete={onDelete}
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
onCancelDelete={onCancelDelete}
|
||||
isDeletePending={isDeletePending}
|
||||
deleting={deleting}
|
||||
/>
|
||||
|
||||
{preferencesContent && preferencesPlacement === 'after' && (
|
||||
<>
|
||||
<div className="border-t border-[var(--border-muted)]" />
|
||||
{preferencesContent}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
key={user.id}
|
||||
@@ -132,82 +123,18 @@ export const UserListView = ({
|
||||
<div
|
||||
className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 ${isEditingRow ? 'border-b border-[var(--border-muted)]' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium shrink-0
|
||||
${user.role === 'admin' ? 'bg-sky-500/20 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/20'}`}
|
||||
>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{user.display_name || user.username}
|
||||
</span>
|
||||
{user.display_name && (
|
||||
<span className="text-xs opacity-40 truncate">@{user.username}</span>
|
||||
)}
|
||||
<UserAuthSourceBadge user={user} />
|
||||
</div>
|
||||
<div className="text-xs opacity-50 truncate">
|
||||
{user.email || 'No email'}
|
||||
</div>
|
||||
{!active && (
|
||||
<div className="text-[11px] opacity-60 truncate">
|
||||
Inactive for current authentication mode
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<UserIdentityHeader user={user} />
|
||||
|
||||
<div className="flex items-center flex-wrap gap-2 shrink-0 sm:justify-end">
|
||||
{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 (
|
||||
<Tooltip content={roleDisabledReason || 'Role cannot be changed'} position="bottom">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium leading-none cursor-not-allowed
|
||||
${editingUser.role === 'admin' ? 'bg-sky-500/15 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/10 opacity-70'}`}
|
||||
>
|
||||
{editingUser.role.charAt(0).toUpperCase() + editingUser.role.slice(1)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownList
|
||||
options={EDIT_ROLE_OPTIONS}
|
||||
value={editingUser.role}
|
||||
onChange={(value) => {
|
||||
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'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
})() : (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium leading-none
|
||||
${user.role === 'admin' ? 'bg-sky-500/15 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/10 opacity-70'}`}
|
||||
>
|
||||
{roleLabel}
|
||||
</span>
|
||||
{hasLoadedEditUser && editingUser ? (
|
||||
<UserRoleControl
|
||||
user={editingUser}
|
||||
onUserChange={onEditingUserChange}
|
||||
oidcAdminGroup={downloadDefaults?.OIDC_ADMIN_GROUP}
|
||||
tooltipPosition="bottom"
|
||||
/>
|
||||
) : (
|
||||
<UserRoleControl user={user} />
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -244,38 +171,27 @@ export const UserListView = ({
|
||||
{isEditingRow && (
|
||||
<div className="p-4 space-y-5 bg-[var(--bg)] rounded-b-lg">
|
||||
{hasLoadedEditUser && editingUser ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-sm font-medium">User Preferences</label>
|
||||
<p className="text-xs opacity-60 mt-0.5">Override global delivery and request policy settings for this user.</p>
|
||||
<button
|
||||
onClick={onOpenOverrides}
|
||||
className="mt-2 px-4 py-2 rounded-lg text-sm font-medium text-white
|
||||
bg-sky-600 hover:bg-sky-700 transition-colors"
|
||||
>
|
||||
Open User Preferences
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border-muted)]" />
|
||||
|
||||
<UserEditFields
|
||||
user={editingUser}
|
||||
onUserChange={onEditingUserChange}
|
||||
onSave={onEditSave}
|
||||
saving={saving}
|
||||
onCancel={onCancelEdit}
|
||||
editPassword={editPassword}
|
||||
onEditPasswordChange={onEditPasswordChange}
|
||||
editPasswordConfirm={editPasswordConfirm}
|
||||
onEditPasswordConfirmChange={onEditPasswordConfirmChange}
|
||||
onDelete={() => setConfirmDelete(user.id)}
|
||||
onConfirmDelete={() => handleDelete(user.id)}
|
||||
onCancelDelete={() => setConfirmDelete(null)}
|
||||
isDeletePending={confirmDelete === user.id}
|
||||
deleting={deletingUserId === user.id}
|
||||
/>
|
||||
</>
|
||||
<UserAccountCardContent
|
||||
user={editingUser}
|
||||
onUserChange={onEditingUserChange}
|
||||
onSave={onEditSave}
|
||||
saving={saving}
|
||||
onCancel={onCancelEdit}
|
||||
editPassword={editPassword}
|
||||
onEditPasswordChange={onEditPasswordChange}
|
||||
editPasswordConfirm={editPasswordConfirm}
|
||||
onEditPasswordConfirmChange={onEditPasswordConfirmChange}
|
||||
onDelete={() => 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,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm opacity-60">Loading user details...</div>
|
||||
)}
|
||||
|
||||
@@ -35,8 +35,8 @@ const REQUEST_POLICY_OVERRIDE_KEYS: Array<keyof PerUserSettings> = [
|
||||
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 => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
417
src/frontend/src/hooks/useActivity.ts
Normal file
417
src/frontend/src/hooks/useActivity.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<void>;
|
||||
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<StatusData>({});
|
||||
const [activityRequests, setActivityRequests] = useState<RequestRecord[]>([]);
|
||||
const [dismissedActivityKeys, setDismissedActivityKeys] = useState<string[]>([]);
|
||||
const [isActivitySnapshotLoading, setIsActivitySnapshotLoading] = useState(false);
|
||||
|
||||
const [activityHistoryRows, setActivityHistoryRows] = useState<ActivityHistoryItem[]>([]);
|
||||
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<number>();
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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<StatusData> => {
|
||||
return fetchJSON<StatusData>(API.status);
|
||||
};
|
||||
|
||||
export const getActivitySnapshot = async (): Promise<ActivitySnapshotResponse> => {
|
||||
return fetchJSON<ActivitySnapshotResponse>(API.activitySnapshot);
|
||||
};
|
||||
|
||||
export const dismissActivityItem = async (payload: ActivityDismissPayload): Promise<void> => {
|
||||
await fetchJSON(API.activityDismiss, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
};
|
||||
|
||||
export const dismissManyActivityItems = async (items: ActivityDismissPayload[]): Promise<void> => {
|
||||
await fetchJSON(API.activityDismissMany, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ items }),
|
||||
});
|
||||
};
|
||||
|
||||
export const listActivityHistory = async (
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<ActivityHistoryItem[]> => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(limit));
|
||||
params.set('offset', String(offset));
|
||||
return fetchJSON<ActivityHistoryItem[]>(`${API.activityHistory}?${params.toString()}`);
|
||||
};
|
||||
|
||||
export const clearActivityHistory = async (): Promise<void> => {
|
||||
await fetchJSON(API.activityHistory, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
export const cancelDownload = async (id: string): Promise<void> => {
|
||||
await fetchJSON(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' });
|
||||
};
|
||||
@@ -309,6 +345,38 @@ export interface AdminRequestCounts {
|
||||
by_status: Record<string, number>;
|
||||
}
|
||||
|
||||
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<string, unknown> | 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<RequestPolicyResponse> => {
|
||||
return fetchJSON<RequestPolicyResponse>(API.requestPolicy);
|
||||
};
|
||||
@@ -527,6 +595,12 @@ export interface AdminUser {
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SelfUserEditContext {
|
||||
user: AdminUser;
|
||||
deliveryPreferences: DeliveryPreferencesResponse | null;
|
||||
userOverridableKeys: string[];
|
||||
}
|
||||
|
||||
export const getAdminUsers = async (): Promise<AdminUser[]> => {
|
||||
return fetchJSON<AdminUser[]>(`${API_BASE}/admin/users`);
|
||||
};
|
||||
@@ -644,3 +718,19 @@ export const getAdminSettingsOverridesSummary = async (
|
||||
): Promise<SettingsOverridesSummaryResponse> => {
|
||||
return fetchJSON<SettingsOverridesSummaryResponse>(`${API_BASE}/admin/settings/overrides-summary?tab=${encodeURIComponent(tabName)}`);
|
||||
};
|
||||
|
||||
export const getSelfUserEditContext = async (): Promise<SelfUserEditContext> => {
|
||||
return fetchJSON<SelfUserEditContext>(`${API_BASE}/users/me/edit-context`);
|
||||
};
|
||||
|
||||
export const updateSelfUser = async (
|
||||
data: Partial<Pick<AdminUser, 'email' | 'display_name'>> & {
|
||||
password?: string;
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<AdminUser> => {
|
||||
return fetchJSON<AdminUser>(`${API_BASE}/users/me`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
41
src/frontend/src/utils/themePreference.ts
Normal file
41
src/frontend/src/utils/themePreference.ts
Normal file
@@ -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);
|
||||
}
|
||||
420
tests/core/test_activity_routes_api.py
Normal file
420
tests/core/test_activity_routes_api.py
Normal file
@@ -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']}",
|
||||
)
|
||||
245
tests/core/test_activity_service.py
Normal file
245
tests/core/test_activity_service.py
Normal file
@@ -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"},
|
||||
}
|
||||
156
tests/core/test_activity_terminal_snapshots.py
Normal file
156
tests/core/test_activity_terminal_snapshots.py
Normal file
@@ -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)
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user