199 lines
6.1 KiB
Python
199 lines
6.1 KiB
Python
"""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/<int(max=2147483647):book_id>")
|
|
@login_required
|
|
def get_book(book_id: int):
|
|
return jsonify(to_json(owned_book(book_id)))
|
|
|
|
|
|
@bp.patch("/books/<int(max=2147483647):book_id>")
|
|
@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/<int(max=2147483647):book_id>")
|
|
@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
|