Merge pull request 'Feature/activity history' (#2) from feature/activity-history into main
Build image / image (push) Successful in 50s
Build image / image (push) Successful in 50s
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
commit
4deaca4f1c
9 files changed
+185
-4
No files matched your search
+36
-1
@@ -93,7 +93,7 @@ async function refreshMissingBadge() {
|
|||||||
|
|
||||||
// ---- navigation ----
|
// ---- navigation ----
|
||||||
// the view lives in the hash so reload, back button and links keep it
|
// 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() {
|
function showView() {
|
||||||
const hash = location.hash.slice(1);
|
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));
|
$$("nav button[data-view]").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
|
||||||
$$("main > section").forEach((s) => (s.hidden = s.id !== "view-" + name));
|
$$("main > section").forEach((s) => (s.hidden = s.id !== "view-" + name));
|
||||||
if (name === "missing" || name === "imported") loadRequests(name);
|
if (name === "missing" || name === "imported") loadRequests(name);
|
||||||
|
if (name === "activity") loadHistory();
|
||||||
if (name === "settings") loadLibraries().catch((err) => toast(err.message, true));
|
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"));
|
$("#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 ----
|
// ---- request detail dialog ----
|
||||||
let detailRequest = null;
|
let detailRequest = null;
|
||||||
|
|
||||||
@@ -1758,6 +1792,7 @@ function onLangChange() {
|
|||||||
renderSearchResults();
|
renderSearchResults();
|
||||||
renderRequests("missing");
|
renderRequests("missing");
|
||||||
renderRequests("imported");
|
renderRequests("imported");
|
||||||
|
renderHistory();
|
||||||
if (libraries.length) loadLibraries();
|
if (libraries.length) loadLibraries();
|
||||||
const editId = $("#lib-form").dataset.editId;
|
const editId = $("#lib-form").dataset.editId;
|
||||||
editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit();
|
editId ? startLibraryEdit(parseInt(editId)) : endLibraryEdit();
|
||||||
|
|||||||
@@ -46,7 +46,16 @@ const STRINGS = {
|
|||||||
"nav.imported": "Importiert",
|
"nav.imported": "Importiert",
|
||||||
"nav.import": "Import",
|
"nav.import": "Import",
|
||||||
"nav.settings": "Libraries",
|
"nav.settings": "Libraries",
|
||||||
|
"nav.activity": "Aktivität",
|
||||||
"nav.langToggle": "Switch to English",
|
"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.languageTitle": "Sprache der Ausgabe — filtert Ebook- und Audiobook-Suche",
|
||||||
"search.allLanguages": "Alle Sprachen",
|
"search.allLanguages": "Alle Sprachen",
|
||||||
@@ -270,7 +279,16 @@ const STRINGS = {
|
|||||||
"nav.imported": "Imported",
|
"nav.imported": "Imported",
|
||||||
"nav.import": "Import",
|
"nav.import": "Import",
|
||||||
"nav.settings": "Libraries",
|
"nav.settings": "Libraries",
|
||||||
|
"nav.activity": "Activity",
|
||||||
"nav.langToggle": "Auf Deutsch umschalten",
|
"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.languageTitle": "Edition language — filters the ebook and audiobook search",
|
||||||
"search.allLanguages": "All languages",
|
"search.allLanguages": "All languages",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button data-view="imported" data-i18n="nav.imported"></button>
|
<button data-view="imported" data-i18n="nav.imported"></button>
|
||||||
<button data-view="import" data-i18n="nav.import"></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 data-view="settings" data-i18n="nav.settings"></button>
|
||||||
<button id="lang-toggle" class="secondary" data-i18n-title="nav.langToggle"></button>
|
<button id="lang-toggle" class="secondary" data-i18n-title="nav.langToggle"></button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -269,6 +270,22 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
</section>
|
</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>
|
<section id="view-settings" hidden>
|
||||||
<h3 data-i18n="lib.heading"></h3>
|
<h3 data-i18n="lib.heading"></h3>
|
||||||
<div class="table-scroll">
|
<div class="table-scroll">
|
||||||
|
|||||||
@@ -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