feat(history): record what happens to a request
This commit is contained in:
1 parent
53927f7e11
commit
0c504790df
6 files changed
+114
-3
No files matched your search
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
Reference in new issue
Block a user