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",
"jinja2-fragments>=1.11.0",
"pydantic>=2.12.5",
"pydantic-extra-types>=2.11.1",
"pyparsing>=3.3.2",
"requests>=2.32.5",
"sqlalchemy>=2.0.48",

View File

@@ -35,6 +35,8 @@ def create_book(
location_shelf: int | None = None,
first_published: int | None = None,
bought_date: date | None = None,
loaned_to: str | None = None,
loaned_date: date | None = None,
) -> Book:
"""Create a new book with the given details."""
book = Book(
@@ -50,6 +52,8 @@ def create_book(
location_shelf=location_shelf,
first_published=first_published,
bought_date=bought_date,
loaned_to=loaned_to or "",
loaned_date=loaned_date,
)
db.session.add(book)
@@ -90,6 +94,8 @@ def get_book(book_id: int) -> Book | None:
def update_book(
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,
authors: list[str] | None = None,
genres: list[str] | None = None,
@@ -103,6 +109,8 @@ def update_book(
location_shelf: int | None = None,
first_published: int | None = None,
bought_date: date | None = None,
loaned_to: str | None = None,
loaned_date: date | None = None,
) -> Book | None:
"""Update a book with new details."""
book = get_book(book_id)
@@ -110,43 +118,73 @@ def update_book(
return None
# 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
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
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
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
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
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
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
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
if location_shelf is not None:
if location_shelf is not None or set_all_fields:
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
if bought_date is not None:
if bought_date is not None or set_all_fields:
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
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()
for author_name in [a_strip for a in authors if (a_strip := a.strip())]:
author = _get_or_create_author(author_name)
book.authors.append(author)
# 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()
for genre_name in [g_strip for g in genres if (g_strip := g.strip())]:
genre = _get_or_create_genre(genre_name)
book.genres.append(genre)
if owner_id is not None or set_all_fields:
book.owner_id = owner_id
db.session.commit()
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:
"""Add a book to user's wishlist."""
# 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.
"""
from typing import Any
from datetime import date
from typing import Annotated, Any
from flask import (
Blueprint,
@@ -17,8 +18,16 @@ from flask import (
url_for,
)
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 .db import db
@@ -26,25 +35,56 @@ from .db import db
bp = Blueprint("main", __name__)
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
print(f"{user.saved_searches=}")
db.session.commit()
return True
# Pydantic validation models
StrOrNone = Annotated[str | None, BeforeValidator(lambda v: v.strip() or None)]
StripStr = Annotated[str, StringConstraints(strip_whitespace=True)]
ISBNOrNone = Annotated[ISBN | None, BeforeValidator(lambda v: v.strip() or None)]
TextareaList = Annotated[
list[str],
BeforeValidator(
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:
"""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
class BookFormData(BaseModel):
title: StripStr = Field(min_length=1)
owner: StrOrNone = None
isbn: ISBNOrNone = None
authors: TextareaList = Field(default_factory=list)
genres: TextareaList = Field(default_factory=list)
first_published: IntOrNone = Field(default=None, le=2030)
publisher: StripStr = Field(default="")
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
@@ -101,61 +141,60 @@ def book_detail(book_id: int) -> ResponseReturnValue:
flash("Book not found", "error")
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)
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"])
def create_book() -> ResponseReturnValue:
"""Create a new book."""
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:
# Get current viewing user as owner
viewing_user = g.get("viewing_user")
# Validate form data with Pydantic
form_data = BookFormData.model_validate(dict(request.form))
# Process textarea inputs for authors and genres
authors = [
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()
]
# Get owner ID if provided
owner_id = _get_or_create_user(form_data.owner) if form_data.owner else None
# Create book with submitted data
# Create book with validated data
book = library.create_book(
title=title,
owner_id=viewing_user.id if viewing_user else None,
authors=authors,
genres=genres,
isbn=request.form.get("isbn"),
publisher=request.form.get("publisher"),
edition=request.form.get("edition"),
description=request.form.get("description"),
notes=request.form.get("notes"),
location_place=request.form.get("location_place"),
location_bookshelf=request.form.get("location_bookshelf"),
location_shelf=int(request.form.get("location_shelf") or 0) or None,
first_published=int(request.form.get("first_published") or 0) or None,
title=form_data.title,
owner_id=owner_id,
authors=form_data.authors,
genres=form_data.genres,
isbn=str(form_data.isbn) if form_data.isbn else None,
publisher=form_data.publisher,
edition=form_data.edition,
description=form_data.description,
notes=form_data.notes,
location_place=form_data.location_place,
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(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))
except ValidationError as e:
_flash_validation_errors(e)
return render_template("book/create.html.j2", form_data=request.form)
except Exception as e:
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")
@@ -169,37 +208,39 @@ def update_book(book_id: int) -> ResponseReturnValue:
return redirect(url_for("main.index"))
try:
# Process textarea inputs for authors and genres
authors = [
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()
]
# Validate form data with Pydantic
form_data = BookFormData.model_validate(dict(request.form))
# 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(
book_id=book_id,
title=request.form.get("title"),
authors=authors,
genres=genres,
isbn=request.form.get("isbn"),
publisher=request.form.get("publisher"),
edition=request.form.get("edition"),
description=request.form.get("description"),
notes=request.form.get("notes"),
location_place=request.form.get("location_place"),
location_bookshelf=request.form.get("location_bookshelf"),
location_shelf=int(request.form.get("location_shelf") or 0) or None,
first_published=int(request.form.get("first_published") or 0) or None,
set_all_fields=True, # Ensure all fields are updated, even if None/empty
title=form_data.title,
owner_id=owner_id,
authors=form_data.authors,
genres=form_data.genres,
isbn=str(form_data.isbn) if form_data.isbn else None,
publisher=form_data.publisher,
edition=form_data.edition,
description=form_data.description,
notes=form_data.notes,
location_place=form_data.location_place,
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")
except ValidationError as e:
# Format validation errors for display
_flash_validation_errors(e)
except Exception as e:
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"))
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"])
def save_search_route() -> ResponseReturnValue:
"""Save a search for the current user."""
@@ -283,16 +344,13 @@ def save_search_route() -> ResponseReturnValue:
search_name = request.form.get("name", "").strip()
query_params = request.form.get("query_params", "")
print(
f"Saving search for user {viewing_user.username}: {search_name} -> {query_params}"
)
if not search_name:
flash("Search name is required", "error")
return redirect(url_for("main.index", q=query_params))
try:
success = save_search(viewing_user, search_name, query_params)
success = _save_search(viewing_user, search_name, query_params)
if success:
flash(f"Search '{search_name}' saved successfully!", "success")
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))
@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"])
def delete_saved_search_route(search_name: str) -> ResponseReturnValue:
"""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":
# Perform the actual deletion
try:
success = delete_saved_search(viewing_user, search_name)
success = _delete_saved_search(viewing_user, search_name)
if success:
flash(f"Saved search '{search_name}' deleted successfully!", "success")
else:

View File

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

View File

@@ -7,15 +7,19 @@
<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='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 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 error handling

View File

@@ -4,7 +4,7 @@
{% block header %}
<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 -->
<div class="search-container mx-3 flex-grow-1">

View File

@@ -1,10 +1,17 @@
<!-- Basic Information -->
<div class="row mb-3">
<div class="col-md-8">
<div class="col-md-6">
<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>
</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>
<input type="text" class="form-control" id="isbn" name="isbn" value="{{ book.isbn if book else '' }}">
</div>
@@ -70,6 +77,20 @@
</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 -->
<div class="mb-3">
<label for="notes" class="form-label">Personal Notes</label>

View File

@@ -2,52 +2,124 @@
{% if g.viewing_user %}
{% 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 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">
<h6 class="text-muted mb-2">📖 Reading Status</h6>
<!-- Current Reading Status -->
{% if current_reading %}
<div class="alert alert-info py-2">
<strong>Currently Reading</strong><br>
<small>Started: {{ current_reading.start_date.strftime('%B %d, %Y') }}</small>
</div>
<form action="/book/{{ book.id }}/reading/finish" method="POST" class="d-inline">
<button type="submit" class="btn btn-success btn-sm me-2">✓ Finish Reading</button>
</form>
<form action="/book/{{ book.id }}/reading/drop" method="POST" class="d-inline">
<button type="submit" class="btn btn-outline-secondary btn-sm">⏸ Drop Reading</button>
</form>
{% else %}
<!-- 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>
<!-- Action Buttons -->
<div class="mb-3">
{% if current_reading %}
<form action="/book/{{ book.id }}/reading/finish" method="POST" class="d-inline">
<button type="submit" class="btn btn-success btn-sm me-2">✓ Finish Reading</button>
</form>
<form action="/book/{{ book.id }}/reading/drop" method="POST" class="d-inline">
<button type="submit" class="btn btn-outline-secondary btn-sm">⏸ Drop Reading</button>
</form>
{% else %}
<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 %}
</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 %}
</div>
{% endif %}

15
uv.lock generated
View File

@@ -221,6 +221,7 @@ dependencies = [
{ name = "gunicorn" },
{ name = "jinja2-fragments" },
{ name = "pydantic" },
{ name = "pydantic-extra-types" },
{ name = "pyparsing" },
{ name = "requests" },
{ name = "sqlalchemy" },
@@ -246,6 +247,7 @@ requires-dist = [
{ name = "gunicorn", specifier = ">=25.1.0" },
{ name = "jinja2-fragments", specifier = ">=1.11.0" },
{ name = "pydantic", specifier = ">=2.12.5" },
{ name = "pydantic-extra-types", specifier = ">=2.11.1" },
{ name = "pyparsing", specifier = ">=3.3.2" },
{ name = "requests", specifier = ">=2.32.5" },
{ 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" },
]
[[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]]
name = "pygments"
version = "2.19.2"