feat: auth by default, theme toggle, CoderPad startup, review fixes
- Require a session on every route; public routes opt out with @allow_anonymous - Split password hashing and pepper loading into passwords.py - Add a system/light/dark theme toggle backed by light-dark() colors - Ignore stale 401s from an earlier session, PATCH only changed book fields, and block overlapping journal-entry saves - Add bin/start and CoderPad Vite server settings for the pad's start/restart - Rewrite README as a mise onboarding guide; expand .gitignore - Include review-round fixes and tests
This commit is contained in:
+57
-33
@@ -1,13 +1,16 @@
|
||||
"""Books slice: CRUD, progress, search/filter, and the genre list."""
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from typing import TypedDict, cast
|
||||
|
||||
import psycopg2.errors
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
from flask import Blueprint, Response, g, jsonify, request
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
from auth import login_required
|
||||
from db import query, query_one
|
||||
from validation import ApiError, integer, json_body, text
|
||||
from db import Params, Row, query, query_one, query_row
|
||||
from validation import ApiError, JsonObject, integer, json_body, text
|
||||
|
||||
bp = Blueprint("books", __name__, url_prefix="/api")
|
||||
|
||||
@@ -17,7 +20,7 @@ MAX_GENRE_ID = 32_767
|
||||
GENRE_ID_PATTERN = re.compile(r"[0-9]{1,5}")
|
||||
REQUIRED_FIELDS = ("title", "author", "genre_id", "total_pages")
|
||||
FIELDS = set(REQUIRED_FIELDS) | {"current_page"}
|
||||
VALIDATORS = {
|
||||
VALIDATORS: dict[str, Callable[[JsonObject], str | int]] = {
|
||||
"title": lambda body: text(body, "title", MAX_TEXT),
|
||||
"author": lambda body: text(body, "author", MAX_TEXT),
|
||||
"genre_id": lambda body: integer(body, "genre_id", 1, MAX_GENRE_ID),
|
||||
@@ -25,6 +28,22 @@ VALIDATORS = {
|
||||
"current_page": lambda body: integer(body, "current_page", 0, MAX_PAGES),
|
||||
}
|
||||
|
||||
|
||||
class BookFields(TypedDict):
|
||||
title: str
|
||||
author: str
|
||||
genre_id: int
|
||||
total_pages: int
|
||||
current_page: int
|
||||
|
||||
|
||||
class BookRow(BookFields):
|
||||
id: int
|
||||
genre_name: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
BOOK_SELECT = """
|
||||
SELECT b.id, b.title, b.author, b.genre_id, gn.name AS genre_name,
|
||||
b.total_pages, b.current_page, b.created_at, b.updated_at
|
||||
@@ -41,7 +60,7 @@ def reading_status(current_page: int, total_pages: int) -> str:
|
||||
return "reading"
|
||||
|
||||
|
||||
def to_json(row: dict) -> dict:
|
||||
def to_json(row: BookRow) -> dict[str, object]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"title": row["title"],
|
||||
@@ -59,16 +78,19 @@ def escape_like(term: str) -> str:
|
||||
return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def owned_book(book_id: int) -> dict:
|
||||
def owned_book(book_id: int, for_update: bool = False) -> BookRow:
|
||||
row = query_one(
|
||||
BOOK_SELECT + " WHERE b.id = %s AND b.user_id = %s", (book_id, g.user_id)
|
||||
BOOK_SELECT
|
||||
+ " WHERE b.id = %s AND b.user_id = %s"
|
||||
+ (" FOR UPDATE OF b" if for_update else ""),
|
||||
(book_id, g.user_id),
|
||||
)
|
||||
if row is None:
|
||||
raise ApiError(404, "Book not found")
|
||||
return row
|
||||
return cast(BookRow, row)
|
||||
|
||||
|
||||
def _check_progress(book: dict, field: str) -> None:
|
||||
def _check_progress(book: BookFields, field: str) -> None:
|
||||
if book["current_page"] > book["total_pages"]:
|
||||
raise ApiError(
|
||||
400,
|
||||
@@ -78,9 +100,9 @@ def _check_progress(book: dict, field: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _save(sql: str, params: tuple) -> dict | None:
|
||||
def _save(sql: str, params: Params) -> Row:
|
||||
try:
|
||||
return query_one(sql, params)
|
||||
return query_row(sql, params)
|
||||
except psycopg2.errors.ForeignKeyViolation:
|
||||
raise ApiError(
|
||||
400, "Unknown genre_id; see GET /api/genres", "genre_id"
|
||||
@@ -88,16 +110,14 @@ def _save(sql: str, params: tuple) -> dict | None:
|
||||
|
||||
|
||||
@bp.get("/genres")
|
||||
@login_required
|
||||
def list_genres():
|
||||
def list_genres() -> Response:
|
||||
return jsonify(query("SELECT id, name FROM genres ORDER BY name"))
|
||||
|
||||
|
||||
@bp.get("/books")
|
||||
@login_required
|
||||
def list_books():
|
||||
def list_books() -> Response:
|
||||
sql = BOOK_SELECT + " WHERE b.user_id = %s"
|
||||
params: list = [g.user_id]
|
||||
params: list[object] = [g.user_id]
|
||||
search = request.args.get("q", "").strip()
|
||||
if search:
|
||||
if len(search) > MAX_TEXT or "\x00" in search:
|
||||
@@ -116,16 +136,20 @@ def list_books():
|
||||
sql += " AND b.genre_id = %s"
|
||||
params.append(int(genre_id))
|
||||
sql += " ORDER BY b.updated_at DESC, b.id DESC"
|
||||
return jsonify([to_json(row) for row in query(sql, tuple(params))])
|
||||
return jsonify([to_json(cast(BookRow, row)) for row in query(sql, tuple(params))])
|
||||
|
||||
|
||||
@bp.post("/books")
|
||||
@login_required
|
||||
def create_book():
|
||||
def create_book() -> ResponseReturnValue:
|
||||
body = json_body(FIELDS)
|
||||
book = {field: VALIDATORS[field](body) for field in REQUIRED_FIELDS}
|
||||
book["current_page"] = (
|
||||
VALIDATORS["current_page"](body) if "current_page" in body else 0
|
||||
book = cast(
|
||||
BookFields,
|
||||
{field: VALIDATORS[field](body) for field in REQUIRED_FIELDS}
|
||||
| {
|
||||
"current_page": (
|
||||
VALIDATORS["current_page"](body) if "current_page" in body else 0
|
||||
)
|
||||
},
|
||||
)
|
||||
_check_progress(book, "current_page")
|
||||
row = _save(
|
||||
@@ -147,27 +171,28 @@ def create_book():
|
||||
|
||||
|
||||
@bp.get("/books/<int(max=2147483647):book_id>")
|
||||
@login_required
|
||||
def get_book(book_id: int):
|
||||
def get_book(book_id: int) -> Response:
|
||||
return jsonify(to_json(owned_book(book_id)))
|
||||
|
||||
|
||||
@bp.patch("/books/<int(max=2147483647):book_id>")
|
||||
@login_required
|
||||
def update_book(book_id: int):
|
||||
def update_book(book_id: int) -> Response:
|
||||
body = json_body(FIELDS)
|
||||
if not body:
|
||||
raise ApiError(400, f"Provide at least one of: {', '.join(sorted(FIELDS))}")
|
||||
current = owned_book(book_id)
|
||||
book = {field: current[field] for field in FIELDS} | {
|
||||
field: VALIDATORS[field](body) for field in body
|
||||
}
|
||||
current: Row = dict(owned_book(book_id, for_update=True))
|
||||
book = cast(
|
||||
BookFields,
|
||||
{field: current[field] for field in FIELDS}
|
||||
| {field: VALIDATORS[field](body) for field in body},
|
||||
)
|
||||
_check_progress(book, "total_pages" if "total_pages" in body else "current_page")
|
||||
_save(
|
||||
"""
|
||||
UPDATE books
|
||||
SET title = %s, author = %s, genre_id = %s, total_pages = %s, current_page = %s, updated_at = now()
|
||||
WHERE id = %s AND user_id = %s
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
book["title"],
|
||||
@@ -183,8 +208,7 @@ def update_book(book_id: int):
|
||||
|
||||
|
||||
@bp.delete("/books/<int(max=2147483647):book_id>")
|
||||
@login_required
|
||||
def delete_book(book_id: int):
|
||||
def delete_book(book_id: int) -> ResponseReturnValue:
|
||||
if (
|
||||
query_one(
|
||||
"DELETE FROM books WHERE id = %s AND user_id = %s RETURNING id",
|
||||
|
||||
Reference in New Issue
Block a user