mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 09:01:12 +01: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",
|
"value": "download",
|
||||||
"label": "Download",
|
"label": "Download",
|
||||||
"description": "Allow direct downloads.",
|
"description": "Everything can be downloaded directly.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": "request_release",
|
"value": "request_release",
|
||||||
"label": "Request Release",
|
"label": "Request Release",
|
||||||
"description": "Block direct download; allow requesting a specific release.",
|
"description": "Users must request a specific release.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": "request_book",
|
"value": "request_book",
|
||||||
"label": "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",
|
"value": "blocked",
|
||||||
"label": "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",
|
key="users_management",
|
||||||
component="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(
|
HeadingField(
|
||||||
key="requests_heading",
|
key="requests_heading",
|
||||||
title="Request Policy",
|
title="Requests",
|
||||||
description=(
|
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(
|
CheckboxField(
|
||||||
key="REQUESTS_ENABLED",
|
key="REQUESTS_ENABLED",
|
||||||
label="Enable Request Workflow",
|
label="Enable Requests",
|
||||||
description=(
|
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,
|
default=False,
|
||||||
user_overridable=True,
|
user_overridable=True,
|
||||||
@@ -213,9 +198,9 @@ def users_settings():
|
|||||||
CustomComponentField(
|
CustomComponentField(
|
||||||
key="request_policy_editor",
|
key="request_policy_editor",
|
||||||
component="request_policy_grid",
|
component="request_policy_grid",
|
||||||
label="Request Policy Rules",
|
label="Request Rules",
|
||||||
description=(
|
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},
|
show_when={"field": "REQUESTS_ENABLED", "value": True},
|
||||||
wrap_in_field_wrapper=True,
|
wrap_in_field_wrapper=True,
|
||||||
@@ -224,7 +209,7 @@ def users_settings():
|
|||||||
key="REQUEST_POLICY_DEFAULT_EBOOK",
|
key="REQUEST_POLICY_DEFAULT_EBOOK",
|
||||||
label="Default Ebook Mode",
|
label="Default Ebook Mode",
|
||||||
description=(
|
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,
|
options=_REQUEST_DEFAULT_MODE_OPTIONS,
|
||||||
default="download",
|
default="download",
|
||||||
@@ -234,7 +219,7 @@ def users_settings():
|
|||||||
key="REQUEST_POLICY_DEFAULT_AUDIOBOOK",
|
key="REQUEST_POLICY_DEFAULT_AUDIOBOOK",
|
||||||
label="Default Audiobook Mode",
|
label="Default Audiobook Mode",
|
||||||
description=(
|
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,
|
options=_REQUEST_DEFAULT_MODE_OPTIONS,
|
||||||
default="download",
|
default="download",
|
||||||
@@ -242,9 +227,9 @@ def users_settings():
|
|||||||
),
|
),
|
||||||
TableField(
|
TableField(
|
||||||
key="REQUEST_POLICY_RULES",
|
key="REQUEST_POLICY_RULES",
|
||||||
label="Request Policy Rules",
|
label="Request Rules",
|
||||||
description=(
|
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,
|
columns=_get_request_policy_rule_columns,
|
||||||
default=[],
|
default=[],
|
||||||
@@ -257,8 +242,8 @@ def users_settings():
|
|||||||
),
|
),
|
||||||
NumberField(
|
NumberField(
|
||||||
key="MAX_PENDING_REQUESTS_PER_USER",
|
key="MAX_PENDING_REQUESTS_PER_USER",
|
||||||
label="Max Pending Requests Per User",
|
label="Max pending requests per user",
|
||||||
description="Maximum number of pending requests a user can have at once.",
|
description="How many open requests a user can have at a time.",
|
||||||
default=20,
|
default=20,
|
||||||
min_value=1,
|
min_value=1,
|
||||||
max_value=1000,
|
max_value=1000,
|
||||||
@@ -267,8 +252,8 @@ def users_settings():
|
|||||||
),
|
),
|
||||||
CheckboxField(
|
CheckboxField(
|
||||||
key="REQUESTS_ALLOW_NOTES",
|
key="REQUESTS_ALLOW_NOTES",
|
||||||
label="Allow Request Notes",
|
label="Allow notes on requests",
|
||||||
description="Allow users to include notes when creating requests.",
|
description="Let users add a note when they submit a request.",
|
||||||
default=True,
|
default=True,
|
||||||
user_overridable=True,
|
user_overridable=True,
|
||||||
show_when={"field": "REQUESTS_ENABLED", "value": True},
|
show_when={"field": "REQUESTS_ENABLED", "value": True},
|
||||||
|
|||||||
@@ -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})
|
||||||
@@ -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(
|
def should_restrict_settings_to_admin(
|
||||||
users_config: Mapping[str, Any],
|
_users_config: Mapping[str, Any],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return whether settings/onboarding access is limited to admins."""
|
"""Settings/onboarding is always admin-only."""
|
||||||
return bool(users_config.get("RESTRICT_SETTINGS_TO_ADMIN", True))
|
return True
|
||||||
|
|
||||||
|
|
||||||
def requires_admin_for_settings_access(
|
def requires_admin_for_settings_access(
|
||||||
@@ -116,14 +116,11 @@ def requires_admin_for_settings_access(
|
|||||||
|
|
||||||
def get_auth_check_admin_status(
|
def get_auth_check_admin_status(
|
||||||
_auth_mode: str,
|
_auth_mode: str,
|
||||||
users_config: Mapping[str, Any],
|
_users_config: Mapping[str, Any],
|
||||||
session_data: Mapping[str, Any],
|
session_data: Mapping[str, Any],
|
||||||
) -> bool:
|
) -> 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:
|
if "user_id" not in session_data:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not should_restrict_settings_to_admin(users_config):
|
|
||||||
return True
|
|
||||||
|
|
||||||
return bool(session_data.get("is_admin", False))
|
return bool(session_data.get("is_admin", False))
|
||||||
|
|||||||
+63
-12
@@ -5,7 +5,7 @@ import time
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock, Event
|
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.config import config as app_config
|
||||||
from shelfmark.core.models import QueueStatus, QueueItem, DownloadTask
|
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._status_timestamps: dict[str, datetime] = {} # Track when each status was last updated
|
||||||
self._cancel_flags: dict[str, Event] = {} # Cancellation flags for active downloads
|
self._cancel_flags: dict[str, Event] = {} # Cancellation flags for active downloads
|
||||||
self._active_downloads: dict[str, bool] = {} # Track currently downloading tasks
|
self._active_downloads: dict[str, bool] = {} # Track currently downloading tasks
|
||||||
|
self._terminal_status_hook: Optional[
|
||||||
|
Callable[[str, QueueStatus, DownloadTask], None]
|
||||||
|
] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _status_timeout(self) -> timedelta:
|
def _status_timeout(self) -> timedelta:
|
||||||
@@ -79,16 +82,47 @@ class BookQueue:
|
|||||||
self._status[book_id] = status
|
self._status[book_id] = status
|
||||||
self._status_timestamps[book_id] = datetime.now()
|
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:
|
def update_status(self, book_id: str, status: QueueStatus) -> None:
|
||||||
"""Update status of a book in the queue."""
|
"""Update status of a book in the queue."""
|
||||||
|
hook: Optional[Callable[[str, QueueStatus, DownloadTask], None]] = None
|
||||||
|
hook_task: Optional[DownloadTask] = None
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
previous_status = self._status.get(book_id)
|
||||||
self._update_status(book_id, status)
|
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
|
# Clean up active download tracking when finished
|
||||||
if status in [QueueStatus.COMPLETE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
|
if status in [QueueStatus.COMPLETE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
|
||||||
self._active_downloads.pop(book_id, None)
|
self._active_downloads.pop(book_id, None)
|
||||||
self._cancel_flags.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:
|
def update_download_path(self, task_id: str, download_path: str) -> None:
|
||||||
"""Update the download path of a task in the queue."""
|
"""Update the download path of a task in the queue."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -166,13 +200,7 @@ class BookQueue:
|
|||||||
# Signal active download to stop
|
# Signal active download to stop
|
||||||
if task_id in self._cancel_flags:
|
if task_id in self._cancel_flags:
|
||||||
self._cancel_flags[task_id].set()
|
self._cancel_flags[task_id].set()
|
||||||
self._update_status(task_id, QueueStatus.CANCELLED)
|
if current_status in [QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, 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]:
|
|
||||||
# Clear completed/errored/cancelled items from tracking
|
# Clear completed/errored/cancelled items from tracking
|
||||||
self._status.pop(task_id, None)
|
self._status.pop(task_id, None)
|
||||||
self._status_timestamps.pop(task_id, None)
|
self._status_timestamps.pop(task_id, None)
|
||||||
@@ -181,7 +209,11 @@ class BookQueue:
|
|||||||
self._active_downloads.pop(task_id, None)
|
self._active_downloads.pop(task_id, None)
|
||||||
return True
|
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:
|
def set_priority(self, task_id: str, new_priority: int) -> bool:
|
||||||
"""Change the priority of a queued task (lower = higher priority)."""
|
"""Change the priority of a queued task (lower = higher priority)."""
|
||||||
@@ -253,11 +285,30 @@ class BookQueue:
|
|||||||
return True
|
return True
|
||||||
return any(status == QueueStatus.QUEUED for status in self._status.values())
|
return any(status == QueueStatus.QUEUED for status in self._status.values())
|
||||||
|
|
||||||
def clear_completed(self) -> int:
|
def clear_completed(self, user_id: Optional[int] = None) -> int:
|
||||||
"""Remove all completed, errored, or cancelled tasks from tracking."""
|
"""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}
|
terminal_statuses = {QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED}
|
||||||
with self._lock:
|
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:
|
for task_id in to_remove:
|
||||||
self._status.pop(task_id, None)
|
self._status.pop(task_id, None)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from shelfmark.core.requests_service import (
|
|||||||
fulfil_request,
|
fulfil_request,
|
||||||
reject_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.settings_registry import load_config_file
|
||||||
from shelfmark.core.user_db import UserDB
|
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}")
|
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(
|
def register_request_routes(
|
||||||
app: Flask,
|
app: Flask,
|
||||||
user_db: UserDB,
|
user_db: UserDB,
|
||||||
*,
|
*,
|
||||||
resolve_auth_mode: Callable[[], str],
|
resolve_auth_mode: Callable[[], str],
|
||||||
queue_release: Callable[..., tuple[bool, str | None]],
|
queue_release: Callable[..., tuple[bool, str | None]],
|
||||||
|
activity_service: ActivityService | None = None,
|
||||||
ws_manager: Any | None = None,
|
ws_manager: Any | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Register request policy and request lifecycle routes."""
|
"""Register request policy and request lifecycle routes."""
|
||||||
@@ -408,6 +463,8 @@ def register_request_routes(
|
|||||||
except RequestServiceError as exc:
|
except RequestServiceError as exc:
|
||||||
return _error_response(str(exc), exc.status_code, code=exc.code)
|
return _error_response(str(exc), exc.status_code, code=exc.code)
|
||||||
|
|
||||||
|
_record_terminal_request_snapshot(activity_service, request_row=updated)
|
||||||
|
|
||||||
event_payload = {
|
event_payload = {
|
||||||
"request_id": updated["id"],
|
"request_id": updated["id"],
|
||||||
"status": updated["status"],
|
"status": updated["status"],
|
||||||
@@ -557,6 +614,8 @@ def register_request_routes(
|
|||||||
except RequestServiceError as exc:
|
except RequestServiceError as exc:
|
||||||
return _error_response(str(exc), exc.status_code, code=exc.code)
|
return _error_response(str(exc), exc.status_code, code=exc.code)
|
||||||
|
|
||||||
|
_record_terminal_request_snapshot(activity_service, request_row=updated)
|
||||||
|
|
||||||
event_payload = {
|
event_payload = {
|
||||||
"request_id": updated["id"],
|
"request_id": updated["id"],
|
||||||
"status": updated["status"],
|
"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"})
|
VALID_REQUEST_STATUSES = frozenset({"pending", "fulfilled", "rejected", "cancelled"})
|
||||||
TERMINAL_REQUEST_STATUSES = frozenset({"fulfilled", "rejected", "cancelled"})
|
TERMINAL_REQUEST_STATUSES = frozenset({"fulfilled", "rejected", "cancelled"})
|
||||||
VALID_REQUEST_LEVELS = frozenset({"book", "release"})
|
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_NOTE_LENGTH = 1000
|
||||||
MAX_REQUEST_JSON_BLOB_BYTES = 10 * 1024
|
MAX_REQUEST_JSON_BLOB_BYTES = 10 * 1024
|
||||||
|
|
||||||
@@ -63,6 +76,16 @@ def normalize_request_level(request_level: Any) -> str:
|
|||||||
return normalized
|
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:
|
def validate_request_level_payload(request_level: Any, release_data: Any) -> str:
|
||||||
"""Validate request_level and release_data shape coupling."""
|
"""Validate request_level and release_data shape coupling."""
|
||||||
normalized_level = normalize_request_level(request_level)
|
normalized_level = normalize_request_level(request_level)
|
||||||
@@ -163,6 +186,68 @@ def _now_timestamp() -> str:
|
|||||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
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(
|
def create_request(
|
||||||
user_db: "UserDB",
|
user_db: "UserDB",
|
||||||
*,
|
*,
|
||||||
@@ -383,6 +468,8 @@ def fulfil_request(
|
|||||||
request_id,
|
request_id,
|
||||||
status="fulfilled",
|
status="fulfilled",
|
||||||
release_data=selected_release_data,
|
release_data=selected_release_data,
|
||||||
|
delivery_state="queued",
|
||||||
|
delivery_updated_at=_now_timestamp(),
|
||||||
admin_note=normalized_admin_note,
|
admin_note=normalized_admin_note,
|
||||||
reviewed_by=admin_user_id,
|
reviewed_by=admin_user_id,
|
||||||
reviewed_at=_now_timestamp(),
|
reviewed_at=_now_timestamp(),
|
||||||
|
|||||||
@@ -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)
|
||||||
+143
-3
@@ -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.auth_modes import AUTH_SOURCE_BUILTIN, AUTH_SOURCE_SET
|
||||||
from shelfmark.core.logger import setup_logger
|
from shelfmark.core.logger import setup_logger
|
||||||
from shelfmark.core.requests_service import (
|
from shelfmark.core.requests_service import (
|
||||||
|
normalize_delivery_state,
|
||||||
normalize_policy_mode,
|
normalize_policy_mode,
|
||||||
normalize_request_level,
|
normalize_request_level,
|
||||||
normalize_request_status,
|
normalize_request_status,
|
||||||
@@ -40,6 +41,7 @@ CREATE TABLE IF NOT EXISTS download_requests (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
delivery_state TEXT NOT NULL DEFAULT 'none',
|
||||||
source_hint TEXT,
|
source_hint TEXT,
|
||||||
content_type TEXT NOT NULL,
|
content_type TEXT NOT NULL,
|
||||||
request_level TEXT NOT NULL,
|
request_level TEXT NOT NULL,
|
||||||
@@ -50,7 +52,8 @@ CREATE TABLE IF NOT EXISTS download_requests (
|
|||||||
admin_note TEXT,
|
admin_note TEXT,
|
||||||
reviewed_by INTEGER REFERENCES users(id),
|
reviewed_by INTEGER REFERENCES users(id),
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
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
|
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
|
CREATE INDEX IF NOT EXISTS idx_download_requests_status_created_at
|
||||||
ON download_requests (status, created_at DESC);
|
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:
|
try:
|
||||||
conn.executescript(_CREATE_TABLES_SQL)
|
conn.executescript(_CREATE_TABLES_SQL)
|
||||||
self._migrate_auth_source_column(conn)
|
self._migrate_auth_source_column(conn)
|
||||||
|
self._migrate_request_delivery_columns(conn)
|
||||||
|
self._migrate_activity_tables(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
# WAL mode must be changed outside an open transaction.
|
# WAL mode must be changed outside an open transaction.
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
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 = ''"
|
"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(
|
def create_user(
|
||||||
self,
|
self,
|
||||||
username: str,
|
username: str,
|
||||||
@@ -350,6 +473,8 @@ class UserDB:
|
|||||||
admin_note: Optional[str] = None,
|
admin_note: Optional[str] = None,
|
||||||
reviewed_by: Optional[int] = None,
|
reviewed_by: Optional[int] = None,
|
||||||
reviewed_at: Optional[str] = None,
|
reviewed_at: Optional[str] = None,
|
||||||
|
delivery_state: str = "none",
|
||||||
|
delivery_updated_at: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Create a download request row and return the created record."""
|
"""Create a download request row and return the created record."""
|
||||||
if not isinstance(book_data, dict):
|
if not isinstance(book_data, dict):
|
||||||
@@ -360,6 +485,7 @@ class UserDB:
|
|||||||
raise ValueError("content_type is required")
|
raise ValueError("content_type is required")
|
||||||
|
|
||||||
normalized_status = normalize_request_status(status)
|
normalized_status = normalize_request_status(status)
|
||||||
|
normalized_delivery_state = normalize_delivery_state(delivery_state)
|
||||||
normalized_policy_mode = normalize_policy_mode(policy_mode)
|
normalized_policy_mode = normalize_policy_mode(policy_mode)
|
||||||
normalized_request_level = validate_request_level_payload(request_level, release_data)
|
normalized_request_level = validate_request_level_payload(request_level, release_data)
|
||||||
|
|
||||||
@@ -371,6 +497,7 @@ class UserDB:
|
|||||||
INSERT INTO download_requests (
|
INSERT INTO download_requests (
|
||||||
user_id,
|
user_id,
|
||||||
status,
|
status,
|
||||||
|
delivery_state,
|
||||||
source_hint,
|
source_hint,
|
||||||
content_type,
|
content_type,
|
||||||
request_level,
|
request_level,
|
||||||
@@ -380,13 +507,15 @@ class UserDB:
|
|||||||
note,
|
note,
|
||||||
admin_note,
|
admin_note,
|
||||||
reviewed_by,
|
reviewed_by,
|
||||||
reviewed_at
|
reviewed_at,
|
||||||
|
delivery_updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
user_id,
|
user_id,
|
||||||
normalized_status,
|
normalized_status,
|
||||||
|
normalized_delivery_state,
|
||||||
source_hint,
|
source_hint,
|
||||||
content_type,
|
content_type,
|
||||||
normalized_request_level,
|
normalized_request_level,
|
||||||
@@ -397,6 +526,7 @@ class UserDB:
|
|||||||
admin_note,
|
admin_note,
|
||||||
reviewed_by,
|
reviewed_by,
|
||||||
reviewed_at,
|
reviewed_at,
|
||||||
|
delivery_updated_at,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -483,6 +613,8 @@ class UserDB:
|
|||||||
"admin_note",
|
"admin_note",
|
||||||
"reviewed_by",
|
"reviewed_by",
|
||||||
"reviewed_at",
|
"reviewed_at",
|
||||||
|
"delivery_state",
|
||||||
|
"delivery_updated_at",
|
||||||
}
|
}
|
||||||
|
|
||||||
def update_request(self, request_id: int, **kwargs) -> Dict[str, Any]:
|
def update_request(self, request_id: int, **kwargs) -> Dict[str, Any]:
|
||||||
@@ -520,6 +652,14 @@ class UserDB:
|
|||||||
if "policy_mode" in updates:
|
if "policy_mode" in updates:
|
||||||
updates["policy_mode"] = normalize_policy_mode(updates["policy_mode"])
|
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"]:
|
if "content_type" in updates and not updates["content_type"]:
|
||||||
raise ValueError("content_type is required")
|
raise ValueError("content_type is required")
|
||||||
|
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ def _task_to_dict(task: DownloadTask) -> Dict[str, Any]:
|
|||||||
'status': task.status,
|
'status': task.status,
|
||||||
'status_message': task.status_message,
|
'status_message': task.status_message,
|
||||||
'download_path': task.download_path,
|
'download_path': task.download_path,
|
||||||
|
'user_id': task.user_id,
|
||||||
'username': task.username,
|
'username': task.username,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,12 +493,13 @@ def update_download_status(book_id: str, status: str, message: Optional[str] = N
|
|||||||
return
|
return
|
||||||
_last_status_event[book_id] = status_event
|
_last_status_event[book_id] = status_event
|
||||||
|
|
||||||
book_queue.update_status(book_id, queue_status_enum)
|
# Update status message first so terminal snapshots capture the final message
|
||||||
|
# (for example, "Complete" or "Sent to ...") instead of a stale in-progress one.
|
||||||
# Update status message if provided (empty string clears the message)
|
|
||||||
if message is not None:
|
if message is not None:
|
||||||
book_queue.update_status_message(book_id, message)
|
book_queue.update_status_message(book_id, message)
|
||||||
|
|
||||||
|
book_queue.update_status(book_id, queue_status_enum)
|
||||||
|
|
||||||
# Broadcast status update via WebSocket
|
# Broadcast status update via WebSocket
|
||||||
if ws_manager:
|
if ws_manager:
|
||||||
ws_manager.broadcast_status_update(queue_status())
|
ws_manager.broadcast_status_update(queue_status())
|
||||||
@@ -528,9 +530,9 @@ def get_active_downloads() -> List[str]:
|
|||||||
"""Get list of currently active downloads."""
|
"""Get list of currently active downloads."""
|
||||||
return book_queue.get_active_downloads()
|
return book_queue.get_active_downloads()
|
||||||
|
|
||||||
def clear_completed() -> int:
|
def clear_completed(user_id: Optional[int] = None) -> int:
|
||||||
"""Clear all completed downloads from tracking."""
|
"""Clear completed downloads from tracking (optionally user-scoped)."""
|
||||||
return book_queue.clear_completed()
|
return book_queue.clear_completed(user_id=user_id)
|
||||||
|
|
||||||
def _cleanup_progress_tracking(task_id: str) -> None:
|
def _cleanup_progress_tracking(task_id: str) -> None:
|
||||||
"""Clean up progress tracking data for a completed/cancelled download."""
|
"""Clean up progress tracking data for a completed/cancelled download."""
|
||||||
|
|||||||
+201
-4
@@ -26,7 +26,7 @@ from shelfmark.config.env import (
|
|||||||
)
|
)
|
||||||
from shelfmark.core.config import config as app_config
|
from shelfmark.core.config import config as app_config
|
||||||
from shelfmark.core.logger import setup_logger
|
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.prefix_middleware import PrefixMiddleware
|
||||||
from shelfmark.core.auth_modes import (
|
from shelfmark.core.auth_modes import (
|
||||||
determine_auth_mode,
|
determine_auth_mode,
|
||||||
@@ -45,6 +45,10 @@ from shelfmark.core.request_policy import (
|
|||||||
normalize_source,
|
normalize_source,
|
||||||
resolve_policy_mode,
|
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.core.utils import normalize_base_path
|
||||||
from shelfmark.api.websocket import ws_manager
|
from shelfmark.api.websocket import ws_manager
|
||||||
|
|
||||||
@@ -113,14 +117,18 @@ import os as _os
|
|||||||
from shelfmark.core.user_db import UserDB
|
from shelfmark.core.user_db import UserDB
|
||||||
_user_db_path = _os.path.join(_os.environ.get("CONFIG_DIR", "/config"), "users.db")
|
_user_db_path = _os.path.join(_os.environ.get("CONFIG_DIR", "/config"), "users.db")
|
||||||
user_db: UserDB | None = None
|
user_db: UserDB | None = None
|
||||||
|
activity_service: ActivityService | None = None
|
||||||
try:
|
try:
|
||||||
user_db = UserDB(_user_db_path)
|
user_db = UserDB(_user_db_path)
|
||||||
user_db.initialize()
|
user_db.initialize()
|
||||||
|
activity_service = ActivityService(_user_db_path)
|
||||||
import shelfmark.config.users_settings as _ # noqa: F401 - registers users tab
|
import shelfmark.config.users_settings as _ # noqa: F401 - registers users tab
|
||||||
from shelfmark.core.oidc_routes import register_oidc_routes
|
from shelfmark.core.oidc_routes import register_oidc_routes
|
||||||
from shelfmark.core.admin_routes import register_admin_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_oidc_routes(app, user_db)
|
||||||
register_admin_routes(app, user_db)
|
register_admin_routes(app, user_db)
|
||||||
|
register_self_user_routes(app, user_db)
|
||||||
except (sqlite3.OperationalError, OSError) as e:
|
except (sqlite3.OperationalError, OSError) as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"User database initialization failed: {e}. "
|
f"User database initialization failed: {e}. "
|
||||||
@@ -380,14 +388,28 @@ def _policy_block_response(mode: PolicyMode):
|
|||||||
if user_db is not None:
|
if user_db is not None:
|
||||||
try:
|
try:
|
||||||
from shelfmark.core.request_routes import register_request_routes
|
from shelfmark.core.request_routes import register_request_routes
|
||||||
|
from shelfmark.core.activity_routes import register_activity_routes
|
||||||
|
|
||||||
register_request_routes(
|
register_request_routes(
|
||||||
app,
|
app,
|
||||||
user_db,
|
user_db,
|
||||||
resolve_auth_mode=lambda: get_auth_mode(),
|
resolve_auth_mode=lambda: get_auth_mode(),
|
||||||
queue_release=lambda *args, **kwargs: backend.queue_release(*args, **kwargs),
|
queue_release=lambda *args, **kwargs: backend.queue_release(*args, **kwargs),
|
||||||
|
activity_service=activity_service,
|
||||||
ws_manager=ws_manager,
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to register request routes: {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
|
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'])
|
@app.route('/api/status', methods=['GET'])
|
||||||
@login_required
|
@login_required
|
||||||
def api_status() -> Union[Response, Tuple[Response, int]]:
|
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
|
user_id = None if is_admin else db_user_id
|
||||||
status = backend.queue_status(user_id=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)
|
return jsonify(status)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error_trace(f"Status error: {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.
|
flask.Response: JSON status indicating success or failure.
|
||||||
"""
|
"""
|
||||||
try:
|
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)
|
success = backend.cancel_download(book_id)
|
||||||
if success:
|
if success:
|
||||||
return jsonify({"status": "cancelled", "book_id": book_id})
|
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.
|
flask.Response: JSON with count of removed books.
|
||||||
"""
|
"""
|
||||||
try:
|
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
|
# Broadcast status update after clearing
|
||||||
if ws_manager:
|
if ws_manager:
|
||||||
ws_manager.broadcast_status_update(backend.queue_status())
|
ws_manager.broadcast_status_update(backend.queue_status())
|
||||||
|
|
||||||
return jsonify({"status": "cleared", "removed_count": removed_count})
|
return jsonify({"status": "cleared", "removed_count": removed_count})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error_trace(f"Clear completed error: {e}")
|
logger.error_trace(f"Clear completed error: {e}")
|
||||||
|
|||||||
+100
-97
@@ -17,7 +17,6 @@ import {
|
|||||||
downloadBook,
|
downloadBook,
|
||||||
downloadRelease,
|
downloadRelease,
|
||||||
cancelDownload,
|
cancelDownload,
|
||||||
clearCompleted,
|
|
||||||
getConfig,
|
getConfig,
|
||||||
createRequest,
|
createRequest,
|
||||||
isApiResponseError,
|
isApiResponseError,
|
||||||
@@ -31,6 +30,7 @@ import { useDownloadTracking } from './hooks/useDownloadTracking';
|
|||||||
import { useRequestPolicy } from './hooks/useRequestPolicy';
|
import { useRequestPolicy } from './hooks/useRequestPolicy';
|
||||||
import { resolveDefaultModeFromPolicy, resolveSourceModeFromPolicy } from './hooks/requestPolicyCore';
|
import { resolveDefaultModeFromPolicy, resolveSourceModeFromPolicy } from './hooks/requestPolicyCore';
|
||||||
import { useRequests } from './hooks/useRequests';
|
import { useRequests } from './hooks/useRequests';
|
||||||
|
import { useActivity } from './hooks/useActivity';
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { SearchSection } from './components/SearchSection';
|
import { SearchSection } from './components/SearchSection';
|
||||||
import { AdvancedFilters } from './components/AdvancedFilters';
|
import { AdvancedFilters } from './components/AdvancedFilters';
|
||||||
@@ -40,9 +40,9 @@ import { ReleaseModal } from './components/ReleaseModal';
|
|||||||
import { RequestConfirmationModal } from './components/RequestConfirmationModal';
|
import { RequestConfirmationModal } from './components/RequestConfirmationModal';
|
||||||
import { ToastContainer } from './components/ToastContainer';
|
import { ToastContainer } from './components/ToastContainer';
|
||||||
import { Footer } from './components/Footer';
|
import { Footer } from './components/Footer';
|
||||||
import { ActivitySidebar, requestToActivityItem } from './components/activity';
|
import { ActivitySidebar } from './components/activity';
|
||||||
import { LoginPage } from './pages/LoginPage';
|
import { LoginPage } from './pages/LoginPage';
|
||||||
import { SettingsModal } from './components/settings';
|
import { SelfSettingsModal, SettingsModal } from './components/settings';
|
||||||
import { ConfigSetupBanner } from './components/ConfigSetupBanner';
|
import { ConfigSetupBanner } from './components/ConfigSetupBanner';
|
||||||
import { OnboardingModal } from './components/OnboardingModal';
|
import { OnboardingModal } from './components/OnboardingModal';
|
||||||
import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages';
|
import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages';
|
||||||
@@ -62,6 +62,7 @@ import {
|
|||||||
import { bookFromRequestData } from './utils/requestFulfil';
|
import { bookFromRequestData } from './utils/requestFulfil';
|
||||||
import { policyTrace } from './utils/policyTrace';
|
import { policyTrace } from './utils/policyTrace';
|
||||||
import { SearchModeProvider } from './contexts/SearchModeContext';
|
import { SearchModeProvider } from './contexts/SearchModeContext';
|
||||||
|
import { useSocket } from './contexts/SocketContext';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type';
|
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 POLICY_GUARD_ERROR_CODES = new Set(['policy_requires_request', 'policy_blocked']);
|
||||||
|
|
||||||
const isPolicyGuardError = (error: unknown): boolean => {
|
const isPolicyGuardError = (error: unknown): boolean => {
|
||||||
return (
|
return (
|
||||||
isApiResponseError(error) &&
|
isApiResponseError(error) &&
|
||||||
@@ -117,6 +117,7 @@ const getErrorMessage = (error: unknown, fallback: string): string => {
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { toasts, showToast, removeToast } = useToast();
|
const { toasts, showToast, removeToast } = useToast();
|
||||||
|
const { socket } = useSocket();
|
||||||
|
|
||||||
// Realtime status with WebSocket and polling fallback
|
// Realtime status with WebSocket and polling fallback
|
||||||
// Socket connection is managed by SocketProvider in main.tsx
|
// Socket connection is managed by SocketProvider in main.tsx
|
||||||
@@ -144,7 +145,7 @@ function App() {
|
|||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
authRequired,
|
authRequired,
|
||||||
authChecked,
|
authChecked,
|
||||||
isAdmin: authCanAccessSettings,
|
isAdmin: authIsAdmin,
|
||||||
authMode,
|
authMode,
|
||||||
username,
|
username,
|
||||||
displayName,
|
displayName,
|
||||||
@@ -163,9 +164,9 @@ function App() {
|
|||||||
if (!authChecked || !isAuthenticated) {
|
if (!authChecked || !isAuthenticated) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
policyTrace('auth.status', { authChecked, isAuthenticated, isAdmin: authCanAccessSettings, username });
|
policyTrace('auth.status', { authChecked, isAuthenticated, isAdmin: authIsAdmin, username });
|
||||||
void fetchStatus();
|
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
|
// Content type state (ebook vs audiobook) - defined before useSearch since it's passed to it
|
||||||
const [contentType, setContentType] = useState<ContentType>(() => getInitialContentType());
|
const [contentType, setContentType] = useState<ContentType>(() => getInitialContentType());
|
||||||
@@ -187,14 +188,12 @@ function App() {
|
|||||||
refresh: refreshRequestPolicy,
|
refresh: refreshRequestPolicy,
|
||||||
} = useRequestPolicy({
|
} = useRequestPolicy({
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
isAdmin: authCanAccessSettings,
|
isAdmin: authIsAdmin,
|
||||||
});
|
});
|
||||||
|
|
||||||
const requestRoleIsAdmin = requestPolicy ? Boolean(requestPolicy.is_admin) : false;
|
const requestRoleIsAdmin = requestPolicy ? Boolean(requestPolicy.is_admin) : false;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
requests,
|
|
||||||
pendingCount: pendingRequestCount,
|
|
||||||
isLoading: isRequestsLoading,
|
isLoading: isRequestsLoading,
|
||||||
cancelRequest: cancelUserRequest,
|
cancelRequest: cancelUserRequest,
|
||||||
fulfilRequest: fulfilSidebarRequest,
|
fulfilRequest: fulfilSidebarRequest,
|
||||||
@@ -204,58 +203,28 @@ function App() {
|
|||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
});
|
});
|
||||||
|
|
||||||
const dismissedRequestStorageKey = useMemo(() => {
|
const {
|
||||||
const roleScope = requestRoleIsAdmin ? 'admin' : 'user';
|
requestItems,
|
||||||
const userScope = username?.trim().toLowerCase() || 'anonymous';
|
dismissedActivityKeys,
|
||||||
return `activity-dismissed-requests:${roleScope}:${userScope}`;
|
historyItems,
|
||||||
}, [requestRoleIsAdmin, username]);
|
pendingRequestCount,
|
||||||
|
isActivitySnapshotLoading,
|
||||||
const [dismissedRequestIds, setDismissedRequestIds] = useState<number[]>([]);
|
activityHistoryLoading,
|
||||||
|
activityHistoryHasMore,
|
||||||
useEffect(() => {
|
refreshActivitySnapshot,
|
||||||
if (!isAuthenticated) {
|
resetActivity,
|
||||||
setDismissedRequestIds([]);
|
handleActivityTabChange,
|
||||||
return;
|
handleActivityHistoryLoadMore,
|
||||||
}
|
handleRequestDismiss,
|
||||||
|
handleDownloadDismiss,
|
||||||
try {
|
handleClearCompleted,
|
||||||
const raw = window.localStorage.getItem(dismissedRequestStorageKey);
|
handleClearHistory,
|
||||||
if (!raw) {
|
} = useActivity({
|
||||||
setDismissedRequestIds([]);
|
isAuthenticated,
|
||||||
return;
|
isAdmin: requestRoleIsAdmin,
|
||||||
}
|
showToast,
|
||||||
const parsed: unknown = JSON.parse(raw);
|
socket,
|
||||||
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 showRequestsTab = useMemo(() => {
|
const showRequestsTab = useMemo(() => {
|
||||||
if (requestRoleIsAdmin) {
|
if (requestRoleIsAdmin) {
|
||||||
@@ -317,7 +286,10 @@ function App() {
|
|||||||
clearTracking();
|
clearTracking();
|
||||||
setPendingRequestPayload(null);
|
setPendingRequestPayload(null);
|
||||||
setFulfillingRequest(null);
|
setFulfillingRequest(null);
|
||||||
}, [handleLogout, setBooks, clearTracking]);
|
resetActivity();
|
||||||
|
setSettingsOpen(false);
|
||||||
|
setSelfSettingsOpen(false);
|
||||||
|
}, [handleLogout, setBooks, clearTracking, resetActivity]);
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
|
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
|
||||||
@@ -341,6 +313,7 @@ function App() {
|
|||||||
headerObserverRef.current = observer;
|
headerObserverRef.current = observer;
|
||||||
}, []);
|
}, []);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [selfSettingsOpen, setSelfSettingsOpen] = useState(false);
|
||||||
const [configBannerOpen, setConfigBannerOpen] = useState(false);
|
const [configBannerOpen, setConfigBannerOpen] = useState(false);
|
||||||
const [onboardingOpen, setOnboardingOpen] = useState(false);
|
const [onboardingOpen, setOnboardingOpen] = useState(false);
|
||||||
|
|
||||||
@@ -363,26 +336,45 @@ function App() {
|
|||||||
|
|
||||||
// Calculate status counts for header badges (memoized)
|
// Calculate status counts for header badges (memoized)
|
||||||
const statusCounts = useMemo(() => {
|
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 = [
|
const ongoing = [
|
||||||
currentStatus.queued,
|
currentStatus.queued,
|
||||||
currentStatus.resolving,
|
currentStatus.resolving,
|
||||||
currentStatus.locating,
|
currentStatus.locating,
|
||||||
currentStatus.downloading,
|
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
|
const completed = countVisibleDownloads(currentStatus.complete, { filterDismissed: true });
|
||||||
? Object.keys(currentStatus.complete).length
|
const errored = countVisibleDownloads(currentStatus.error, { filterDismissed: true });
|
||||||
: 0;
|
const pendingVisibleRequests = requestItems.filter((item) => {
|
||||||
|
const requestId = item.requestId;
|
||||||
const errored = currentStatus.error ? Object.keys(currentStatus.error).length : 0;
|
if (!requestId || item.requestRecord?.status !== 'pending') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !dismissedKeySet.has(`request:${requestId}`);
|
||||||
|
}).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ongoing,
|
ongoing,
|
||||||
completed,
|
completed,
|
||||||
errored,
|
errored,
|
||||||
pendingRequests: pendingRequestCount,
|
pendingRequests: pendingVisibleRequests,
|
||||||
};
|
};
|
||||||
}, [currentStatus, pendingRequestCount]);
|
}, [currentStatus, dismissedActivityKeys, requestItems]);
|
||||||
|
|
||||||
|
|
||||||
// Compute visibility states
|
// Compute visibility states
|
||||||
@@ -651,6 +643,7 @@ function App() {
|
|||||||
async (payload: CreateRequestPayload, successMessage: string): Promise<boolean> => {
|
async (payload: CreateRequestPayload, successMessage: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
await createRequest(payload);
|
await createRequest(payload);
|
||||||
|
await refreshActivitySnapshot();
|
||||||
showToast(successMessage, 'success');
|
showToast(successMessage, 'success');
|
||||||
await refreshRequestPolicy({ force: true });
|
await refreshRequestPolicy({ force: true });
|
||||||
return true;
|
return true;
|
||||||
@@ -663,7 +656,7 @@ function App() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[showToast, refreshRequestPolicy]
|
[showToast, refreshRequestPolicy, refreshActivitySnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const openRequestConfirmation = useCallback((payload: CreateRequestPayload) => {
|
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).
|
// Universal-mode "Get" action (open releases, request-book, or block by policy).
|
||||||
const handleGetReleases = async (book: Book) => {
|
const handleGetReleases = async (book: Book) => {
|
||||||
let mode = getUniversalDefaultPolicyMode();
|
let mode = getUniversalDefaultPolicyMode();
|
||||||
@@ -969,20 +951,15 @@ function App() {
|
|||||||
async (requestId: number) => {
|
async (requestId: number) => {
|
||||||
try {
|
try {
|
||||||
await cancelUserRequest(requestId);
|
await cancelUserRequest(requestId);
|
||||||
|
await refreshActivitySnapshot();
|
||||||
showToast('Request cancelled', 'success');
|
showToast('Request cancelled', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(getErrorMessage(error, 'Failed to cancel request'), '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(
|
const handleRequestReject = useCallback(
|
||||||
async (requestId: number, adminNote?: string) => {
|
async (requestId: number, adminNote?: string) => {
|
||||||
if (!requestRoleIsAdmin) {
|
if (!requestRoleIsAdmin) {
|
||||||
@@ -991,12 +968,13 @@ function App() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await rejectSidebarRequest(requestId, adminNote);
|
await rejectSidebarRequest(requestId, adminNote);
|
||||||
|
await refreshActivitySnapshot();
|
||||||
showToast('Request rejected', 'success');
|
showToast('Request rejected', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(getErrorMessage(error, 'Failed to reject request'), 'error');
|
showToast(getErrorMessage(error, 'Failed to reject request'), 'error');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[requestRoleIsAdmin, rejectSidebarRequest, showToast]
|
[refreshActivitySnapshot, requestRoleIsAdmin, rejectSidebarRequest, showToast]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRequestApprove = useCallback(
|
const handleRequestApprove = useCallback(
|
||||||
@@ -1008,6 +986,7 @@ function App() {
|
|||||||
if (record.request_level === 'release') {
|
if (record.request_level === 'release') {
|
||||||
try {
|
try {
|
||||||
await fulfilSidebarRequest(requestId, record.release_data || undefined);
|
await fulfilSidebarRequest(requestId, record.release_data || undefined);
|
||||||
|
await refreshActivitySnapshot();
|
||||||
showToast('Request approved', 'success');
|
showToast('Request approved', 'success');
|
||||||
await fetchStatus();
|
await fetchStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1023,7 +1002,7 @@ function App() {
|
|||||||
contentType: record.content_type,
|
contentType: record.content_type,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[requestRoleIsAdmin, fulfilSidebarRequest, showToast, fetchStatus]
|
[requestRoleIsAdmin, fulfilSidebarRequest, showToast, fetchStatus, refreshActivitySnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBrowseFulfilDownload = useCallback(
|
const handleBrowseFulfilDownload = useCallback(
|
||||||
@@ -1037,6 +1016,7 @@ function App() {
|
|||||||
fulfillingRequest.requestId,
|
fulfillingRequest.requestId,
|
||||||
buildReleaseDataFromMetadataRelease(book, release, toContentType(releaseContentType))
|
buildReleaseDataFromMetadataRelease(book, release, toContentType(releaseContentType))
|
||||||
);
|
);
|
||||||
|
await refreshActivitySnapshot();
|
||||||
showToast(`Request approved: ${book.title || 'Untitled'}`, 'success');
|
showToast(`Request approved: ${book.title || 'Untitled'}`, 'success');
|
||||||
setFulfillingRequest(null);
|
setFulfillingRequest(null);
|
||||||
await fetchStatus();
|
await fetchStatus();
|
||||||
@@ -1046,7 +1026,7 @@ function App() {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fulfillingRequest, fulfilSidebarRequest, showToast, fetchStatus]
|
[fulfillingRequest, fulfilSidebarRequest, showToast, fetchStatus, refreshActivitySnapshot]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getDirectActionButtonState = useCallback(
|
const getDirectActionButtonState = useCallback(
|
||||||
@@ -1128,13 +1108,17 @@ function App() {
|
|||||||
onDownloadsClick={() => setDownloadsSidebarOpen((prev) => !prev)}
|
onDownloadsClick={() => setDownloadsSidebarOpen((prev) => !prev)}
|
||||||
onSettingsClick={() => {
|
onSettingsClick={() => {
|
||||||
if (config?.settings_enabled) {
|
if (config?.settings_enabled) {
|
||||||
setSettingsOpen(true);
|
if (authIsAdmin) {
|
||||||
|
setSettingsOpen(true);
|
||||||
|
} else {
|
||||||
|
setSelfSettingsOpen(true);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setConfigBannerOpen(true);
|
setConfigBannerOpen(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
isAdmin={requestRoleIsAdmin}
|
isAdmin={requestRoleIsAdmin}
|
||||||
canAccessSettings={authCanAccessSettings}
|
canAccessSettings={isAuthenticated}
|
||||||
username={username}
|
username={username}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
statusCounts={statusCounts}
|
statusCounts={statusCounts}
|
||||||
@@ -1176,6 +1160,7 @@ function App() {
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: '25rem',
|
right: '25rem',
|
||||||
|
zIndex: 40,
|
||||||
}
|
}
|
||||||
: { paddingTop: `${headerHeight}px` }
|
: { paddingTop: `${headerHeight}px` }
|
||||||
}
|
}
|
||||||
@@ -1306,10 +1291,18 @@ function App() {
|
|||||||
isAdmin={requestRoleIsAdmin}
|
isAdmin={requestRoleIsAdmin}
|
||||||
onClearCompleted={handleClearCompleted}
|
onClearCompleted={handleClearCompleted}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
|
onDownloadDismiss={handleDownloadDismiss}
|
||||||
requestItems={requestItems}
|
requestItems={requestItems}
|
||||||
|
dismissedItemKeys={dismissedActivityKeys}
|
||||||
|
historyItems={historyItems}
|
||||||
|
historyHasMore={activityHistoryHasMore}
|
||||||
|
historyLoading={activityHistoryLoading}
|
||||||
|
onHistoryLoadMore={handleActivityHistoryLoadMore}
|
||||||
|
onClearHistory={handleClearHistory}
|
||||||
|
onActiveTabChange={handleActivityTabChange}
|
||||||
pendingRequestCount={pendingRequestCount}
|
pendingRequestCount={pendingRequestCount}
|
||||||
showRequestsTab={showRequestsTab}
|
showRequestsTab={showRequestsTab}
|
||||||
isRequestsLoading={isRequestsLoading}
|
isRequestsLoading={isRequestsLoading || isActivitySnapshotLoading}
|
||||||
onRequestCancel={showRequestsTab ? handleRequestCancel : undefined}
|
onRequestCancel={showRequestsTab ? handleRequestCancel : undefined}
|
||||||
onRequestApprove={requestRoleIsAdmin ? handleRequestApprove : undefined}
|
onRequestApprove={requestRoleIsAdmin ? handleRequestApprove : undefined}
|
||||||
onRequestReject={requestRoleIsAdmin ? handleRequestReject : undefined}
|
onRequestReject={requestRoleIsAdmin ? handleRequestReject : undefined}
|
||||||
@@ -1328,6 +1321,12 @@ function App() {
|
|||||||
onSettingsSaved={handleSettingsSaved}
|
onSettingsSaved={handleSettingsSaved}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SelfSettingsModal
|
||||||
|
isOpen={selfSettingsOpen}
|
||||||
|
onClose={() => setSelfSettingsOpen(false)}
|
||||||
|
onShowToast={showToast}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Auto-show banner on startup for users without config */}
|
{/* Auto-show banner on startup for users without config */}
|
||||||
{config && (
|
{config && (
|
||||||
<ConfigSetupBanner settingsEnabled={config.settings_enabled} />
|
<ConfigSetupBanner settingsEnabled={config.settings_enabled} />
|
||||||
@@ -1339,7 +1338,11 @@ function App() {
|
|||||||
onClose={() => setConfigBannerOpen(false)}
|
onClose={() => setConfigBannerOpen(false)}
|
||||||
onContinue={() => {
|
onContinue={() => {
|
||||||
setConfigBannerOpen(false);
|
setConfigBannerOpen(false);
|
||||||
setSettingsOpen(true);
|
if (authIsAdmin) {
|
||||||
|
setSettingsOpen(true);
|
||||||
|
} else {
|
||||||
|
setSelfSettingsOpen(true);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface ActivityCardProps {
|
|||||||
item: ActivityItem;
|
item: ActivityItem;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
onDownloadCancel?: (bookId: string) => void;
|
onDownloadCancel?: (bookId: string) => void;
|
||||||
|
onDownloadDismiss?: (bookId: string, linkedRequestId?: number) => void;
|
||||||
onRequestCancel?: (requestId: number) => void;
|
onRequestCancel?: (requestId: number) => void;
|
||||||
onRequestApprove?: (requestId: number, record: RequestRecord) => void;
|
onRequestApprove?: (requestId: number, record: RequestRecord) => void;
|
||||||
onRequestReject?: (requestId: number) => void;
|
onRequestReject?: (requestId: number) => void;
|
||||||
@@ -145,6 +146,7 @@ export const ActivityCard = ({
|
|||||||
item,
|
item,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
onDownloadCancel,
|
onDownloadCancel,
|
||||||
|
onDownloadDismiss,
|
||||||
onRequestCancel,
|
onRequestCancel,
|
||||||
onRequestApprove,
|
onRequestApprove,
|
||||||
onRequestReject,
|
onRequestReject,
|
||||||
@@ -205,10 +207,7 @@ export const ActivityCard = ({
|
|||||||
onDownloadCancel?.(action.bookId);
|
onDownloadCancel?.(action.bookId);
|
||||||
break;
|
break;
|
||||||
case 'download-dismiss':
|
case 'download-dismiss':
|
||||||
onDownloadCancel?.(action.bookId);
|
onDownloadDismiss?.(action.bookId, action.linkedRequestId);
|
||||||
if (action.linkedRequestId) {
|
|
||||||
onRequestDismiss?.(action.linkedRequestId);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case 'request-approve':
|
case 'request-approve':
|
||||||
onRequestApprove?.(action.requestId, action.record);
|
onRequestApprove?.(action.requestId, action.record);
|
||||||
@@ -231,8 +230,9 @@ export const ActivityCard = ({
|
|||||||
switch (action.kind) {
|
switch (action.kind) {
|
||||||
case 'download-remove':
|
case 'download-remove':
|
||||||
case 'download-stop':
|
case 'download-stop':
|
||||||
case 'download-dismiss':
|
|
||||||
return Boolean(onDownloadCancel);
|
return Boolean(onDownloadCancel);
|
||||||
|
case 'download-dismiss':
|
||||||
|
return Boolean(onDownloadDismiss);
|
||||||
case 'request-approve':
|
case 'request-approve':
|
||||||
return Boolean(onRequestApprove);
|
return Boolean(onRequestApprove);
|
||||||
case 'request-reject':
|
case 'request-reject':
|
||||||
@@ -309,7 +309,7 @@ export const ActivityCard = ({
|
|||||||
</div>
|
</div>
|
||||||
</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}
|
{item.metaLine}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,24 @@ import { downloadToActivityItem, DownloadStatusKey } from './activityMappers';
|
|||||||
import { ActivityItem } from './activityTypes';
|
import { ActivityItem } from './activityTypes';
|
||||||
import { ActivityCard } from './ActivityCard';
|
import { ActivityCard } from './ActivityCard';
|
||||||
import { RejectDialog } from './RejectDialog';
|
import { RejectDialog } from './RejectDialog';
|
||||||
|
import { Dropdown } from '../Dropdown';
|
||||||
|
|
||||||
interface ActivitySidebarProps {
|
interface ActivitySidebarProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
status: StatusData;
|
status: StatusData;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
onClearCompleted: () => void;
|
onClearCompleted: (items: ActivityDismissTarget[]) => void;
|
||||||
onCancel: (id: string) => void;
|
onCancel: (id: string) => void;
|
||||||
|
onDownloadDismiss?: (bookId: string, linkedRequestId?: number) => void;
|
||||||
requestItems: ActivityItem[];
|
requestItems: ActivityItem[];
|
||||||
|
dismissedItemKeys?: string[];
|
||||||
|
historyItems?: ActivityItem[];
|
||||||
|
historyHasMore?: boolean;
|
||||||
|
historyLoading?: boolean;
|
||||||
|
onHistoryLoadMore?: () => void;
|
||||||
|
onClearHistory?: () => void;
|
||||||
|
onActiveTabChange?: (tab: ActivityTabKey) => void;
|
||||||
pendingRequestCount: number;
|
pendingRequestCount: number;
|
||||||
showRequestsTab: boolean;
|
showRequestsTab: boolean;
|
||||||
isRequestsLoading?: boolean;
|
isRequestsLoading?: boolean;
|
||||||
@@ -24,6 +33,11 @@ interface ActivitySidebarProps {
|
|||||||
pinnedTopOffset?: number;
|
pinnedTopOffset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActivityDismissTarget {
|
||||||
|
itemType: 'download' | 'request';
|
||||||
|
itemKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const ACTIVITY_SIDEBAR_PINNED_STORAGE_KEY = 'activity-sidebar-pinned';
|
export const ACTIVITY_SIDEBAR_PINNED_STORAGE_KEY = 'activity-sidebar-pinned';
|
||||||
|
|
||||||
const DOWNLOAD_STATUS_KEYS: DownloadStatusKey[] = [
|
const DOWNLOAD_STATUS_KEYS: DownloadStatusKey[] = [
|
||||||
@@ -37,53 +51,95 @@ const DOWNLOAD_STATUS_KEYS: DownloadStatusKey[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
type ActivityCategoryKey =
|
type ActivityCategoryKey =
|
||||||
| 'downloads'
|
| 'needs_review'
|
||||||
| 'pending_requests'
|
| 'in_progress'
|
||||||
| 'fulfilled_requests'
|
| 'complete'
|
||||||
| 'other_requests';
|
| 'failed';
|
||||||
|
|
||||||
|
type ActivityTabKey = 'all' | 'downloads' | 'requests' | 'history';
|
||||||
|
const ALL_USERS_FILTER = '__all_users__';
|
||||||
|
|
||||||
const getCategoryLabel = (
|
const getCategoryLabel = (
|
||||||
key: ActivityCategoryKey,
|
key: ActivityCategoryKey,
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
): string => {
|
): string => {
|
||||||
if (key === 'downloads') {
|
if (key === 'needs_review') {
|
||||||
return 'Downloads';
|
return isAdmin ? 'Needs Review' : 'Waiting';
|
||||||
}
|
}
|
||||||
if (key === 'pending_requests') {
|
if (key === 'in_progress') {
|
||||||
return 'Pending Requests';
|
return 'In Progress';
|
||||||
}
|
}
|
||||||
if (key === 'fulfilled_requests') {
|
if (key === 'complete') {
|
||||||
return isAdmin ? 'Fulfilled Requests' : 'Completed Requests';
|
return 'Complete';
|
||||||
}
|
}
|
||||||
return 'Other Requests';
|
return 'Failed';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getVisibleCategoryOrder = (
|
const getVisibleCategoryOrder = (
|
||||||
tab: 'all' | 'downloads' | 'requests'
|
tab: ActivityTabKey
|
||||||
): ActivityCategoryKey[] => {
|
): ActivityCategoryKey[] => {
|
||||||
if (tab === 'downloads') {
|
if (tab === 'downloads') {
|
||||||
return ['downloads'];
|
return ['in_progress', 'complete', 'failed'];
|
||||||
}
|
}
|
||||||
if (tab === 'requests') {
|
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 => {
|
const getActivityCategory = (item: ActivityItem): ActivityCategoryKey => {
|
||||||
if (!item.requestId) {
|
if (item.kind === 'download') {
|
||||||
return 'downloads';
|
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') {
|
const requestStatus = item.requestRecord?.status;
|
||||||
return 'pending_requests';
|
if (requestStatus === 'pending' || item.visualStatus === 'pending') {
|
||||||
|
return 'needs_review';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.requestRecord?.status === 'fulfilled' || item.visualStatus === 'fulfilled') {
|
if (requestStatus === 'rejected' || requestStatus === 'cancelled') {
|
||||||
return 'fulfilled_requests';
|
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 => {
|
const getLinkedDownloadIdFromRequestItem = (item: ActivityItem): string | null => {
|
||||||
@@ -138,6 +194,15 @@ const dedupeById = (items: ActivityItem[]): ActivityItem[] => {
|
|||||||
return Array.from(byId.values());
|
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 => {
|
const parsePinned = (value: string | null): boolean => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return false;
|
return false;
|
||||||
@@ -170,7 +235,15 @@ export const ActivitySidebar = ({
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
onClearCompleted,
|
onClearCompleted,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
onDownloadDismiss,
|
||||||
requestItems,
|
requestItems,
|
||||||
|
dismissedItemKeys = [],
|
||||||
|
historyItems = [],
|
||||||
|
historyHasMore = false,
|
||||||
|
historyLoading = false,
|
||||||
|
onHistoryLoadMore,
|
||||||
|
onClearHistory,
|
||||||
|
onActiveTabChange,
|
||||||
pendingRequestCount,
|
pendingRequestCount,
|
||||||
showRequestsTab,
|
showRequestsTab,
|
||||||
isRequestsLoading = false,
|
isRequestsLoading = false,
|
||||||
@@ -183,10 +256,15 @@ export const ActivitySidebar = ({
|
|||||||
}: ActivitySidebarProps) => {
|
}: ActivitySidebarProps) => {
|
||||||
const [isPinned, setIsPinned] = useState<boolean>(() => getInitialPinnedPreference());
|
const [isPinned, setIsPinned] = useState<boolean>(() => getInitialPinnedPreference());
|
||||||
const [isDesktop, setIsDesktop] = useState<boolean>(() => getInitialDesktopState());
|
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 [rejectingRequest, setRejectingRequest] = useState<{ requestId: number; bookTitle: string } | null>(null);
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||||
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
|
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const dismissedKeySet = useMemo(
|
||||||
|
() => new Set(dismissedItemKeys),
|
||||||
|
[dismissedItemKeys]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mediaQuery = window.matchMedia('(min-width: 1024px)');
|
const mediaQuery = window.matchMedia('(min-width: 1024px)');
|
||||||
@@ -209,6 +287,10 @@ export const ActivitySidebar = ({
|
|||||||
}
|
}
|
||||||
}, [showRequestsTab, activeTab]);
|
}, [showRequestsTab, activeTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onActiveTabChange?.(activeTab);
|
||||||
|
}, [activeTab, onActiveTabChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'downloads') {
|
if (activeTab === 'downloads') {
|
||||||
setRejectingRequest(null);
|
setRejectingRequest(null);
|
||||||
@@ -245,12 +327,29 @@ export const ActivitySidebar = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Object.values(bucket).forEach((book) => {
|
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));
|
items.push(downloadToActivityItem(book, statusKey));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return items.sort((left, right) => right.timestamp - left.timestamp);
|
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 { mergedRequestItems, mergedDownloadItems } = useMemo(() => {
|
||||||
const downloadsById = new Map<string, ActivityItem>();
|
const downloadsById = new Map<string, ActivityItem>();
|
||||||
@@ -261,7 +360,7 @@ export const ActivitySidebar = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const mergedByDownloadId = new Map<string, ActivityItem>();
|
const mergedByDownloadId = new Map<string, ActivityItem>();
|
||||||
const nextRequestItems = requestItems.map((requestItem) => {
|
const nextRequestItems = visibleRequestItems.map((requestItem) => {
|
||||||
const linkedDownloadId = getLinkedDownloadIdFromRequestItem(requestItem);
|
const linkedDownloadId = getLinkedDownloadIdFromRequestItem(requestItem);
|
||||||
if (!linkedDownloadId) {
|
if (!linkedDownloadId) {
|
||||||
return requestItem;
|
return requestItem;
|
||||||
@@ -291,7 +390,7 @@ export const ActivitySidebar = ({
|
|||||||
mergedRequestItems: nextRequestItems,
|
mergedRequestItems: nextRequestItems,
|
||||||
mergedDownloadItems: nextDownloadItems,
|
mergedDownloadItems: nextDownloadItems,
|
||||||
};
|
};
|
||||||
}, [downloadItems, requestItems]);
|
}, [downloadItems, visibleRequestItems]);
|
||||||
|
|
||||||
const hasTerminalDownloadItems = useMemo(
|
const hasTerminalDownloadItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -307,11 +406,84 @@ export const ActivitySidebar = ({
|
|||||||
return combined.sort((a, b) => b.timestamp - a.timestamp);
|
return combined.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
}, [mergedDownloadItems, mergedRequestItems]);
|
}, [mergedDownloadItems, mergedRequestItems]);
|
||||||
|
|
||||||
const visibleItems = activeTab === 'all'
|
const baseVisibleItems = activeTab === 'all'
|
||||||
? allItems
|
? allItems
|
||||||
: activeTab === 'requests'
|
: activeTab === 'requests'
|
||||||
? mergedRequestItems
|
? mergedRequestItems.filter((item) => {
|
||||||
: mergedDownloadItems;
|
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(
|
const visibleCategoryOrder = useMemo(
|
||||||
() => getVisibleCategoryOrder(activeTab),
|
() => getVisibleCategoryOrder(activeTab),
|
||||||
@@ -319,11 +491,15 @@ export const ActivitySidebar = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const groupedVisibleItems = useMemo(() => {
|
const groupedVisibleItems = useMemo(() => {
|
||||||
|
if (activeTab === 'history') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const grouped = new Map<ActivityCategoryKey, ActivityItem[]>();
|
const grouped = new Map<ActivityCategoryKey, ActivityItem[]>();
|
||||||
visibleCategoryOrder.forEach((key) => grouped.set(key, []));
|
visibleCategoryOrder.forEach((key) => grouped.set(key, []));
|
||||||
|
|
||||||
visibleItems.forEach((item) => {
|
visibleItems.forEach((item) => {
|
||||||
const category = activeTab === 'downloads' ? 'downloads' : getActivityCategory(item);
|
const category = getActivityCategory(item);
|
||||||
if (!grouped.has(category)) {
|
if (!grouped.has(category)) {
|
||||||
grouped.set(category, []);
|
grouped.set(category, []);
|
||||||
}
|
}
|
||||||
@@ -355,31 +531,33 @@ export const ActivitySidebar = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeButton = tabRefs.current[activeTab];
|
const activeButton = tabRefs.current[activeTab];
|
||||||
if (activeButton) {
|
if (!activeButton) {
|
||||||
const containerRect = activeButton.parentElement?.getBoundingClientRect();
|
setTabIndicatorStyle({ left: 0, width: 0 });
|
||||||
const buttonRect = activeButton.getBoundingClientRect();
|
return;
|
||||||
if (containerRect) {
|
}
|
||||||
setTabIndicatorStyle({
|
|
||||||
left: buttonRect.left - containerRect.left,
|
const containerRect = activeButton.parentElement?.getBoundingClientRect();
|
||||||
width: buttonRect.width,
|
const buttonRect = activeButton.getBoundingClientRect();
|
||||||
});
|
if (containerRect) {
|
||||||
}
|
setTabIndicatorStyle({
|
||||||
|
left: buttonRect.left - containerRect.left,
|
||||||
|
width: buttonRect.width,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [activeTab, showRequestsTab]);
|
}, [activeTab, showRequestsTab]);
|
||||||
|
|
||||||
const panel = (
|
const panel = (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`px-4 pt-4 ${showRequestsTab ? 'pb-0' : 'pb-4 border-b'}`}
|
className="px-4 pt-4 pb-0"
|
||||||
style={{
|
style={{
|
||||||
borderColor: 'var(--border-muted)',
|
borderColor: 'var(--border-muted)',
|
||||||
paddingTop: 'calc(1rem + env(safe-area-inset-top))',
|
paddingTop: 'calc(1rem + env(safe-area-inset-top))',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h2 className="text-lg font-semibold">Activity</h2>
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-lg font-semibold">{activeTab === 'history' ? 'History' : 'Activity'}</h2>
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleTogglePinned}
|
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" />
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<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" />
|
<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>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="h-9 w-9 inline-flex items-center justify-center rounded-full hover-action transition-colors"
|
className="h-9 w-9 inline-flex items-center justify-center rounded-full hover-action transition-colors"
|
||||||
aria-label="Close activity sidebar"
|
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" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showRequestsTab && (
|
{activeTab !== 'history' && (
|
||||||
<div className="mt-2 border-b border-[var(--border-muted)] -mx-4 px-4">
|
<div className="mt-2 border-b border-[var(--border-muted)] -mx-4 px-4">
|
||||||
<div className="relative flex gap-1">
|
<div className="relative flex gap-1">
|
||||||
{/* Sliding indicator */}
|
{/* Sliding indicator */}
|
||||||
@@ -452,24 +701,26 @@ export const ActivitySidebar = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
{showRequestsTab && (
|
||||||
type="button"
|
<button
|
||||||
ref={(el) => { tabRefs.current.requests = el; }}
|
type="button"
|
||||||
onClick={() => setActiveTab('requests')}
|
ref={(el) => { tabRefs.current.requests = el; }}
|
||||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${
|
onClick={() => setActiveTab('requests')}
|
||||||
activeTab === 'requests'
|
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${
|
||||||
? 'text-sky-600 dark:text-sky-400'
|
activeTab === 'requests'
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
? '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}
|
}`}
|
||||||
>
|
aria-current={activeTab === 'requests' ? 'page' : undefined}
|
||||||
Requests
|
>
|
||||||
{pendingRequestCount > 0 && (
|
Requests
|
||||||
<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 > 0 && (
|
||||||
{pendingRequestCount}
|
<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">
|
||||||
</span>
|
{pendingRequestCount}
|
||||||
)}
|
</span>
|
||||||
</button>
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -484,11 +735,36 @@ export const ActivitySidebar = ({
|
|||||||
<p className="text-center text-sm opacity-70 mt-8">
|
<p className="text-center text-sm opacity-70 mt-8">
|
||||||
{activeTab === 'requests'
|
{activeTab === 'requests'
|
||||||
? isRequestsLoading ? 'Loading requests...' : 'No requests'
|
? isRequestsLoading ? 'Loading requests...' : 'No requests'
|
||||||
|
: activeTab === 'history'
|
||||||
|
? historyLoading ? 'Loading history...' : 'No history'
|
||||||
: activeTab === 'downloads'
|
: activeTab === 'downloads'
|
||||||
? 'No downloads'
|
? 'No downloads'
|
||||||
: 'No activity'}
|
: 'No activity'}
|
||||||
</p>
|
</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) => (
|
groupedVisibleItems.map((group) => (
|
||||||
<section key={group.key} className="mb-4 last:mb-0">
|
<section key={group.key} className="mb-4 last:mb-0">
|
||||||
{activeTab !== 'downloads' && (
|
{activeTab !== 'downloads' && (
|
||||||
@@ -527,6 +803,7 @@ export const ActivitySidebar = ({
|
|||||||
item={item}
|
item={item}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
onDownloadCancel={onCancel}
|
onDownloadCancel={onCancel}
|
||||||
|
onDownloadDismiss={onDownloadDismiss}
|
||||||
onRequestCancel={onRequestCancel}
|
onRequestCancel={onRequestCancel}
|
||||||
onRequestApprove={onRequestApprove}
|
onRequestApprove={onRequestApprove}
|
||||||
onRequestDismiss={onRequestDismiss}
|
onRequestDismiss={onRequestDismiss}
|
||||||
@@ -554,10 +831,11 @@ export const ActivitySidebar = ({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
))
|
))
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(activeTab === 'downloads' || activeTab === 'all') && hasTerminalDownloadItems && (
|
{(activeTab === 'downloads' || activeTab === 'all') && hasTerminalDownloadItems && clearCompletedTargets.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="p-3 border-t flex items-center justify-center"
|
className="p-3 border-t flex items-center justify-center"
|
||||||
style={{
|
style={{
|
||||||
@@ -567,13 +845,31 @@ export const ActivitySidebar = ({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||||
>
|
>
|
||||||
Clear Completed
|
Clear Completed
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 => {
|
const getPendingRequestText = (item: ActivityItem, isAdmin: boolean): string => {
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return 'Pending';
|
return 'Awaiting review';
|
||||||
}
|
}
|
||||||
const username = item.username?.trim() || item.requestRecord?.username?.trim();
|
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 => {
|
const getRequestBadge = (item: ActivityItem, isAdmin: boolean): ActivityCardBadge => {
|
||||||
@@ -72,15 +72,15 @@ const getRequestBadge = (item: ActivityItem, isAdmin: boolean): ActivityCardBadg
|
|||||||
|
|
||||||
let text = item.statusLabel;
|
let text = item.statusLabel;
|
||||||
if (hasInFlightLinkedDownload) {
|
if (hasInFlightLinkedDownload) {
|
||||||
text = isAdmin ? 'Request approved' : 'Approved';
|
text = 'Approved';
|
||||||
} else if (requestVisualStatus === 'pending') {
|
} else if (requestVisualStatus === 'pending') {
|
||||||
text = getPendingRequestText(item, isAdmin);
|
text = getPendingRequestText(item, isAdmin);
|
||||||
} else if (requestVisualStatus === 'fulfilled') {
|
} else if (requestVisualStatus === 'fulfilled') {
|
||||||
text = isAdmin ? 'Request fulfilled' : 'Approved';
|
text = 'Approved';
|
||||||
} else if (requestVisualStatus === 'rejected') {
|
} else if (requestVisualStatus === 'rejected') {
|
||||||
text = 'Rejected';
|
text = isAdmin ? 'Declined' : 'Not approved';
|
||||||
} else if (requestVisualStatus === 'cancelled') {
|
} else if (requestVisualStatus === 'cancelled') {
|
||||||
text = 'Cancelled';
|
text = isAdmin ? 'Cancelled by requester' : 'Cancelled';
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -109,6 +109,10 @@ const getDownloadBadge = (item: ActivityItem): ActivityCardBadge => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildBadges = (item: ActivityItem, isAdmin: boolean): 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) {
|
if (item.kind === 'download' && item.requestId && item.requestRecord) {
|
||||||
return [getRequestBadge(item, isAdmin), getDownloadBadge(item)];
|
return [getRequestBadge(item, isAdmin), getDownloadBadge(item)];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export {
|
|||||||
} from './activityStyles';
|
} from './activityStyles';
|
||||||
export type { ActivityItem, ActivityKind, ActivityVisualStatus } from './activityTypes';
|
export type { ActivityItem, ActivityKind, ActivityVisualStatus } from './activityTypes';
|
||||||
export { ACTIVITY_SIDEBAR_PINNED_STORAGE_KEY } from './ActivitySidebar';
|
export { ACTIVITY_SIDEBAR_PINNED_STORAGE_KEY } from './ActivitySidebar';
|
||||||
|
export type { ActivityDismissTarget } from './ActivitySidebar';
|
||||||
|
|||||||
@@ -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) => (
|
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>
|
<h3 className="text-base font-semibold mb-1">{field.title}</h3>
|
||||||
{field.description && (
|
{field.description && (
|
||||||
<p className="text-sm opacity-70">
|
<p className="text-sm opacity-70">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { SettingsModal } from './SettingsModal';
|
export { SettingsModal } from './SettingsModal';
|
||||||
|
export { SelfSettingsModal } from './SelfSettingsModal';
|
||||||
export { SettingsHeader } from './SettingsHeader';
|
export { SettingsHeader } from './SettingsHeader';
|
||||||
export { SettingsSidebar } from './SettingsSidebar';
|
export { SettingsSidebar } from './SettingsSidebar';
|
||||||
export { SettingsContent } from './SettingsContent';
|
export { SettingsContent } from './SettingsContent';
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ const formatSourceLabel = (source: string): string => {
|
|||||||
const toRuleKey = (source: string, contentType: RequestPolicyContentType) => `${source}::${contentType}`;
|
const toRuleKey = (source: string, contentType: RequestPolicyContentType) => `${source}::${contentType}`;
|
||||||
|
|
||||||
const modeDescriptions: Record<RequestPolicyMode, string> = {
|
const modeDescriptions: Record<RequestPolicyMode, string> = {
|
||||||
download: 'Direct downloads allowed.',
|
download: 'Users can download directly.',
|
||||||
request_release: 'Specific release requests only.',
|
request_release: 'Users pick a release and request it.',
|
||||||
request_book: 'Book-level requests only.',
|
request_book: 'Users can request a book, admin picks the release.',
|
||||||
blocked: 'Unavailable.',
|
blocked: 'Downloads and requests are blocked.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RequestPolicyGrid = ({
|
export const RequestPolicyGrid = ({
|
||||||
@@ -328,7 +328,7 @@ export const RequestPolicyGrid = ({
|
|||||||
) : (
|
) : (
|
||||||
<div className="px-3 py-3">
|
<div className="px-3 py-3">
|
||||||
<p className="text-xs opacity-60">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { AdminUser } from '../../../services/api';
|
import { AdminUser } from '../../../services/api';
|
||||||
import { PasswordFieldConfig, SelectFieldConfig, SelectOption, TextFieldConfig } from '../../../types/settings';
|
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 { PasswordField, SelectField, TextField } from '../fields';
|
||||||
import { FieldWrapper } from '../shared';
|
import { FieldWrapper } from '../shared';
|
||||||
import { CreateUserFormState } from './types';
|
import { CreateUserFormState } from './types';
|
||||||
@@ -17,6 +20,11 @@ const CREATE_ROLE_OPTIONS: SelectOption[] = [
|
|||||||
{ value: 'admin', label: 'Admin' },
|
{ value: 'admin', label: 'Admin' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const EDIT_ROLE_OPTIONS: SelectOption[] = [
|
||||||
|
{ value: 'admin', label: 'Admin' },
|
||||||
|
{ value: 'user', label: 'User' },
|
||||||
|
];
|
||||||
|
|
||||||
const createTextField = (
|
const createTextField = (
|
||||||
key: string,
|
key: string,
|
||||||
label: string,
|
label: string,
|
||||||
@@ -89,6 +97,240 @@ const renderPasswordField = (
|
|||||||
</FieldWrapper>
|
</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 {
|
interface UserCreateCardProps {
|
||||||
form: CreateUserFormState;
|
form: CreateUserFormState;
|
||||||
onChange: (form: CreateUserFormState) => void;
|
onChange: (form: CreateUserFormState) => void;
|
||||||
@@ -168,6 +410,7 @@ interface UserEditFieldsProps {
|
|||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
hideActions?: boolean;
|
||||||
editPassword: string;
|
editPassword: string;
|
||||||
onEditPasswordChange: (value: string) => void;
|
onEditPasswordChange: (value: string) => void;
|
||||||
editPasswordConfirm: string;
|
editPasswordConfirm: string;
|
||||||
@@ -185,6 +428,7 @@ export const UserEditFields = ({
|
|||||||
onSave,
|
onSave,
|
||||||
saving,
|
saving,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
hideActions = false,
|
||||||
editPassword,
|
editPassword,
|
||||||
onEditPasswordChange,
|
onEditPasswordChange,
|
||||||
editPasswordConfirm,
|
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">
|
{!hideActions && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<UserEditActions
|
||||||
<button
|
onSave={onSave}
|
||||||
onClick={onSave}
|
saving={saving}
|
||||||
disabled={saving}
|
saveDisabled={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"
|
onCancel={onCancel}
|
||||||
>
|
onDelete={onDelete}
|
||||||
{saving ? 'Saving...' : 'Save Changes'}
|
onConfirmDelete={onConfirmDelete}
|
||||||
</button>
|
onCancelDelete={onCancelDelete}
|
||||||
<button
|
isDeletePending={isDeletePending}
|
||||||
onClick={onCancel}
|
deleting={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"
|
)}
|
||||||
>
|
|
||||||
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 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 { useState } from 'react';
|
||||||
import { AdminUser, DownloadDefaults } from '../../../services/api';
|
import { AdminUser, DownloadDefaults } from '../../../services/api';
|
||||||
import { DropdownList } from '../../DropdownList';
|
|
||||||
import { Tooltip } from '../../shared/Tooltip';
|
|
||||||
import {
|
import {
|
||||||
canCreateLocalUsersForAuthMode,
|
canCreateLocalUsersForAuthMode,
|
||||||
CreateUserFormState,
|
CreateUserFormState,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { UserAuthSourceBadge } from './UserAuthSourceBadge';
|
import { UserAccountCardContent, UserCreateCard, UserIdentityHeader, UserRoleControl } from './UserCard';
|
||||||
import { UserCreateCard, UserEditFields } from './UserCard';
|
|
||||||
|
|
||||||
const EDIT_ROLE_OPTIONS = [
|
|
||||||
{ value: 'admin', label: 'Admin' },
|
|
||||||
{ value: 'user', label: 'User' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface UserListViewProps {
|
interface UserListViewProps {
|
||||||
authMode: string;
|
authMode: string;
|
||||||
@@ -123,7 +115,6 @@ export const UserListView = ({
|
|||||||
const active = user.is_active !== false;
|
const active = user.is_active !== false;
|
||||||
const isEditingRow = showEditForm && activeEditUserId === user.id;
|
const isEditingRow = showEditForm && activeEditUserId === user.id;
|
||||||
const hasLoadedEditUser = isEditingRow && editingUser?.id === user.id;
|
const hasLoadedEditUser = isEditingRow && editingUser?.id === user.id;
|
||||||
const roleLabel = user.role.charAt(0).toUpperCase() + user.role.slice(1);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={user.id}
|
key={user.id}
|
||||||
@@ -132,82 +123,18 @@ export const UserListView = ({
|
|||||||
<div
|
<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)]' : ''}`}
|
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">
|
<UserIdentityHeader user={user} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="flex items-center flex-wrap gap-2 shrink-0 sm:justify-end">
|
<div className="flex items-center flex-wrap gap-2 shrink-0 sm:justify-end">
|
||||||
{hasLoadedEditUser && editingUser ? (() => {
|
{hasLoadedEditUser && editingUser ? (
|
||||||
const caps = editingUser.edit_capabilities;
|
<UserRoleControl
|
||||||
const canEditRole = caps.canEditRole;
|
user={editingUser}
|
||||||
const roleDisabledReason = !canEditRole
|
onUserChange={onEditingUserChange}
|
||||||
? (caps.authSource === 'oidc'
|
oidcAdminGroup={downloadDefaults?.OIDC_ADMIN_GROUP}
|
||||||
? (downloadDefaults?.OIDC_ADMIN_GROUP
|
tooltipPosition="bottom"
|
||||||
? `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.')
|
<UserRoleControl user={user} />
|
||||||
: 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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -244,38 +171,27 @@ export const UserListView = ({
|
|||||||
{isEditingRow && (
|
{isEditingRow && (
|
||||||
<div className="p-4 space-y-5 bg-[var(--bg)] rounded-b-lg">
|
<div className="p-4 space-y-5 bg-[var(--bg)] rounded-b-lg">
|
||||||
{hasLoadedEditUser && editingUser ? (
|
{hasLoadedEditUser && editingUser ? (
|
||||||
<>
|
<UserAccountCardContent
|
||||||
<div>
|
user={editingUser}
|
||||||
<label className="text-sm font-medium">User Preferences</label>
|
onUserChange={onEditingUserChange}
|
||||||
<p className="text-xs opacity-60 mt-0.5">Override global delivery and request policy settings for this user.</p>
|
onSave={onEditSave}
|
||||||
<button
|
saving={saving}
|
||||||
onClick={onOpenOverrides}
|
onCancel={onCancelEdit}
|
||||||
className="mt-2 px-4 py-2 rounded-lg text-sm font-medium text-white
|
editPassword={editPassword}
|
||||||
bg-sky-600 hover:bg-sky-700 transition-colors"
|
onEditPasswordChange={onEditPasswordChange}
|
||||||
>
|
editPasswordConfirm={editPasswordConfirm}
|
||||||
Open User Preferences
|
onEditPasswordConfirmChange={onEditPasswordConfirmChange}
|
||||||
</button>
|
onDelete={() => setConfirmDelete(user.id)}
|
||||||
</div>
|
onConfirmDelete={() => handleDelete(user.id)}
|
||||||
|
onCancelDelete={() => setConfirmDelete(null)}
|
||||||
<div className="border-t border-[var(--border-muted)]" />
|
isDeletePending={confirmDelete === user.id}
|
||||||
|
deleting={deletingUserId === user.id}
|
||||||
<UserEditFields
|
preferencesPanel={{
|
||||||
user={editingUser}
|
description: 'Customise delivery and request settings for this user.',
|
||||||
onUserChange={onEditingUserChange}
|
actionLabel: 'Open User Preferences',
|
||||||
onSave={onEditSave}
|
onAction: onOpenOverrides,
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm opacity-60">Loading user details...</div>
|
<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 = {
|
const requestPolicyHeading: HeadingFieldConfig = {
|
||||||
type: 'HeadingField',
|
type: 'HeadingField',
|
||||||
key: 'request_policy_overrides_heading',
|
key: 'request_policy_overrides_heading',
|
||||||
title: 'Request Policy',
|
title: 'Requests',
|
||||||
description: 'User-level request policy overrides. Reset to inherit global policy values.',
|
description: 'Custom request settings for this user. Reset any to fall back to the global defaults.',
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasOwnNonNull = (settings: PerUserSettings, key: keyof PerUserSettings): boolean => {
|
const hasOwnNonNull = (settings: PerUserSettings, key: keyof PerUserSettings): boolean => {
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
export { UserAuthSourceBadge } from './UserAuthSourceBadge';
|
export { UserAuthSourceBadge } from './UserAuthSourceBadge';
|
||||||
export { UserCreateCard, UserEditFields } from './UserCard';
|
export {
|
||||||
|
UserAccountCardContent,
|
||||||
|
UserCreateCard,
|
||||||
|
UserEditActions,
|
||||||
|
UserEditFields,
|
||||||
|
UserIdentityHeader,
|
||||||
|
UserRoleControl,
|
||||||
|
} from './UserCard';
|
||||||
export { UserListView } from './UserListView';
|
export { UserListView } from './UserListView';
|
||||||
export { RequestPolicyGrid } from './RequestPolicyGrid';
|
export { RequestPolicyGrid } from './RequestPolicyGrid';
|
||||||
export { UserOverridesSection } from './UserOverridesSection';
|
export { UserOverridesSection } from './UserOverridesSection';
|
||||||
|
|||||||
@@ -29,22 +29,22 @@ export const REQUEST_POLICY_DEFAULT_OPTIONS: Array<{
|
|||||||
{
|
{
|
||||||
value: 'download',
|
value: 'download',
|
||||||
label: 'Download',
|
label: 'Download',
|
||||||
description: 'Allow direct downloads.',
|
description: 'Everything can be downloaded directly.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'request_release',
|
value: 'request_release',
|
||||||
label: 'Request Release',
|
label: 'Request Release',
|
||||||
description: 'Require requesting a specific release.',
|
description: 'Users must request a specific release.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'request_book',
|
value: 'request_book',
|
||||||
label: 'Request Book',
|
label: 'Request Book',
|
||||||
description: 'Allow book-level requests only.',
|
description: 'Users request a book, admin picks the release.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'blocked',
|
value: 'blocked',
|
||||||
label: 'Blocked',
|
label: 'Blocked',
|
||||||
description: 'Block downloads and requests.',
|
description: 'No downloads or requests allowed.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
SettingsTab,
|
||||||
SettingsGroup,
|
SettingsGroup,
|
||||||
SettingsField,
|
SettingsField,
|
||||||
SelectFieldConfig,
|
|
||||||
ActionResult,
|
ActionResult,
|
||||||
UpdateResult,
|
UpdateResult,
|
||||||
} from '../types/settings';
|
} from '../types/settings';
|
||||||
|
import {
|
||||||
|
getStoredThemePreference,
|
||||||
|
setThemePreference,
|
||||||
|
THEME_FIELD,
|
||||||
|
} from '../utils/themePreference';
|
||||||
|
|
||||||
type ValueBearingField = Exclude<
|
type ValueBearingField = Exclude<
|
||||||
SettingsField,
|
SettingsField,
|
||||||
{ type: 'ActionButton' } | { type: 'HeadingField' } | { type: 'CustomComponentField' }
|
{ 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
|
// Extract value from a field based on its type
|
||||||
function getFieldValue(field: SettingsField): unknown {
|
function getFieldValue(field: SettingsField): unknown {
|
||||||
// These field types have no value property
|
// These field types have no value property
|
||||||
@@ -138,7 +120,7 @@ export function useSettings(): UseSettingsReturn {
|
|||||||
getValueBearingFields(tab.fields).forEach((field) => {
|
getValueBearingFields(tab.fields).forEach((field) => {
|
||||||
// Special handling for theme field - get from localStorage
|
// Special handling for theme field - get from localStorage
|
||||||
if (field.key === '_THEME') {
|
if (field.key === '_THEME') {
|
||||||
initialValues[tab.name][field.key] = localStorage.getItem('preferred-theme') || 'auto';
|
initialValues[tab.name][field.key] = getStoredThemePreference();
|
||||||
} else {
|
} else {
|
||||||
initialValues[tab.name][field.key] = getFieldValue(field);
|
initialValues[tab.name][field.key] = getFieldValue(field);
|
||||||
}
|
}
|
||||||
@@ -169,8 +151,7 @@ export function useSettings(): UseSettingsReturn {
|
|||||||
const updateValue = useCallback((tabName: string, key: string, value: unknown) => {
|
const updateValue = useCallback((tabName: string, key: string, value: unknown) => {
|
||||||
// Apply theme immediately when changed (no save button needed)
|
// Apply theme immediately when changed (no save button needed)
|
||||||
if (key === '_THEME' && typeof value === 'string') {
|
if (key === '_THEME' && typeof value === 'string') {
|
||||||
localStorage.setItem('preferred-theme', value);
|
setThemePreference(value);
|
||||||
applyTheme(value);
|
|
||||||
// Also update original value so it doesn't show as pending change
|
// Also update original value so it doesn't show as pending change
|
||||||
setOriginalValues((prev) => ({
|
setOriginalValues((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ const API = {
|
|||||||
requests: `${API_BASE}/requests`,
|
requests: `${API_BASE}/requests`,
|
||||||
adminRequests: `${API_BASE}/admin/requests`,
|
adminRequests: `${API_BASE}/admin/requests`,
|
||||||
adminRequestCounts: `${API_BASE}/admin/requests/count`,
|
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
|
// Custom error class for authentication failures
|
||||||
@@ -289,6 +293,38 @@ export const getStatus = async (): Promise<StatusData> => {
|
|||||||
return fetchJSON<StatusData>(API.status);
|
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> => {
|
export const cancelDownload = async (id: string): Promise<void> => {
|
||||||
await fetchJSON(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' });
|
await fetchJSON(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' });
|
||||||
};
|
};
|
||||||
@@ -309,6 +345,38 @@ export interface AdminRequestCounts {
|
|||||||
by_status: Record<string, number>;
|
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> => {
|
export const fetchRequestPolicy = async (): Promise<RequestPolicyResponse> => {
|
||||||
return fetchJSON<RequestPolicyResponse>(API.requestPolicy);
|
return fetchJSON<RequestPolicyResponse>(API.requestPolicy);
|
||||||
};
|
};
|
||||||
@@ -527,6 +595,12 @@ export interface AdminUser {
|
|||||||
settings?: Record<string, unknown>;
|
settings?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SelfUserEditContext {
|
||||||
|
user: AdminUser;
|
||||||
|
deliveryPreferences: DeliveryPreferencesResponse | null;
|
||||||
|
userOverridableKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export const getAdminUsers = async (): Promise<AdminUser[]> => {
|
export const getAdminUsers = async (): Promise<AdminUser[]> => {
|
||||||
return fetchJSON<AdminUser[]>(`${API_BASE}/admin/users`);
|
return fetchJSON<AdminUser[]>(`${API_BASE}/admin/users`);
|
||||||
};
|
};
|
||||||
@@ -644,3 +718,19 @@ export const getAdminSettingsOverridesSummary = async (
|
|||||||
): Promise<SettingsOverridesSummaryResponse> => {
|
): Promise<SettingsOverridesSummaryResponse> => {
|
||||||
return fetchJSON<SettingsOverridesSummaryResponse>(`${API_BASE}/admin/settings/overrides-summary?tab=${encodeURIComponent(tabName)}`);
|
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.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', () => {
|
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.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', () => {
|
it('uses requester-friendly approved wording for fulfilled requests', () => {
|
||||||
@@ -98,7 +98,7 @@ describe('activityCardModel', () => {
|
|||||||
assert.equal(model.badges[0]?.visualStatus, 'resolving');
|
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(
|
const model = buildActivityCardModel(
|
||||||
makeItem({
|
makeItem({
|
||||||
visualStatus: 'complete',
|
visualStatus: 'complete',
|
||||||
@@ -127,11 +127,42 @@ describe('activityCardModel', () => {
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(model.badges.length, 2);
|
assert.equal(model.badges.length, 1);
|
||||||
assert.equal(model.badges[0]?.key, 'request');
|
assert.equal(model.badges[0]?.key, 'download');
|
||||||
assert.equal(model.badges[0]?.text, 'Request fulfilled');
|
assert.equal(model.badges[0]?.text, 'Sent to Kindle');
|
||||||
assert.equal(model.badges[1]?.key, 'download');
|
assert.equal(model.badges[0]?.visualStatus, 'complete');
|
||||||
assert.equal(model.badges[1]?.text, 'Sent to Kindle');
|
});
|
||||||
|
|
||||||
|
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', () => {
|
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.kind, 'download');
|
||||||
assert.equal(item.visualStatus, 'queued');
|
assert.equal(item.visualStatus, 'queued');
|
||||||
assert.equal(item.statusLabel, '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.progress, 5);
|
||||||
assert.equal(item.progressAnimated, true);
|
assert.equal(item.progressAnimated, true);
|
||||||
assert.equal(item.timestamp, 123);
|
assert.equal(item.timestamp, 123);
|
||||||
@@ -117,7 +117,7 @@ describe('activityMappers.requestToActivityItem', () => {
|
|||||||
|
|
||||||
assert.equal(item.kind, 'request');
|
assert.equal(item.kind, 'request');
|
||||||
assert.equal(item.visualStatus, 'pending');
|
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.requestId, 42);
|
||||||
assert.equal(item.requestLevel, 'release');
|
assert.equal(item.requestLevel, 'release');
|
||||||
assert.equal(item.requestNote, 'please add this');
|
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', () => {
|
it('does not append username to meta line for user viewer role', () => {
|
||||||
const item = requestToActivityItem(makeRequest(), 'user');
|
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;
|
id: number;
|
||||||
user_id: number;
|
user_id: number;
|
||||||
status: 'pending' | 'fulfilled' | 'rejected' | 'cancelled';
|
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;
|
source_hint: string | null;
|
||||||
content_type: ContentType;
|
content_type: ContentType;
|
||||||
request_level: 'book' | 'release';
|
request_level: 'book' | 'release';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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']}",
|
||||||
|
)
|
||||||
@@ -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"},
|
||||||
|
}
|
||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
|
import uuid
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from shelfmark.core.models import DownloadTask
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def main_module():
|
def main_module():
|
||||||
@@ -42,6 +45,11 @@ def _set_authenticated_session(
|
|||||||
sess["db_user_id"] = db_user_id
|
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:
|
class TestDownloadEndpointGuardrails:
|
||||||
def test_missing_book_id_returns_400_and_does_not_queue(self, main_module, client):
|
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"):
|
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||||
@@ -211,6 +219,148 @@ class TestReleaseDownloadEndpointGuardrails:
|
|||||||
mock_queue_release.assert_not_called()
|
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:
|
class TestStatusEndpointGuardrails:
|
||||||
def test_no_auth_allows_without_session_and_returns_status(self, main_module, client):
|
def test_no_auth_allows_without_session_and_returns_status(self, main_module, client):
|
||||||
observed: dict[str, object] = {}
|
observed: dict[str, object] = {}
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ class TestSettingsRestrictionPolicy:
|
|||||||
def test_default_is_admin_restricted(self):
|
def test_default_is_admin_restricted(self):
|
||||||
assert should_restrict_settings_to_admin({}) is True
|
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": 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):
|
def test_extracts_settings_tab_from_path(self):
|
||||||
assert get_settings_tab_from_path("/api/settings/security") == "security"
|
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/security", users_config) is True
|
||||||
assert requires_admin_for_settings_access("/api/settings/users", 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(
|
assert requires_admin_for_settings_access(
|
||||||
"/api/settings/general",
|
"/api/settings/general",
|
||||||
{"RESTRICT_SETTINGS_TO_ADMIN": False},
|
{"RESTRICT_SETTINGS_TO_ADMIN": False},
|
||||||
) is False
|
) is True
|
||||||
assert requires_admin_for_settings_access(
|
assert requires_admin_for_settings_access(
|
||||||
"/api/settings/general",
|
"/api/settings/general",
|
||||||
{"RESTRICT_SETTINGS_TO_ADMIN": True},
|
{"RESTRICT_SETTINGS_TO_ADMIN": True},
|
||||||
@@ -112,13 +112,13 @@ class TestAuthCheckAdminStatus:
|
|||||||
)
|
)
|
||||||
assert result is False
|
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(
|
result = get_auth_check_admin_status(
|
||||||
"proxy",
|
"proxy",
|
||||||
{"RESTRICT_SETTINGS_TO_ADMIN": False},
|
{"RESTRICT_SETTINGS_TO_ADMIN": False},
|
||||||
{"user_id": "user", "is_admin": False},
|
{"user_id": "user", "is_admin": False},
|
||||||
)
|
)
|
||||||
assert result is True
|
assert result is False
|
||||||
|
|
||||||
def test_unauthenticated_is_never_admin(self):
|
def test_unauthenticated_is_never_admin(self):
|
||||||
result = get_auth_check_admin_status(
|
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.
|
can be filtered by user.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from shelfmark.core.models import DownloadTask
|
from shelfmark.core.models import DownloadTask, QueueStatus
|
||||||
from shelfmark.core.queue import BookQueue
|
from shelfmark.core.queue import BookQueue
|
||||||
|
|
||||||
|
|
||||||
@@ -116,6 +116,39 @@ class TestQueueFilterByUser:
|
|||||||
# User 1 sees their own + legacy (no user_id)
|
# User 1 sees their own + legacy (no user_id)
|
||||||
assert len(all_tasks) == 2
|
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
|
# 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:
|
class TestDownloadPolicyGuards:
|
||||||
def test_download_endpoint_blocks_before_queue_when_policy_requires_request(self, main_module, client):
|
def test_download_endpoint_blocks_before_queue_when_policy_requires_request(self, main_module, client):
|
||||||
user = _create_user(main_module, prefix="reader")
|
user = _create_user(main_module, prefix="reader")
|
||||||
@@ -194,6 +202,17 @@ class TestRequestRoutes:
|
|||||||
assert cancel_resp.status_code == 200
|
assert cancel_resp.status_code == 200
|
||||||
assert cancel_resp.json["status"] == "cancelled"
|
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):
|
def test_create_request_emits_websocket_events(self, main_module, client):
|
||||||
user = _create_user(main_module, prefix="reader")
|
user = _create_user(main_module, prefix="reader")
|
||||||
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
|
_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.status_code == 409
|
||||||
assert reject_again_resp.json["code"] == "stale_transition"
|
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):
|
def test_admin_reject_emits_update_to_user_and_admin_rooms(self, main_module, client):
|
||||||
user = _create_user(main_module, prefix="reader")
|
user = _create_user(main_module, prefix="reader")
|
||||||
admin = _create_user(main_module, prefix="admin", role="admin")
|
admin = _create_user(main_module, prefix="admin", role="admin")
|
||||||
@@ -1638,3 +1668,123 @@ class TestDownloadPolicyGuardsExtended:
|
|||||||
})
|
})
|
||||||
|
|
||||||
assert resp.status_code == 200
|
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,
|
create_request,
|
||||||
fulfil_request,
|
fulfil_request,
|
||||||
normalize_policy_mode,
|
normalize_policy_mode,
|
||||||
|
normalize_delivery_state,
|
||||||
normalize_request_level,
|
normalize_request_level,
|
||||||
normalize_request_status,
|
normalize_request_status,
|
||||||
reject_request,
|
reject_request,
|
||||||
|
sync_delivery_states_from_queue_status,
|
||||||
validate_request_level_payload,
|
validate_request_level_payload,
|
||||||
validate_status_transition,
|
validate_status_transition,
|
||||||
)
|
)
|
||||||
@@ -61,6 +63,16 @@ def test_normalize_request_status_rejects_unknown_values():
|
|||||||
normalize_request_status("queued")
|
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():
|
def test_normalize_policy_mode_accepts_strings_and_enum():
|
||||||
assert normalize_policy_mode("download") == "download"
|
assert normalize_policy_mode("download") == "download"
|
||||||
assert normalize_policy_mode("REQUEST_BOOK") == "request_book"
|
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["status"] == "fulfilled"
|
||||||
|
assert fulfilled["delivery_state"] == "queued"
|
||||||
|
assert fulfilled["delivery_updated_at"] is not None
|
||||||
assert fulfilled["reviewed_by"] == admin["id"]
|
assert fulfilled["reviewed_by"] == admin["id"]
|
||||||
assert captured["priority"] == 0
|
assert captured["priority"] == 0
|
||||||
assert captured["user_id"] == alice["id"]
|
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["status"] == "fulfilled"
|
||||||
|
assert fulfilled["delivery_state"] == "queued"
|
||||||
|
assert fulfilled["delivery_updated_at"] is not None
|
||||||
assert fulfilled["request_level"] == "book"
|
assert fulfilled["request_level"] == "book"
|
||||||
assert fulfilled["release_data"]["source_id"] == "admin-picked-book-release"
|
assert fulfilled["release_data"]["source_id"] == "admin-picked-book-release"
|
||||||
assert captured["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"
|
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
|
# book_data validation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ class TestUserDBInitialization:
|
|||||||
assert cursor.fetchone() is not None
|
assert cursor.fetchone() is not None
|
||||||
conn.close()
|
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):
|
def test_initialize_creates_download_requests_indexes(self, user_db, db_path):
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -72,6 +84,22 @@ class TestUserDBInitialization:
|
|||||||
assert "idx_download_requests_status_created_at" in index_names
|
assert "idx_download_requests_status_created_at" in index_names
|
||||||
conn.close()
|
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):
|
def test_initialize_enables_wal_mode(self, user_db, db_path):
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
cursor = conn.execute("PRAGMA journal_mode")
|
cursor = conn.execute("PRAGMA journal_mode")
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ class TestLoginRequiredDecorator:
|
|||||||
|
|
||||||
assert resp[0]["success"] is True
|
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.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||||
with patch(
|
with patch(
|
||||||
"shelfmark.core.settings_registry.load_config_file",
|
"shelfmark.core.settings_registry.load_config_file",
|
||||||
@@ -201,9 +201,11 @@ class TestLoginRequiredDecorator:
|
|||||||
main_module.session["user_id"] = "user"
|
main_module.session["user_id"] = "user"
|
||||||
main_module.session["is_admin"] = False
|
main_module.session["is_admin"] = False
|
||||||
decorated = main_module.login_required(view)
|
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):
|
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"):
|
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||||
|
|||||||
Reference in New Issue
Block a user