refactor: address /code-review on R11 feedback symmetry

- admin._decide: add an idempotency guard (409 unless status was 'pending') so a
  repeat/stale approve OR deny can't re-run its side effects — now that deny emails,
  this stops duplicate denial mails and a rejection mail to an already-approved user.
- SavedViewsWidget: extract runViewMutation(fn, failMsg) (returns success) — dedups
  the five try/catch+refresh blocks AND lets create/rename close their input only on
  success, so a failed create keeps the user's typed name.
- worker._notify_download_done: take user_id/title captured BEFORE the caller's commit
  (no post-commit refresh SELECT); add a  so the inbox labels an edit job
  'Edit ready' instead of 'Download ready' (new inbox.editDone i18n, HU+EN).

Gate green (ruff/tsc/eslint-0/prettier/knip/vitest 79/pytest 193); smoke-tested the
saved-views + notifications surfaces render clean on a fresh dev server.
This commit is contained in:
2026-08-11 20:25:18 +02:00
parent e2fe6fa286
commit 359bac149b
6 changed files with 72 additions and 49 deletions
+21 -12
View File
@@ -40,24 +40,28 @@ from app.titles import normalize_title
log = logging.getLogger("siftlode.worker")
def _notify_download_done(db, job: DownloadJob | None, title: str | None) -> None:
"""Post a best-effort 'download ready' notice to the owner's inbox — downloads used to slide to
Done silently while scheduler jobs and playlist-push already notified (R11 feedback symmetry).
A notification failure must never mask the completed download, so it is isolated + rolled back
(mirrors scheduler._notify_done). English title is a fallback; the inbox localizes from `data`."""
if not job:
def _notify_download_done(
db, user_id: int | None, title: str | None, job_id, *, kind: str
) -> None:
"""Post a best-effort 'ready' notice to the owner's inbox — downloads/edits used to finish
silently while scheduler jobs and playlist-push already notified (R11 feedback symmetry). A
notification failure must never mask the completed job, so it is isolated + rolled back (mirrors
scheduler._notify_done). `kind` ("download"/"edit") lets the inbox pick accurate copy; the
English title here is only a fallback for an unknown type. `user_id`/`title` are captured by the
caller BEFORE its commit, so reading them here needs no post-commit refresh of an expired row."""
if user_id is None:
return
try:
create_notification(
db,
user_id=job.user_id,
user_id=user_id,
type="download_done",
title=f"Download ready: {title}" if title else "Download ready",
data={"title": title},
title=f"Ready to watch: {title}" if title else "Ready to watch",
data={"title": title, "kind": kind},
)
except Exception:
db.rollback()
log.exception("failed to write download-done notification for job %s", job.id)
log.exception("failed to write download-done notification for job %s", job_id)
_stop = threading.Event()
@@ -657,8 +661,10 @@ def _download(job_id: int, asset_id: int, source_kind: str, source_ref: str, spe
if not job.display_name:
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
job.display_name = meta.title[:255] if meta.title else None
# Capture the owner before commit expires the row (avoids a refresh SELECT just to notify).
owner_id = job.user_id if job else None
db.commit()
_notify_download_done(db, job, meta.title)
_notify_download_done(db, owner_id, meta.title, job_id, kind="download")
log.info("job %s done: %s", job_id, rel)
except (_Aborted, editmod.EditAborted):
@@ -790,8 +796,11 @@ def _process_edit(job_id: int, asset_id: int) -> None:
job.status = "done"
job.progress = 100
job.phase = None
# Capture owner + title before commit expires the rows (avoids a refresh SELECT).
owner_id = job.user_id if job else None
dl_title = asset.title if asset else None
db.commit()
_notify_download_done(db, job, asset.title if asset else None)
_notify_download_done(db, owner_id, dl_title, job_id, kind="edit")
log.info("edit job %s done: %s", job_id, rel)
except editmod.EditAborted: