Compare commits

...

9 Commits

Author SHA1 Message Date
1262aa3c37 Fix #4: readings switch to dropped after rating
All checks were successful
CI / quality-checks (push) Successful in 52s
The hidden input for "dropped" in the completed reading form was always
setting the value to "1", which caused the reading to be marked as
dropped when the rating was updated (hidden inputs do not honor the
"checked" attribute).

Additionally, the book rating section is now swapped OOB when any
reading is updated, to ensure the changes are reflected in the hidden
inputs. This was causing the latest changes to a reading to be lost when
the rating was updated immediately after.
2026-04-28 23:08:58 +02:00
9429d203bd Fix #2: Focus jumps to genre after status change
All checks were successful
CI / quality-checks (push) Successful in 43s
No idea why this is happening, but I solved it by making htmx swap only
the user status containers instead of the whole page. This keeps the
toggle status intact and prevents the jump.
2026-04-01 11:37:27 +02:00
206b2a9d5b Fix #3: Old cover shown after updating book cover
All checks were successful
CI / quality-checks (push) Successful in 1m10s
Caddy is set up to serve static files with aggressive caching, which is
great for performance but can cause issues when updating book covers. To
ensure users see the updated cover immediately, we need implement a
cache-busting strategy. This involves generating a unique filename for
each cover image based on its content, using a hash of the image data.
By doing this, when a cover is updated, it will have a new filename,
prompting browsers to fetch the new image instead of using the cached
version.
2026-04-01 11:03:36 +02:00
dc73de6799 Fix #1: 500 error when creating first book
All checks were successful
CI / quality-checks (push) Successful in 43s
The 500 error occurred because the create template was missing the
genres, authors, and locations data needed to render the form.
2026-03-31 20:00:31 +02:00
03a5b3803e Add CI and deployment workflows for Gitea
All checks were successful
CI / quality-checks (push) Successful in 42s
2026-03-31 19:38:19 +02:00
9f73077207 Fix deployment issues 2026-03-31 17:26:00 +02:00
4d64db45c8 Fix case sensitivity in tests 2026-03-31 16:54:19 +02:00
c97f4c7d38 Make ISBN field nullable and add unique constraint 2026-03-31 16:42:34 +02:00
da0924eb41 Handle genres and authors casing 2026-03-31 13:31:50 +02:00
19 changed files with 290 additions and 73 deletions

35
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,35 @@
name: CI
on:
push:
branches: ["*"]
pull_request:
branches: ["*"]
jobs:
quality-checks:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.14"
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "0.11.1"
enable-cache: true
- name: Run pre-commit hooks
run: uv run pre-commit run --all-files
- name: Run type checking with ty
run: uv run ty check
- name: Run tests with pytest
run: uv run pytest

View File

@@ -0,0 +1,55 @@
name: Deploy
on:
workflow_run:
workflows: ["CI"]
types:
- completed
branches: ["main"]
jobs:
deploy:
runs-on: ubuntu-latest
# Only deploy if CI workflow succeeded
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Stop existing containers
run: |
# Stop and remove existing containers if they exist
docker compose down --remove-orphans || true
- name: Build and deploy with Docker Compose
run: |
# Build images
docker compose build
# Deploy the stack in detached mode
export GOOGLE_BOOKS_API_KEY="${{ secrets.GOOGLE_BOOKS_API_KEY }}"
docker compose up -d
# Wait for health checks to pass
echo "Waiting for application to be healthy..."
timeout 300 sh -c 'until docker compose ps | grep -q "healthy"; do sleep 5; done'
- name: Verify deployment
run: |
# Check if all services are running
docker compose ps
# Test if the application responds
sleep 10
wget --spider http://172.17.0.1:5123 || exit 1
echo "Deployment successful!"
- name: Cleanup old images
run: |
# Remove dangling images to save space
docker image prune -f

View File

@@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/uv-pre-commit
# uv version.
rev: 0.10.10
rev: 0.11.1
hooks:
- id: uv-lock

View File

