mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-10 12:28:27 +00:00
refactor: move the queue routes into core/queue_routes.py (#1637)
Next from the route plan in Discussion #1536 are the queue routes: status, cancel, retry, priority, reorder, queue order, active downloads, the local file download and the cover proxy. They move from `main.py` to `core/queue_routes.py`, registered with `register_queue_routes(app, login_required, ...)`, and `main.py` is about 600 lines shorter. The first commit adds 33 tests for what the guardrail tests did not cover, mostly the local download in each shape, the admin paths, the validation errors and the failure responses. They pass on current `main`. The second commit is the move. `resolve_status_scope` and the request update emitter stay in `main.py` because the release routes and the request code still use them, so they come in as parameters, with the user database and the history service as callables evaluated per request. Two test lines changed: the patches of `send_file` and of the delivery state sync now point at `queue_routes`, since that is where they get looked up. Log lines from these routes now name `shelfmark.core.queue_routes`. The URL map is identical, all 77 rules. The full suite, ruff and basedpyright are clean, and I ran the app from the branch and hit status, queue order, active downloads, the local download and the cover route, which answer as before. The exception text a few of them return in a 500 is unchanged, that is for the follow-up on exception text. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **Improvements** * Queue status, downloads, and queue controls respect account ownership, with administrative access retained. * More specific errors clarify when queue items or files are missing or unavailable. * Queue actions support cancellation, retrying, prioritization, and reordering. * Cover requests can use cached images when caching is enabled. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
f06585aa01
commit
384adc4c75
4 files changed
+1157
-600
No files matched your search
@@ -0,0 +1,650 @@
|
||||
"""Download queue routes: status, cancel, retry, priority, order, local file and cover."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import binascii
|
||||
import sqlite3
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Response, jsonify, request, send_file, session
|
||||
|
||||
from shelfmark.core.download_history_service import DownloadHistoryService
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.models import build_filename
|
||||
from shelfmark.core.request_helpers import normalize_optional_text, normalize_positive_int
|
||||
from shelfmark.core.requests_service import sync_delivery_states_from_queue_status
|
||||
from shelfmark.core.viewer_redaction import (
|
||||
is_public_download_id,
|
||||
match_public_download_id,
|
||||
redact_status,
|
||||
viewer_download_id,
|
||||
)
|
||||
from shelfmark.download import orchestrator as backend
|
||||
from shelfmark.download.zip_stream import stream_zip
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
|
||||
_IMPORT_OPERATIONAL_ERRORS = (ImportError, *_OPERATIONAL_ERRORS)
|
||||
|
||||
|
||||
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 _download_row_owned_by_actor(
|
||||
row: dict[str, Any],
|
||||
*,
|
||||
actor_user_id: int | None,
|
||||
actor_username: str | None,
|
||||
) -> bool:
|
||||
owner_user_id = normalize_positive_int(row.get("user_id"))
|
||||
if actor_user_id is not None and owner_user_id is not None:
|
||||
return owner_user_id == actor_user_id
|
||||
|
||||
row_username = normalize_optional_text(row.get("username"))
|
||||
if row_username is not None and isinstance(actor_username, str):
|
||||
return row_username == actor_username.strip()
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _queue_task_visible_to_actor(
|
||||
task_id: str,
|
||||
*,
|
||||
is_admin: bool,
|
||||
actor_user_id: int | None,
|
||||
actor_username: str | None,
|
||||
) -> bool:
|
||||
if is_admin:
|
||||
return True
|
||||
|
||||
task = backend.book_queue.get_task(task_id)
|
||||
if task is None:
|
||||
return False
|
||||
|
||||
return _task_owned_by_actor(
|
||||
task,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_username=actor_username,
|
||||
)
|
||||
|
||||
|
||||
def _viewer_queue_task_id(task_id: str) -> str:
|
||||
"""Return the id a non-admin sees for a live queue task."""
|
||||
task = backend.book_queue.get_task(task_id)
|
||||
return viewer_download_id(task_id, getattr(task, "request_id", None))
|
||||
|
||||
|
||||
def _attachment_filename_options(download_name: str) -> dict[str, str]:
|
||||
"""Content-Disposition filename options, RFC 2231-encoded when not plain ASCII.
|
||||
|
||||
Mirrors what send_file does, for responses built from a generator.
|
||||
"""
|
||||
try:
|
||||
download_name.encode("ascii")
|
||||
except UnicodeEncodeError:
|
||||
simple = unicodedata.normalize("NFKD", download_name)
|
||||
simple = simple.encode("ascii", "ignore").decode("ascii")
|
||||
quoted = quote(download_name, safe="!#$&+^`|~")
|
||||
return {"filename": simple, "filename*": f"UTF-8''{quoted}"}
|
||||
return {"filename": download_name}
|
||||
|
||||
|
||||
def _zip_download_response(file_paths: list[str], task: DownloadTask) -> Response:
|
||||
"""Send a multi-file download (audiobook chapters, a pack) as one streamed ZIP."""
|
||||
download_name = build_filename(task.title or "download", task.author, task.year, "zip")
|
||||
response = Response(stream_zip(file_paths), mimetype="application/zip")
|
||||
response.headers.set(
|
||||
"Content-Disposition", "attachment", **_attachment_filename_options(download_name)
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def register_queue_routes(
|
||||
app: Flask,
|
||||
login_required: Callable[..., Any],
|
||||
*,
|
||||
get_user_db: Callable[[], UserDB | None],
|
||||
get_download_history_service: Callable[[], DownloadHistoryService | None],
|
||||
resolve_status_scope: Callable[..., tuple[bool, int | None, bool]],
|
||||
emit_request_update_events: Callable[[list[dict[str, Any]]], None],
|
||||
) -> None:
|
||||
"""Register the queue routes.
|
||||
|
||||
The history service and the user database are called per request, so a runtime patch of
|
||||
either still takes effect. ``resolve_status_scope`` and ``emit_request_update_events`` stay
|
||||
in ``main.py`` because the release routes and the request routes use them too.
|
||||
"""
|
||||
|
||||
def _resolve_queue_actor() -> tuple[bool, int | None, str | None, Response | None]:
|
||||
is_admin, db_user_id, can_access_status = resolve_status_scope()
|
||||
actor_username = session.get("user_id")
|
||||
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
|
||||
|
||||
if not is_admin and (not can_access_status or db_user_id is None):
|
||||
return (
|
||||
is_admin,
|
||||
db_user_id,
|
||||
normalized_actor_username,
|
||||
jsonify(
|
||||
{"error": "User identity unavailable", "code": "user_identity_unavailable"}
|
||||
),
|
||||
)
|
||||
|
||||
return is_admin, db_user_id, normalized_actor_username, None
|
||||
|
||||
def _resolve_viewer_download_id(book_id: str) -> str:
|
||||
"""Map a non-admin's opaque download id back to the task it names (#1418).
|
||||
|
||||
Only the caller's own request-linked downloads are searched, so an id that
|
||||
doesn't resolve comes back unchanged and fails the route's usual lookup.
|
||||
"""
|
||||
download_history_service = get_download_history_service()
|
||||
if not is_public_download_id(book_id):
|
||||
return book_id
|
||||
is_admin, db_user_id, _ = resolve_status_scope()
|
||||
if is_admin or db_user_id is None:
|
||||
return book_id
|
||||
candidates = [
|
||||
task_id
|
||||
for tasks in backend.book_queue.get_status(user_id=db_user_id).values()
|
||||
for task_id, task in tasks.items()
|
||||
if getattr(task, "request_id", None) is not None
|
||||
]
|
||||
if download_history_service is not None:
|
||||
candidates.extend(download_history_service.list_request_task_ids(user_id=db_user_id))
|
||||
return match_public_download_id(book_id, candidates) or book_id
|
||||
|
||||
@app.route("/api/status", methods=["GET"])
|
||||
@login_required
|
||||
def api_status() -> Response | tuple[Response, int]:
|
||||
"""Get current download queue status.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON object with queue status.
|
||||
|
||||
"""
|
||||
user_db = get_user_db()
|
||||
try:
|
||||
is_admin, db_user_id, can_access_status = resolve_status_scope()
|
||||
if not can_access_status:
|
||||
return jsonify({})
|
||||
|
||||
user_id = None if is_admin else db_user_id
|
||||
status = backend.queue_status(user_id=user_id)
|
||||
if user_db is not None:
|
||||
updated_requests = sync_delivery_states_from_queue_status(
|
||||
user_db,
|
||||
queue_status=status,
|
||||
user_id=user_id,
|
||||
)
|
||||
emit_request_update_events(updated_requests)
|
||||
return jsonify(status if is_admin else redact_status(status))
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Status error: {e}")
|
||||
return jsonify({"error": "Could not read the download status"}), 500
|
||||
|
||||
@app.route("/api/localdownload", methods=["GET"])
|
||||
@login_required
|
||||
def api_local_download() -> Response | tuple[Response, int]:
|
||||
"""Download a completed book from local storage if available.
|
||||
|
||||
Query Parameters:
|
||||
id (str): Book identifier (MD5 hash)
|
||||
|
||||
Returns:
|
||||
flask.Response: The file if found, a ZIP of all of them when the download
|
||||
produced several (a live task only; history keeps just the first), otherwise
|
||||
an error response.
|
||||
|
||||
"""
|
||||
download_history_service = get_download_history_service()
|
||||
book_id = request.args.get("id", "")
|
||||
if not book_id:
|
||||
return jsonify({"error": "No book ID provided"}), 400
|
||||
book_id = _resolve_viewer_download_id(book_id)
|
||||
|
||||
try:
|
||||
# The path, not the bytes: send_file streams it off disk, where reading it
|
||||
# first cost two resident copies of the whole book.
|
||||
file_paths, book_info = backend.get_book_paths(book_id)
|
||||
if not file_paths:
|
||||
# Fallback for dismissed/history entries where queue task may no longer exist.
|
||||
if download_history_service is not None:
|
||||
is_admin, db_user_id, can_access_status = resolve_status_scope()
|
||||
if can_access_status:
|
||||
history_row = download_history_service.get_by_task_id(book_id)
|
||||
if history_row is not None:
|
||||
owner_user_id = history_row.get("user_id")
|
||||
if is_admin or owner_user_id == db_user_id:
|
||||
download_path = (
|
||||
DownloadHistoryService._resolve_existing_download_path(
|
||||
history_row.get("download_path")
|
||||
)
|
||||
)
|
||||
if download_path:
|
||||
return send_file(
|
||||
download_path,
|
||||
download_name=Path(download_path).name,
|
||||
as_attachment=True,
|
||||
)
|
||||
|
||||
# Book data not found or not available
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
is_admin, db_user_id, can_access_status = resolve_status_scope()
|
||||
if not is_admin:
|
||||
actor_username = session.get("user_id")
|
||||
if not can_access_status or not _task_owned_by_actor(
|
||||
book_info,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=actor_username if isinstance(actor_username, str) else None,
|
||||
):
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
if book_info is not None and len(file_paths) > 1:
|
||||
return _zip_download_response(file_paths, book_info)
|
||||
|
||||
file_name = book_info.get_filename() if book_info is not None else Path(book_id).name
|
||||
return send_file(file_paths[0], download_name=file_name, as_attachment=True)
|
||||
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
# The details are logged. The message of an OSError names the file's path on the server,
|
||||
# which a non-admin never sees anywhere else.
|
||||
logger.error_trace(f"Local download error: {e}")
|
||||
return jsonify({"error": "Could not read the file"}), 500
|
||||
|
||||
@app.route("/api/covers/<cover_id>", methods=["GET"])
|
||||
@login_required
|
||||
def api_cover(cover_id: str) -> Response | tuple[Response, int]:
|
||||
"""Serve a cached book cover image.
|
||||
|
||||
This endpoint proxies and caches cover images from external sources.
|
||||
Images are cached to disk for faster subsequent requests.
|
||||
|
||||
Path Parameters:
|
||||
cover_id (str): Cover identifier (book ID or composite key for universal mode).
|
||||
Kept for the link shape only; the cache entry is chosen by `url`.
|
||||
|
||||
Query Parameters:
|
||||
url (str): Base64-encoded original image URL (required)
|
||||
|
||||
Returns:
|
||||
flask.Response: Binary image data with appropriate Content-Type, or 404.
|
||||
|
||||
"""
|
||||
try:
|
||||
import base64
|
||||
|
||||
from shelfmark.config.env import is_covers_cache_enabled
|
||||
from shelfmark.core.image_cache import cover_cache_key, get_image_cache
|
||||
|
||||
# Check if caching is enabled
|
||||
if not is_covers_cache_enabled():
|
||||
return jsonify({"error": "Cover caching is disabled"}), 404
|
||||
|
||||
encoded_url = request.args.get("url")
|
||||
if not encoded_url:
|
||||
return jsonify({"error": "Cover URL not provided"}), 404
|
||||
|
||||
try:
|
||||
original_url = base64.urlsafe_b64decode(encoded_url).decode()
|
||||
except (binascii.Error, UnicodeDecodeError) as e:
|
||||
logger.warning("Failed to decode cover URL: %s", e)
|
||||
return jsonify({"error": "Invalid cover URL encoding"}), 400
|
||||
|
||||
# The entry is keyed by the URL, never by the caller-supplied id: the id would
|
||||
# let any user cache an image of their choosing under another book's link.
|
||||
cache = get_image_cache()
|
||||
cache_key = cover_cache_key(original_url)
|
||||
|
||||
cached = cache.get(cache_key)
|
||||
if cached:
|
||||
image_data, content_type = cached
|
||||
response = app.response_class(
|
||||
response=image_data, status=200, mimetype=content_type
|
||||
)
|
||||
response.headers["Cache-Control"] = "public, max-age=86400"
|
||||
response.headers["X-Cache"] = "HIT"
|
||||
return response
|
||||
|
||||
# Fetch and cache the image
|
||||
result = cache.fetch_and_cache(cache_key, original_url)
|
||||
if not result:
|
||||
return jsonify({"error": "Failed to fetch cover image"}), 404
|
||||
|
||||
image_data, content_type = result
|
||||
response = app.response_class(response=image_data, status=200, mimetype=content_type)
|
||||
response.headers["Cache-Control"] = "public, max-age=86400"
|
||||
response.headers["X-Cache"] = "MISS"
|
||||
except _IMPORT_OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Cover fetch error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
else:
|
||||
return response
|
||||
|
||||
@app.route("/api/download/<path:book_id>/cancel", methods=["DELETE"])
|
||||
@login_required
|
||||
def api_cancel_download(book_id: str) -> Response | tuple[Response, int]:
|
||||
"""Cancel a download.
|
||||
|
||||
Path Parameters:
|
||||
book_id (str): Book identifier to cancel
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON status indicating success or failure.
|
||||
|
||||
"""
|
||||
viewer_book_id = book_id
|
||||
book_id = _resolve_viewer_download_id(book_id)
|
||||
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 getattr(task, "request_id", None) is not None:
|
||||
return jsonify(
|
||||
{"error": "Forbidden", "code": "requested_download_cancel_forbidden"}
|
||||
), 403
|
||||
|
||||
success = backend.cancel_download(book_id)
|
||||
if success:
|
||||
return jsonify({"status": "cancelled", "book_id": viewer_book_id})
|
||||
return jsonify({"error": "Failed to cancel download or book not found"}), 404
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Cancel download error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route("/api/download/<path:book_id>/retry", methods=["POST"])
|
||||
@login_required
|
||||
def api_retry_download(book_id: str) -> Response | tuple[Response, int]:
|
||||
"""Retry a failed download."""
|
||||
download_history_service = get_download_history_service()
|
||||
viewer_book_id = book_id
|
||||
book_id = _resolve_viewer_download_id(book_id)
|
||||
try:
|
||||
task = backend.book_queue.get_task(book_id)
|
||||
history_row = None
|
||||
if task is None and download_history_service is not None:
|
||||
history_row = download_history_service.get_by_task_id(book_id)
|
||||
if task is None and history_row is None:
|
||||
return jsonify({"error": "Download not found"}), 404
|
||||
|
||||
is_admin, db_user_id, can_access_status = resolve_status_scope()
|
||||
actor_username = session.get("user_id")
|
||||
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
|
||||
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
|
||||
|
||||
if task is not 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
|
||||
elif history_row is None or not _download_row_owned_by_actor(
|
||||
history_row,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=normalized_actor_username,
|
||||
):
|
||||
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
|
||||
|
||||
if task is not None:
|
||||
task_status = backend.book_queue.get_task_status(book_id)
|
||||
if getattr(
|
||||
task, "request_id", None
|
||||
) is not None and not backend.can_retry_download_task(task, task_status):
|
||||
return jsonify(
|
||||
{"error": "Forbidden", "code": "requested_download_retry_forbidden"}
|
||||
), 403
|
||||
success, error = backend.retry_download(book_id)
|
||||
else:
|
||||
if history_row is None:
|
||||
logger.error("Download history row disappeared while retrying task %s", book_id)
|
||||
return jsonify({"error": "Download history not found"}), 404
|
||||
request_id = normalize_positive_int(history_row.get("request_id"))
|
||||
retry_payload = history_row.get("retry_payload")
|
||||
final_status = history_row.get("final_status")
|
||||
if request_id is not None:
|
||||
history_service = download_history_service
|
||||
if history_service is None:
|
||||
logger.error(
|
||||
"Download history service unavailable while retrying task %s", book_id
|
||||
)
|
||||
return jsonify({"error": "Download history unavailable"}), 500
|
||||
if not history_service.is_retry_available(history_row):
|
||||
return jsonify(
|
||||
{"error": "Forbidden", "code": "requested_download_retry_forbidden"}
|
||||
), 403
|
||||
success, error = backend.retry_persisted_download(
|
||||
retry_payload,
|
||||
final_status=final_status,
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({"status": "queued", "book_id": viewer_book_id})
|
||||
|
||||
if error == "Download not found":
|
||||
return jsonify({"error": error}), 404
|
||||
|
||||
return jsonify({"error": error or "Download cannot be retried"}), 409
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Retry download error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route("/api/queue/<path:book_id>/priority", methods=["PUT"])
|
||||
@login_required
|
||||
def api_set_priority(book_id: str) -> Response | tuple[Response, int]:
|
||||
"""Set priority for a queued book.
|
||||
|
||||
Path Parameters:
|
||||
book_id (str): Book identifier
|
||||
|
||||
Request Body:
|
||||
priority (int): New priority level (lower number = higher priority)
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON status indicating success or failure.
|
||||
|
||||
"""
|
||||
try:
|
||||
data = request.get_json(silent=True)
|
||||
if not data or "priority" not in data:
|
||||
return jsonify({"error": "Priority not provided"}), 400
|
||||
|
||||
priority = int(data["priority"])
|
||||
|
||||
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
|
||||
if identity_error is not None:
|
||||
return identity_error, 403
|
||||
|
||||
viewer_book_id = book_id
|
||||
book_id = _resolve_viewer_download_id(book_id)
|
||||
task = backend.book_queue.get_task(book_id)
|
||||
if task is None:
|
||||
return jsonify({"error": "Failed to update priority or book not found"}), 404
|
||||
|
||||
if not is_admin and not _task_owned_by_actor(
|
||||
task,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=actor_username,
|
||||
):
|
||||
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
|
||||
|
||||
success = backend.set_book_priority(book_id, priority)
|
||||
|
||||
if success:
|
||||
return jsonify(
|
||||
{"status": "updated", "book_id": viewer_book_id, "priority": priority}
|
||||
)
|
||||
return jsonify({"error": "Failed to update priority or book not found"}), 404
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid priority value"}), 400
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Set priority error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route("/api/queue/reorder", methods=["POST"])
|
||||
@login_required
|
||||
def api_reorder_queue() -> Response | tuple[Response, int]:
|
||||
"""Bulk reorder queue by setting new priorities.
|
||||
|
||||
Request Body:
|
||||
book_priorities (dict): Mapping of book_id to new priority
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON status indicating success or failure.
|
||||
|
||||
"""
|
||||
try:
|
||||
data = request.get_json(silent=True)
|
||||
if not data or "book_priorities" not in data:
|
||||
return jsonify({"error": "book_priorities not provided"}), 400
|
||||
|
||||
book_priorities = data["book_priorities"]
|
||||
if not isinstance(book_priorities, dict):
|
||||
return jsonify({"error": "book_priorities must be a dictionary"}), 400
|
||||
|
||||
# Validate all priorities are integers
|
||||
for book_id, priority in book_priorities.items():
|
||||
if not isinstance(priority, int):
|
||||
return jsonify({"error": f"Invalid priority for book {book_id}"}), 400
|
||||
|
||||
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
|
||||
if identity_error is not None:
|
||||
return identity_error, 403
|
||||
|
||||
if not is_admin:
|
||||
book_priorities = {
|
||||
_resolve_viewer_download_id(str(book_id)): priority
|
||||
for book_id, priority in book_priorities.items()
|
||||
}
|
||||
owned_book_priorities = {}
|
||||
for book_id in book_priorities:
|
||||
task = backend.book_queue.get_task(str(book_id))
|
||||
if task is None:
|
||||
continue
|
||||
if not _task_owned_by_actor(
|
||||
task, actor_user_id=db_user_id, actor_username=actor_username
|
||||
):
|
||||
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
|
||||
owned_book_priorities[book_id] = book_priorities[book_id]
|
||||
book_priorities = owned_book_priorities
|
||||
|
||||
success = backend.reorder_queue(book_priorities)
|
||||
|
||||
if success:
|
||||
return jsonify({"status": "reordered", "updated_count": len(book_priorities)})
|
||||
return jsonify({"error": "Failed to reorder queue"}), 500
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Reorder queue error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route("/api/queue/order", methods=["GET"])
|
||||
@login_required
|
||||
def api_queue_order() -> Response | tuple[Response, int]:
|
||||
"""Get current queue order for display.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON array of queued books with their order and priorities.
|
||||
|
||||
"""
|
||||
try:
|
||||
queue_order = backend.get_queue_order()
|
||||
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
|
||||
if identity_error is not None:
|
||||
return identity_error, 403
|
||||
if not is_admin:
|
||||
queue_order = [
|
||||
{**item, "id": _viewer_queue_task_id(str(item.get("id", "")))}
|
||||
for item in queue_order
|
||||
if _queue_task_visible_to_actor(
|
||||
str(item.get("id", "")),
|
||||
is_admin=False,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=actor_username,
|
||||
)
|
||||
]
|
||||
return jsonify({"queue": queue_order})
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Queue order error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route("/api/downloads/active", methods=["GET"])
|
||||
@login_required
|
||||
def api_active_downloads() -> Response | tuple[Response, int]:
|
||||
"""Get list of currently active downloads.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON array of active download book IDs.
|
||||
|
||||
"""
|
||||
try:
|
||||
active_downloads = backend.get_active_downloads()
|
||||
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
|
||||
if identity_error is not None:
|
||||
return identity_error, 403
|
||||
if not is_admin:
|
||||
active_downloads = [
|
||||
_viewer_queue_task_id(task_id)
|
||||
for task_id in active_downloads
|
||||
if _queue_task_visible_to_actor(
|
||||
task_id,
|
||||
is_admin=False,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=actor_username,
|
||||
)
|
||||
]
|
||||
return jsonify({"active_downloads": active_downloads})
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Active downloads error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
+12
-599
@@ -1,23 +1,19 @@
|
||||
"""Flask app - routes, WebSocket handlers, and middleware."""
|
||||
|
||||
import binascii
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
import unicodedata
|
||||
from functools import wraps
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Callable, NoReturn, cast
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Flask, g, jsonify, request, send_file, session
|
||||
from flask import Flask, g, jsonify, request, session
|
||||
from flask_cors import CORS
|
||||
from flask_socketio import SocketIO
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from werkzeug.security import check_password_hash
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from shelfmark.api.websocket import ws_manager
|
||||
from shelfmark.config.env import (
|
||||
@@ -73,7 +69,6 @@ from shelfmark.core.models import (
|
||||
TERMINAL_QUEUE_STATUSES,
|
||||
QueueStatus,
|
||||
SearchFilters,
|
||||
build_filename,
|
||||
)
|
||||
from shelfmark.core.notifications import (
|
||||
NotificationContext,
|
||||
@@ -83,6 +78,7 @@ from shelfmark.core.notifications import (
|
||||
)
|
||||
from shelfmark.core.onboarding_routes import register_onboarding_routes
|
||||
from shelfmark.core.prefix_middleware import PrefixMiddleware
|
||||
from shelfmark.core.queue_routes import register_queue_routes
|
||||
from shelfmark.core.release_inspect_routes import register_release_inspect_routes
|
||||
from shelfmark.core.request_helpers import (
|
||||
coerce_bool,
|
||||
@@ -114,14 +110,11 @@ from shelfmark.core.user_db import UserDB
|
||||
from shelfmark.core.utils import AUDIOBOOK_FORMATS, normalize_base_path
|
||||
from shelfmark.core.viewer_redaction import (
|
||||
configure_public_id_key,
|
||||
is_public_download_id,
|
||||
match_public_download_id,
|
||||
redact_status,
|
||||
viewer_download_id,
|
||||
)
|
||||
from shelfmark.download import orchestrator as backend
|
||||
from shelfmark.download import warmup
|
||||
from shelfmark.download.zip_stream import stream_zip
|
||||
from shelfmark.release_sources import (
|
||||
BrowseRecord,
|
||||
Release,
|
||||
@@ -130,7 +123,8 @@ from shelfmark.release_sources import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shelfmark.core.models import DownloadTask
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -1358,107 +1352,6 @@ def _record_download_terminal_snapshot(task_id: str, status: QueueStatus, task:
|
||||
)
|
||||
|
||||
|
||||
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 _download_row_owned_by_actor(
|
||||
row: dict[str, Any],
|
||||
*,
|
||||
actor_user_id: int | None,
|
||||
actor_username: str | None,
|
||||
) -> bool:
|
||||
owner_user_id = normalize_positive_int(row.get("user_id"))
|
||||
if actor_user_id is not None and owner_user_id is not None:
|
||||
return owner_user_id == actor_user_id
|
||||
|
||||
row_username = normalize_optional_text(row.get("username"))
|
||||
if row_username is not None and isinstance(actor_username, str):
|
||||
return row_username == actor_username.strip()
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_queue_actor() -> tuple[bool, int | None, str | None, Response | None]:
|
||||
is_admin, db_user_id, can_access_status = _resolve_status_scope()
|
||||
actor_username = session.get("user_id")
|
||||
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
|
||||
|
||||
if not is_admin and (not can_access_status or db_user_id is None):
|
||||
return (
|
||||
is_admin,
|
||||
db_user_id,
|
||||
normalized_actor_username,
|
||||
jsonify({"error": "User identity unavailable", "code": "user_identity_unavailable"}),
|
||||
)
|
||||
|
||||
return is_admin, db_user_id, normalized_actor_username, None
|
||||
|
||||
|
||||
def _queue_task_visible_to_actor(
|
||||
task_id: str,
|
||||
*,
|
||||
is_admin: bool,
|
||||
actor_user_id: int | None,
|
||||
actor_username: str | None,
|
||||
) -> bool:
|
||||
if is_admin:
|
||||
return True
|
||||
|
||||
task = backend.book_queue.get_task(task_id)
|
||||
if task is None:
|
||||
return False
|
||||
|
||||
return _task_owned_by_actor(
|
||||
task,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_username=actor_username,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_viewer_download_id(book_id: str) -> str:
|
||||
"""Map a non-admin's opaque download id back to the task it names (#1418).
|
||||
|
||||
Only the caller's own request-linked downloads are searched, so an id that
|
||||
doesn't resolve comes back unchanged and fails the route's usual lookup.
|
||||
"""
|
||||
if not is_public_download_id(book_id):
|
||||
return book_id
|
||||
is_admin, db_user_id, _ = _resolve_status_scope()
|
||||
if is_admin or db_user_id is None:
|
||||
return book_id
|
||||
candidates = [
|
||||
task_id
|
||||
for tasks in backend.book_queue.get_status(user_id=db_user_id).values()
|
||||
for task_id, task in tasks.items()
|
||||
if getattr(task, "request_id", None) is not None
|
||||
]
|
||||
if download_history_service is not None:
|
||||
candidates.extend(download_history_service.list_request_task_ids(user_id=db_user_id))
|
||||
return match_public_download_id(book_id, candidates) or book_id
|
||||
|
||||
|
||||
def _viewer_queue_task_id(task_id: str) -> str:
|
||||
"""Return the id a non-admin sees for a live queue task."""
|
||||
task = backend.book_queue.get_task(task_id)
|
||||
return viewer_download_id(task_id, getattr(task, "request_id", None))
|
||||
|
||||
|
||||
backend.book_queue.set_queue_hook(_record_download_queued)
|
||||
backend.book_queue.set_terminal_status_hook(_record_download_terminal_snapshot)
|
||||
|
||||
@@ -1489,494 +1382,14 @@ def _emit_request_update_events(updated_requests: list[dict[str, Any]]) -> None:
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/status", methods=["GET"])
|
||||
@login_required
|
||||
def api_status() -> Response | tuple[Response, int]:
|
||||
"""Get current download queue status.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON object with queue status.
|
||||
|
||||
"""
|
||||
try:
|
||||
is_admin, db_user_id, can_access_status = _resolve_status_scope()
|
||||
if not can_access_status:
|
||||
return jsonify({})
|
||||
|
||||
user_id = None if is_admin else db_user_id
|
||||
status = backend.queue_status(user_id=user_id)
|
||||
if user_db is not None:
|
||||
updated_requests = sync_delivery_states_from_queue_status(
|
||||
user_db,
|
||||
queue_status=status,
|
||||
user_id=user_id,
|
||||
)
|
||||
_emit_request_update_events(updated_requests)
|
||||
return jsonify(status if is_admin else redact_status(status))
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Status error: {e}")
|
||||
return jsonify({"error": "Could not read the download status"}), 500
|
||||
|
||||
|
||||
def _attachment_filename_options(download_name: str) -> dict[str, str]:
|
||||
"""Content-Disposition filename options, RFC 2231-encoded when not plain ASCII.
|
||||
|
||||
Mirrors what send_file does, for responses built from a generator.
|
||||
"""
|
||||
try:
|
||||
download_name.encode("ascii")
|
||||
except UnicodeEncodeError:
|
||||
simple = unicodedata.normalize("NFKD", download_name)
|
||||
simple = simple.encode("ascii", "ignore").decode("ascii")
|
||||
quoted = quote(download_name, safe="!#$&+^`|~")
|
||||
return {"filename": simple, "filename*": f"UTF-8''{quoted}"}
|
||||
return {"filename": download_name}
|
||||
|
||||
|
||||
def _zip_download_response(file_paths: list[str], task: DownloadTask) -> Response:
|
||||
"""Send a multi-file download (audiobook chapters, a pack) as one streamed ZIP."""
|
||||
download_name = build_filename(task.title or "download", task.author, task.year, "zip")
|
||||
response = Response(stream_zip(file_paths), mimetype="application/zip")
|
||||
response.headers.set(
|
||||
"Content-Disposition", "attachment", **_attachment_filename_options(download_name)
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@app.route("/api/localdownload", methods=["GET"])
|
||||
@login_required
|
||||
def api_local_download() -> Response | tuple[Response, int]:
|
||||
"""Download a completed book from local storage if available.
|
||||
|
||||
Query Parameters:
|
||||
id (str): Book identifier (MD5 hash)
|
||||
|
||||
Returns:
|
||||
flask.Response: The file if found, a ZIP of all of them when the download
|
||||
produced several (a live task only; history keeps just the first), otherwise
|
||||
an error response.
|
||||
|
||||
"""
|
||||
book_id = request.args.get("id", "")
|
||||
if not book_id:
|
||||
return jsonify({"error": "No book ID provided"}), 400
|
||||
book_id = _resolve_viewer_download_id(book_id)
|
||||
|
||||
try:
|
||||
# The path, not the bytes: send_file streams it off disk, where reading it
|
||||
# first cost two resident copies of the whole book.
|
||||
file_paths, book_info = backend.get_book_paths(book_id)
|
||||
if not file_paths:
|
||||
# Fallback for dismissed/history entries where queue task may no longer exist.
|
||||
if download_history_service is not None:
|
||||
is_admin, db_user_id, can_access_status = _resolve_status_scope()
|
||||
if can_access_status:
|
||||
history_row = download_history_service.get_by_task_id(book_id)
|
||||
if history_row is not None:
|
||||
owner_user_id = history_row.get("user_id")
|
||||
if is_admin or owner_user_id == db_user_id:
|
||||
download_path = DownloadHistoryService._resolve_existing_download_path(
|
||||
history_row.get("download_path")
|
||||
)
|
||||
if download_path:
|
||||
return send_file(
|
||||
download_path,
|
||||
download_name=Path(download_path).name,
|
||||
as_attachment=True,
|
||||
)
|
||||
|
||||
# Book data not found or not available
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
is_admin, db_user_id, can_access_status = _resolve_status_scope()
|
||||
if not is_admin:
|
||||
actor_username = session.get("user_id")
|
||||
if not can_access_status or not _task_owned_by_actor(
|
||||
book_info,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=actor_username if isinstance(actor_username, str) else None,
|
||||
):
|
||||
return jsonify({"error": "File not found"}), 404
|
||||
|
||||
if book_info is not None and len(file_paths) > 1:
|
||||
return _zip_download_response(file_paths, book_info)
|
||||
|
||||
file_name = book_info.get_filename() if book_info is not None else Path(book_id).name
|
||||
return send_file(file_paths[0], download_name=file_name, as_attachment=True)
|
||||
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
# The details are logged. The message of an OSError names the file's path on the server,
|
||||
# which a non-admin never sees anywhere else.
|
||||
logger.error_trace(f"Local download error: {e}")
|
||||
return jsonify({"error": "Could not read the file"}), 500
|
||||
|
||||
|
||||
@app.route("/api/covers/<cover_id>", methods=["GET"])
|
||||
@login_required
|
||||
def api_cover(cover_id: str) -> Response | tuple[Response, int]:
|
||||
"""Serve a cached book cover image.
|
||||
|
||||
This endpoint proxies and caches cover images from external sources.
|
||||
Images are cached to disk for faster subsequent requests.
|
||||
|
||||
Path Parameters:
|
||||
cover_id (str): Cover identifier (book ID or composite key for universal mode).
|
||||
Kept for the link shape only; the cache entry is chosen by `url`.
|
||||
|
||||
Query Parameters:
|
||||
url (str): Base64-encoded original image URL (required)
|
||||
|
||||
Returns:
|
||||
flask.Response: Binary image data with appropriate Content-Type, or 404.
|
||||
|
||||
"""
|
||||
try:
|
||||
import base64
|
||||
|
||||
from shelfmark.config.env import is_covers_cache_enabled
|
||||
from shelfmark.core.image_cache import cover_cache_key, get_image_cache
|
||||
|
||||
# Check if caching is enabled
|
||||
if not is_covers_cache_enabled():
|
||||
return jsonify({"error": "Cover caching is disabled"}), 404
|
||||
|
||||
encoded_url = request.args.get("url")
|
||||
if not encoded_url:
|
||||
return jsonify({"error": "Cover URL not provided"}), 404
|
||||
|
||||
try:
|
||||
original_url = base64.urlsafe_b64decode(encoded_url).decode()
|
||||
except (binascii.Error, UnicodeDecodeError) as e:
|
||||
logger.warning("Failed to decode cover URL: %s", e)
|
||||
return jsonify({"error": "Invalid cover URL encoding"}), 400
|
||||
|
||||
# The entry is keyed by the URL, never by the caller-supplied id: the id would
|
||||
# let any user cache an image of their choosing under another book's link.
|
||||
cache = get_image_cache()
|
||||
cache_key = cover_cache_key(original_url)
|
||||
|
||||
cached = cache.get(cache_key)
|
||||
if cached:
|
||||
image_data, content_type = cached
|
||||
response = app.response_class(response=image_data, status=200, mimetype=content_type)
|
||||
response.headers["Cache-Control"] = "public, max-age=86400"
|
||||
response.headers["X-Cache"] = "HIT"
|
||||
return response
|
||||
|
||||
# Fetch and cache the image
|
||||
result = cache.fetch_and_cache(cache_key, original_url)
|
||||
if not result:
|
||||
return jsonify({"error": "Failed to fetch cover image"}), 404
|
||||
|
||||
image_data, content_type = result
|
||||
response = app.response_class(response=image_data, status=200, mimetype=content_type)
|
||||
response.headers["Cache-Control"] = "public, max-age=86400"
|
||||
response.headers["X-Cache"] = "MISS"
|
||||
except _IMPORT_OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Cover fetch error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
else:
|
||||
return response
|
||||
|
||||
|
||||
@app.route("/api/download/<path:book_id>/cancel", methods=["DELETE"])
|
||||
@login_required
|
||||
def api_cancel_download(book_id: str) -> Response | tuple[Response, int]:
|
||||
"""Cancel a download.
|
||||
|
||||
Path Parameters:
|
||||
book_id (str): Book identifier to cancel
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON status indicating success or failure.
|
||||
|
||||
"""
|
||||
viewer_book_id = book_id
|
||||
book_id = _resolve_viewer_download_id(book_id)
|
||||
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 getattr(task, "request_id", None) is not None:
|
||||
return jsonify(
|
||||
{"error": "Forbidden", "code": "requested_download_cancel_forbidden"}
|
||||
), 403
|
||||
|
||||
success = backend.cancel_download(book_id)
|
||||
if success:
|
||||
return jsonify({"status": "cancelled", "book_id": viewer_book_id})
|
||||
return jsonify({"error": "Failed to cancel download or book not found"}), 404
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Cancel download error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/download/<path:book_id>/retry", methods=["POST"])
|
||||
@login_required
|
||||
def api_retry_download(book_id: str) -> Response | tuple[Response, int]:
|
||||
"""Retry a failed download."""
|
||||
viewer_book_id = book_id
|
||||
book_id = _resolve_viewer_download_id(book_id)
|
||||
try:
|
||||
task = backend.book_queue.get_task(book_id)
|
||||
history_row = None
|
||||
if task is None and download_history_service is not None:
|
||||
history_row = download_history_service.get_by_task_id(book_id)
|
||||
if task is None and history_row is None:
|
||||
return jsonify({"error": "Download not found"}), 404
|
||||
|
||||
is_admin, db_user_id, can_access_status = _resolve_status_scope()
|
||||
actor_username = session.get("user_id")
|
||||
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
|
||||
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
|
||||
|
||||
if task is not 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
|
||||
elif history_row is None or not _download_row_owned_by_actor(
|
||||
history_row,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=normalized_actor_username,
|
||||
):
|
||||
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
|
||||
|
||||
if task is not None:
|
||||
task_status = backend.book_queue.get_task_status(book_id)
|
||||
if getattr(
|
||||
task, "request_id", None
|
||||
) is not None and not backend.can_retry_download_task(task, task_status):
|
||||
return jsonify(
|
||||
{"error": "Forbidden", "code": "requested_download_retry_forbidden"}
|
||||
), 403
|
||||
success, error = backend.retry_download(book_id)
|
||||
else:
|
||||
if history_row is None:
|
||||
logger.error("Download history row disappeared while retrying task %s", book_id)
|
||||
return jsonify({"error": "Download history not found"}), 404
|
||||
request_id = normalize_positive_int(history_row.get("request_id"))
|
||||
retry_payload = history_row.get("retry_payload")
|
||||
final_status = history_row.get("final_status")
|
||||
if request_id is not None:
|
||||
history_service = download_history_service
|
||||
if history_service is None:
|
||||
logger.error(
|
||||
"Download history service unavailable while retrying task %s", book_id
|
||||
)
|
||||
return jsonify({"error": "Download history unavailable"}), 500
|
||||
if not history_service.is_retry_available(history_row):
|
||||
return jsonify(
|
||||
{"error": "Forbidden", "code": "requested_download_retry_forbidden"}
|
||||
), 403
|
||||
success, error = backend.retry_persisted_download(
|
||||
retry_payload,
|
||||
final_status=final_status,
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({"status": "queued", "book_id": viewer_book_id})
|
||||
|
||||
if error == "Download not found":
|
||||
return jsonify({"error": error}), 404
|
||||
|
||||
return jsonify({"error": error or "Download cannot be retried"}), 409
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Retry download error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/queue/<path:book_id>/priority", methods=["PUT"])
|
||||
@login_required
|
||||
def api_set_priority(book_id: str) -> Response | tuple[Response, int]:
|
||||
"""Set priority for a queued book.
|
||||
|
||||
Path Parameters:
|
||||
book_id (str): Book identifier
|
||||
|
||||
Request Body:
|
||||
priority (int): New priority level (lower number = higher priority)
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON status indicating success or failure.
|
||||
|
||||
"""
|
||||
try:
|
||||
data = request.get_json(silent=True)
|
||||
if not data or "priority" not in data:
|
||||
return jsonify({"error": "Priority not provided"}), 400
|
||||
|
||||
priority = int(data["priority"])
|
||||
|
||||
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
|
||||
if identity_error is not None:
|
||||
return identity_error, 403
|
||||
|
||||
viewer_book_id = book_id
|
||||
book_id = _resolve_viewer_download_id(book_id)
|
||||
task = backend.book_queue.get_task(book_id)
|
||||
if task is None:
|
||||
return jsonify({"error": "Failed to update priority or book not found"}), 404
|
||||
|
||||
if not is_admin and not _task_owned_by_actor(
|
||||
task,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=actor_username,
|
||||
):
|
||||
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
|
||||
|
||||
success = backend.set_book_priority(book_id, priority)
|
||||
|
||||
if success:
|
||||
return jsonify({"status": "updated", "book_id": viewer_book_id, "priority": priority})
|
||||
return jsonify({"error": "Failed to update priority or book not found"}), 404
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid priority value"}), 400
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Set priority error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/queue/reorder", methods=["POST"])
|
||||
@login_required
|
||||
def api_reorder_queue() -> Response | tuple[Response, int]:
|
||||
"""Bulk reorder queue by setting new priorities.
|
||||
|
||||
Request Body:
|
||||
book_priorities (dict): Mapping of book_id to new priority
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON status indicating success or failure.
|
||||
|
||||
"""
|
||||
try:
|
||||
data = request.get_json(silent=True)
|
||||
if not data or "book_priorities" not in data:
|
||||
return jsonify({"error": "book_priorities not provided"}), 400
|
||||
|
||||
book_priorities = data["book_priorities"]
|
||||
if not isinstance(book_priorities, dict):
|
||||
return jsonify({"error": "book_priorities must be a dictionary"}), 400
|
||||
|
||||
# Validate all priorities are integers
|
||||
for book_id, priority in book_priorities.items():
|
||||
if not isinstance(priority, int):
|
||||
return jsonify({"error": f"Invalid priority for book {book_id}"}), 400
|
||||
|
||||
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
|
||||
if identity_error is not None:
|
||||
return identity_error, 403
|
||||
|
||||
if not is_admin:
|
||||
book_priorities = {
|
||||
_resolve_viewer_download_id(str(book_id)): priority
|
||||
for book_id, priority in book_priorities.items()
|
||||
}
|
||||
owned_book_priorities = {}
|
||||
for book_id in book_priorities:
|
||||
task = backend.book_queue.get_task(str(book_id))
|
||||
if task is None:
|
||||
continue
|
||||
if not _task_owned_by_actor(
|
||||
task, actor_user_id=db_user_id, actor_username=actor_username
|
||||
):
|
||||
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
|
||||
owned_book_priorities[book_id] = book_priorities[book_id]
|
||||
book_priorities = owned_book_priorities
|
||||
|
||||
success = backend.reorder_queue(book_priorities)
|
||||
|
||||
if success:
|
||||
return jsonify({"status": "reordered", "updated_count": len(book_priorities)})
|
||||
return jsonify({"error": "Failed to reorder queue"}), 500
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Reorder queue error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/queue/order", methods=["GET"])
|
||||
@login_required
|
||||
def api_queue_order() -> Response | tuple[Response, int]:
|
||||
"""Get current queue order for display.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON array of queued books with their order and priorities.
|
||||
|
||||
"""
|
||||
try:
|
||||
queue_order = backend.get_queue_order()
|
||||
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
|
||||
if identity_error is not None:
|
||||
return identity_error, 403
|
||||
if not is_admin:
|
||||
queue_order = [
|
||||
{**item, "id": _viewer_queue_task_id(str(item.get("id", "")))}
|
||||
for item in queue_order
|
||||
if _queue_task_visible_to_actor(
|
||||
str(item.get("id", "")),
|
||||
is_admin=False,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=actor_username,
|
||||
)
|
||||
]
|
||||
return jsonify({"queue": queue_order})
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Queue order error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/downloads/active", methods=["GET"])
|
||||
@login_required
|
||||
def api_active_downloads() -> Response | tuple[Response, int]:
|
||||
"""Get list of currently active downloads.
|
||||
|
||||
Returns:
|
||||
flask.Response: JSON array of active download book IDs.
|
||||
|
||||
"""
|
||||
try:
|
||||
active_downloads = backend.get_active_downloads()
|
||||
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
|
||||
if identity_error is not None:
|
||||
return identity_error, 403
|
||||
if not is_admin:
|
||||
active_downloads = [
|
||||
_viewer_queue_task_id(task_id)
|
||||
for task_id in active_downloads
|
||||
if _queue_task_visible_to_actor(
|
||||
task_id,
|
||||
is_admin=False,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=actor_username,
|
||||
)
|
||||
]
|
||||
return jsonify({"active_downloads": active_downloads})
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Active downloads error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
register_queue_routes(
|
||||
app,
|
||||
login_required,
|
||||
get_user_db=lambda: user_db,
|
||||
get_download_history_service=lambda: download_history_service,
|
||||
resolve_status_scope=_resolve_status_scope,
|
||||
emit_request_update_events=_emit_request_update_events,
|
||||
)
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
|
||||
@@ -384,7 +384,7 @@ class TestActivityRoutes:
|
||||
main_module.backend.book_queue.update_status(task_id, QueueStatus.COMPLETE)
|
||||
with (
|
||||
patch.object(main_module, "get_auth_mode", return_value="builtin"),
|
||||
patch.object(main_module, "send_file", side_effect=failure),
|
||||
patch("shelfmark.core.queue_routes.send_file", side_effect=failure),
|
||||
):
|
||||
response = client.get(f"/api/localdownload?id={task_id}")
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
"""Characterization tests for the queue routes, written before they moved out of main.py.
|
||||
|
||||
They pin what the routes do today: the status codes, the bodies and who may do what. The
|
||||
guardrail tests cover the owner and non-owner cases for cancel, retry, priority and reorder,
|
||||
so these fill in the rest: the local download route, the admin paths, the validation errors
|
||||
and the failure responses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import uuid
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
|
||||
@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 _login(client, *, user_id="alice", db_user_id: int | None = 7, is_admin=False) -> 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
|
||||
|
||||
|
||||
def _create_user(main_module, *, prefix: str) -> dict:
|
||||
return main_module.user_db.create_user(username=f"{prefix}-{uuid.uuid4().hex[:8]}", role="user")
|
||||
|
||||
|
||||
def _task(task_id="task-1", *, user=None, request_id=None) -> DownloadTask:
|
||||
return DownloadTask(
|
||||
task_id=task_id,
|
||||
source="direct_download",
|
||||
title="A Book",
|
||||
author="An Author",
|
||||
user_id=user["id"] if user else None,
|
||||
username=user["username"] if user else None,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
|
||||
def _builtin(main_module):
|
||||
return patch.object(main_module, "get_auth_mode", return_value="builtin")
|
||||
|
||||
|
||||
class TestLocalDownload:
|
||||
def test_missing_id_is_a_400(self, main_module, client):
|
||||
_login(client, is_admin=True)
|
||||
with _builtin(main_module):
|
||||
resp = client.get("/api/localdownload")
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json() == {"error": "No book ID provided"}
|
||||
|
||||
def test_requires_a_login(self, main_module, client):
|
||||
with _builtin(main_module):
|
||||
resp = client.get("/api/localdownload?id=task-1")
|
||||
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_unknown_book_is_a_404_when_history_has_nothing(self, main_module, client):
|
||||
_login(client, is_admin=True)
|
||||
history = MagicMock()
|
||||
history.get_by_task_id.return_value = None
|
||||
with _builtin(main_module):
|
||||
with patch.object(main_module.backend, "get_book_paths", return_value=([], None)):
|
||||
with patch.object(main_module, "download_history_service", history):
|
||||
resp = client.get("/api/localdownload?id=gone")
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert resp.get_json() == {"error": "File not found"}
|
||||
|
||||
def test_admin_gets_the_file_as_an_attachment(self, main_module, client, tmp_path):
|
||||
book = tmp_path / "book.epub"
|
||||
book.write_bytes(b"epub-bytes")
|
||||
_login(client, is_admin=True)
|
||||
task = _task()
|
||||
with _builtin(main_module):
|
||||
with patch.object(
|
||||
main_module.backend, "get_book_paths", return_value=([str(book)], task)
|
||||
):
|
||||
resp = client.get("/api/localdownload?id=task-1")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == b"epub-bytes"
|
||||
assert resp.headers["Content-Disposition"].startswith("attachment")
|
||||
|
||||
def test_owner_gets_the_file(self, main_module, client, tmp_path):
|
||||
book = tmp_path / "book.epub"
|
||||
book.write_bytes(b"mine")
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
_login(client, user_id=user["username"], db_user_id=user["id"])
|
||||
with _builtin(main_module):
|
||||
with patch.object(
|
||||
main_module.backend,
|
||||
"get_book_paths",
|
||||
return_value=([str(book)], _task(user=user)),
|
||||
):
|
||||
resp = client.get("/api/localdownload?id=task-1")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == b"mine"
|
||||
|
||||
def test_non_owner_gets_a_404_not_a_403(self, main_module, client, tmp_path):
|
||||
book = tmp_path / "book.epub"
|
||||
book.write_bytes(b"theirs")
|
||||
owner = _create_user(main_module, prefix="owner")
|
||||
other = _create_user(main_module, prefix="other")
|
||||
_login(client, user_id=other["username"], db_user_id=other["id"])
|
||||
with _builtin(main_module):
|
||||
with patch.object(
|
||||
main_module.backend,
|
||||
"get_book_paths",
|
||||
return_value=([str(book)], _task(user=owner)),
|
||||
):
|
||||
resp = client.get("/api/localdownload?id=task-1")
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert resp.get_json() == {"error": "File not found"}
|
||||
|
||||
def test_several_files_come_back_as_one_zip(self, main_module, client, tmp_path):
|
||||
first = tmp_path / "01.mp3"
|
||||
second = tmp_path / "02.mp3"
|
||||
first.write_bytes(b"one")
|
||||
second.write_bytes(b"two")
|
||||
_login(client, is_admin=True)
|
||||
with _builtin(main_module):
|
||||
with patch.object(
|
||||
main_module.backend,
|
||||
"get_book_paths",
|
||||
return_value=([str(first), str(second)], _task()),
|
||||
):
|
||||
resp = client.get("/api/localdownload?id=task-1")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.mimetype == "application/zip"
|
||||
assert resp.headers["Content-Disposition"].startswith("attachment")
|
||||
with zipfile.ZipFile(BytesIO(resp.data)) as archive:
|
||||
assert sorted(archive.namelist()) == ["01.mp3", "02.mp3"]
|
||||
|
||||
def test_a_finished_download_is_served_from_history_to_its_owner(
|
||||
self, main_module, client, tmp_path
|
||||
):
|
||||
book = tmp_path / "old.epub"
|
||||
book.write_bytes(b"from-history")
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
_login(client, user_id=user["username"], db_user_id=user["id"])
|
||||
history = MagicMock()
|
||||
history.get_by_task_id.return_value = {"user_id": user["id"], "download_path": str(book)}
|
||||
with _builtin(main_module):
|
||||
with patch.object(main_module.backend, "get_book_paths", return_value=([], None)):
|
||||
with patch.object(main_module, "download_history_service", history):
|
||||
with patch.object(
|
||||
main_module.DownloadHistoryService,
|
||||
"_resolve_existing_download_path",
|
||||
return_value=str(book),
|
||||
):
|
||||
resp = client.get("/api/localdownload?id=task-1")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == b"from-history"
|
||||
|
||||
def test_a_history_entry_of_someone_else_is_a_404(self, main_module, client, tmp_path):
|
||||
book = tmp_path / "old.epub"
|
||||
book.write_bytes(b"not-yours")
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
_login(client, user_id=user["username"], db_user_id=user["id"])
|
||||
history = MagicMock()
|
||||
history.get_by_task_id.return_value = {
|
||||
"user_id": user["id"] + 1000,
|
||||
"download_path": str(book),
|
||||
}
|
||||
with _builtin(main_module):
|
||||
with patch.object(main_module.backend, "get_book_paths", return_value=([], None)):
|
||||
with patch.object(main_module, "download_history_service", history):
|
||||
resp = client.get("/api/localdownload?id=task-1")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_a_read_failure_returns_a_fixed_message(self, main_module, client):
|
||||
_login(client, is_admin=True)
|
||||
with _builtin(main_module):
|
||||
with patch.object(
|
||||
main_module.backend,
|
||||
"get_book_paths",
|
||||
side_effect=OSError("/srv/secret/path.epub is unreadable"),
|
||||
):
|
||||
resp = client.get("/api/localdownload?id=task-1")
|
||||
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json() == {"error": "Could not read the file"}
|
||||
|
||||
|
||||
class TestCancel:
|
||||
def test_unknown_task_is_a_404(self, main_module, client):
|
||||
_login(client, is_admin=True)
|
||||
with _builtin(main_module):
|
||||
with patch.object(main_module.backend.book_queue, "get_task", return_value=None):
|
||||
resp = client.delete("/api/download/nope/cancel")
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert resp.get_json() == {"error": "Failed to cancel download or book not found"}
|
||||
|
||||
def test_admin_can_cancel_anyones_download(self, main_module, client):
|
||||
owner = _create_user(main_module, prefix="owner")
|
||||
_login(client, user_id="root", db_user_id=None, is_admin=True)
|
||||
with _builtin(main_module):
|
||||
with patch.object(
|
||||
main_module.backend.book_queue, "get_task", return_value=_task(user=owner)
|
||||
):
|
||||
with patch.object(
|
||||
main_module.backend, "cancel_download", return_value=True
|
||||
) as cancel:
|
||||
resp = client.delete("/api/download/task-1/cancel")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"status": "cancelled", "book_id": "task-1"}
|
||||
cancel.assert_called_once_with("task-1")
|
||||
|
||||
def test_a_cancel_the_backend_refuses_is_a_404(self, main_module, client):
|
||||
_login(client, is_admin=True)
|
||||
with _builtin(main_module):
|
||||
with patch.object(main_module.backend.book_queue, "get_task", return_value=_task()):
|
||||
with patch.object(main_module.backend, "cancel_download", return_value=False):
|
||||
resp = client.delete("/api/download/task-1/cancel")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_a_user_without_a_database_identity_is_a_403(self, main_module, client):
|
||||
_login(client, user_id="ghost", db_user_id=None)
|
||||
with _builtin(main_module):
|
||||
with patch.object(main_module.backend.book_queue, "get_task", return_value=_task()):
|
||||
resp = client.delete("/api/download/task-1/cancel")
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert resp.get_json() == {
|
||||
"error": "User identity unavailable",
|
||||
"code": "user_identity_unavailable",
|
||||
}
|
||||
|
||||
def test_a_failure_in_the_backend_is_a_500_with_its_message(self, main_module, client):
|
||||
_login(client, is_admin=True)
|
||||
with _builtin(main_module):
|
||||
with patch.object(
|
||||
main_module.backend.book_queue, "get_task", side_effect=RuntimeError("queue broke")
|
||||
):
|
||||
resp = client.delete("/api/download/task-1/cancel")
|
||||
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json() == {"error": "queue broke"}
|
||||
|
||||
def test_the_opaque_id_a_viewer_sees_cancels_the_real_task(self, main_module, client):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
_login(client, user_id=user["username"], db_user_id=user["id"])
|
||||
real_id = "21:https://tracker.example/t/1"
|
||||
viewer_id = main_module.viewer_download_id(real_id, 5)
|
||||
assert viewer_id != real_id
|
||||
task = _task(real_id, user=user, request_id=None)
|
||||
live = MagicMock()
|
||||
live.get_status.return_value = {
|
||||
"queued": {real_id: _task(real_id, user=user, request_id=5)}
|
||||
}
|
||||
with _builtin(main_module):
|
||||
with patch.object(main_module.backend, "book_queue", live):
|
||||
live.get_task.return_value = task
|
||||
with patch.object(main_module, "download_history_service", None):
|
||||
with patch.object(
|
||||
main_module.backend, "cancel_download", return_value=True
|
||||
) as cancel:
|
||||
resp = client.delete(f"/api/download/{viewer_id}/cancel")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"status": "cancelled", "book_id": viewer_id}
|
||||
cancel.assert_called_once_with(real_id)
|
||||
|
||||
|
||||
class TestPriority:
|
||||
def test_a_missing_body_is_a_400(self, main_module, client):
|
||||
_login(client, is_admin=True)
|
||||
with _builtin(main_module):
|
||||
resp = client.put("/api/queue/task-1/priority", json={})
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json() == {"error": "Priority not provided"}
|
||||
|
||||
def test_a_priority_that_is_not_a_number_is_a_400(self, main_module, client):
|
||||
_login(client, is_admin=True)
|
||||
with _builtin(main_module):
|
||||
resp = client.put("/api/queue/task-1/priority", json={"priority": "high"})
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json() == {"error": "Invalid priority value"}
|
||||
|
||||
def test_a_user_without_a_database_identity_is_a_403(self, main_module, client):
|
||||
_login(client, user_id="ghost", db_user_id=None)
|
||||
with _builtin(main_module):
|
||||
resp = client.put("/api/queue/task-1/priority", json={"priority": 1})
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert resp.get_json()["code"] == "user_identity_unavailable"
|
||||
|
||||
def test_unknown_task_is_a_404(self, main_module, client):
|
||||
_login(client, is_admin=True)
|
||||
with _builtin(main_module):
|
||||
with patch.object(main_module.backend.book_queue, "get_task", return_value=None):
|
||||
resp = client.put("/api/queue/nope/priority", json={"priority": 1})
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert resp.get_json() == {"error": "Failed to update priority or book not found"}
|
||||
|
||||
def test_admin_can_set_anyones_priority(self, main_module, client):
|
||||
owner = _create_user(main_module, prefix="owner")
|
||||
_login(client, user_id="root", db_user_id=None, is_admin=True)
|
||||
with _builtin(main_module):
|
||||
with patch.object(
|
||||
main_module.backend.book_queue, "get_task", return_value=_task(user=owner)
|
||||
):
|
||||
with patch.object(
|
||||
main_module.backend, "set_book_priority", return_value=True
|
||||
) as set_priority:
|
||||
resp = client.put("/api/queue/task-1/priority", json={"priority": "3"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"status": "updated", "book_id": "task-1", "priority": 3}
|
||||
set_priority.assert_called_once_with("task-1", 3)
|
||||
|
||||
|
||||
class TestReorder:
|
||||
def test_a_missing_body_is_a_400(self, main_module, client):
|
||||
_login(client, is_admin=True)
|
||||
with _builtin(main_module):
|
||||
resp = client.post("/api/queue/reorder", json={})
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json() == {"error": "book_priorities not provided"}
|
||||
|
||||
def test_priorities_that_are_not_a_dict_are_a_400(self, main_module, client):
|
||||
_login(client, is_admin=True)
|
||||
with _builtin(main_module):
|
||||
resp = client.post("/api/queue/reorder", json={"book_priorities": [1, 2]})
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json() == {"error": "book_priorities must be a dictionary"}
|
||||
|
||||
def test_a_priority_that_is_not_an_integer_names_the_book(self, main_module, client):
|
||||
_login(client, is_admin=True)
|
||||
with _builtin(main_module):
|
||||
resp = client.post("/api/queue/reorder", json={"book_priorities": {"a": "1"}})
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.get_json() == {"error": "Invalid priority for book a"}
|
||||
|
||||
def test_admin_reorders_everything_it_names(self, main_module, client):
|
||||
_login(client, user_id="root", db_user_id=None, is_admin=True)
|
||||
with _builtin(main_module):
|
||||
with patch.object(main_module.backend, "reorder_queue", return_value=True) as reorder:
|
||||
resp = client.post("/api/queue/reorder", json={"book_priorities": {"a": 0, "b": 1}})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"status": "reordered", "updated_count": 2}
|
||||
reorder.assert_called_once_with({"a": 0, "b": 1})
|
||||
|
||||
def test_a_backend_that_refuses_is_a_500(self, main_module, client):
|
||||
_login(client, is_admin=True)
|
||||
with _builtin(main_module):
|
||||
with patch.object(main_module.backend, "reorder_queue", return_value=False):
|
||||
resp = client.post("/api/queue/reorder", json={"book_priorities": {"a": 0}})
|
||||
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json() == {"error": "Failed to reorder queue"}
|
||||
|
||||
def test_a_non_admin_skips_ids_that_name_no_task(self, main_module, client):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
_login(client, user_id=user["username"], db_user_id=user["id"])
|
||||
mine = _task("mine", user=user)
|
||||
with _builtin(main_module):
|
||||
with patch.object(
|
||||
main_module.backend.book_queue,
|
||||
"get_task",
|
||||
side_effect=lambda task_id: {"mine": mine}.get(task_id),
|
||||
):
|
||||
with patch.object(
|
||||
main_module.backend, "reorder_queue", return_value=True
|
||||
) as reorder:
|
||||
resp = client.post(
|
||||
"/api/queue/reorder", json={"book_priorities": {"mine": 0, "gone": 1}}
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"status": "reordered", "updated_count": 1}
|
||||
reorder.assert_called_once_with({"mine": 0})
|
||||
|
||||
|
||||
class TestQueueViews:
|
||||
def test_admin_sees_the_whole_queue_order(self, main_module, client):
|
||||
_login(client, user_id="root", db_user_id=None, is_admin=True)
|
||||
order = [{"id": "a", "priority": 0}, {"id": "b", "priority": 1}]
|
||||
with _builtin(main_module):
|
||||
with patch.object(main_module.backend, "get_queue_order", return_value=order):
|
||||
resp = client.get("/api/queue/order")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"queue": order}
|
||||
|
||||
def test_admin_sees_every_active_download(self, main_module, client):
|
||||
_login(client, user_id="root", db_user_id=None, is_admin=True)
|
||||
with _builtin(main_module):
|
||||
with patch.object(main_module.backend, "get_active_downloads", return_value=["a", "b"]):
|
||||
resp = client.get("/api/downloads/active")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"active_downloads": ["a", "b"]}
|
||||
|
||||
def test_both_views_refuse_a_user_without_a_database_identity(self, main_module, client):
|
||||
_login(client, user_id="ghost", db_user_id=None)
|
||||
with _builtin(main_module):
|
||||
order = client.get("/api/queue/order")
|
||||
active = client.get("/api/downloads/active")
|
||||
|
||||
assert order.status_code == active.status_code == 403
|
||||
assert order.get_json()["code"] == active.get_json()["code"] == "user_identity_unavailable"
|
||||
|
||||
def test_a_failure_is_a_500_with_its_message(self, main_module, client):
|
||||
_login(client, is_admin=True)
|
||||
with _builtin(main_module):
|
||||
with patch.object(
|
||||
main_module.backend, "get_queue_order", side_effect=RuntimeError("order broke")
|
||||
):
|
||||
resp = client.get("/api/queue/order")
|
||||
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json() == {"error": "order broke"}
|
||||
|
||||
|
||||
class TestStatus:
|
||||
def test_a_user_without_a_database_identity_gets_an_empty_object(self, main_module, client):
|
||||
_login(client, user_id="ghost", db_user_id=None)
|
||||
with _builtin(main_module):
|
||||
resp = client.get("/api/status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {}
|
||||
|
||||
def test_delivery_states_are_synced_and_announced_for_the_requests_that_changed(
|
||||
self, main_module, client
|
||||
):
|
||||
_login(client, user_id="root", db_user_id=None, is_admin=True)
|
||||
changed = [
|
||||
{
|
||||
"id": 4,
|
||||
"status": "fulfilled",
|
||||
"delivery_state": "complete",
|
||||
"user_id": 9,
|
||||
"book_data": {"title": "Dune"},
|
||||
}
|
||||
]
|
||||
with _builtin(main_module):
|
||||
with patch.object(main_module.backend, "queue_status", return_value={}):
|
||||
with patch(
|
||||
"shelfmark.core.queue_routes.sync_delivery_states_from_queue_status",
|
||||
return_value=changed,
|
||||
):
|
||||
with patch.object(main_module, "emit_ws_event") as emit:
|
||||
resp = client.get("/api/status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
rooms = {call.kwargs["room"] for call in emit.call_args_list}
|
||||
assert rooms == {"user_9", "admins"}
|
||||
payload = emit.call_args_list[0].kwargs["payload"]
|
||||
assert payload == {
|
||||
"request_id": 4,
|
||||
"status": "fulfilled",
|
||||
"delivery_state": "complete",
|
||||
"title": "Dune",
|
||||
}
|
||||
Reference in new issue
Block a user