Add server-side validation and small improvements

This commit is contained in:
2026-03-20 21:35:53 +01:00
parent cc03e60a4b
commit 04cd6dafb0
9 changed files with 436 additions and 157 deletions

View File

@@ -14,6 +14,7 @@ dependencies = [
"gunicorn>=25.1.0", "gunicorn>=25.1.0",
"jinja2-fragments>=1.11.0", "jinja2-fragments>=1.11.0",
"pydantic>=2.12.5", "pydantic>=2.12.5",
"pydantic-extra-types>=2.11.1",
"pyparsing>=3.3.2", "pyparsing>=3.3.2",
"requests>=2.32.5", "requests>=2.32.5",
"sqlalchemy>=2.0.48", "sqlalchemy>=2.0.48",

View File

@@ -35,6 +35,8 @@ def create_book(
location_shelf: int | None = None, location_shelf: int | None = None,
first_published: int | None = None, first_published: int | None = None,
bought_date: date | None = None, bought_date: date | None = None,
loaned_to: str | None = None,
loaned_date: date | None = None,
) -> Book: ) -> Book:
"""Create a new book with the given details.""" """Create a new book with the given details."""
book = Book( book = Book(
@@ -50,6 +52,8 @@ def create_book(
location_shelf=location_shelf, location_shelf=location_shelf,
first_published=first_published, first_published=first_published,
bought_date=bought_date, bought_date=bought_date,
loaned_to=loaned_to or "",
loaned_date=loaned_date,
) )
db.session.add(book) db.session.add(book)
@@ -90,6 +94,8 @@ def get_book(book_id: int) -> Book | None:
def update_book( def update_book(
book_id: int, book_id: int,
set_all_fields: bool = False, # If True, all fields must be provided and will be set (even if None)
owner_id: int | None = None,
title: str | None = None, title: str | None = None,
authors: list[str] | None = None, authors: list[str] | None = None,
genres: list[str] | None = None, genres: list[str] | None = None,
@@ -103,6 +109,8 @@ def update_book(
location_shelf: int | None = None, location_shelf: int | None = None,
first_published: int | None = None, first_published: int | None = None,
bought_date: date | None = None, bought_date: date | None = None,
loaned_to: str | None = None,
loaned_date: date | None = None,
) -> Book | None: ) -> Book | None:
"""Update a book with new details.""" """Update a book with new details."""
book = get_book(book_id) book = get_book(book_id)
@@ -110,43 +118,73 @@ def update_book(
return None return None
# Update scalar fields # Update scalar fields
if title is not None: if title is not None or set_all_fields:
assert title is not None, "Title is required when set_all_fields is True"
book.title = title book.title = title
if isbn is not None: if isbn is not None or set_all_fields:
assert isbn is not None, "ISBN is required when set_all_fields is True"
book.isbn = isbn book.isbn = isbn
if publisher is not None: if publisher is not None or set_all_fields:
assert publisher is not None, (
"Publisher is required when set_all_fields is True"
)
book.publisher = publisher book.publisher = publisher
if edition is not None: if edition is not None or set_all_fields:
assert edition is not None, "Edition is required when set_all_fields is True"
book.edition = edition book.edition = edition
if description is not None: if description is not None or set_all_fields:
assert description is not None, (
"Description is required when set_all_fields is True"
)
book.description = description book.description = description
if notes is not None: if notes is not None or set_all_fields:
assert notes is not None, "Notes is required when set_all_fields is True"
book.notes = notes book.notes = notes
if location_place is not None: if location_place is not None or set_all_fields:
assert location_place is not None, (
"Location place is required when set_all_fields is True"
)
book.location_place = location_place book.location_place = location_place
if location_bookshelf is not None: if location_bookshelf is not None or set_all_fields:
assert location_bookshelf is not None, (
"Location bookshelf is required when set_all_fields is True"
)
book.location_bookshelf = location_bookshelf book.location_bookshelf = location_bookshelf
if location_shelf is not None: if location_shelf is not None or set_all_fields:
book.location_shelf = location_shelf book.location_shelf = location_shelf
if first_published is not None: if first_published is not None or set_all_fields:
book.first_published = first_published book.first_published = first_published
if bought_date is not None: if bought_date is not None or set_all_fields:
book.bought_date = bought_date book.bought_date = bought_date
if loaned_to is not None or set_all_fields:
assert loaned_to is not None, (
"Loaned to is required when set_all_fields is True"
)
book.loaned_to = loaned_to
if loaned_date is not None or set_all_fields:
book.loaned_date = loaned_date
# Update authors # Update authors
if authors is not None: if authors is not None or set_all_fields:
assert authors is not None, (
"Authors list is required when set_all_fields is True"
)
book.authors.clear() book.authors.clear()
for author_name in [a_strip for a in authors if (a_strip := a.strip())]: for author_name in [a_strip for a in authors if (a_strip := a.strip())]:
author = _get_or_create_author(author_name) author = _get_or_create_author(author_name)
book.authors.append(author) book.authors.append(author)
# Update genres # Update genres
if genres is not None: if genres is not None or set_all_fields:
assert genres is not None, "Genres list is required when set_all_fields is True"
book.genres.clear() book.genres.clear()
for genre_name in [g_strip for g in genres if (g_strip := g.strip())]: for genre_name in [g_strip for g in genres if (g_strip := g.strip())]:
genre = _get_or_create_genre(genre_name) genre = _get_or_create_genre(genre_name)
book.genres.append(genre) book.genres.append(genre)
if owner_id is not None or set_all_fields:
book.owner_id = owner_id
db.session.commit() db.session.commit()
return book return book
@@ -632,6 +670,17 @@ def get_reading_history(user_id: int, limit: int = 50) -> Sequence[Reading]:
) )
def delete_reading(reading_id: int) -> bool:
"""Delete a reading session."""
reading = db.session.get(Reading, reading_id)
if not reading:
return False
db.session.delete(reading)
db.session.commit()
return True
def add_to_wishlist(book_id: int, user_id: int) -> Wishlist: def add_to_wishlist(book_id: int, user_id: int) -> Wishlist:
"""Add a book to user's wishlist.""" """Add a book to user's wishlist."""
# Check if book exists # Check if book exists

View File

@@ -4,7 +4,8 @@ Main application routes for HXBooks frontend.
Provides clean URL structure and integrates with library.py business logic. Provides clean URL structure and integrates with library.py business logic.
""" """
from typing import Any from datetime import date
from typing import Annotated, Any
from flask import ( from flask import (
Blueprint, Blueprint,
@@ -17,8 +18,16 @@ from flask import (
url_for, url_for,
) )
from flask.typing import ResponseReturnValue from flask.typing import ResponseReturnValue
from pydantic import (
BaseModel,
BeforeValidator,
Field,
StringConstraints,
ValidationError,
)
from pydantic_extra_types.isbn import ISBN
from hxbooks.models import User from hxbooks.models import Reading, User
from . import library from . import library
from .db import db from .db import db
@@ -26,25 +35,56 @@ from .db import db
bp = Blueprint("main", __name__) bp = Blueprint("main", __name__)
def save_search(user: User, search_name: str, query_params: str) -> bool: # Pydantic validation models
"""Save a search for a user. Mock implementation.""" StrOrNone = Annotated[str | None, BeforeValidator(lambda v: v.strip() or None)]
# Initialize saved_searches if None StripStr = Annotated[str, StringConstraints(strip_whitespace=True)]
ISBNOrNone = Annotated[ISBN | None, BeforeValidator(lambda v: v.strip() or None)]
user.saved_searches = user.saved_searches | {search_name: query_params} # noqa: PLR6104 TextareaList = Annotated[
print(f"{user.saved_searches=}") list[str],
db.session.commit() BeforeValidator(
return True lambda v: (
[line.strip() for line in v.split("\n") if line.strip()]
if isinstance(v, str)
else v or []
)
),
]
DateOrNone = Annotated[date | None, BeforeValidator(lambda v: v.strip() or None)]
IntOrNone = Annotated[int | None, BeforeValidator(lambda v: v.strip() or None)]
def delete_saved_search(user: User, search_name: str) -> bool: class BookFormData(BaseModel):
"""Delete a saved search for a user. Mock implementation.""" title: StripStr = Field(min_length=1)
if search_name in user.saved_searches: owner: StrOrNone = None
user.saved_searches = { isbn: ISBNOrNone = None
k: v for k, v in user.saved_searches.items() if k != search_name authors: TextareaList = Field(default_factory=list)
} # needs to be a new object to trigger SQLAlchemy change detection genres: TextareaList = Field(default_factory=list)
db.session.commit() first_published: IntOrNone = Field(default=None, le=2030)
return True publisher: StripStr = Field(default="")
return False edition: StripStr = Field(default="")
description: StripStr = Field(default="")
notes: StripStr = Field(default="")
location_place: StripStr = Field(default="")
location_bookshelf: StripStr = Field(default="")
location_shelf: IntOrNone = Field(default=None, ge=1)
loaned_to: StripStr = Field(default="")
loaned_date: DateOrNone = None
class ReadingFormData(BaseModel):
start_date: date
end_date: DateOrNone = None
dropped: bool = Field(default=False)
rating: IntOrNone = Field(default=None, ge=1, le=5)
comments: StripStr = Field(default="")
def _flash_validation_errors(e: ValidationError) -> None:
"""Helper to flash validation errors."""
error = e.errors()[0]
loc = " -> ".join(str(v) for v in error.get("loc", []))
msg = error.get("msg", "Invalid input")
flash(f"Validation error in '{loc}': {msg}", "error")
@bp.before_app_request @bp.before_app_request
@@ -101,61 +141,60 @@ def book_detail(book_id: int) -> ResponseReturnValue:
flash("Book not found", "error") flash("Book not found", "error")
return redirect(url_for("main.index")) return redirect(url_for("main.index"))
for reading in book.readings:
print(
f"Reading: {reading}, user: {reading.user.username if reading.user else 'N/A'}, dropped: {reading.dropped}, finished: {reading.finished}, end_date: {reading.end_date}"
)
return render_template("book/detail.html.j2", book=book) return render_template("book/detail.html.j2", book=book)
def _get_or_create_user(username: str) -> int:
"""Helper to get or create a user by username."""
owner = library.get_user_by_username(username)
if not owner:
# Create new user if username doesn't exist
owner = library.create_user(username)
return owner.id
@bp.route("/book/new", methods=["GET", "POST"]) @bp.route("/book/new", methods=["GET", "POST"])
def create_book() -> ResponseReturnValue: def create_book() -> ResponseReturnValue:
"""Create a new book.""" """Create a new book."""
if request.method == "POST": if request.method == "POST":
title = request.form.get("title", "").strip()
if not title:
flash("Title is required", "error")
return render_template("book/create.html.j2")
try: try:
# Get current viewing user as owner # Validate form data with Pydantic
viewing_user = g.get("viewing_user") form_data = BookFormData.model_validate(dict(request.form))
# Process textarea inputs for authors and genres # Get owner ID if provided
authors = [ owner_id = _get_or_create_user(form_data.owner) if form_data.owner else None
author.strip()
for author in request.form.get("authors", "").split("\n")
if author.strip()
]
genres = [
genre.strip()
for genre in request.form.get("genres", "").split("\n")
if genre.strip()
]
# Create book with submitted data # Create book with validated data
book = library.create_book( book = library.create_book(
title=title, title=form_data.title,
owner_id=viewing_user.id if viewing_user else None, owner_id=owner_id,
authors=authors, authors=form_data.authors,
genres=genres, genres=form_data.genres,
isbn=request.form.get("isbn"), isbn=str(form_data.isbn) if form_data.isbn else None,
publisher=request.form.get("publisher"), publisher=form_data.publisher,
edition=request.form.get("edition"), edition=form_data.edition,
description=request.form.get("description"), description=form_data.description,
notes=request.form.get("notes"), notes=form_data.notes,
location_place=request.form.get("location_place"), location_place=form_data.location_place,
location_bookshelf=request.form.get("location_bookshelf"), location_bookshelf=form_data.location_bookshelf,
location_shelf=int(request.form.get("location_shelf") or 0) or None, location_shelf=form_data.location_shelf,
first_published=int(request.form.get("first_published") or 0) or None, first_published=form_data.first_published,
loaned_to=form_data.loaned_to,
loaned_date=form_data.loaned_date,
) )
flash(f"Book '{title}' created successfully!", "success") flash(f"Book '{form_data.title}' created successfully!", "success")
return redirect(url_for("main.book_detail", book_id=book.id)) return redirect(url_for("main.book_detail", book_id=book.id))
except ValidationError as e:
_flash_validation_errors(e)
return render_template("book/create.html.j2", form_data=request.form)
except Exception as e: except Exception as e:
flash(f"Error creating book: {e}", "error") flash(f"Error creating book: {e}", "error")
return render_template("book/create.html.j2", form_data=request.form)
return render_template("book/create.html.j2") return render_template("book/create.html.j2")
@@ -169,37 +208,39 @@ def update_book(book_id: int) -> ResponseReturnValue:
return redirect(url_for("main.index")) return redirect(url_for("main.index"))
try: try:
# Process textarea inputs for authors and genres # Validate form data with Pydantic
authors = [ form_data = BookFormData.model_validate(dict(request.form))
author.strip()
for author in request.form.get("authors", "").split("\n")
if author.strip()
]
genres = [
genre.strip()
for genre in request.form.get("genres", "").split("\n")
if genre.strip()
]
# Update book with form data # Get owner ID if provided
owner_id = _get_or_create_user(form_data.owner) if form_data.owner else None
# Update book with validated data
library.update_book( library.update_book(
book_id=book_id, book_id=book_id,
title=request.form.get("title"), set_all_fields=True, # Ensure all fields are updated, even if None/empty
authors=authors, title=form_data.title,
genres=genres, owner_id=owner_id,
isbn=request.form.get("isbn"), authors=form_data.authors,
publisher=request.form.get("publisher"), genres=form_data.genres,
edition=request.form.get("edition"), isbn=str(form_data.isbn) if form_data.isbn else None,
description=request.form.get("description"), publisher=form_data.publisher,
notes=request.form.get("notes"), edition=form_data.edition,
location_place=request.form.get("location_place"), description=form_data.description,
location_bookshelf=request.form.get("location_bookshelf"), notes=form_data.notes,
location_shelf=int(request.form.get("location_shelf") or 0) or None, location_place=form_data.location_place,
first_published=int(request.form.get("first_published") or 0) or None, location_bookshelf=form_data.location_bookshelf,
location_shelf=form_data.location_shelf,
first_published=form_data.first_published,
loaned_to=form_data.loaned_to,
loaned_date=form_data.loaned_date,
) )
flash("Book updated successfully!", "success") flash("Book updated successfully!", "success")
except ValidationError as e:
# Format validation errors for display
_flash_validation_errors(e)
except Exception as e: except Exception as e:
flash(f"Error updating book: {e}", "error") flash(f"Error updating book: {e}", "error")
@@ -273,6 +314,26 @@ def set_viewing_user(username: str = "") -> ResponseReturnValue:
return redirect(request.referrer or url_for("main.index")) return redirect(request.referrer or url_for("main.index"))
def _save_search(user: User, search_name: str, query_params: str) -> bool:
"""Save a search for a user. Mock implementation."""
# Initialize saved_searches if None
user.saved_searches = user.saved_searches | {search_name: query_params} # noqa: PLR6104
db.session.commit()
return True
def _delete_saved_search(user: User, search_name: str) -> bool:
"""Delete a saved search for a user. Mock implementation."""
if search_name in user.saved_searches:
user.saved_searches = {
k: v for k, v in user.saved_searches.items() if k != search_name
} # needs to be a new object to trigger SQLAlchemy change detection
db.session.commit()
return True
return False
@bp.route("/saved-search", methods=["POST"]) @bp.route("/saved-search", methods=["POST"])
def save_search_route() -> ResponseReturnValue: def save_search_route() -> ResponseReturnValue:
"""Save a search for the current user.""" """Save a search for the current user."""
@@ -283,16 +344,13 @@ def save_search_route() -> ResponseReturnValue:
search_name = request.form.get("name", "").strip() search_name = request.form.get("name", "").strip()
query_params = request.form.get("query_params", "") query_params = request.form.get("query_params", "")
print(
f"Saving search for user {viewing_user.username}: {search_name} -> {query_params}"
)
if not search_name: if not search_name:
flash("Search name is required", "error") flash("Search name is required", "error")
return redirect(url_for("main.index", q=query_params)) return redirect(url_for("main.index", q=query_params))
try: try:
success = save_search(viewing_user, search_name, query_params) success = _save_search(viewing_user, search_name, query_params)
if success: if success:
flash(f"Search '{search_name}' saved successfully!", "success") flash(f"Search '{search_name}' saved successfully!", "success")
else: else:
@@ -411,6 +469,70 @@ def remove_from_wishlist_route(book_id: int) -> ResponseReturnValue:
return redirect(url_for("main.book_detail", book_id=book_id)) return redirect(url_for("main.book_detail", book_id=book_id))
@bp.route("/book/<int:book_id>/reading/<int:reading_id>/update", methods=["POST"])
def update_reading_route(book_id: int, reading_id: int) -> ResponseReturnValue:
"""Update a single reading session."""
viewing_user = g.get("viewing_user")
if not viewing_user:
flash("You must select a user to update readings", "error")
return redirect(url_for("main.book_detail", book_id=book_id))
try:
# Get and verify the reading belongs to current user
reading = db.session.get(Reading, reading_id)
if not reading or reading.user_id != viewing_user.id:
flash("Reading not found or not yours to modify", "error")
return redirect(url_for("main.book_detail", book_id=book_id))
# Validate the form data
form_data = ReadingFormData.model_validate(dict(request.form))
# Update the reading with validated data
reading.start_date = form_data.start_date
reading.end_date = form_data.end_date
reading.dropped = form_data.dropped
reading.rating = form_data.rating
reading.comments = form_data.comments
db.session.commit()
flash("Reading updated successfully!", "success")
except ValidationError as e:
db.session.rollback() # Rollback any partial changes on validation error
_flash_validation_errors(e)
except Exception as e:
db.session.rollback() # Rollback any partial changes on general error
flash(f"Error updating reading: {e}", "error")
return redirect(url_for("main.book_detail", book_id=book_id))
@bp.route("/book/<int:book_id>/reading/<int:reading_id>/delete", methods=["POST"])
def delete_reading_route(book_id: int, reading_id: int) -> ResponseReturnValue:
"""Delete a reading session."""
viewing_user = g.get("viewing_user")
if not viewing_user:
flash("You must select a user to delete readings", "error")
return redirect(url_for("main.book_detail", book_id=book_id))
try:
# Verify the reading belongs to the current user
reading = db.session.get(Reading, reading_id)
if reading and reading.user_id == viewing_user.id:
deleted = library.delete_reading(reading_id)
if deleted:
flash("Reading session deleted", "info")
else:
flash("Reading session not found", "warning")
else:
flash("Reading session not found or not yours to delete", "error")
except Exception as e:
flash(f"Error deleting reading: {e}", "error")
return redirect(url_for("main.book_detail", book_id=book_id))
@bp.route("/saved-search/<search_name>/delete", methods=["GET", "POST"]) @bp.route("/saved-search/<search_name>/delete", methods=["GET", "POST"])
def delete_saved_search_route(search_name: str) -> ResponseReturnValue: def delete_saved_search_route(search_name: str) -> ResponseReturnValue:
"""Delete a saved search (GET shows confirmation, POST performs deletion).""" """Delete a saved search (GET shows confirmation, POST performs deletion)."""
@@ -428,7 +550,7 @@ def delete_saved_search_route(search_name: str) -> ResponseReturnValue:
if request.method == "POST": if request.method == "POST":
# Perform the actual deletion # Perform the actual deletion
try: try:
success = delete_saved_search(viewing_user, search_name) success = _delete_saved_search(viewing_user, search_name)
if success: if success:
flash(f"Saved search '{search_name}' deleted successfully!", "success") flash(f"Saved search '{search_name}' deleted successfully!", "success")
else: else:

View File

@@ -53,7 +53,7 @@
.book-card:hover { .book-card:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
} }
.book-card .card-img-top { .book-card .card-img-top {
@@ -69,24 +69,19 @@
} }
.book-card-footer { .book-card-footer {
background-color: rgba(0,0,0,0.05); background-color: rgba(0, 0, 0, 0.05);
font-size: 0.875rem; font-size: 0.875rem;
} }
/* Search Bar */
.search-container {
max-width: 600px;
}
/* Form Styles */ /* Form Styles */
.form-floating > .form-control:focus, .form-floating>.form-control:focus,
.form-floating > .form-control:not(:placeholder-shown) { .form-floating>.form-control:not(:placeholder-shown) {
padding-top: 1.625rem; padding-top: 1.625rem;
padding-bottom: .625rem; padding-bottom: .625rem;
} }
.form-floating > .form-control:focus ~ label, .form-floating>.form-control:focus~label,
.form-floating > .form-control:not(:placeholder-shown) ~ label { .form-floating>.form-control:not(:placeholder-shown)~label {
opacity: .65; opacity: .65;
transform: scale(.85) translateY(-.5rem) translateX(.15rem); transform: scale(.85) translateY(-.5rem) translateX(.15rem);
} }