@@ -1,6 +1,6 @@
# Caddyfile for HXBooks
# Replace 'localhost' with your domain for production with automatic HTTPS
localhost {
:80 {
# Serve static files directly (CSS, JS, images, etc.)
handle /static/* {
root * /var/www
@@ -33,9 +33,6 @@ localhost {
# Forward real IP to app
header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
}
# Optional: Enable compression for better performance

View File

@@ -9,10 +9,13 @@ services:
- instance:/app/instance
# Mount shared directory for static files that Caddy can access
- static:/shared/static
# Mount caddy_file for Caddy configuration
- caddy_file:/app/caddy
expose:
- "5000"
environment:
- FLASK_ENV=production
- GOOGLE_BOOKS_API_KEY=${GOOGLE_BOOKS_API_KEY}
networks:
- hxbooks
# Health check to ensure app is ready
@@ -31,11 +34,10 @@ services:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "5123:80"
volumes:
# Caddyfile configuration
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_file:/etc/caddy
# Media files served directly by Caddy
- media:/var/www/media:ro
# Static files served directly by Caddy (populated by app container)
@@ -64,3 +66,5 @@ volumes:
driver: local
caddy_config:
driver: local
caddy_file:
driver: local

View File

@@ -12,6 +12,9 @@ else
echo "Static files already present in shared volume"
fi
# Copy Caddyfile to shared volume
cp /app/Caddyfile /app/caddy/Caddyfile
# Initialize database if it doesn't exist or run migrations if it does
echo "Checking database status..."
if [ ! -f /app/instance/hxbooks.sqlite ]; then

View File

@@ -0,0 +1,64 @@
"""Add unique constraint to ISBN field
Revision ID: 5584e4fd820e
Revises: 0c155d83c55b
Create Date: 2026-03-31 13:41:16.356631
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5584e4fd820e'
down_revision = '0c155d83c55b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
# First, convert empty string ISBNs to a temporary placeholder
# This is needed because we can't set NULL while column is NOT NULL
op.execute("UPDATE book SET isbn = '__EMPTY_ISBN__' WHERE isbn = ''")
# Remove duplicate ISBNs, keeping only the book with the lowest ID
op.execute("""
DELETE FROM book
WHERE isbn != '__EMPTY_ISBN__'
AND isbn != ''
AND id NOT IN (
SELECT MIN(id)
FROM book
WHERE isbn != '__EMPTY_ISBN__'
AND isbn != ''
GROUP BY isbn
)
""")
with op.batch_alter_table('book', schema=None) as batch_op:
# Make the column nullable
batch_op.alter_column('isbn',
existing_type=sa.VARCHAR(length=20),
nullable=True)
# Now convert the placeholder values to NULL
op.execute("UPDATE book SET isbn = NULL WHERE isbn = '__EMPTY_ISBN__'")
with op.batch_alter_table('book', schema=None) as batch_op:
# Add the unique constraint
batch_op.create_unique_constraint('uq_book_isbn', ['isbn'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('book', schema=None) as batch_op:
batch_op.drop_constraint('uq_book_isbn', type_='unique')
batch_op.alter_column('isbn',
existing_type=sa.VARCHAR(length=20),
nullable=False)
# ### end Alembic commands ###

View File

@@ -19,7 +19,7 @@ def create_app(test_config: dict | None = None) -> Flask:
app.config.from_mapping(
SECRET_KEY="dev",
# Put database in project root
SQLALCHEMY_DATABASE_URI=f"sqlite:///{PROJECT_ROOT / 'hxbooks.sqlite'}",
SQLALCHEMY_DATABASE_URI=f"sqlite:///{PROJECT_ROOT / 'instance/hxbooks.sqlite'}",
)
# Setup logging

View File

@@ -16,6 +16,7 @@ from flask import Flask
from . import library
from .app import create_app
from .db import db
from .library import DuplicateISBNError
from .models import Author, Book, Genre, Reading, User, Wishlist
logger = logging.getLogger(__name__)
@@ -129,6 +130,9 @@ def add_book(
cover_image_url=cover_url,
)
click.echo(f"Added book: {book.title} (ID: {book.id})")
except DuplicateISBNError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
except Exception as e:
logger.error(f"Error adding book '{title}': {e}", exc_info=True)
click.echo(f"Error adding book: {e}", err=True)
@@ -380,7 +384,7 @@ def list_books(
click.echo("-" * 75)
for book in books:
authors_str = ", ".join(a.name for a in book.authors)[:22]
authors_str = ", ".join(a.name.title() for a in book.authors)[:22]
if len(authors_str) == 22:
authors_str += "..."
owner_str = book.owner.username if book.owner else ""
@@ -459,7 +463,7 @@ def search_books(
click.echo("-" * 72)
for book in books:
authors_str = ", ".join(a.name for a in book.authors)[:27]
authors_str = ", ".join(a.name.title() for a in book.authors)[:27]
if len(authors_str) == 27:
authors_str += "..."
click.echo(f"{book.id:<4} {book.title[:32]:<35} {authors_str:<30}")
@@ -502,8 +506,11 @@ def import_book(
fetch_cover=not no_cover,
)
click.echo(
f"Imported book: {book.title} by {', '.join(a.name for a in book.authors)} (ID: {book.id})"
f"Imported book: {book.title} by {', '.join(a.name.title() for a in book.authors)} (ID: {book.id})"
)
except DuplicateISBNError as e:
click.echo(f"Error: {e}", err=True)
sys.exit(1)
except Exception as e:
click.echo(f"Error importing book: {e}", err=True)
sys.exit(1)

View File

@@ -6,7 +6,7 @@ from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase): ...
db = SQLAlchemy(model_class=Base)
db = SQLAlchemy(model_class=Base, session_options={"autoflush": False})
def init_app(app: Flask) -> None:

View File

@@ -5,6 +5,7 @@ Clean service layer for book management, reading tracking, and wishlist operatio
Separated from web interface concerns to enable both CLI and web access.
"""
import hashlib
import logging
from collections import defaultdict
from collections.abc import Sequence
@@ -16,6 +17,7 @@ import requests
from flask import current_app
from PIL import Image
from sqlalchemy import ColumnElement, Select, and_, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import InstrumentedAttribute, joinedload
from hxbooks.search import IsOperatorValue, QueryParser, SortDirection, ValueT
@@ -29,6 +31,20 @@ from .search import ComparisonOperator, Field, FieldFilter
logger = logging.getLogger(__name__)
class DuplicateISBNError(ValueError):
"""Raised when attempting to create or update a book with a duplicate ISBN."""
def __init__(
self, isbn: str, existing_book_id: int, existing_book_title: str
) -> None:
self.isbn = isbn
self.existing_book_id = existing_book_id
self.existing_book_title = existing_book_title
super().__init__(
f"ISBN '{isbn}' is already used by book '{existing_book_title}' (ID: {existing_book_id})"
)
def create_book(
title: str,
owner_id: int | None = None,
@@ -52,7 +68,7 @@ def create_book(
book = Book(
title=title,
owner_id=owner_id,
isbn=isbn or "",
isbn=isbn,
publisher=publisher or "",
edition=edition or "",
description=description or "",
@@ -79,7 +95,20 @@ def create_book(
genre = _get_or_create_genre(genre_name)
book.genres.append(genre)
try:
db.session.commit()
except IntegrityError as e:
db.session.rollback()
if "book.isbn" in str(e.orig).lower():
# Find the existing book with this ISBN
existing_book = db.session.execute(
select(Book).filter(Book.isbn == isbn)
).scalar_one_or_none()
if existing_book:
raise DuplicateISBNError(
isbn or "", existing_book.id, existing_book.title
) from e
raise
# Handle cover image if provided
if cover_image_url:
@@ -137,7 +166,6 @@ def update_book(
assert title is not None, "Title is required when set_all_fields is True"
book.title = title
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 or set_all_fields:
assert publisher is not None, (
@@ -208,7 +236,21 @@ def update_book(
else:
remove_book_cover(book)
try:
db.session.commit()
except IntegrityError as e:
db.session.rollback()
if "book.isbn" in str(e.orig).lower():
# Find the existing book with this ISBN (excluding current book)
existing_book = db.session.execute(
select(Book).filter(Book.isbn == isbn, Book.id != book.id)
).scalar_one_or_none()
if existing_book:
raise DuplicateISBNError(
isbn or "", existing_book.id, existing_book.title
) from e
raise
return book
@@ -645,13 +687,14 @@ def get_books_by_location(
def _get_or_create_author(name: str) -> Author:
"""Get existing author or create a new one."""
"""Get existing author or create a new one. Always store as lowercase."""
normalized = name.strip().lower()
author = db.session.execute(
select(Author).filter(Author.name == name)
select(Author).filter(Author.name == normalized)
).scalar_one_or_none()
if author is None:
author = Author(name=name)
author = Author(name=normalized)
db.session.add(author)
# Don't commit here - let the caller handle the transaction
@@ -659,13 +702,14 @@ def _get_or_create_author(name: str) -> Author:
def _get_or_create_genre(name: str) -> Genre:
"""Get existing genre or create a new one."""
"""Get existing genre or create a new one. Always store as lowercase."""
normalized = name.strip().lower()
genre = db.session.execute(
select(Genre).filter(Genre.name == name)
select(Genre).filter(Genre.name == normalized)
).scalar_one_or_none()
if genre is None:
genre = Genre(name=name)
genre = Genre(name=normalized)
db.session.add(genre)
# Don't commit here - let the caller handle the transaction
@@ -970,28 +1014,23 @@ def download_book_cover(book: Book, image_url: str) -> bool:
return False
# Load image directly from file
with Image.open(source_path) as image:
processed_image = _process_cover_image(image)
# Generate filename
extension = ".jpg" # Always save as JPEG
filename = f"book_{book.id}{extension}"
cover_path = covers_dir / filename
# Save processed image
processed_image.save(cover_path, "JPEG", quality=85)
source = source_path
else:
# Handle HTTP(S) URLs
response = requests.get(image_url, timeout=10, stream=True)
response.raise_for_status()
# Load image from response content
with Image.open(response.raw) as image:
source = response.raw
with Image.open(source) as image:
processed_image = _process_cover_image(image)
# Generate filename
extension = ".jpg" # Always save as JPEG
filename = f"book_{book.id}{extension}"
# Hash the image to create a unique filename based on content
image_hash = hashlib.md5(processed_image.tobytes()).hexdigest()
filename = f"book_{book.id}_{image_hash}{extension}"
cover_path = covers_dir / filename
# Save processed image

View File

@@ -9,7 +9,7 @@ import os
import tempfile
from datetime import date
from pathlib import Path
from typing import Annotated, Any, Literal
from typing import Annotated, Any
from flask import (
Blueprint,
@@ -41,6 +41,7 @@ from hxbooks.search import IsOperatorValue, SortDirection
from . import library
from .db import db
from .library import DuplicateISBNError
bp = Blueprint("main", __name__)
@@ -51,7 +52,7 @@ logger = logging.getLogger(__name__)
# Pydantic validation models
StrOrNone = Annotated[str | None, BeforeValidator(lambda v: v.strip() or None)]
StripStr = Annotated[str, StringConstraints(strip_whitespace=True)]
ISBNOrEmpty = Annotated[ISBN | Literal[""], BeforeValidator(lambda v: v.strip() or "")]
ISBNOrNone = Annotated[ISBN | None, BeforeValidator(lambda v: v.strip() or None)]
TextareaList = Annotated[
list[str],
BeforeValidator(
@@ -70,7 +71,7 @@ UrlOrNone = Annotated[AnyHttpUrl | None, BeforeValidator(lambda v: v.strip() or
class BookFormData(BaseModel):
title: StripStr = Field(min_length=1)
owner: StrOrNone = None
isbn: ISBNOrEmpty = ""
isbn: ISBNOrNone = None
authors: TextareaList = Field(default_factory=list)
genres: TextareaList = Field(default_factory=list)
first_published: IntOrNone = Field(default=None, le=2030)
@@ -283,7 +284,7 @@ def create_book() -> ResponseReturnValue:
owner_id=owner_id,
authors=form_data.authors,
genres=form_data.genres,
isbn=str(form_data.isbn),
isbn=form_data.isbn,
publisher=form_data.publisher,
edition=form_data.edition,
description=form_data.description,
@@ -300,17 +301,23 @@ def create_book() -> ResponseReturnValue:
flash(f"Book '{form_data.title}' created successfully!", "success")
return redirect(url_for("main.book_detail", book_id=book.id))
except DuplicateISBNError as e:
flash(f"Error: {e}", "error")
except ValidationError as e:
_flash_validation_errors(e)
return render_template("book/create.html.j2", form_data=request.form)
except Exception as e:
logger.error(f"Error creating book '{form_data.title}': {e}", exc_info=True)
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",
form_data=request.form,
genres=library.list_genres(),
authors=library.list_authors(),
locations=library.list_locations(),
)
@bp.route("/book/<int:book_id>/edit", methods=["POST"])
@@ -357,6 +364,9 @@ def update_book(book_id: int) -> ResponseReturnValue:
flash("Book updated successfully!", "success")
except DuplicateISBNError as e:
flash(f"Error: {e}", "error")
except ValidationError as e:
# Format validation errors for display
_flash_validation_errors(e)

View File

@@ -56,7 +56,9 @@ class Book(db.Model): # ty:ignore[unsupported-base]
first_published: Mapped[int | None] = mapped_column(default=None)
edition: Mapped[str] = mapped_column(String(200), default="")
publisher: Mapped[str] = mapped_column(String(200), default="")
isbn: Mapped[str] = mapped_column(String(20), default="")
isbn: Mapped[str | None] = mapped_column(
String(20), unique=True, nullable=True, default=None
)
notes: Mapped[str] = mapped_column(default="")
added_date: Mapped[datetime] = mapped_column(default=datetime.now)
bought_date: Mapped[date | None] = mapped_column(default=None)

View File

@@ -22,7 +22,7 @@
<div class="bg-light p-3 rounded mb-4">
<h6 class="fw-bold">{{ book.title }}</h6>
{% if book.authors %}
<p class="text-muted small mb-1">by {{ book.authors | join(', ') }}</p>
<p class="text-muted small mb-1">by {{ book.authors | map('title') | join(', ') }}</p>
{% endif %}
{% if book.isbn %}
<p class="text-muted small mb-0">ISBN: {{ book.isbn }}</p>

View File

@@ -23,7 +23,7 @@
{% import 'components/user_book_vars.html.j2' as vars with context %}
<div class="col-12 d-lg-none mb-3">
<input type="checkbox" id="status-toggle" class="status-toggle-checkbox" hidden>
<div class="user-status-card">
<div class="user-status-card" id="user-status-card">
<label for="status-toggle" class="status-bar">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-2">
@@ -110,7 +110,7 @@
<div class="card-header">
<h6 class="mb-0">{{ session.get('viewing_as_user').title() }}'s Data</h6>
</div>
<div class="card-body">
<div class="card-body" id="user-status">
{% include 'components/reading_status.html.j2' %}
{% include 'components/wishlist_status.html.j2' %}
</div>

View File

@@ -45,7 +45,7 @@
{% if book.authors %}
<p class="card-text text-muted small text-truncate mb-2">
by {{ book.authors | join(', ') }}
by {{ book.authors | map('title') | join(', ') }}
</p>
{% endif %}
@@ -63,7 +63,7 @@
{% if book.genres %}
<div class="mt-1">
{% for genre in book.genres[:2] %}
<span class="badge bg-light text-dark small me-1">{{ genre }}</span>
<span class="badge bg-light text-dark small me-1">{{ genre|title }}</span>
{% endfor %}
{% if book.genres|length > 2 %}
<span class="badge bg-light text-dark small">+{{ book.genres|length - 2 }}</span>

View File

@@ -12,7 +12,7 @@
</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 '' }}">
<input type="text" class="form-control" id="isbn" name="isbn" value="{{ book.isbn if book and book.isbn else '' }}">
</div>
</div>
@@ -21,12 +21,12 @@
<div class="col-md-6">
<label for="authors" class="form-label">Authors</label>
<textarea class="form-control" id="authors" name="authors" rows="2"
placeholder="One author per line">{% if book and book.authors %}{{ book.authors | join('\n') }}{% endif %}</textarea>
placeholder="One author per line">{% if book and book.authors %}{{ book.authors | map('title') | join('\n') }}{% endif %}</textarea>
</div>
<div class="col-md-6">
<label for="genres" class="form-label">Genres</label>
<textarea class="form-control" id="genres" name="genres" rows="2"
placeholder="One genre per line">{% if book and book.genres %}{{ book.genres | join('\n') }}{% endif %}</textarea>
placeholder="One genre per line">{% if book and book.genres %}{{ book.genres | map('title') | join('\n') }}{% endif %}</textarea>
</div>
</div>
@@ -73,7 +73,7 @@
<div class="col-md-6">
<label for="cover_image_url" class="form-label">Replace with URL</label>
<input type="url" class="form-control" id="cover_image_url" name="cover_image_url" value=""
placeholder="https://example.com/cover.jpg or file:///path/to/image.jpg">
placeholder="https://example.com/cover.jpg">
</div>
<div class="col-md-6">
<label for="cover_image_file" class="form-label">Replace with Upload</label>
@@ -155,12 +155,12 @@
};
new Tagify(document.querySelector('#genres'), {
...commmon_settings,
whitelist: {{ genres | map(attribute = 'name') | list | pprint }},
whitelist: {{ genres | map(attribute = 'name') | map('title') | list | pprint }},
dropdown: { enabled: 0, closeOnSelect: false }
});
new Tagify(document.querySelector('#authors'), {
...commmon_settings,
whitelist: {{ authors | map(attribute = 'name') | list | pprint }},
whitelist: {{ authors | map(attribute = 'name') | map('title') | list | pprint }},
dropdown: { enabled: 0, closeOnSelect: false }
});
</script>

View File

@@ -6,7 +6,7 @@
<h6 class="text-muted mb-2">📖 Reading Status</h6>
<!-- Action Buttons -->
<div class="mb-3">
<div class="mb-3" hx-select-oob="#flash-messages-container,#user-status-card,#user-status" hx-swap="none show:none">
{% if vars.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>
@@ -26,15 +26,15 @@
<!-- Current Book Rating (if any completed readings) -->
{% if vars.completed_readings %}
<div class="alert alert-light border py-2 mb-3">
<div id="book-rating" class="alert alert-light border py-2 mb-3">
<form action="/book/{{ book.id }}/reading/{{ vars.completed_readings[0].id }}/update" method="POST"
class="row align-items-center g-2" hx-trigger="change,submit" hx-swap="none show:none"
hx-select-oob="#flash-messages-container:outerHTML" hx-target="this">
hx-select-oob="#flash-messages-container" hx-target="this">
<!-- Hidden fields to preserve other reading data -->
<input type="hidden" name="start_date" value="{{ vars.completed_readings[0].start_date.strftime('%Y-%m-%d') }}">
<input type="hidden" name="end_date"
value="{{ vars.completed_readings[0].end_date.strftime('%Y-%m-%d') if vars.completed_readings[0].end_date else '' }}">
<input type="hidden" name="dropped" value="1" {{ 'checked' if vars.completed_readings[0].dropped else '' }}>
<input type="hidden" name="dropped" value="{{ '1' if vars.completed_readings[0].dropped else '0' }}">
<input type="hidden" name="comments" value="{{ vars.completed_readings[0].comments or '' }}">
<div class="col-auto">
@@ -61,7 +61,8 @@
{% for reading in vars.user_readings | sort(attribute='start_date', reverse=true) %}
<div class="border rounded p-3 mb-2 {% if reading == vars.current_reading %}border-primary bg-light{% endif %}">
<form action="/book/{{ book.id }}/reading/{{ reading.id }}/update" method="POST" hx-trigger="change,submit"
hx-swap="none show:none" hx-select-oob="#flash-messages-container:outerHTML" hx-target="this">
hx-swap="none show:none" hx-select-oob="#flash-messages-container:outerHTML,#book-rating:outerHTML"
hx-target="this">
<div class="row">
<div class="col-md-6">
<label class="form-label-sm">Start Date</label>

View File

@@ -92,7 +92,7 @@ class TestBookAddCommand:
authors = db.session.execute(db.select(Author)).scalars().all()
assert len(authors) == 1
author = authors[0]
assert author.name == "J.R.R. Tolkien"
assert author.name == "j.r.r. tolkien"
assert book in author.books
assert author in book.authors
@@ -100,7 +100,7 @@ class TestBookAddCommand:
genres = db.session.execute(db.select(Genre)).scalars().all()
assert len(genres) == 2
genre_names = {genre.name for genre in genres}
assert genre_names == {"Fantasy", "Adventure"}
assert genre_names == {"fantasy", "adventure"}
for genre in genres:
assert book in genre.books
@@ -118,7 +118,7 @@ class TestBookAddCommand:
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
assert book.title == "Minimal Book"
assert book.isbn == "" # Default empty string
assert book.isbn is None
assert book.publisher == ""
assert book.location_shelf is None # Default None
assert len(book.authors) == 0 # No authors provided
@@ -181,7 +181,7 @@ class TestBookListCommand:
assert len(books_data) == 1
book = books_data[0]
assert book["title"] == "Test Book"
assert book["authors"] == ["Test Author"]
assert book["authors"] == ["test author"]
assert book["owner"] == "alice"
assert book["isbn"] == "1234567890"
@@ -819,8 +819,8 @@ class TestWishlistCommands:
assert result.exit_code == 0
assert "Wished Book 1" in result.output
assert "Wished Book 2" in result.output
assert "Author One" in result.output
assert "Author Two" in result.output
assert "author one" in result.output
assert "author two" in result.output
def test_wishlist_list_json_format(self, app: Flask, cli_runner: CliRunner) -> None:
"""Test listing wishlist in JSON format."""
@@ -852,7 +852,7 @@ class TestWishlistCommands:
assert len(wishlist_data) == 1
item = wishlist_data[0]
assert item["title"] == "JSON Wished Book"
assert item["authors"] == ["JSON Author"]
assert item["authors"] == ["json author"]
class TestDatabaseCommands: