commit b4b931633b77300f9ef8d3a35a21c04358309068 Author: Francisco Penedo Date: Wed Apr 24 17:59:20 2024 +0200 First full version diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..93e05c6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.envrc +.venv +.git +.vscode +**/instance +*.egg-info +**/__pycache__ +**/__mypy_cache__ \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b973a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +instance/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b917474 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.11-alpine + +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt +COPY . . +RUN pip install -e . + +EXPOSE 8080 + +CMD ["gunicorn", "--bind", "0.0.0.0:8080", "hxbooks:create_app()"] \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b002d98 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "hxbooks" +version = "0.1.0" +requires-python = ">=3.11" + +dependencies = [ + "flask", + "flask_sqlalchemy", + "flask-htmx", + "jinja2_fragments", + "sqlalchemy", + "pydantic", + "requests", + "gunicorn", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..22c3f05 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,64 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# pip-compile +# +annotated-types==0.6.0 + # via pydantic +blinker==1.7.0 + # via flask +certifi==2024.2.2 + # via requests +charset-normalizer==3.3.2 + # via requests +click==8.1.7 + # via flask +flask==3.0.3 + # via + # flask-htmx + # flask-sqlalchemy + # hxbooks (pyproject.toml) +flask-htmx==0.3.2 + # via hxbooks (pyproject.toml) +flask-sqlalchemy==3.1.1 + # via hxbooks (pyproject.toml) +greenlet==3.0.3 + # via sqlalchemy +gunicorn==22.0.0 + # via hxbooks (pyproject.toml) +idna==3.7 + # via requests +itsdangerous==2.2.0 + # via flask +jinja2==3.1.3 + # via + # flask + # jinja2-fragments +jinja2-fragments==1.3.0 + # via hxbooks (pyproject.toml) +markupsafe==2.1.5 + # via + # jinja2 + # werkzeug +packaging==24.0 + # via gunicorn +pydantic==2.7.1 + # via hxbooks (pyproject.toml) +pydantic-core==2.18.2 + # via pydantic +requests==2.31.0 + # via hxbooks (pyproject.toml) +sqlalchemy==2.0.29 + # via + # flask-sqlalchemy + # hxbooks (pyproject.toml) +typing-extensions==4.11.0 + # via + # pydantic + # pydantic-core + # sqlalchemy +urllib3==2.2.1 + # via requests +werkzeug==3.0.2 + # via flask diff --git a/src/hxbooks/__init__.py b/src/hxbooks/__init__.py new file mode 100644 index 0000000..0e46241 --- /dev/null +++ b/src/hxbooks/__init__.py @@ -0,0 +1,43 @@ +import os +from typing import Optional + +from flask import Flask + +from . import auth, book, db +from .htmx import htmx + + +def create_app(test_config: Optional[dict] = None) -> Flask: + app = Flask(__name__, instance_relative_config=True) + app.config.from_mapping( + SECRET_KEY="dev", + SQLALCHEMY_DATABASE_URI="sqlite:///hxbooks.sqlite", + ) + + if test_config is None: + # load the instance config, if it exists, when not testing + app.config.from_pyfile("config.py", silent=True) + else: + # load the test config if passed in + app.config.from_mapping(test_config) + + # ensure the instance folder exists + try: + os.makedirs(app.instance_path) + except OSError: + pass + + db.init_app(app) + htmx.init_app(app) + + app.register_blueprint(auth.bp) + app.register_blueprint(book.bp) + + app.add_url_rule("/", endpoint="books.books") + + return app + + +if __name__ == "__main__": + app = create_app() + app.run() diff --git a/src/hxbooks/__main__.py b/src/hxbooks/__main__.py new file mode 100644 index 0000000..d0af59a --- /dev/null +++ b/src/hxbooks/__main__.py @@ -0,0 +1,10 @@ +import livereload # type: ignore + +from hxbooks import create_app + +app = create_app() +app.debug = True +# app.run() +server = livereload.Server(app.wsgi_app) +server.watch("hxbooks/templates/**") +server.serve(port=5000, host="0.0.0.0") diff --git a/src/hxbooks/auth.py b/src/hxbooks/auth.py new file mode 100644 index 0000000..68f9cb4 --- /dev/null +++ b/src/hxbooks/auth.py @@ -0,0 +1,107 @@ +from functools import wraps +from typing import Annotated, Any + +from flask import ( + Blueprint, + g, + redirect, + render_template, + request, + session, + url_for, +) +from flask.typing import RouteCallable +from pydantic import ( + BaseModel, + StringConstraints, + ValidationError, +) +from werkzeug import Response + +from hxbooks.db import db +from hxbooks.models import User +from hxbooks.util import flatten_form_data + +bp = Blueprint("auth", __name__, url_prefix="/auth") + + +class UserRequestSchema(BaseModel): + username: Annotated[str, StringConstraints(min_length=1, to_lower=True)] + + +# register endpoint +@bp.route("/register", methods=["GET", "POST"]) +def register() -> str | Response: + errors = {} + if request.method == "POST": + form_data = flatten_form_data(request.form) + try: + user_req = UserRequestSchema.model_validate(form_data) + except ValidationError as e: + errors = {err["loc"][0]: err["msg"] for err in e.errors()} + else: + if ( + User.query.filter(User.username == user_req.username).first() + is not None + ): + return render_template( + "auth/register.html.j2", + errors={"username": "Username already exists"}, + ) + user = User(username=user_req.username) + db.session.add(user) + db.session.commit() + return redirect(url_for(".login")) + return render_template("auth/register.html.j2", errors=errors) + + +# login endpoint +@bp.route("/login", methods=["GET", "POST"]) +def login() -> str | Response: + errors = {} + users = User.query.all() + if request.method == "POST": + form_data = flatten_form_data(request.form) + try: + user_req = UserRequestSchema.model_validate(form_data) + except ValidationError as e: + errors = {err["loc"][0]: err["msg"] for err in e.errors()} + else: + user = User.query.filter(User.username == user_req.username).first() + if user is None: + return render_template( + "auth/login.html.j2", + errors={"username": "User not found"}, + users=users, + ) + session.clear() + session["user_id"] = user.id + return redirect(url_for("books.books")) + if g.user is not None: + return redirect(url_for("books.books")) + return render_template("auth/login.html.j2", errors=errors, users=users) + + +# logout endpoint +@bp.route("/logout", methods=["GET"]) +def logout() -> Response: + session.clear() + return redirect(url_for(".login")) + + +@bp.before_app_request +def load_logged_in_user() -> None: + if (user_id := session.get("user_id")) is None: + g.user = None + else: + g.user = User.query.get(user_id) + + +def login_required(view: RouteCallable) -> RouteCallable: + @wraps(view) + def wrapped_view(*args: Any, **kwargs: Any) -> Any: + if g.user is None: + return redirect(url_for("auth.login")) + return view(*args, **kwargs) + + return wrapped_view diff --git a/src/hxbooks/book.py b/src/hxbooks/book.py new file mode 100644 index 0000000..a26f6c6 --- /dev/null +++ b/src/hxbooks/book.py @@ -0,0 +1,573 @@ +import re +from datetime import date, datetime +from functools import lru_cache +from typing import Any, Literal, Optional, cast, get_args + +import requests +from flask import ( + Blueprint, + abort, + g, + redirect, + render_template, + request, + url_for, +) +from jinja2_fragments.flask import render_block +from pydantic import BaseModel, Field, ValidationError, field_validator +from sqlalchemy import Column, Select, Subquery, exists, func, or_, select +from sqlalchemy.orm.attributes import InstrumentedAttribute +from werkzeug import Response + +from hxbooks.auth import login_required +from hxbooks.db import db +from hxbooks.gbooks import fetch_google_book_data +from hxbooks.htmx import htmx +from hxbooks.models import Book, Reading, User, Wishlist +from hxbooks.util import flatten_form_data + +bp = Blueprint("books", __name__, url_prefix="/books") + + +@bp.before_request +@login_required +def before_request(): + pass + + +FTS_FIELDS = ["title", "description", "notes", "genres", "authors", "publisher"] + +ResultColumn = Literal[ + "title", + "first_published", + "edition", + "added", + "description", + "notes", + "isbn", + "authors", + "genres", + "publisher", + "owner", + "bought", + "location", + "loaned_to", + "loaned_from", + "wishlisted", + "read", + "reading", + "dropped", + "started_reading", + "finished_reading", +] + + +class SearchRequestSchema(BaseModel, extra="forbid"): + q: str = "" + wishlisted: Optional[bool] = None + read: Optional[bool] = None + reading: Optional[bool] = None + dropped: Optional[bool] = None + bought_start: Optional[date] = None + bought_end: Optional[date] = None + started_reading_start: Optional[date] = None + started_reading_end: Optional[date] = None + finished_reading_start: Optional[date] = None + finished_reading_end: Optional[date] = None + sort_by: ResultColumn = "title" + sort_order: Literal["asc", "desc"] = "asc" + saved_search: Optional[str] = None + + @field_validator( + "wishlisted", + "read", + "reading", + "dropped", + "bought_start", + "bought_end", + "started_reading_start", + "started_reading_end", + "finished_reading_start", + "finished_reading_end", + mode="before", + ) + @classmethod + def coerce_empty_to_none(cls, v: Any) -> Any: + if v == "": + return None + return v + + +class BookResultSchema(BaseModel): + id: int + title: str + authors: list[str] + genres: list[str] + publisher: str + first_published: Optional[int] + edition: str + added: datetime + description: str + notes: str + isbn: str + owner: Optional[str] + bought: date + location: str + loaned_to: str + loaned_from: str + wishlisted: bool + read: bool + reading: bool + dropped: bool + started_reading: Optional[date] + finished_reading: Optional[date] + + +@bp.route("", methods=["GET"]) +def books(): + if len(request.args) == 0: + search_req = SearchRequestSchema(q="") + else: + args_data = flatten_form_data(request.args) + search_req = SearchRequestSchema.model_validate(args_data) + + saved_searches = get_saved_searches(g.user) + if search_req.saved_search is not None: + search_req = saved_searches.get(search_req.saved_search, search_req) + + query = build_query_from_req(search_req) + + books = [ + BookResultSchema.model_validate(book._mapping) + for book in db.session.execute(query).all() + ] + + if htmx.target == "search-results": + return render_block( + "books/index.html.j2", + "search_results", + books=books, + search_req=search_req, + ) + return render_template( + "books/index.html.j2", + books=books, + search_req=search_req, + ) + + +@bp.route("/new", methods=["GET"]) +def books_new() -> Response: + book = Book() + db.session.add(book) + db.session.commit() + return redirect(url_for(".book", id=book.id), 303) + + +@bp.route("/import", methods=["POST"]) +def books_import() -> Response: + isbn = request.form.get("isbn") + if not isbn: + abort(400) + try: + book_data = fetch_google_book_data(isbn) + except ValueError as e: + abort(400, str(e)) + except requests.RequestException: + abort(500, "Error fetching book data") + + book = Book( + title=book_data.title, + description=book_data.description, + isbn=isbn, + authors=book_data.authors, + publisher=book_data.publisher, + first_published=book_data.publishedDate.year, + genres=book_data.categories, + ) + db.session.add(book) + db.session.commit() + return redirect(url_for(".book", id=book.id), 303) + + +@bp.route("/saved_search", methods=["POST", "DELETE"]) +def saved_search(): + form_data = flatten_form_data(request.form) + search_req = SearchRequestSchema.model_validate(form_data) + if request.method == "DELETE": + name_req = htmx.prompt or search_req.saved_search + if name_req is None: + abort(400) + saved_searches = { + name: search + for name, search in g.user.saved_searches.items() + if name.lower() != name_req.lower() + } + g.user.saved_searches = saved_searches + db.session.commit() + return redirect(url_for(".books"), 303) + + if (name := htmx.prompt) is None or name == "": + abort(400) + + saved_searches = {name: search for name, search in g.user.saved_searches.items()} + saved_searches[name] = search_req.model_dump() + g.user.saved_searches = saved_searches + db.session.commit() + return redirect(url_for(".books", saved_search=name), 303) + + +def book_results_subquery(user: User) -> Subquery: + # Subqueries to check if a book is wishlisted, read, reading, or dropped by the user + wishlisted = ( + exists() + .where(Wishlist.user_id == user.id, Book.id == Wishlist.book_id) + .correlate(Book) + ) + read = ( + exists() + .where( + Reading.user_id == user.id, + Book.id == Reading.book_id, + Reading.finished, + ) + .correlate(Book) + ) + reading = ( + exists() + .where( + Reading.user_id == user.id, + Book.id == Reading.book_id, + ~Reading.finished, + ~Reading.dropped, + ) + .correlate(Book) + ) + dropped = ( + exists() + .where( + Reading.user_id == user.id, + Book.id == Reading.book_id, + Reading.dropped, + ) + .correlate(Book) + ) + + # Get the maximum id for each book_id and user_id combination in Reading + max_reading_id_subquery = ( + select(Reading.book_id, Reading.user_id, func.max(Reading.id).label("max_id")) + .where(Reading.user_id == user.id) + .group_by(Reading.book_id, Reading.user_id) + .subquery() + ) + # Get the last reading for each book_id and user_id combination + last_readings = ( + select(Reading) + .join(max_reading_id_subquery, Reading.id == max_reading_id_subquery.c.max_id) + .subquery() + ) + + # Join Book to User to obtain the owner's username and join with the subqueries. + # The join with last_readings is an outer join to allow for books that have not been + # read + query = ( + select( + Book, + User.username.label("owner"), + wishlisted.label("wishlisted"), + read.label("read"), + reading.label("reading"), + dropped.label("dropped"), + last_readings.c.start_date.label("started_reading"), + last_readings.c.end_date.label("finished_reading"), + ) + .join_from(Book, User, isouter=True) + .outerjoin( + last_readings, + (Book.id == last_readings.c.book_id) + and (last_readings.c.user_id == user.id), + ) + .subquery() + ) + + return query + + +def build_query_from_req(search_req: SearchRequestSchema) -> Select: + subq = book_results_subquery(g.user) + query = build_query_from_str(subq, search_req.q) + + # date filters + if search_req.bought_start is not None: + query = query.where(subq.c.bought >= search_req.bought_start) + if search_req.bought_end is not None: + query = query.where(subq.c.bought <= search_req.bought_end) + if search_req.started_reading_start is not None: + query = query.where(subq.c.started_reading >= search_req.started_reading_start) + if search_req.started_reading_end is not None: + query = query.where(subq.c.started_reading <= search_req.started_reading_end) + if search_req.finished_reading_start is not None: + query = query.where( + subq.c.finished_reading >= search_req.finished_reading_start + ) + if search_req.finished_reading_end is not None: + query = query.where(subq.c.finished_reading <= search_req.finished_reading_end) + + # boolean filters + if search_req.wishlisted is not None: + query = query.where(subq.c.wishlisted == search_req.wishlisted) + if search_req.read is not None: + query = query.where(subq.c.read == search_req.read) + if search_req.reading is not None: + query = query.where(subq.c.reading == search_req.reading) + if search_req.dropped is not None: + query = query.where(subq.c.dropped == search_req.dropped) + + # sorting + query = query.order_by( + getattr(getattr(subq.c, search_req.sort_by), search_req.sort_order)() + ) + + return query + + +# regex to split query string by spaces, but not inside quotes +SPLIT_REGEX = re.compile(r"([^\s\"]+)|\"([^\"]*)\"") + + +def build_query_from_str(subq: Subquery, query_str: str) -> Select: + query = select(subq) + while match := re.search(SPLIT_REGEX, query_str): + query_str = query_str[match.end() :] + token = match.group(1) or match.group(2) + if ":" in token: + field_name, value = token.split(":") + field: Column = getattr(subq.c, field_name) + schema_field = BookResultSchema.model_fields[field_name] + if schema_field.annotation == str: + query = query.where(field.contains(value)) + elif schema_field.annotation == list[str]: + fn = func.json_each(field).table_valued("value") + query = query.where(fn.c.value.contains(value)) + elif schema_field.annotation == Optional[int]: + query = query.where(field == int(value)) + else: + query = query.where( + or_(*(getattr(subq.c, field).contains(token) for field in FTS_FIELDS)) + ) + + return query + + +@lru_cache +def get_default_searches(username: str) -> dict[str, SearchRequestSchema]: + return { + "all": SearchRequestSchema(), + "owned": SearchRequestSchema(q=f"owner:{username}"), + "wishlisted": SearchRequestSchema(wishlisted=True), + "read": SearchRequestSchema(read=True), + "reading": SearchRequestSchema(reading=True), + } + + +def get_saved_searches(user: User) -> dict[str, SearchRequestSchema]: + searches = get_default_searches(user.username).copy() + searches.update( + { + name: SearchRequestSchema.model_validate(value) + for name, value in user.saved_searches.items() + } + ) + for name, search in searches.items(): + search.saved_search = name + return searches + + +class BookRequestSchema(BaseModel): + title: str = Field(min_length=1) + first_published: Optional[int] = None + edition: str = "" + notes: str = "" + isbn: str = "" + authors: list[str] = [] + genres: list[str] = [] + publisher: str = "" + owner_id: Optional[int] = None + bought: datetime = Field(default_factory=datetime.now) + location: str = "billy salon" + loaned_to: str = "" + loaned_from: str = "" + wishlisted: bool = False + + @field_validator("first_published", "owner_id", mode="before") + @classmethod + def coerce_empty_to_none(cls, v: Any) -> Any: + if v == "": + return None + return v + + +@bp.route("/", methods=["GET", "PUT", "POST", "DELETE"]) +def book(id: int) -> str | Response: + book = db.session.execute(select(Book).filter(Book.id == id)).scalar_one_or_none() + if book is None: + abort(404) + + if request.method == "DELETE": + db.session.delete(book) + db.session.commit() + return redirect(url_for(".books"), 303) + + errors = {} + if request.method in ("PUT", "POST"): + try: + book_req = BookRequestSchema.model_validate(flatten_form_data(request.form)) + except ValidationError as e: + errors = {err["loc"][0]: err["msg"] for err in e.errors()} + else: + form_data = book_req.model_dump() + wishlisted = form_data.pop("wishlisted") + + for key, value in book_req.model_dump().items(): + setattr(book, key, value) + + wishlist = db.session.execute( + select(Wishlist).filter( + Wishlist.user_id == g.user.id, Wishlist.book_id == id + ) + ).scalar_one_or_none() + if wishlist is None and wishlisted: + wishlist = Wishlist(user_id=g.user.id, book_id=book.id) + db.session.add(wishlist) + elif wishlist is not None and not wishlisted: + db.session.delete(wishlist) + + db.session.commit() + if request.method == "POST": + return redirect(url_for(".books"), 303) + + template_args = { + "book": book, + "users": db.session.execute(select(User)).scalars().all(), + "genres": get_distinct_json_list_values(Book.genres), + "authors": get_distinct_json_list_values(Book.authors), + "locations": db.session.execute(select(Book.location).distinct()) + .scalars() + .all(), + "wished_by": [wishlist.user.username for wishlist in book.wished_by], + "errors": errors, + } + if request.method == "PUT": + return render_block("books/book.html.j2", "form", **template_args) + return render_template("books/book.html.j2", **template_args) + + +@bp.route("//readings/new", methods=["POST"]) +def readings_new(id: int) -> str: + book = db.session.execute(select(Book).filter(Book.id == id)).scalar_one_or_none() + if book is None: + abort(404) + + reading = Reading(book_id=book.id, user_id=g.user.id) + db.session.add(reading) + db.session.commit() + return render_block("books/book.html.j2", "reading_row", reading=reading) + + +class ReadingRequestSchema(BaseModel): + start_date: datetime = Field(default_factory=datetime.now) + end_date: Optional[datetime] = None + finished: bool = False + dropped: bool = False + rating: Optional[int] = None + comments: str = "" + user_id: int + book_id: int + + @field_validator("end_date", "rating", mode="before") + @classmethod + def coerce_empty_to_none(cls, v: Any) -> Any: + if v == "": + return None + return v + + +@bp.route( + "//readings/", methods=["PUT", "PATCH", "DELETE"] +) +def reading(book_id: int, reading_id: int) -> str: + reading = db.session.execute( + select(Reading).filter(Reading.id == reading_id) + ).scalar_one_or_none() + if reading is None: + abort(404) + + if request.method == "DELETE": + db.session.delete(reading) + db.session.commit() + return "" + else: + form_data = flatten_form_data(request.form) + form_data["book_id"] = str(book_id) + form_data["user_id"] = str(g.user.id) + try: + reading_req = ReadingRequestSchema.model_validate(form_data) + except ValidationError as e: + errors = {err["loc"][0]: err["msg"] for err in e.errors()} + else: + errors = {} + if reading_req.rating is not None: + reading_req.rating = min(max(reading_req.rating, 1), 5) + if reading_req.end_date is None and ( + reading_req.finished or reading_req.dropped + ): + reading_req.end_date = datetime.now() + for key, value in reading_req.model_dump().items(): + setattr(reading, key, value) + db.session.commit() + finally: + return render_block( + "books/book.html.j2", + "reading_row", + reading=reading, + edit_reading_id=reading_id, + errors=errors, + ) + + +@bp.route("//readings//edit", methods=["GET"]) +def reading_edit(book_id: int, reading_id: int) -> str: + reading = db.session.execute( + select(Reading).filter(Reading.id == reading_id) + ).scalar_one_or_none() + if reading is None: + abort(404) + + if reading.user_id != g.user.id: + abort(403) + + return render_block( + "books/book.html.j2", + "reading_row", + reading=reading, + edit_reading_id=reading_id, + ) + + +def get_distinct_json_list_values(column: InstrumentedAttribute) -> list[str]: + stmt = ( + select(func.json_each(column).table_valued("value")) + .select_from(column.parent) + .distinct() + ) + + values = cast(list[str], db.session.execute(stmt).scalars().all()) + return values + + +@bp.context_processor +def inject_aux_functions(): + return { + "all_columns": get_args(ResultColumn), + "get_default_searches": get_default_searches, + } diff --git a/src/hxbooks/db.py b/src/hxbooks/db.py new file mode 100644 index 0000000..73036a0 --- /dev/null +++ b/src/hxbooks/db.py @@ -0,0 +1,15 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): ... + + +db = SQLAlchemy(model_class=Base) + + +def init_app(app: Flask) -> None: + db.init_app(app) + with app.app_context(): + db.create_all() diff --git a/src/hxbooks/gbooks.py b/src/hxbooks/gbooks.py new file mode 100644 index 0000000..370fb37 --- /dev/null +++ b/src/hxbooks/gbooks.py @@ -0,0 +1,80 @@ +from datetime import date + +import requests +from pydantic import BaseModel + +# { +# "title": "Concilio de Sombras (Sombras de Magia 2)", +# "authors": [ +# "Victoria Schwab" +# ], +# "publisher": "Urano World", +# "publishedDate": "2023-11-14", +# "description": "Four months have passed since the shadow stone fell into Kell's possession. Four months since his path was crossed with Delilah Bard. Four months since Rhy was wounded and the Dane twins fell, and the stone was cast with Holland's dying body through the rift, and into Black London. In many ways, things have almost returned to normal, though Rhy is more sober, and Kell is now plagued by his guilt. Restless, and having been given up smuggling, Kell is visited by dreams of ominous magical events, waking only to think of Lila, who disappeared from the docks like she always meant to do. As Red London finalizes preparations for the Element Games-an extravagant international competition of magic, meant to entertain and keep the ties between neighboring countries healthy- a certain pirate ship draws closer, carrying old friends back into port. But while Red London is caught up in the pageantry and thrills of the Games, another London is coming back to life, and those who were thought to be forever gone have returned. After all, a shadow that was gone in the night reappears in the morning, and so it seems Black London has risen again-and so to keep magic's balance, another London must fall.", +# "industryIdentifiers": [ +# { +# "type": "ISBN_10", +# "identifier": "8419030503" +# }, +# { +# "type": "ISBN_13", +# "identifier": "9788419030504" +# } +# ], +# "readingModes": { +# "text": false, +# "image": false +# }, +# "pageCount": 0, +# "printType": "BOOK", +# "categories": [ +# "Fiction" +# ], +# "maturityRating": "NOT_MATURE", +# "allowAnonLogging": false, +# "contentVersion": "preview-1.0.0", +# "panelizationSummary": { +# "containsEpubBubbles": false, +# "containsImageBubbles": false +# }, +# "imageLinks": { +# "smallThumbnail": "http://books.google.com/books/content?id=GM4G0AEACAAJ&printsec=frontcover&img=1&zoom=5&source=gbs_api", +# "thumbnail": "http://books.google.com/books/content?id=GM4G0AEACAAJ&printsec=frontcover&img=1&zoom=1&source=gbs_api" +# }, +# "language": "es", +# "previewLink": "http://books.google.es/books?id=GM4G0AEACAAJ&dq=isbn:9788419030504&hl=&cd=1&source=gbs_api", +# "infoLink": "http://books.google.es/books?id=GM4G0AEACAAJ&dq=isbn:9788419030504&hl=&source=gbs_api", +# "canonicalVolumeLink": "https://books.google.com/books/about/Concilio_de_Sombras_Sombras_de_Magia_2.html?hl=&id=GM4G0AEACAAJ" +# } + + +class GoogleBook(BaseModel): + title: str + authors: list[str] + publisher: str + publishedDate: date + description: str + industryIdentifiers: list[dict[str, str]] + pageCount: int + printType: str + categories: list[str] + maturityRating: str + allowAnonLogging: bool + contentVersion: str + panelizationSummary: dict[str, bool] + imageLinks: dict[str, str] + language: str + previewLink: str + infoLink: str + canonicalVolumeLink: str + + +def fetch_google_book_data(isbn: str) -> GoogleBook: + req = requests.get( + "https://www.googleapis.com/books/v1/volumes", params={"q": f"isbn:{isbn}"} + ) + req.raise_for_status() + data = req.json() + if data["totalItems"] == 0: + raise ValueError(f"Book with ISBN {isbn} not found") + return GoogleBook.model_validate(data["items"][0]["volumeInfo"]) diff --git a/src/hxbooks/htmx.py b/src/hxbooks/htmx.py new file mode 100644 index 0000000..4ac235a --- /dev/null +++ b/src/hxbooks/htmx.py @@ -0,0 +1,3 @@ +from flask_htmx import HTMX # type: ignore + +htmx = HTMX() diff --git a/src/hxbooks/models.py b/src/hxbooks/models.py new file mode 100644 index 0000000..96fbb04 --- /dev/null +++ b/src/hxbooks/models.py @@ -0,0 +1,65 @@ +from datetime import date, datetime +from typing import Optional + +from sqlalchemy import JSON, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .db import db + + +class User(db.Model): # type: ignore[name-defined] + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str] = mapped_column() + saved_searches: Mapped[dict] = mapped_column(JSON, default=dict) + readings: Mapped[list["Reading"]] = relationship(back_populates="user") + owned_books: Mapped[list["Book"]] = relationship(back_populates="owner") + wishes: Mapped[list["Wishlist"]] = relationship(back_populates="user") + + +class Book(db.Model): # type: ignore[name-defined] + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(default="") + description: Mapped[str] = mapped_column(default="") + first_published: Mapped[Optional[int]] = mapped_column(default=None) + edition: Mapped[str] = mapped_column(default="") + added: Mapped[datetime] = mapped_column(default=datetime.now) + notes: Mapped[str] = mapped_column(default="") + isbn: Mapped[str] = mapped_column(default="") + authors: Mapped[list[str]] = mapped_column(JSON, default=list) + genres: Mapped[list[str]] = mapped_column(JSON, default=list) + publisher: Mapped[str] = mapped_column(default="") + owner_id: Mapped[Optional[int]] = mapped_column(ForeignKey("user.id")) + bought: Mapped[datetime] = mapped_column(default=datetime.now) + location: Mapped[str] = mapped_column(default="billy salon") + loaned_to: Mapped[str] = mapped_column(default="") + loaned_from: Mapped[str] = mapped_column(default="") + owner: Mapped[Optional[User]] = relationship(back_populates="owned_books") + readings: Mapped[list["Reading"]] = relationship( + back_populates="book", cascade="delete, delete-orphan" + ) + wished_by: Mapped[list["Wishlist"]] = relationship( + back_populates="book", cascade="delete, delete-orphan" + ) + + +class Reading(db.Model): # type: ignore[name-defined] + id: Mapped[int] = mapped_column(primary_key=True) + start_date: Mapped[date] = mapped_column(default=datetime.now) + end_date: Mapped[Optional[date]] = mapped_column(default=None) + finished: Mapped[bool] = mapped_column(default=False) + dropped: Mapped[bool] = mapped_column(default=False) + rating: Mapped[Optional[int]] = mapped_column(default=None) + comments: Mapped[str] = mapped_column(default="") + user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + book_id: Mapped[int] = mapped_column(ForeignKey("book.id")) + user: Mapped["User"] = relationship(back_populates="readings") + book: Mapped["Book"] = relationship(back_populates="readings") + + +class Wishlist(db.Model): # type: ignore[name-defined] + id: Mapped[int] = mapped_column(primary_key=True) + wishlisted: Mapped[date] = mapped_column(default=datetime.now) + user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + book_id: Mapped[int] = mapped_column(ForeignKey("book.id")) + user: Mapped["User"] = relationship(back_populates="wishes") + book: Mapped["Book"] = relationship(back_populates="wished_by") diff --git a/src/hxbooks/static/alpine.min.js b/src/hxbooks/static/alpine.min.js new file mode 100644 index 0000000..cc6050c --- /dev/null +++ b/src/hxbooks/static/alpine.min.js @@ -0,0 +1,5 @@ +(()=>{var rt=!1,nt=!1,U=[],it=-1;function Vt(e){An(e)}function An(e){U.includes(e)||U.push(e),On()}function Se(e){let t=U.indexOf(e);t!==-1&&t>it&&U.splice(t,1)}function On(){!nt&&!rt&&(rt=!0,queueMicrotask(Cn))}function Cn(){rt=!1,nt=!0;for(let e=0;ee.effect(t,{scheduler:r=>{ot?Vt(r):r()}}),st=e.raw}function at(e){D=e}function Wt(e){let t=()=>{};return[n=>{let i=D(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),L(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=D(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>L(i)}function W(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function C(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>C(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)C(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var Gt=!1;function Jt(){Gt&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Gt=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` + + + + + + + + \ No newline at end of file diff --git a/src/hxbooks/templates/books/book.html.j2 b/src/hxbooks/templates/books/book.html.j2 new file mode 100644 index 0000000..e6aa65c --- /dev/null +++ b/src/hxbooks/templates/books/book.html.j2 @@ -0,0 +1,173 @@ +{% extends "base.html.j2" %} + +{% block header %} +

{% block title %}Edit book{% endblock title %}

+{% endblock header %} + +{% block content %} + +
+ {% block form %} +
+ {% from "error_feedback.html.j2" import validation_error %} + {% macro simple_field(field, name, value, type) %} + + {% if type == "textarea" %} + + {% else %} + + {% endif %} + {{ validation_error(field, errors) }} + {% endmacro %} + +
+ {{ simple_field("title", "Title", book.title, "text") }} + + + {{ validation_error("authors", errors) }} + + + + {{ validation_error("genres", errors) }} + + + + + {{ simple_field("bought", "Bought", book.bought.strftime("%Y-%m-%d") if book.bought else "", "date") + }} + + + + {{ validation_error("location", errors) }} + +
+ + + +
Wished by: {{ wished_by|join(", ") }}
+
+ + {{ simple_field("description", "Description", book.description, "textarea") }} +
+
+ {{ simple_field("publisher", "Publisher", book.publisher, "text") }} + {{ simple_field("first_published", "First Published", book.first_published or "", "text") }} + {{ simple_field("edition", "Edition", book.edition, "text") }} + {{ simple_field("isbn", "ISBN", book.isbn, "text") }} + {{ simple_field("loaned_to", "Loaned To", book.loaned_to, "text") }} + {{ simple_field("loaned_from", "Loaned From", book.loaned_from, "text") }} + {{ simple_field("notes", "Notes", book.notes, "textarea") }} + +
+ + +
+
+
+ {% endblock form %} + + {# readings table #} +
+

Readings

+ +
+ + + + + + + + + + + + + + {% for reading in book.readings %} + {% block reading_row scoped %} + {% if edit_reading_id != reading.id %} + + + + + + + + + + {% else %} + + + + + + + + + + + {% endif %} + {% endblock reading_row %} + {% endfor %} + +
UserStartedFinishedRatingFinished?Dropped?Comments
{{ reading.user.username }}{{ reading.start_date.strftime("%d-%m-%Y") }}{{ reading.end_date and reading.end_date.strftime("%d-%m-%Y") or "-" + }}{{ reading.rating or "-" }}{{ reading.finished and "Yes" or "No" }}{{ reading.dropped and "Yes" or "No" }}{{ reading.comments or "-" }}
{{ reading.user.username }} + + +
+
+
+
+ + + +{% endblock content %} \ No newline at end of file diff --git a/src/hxbooks/templates/books/index.html.j2 b/src/hxbooks/templates/books/index.html.j2 new file mode 100644 index 0000000..4bf0e30 --- /dev/null +++ b/src/hxbooks/templates/books/index.html.j2 @@ -0,0 +1,196 @@ +{# booklist main view #} +{% extends "base.html.j2" %} + +{% block header %} +

{% block title %}Books{% endblock title %}

+{% endblock header %} + +{% block content %} + +
+
+ Searches: + {% for name in get_default_searches(g.user.username)|list + g.user.saved_searches|list %} + {{ + name }} + {% endfor %} +
+
+ +{# search form and hidden table state #} +
+ +
+ + + + +
+
+
+
+ + + +
+ +
+ + +
+
+
+ {% macro yes_no_filter(field, name) %} +
+ + +
+ {% endmacro %} +
+ Filters: +
+ {{ yes_no_filter('wishlisted', 'Wishlisted') }} + {{ yes_no_filter('read', 'Read') }} + {{ yes_no_filter('reading', 'Reading') }} + {{ yes_no_filter('dropped', 'Dropped') }} +
+
+ {% macro date_filter(field, name) %} +
+ {{name}}: +
+
+ +
+ + + +
+ +
+
+
+ {% endmacro %} + {{ date_filter('bought', 'Bought') }} + {{ date_filter('started_reading', 'Started') }} + {{ date_filter('finished_reading', 'Finished') }} +
+
+ {% for c in all_columns %} +
+ +
+ {% endfor %} +
+
+ + + + + + {# results table #} +
+
+ + + + {% macro results_th(col) %} + + {% endmacro %} + {% for col in all_columns %} + {{ results_th(col) }} + {% endfor %} + + + + {% block search_results %} + {% for book in books %} + + {% macro results_td(col, text) %} + + {% endmacro %} + {{ results_td('title', book.title) }} + {{ results_td('first_published', book.first_published) }} + {{ results_td('edition', book.edition) }} + {{ results_td('added', book.added.strftime("%d/%m/%Y")) }} + {{ results_td('description', book.description) }} + {{ results_td('notes', book.notes) }} + {{ results_td('isbn', book.isbn) }} + {{ results_td('authors', book.authors|join(", ")) }} + {{ results_td('genres', book.genres|join(", ")) }} + {{ results_td('publisher', book.publisher) }} + {{ results_td('owner', book.owner) }} + {{ results_td('bought', book.bought.strftime("%d/%m/%Y")) }} + {{ results_td('location', book.location) }} + {{ results_td('loaned_to', book.loaned_to) }} + {{ results_td('loaned_from', book.loaned_from) }} + {{ results_td('wishlisted', 'X' if book.wishlisted else '') }} + {{ results_td('read', 'X' if book.read else '') }} + {{ results_td('reading', 'X' if book.reading else '') }} + {{ results_td('dropped', 'X' if book.dropped else '') }} + {{ results_td('started_reading', book.started_reading and book.started_reading or '') }} + {{ results_td('finished_reading', book.finished_reading and book.finished_reading or '') }} + + {% endfor %} + {% endblock search_results %} + +
+
+ {{ col|replace("_", " ")|capitalize }}{% if col == + search_req.sort_by %}{{ "▴" if + search_req.sort_order == "asc" else "▾"}}{% endif %} +
+
{{ + text or '-' }}
+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/src/hxbooks/templates/error_feedback.html.j2 b/src/hxbooks/templates/error_feedback.html.j2 new file mode 100644 index 0000000..445829c --- /dev/null +++ b/src/hxbooks/templates/error_feedback.html.j2 @@ -0,0 +1,10 @@ +{% macro validation_error(field, errors) %} +{% if field in errors %} +{{ errors.get(field) + }} +{% else %} + +{% endif %} +{% endmacro %} \ No newline at end of file diff --git a/src/hxbooks/util.py b/src/hxbooks/util.py new file mode 100644 index 0000000..27b0cc1 --- /dev/null +++ b/src/hxbooks/util.py @@ -0,0 +1,13 @@ +from werkzeug.datastructures import MultiDict + + +def flatten_form_data(form: MultiDict[str, str]) -> dict[str, str | list[str]]: + ret = {} + for key, values in form.lists(): + if key.endswith("[]"): + key = key.removesuffix("[]") + v: str | list[str] = values + else: + v = values[0] + ret[key] = v + return ret