View File

@@ -7,15 +7,19 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{% endblock %} - HXBooks</title> <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='bootstrap.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='tom-select.css') }}"> {#
<link rel="stylesheet" href="{{ url_for('static', filename='tom-select.css') }}"> #}
<link rel="stylesheet" href="{{ url_for('static', filename='style.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="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="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicon-16x16.png') }}">
<link rel="manifest" href="{{ url_for('static', filename='site.webmanifest') }}"> <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='htmx.min.js') }}"></script> #}
<script src="{{ url_for('static', filename='tom-select.complete.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> <script>
// HTMX error handling // HTMX error handling

View File

@@ -4,7 +4,7 @@
{% block header %} {% block header %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">Library</h1> {# <h1 class="h3 mb-0">Library</h1> #}
<!-- Search Bar --> <!-- Search Bar -->
<div class="search-container mx-3 flex-grow-1"> <div class="search-container mx-3 flex-grow-1">

View File

@@ -1,10 +1,17 @@
<!-- Basic Information --> <!-- Basic Information -->
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-8"> <div class="col-md-6">
<label for="title" class="form-label">Title *</label> <label for="title" class="form-label">Title *</label>
<input type="text" class="form-control" id="title" name="title" value="{{ book.title if book else '' }}" required> <input type="text" class="form-control" id="title" name="title" value="{{ book.title if book else '' }}" required>
</div> </div>
<div class="col-md-4"> <div class="col-md-3">
<label for="owner" class="form-label">Owner</label>
<input type="text" class="form-control" id="owner" name="owner"
value="{% if book and book.owner %}{{ book.owner.username }}{% elif g.viewing_user %}{{ g.viewing_user.username }}{% endif %}"
placeholder="Username">
<div class="form-text">Leave empty for no owner</div>
</div>
<div class="col-md-3">
<label for="isbn" class="form-label">ISBN</label> <label for="isbn" class="form-label">ISBN</label>
<input type="text" class="form-control" id="isbn" name="isbn" value="{{ book.isbn if book else '' }}"> <input type="text" class="form-control" id="isbn" name="isbn" value="{{ book.isbn if book else '' }}">
</div> </div>
@@ -70,6 +77,20 @@
</div> </div>
</div> </div>
<!-- Loan Information -->
<div class="row mb-3">
<div class="col-md-6">
<label for="loaned_to" class="form-label">Loaned To</label>
<input type="text" class="form-control" id="loaned_to" name="loaned_to"
value="{{ book.loaned_to if book else '' }}" placeholder="Person's name">
</div>
<div class="col-md-6">
<label for="loaned_date" class="form-label">Loan Date</label>
<input type="date" class="form-control" id="loaned_date" name="loaned_date"
value="{{ book.loaned_date.strftime('%Y-%m-%d') if book and book.loaned_date else '' }}">
</div>
</div>
<!-- Notes --> <!-- Notes -->
<div class="mb-3"> <div class="mb-3">
<label for="notes" class="form-label">Personal Notes</label> <label for="notes" class="form-label">Personal Notes</label>

View File

@@ -2,52 +2,124 @@
{% if g.viewing_user %} {% if g.viewing_user %}
{% set user_readings = book.readings | selectattr('user_id', 'equalto', g.viewing_user.id) | list %} {% set user_readings = book.readings | selectattr('user_id', 'equalto', g.viewing_user.id) | list %}
{% set current_reading = user_readings | selectattr('end_date', 'none') | selectattr('dropped', 'false') | first %} {% set current_reading = user_readings | selectattr('end_date', 'none') | selectattr('dropped', 'false') | first %}
{% set reading_history = user_readings | selectattr('end_date') | sort(attribute='end_date', reverse=true) %} {% set completed_readings = user_readings | selectattr('end_date') | sort(attribute='end_date', reverse=true) %}
{% set latest_rated_reading = completed_readings | selectattr('rating') | first %}
<div class="mb-3"> <div class="mb-3">
<h6 class="text-muted mb-2">📖 Reading Status</h6> <h6 class="text-muted mb-2">📖 Reading Status</h6>
<!-- Current Reading Status --> <!-- Action Buttons -->
{% if current_reading %} <div class="mb-3">
<div class="alert alert-info py-2"> {% if current_reading %}
<strong>Currently Reading</strong><br> <form action="/book/{{ book.id }}/reading/finish" method="POST" class="d-inline">
<small>Started: {{ current_reading.start_date.strftime('%B %d, %Y') }}</small> <button type="submit" class="btn btn-success btn-sm me-2">✓ Finish Reading</button>
</div> </form>
<form action="/book/{{ book.id }}/reading/finish" method="POST" class="d-inline"> <form action="/book/{{ book.id }}/reading/drop" method="POST" class="d-inline">
<button type="submit" class="btn btn-success btn-sm me-2">✓ Finish Reading</button> <button type="submit" class="btn btn-outline-secondary btn-sm">⏸ Drop Reading</button>
</form> </form>
<form action="/book/{{ book.id }}/reading/drop" method="POST" class="d-inline"> {% else %}
<button type="submit" class="btn btn-outline-secondary btn-sm">⏸ Drop Reading</button> <form action="/book/{{ book.id }}/reading/start" method="POST" class="d-inline">
</form> <button type="submit" class="btn btn-primary btn-sm">▶️ Start Reading</button>
{% else %} </form>
<!-- Not currently reading -->
{% if reading_history %}
<p class="text-muted small mb-2">Previously read</p>
{% else %}
<p class="text-muted small mb-2">Not read yet</p>
{% endif %}
<form action="/book/{{ book.id }}/reading/start" method="POST" class="d-inline">
<button type="submit" class="btn btn-primary btn-sm">▶️ Start Reading</button>
</form>
{% endif %}
<!-- Reading History Summary -->
{% if reading_history %}
<div class="mt-3">
<small class="text-muted">Reading History:</small>
{% for reading in reading_history[:3] %}
<div class="mt-1">
<small>
{% if not reading.dropped %} ✓ Finished {% else %} ⏸ Dropped {% endif %}
{{ reading.start_date.strftime('%m/%d/%Y') }} - {{ reading.end_date.strftime('%m/%d/%Y') }}
- ⭐{{ reading.rating or "-" }}/5
</small>
</div>
{% endfor %}
{% if reading_history | length > 3 %}
<small class="text-muted">... and {{ reading_history | length - 3 }} more</small>
{% endif %} {% endif %}
</div> </div>
<!-- Editable Reading Data -->
{% if user_readings %}
<!-- Current Book Rating (if any completed readings) -->
{% if completed_readings %}
<div class="alert alert-light border py-2 mb-3">
<form action="/book/{{ book.id }}/reading/{{ completed_readings[0].id }}/update" method="POST"
class="row align-items-center g-2">
<!-- Hidden fields to preserve other reading data -->
<input type="hidden" name="start_date" value="{{ completed_readings[0].start_date.strftime('%Y-%m-%d') }}">
<input type="hidden" name="end_date"
value="{{ completed_readings[0].end_date.strftime('%Y-%m-%d') if completed_readings[0].end_date else '' }}">
<input type="hidden" name="dropped" value="1" {{ 'checked' if completed_readings[0].dropped else '' }}>
<input type="hidden" name="comments" value="{{ completed_readings[0].comments or '' }}">
<div class="col-auto">
<label class="form-label mb-0"><strong>Book Rating:</strong></label>
</div>
<div class="col-auto">
<select class="form-select form-select-sm" name="rating" style="width: auto;">
<option value="">No rating</option>
{% for i in range(1, 6) %}
<option value="{{ i }}" {{ 'selected' if latest_rated_reading and latest_rated_reading.rating==i else '' }}>
{{ i }} ⭐</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary btn-sm">💾</button>
</div>
</form>
</div>
{% endif %}
<!-- All Reading Sessions -->
{% for reading in user_readings | sort(attribute='start_date', reverse=true) %}
<div class="border rounded p-3 mb-2 {% if reading == current_reading %}border-primary bg-light{% endif %}">
<form action="/book/{{ book.id }}/reading/{{ reading.id }}/update" method="POST">
<div class="row">
<div class="col-md-6">
<label class="form-label-sm">Start Date</label>
<input type="date" class="form-control form-control-sm" name="start_date"
value="{{ reading.start_date.strftime('%Y-%m-%d') }}">
</div>
<div class="col-md-6">
<label class="form-label-sm">End Date</label>
<input type="date" class="form-control form-control-sm" name="end_date"
value="{{ reading.end_date.strftime('%Y-%m-%d') if reading.end_date else '' }}">
</div>
</div>
<div class="row mt-2">
<div class="col-md-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="dropped" value="1" {{ 'checked' if reading.dropped
else '' }}>
<label class="form-check-label">Dropped</label>
</div>
</div>
<input type="hidden" name="rating" value="{{ reading.rating or '' }}">
</div>
<div class="mt-2">
<label class="form-label-sm">Comments</label>
<textarea class="form-control form-control-sm" rows="2" name="comments"
placeholder="Reading notes and comments...">{{ reading.comments or '' }}</textarea>
</div>
<!-- Reading Status Display and Actions -->
<div class="mt-2 d-flex justify-content-between align-items-center">
<small class="text-muted">
{% if reading == current_reading %}
<span class="badge bg-primary">Currently Reading</span>
{% elif reading.dropped %}
<span class="badge bg-secondary">Dropped</span>
{% elif reading.end_date %}
<span class="badge bg-success">Completed</span>
{% endif %}
</small>
<div>
<button type="submit" class="btn btn-outline-primary btn-sm me-1" title="Save changes">
💾
</button>
<button type="button" class="btn btn-outline-danger btn-sm"
onclick="document.getElementById('delete-form-{{ reading.id }}').submit()" title="Delete reading session">
🗑️
</button>
</div>
</div>
</form>
<!-- Separate delete form -->
<form id="delete-form-{{ reading.id }}" action="/book/{{ book.id }}/reading/{{ reading.id }}/delete" method="POST"
style="display: none;"></form>
</div>
{% endfor %}
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}

