"""Books slice: CRUD, progress, search/filter, and the genre list.""" import re import psycopg2.errors from flask import Blueprint, g, jsonify, request from auth import login_required from db import query, query_one from validation import ApiError, integer, json_body, text bp = Blueprint("books", __name__, url_prefix="/api") MAX_TEXT = 500 MAX_PAGES = 100_000 MAX_GENRE_ID = 32_767 # SMALLINT 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 = { "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), "total_pages": lambda body: integer(body, "total_pages", 1, MAX_PAGES), "current_page": lambda body: integer(body, "current_page", 0, MAX_PAGES), } 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 FROM books b JOIN genres gn ON gn.id = b.genre_id """ def reading_status(current_page: int, total_pages: int) -> str: if current_page == 0: return "not_started" if current_page == total_pages: return "finished" return "reading" def to_json(row: dict) -> dict: return { "id": row["id"], "title": row["title"], "author": row["author"], "genre": {"id": row["genre_id"], "name": row["genre_name"]}, "total_pages": row["total_pages"], "current_page": row["current_page"], "status": reading_status(row["current_page"], row["total_pages"]), "created_at": row["created_at"].isoformat(), "updated_at": row["updated_at"].isoformat(), } def escape_like(term: str) -> str: # Backslash is Postgres's default LIKE escape character. return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") def owned_book(book_id: int) -> dict: row = query_one(BOOK_SELECT + " WHERE b.id = %s AND b.user_id = %s", (book_id, g.user_id)) if row is None: raise ApiError(404, "Book not found") return row def _check_progress(book: dict, field: str) -> None: if book["current_page"] > book["total_pages"]: raise ApiError( 400, f"current_page ({book['current_page']}) exceeds total_pages ({book['total_pages']}); " "lower current_page first or choose a larger total_pages", field, ) def _save(sql: str, params: tuple) -> dict | None: try: return query_one(sql, params) except psycopg2.errors.ForeignKeyViolation: raise ApiError(400, "Unknown genre_id; see GET /api/genres", "genre_id") from None @bp.get("/genres") @login_required def list_genres(): return jsonify(query("SELECT id, name FROM genres ORDER BY name")) @bp.get("/books") @login_required def list_books(): sql = BOOK_SELECT + " WHERE b.user_id = %s" params: list = [g.user_id] search = request.args.get("q", "").strip() if search: if len(search) > MAX_TEXT or "\x00" in search: raise ApiError(400, f"q must be plain text of at most {MAX_TEXT} characters", "q") pattern = f"%{escape_like(search)}%" sql += " AND (b.title ILIKE %s OR b.author ILIKE %s)" params += [pattern, pattern] genre_id = request.args.get("genre_id", "") if genre_id: if not GENRE_ID_PATTERN.fullmatch(genre_id) or int(genre_id) > MAX_GENRE_ID: raise ApiError(400, "genre_id must be an id from GET /api/genres", "genre_id") 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))]) @bp.post("/books") @login_required def create_book(): 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 _check_progress(book, "current_page") row = _save( """ INSERT INTO books (user_id, title, author, genre_id, total_pages, current_page) VALUES (%s, %s, %s, %s, %s, %s) RETURNING id """, (g.user_id, book["title"], book["author"], book["genre_id"], book["total_pages"], book["current_page"]), ) return jsonify(to_json(owned_book(row["id"]))), 201 # int(max=...) keeps ids within Postgres INT range: larger values 404 instead of erroring in SQL. @bp.get("/books/") @login_required def get_book(book_id: int): return jsonify(to_json(owned_book(book_id))) @bp.patch("/books/") @login_required def update_book(book_id: int): 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} _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 """, (book["title"], book["author"], book["genre_id"], book["total_pages"], book["current_page"], book_id, g.user_id), ) return jsonify(to_json(owned_book(book_id))) @bp.delete("/books/") @login_required def delete_book(book_id: int): if query_one("DELETE FROM books WHERE id = %s AND user_id = %s RETURNING id", (book_id, g.user_id)) is None: raise ApiError(404, "Book not found") return "", 204