chore: beta release v0.5.0-beta
- Bump version to 0.5.0-beta - Add Apache-2.0 LICENSE (official text) + NOTICE - Add Docker support: multi-stage Dockerfile + docker-compose * non-root user, tini PID 1, gunicorn + uvicorn workers * healthcheck, OCI labels, persistent /data volume - Add GitHub Actions CI (lint/import-check + Docker smoke-test) - Backend polish: * modern lifespan handler (replaces deprecated on_event) * explicit sort validation (400 instead of 500) * title min_length=1 schema validation * configurable data dir + http timeout via env * avg_rating helper extracted (DRY) - Frontend polish: * beta badge in title, footer with live version * loading spinner on initial fetch * updated README, badges, config table, roadmap
This commit is contained in:
+76
-46
@@ -1,65 +1,87 @@
|
||||
"""FastAPI-App: REST-API + statisches Frontend."""
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import crud, schemas
|
||||
from app import __version__, crud, schemas
|
||||
from app.database import get_db, init_db
|
||||
from app.external import lookup_book_by_isbn, lookup_book_by_title
|
||||
from app.seed import seed_if_empty
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
log = logging.getLogger("watchstack")
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
STATIC_DIR = BASE_DIR / "static"
|
||||
|
||||
app = FastAPI(
|
||||
title="WatchStack",
|
||||
description="Watchlist für Bücher, Serien & mehr.",
|
||||
version="0.1.0",
|
||||
)
|
||||
__all__ = ["app"]
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def _startup() -> None:
|
||||
# ---------- Lifespan (init DB + seed on boot) -------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
init_db()
|
||||
with next(get_db()) as db:
|
||||
seed_if_empty(db)
|
||||
log.info("WatchStack %s ready", __version__)
|
||||
yield
|
||||
|
||||
|
||||
# ---------- Statische Dateien & UI ------------------------------------------
|
||||
app = FastAPI(
|
||||
title="WatchStack",
|
||||
description="Watchlist für Bücher, Serien & mehr.",
|
||||
version=__version__,
|
||||
lifespan=lifespan,
|
||||
contact={"name": "WatchStack", "url": "https://git.pkop.de/Vibecode/watchlist"},
|
||||
license_info={"name": "Apache-2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0"},
|
||||
)
|
||||
|
||||
# CORS — offen genug für lokale Beta, in Produktion via env einschränken
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
|
||||
|
||||
# ---------- UI ---------------------------------------------------------------
|
||||
|
||||
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||||
def root(request: Request) -> HTMLResponse:
|
||||
index = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||
return HTMLResponse(index)
|
||||
def root() -> HTMLResponse:
|
||||
return HTMLResponse((STATIC_DIR / "index.html").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
# ---------- Health -----------------------------------------------------------
|
||||
# ---------- Health / Version -------------------------------------------------
|
||||
|
||||
@app.get("/api/health", tags=["meta"])
|
||||
def health() -> dict:
|
||||
return {"status": "ok", "service": "watchstack", "version": app.version}
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok", "service": "watchstack", "version": __version__}
|
||||
|
||||
|
||||
# ---------- Metadaten-Lookup -------------------------------------------------
|
||||
# ---------- Lookups ----------------------------------------------------------
|
||||
|
||||
@app.get("/api/lookup/book", tags=["lookup"])
|
||||
def lookup_book(
|
||||
isbn: Optional[str] = Query(None, description="ISBN-10 oder ISBN-13"),
|
||||
title: Optional[str] = Query(None),
|
||||
) -> dict:
|
||||
isbn: str | None = Query(None, description="ISBN-10 oder ISBN-13"),
|
||||
title: str | None = Query(None),
|
||||
) -> dict[str, Any]:
|
||||
"""Schlägt Buchdaten extern nach. ISBN hat Vorrang."""
|
||||
if isbn:
|
||||
result = lookup_book_by_isbn(isbn)
|
||||
@@ -72,20 +94,40 @@ def lookup_book(
|
||||
return result
|
||||
|
||||
|
||||
# ---------- Sortierung -------------------------------------------------------
|
||||
|
||||
_SORT_KEYS = {
|
||||
"updated_desc", "updated_asc",
|
||||
"title_asc", "title_desc",
|
||||
"rating_desc", "rating_asc",
|
||||
"year_desc", "year_asc",
|
||||
}
|
||||
|
||||
|
||||
# ---------- Media ------------------------------------------------------------
|
||||
|
||||
def _get_or_404(db: Session, media_id: int):
|
||||
"""Holt einen Eintrag oder wirft 404."""
|
||||
m = crud.get_media(db, media_id)
|
||||
if not m:
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
return m
|
||||
|
||||
|
||||
@app.get("/api/media", response_model=list[schemas.MediaOut], tags=["media"])
|
||||
def api_list_media(
|
||||
kind: Optional[str] = Query(None, pattern="^(book|series)$"),
|
||||
status: Optional[str] = Query(None, pattern="^(plan|reading|done|hold|dropped)$"),
|
||||
search: Optional[str] = Query(None),
|
||||
genre: Optional[str] = Query(None),
|
||||
tag: Optional[str] = Query(None),
|
||||
kind: str | None = Query(None, pattern="^(book|series)$"),
|
||||
status: str | None = Query(None, pattern="^(plan|reading|done|hold|dropped)$"),
|
||||
search: str | None = Query(None, max_length=200),
|
||||
genre: str | None = Query(None, max_length=60),
|
||||
tag: str | None = Query(None, max_length=40),
|
||||
sort: str = Query("updated_desc"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
items = crud.list_media(db, kind=kind, status=status, search=search, genre=genre, tag=tag, sort=sort)
|
||||
return items
|
||||
if sort not in _SORT_KEYS:
|
||||
raise HTTPException(400, f"sort muss eines von {sorted(_SORT_KEYS)} sein")
|
||||
return crud.list_media(db, kind=kind, status=status, search=search,
|
||||
genre=genre, tag=tag, sort=sort)
|
||||
|
||||
|
||||
@app.post("/api/media", response_model=schemas.MediaOut, status_code=201, tags=["media"])
|
||||
@@ -95,10 +137,7 @@ def api_create_media(payload: schemas.MediaCreate, db: Session = Depends(get_db)
|
||||
|
||||
@app.get("/api/media/{media_id}", response_model=schemas.MediaOut, tags=["media"])
|
||||
def api_get_media(media_id: int, db: Session = Depends(get_db)):
|
||||
m = crud.get_media(db, media_id)
|
||||
if not m:
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
return m
|
||||
return _get_or_404(db, media_id)
|
||||
|
||||
|
||||
@app.patch("/api/media/{media_id}", response_model=schemas.MediaOut, tags=["media"])
|
||||
@@ -107,18 +146,12 @@ def api_update_media(
|
||||
payload: schemas.MediaUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
m = crud.get_media(db, media_id)
|
||||
if not m:
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
return crud.update_media(db, m, payload)
|
||||
return crud.update_media(db, _get_or_404(db, media_id), payload)
|
||||
|
||||
|
||||
@app.delete("/api/media/{media_id}", status_code=204, tags=["media"])
|
||||
def api_delete_media(media_id: int, db: Session = Depends(get_db)):
|
||||
m = crud.get_media(db, media_id)
|
||||
if not m:
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
crud.delete_media(db, m)
|
||||
crud.delete_media(db, _get_or_404(db, media_id))
|
||||
return None
|
||||
|
||||
|
||||
@@ -128,10 +161,7 @@ def api_progress(
|
||||
payload: schemas.ProgressUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
m = crud.get_media(db, media_id)
|
||||
if not m:
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
return crud.bump_progress(db, m, payload)
|
||||
return crud.bump_progress(db, _get_or_404(db, media_id), payload)
|
||||
|
||||
|
||||
# ---------- Taxonomien -------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user