diff --git a/src/hxbooks/app.py b/src/hxbooks/app.py index f2c88d6..aec70a1 100644 --- a/src/hxbooks/app.py +++ b/src/hxbooks/app.py @@ -4,8 +4,7 @@ from pathlib import Path from flask import Flask from flask_migrate import Migrate -from . import auth, db -from .htmx import htmx +from . import db from .main import bp as main_bp # Get the project root (parent of src/) @@ -36,13 +35,11 @@ def create_app(test_config: dict | None = None) -> Flask: pass db.init_app(app) - htmx.init_app(app) # Initialize migrations Migrate(app, db.db) # Register blueprints - app.register_blueprint(auth.bp) app.register_blueprint(main_bp) return app diff --git a/src/hxbooks/auth.py b/src/hxbooks/auth.py deleted file mode 100644 index 68f9cb4..0000000 --- a/src/hxbooks/auth.py +++ /dev/null @@ -1,107 +0,0 @@ -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 deleted file mode 100644 index 0ca9ed6..0000000 --- a/src/hxbooks/book.py +++ /dev/null @@ -1,577 +0,0 @@ -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: bool | None = None - read: bool | None = None - reading: bool | None = None - dropped: bool | None = None - bought_start: date | None = None - bought_end: date | None = None - started_reading_start: date | None = None - started_reading_end: date | None = None - finished_reading_start: date | None = None - finished_reading_end: date | None = None - sort_by: ResultColumn = "title" - sort_order: Literal["asc", "desc"] = "asc" - saved_search: str | None = 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: int | None - edition: str - added: datetime - description: str - notes: str - isbn: str - owner: str | None - bought: date - location: str - loaned_to: str - loaned_from: str - wishlisted: bool - read: bool - reading: bool - dropped: bool - started_reading: date | None - finished_reading: date | None - - -@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(owner_id=g.user.id) - 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( - owner_id=g.user.id, - title=book_data.title, - description=book_data.description, - isbn=isbn, - authors=book_data.authors, - publisher=book_data.publisher, - first_published=( - book_data.publishedDate.year - if isinstance(book_data.publishedDate, date) - else book_data.publishedDate - ), - 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: int | None = None - edition: str = "" - notes: str = "" - isbn: str = "" - authors: list[str] = [] - genres: list[str] = [] - publisher: str = "" - owner_id: int | None = None - bought: date = Field(default_factory=datetime.today) - 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: date = Field(default_factory=datetime.today) - end_date: date | None = None - finished: bool = False - dropped: bool = False - rating: int | None = 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.today() - 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/htmx.py b/src/hxbooks/htmx.py deleted file mode 100644 index 4ac235a..0000000 --- a/src/hxbooks/htmx.py +++ /dev/null @@ -1,3 +0,0 @@ -from flask_htmx import HTMX # type: ignore - -htmx = HTMX() diff --git a/src/hxbooks/templates.old/auth/login.html.j2 b/src/hxbooks/templates.old/auth/login.html.j2 deleted file mode 100644 index 1ee102f..0000000 --- a/src/hxbooks/templates.old/auth/login.html.j2 +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "base.html.j2" %} - -{% block header %} -

{% block title %}Login{% endblock title %}

-{% endblock header %} - -{% block content %} -{% from "error_feedback.html.j2" import validation_error %} -
-
- -
- -
-
- {{validation_error("username", errors)}} - - - -
-{% endblock content %} \ No newline at end of file diff --git a/src/hxbooks/templates.old/auth/register.html.j2 b/src/hxbooks/templates.old/auth/register.html.j2 deleted file mode 100644 index f00420b..0000000 --- a/src/hxbooks/templates.old/auth/register.html.j2 +++ /dev/null @@ -1,26 +0,0 @@ -{% extends "base.html.j2" %} - -{% block header %} -

{% block title %}Register new user{% endblock title %}

-{% endblock header %} - -{% block content %} -{% from "error_feedback.html.j2" import validation_error %} - -
- -
- -
- -
-
- {{validation_error('username', errors)}} - -
- -
- -
-{% endblock content %} \ No newline at end of file diff --git a/src/hxbooks/templates.old/base.html.j2 b/src/hxbooks/templates.old/base.html.j2 deleted file mode 100644 index bbfc0c2..0000000 --- a/src/hxbooks/templates.old/base.html.j2 +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - {% block title %}{% endblock %} - hxbooks - - - - - - - - - - - - - - - - -
- -
-
-
- {% block header %}{% endblock %} -
- {% for message in get_flashed_messages() %} -
{{ message }}
- {% endfor %} - {% block content %}{% endblock %} - - - - -
- - - \ No newline at end of file diff --git a/src/hxbooks/templates.old/books/book.html.j2 b/src/hxbooks/templates.old/books/book.html.j2 deleted file mode 100644 index 61ef706..0000000 --- a/src/hxbooks/templates.old/books/book.html.j2 +++ /dev/null @@ -1,173 +0,0 @@ -{% 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.old/books/index.html.j2 b/src/hxbooks/templates.old/books/index.html.j2 deleted file mode 100644 index b5f9728..0000000 --- a/src/hxbooks/templates.old/books/index.html.j2 +++ /dev/null @@ -1,195 +0,0 @@ -{# 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.old/error_feedback.html.j2 b/src/hxbooks/templates.old/error_feedback.html.j2 deleted file mode 100644 index 445829c..0000000 --- a/src/hxbooks/templates.old/error_feedback.html.j2 +++ /dev/null @@ -1,10 +0,0 @@ -{% 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 deleted file mode 100644 index 27b0cc1..0000000 --- a/src/hxbooks/util.py +++ /dev/null @@ -1,13 +0,0 @@ -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