Delete old files
This commit is contained in:
@@ -4,8 +4,7 @@ from pathlib import Path
|
|||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
|
|
||||||
from . import auth, db
|
from . import db
|
||||||
from .htmx import htmx
|
|
||||||
from .main import bp as main_bp
|
from .main import bp as main_bp
|
||||||
|
|
||||||
# Get the project root (parent of src/)
|
# Get the project root (parent of src/)
|
||||||
@@ -36,13 +35,11 @@ def create_app(test_config: dict | None = None) -> Flask:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
htmx.init_app(app)
|
|
||||||
|
|
||||||
# Initialize migrations
|
# Initialize migrations
|
||||||
Migrate(app, db.db)
|
Migrate(app, db.db)
|
||||||
|
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
app.register_blueprint(auth.bp)
|
|
||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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("/<int:id>", 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("/<int:id>/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(
|
|
||||||
"/<int:book_id>/readings/<int:reading_id>", 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("/<int:book_id>/readings/<int:reading_id>/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,
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from flask_htmx import HTMX # type: ignore
|
|
||||||
|
|
||||||
htmx = HTMX()
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{% extends "base.html.j2" %}
|
|
||||||
|
|
||||||
{% block header %}
|
|
||||||
<h1>{% block title %}Login{% endblock title %}</h1>
|
|
||||||
{% endblock header %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% from "error_feedback.html.j2" import validation_error %}
|
|
||||||
<form hx-trigger="change" hx-post="/auth/login" method="post">
|
|
||||||
<div class="row pb-2">
|
|
||||||
<label class="col form-label col-form-label" for="username">Username:</label>
|
|
||||||
<div class="col">
|
|
||||||
<select class="form-select" aria-describedby="username-error" id="username" name="username"
|
|
||||||
placeholder="Username" autocomplete="off">
|
|
||||||
<option value=""></option>
|
|
||||||
{% for user in users %}
|
|
||||||
<option value="{{ user.username }}">{{ user.username }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{validation_error("username", errors)}}
|
|
||||||
|
|
||||||
<input class="col btn btn-primary" type="submit" value="Login">
|
|
||||||
|
|
||||||
</form>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{% extends "base.html.j2" %}
|
|
||||||
|
|
||||||
{% block header %}
|
|
||||||
<h1>{% block title %}Register new user{% endblock title %}</h1>
|
|
||||||
{% endblock header %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
{% from "error_feedback.html.j2" import validation_error %}
|
|
||||||
|
|
||||||
<form hx-post="/auth/register" method="post">
|
|
||||||
|
|
||||||
<div class="row pb-2">
|
|
||||||
<label class="col form-label col-form-label" for="username">Username:</label>
|
|
||||||
<div class="col">
|
|
||||||
<input class="form-control" type="text" id="username" name="username"
|
|
||||||
value="{{ request.form.get('username', '') }}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{validation_error('username', errors)}}
|
|
||||||
|
|
||||||
<div class="col">
|
|
||||||
<input class="btn btn-primary" type="submit" value="Register">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en"
|
|
||||||
x-init="$el.setAttribute('data-bs-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>{% block title %}{% endblock %} - hxbooks</title>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap.min.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='tom-select.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static',
|
|
||||||
filename='favicon-32x32.png') }}">
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static',
|
|
||||||
filename='favicon-16x16.png') }}">
|
|
||||||
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}">
|
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='htmx.min.js') }}"></script>
|
|
||||||
<script src="{{ url_for('static', filename='alpine.min.js') }}" defer></script>
|
|
||||||
<script src="{{ url_for('static', filename='tom-select.complete.min.js') }}"></script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
htmx.on('htmx:beforeHistorySave', function () {
|
|
||||||
// find all TomSelect elements
|
|
||||||
document.querySelectorAll('.tomselect')
|
|
||||||
.forEach(elt => elt.tomselect ? elt.tomselect.destroy() : null) // and call destroy() on them
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('htmx:beforeSwap', function (evt) {
|
|
||||||
// alert on errors
|
|
||||||
if (evt.detail.xhr.status >= 400) {
|
|
||||||
error_dialog = document.querySelector('#error-alert');
|
|
||||||
error_dialog.querySelector('.modal-title').textContent = 'Error ' + evt.detail.xhr.status;
|
|
||||||
error_dialog.querySelector('.modal-body').innerHTML = evt.detail.xhr.response;
|
|
||||||
error_dialog.showModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body hx-boost="true" hx-push-url="true" hx-target="body">
|
|
||||||
<header class="container-sm">
|
|
||||||
<nav class="navbar navbar-expand-sm bg-primary">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand" href="{{ url_for('books.books') }}">
|
|
||||||
<img src="{{ url_for('static', filename='favicon-32x32.png') }}" alt="hxbooks" width="32" height="32"> hxBooks
|
|
||||||
</a>
|
|
||||||
<ul class="navbar-nav ms-auto">
|
|
||||||
{% if g.user %}
|
|
||||||
<li><span class="navbar-item pe-2">{{ g.user.username.title() }}</span></li>
|
|
||||||
<li><a class="navbar-item" href="{{ url_for('auth.logout') }}">Log Out</a></li>
|
|
||||||
{% else %}
|
|
||||||
<li><a class="navbar-item pe-2" href="{{ url_for('auth.register') }}">Register</a></li>
|
|
||||||
<li><a class="navbar-item" href="{{ url_for('auth.login') }}">Log In</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<main class="content container-sm">
|
|
||||||
<header class="row">
|
|
||||||
{% block header %}{% endblock %}
|
|
||||||
</header>
|
|
||||||
{% for message in get_flashed_messages() %}
|
|
||||||
<div class="flash row">{{ message }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
|
|
||||||
<dialog id="error-alert">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title"></h5>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary"
|
|
||||||
onClick="document.querySelector('#error-alert').close()">Close</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
{% extends "base.html.j2" %}
|
|
||||||
|
|
||||||
{% block header %}
|
|
||||||
<h1>{% block title %}Edit book{% endblock title %}</h1>
|
|
||||||
{% endblock header %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{% block form %}
|
|
||||||
<form class="row row-cols-1 row-cols-xxl-2" hx-put="/books/{{ book.id }}" hx-trigger="change" hx-push-url="false"
|
|
||||||
hx-swap="none" method="post">
|
|
||||||
{% from "error_feedback.html.j2" import validation_error %}
|
|
||||||
{% macro simple_field(field, name, value, type) %}
|
|
||||||
<label class="form-label" for="{{ field }}">{{ name }}:</label>
|
|
||||||
{% if type == "textarea" %}
|
|
||||||
<textarea class="form-control" id="{{ field }}" name="{{ field }}" rows="5">{{ value }}</textarea>
|
|
||||||
{% else %}
|
|
||||||
<input class="form-control" aria-describedby="{{ field }}-error" type="{{ type }}" id="{{ field }}"
|
|
||||||
name="{{ field }}" value="{{ value }}">
|
|
||||||
{% endif %}
|
|
||||||
{{ validation_error(field, errors) }}
|
|
||||||
{% endmacro %}
|
|
||||||
|
|
||||||
<div class="col">
|
|
||||||
{{ simple_field("title", "Title", book.title, "text") }}
|
|
||||||
<label class="form-label" for="authors">Authors:</label>
|
|
||||||
<select class="form-select tomselect" id="authors" aria-describedby="authors-error" name="authors[]"
|
|
||||||
multiple placeholder="Add new authors" autocomplete="off"
|
|
||||||
x-init="if (!$el.tomselect) new TomSelect($el, {create: true, persist: false, plugins: ['remove_button']})">
|
|
||||||
{% for author in authors %}
|
|
||||||
<option value="{{ author }}" {% if author in book.authors %} selected {% endif %}>{{ author }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
{{ validation_error("authors", errors) }}
|
|
||||||
|
|
||||||
<label class="form-label" for="genres">Genres:</label>
|
|
||||||
<select class="form-select tomselect" id="genres" aria-describedby="genres-error" name="genres[]" multiple
|
|
||||||
placeholder="Add new genres" autocomplete="off"
|
|
||||||
x-init="if (!$el.tomselect) new TomSelect($el, {create: true, persist: false, plugins: ['remove_button']})">
|
|
||||||
{% for genre in genres %}
|
|
||||||
<option value="{{ genre }}" {% if genre in book.genres %} selected {% endif %}>{{ genre }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
{{ validation_error("genres", errors) }}
|
|
||||||
|
|
||||||
<label class="form-label" for="owner_id">Owner:</label>
|
|
||||||
<select class="form-select" id="owner_id" name="owner_id">
|
|
||||||
<option value="" {% if book.owner_id is none %}selected{% endif %}></option>
|
|
||||||
{% for user in users %}
|
|
||||||
<option value="{{ user.id }}" {% if user.id==book.owner_id %}selected{% endif %}>{{
|
|
||||||
user.username.title() }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{{ simple_field("bought", "Bought", book.bought.strftime("%Y-%m-%d") if book.bought else "", "date")
|
|
||||||
}}
|
|
||||||
|
|
||||||
<label class="form-label" for="location">Location:</label>
|
|
||||||
<select class="form-select tomselect" id="location" aria-describedby="location-error" name="location"
|
|
||||||
placeholder="New location" autocomplete="off"
|
|
||||||
x-init="if (!$el.tomselect) new TomSelect($el, {create: true, persist: false})">
|
|
||||||
<option value=""></option>
|
|
||||||
{% for location in locations %}
|
|
||||||
<option value="{{ location }}" {% if location==book.location %} selected {% endif %}>{{ location
|
|
||||||
}}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
{{ validation_error("location", errors) }}
|
|
||||||
|
|
||||||
<div class="py-2 d-flex align-items-center">
|
|
||||||
<label class="btn btn-secondary" for="wishlisted">Wishlisted</label>
|
|
||||||
<input class="btn-check" hx-put="/books/{{ book.id }}" hx-trigger="change consume"
|
|
||||||
hx-target="#wished_by" hx-swap="outerHTML" hx-select="#wished_by" type="checkbox" id="wishlisted"
|
|
||||||
name="wishlisted" {% if g.user.username in wished_by %}checked{% endif %} autocomplete="off">
|
|
||||||
|
|
||||||
<div id="wished_by" class="ps-2">Wished by: {{ wished_by|join(", ") }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{ simple_field("description", "Description", book.description, "textarea") }}
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
{{ 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") }}
|
|
||||||
|
|
||||||
<div class="pt-2">
|
|
||||||
<input class="btn btn-primary" type="submit" hx-post="/books/{{ book.id }}" hx-target="body"
|
|
||||||
hx-swap="innerHTML" hx-push-url="true" value="Submit">
|
|
||||||
<button class="btn btn-danger" hx-delete="/books/{{ book.id }}" hx-target="body" hx-swap="innerHTML"
|
|
||||||
hx-confirm="Are you sure?" hx-push-url="true">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endblock form %}
|
|
||||||
|
|
||||||
{# readings table #}
|
|
||||||
<section hx-boost="false" hx-push-url="false">
|
|
||||||
<h2 class="py-2">Readings</h2>
|
|
||||||
<button class="btn btn-primary mb-2" hx-post="/books/{{book.id}}/readings/new" hx-trigger="click"
|
|
||||||
hx-swap="beforeend" hx-target="#readings-tbody">Start reading</button>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped collapse-rows">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>User</th>
|
|
||||||
<th>Started</th>
|
|
||||||
<th>Finished</th>
|
|
||||||
<th>Rating</th>
|
|
||||||
<th>Finished?</th>
|
|
||||||
<th>Dropped?</th>
|
|
||||||
<th>Comments</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="readings-tbody">
|
|
||||||
{% for reading in book.readings %}
|
|
||||||
{% block reading_row scoped %}
|
|
||||||
{% if edit_reading_id != reading.id %}
|
|
||||||
<tr hx-get="/books/{{reading.book.id}}/readings/{{reading.id}}/edit" hx-target="this"
|
|
||||||
hx-swap="outerHTML">
|
|
||||||
<td data-label="User">{{ reading.user.username }}</td>
|
|
||||||
<td data-label="Started">{{ reading.start_date.strftime("%d-%m-%Y") }}</td>
|
|
||||||
<td data-label="Finished">{{ reading.end_date and reading.end_date.strftime("%d-%m-%Y") or "-"
|
|
||||||
}}</td>
|
|
||||||
<td data-label="Rating">{{ reading.rating or "-" }}</td>
|
|
||||||
<td data-label="Finished?">{{ reading.finished and "Yes" or "No" }}</td>
|
|
||||||
<td data-label="Dropped?">{{ reading.dropped and "Yes" or "No" }}</td>
|
|
||||||
<td data-label="Comments">{{ reading.comments or "-" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr hx-put="/books/{{reading.book.id}}/readings/{{reading.id}}" hx-trigger="change"
|
|
||||||
hx-swap="outerHTML" hx-include="this" hx-target="this">
|
|
||||||
<td data-label="User">{{ reading.user.username }}</td>
|
|
||||||
<td data-label="Started"><input class="form-control" type="date" id="reading-start_date"
|
|
||||||
name="start_date" value="{{ reading.start_date.strftime('%Y-%m-%d') }}"></td>
|
|
||||||
<td data-label="Finished"><input class="form-control" type="date" id="reading-end_date"
|
|
||||||
name="end_date"
|
|
||||||
value="{{ reading.end_date and reading.end_date.strftime('%Y-%m-%d') }}"></td>
|
|
||||||
<td data-label="Rating"><input class="form-control" type="number" id="reading-rating"
|
|
||||||
name="rating" value="{{ reading.rating }}">
|
|
||||||
</td>
|
|
||||||
<td data-label="Finished?"><input class="form-check-input" type="checkbox" id="reading-finished"
|
|
||||||
name="finished" {% if reading.finished %}checked{% endif %}></td>
|
|
||||||
<td data-label="Dropped?"><input class="form-check-input" type="checkbox" id="reading-dropped"
|
|
||||||
name="dropped" {% if reading.dropped %}checked{% endif %}></td>
|
|
||||||
<td data-label="Comments"><textarea rows="3" cols="20" class="form-control"
|
|
||||||
id="reading-comments" name="comments">{{ reading.comments }}</textarea></td>
|
|
||||||
<td data-label="Options">
|
|
||||||
<button class="btn btn-danger"
|
|
||||||
hx-delete="/books/{{reading.book.id}}/readings/{{reading.id}}"
|
|
||||||
hx-confirm="Are you sure?" hx-target="closest tr" hx-swap="outerHTML">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock reading_row %}
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
{# booklist main view #}
|
|
||||||
{% extends "base.html.j2" %}
|
|
||||||
|
|
||||||
{% block header %}
|
|
||||||
<h1>{% block title %}Books{% endblock title %}</h1>
|
|
||||||
{% endblock header %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<div class="pb-2">
|
|
||||||
<div class="list-group list-group-horizontal" style="overflow-x: auto">
|
|
||||||
<span class="list-group-item">Searches:</span>
|
|
||||||
{% for name in get_default_searches(g.user.username)|list + g.user.saved_searches|list %}
|
|
||||||
<a class="list-group-item list-group-item-action border-0 {{'active' if search_req.saved_search == name else ''}}"
|
|
||||||
style="width: auto" href="/books?saved_search={{ name|urlencode }}" hx-swap="innerHTML show:no-scroll">{{
|
|
||||||
name }}</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# search form and hidden table state #}
|
|
||||||
<form id="search-form" action="/books" method="get" hx-include="closest form"
|
|
||||||
x-data="{columns: (JSON.parse(localStorage.getItem('columns')) || ['title', 'authors', 'genres'])}"
|
|
||||||
x-init="$watch('columns', (val) => localStorage.setItem('columns', JSON.stringify(val)))">
|
|
||||||
|
|
||||||
<div class="input-group pb-2">
|
|
||||||
<input class="form-control" type="text" name="q" placeholder="Begin typing to search books" hx-get="/books"
|
|
||||||
hx-target="#search-results" hx-trigger="keyup changed delay:500ms, search" value="{{search_req['q']|e}}">
|
|
||||||
<button class="btn btn-primary" type="submit">Search</button>
|
|
||||||
<button class="btn btn-secondary" type="button" hx-post="/books/saved_search"
|
|
||||||
hx-prompt="Name your search">Save</button>
|
|
||||||
<button class="btn btn-danger" type="button" hx-delete="/books/saved_search"
|
|
||||||
hx-prompt="Which search would you like to delete?">Delete</button>
|
|
||||||
</div>
|
|
||||||
<div x-data="{ofilters: false, ocols: false}">
|
|
||||||
<div class="btn-toolbar pb-2 justify-content-between">
|
|
||||||
<div class="btn-group pe-2">
|
|
||||||
<button class="btn btn-primary border" type="button" hx-get="/books/new">New</button>
|
|
||||||
<button class="btn btn-primary border" type="button"
|
|
||||||
onclick="document.querySelector('#isbn-prompt').showModal()">Import</button>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn btn-primary border" type="button" @click="ofilters = !ofilters">Filters</button>
|
|
||||||
<button class="btn btn-primary border" type="button" @click="ocols = !ocols">Columns</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div x-show="ofilters">
|
|
||||||
{% macro yes_no_filter(field, name) %}
|
|
||||||
<div class="col form-floating">
|
|
||||||
<select class="form-select" hx-get="/books" hx-target="#search-results" hx-trigger="change"
|
|
||||||
name="{{field}}">
|
|
||||||
<option value="1" {% if search_req[field]==true %}selected{% endif %}>Yes</option>
|
|
||||||
<option value="0" {% if search_req[field]==false %}selected{% endif %}>No</option>
|
|
||||||
<option value="" {% if search_req[field]==none %}selected{% endif %}></option>
|
|
||||||
</select>
|
|
||||||
<label class="ms-2" for="{{field}}">{{name}}</label>
|
|
||||||
</div>
|
|
||||||
{% endmacro %}
|
|
||||||
<fieldset class="row pb-2 align-items-center">
|
|
||||||
<legend class="col-auto">Filters:</legend>
|
|
||||||
<div class="col row row-cols-2 row-cols-md-4">
|
|
||||||
{{ yes_no_filter('wishlisted', 'Wishlisted') }}
|
|
||||||
{{ yes_no_filter('read', 'Read') }}
|
|
||||||
{{ yes_no_filter('reading', 'Reading') }}
|
|
||||||
{{ yes_no_filter('dropped', 'Dropped') }}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
{% macro date_filter(field, name) %}
|
|
||||||
<fieldset class="row pb-2 align-items-center">
|
|
||||||
<legend class="col-auto">{{name}}:</legend>
|
|
||||||
<div class="col row">
|
|
||||||
<div class="col">
|
|
||||||
<input class="form-control" type="date" name="{{field}}_start" hx-get="/books"
|
|
||||||
hx-target="#search-results" value="{{search_req[field + '_start']|e}}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="col-auto">—</span>
|
|
||||||
|
|
||||||
<div class="col">
|
|
||||||
<input class="form-control" type="date" name="{{field}}_end" hx-get="/books"
|
|
||||||
hx-target="#search-results" value="{{search_req[field + '_end']|e}}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
{% endmacro %}
|
|
||||||
{{ date_filter('bought', 'Bought') }}
|
|
||||||
{{ date_filter('started_reading', 'Started') }}
|
|
||||||
{{ date_filter('finished_reading', 'Finished') }}
|
|
||||||
</div>
|
|
||||||
<fieldset x-show="ocols">
|
|
||||||
{% for c in all_columns %}
|
|
||||||
<div class="form-check form-check-inline">
|
|
||||||
<label class="form-check-label">
|
|
||||||
<input class="form-check-input" type="checkbox" x-model="columns" value="{{c}}">{{c}}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input type="hidden" name="sort_by" value="{{search_req.sort_by}}">
|
|
||||||
<input type="hidden" name="sort_order" value="{{search_req.sort_order}}">
|
|
||||||
|
|
||||||
|
|
||||||
{# results table #}
|
|
||||||
<div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped table-hover collapse-rows">
|
|
||||||
<thead x-data="{col_sizes: (JSON.parse(localStorage.getItem('col_sizes')) || {})}"
|
|
||||||
x-init="$watch('col_sizes', (val) => localStorage.setItem('col_sizes', JSON.stringify(val)))">
|
|
||||||
<tr>
|
|
||||||
{% macro results_th(col) %}
|
|
||||||
<th x-show="columns.includes('{{col}}')">
|
|
||||||
<div x-bind:style="'resize: horizontal; overflow: auto; ' + (
|
|
||||||
col_sizes['{{col}}'] ? 'width: ' + col_sizes['{{col}}'] + 'px' : '')" x-init="new ResizeObserver(entries => {
|
|
||||||
for (let entry of entries) {
|
|
||||||
col_sizes['{{col}}'] = entry.contentRect.width;
|
|
||||||
}
|
|
||||||
}).observe($el);">
|
|
||||||
<span hx-get="/books" hx-vals='{
|
|
||||||
"sort_by": "{{col}}", "sort_order": "{{"asc" if
|
|
||||||
col != search_req.sort_by or (col == search_req.sort_by and
|
|
||||||
search_req.sort_order == "desc")
|
|
||||||
else "desc"}}"}'>{{ col|replace("_", " ")|capitalize }}{% if col ==
|
|
||||||
search_req.sort_by %}{{ "▴" if
|
|
||||||
search_req.sort_order == "asc" else "▾"}}{% endif %}</span>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
{% endmacro %}
|
|
||||||
{% for col in all_columns %}
|
|
||||||
{{ results_th(col) }}
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="search-results">
|
|
||||||
{% block search_results %}
|
|
||||||
{% for book in books %}
|
|
||||||
<tr hx-get="/books/{{book.id}}" hx-params="none">
|
|
||||||
{% macro results_td(col, text) %}
|
|
||||||
<td x-show="columns.includes('{{col}}')" data-label="{{col|replace('_', ' ')|capitalize}}">{{
|
|
||||||
text or '-' }}</td>
|
|
||||||
{% 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 '') }}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% endblock search_results %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<dialog id="isbn-prompt">
|
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
|
||||||
<div class="modal-content">
|
|
||||||
<form action="/books/import" method="post">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">Import a book</h5>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<label class="form-label" for="isbn">ISBN:</label>
|
|
||||||
<input class="form-control" type="text" name="isbn" id="isbn" inputmode="numeric" required>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer pt-2">
|
|
||||||
<button type="button" class="btn btn-secondary me-2" hx-post="/books/import">Import</button>
|
|
||||||
<button type="button" class="btn btn-secondary"
|
|
||||||
onClick="document.querySelector('#isbn-prompt').close()">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{% macro validation_error(field, errors) %}
|
|
||||||
{% if field in errors %}
|
|
||||||
<span id="{{ field }}-error" hx-swap-oob="true" class="invalid-feedback"
|
|
||||||
hx-on::load="document.getElementById('{{ field }}').classList.add('is-invalid')">{{ errors.get(field)
|
|
||||||
}}</span>
|
|
||||||
{% else %}
|
|
||||||
<span id="{{ field }}-error" hx-swap-oob="true" class="invalid-feedback"
|
|
||||||
hx-on::load="document.getElementById('{{ field }}').classList.remove('is-invalid')"></span>
|
|
||||||
{% endif %}
|
|
||||||
{% endmacro %}
|
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user