diff --git a/tests/test_import_flow.py b/tests/test_import_flow.py index 68e4f4f..7828337 100644 --- a/tests/test_import_flow.py +++ b/tests/test_import_flow.py @@ -1185,3 +1185,38 @@ def test_version_endpoint_reports_a_build_stamp(client): assert data["version"] # "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] == "-" + + +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") diff --git a/wordarr/api/history.py b/wordarr/api/history.py new file mode 100644 index 0000000..6c83085 --- /dev/null +++ b/wordarr/api/history.py @@ -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() diff --git a/wordarr/api/imports.py b/wordarr/api/imports.py index 44e24f1..b6bea36 100644 --- a/wordarr/api/imports.py +++ b/wordarr/api/imports.py @@ -6,7 +6,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from .. import config -from ..db import BookRequest, get_session +from ..db import BookRequest, get_session, record from ..fsnames import decode_path, readable from ..importer import matcher, mover, scanner @@ -91,9 +91,12 @@ def do_import(data: ImportIn, session: Session = Depends(get_session)): try: dest = mover.append_to_import(item.files, req, req.library) except Exception as exc: + record(session, "failed", req, source=shown, message=str(exc)) + session.commit() results.append({"path": shown, "ok": False, "error": str(exc)}) continue req.imported_path = dest + record(session, "appended", req, source=shown, dest=dest) session.commit() results.append({"path": shown, "ok": True, "dest": dest, "appended": True}) 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) except Exception as exc: 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)}) continue req.status = "imported" req.imported_path = dest + record(session, "imported", req, source=shown, dest=dest) session.commit() results.append({"path": shown, "ok": True, "dest": dest}) if not results: diff --git a/wordarr/api/requests.py b/wordarr/api/requests.py index 49e8b9e..73f3eb9 100644 --- a/wordarr/api/requests.py +++ b/wordarr/api/requests.py @@ -6,7 +6,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from .. import config -from ..db import BookRequest, Library, get_session +from ..db import BookRequest, Library, get_session, record from ..importer import mover, tagger 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}") for i, f in enumerate(files, 1): 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)} if path.is_file(): tagger.tag_audio(path, req) + record(session, "retagged", req, dest=str(path)) + session.commit() return {"ok": True, "files": 1} 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: raise HTTPException(409, str(exc)) req.imported_path = dest + if dest != old: + record(session, "relocated", req, source=old, dest=dest) session.commit() 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) if not req: raise HTTPException(404, "request not found") + record(session, "deleted", req, source=req.imported_path) session.delete(req) session.commit() return {"ok": True} diff --git a/wordarr/db.py b/wordarr/db.py index 38bc2c9..b2dc9d9 100644 --- a/wordarr/db.py +++ b/wordarr/db.py @@ -48,6 +48,32 @@ class BookRequest(Base): 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 SessionLocal = None diff --git a/wordarr/main.py b/wordarr/main.py index a1515d7..c9cd3c3 100644 --- a/wordarr/main.py +++ b/wordarr/main.py @@ -5,7 +5,7 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from . import db, version -from .api import fs, imports, libraries, requests, search +from .api import fs, history, imports, libraries, requests, search @asynccontextmanager @@ -21,6 +21,7 @@ app.include_router(search.router) app.include_router(requests.router) app.include_router(imports.router) app.include_router(fs.router) +app.include_router(history.router) @app.get("/api/version")