15
uv.lock generated
View File

@@ -221,6 +221,7 @@ dependencies = [
{ name = "gunicorn" }, { name = "gunicorn" },
{ name = "jinja2-fragments" }, { name = "jinja2-fragments" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-extra-types" },
{ name = "pyparsing" }, { name = "pyparsing" },
{ name = "requests" }, { name = "requests" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
@@ -246,6 +247,7 @@ requires-dist = [
{ name = "gunicorn", specifier = ">=25.1.0" }, { name = "gunicorn", specifier = ">=25.1.0" },
{ name = "jinja2-fragments", specifier = ">=1.11.0" }, { name = "jinja2-fragments", specifier = ">=1.11.0" },
{ name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic", specifier = ">=2.12.5" },
{ name = "pydantic-extra-types", specifier = ">=2.11.1" },
{ name = "pyparsing", specifier = ">=3.3.2" }, { name = "pyparsing", specifier = ">=3.3.2" },
{ name = "requests", specifier = ">=2.32.5" }, { name = "requests", specifier = ">=2.32.5" },
{ name = "sqlalchemy", specifier = ">=2.0.48" }, { name = "sqlalchemy", specifier = ">=2.0.48" },
@@ -480,6 +482,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
] ]
[[package]]
name = "pydantic-extra-types"
version = "2.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" },
]
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.19.2" version = "2.19.2"