feat(history): record what happens to a request

This commit is contained in:
Steppenstreuner committed 2026-10-25 19:21:56 +01:00
1 parent 53927f7e11
commit 0c504790df
6 files changed
+114 -3

No files matched your search

+35
View File
@@ -1185,3 +1185,38 @@ def test_version_endpoint_reports_a_build_stamp(client):
assert data["version"] assert data["version"]
# "2026-09-01 15:43" - the newest source file, so a stale deploy stands out # "2026-09-01 15:43" - the newest source file, so a stale deploy stands out
assert len(data["built_at"]) == 16 and data["built_at"][4] == "-" assert len(data["built_at"]) == 16 and data["built_at"][4] == "-"
def test_history_records_what_happened_to_a_request(client):
root = client.tmp_path / "library" / "hist"
lib_id = client.post("/api/libraries", json={
"name": "hist", "media_type": "ebook", "root_path": str(root),
"folder_template": "{Title}", "file_template": "{Title}",
}).json()["id"]
req_id = client.post("/api/requests", json={
"library_id": lib_id, "title": "Momo"}).json()["id"]
gone = client.downloads / "gone.epub"
res = client.post("/api/import", json={"items": [{
"path": str(gone), "is_dir": False, "files": [str(gone)], "request_id": req_id,
}]}).json()["results"][0]
assert not res["ok"]
src = client.downloads / "Momo.epub"
src.write_bytes(b"x")
dest = client.post("/api/import", json={"items": [{
"path": str(src), "is_dir": False, "files": [str(src)], "request_id": req_id,
}]}).json()["results"][0]["dest"]
client.put(f"/api/requests/{req_id}", json={"library_id": lib_id, "title": "Momo 2"})
assert client.post(f"/api/requests/{req_id}/relocate").json()["moved"]
# a relocate that moves nothing is not worth a row
client.post(f"/api/requests/{req_id}/relocate")
client.delete(f"/api/requests/{req_id}")
rows = client.get("/api/history").json()
assert [r["event"] for r in rows] == ["deleted", "relocated", "imported", "failed"]
assert all(r["request_id"] == req_id for r in rows)
assert rows[0]["title"] == "Momo 2" # survives the deleted request
assert rows[2]["dest"] == dest
assert rows[3]["message"]
assert rows[0]["at"].endswith("+00:00")
+36
View File
@@ -0,0 +1,36 @@
import datetime
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict, field_serializer
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..db import History, get_session
router = APIRouter(prefix="/api/history", tags=["history"])
class HistoryOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
at: datetime.datetime
event: str
request_id: int | None
title: str
media_type: str
source: str
dest: str
message: str
# SQLite drops the zone, and a bare timestamp reads as local time in JS
@field_serializer("at")
def _utc(self, at: datetime.datetime) -> str:
return at.replace(tzinfo=datetime.UTC).isoformat()
@router.get("", response_model=list[HistoryOut])
def list_history(limit: int = 200, session: Session = Depends(get_session)):
"""Newest first."""
stmt = select(History).order_by(History.id.desc()).limit(min(limit, 1000))
return session.scalars(stmt).all()
+7 -1
View File
@@ -6,7 +6,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import config from .. import config
from ..db import BookRequest, get_session from ..db import BookRequest, get_session, record
from ..fsnames import decode_path, readable from ..fsnames import decode_path, readable
from ..importer import matcher, mover, scanner from ..importer import matcher, mover, scanner
@@ -91,9 +91,12 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
try: try:
dest = mover.append_to_import(item.files, req, req.library) dest = mover.append_to_import(item.files, req, req.library)
except Exception as exc: except Exception as exc:
record(session, "failed", req, source=shown, message=str(exc))
session.commit()
results.append({"path": shown, "ok": False, "error": str(exc)}) results.append({"path": shown, "ok": False, "error": str(exc)})
continue continue
req.imported_path = dest req.imported_path = dest
record(session, "appended", req, source=shown, dest=dest)
session.commit() session.commit()
results.append({"path": shown, "ok": True, "dest": dest, "appended": True}) results.append({"path": shown, "ok": True, "dest": dest, "appended": True})
continue continue
@@ -111,10 +114,13 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)):
dest = mover.import_item(item.path, item.files, item.is_dir, req, req.library) dest = mover.import_item(item.path, item.files, item.is_dir, req, req.library)
except Exception as exc: except Exception as exc:
session.rollback() # the next commit must not keep the folder's series session.rollback() # the next commit must not keep the folder's series
record(session, "failed", req, source=shown, message=str(exc))
session.commit()
results.append({"path": shown, "ok": False, "error": str(exc)}) results.append({"path": shown, "ok": False, "error": str(exc)})
continue continue
req.status = "imported" req.status = "imported"
req.imported_path = dest req.imported_path = dest
record(session, "imported", req, source=shown, dest=dest)
session.commit() session.commit()
results.append({"path": shown, "ok": True, "dest": dest}) results.append({"path": shown, "ok": True, "dest": dest})
if not results: if not results:
+8 -1
View File
@@ -6,7 +6,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .. import config from .. import config
from ..db import BookRequest, Library, get_session from ..db import BookRequest, Library, get_session, record
from ..importer import mover, tagger from ..importer import mover, tagger
router = APIRouter(prefix="/api/requests", tags=["requests"]) router = APIRouter(prefix="/api/requests", tags=["requests"])
@@ -213,9 +213,13 @@ def retag_request(request_id: int, session: Session = Depends(get_session)):
raise HTTPException(404, f"no audio files in {path}") raise HTTPException(404, f"no audio files in {path}")
for i, f in enumerate(files, 1): for i, f in enumerate(files, 1):
tagger.tag_audio(f, req, track=i, total=len(files)) tagger.tag_audio(f, req, track=i, total=len(files))
record(session, "retagged", req, dest=str(path))
session.commit()
return {"ok": True, "files": len(files)} return {"ok": True, "files": len(files)}
if path.is_file(): if path.is_file():
tagger.tag_audio(path, req) tagger.tag_audio(path, req)
record(session, "retagged", req, dest=str(path))
session.commit()
return {"ok": True, "files": 1} return {"ok": True, "files": 1}
raise HTTPException(404, f"imported path no longer exists: {path}") raise HTTPException(404, f"imported path no longer exists: {path}")
@@ -237,6 +241,8 @@ def relocate_request(request_id: int, session: Session = Depends(get_session)):
except FileExistsError as exc: except FileExistsError as exc:
raise HTTPException(409, str(exc)) raise HTTPException(409, str(exc))
req.imported_path = dest req.imported_path = dest
if dest != old:
record(session, "relocated", req, source=old, dest=dest)
session.commit() session.commit()
return {"ok": True, "moved": dest != old, "from": old, "dest": dest} return {"ok": True, "moved": dest != old, "from": old, "dest": dest}
@@ -246,6 +252,7 @@ def delete_request(request_id: int, session: Session = Depends(get_session)):
req = session.get(BookRequest, request_id) req = session.get(BookRequest, request_id)
if not req: if not req:
raise HTTPException(404, "request not found") raise HTTPException(404, "request not found")
record(session, "deleted", req, source=req.imported_path)
session.delete(req) session.delete(req)
session.commit() session.commit()
return {"ok": True} return {"ok": True}
+26
View File
@@ -48,6 +48,32 @@ class BookRequest(Base):
library: Mapped[Library] = relationship(back_populates="requests") library: Mapped[Library] = relationship(back_populates="requests")
class History(Base):
"""One row per thing done to a request. Title and type are copied, and
there is no foreign key, so an entry outlives a deleted request."""
__tablename__ = "history"
id: Mapped[int] = mapped_column(primary_key=True)
at: Mapped[datetime.datetime] = mapped_column(
default=lambda: datetime.datetime.now(datetime.UTC)
)
# imported | appended | relocated | retagged | deleted | failed
event: Mapped[str] = mapped_column(String)
request_id: Mapped[int | None] = mapped_column(default=None)
title: Mapped[str] = mapped_column(String)
media_type: Mapped[str] = mapped_column(String)
source: Mapped[str] = mapped_column(String, default="")
dest: Mapped[str] = mapped_column(String, default="")
message: Mapped[str] = mapped_column(String, default="")
def record(session, event: str, req: BookRequest, **fields: str) -> None:
"""Add a history row; it is saved with the caller's next commit."""
session.add(History(event=event, request_id=req.id, title=req.title,
media_type=req.media_type, **fields))
_engine = None _engine = None
SessionLocal = None SessionLocal = None
+2 -1
View File
@@ -5,7 +5,7 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from . import db, version from . import db, version
from .api import fs, imports, libraries, requests, search from .api import fs, history, imports, libraries, requests, search
@asynccontextmanager @asynccontextmanager
@@ -21,6 +21,7 @@ app.include_router(search.router)
app.include_router(requests.router) app.include_router(requests.router)
app.include_router(imports.router) app.include_router(imports.router)
app.include_router(fs.router) app.include_router(fs.router)
app.include_router(history.router)
@app.get("/api/version") @app.get("/api/version")