Feature/activity history #2

Merged
admin merged 2 commits from feature/activity-history into main 2026-10-25 19:46:27 +01:00
9 changed files with 185 additions and 4 deletions

No files matched your search

+36 -1
View File
@@ -93,7 +93,7 @@ async function refreshMissingBadge() {
// ---- navigation ----
// the view lives in the hash so reload, back button and links keep it
const VIEWS = ["search", "missing", "imported", "import", "settings"];
const VIEWS = ["search", "missing", "imported", "import", "activity", "settings"];
function showView() {
const hash = location.hash.slice(1);
@@ -101,6 +101,7 @@ function showView() {
$$("nav button[data-view]").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
$$("main > section").forEach((s) => (s.hidden = s.id !== "view-" + name));
if (name === "missing" || name === "imported") loadRequests(name);
if (name === "activity") loadHistory();
if (name === "settings") loadLibraries().catch((err) => toast(err.message, true));
}
@@ -622,6 +623,39 @@ $("#imported-search").addEventListener("input", () => renderRequests("imported")
$("#missing-bulk-delete").addEventListener("click", () => bulkDeleteRequests("missing"));
// ---- activity ----
let historyRows = [];
const HISTORY_STATE = { imported: "done", appended: "done", failed: "failed" };
async function loadHistory() {
try {
historyRows = await api("/api/history");
} catch (err) {
toast(err.message, true);
}
renderHistory();
}
function renderHistory() {
const tbody = $("#history-table tbody");
if (!historyRows.length) {
tbody.innerHTML = `<tr><td colspan="4" class="muted">${t("common.nothingHere")}</td></tr>`;
return;
}
const locale = lang === "de" ? "de-DE" : "en-GB";
tbody.innerHTML = historyRows
.map((h) => {
const where = h.message || (h.source && h.dest ? `${h.source} → ${h.dest}` : h.dest || h.source);
return `<tr>
<td class="state">${esc(new Date(h.at).toLocaleString(locale))}</td>
<td class="state ${HISTORY_STATE[h.event] || ""}">${esc(t("history." + h.event))}</td>
<td>${esc(h.title)} <span class="tag fmt">${esc(h.media_type)}</span></td>
<td class="muted mono">${esc(where)}</td>
</tr>`;
})
.join("");
}
// ---- request detail dialog ----
let detailRequest = null;
@@ -1758,6 +1792,7 @@ function onLangChange() {
renderSearchResults();
renderRequests("missing");
renderRequests("imported");
renderHistory();
if (libraries.length) loadLibraries();
const editId = $("#lib-form").dataset.editId;
editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit();
+18
View File
@@ -46,7 +46,16 @@ const STRINGS = {
"nav.imported": "Importiert",
"nav.import": "Import",
"nav.settings": "Libraries",
"nav.activity": "Aktivität",
"nav.langToggle": "Switch to English",
"history.time": "Zeit",
"history.event": "Ereignis",
"history.imported": "importiert",
"history.appended": "ergänzt",
"history.relocated": "verschoben",
"history.retagged": "neu getaggt",
"history.deleted": "gelöscht",
"history.failed": "fehlgeschlagen",
"search.languageTitle": "Sprache der Ausgabe — filtert Ebook- und Audiobook-Suche",
"search.allLanguages": "Alle Sprachen",
@@ -270,7 +279,16 @@ const STRINGS = {
"nav.imported": "Imported",
"nav.import": "Import",
"nav.settings": "Libraries",
"nav.activity": "Activity",
"nav.langToggle": "Auf Deutsch umschalten",
"history.time": "Time",
"history.event": "Event",
"history.imported": "imported",
"history.appended": "appended",
"history.relocated": "relocated",
"history.retagged": "retagged",
"history.deleted": "deleted",
"history.failed": "failed",
"search.languageTitle": "Edition language — filters the ebook and audiobook search",
"search.allLanguages": "All languages",
+17
View File
@@ -20,6 +20,7 @@
</button>
<button data-view="imported" data-i18n="nav.imported"></button>
<button data-view="import" data-i18n="nav.import"></button>
<button data-view="activity" data-i18n="nav.activity"></button>
<button data-view="settings" data-i18n="nav.settings"></button>
<button id="lang-toggle" class="secondary" data-i18n-title="nav.langToggle"></button>
</nav>
@@ -269,6 +270,22 @@
</dialog>
</section>
<section id="view-activity" hidden>
<div class="table-scroll">
<table id="history-table">
<thead>
<tr>
<th data-i18n="history.time"></th>
<th data-i18n="history.event"></th>
<th data-i18n="common.title"></th>
<th data-i18n="common.path"></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<section id="view-settings" hidden>
<h3 data-i18n="lib.heading"></h3>
<div class="table-scroll">
+35
View File
@@ -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")
+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 .. 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:
+8 -1
View File
@@ -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}
+26
View File
@@ -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
+2 -1
View File
@@ -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")