mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-15 09:58:37 +00:00
feat(activity): clearer cards, a Rejected view and Reopen for failed deliveries (#1618)
A batch of activity list changes from running it as an admin on my own
instance. They are separate commits that build on each other, so they
can be reviewed in order.
The cards read better. Every card gets a format pill (violet for audio,
orange for ebooks), the requester is named once, and finished cards say
how long a download took and how long ago it ended ("took 5m · 35m
ago"). A download that picked up work already under way shows a
Reattached pill.
Admins can tell what is automatic and what needs a person. A declined
request shows who declined it and when, anything with an `[auto]` note
reads "Declined automatically", and "Needs Review" becomes "No Match
Yet" with a line saying the search repeats on every sync.
There is a Rejected view next to History for admins, with Reopen on each
card and a way to hide the dead ones.
The part to read most carefully is the backend in `requests_service.py`
and `user_db.py`. An admin can now reopen a fulfilled request whose
download failed or was cancelled, and the reopen a failed download
triggers remembers which release failed, so the next search doesn't pick
it again.
I ran it on a scratch instance (when pulling them together to move from
my fork to upstream) as an admin with seeded requests and looked at the
pills, the durations, the wording and the Rejected view in a browser.
Reopening a fulfilled request I only exercised in the tests. The
frontend and Python suites, type check, ruff and basedpyright pass.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->
## Summary by CodeRabbit
* **New Features**
* Admins can review rejected requests in a dedicated view, hide or
restore cards, and reopen requests.
* Fulfilled requests with failed or cancelled deliveries can be
reopened; the failed release is skipped in the next automatic search.
* Activity cards now show format badges, download timing, and when a
download resumes existing work.
* **Improvements**
* Activity views clarify automatic retries and actions, and show
reviewer names for admins.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
be803ed5ca
commit
e575ff0fcb
34 files changed
+1774
-138
No files matched your search
@@ -126,3 +126,16 @@ def test_the_download_payload_leaves_a_missing_cover_alone():
|
||||
)["preview"]
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_the_download_payload_says_when_a_finished_download_ended():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
_user_db, service = _service(tmpdir)
|
||||
_record(service, request_id=None, title="Book")
|
||||
active = DownloadHistoryService.to_download_payload(service.get_by_task_id("release-1"))
|
||||
service.finalize_download(task_id="release-1", final_status="complete", status_message="x")
|
||||
done = DownloadHistoryService.to_download_payload(service.get_by_task_id("release-1"))
|
||||
|
||||
assert active["finished_at"] is None
|
||||
assert isinstance(done["finished_at"], float)
|
||||
assert done["finished_at"] >= done["added_time"]
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for shared request helper normalization utilities."""
|
||||
|
||||
from shelfmark.core.request_helpers import normalize_optional_text
|
||||
from shelfmark.core.request_helpers import normalize_optional_text, populate_request_reviewers
|
||||
|
||||
|
||||
def test_normalize_optional_text_trims_strings():
|
||||
@@ -12,3 +12,27 @@ def test_normalize_optional_text_returns_none_for_empty_or_non_string_values():
|
||||
assert normalize_optional_text(None) is None
|
||||
assert normalize_optional_text(123) is None
|
||||
assert normalize_optional_text(False) is None
|
||||
|
||||
|
||||
class _Users:
|
||||
def __init__(self):
|
||||
self.lookups = 0
|
||||
|
||||
def get_user(self, *, user_id):
|
||||
self.lookups += 1
|
||||
return {1: {"username": "admin@example.com"}}.get(user_id)
|
||||
|
||||
|
||||
def test_populate_request_reviewers_names_the_reviewer_once_per_user():
|
||||
users = _Users()
|
||||
rows = [{"reviewed_by": 1}, {"reviewed_by": 1}, {"reviewed_by": None}, {"reviewed_by": 99}]
|
||||
|
||||
populate_request_reviewers(rows, users)
|
||||
|
||||
assert [row["reviewer_username"] for row in rows] == [
|
||||
"admin@example.com",
|
||||
"admin@example.com",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
assert users.lookups == 2
|
||||
@@ -1393,6 +1393,48 @@ class TestRequestRoutes:
|
||||
}
|
||||
assert f"request:{request_id}" not in hidden_keys
|
||||
|
||||
def test_admin_can_hide_a_rejected_request_and_list_hidden(self, main_module, client):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
admin = _create_user(main_module, prefix="admin", role="admin")
|
||||
created = main_module.user_db.create_request(
|
||||
user_id=user["id"],
|
||||
content_type="ebook",
|
||||
request_level="book",
|
||||
policy_mode="request_book",
|
||||
book_data={"title": "Half a Book", "author": "Someone", "content_type": "ebook"},
|
||||
)
|
||||
request_id = created["id"]
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
|
||||
as_user = client.post(
|
||||
f"/api/admin/requests/{request_id}/rejected-hidden", json={"hidden": True}
|
||||
)
|
||||
_set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True)
|
||||
while_pending = client.post(
|
||||
f"/api/admin/requests/{request_id}/rejected-hidden", json={"hidden": True}
|
||||
)
|
||||
main_module.user_db.update_request(request_id, status="rejected")
|
||||
bad_payload = client.post(
|
||||
f"/api/admin/requests/{request_id}/rejected-hidden", json={"hidden": "yes"}
|
||||
)
|
||||
hide = client.post(
|
||||
f"/api/admin/requests/{request_id}/rejected-hidden", json={"hidden": True}
|
||||
)
|
||||
listed = client.get("/api/admin/requests/rejected-hidden")
|
||||
show = client.post(
|
||||
f"/api/admin/requests/{request_id}/rejected-hidden", json={"hidden": False}
|
||||
)
|
||||
listed_after = client.get("/api/admin/requests/rejected-hidden")
|
||||
|
||||
assert as_user.status_code == 403
|
||||
assert while_pending.status_code == 409
|
||||
assert bad_payload.status_code == 400
|
||||
assert hide.status_code == 200
|
||||
assert request_id in listed.json["request_ids"]
|
||||
assert show.status_code == 200
|
||||
assert request_id not in listed_after.json["request_ids"]
|
||||
|
||||
def test_admin_reject_emits_update_to_user_and_admin_rooms(self, main_module, client):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
admin = _create_user(main_module, prefix="admin", role="admin")
|
||||
|
||||
@@ -2155,3 +2155,112 @@ def test_reopen_rejected_request_returns_none_for_unknown_or_other_states(user_d
|
||||
book_data=_book_data(),
|
||||
)
|
||||
assert user_db.reopen_rejected_request(created["id"]) is None
|
||||
|
||||
|
||||
def test_rejected_request_can_be_hidden_and_shown_again(user_db):
|
||||
_alice, _admin, created = _rejected_request(user_db)
|
||||
|
||||
assert user_db.set_rejected_request_hidden(created["id"], hidden=True) is True
|
||||
assert user_db.set_rejected_request_hidden(created["id"], hidden=True) is True
|
||||
assert user_db.list_hidden_rejected_request_ids() == [created["id"]]
|
||||
|
||||
assert user_db.set_rejected_request_hidden(created["id"], hidden=False) is True
|
||||
assert user_db.list_hidden_rejected_request_ids() == []
|
||||
|
||||
|
||||
def test_only_a_rejected_request_can_be_hidden(user_db):
|
||||
alice = user_db.create_user(username="alice")
|
||||
created = create_request(
|
||||
user_db,
|
||||
user_id=alice["id"],
|
||||
source_hint="prowlarr",
|
||||
content_type="ebook",
|
||||
request_level="book",
|
||||
policy_mode="request_book",
|
||||
book_data=_book_data(),
|
||||
)
|
||||
|
||||
assert user_db.set_rejected_request_hidden(created["id"], hidden=True) is False
|
||||
assert user_db.list_hidden_rejected_request_ids() == []
|
||||
|
||||
|
||||
def test_reopen_clears_hidden_so_a_later_rejection_starts_visible(user_db):
|
||||
_alice, admin, created = _rejected_request(user_db)
|
||||
user_db.set_rejected_request_hidden(created["id"], hidden=True)
|
||||
|
||||
reopen_request(user_db, request_id=created["id"], admin_user_id=admin["id"])
|
||||
reject_request(user_db, request_id=created["id"], admin_user_id=admin["id"])
|
||||
|
||||
assert user_db.list_hidden_rejected_request_ids() == []
|
||||
|
||||
|
||||
def _fulfilled_request(user_db, *, delivery_state):
|
||||
alice = user_db.create_user(username="alice")
|
||||
admin = user_db.create_user(username="admin", role="admin")
|
||||
created = create_request(
|
||||
user_db,
|
||||
user_id=alice["id"],
|
||||
source_hint="prowlarr",
|
||||
content_type="audiobook",
|
||||
request_level="release",
|
||||
policy_mode="request_release",
|
||||
book_data=_book_data(),
|
||||
release_data=_release_data(),
|
||||
)
|
||||
fulfil_request(
|
||||
user_db,
|
||||
request_id=created["id"],
|
||||
admin_user_id=admin["id"],
|
||||
queue_release=lambda *a, **kw: (True, None),
|
||||
)
|
||||
user_db.update_request(created["id"], delivery_state=delivery_state)
|
||||
return created["id"], admin
|
||||
|
||||
|
||||
@pytest.mark.parametrize("delivery_state", ["cancelled", "error"])
|
||||
def test_reopen_request_reopens_a_fulfilled_request_whose_download_failed(user_db, delivery_state):
|
||||
request_id, admin = _fulfilled_request(user_db, delivery_state=delivery_state)
|
||||
|
||||
reopened = reopen_request(
|
||||
user_db, request_id=request_id, admin_user_id=admin["id"], admin_note="[auto] retry"
|
||||
)
|
||||
|
||||
assert reopened["status"] == "pending"
|
||||
assert reopened["delivery_state"] == "none"
|
||||
assert reopened["release_data"] is None
|
||||
assert reopened["admin_note"] == "[auto] retry"
|
||||
assert reopened["last_failure_reason"] == "[auto] retry"
|
||||
# The next automatic search skips the release that failed, as when the queue reopens it.
|
||||
assert reopened["book_data"]["failed_releases"] == [
|
||||
{"source": "prowlarr", "source_id": "release-123"}
|
||||
]
|
||||
|
||||
|
||||
def test_reopen_request_writes_the_note_in_the_reopening_write(user_db, monkeypatch):
|
||||
"""A failed-download reopen never lands without its note: there is no second update."""
|
||||
request_id, admin = _fulfilled_request(user_db, delivery_state="error")
|
||||
|
||||
def no_second_write(*_args, **_kwargs):
|
||||
msg = "admin_note must be written by reopen_failed_request"
|
||||
raise AssertionError(msg)
|
||||
|
||||
monkeypatch.setattr(user_db, "update_request", no_second_write)
|
||||
reopened = reopen_request(
|
||||
user_db, request_id=request_id, admin_user_id=admin["id"], admin_note="Try again"
|
||||
)
|
||||
|
||||
assert reopened["status"] == "pending"
|
||||
assert reopened["admin_note"] == "Try again"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("delivery_state", ["complete", "queued"])
|
||||
def test_reopen_request_refuses_a_fulfilled_request_that_is_delivered_or_in_flight(
|
||||
user_db, delivery_state
|
||||
):
|
||||
request_id, admin = _fulfilled_request(user_db, delivery_state=delivery_state)
|
||||
|
||||
with pytest.raises(RequestServiceError) as exc_info:
|
||||
reopen_request(user_db, request_id=request_id, admin_user_id=admin["id"])
|
||||
|
||||
assert exc_info.value.code == "stale_transition"
|
||||
assert user_db.get_request(request_id)["status"] == "fulfilled"
|
||||
@@ -1028,6 +1028,29 @@ class TestDownloadRequests:
|
||||
assert reopened["status"] == "pending"
|
||||
assert reopened["last_failure_reason"] is None
|
||||
|
||||
def test_reopen_failed_request_writes_the_admin_note_and_keeps_it_when_not_given(self, user_db):
|
||||
user = user_db.create_user(username="alice")
|
||||
created = user_db.create_request(
|
||||
user_id=user["id"],
|
||||
content_type="ebook",
|
||||
request_level="release",
|
||||
policy_mode="request_release",
|
||||
book_data=self._book_data(),
|
||||
release_data=self._release_data(),
|
||||
status="fulfilled",
|
||||
delivery_state="error",
|
||||
admin_note="Approved from browse mode",
|
||||
)
|
||||
|
||||
kept = user_db.reopen_failed_request(created["id"])
|
||||
assert kept is not None
|
||||
assert kept["admin_note"] == "Approved from browse mode"
|
||||
|
||||
user_db.update_request(created["id"], status="fulfilled", delivery_state="error")
|
||||
noted = user_db.reopen_failed_request(created["id"], admin_note=" Try again ")
|
||||
assert noted is not None
|
||||
assert noted["admin_note"] == "Try again"
|
||||
|
||||
def test_count_pending_requests(self, user_db):
|
||||
alice = user_db.create_user(username="alice")
|
||||
bob = user_db.create_user(username="bob")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""A live queue entry says when it ended, so a card has its timing before history catches up."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from shelfmark.core.models import DownloadTask, QueueStatus
|
||||
from shelfmark.core.queue import BookQueue
|
||||
from shelfmark.download.orchestrator import _task_to_dict
|
||||
|
||||
|
||||
def _finished_task(status: QueueStatus) -> tuple[BookQueue, DownloadTask]:
|
||||
"""A task driven to the given terminal status through the queue."""
|
||||
queue = BookQueue()
|
||||
task = DownloadTask(task_id="t", source="direct_download", title="Book")
|
||||
assert queue.add(task) is True
|
||||
assert queue.get_next() is not None
|
||||
queue.update_status("t", QueueStatus.DOWNLOADING)
|
||||
queue.update_status("t", status)
|
||||
return queue, task
|
||||
|
||||
|
||||
def test_a_task_is_stamped_when_it_first_ends() -> None:
|
||||
_queue, task = _finished_task(QueueStatus.COMPLETE)
|
||||
|
||||
assert task.finished_at is not None
|
||||
assert task.finished_at >= task.added_time
|
||||
|
||||
|
||||
def test_the_stamp_is_set_before_the_terminal_hook_runs() -> None:
|
||||
queue = BookQueue()
|
||||
task = DownloadTask(task_id="t", source="direct_download", title="Book")
|
||||
seen: list[float | None] = []
|
||||
queue.set_terminal_status_hook(lambda _id, _status, hooked: seen.append(hooked.finished_at))
|
||||
assert queue.add(task) is True
|
||||
assert queue.get_next() is not None
|
||||
|
||||
queue.update_status("t", QueueStatus.ERROR)
|
||||
|
||||
assert seen and seen[0] is not None
|
||||
|
||||
|
||||
def test_a_requeued_task_is_running_again_so_it_has_no_end() -> None:
|
||||
queue, task = _finished_task(QueueStatus.ERROR)
|
||||
|
||||
assert queue.enqueue_existing("t") is True
|
||||
|
||||
assert task.finished_at is None
|
||||
|
||||
|
||||
def test_the_stamp_reaches_the_status_payload() -> None:
|
||||
_queue, task = _finished_task(QueueStatus.CANCELLED)
|
||||
|
||||
assert (
|
||||
_task_to_dict(task, current_status=QueueStatus.CANCELLED)["finished_at"] == task.finished_at
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""A download that picks up work already under way says so, so nobody wonders why it started at 40%."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.core.models import DownloadTask, QueueStatus
|
||||
from shelfmark.download.clients import DownloadState, DownloadStatus
|
||||
from shelfmark.download.orchestrator import _task_to_dict
|
||||
from shelfmark.release_sources.prowlarr.handler import ProwlarrHandler
|
||||
|
||||
_STATUS = DownloadStatus(
|
||||
progress=40.0, state=DownloadState.DOWNLOADING, message=None, complete=False, file_path=None
|
||||
)
|
||||
_DONE = DownloadStatus(
|
||||
progress=100.0, state=DownloadState.COMPLETE, message=None, complete=True, file_path="/x/b"
|
||||
)
|
||||
|
||||
|
||||
def _run(*, existing: bool, finished: bool = False) -> DownloadTask:
|
||||
"""Run one Prowlarr download against a mocked client that may already hold the torrent.
|
||||
|
||||
With ``finished`` the client's copy is already complete, so the handler copies the file
|
||||
instead of joining the poll loop.
|
||||
"""
|
||||
client = MagicMock()
|
||||
client.name = "qbittorrent"
|
||||
found = ("dl", _DONE if finished else _STATUS) if existing else None
|
||||
client.find_existing.return_value = found
|
||||
client.add_download.return_value = "dl"
|
||||
cancel = Event()
|
||||
|
||||
def get_status(_id: str) -> DownloadStatus:
|
||||
cancel.set()
|
||||
return _STATUS
|
||||
|
||||
client.get_status.side_effect = get_status
|
||||
release = {"protocol": "torrent", "magnetUrl": "magnet:?xt=urn:btih:abc123"}
|
||||
task = DownloadTask(task_id="t", source="prowlarr", title="Book")
|
||||
with (
|
||||
patch("shelfmark.release_sources.prowlarr.handler.get_release", return_value=release),
|
||||
patch("shelfmark.release_sources.prowlarr.handler.get_client", return_value=client),
|
||||
patch("shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL", 0.01),
|
||||
patch.object(
|
||||
ProwlarrHandler, "_wait_for_completed_path", return_value=(Path("/x/b"), None)
|
||||
),
|
||||
patch.object(ProwlarrHandler, "_handle_completed_file", return_value="/lib/b"),
|
||||
):
|
||||
ProwlarrHandler().download(
|
||||
task=task,
|
||||
cancel_flag=cancel,
|
||||
progress_callback=lambda _p: None,
|
||||
status_callback=lambda *_a: None,
|
||||
)
|
||||
return task
|
||||
|
||||
|
||||
def test_a_task_that_joins_a_torrent_the_client_had_is_reattached() -> None:
|
||||
assert _run(existing=True).reattached is True
|
||||
|
||||
|
||||
def test_a_task_whose_torrent_the_client_had_finished_is_reattached_too() -> None:
|
||||
assert _run(existing=True, finished=True).reattached is True
|
||||
|
||||
|
||||
def test_a_task_that_adds_its_own_torrent_is_not() -> None:
|
||||
assert _run(existing=False).reattached is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", [True, False])
|
||||
def test_the_flag_reaches_the_status_payload(flag: bool) -> None:
|
||||
task = DownloadTask(task_id="t", source="prowlarr", title="Book", reattached=flag)
|
||||
|
||||
assert _task_to_dict(task, current_status=QueueStatus.DOWNLOADING)["reattached"] is flag
|
||||
Reference in new issue
Block a user