Add pagination

This commit is contained in:
2026-03-21 20:07:45 +01:00
parent 452567d0c4
commit 3ac792720e
5 changed files with 179 additions and 30 deletions

View File

@@ -2,26 +2,29 @@
## Project Overview ## Project Overview
HXBooks is a personal book library management application built with Flask, HTMX, and SQLAlchemy. It provides dynamic book searching, reading tracking, and library management without heavy JavaScript frameworks. HXBooks is a personal book library management application with both web (Flask + HTMX) and CLI interfaces. It provides advanced book searching, reading tracking, and library management without heavy JavaScript frameworks.
**Core Technologies:** **Core Technologies:**
- **Backend**: Flask 3.1+ with SQLAlchemy 2.0 (modern `Mapped[]` annotations) - **Backend**: Flask 3.1+ with SQLAlchemy 2.0 (modern `Mapped[]` annotations)
- **Frontend**: HTMX + Alpine.js + Bootstrap (minimal JavaScript approach) - **Frontend**: HTMX + Alpine.js + Bootstrap (minimal JavaScript approach)
- **Validation**: Pydantic 2.x schemas for request/response validation - **Validation**: Pydantic 2.x with modern `Annotated` types for composable validation
- **Templates**: Jinja2 with fragments for partial page updates - **Templates**: Jinja2 with fragments for partial page updates
- **Database**: SQLite with JSON columns for flexible arrays - **Database**: SQLite with proper normalized schema (no JSON columns for core entities)
- **Package Manager**: UV with Python 3.14 - **Package Manager**: UV with Python 3.14
- **CLI**: Click-based comprehensive command interface
## Architecture ## Architecture
**Application Factory Pattern:** **Service Layer Pattern:**
- `create_app()` in [src/hxbooks/__init__.py](src/hxbooks/__init__.py) - Flask app factory in [src/hxbooks/app.py](src/hxbooks/app.py)
- Blueprint organization: `auth.py` (authentication), `book.py` (main features) - Service layer: [src/hxbooks/library.py](src/hxbooks/library.py) - business logic shared between web and CLI
- Models: User, Book, Reading, Wishlist with cascade relationships - Web routes: [src/hxbooks/main.py](src/hxbooks/main.py) - Flask blueprint handling HTTP requests
- CLI commands: [src/hxbooks/cli.py](src/hxbooks/cli.py) - Click command groups
**Key Components:** **Key Components:**
- [src/hxbooks/models.py](src/hxbooks/models.py): SQLAlchemy models with modern `Mapped[]` syntax - [src/hxbooks/models.py](src/hxbooks/models.py): SQLAlchemy models with proper normalization and relationships
- [src/hxbooks/book.py](src/hxbooks/book.py): Complex search with Pydantic validation - [src/hxbooks/library.py](src/hxbooks/library.py): Core business logic (create_book, search_books_advanced, etc.)
- [src/hxbooks/search.py](src/hxbooks/search.py): Advanced query parser with pyparsing
- [src/hxbooks/gbooks.py](src/hxbooks/gbooks.py): Google Books API integration - [src/hxbooks/gbooks.py](src/hxbooks/gbooks.py): Google Books API integration
- [src/hxbooks/templates/](src/hxbooks/templates/): Jinja2 templates with HTMX fragments - [src/hxbooks/templates/](src/hxbooks/templates/): Jinja2 templates with HTMX fragments
@@ -31,13 +34,30 @@ HXBooks is a personal book library management application built with Flask, HTMX
```bash ```bash
uv sync # Install dependencies uv sync # Install dependencies
python -m hxbooks # Dev server with livereload (port 5000) python -m hxbooks # Dev server with livereload (port 5000)
hxbooks --help # CLI interface (after uv sync)
```
**CLI Commands:**
```bash
hxbooks book add "Title" --authors "Author1,Author2" --genres "Fiction"
hxbooks book search "author:tolkien genre:fantasy rating>=4"
hxbooks reading start <book_id>
hxbooks reading finish <book_id> --rating 5 --comments "Excellent!"
hxbooks wishlist add <book_id>
``` ```
**Development Server:** **Development Server:**
- [src/hxbooks/__main__.py](src/hxbooks/__main__.py): Livereload server watching templates - CLI command `hxbooks serve`: Livereload server watching templates
- VS Code debugging: Use "Python Debugger: Flask" launch configuration - VS Code debugging: Flask app factory in [src/hxbooks/app.py](src/hxbooks/app.py)
- Config: Instance folder pattern (`instance/config.py` for local overrides) - Config: Instance folder pattern (`instance/config.py` for local overrides)
**Testing:**
```bash
pytest # Run all tests with verbose output
pytest tests/test_cli.py # Test CLI commands
pytest tests/test_search.py # Test query parser
```
**Production Deployment:** **Production Deployment:**
- Dockerfile uses Gunicorn on port 8080 - Dockerfile uses Gunicorn on port 8080
- **Note**: Current Dockerfile expects `requirements.txt` but project uses `pyproject.toml` - **Note**: Current Dockerfile expects `requirements.txt` but project uses `pyproject.toml`
@@ -48,7 +68,13 @@ python -m hxbooks # Dev server with livereload (port 5000)
- **Types**: Modern annotations (`str | Response`, `Mapped[str]`, `Optional[Type]`) - **Types**: Modern annotations (`str | Response`, `Mapped[str]`, `Optional[Type]`)
- **Naming**: snake_case functions/vars, PascalCase classes, UPPERCASE constants - **Naming**: snake_case functions/vars, PascalCase classes, UPPERCASE constants
- **SQLAlchemy**: Use `mapped_column()` and `relationship()` with `back_populates` - **SQLAlchemy**: Use `mapped_column()` and `relationship()` with `back_populates`
- **Validation**: Pydantic schemas with `{Entity}RequestSchema`/`{Entity}ResultSchema` pattern - **Validation**: Pydantic schemas with `{Entity}FormData` pattern
**Pydantic 2.x Patterns:**
- **Composable types**: `Annotated[str, StringConstraints(strip_whitespace=True)]`
- **Custom validators**: `BeforeValidator()` for preprocessing (strip, empty-to-None)
- **Reusable aliases**: `StrOrNone`, `TextareaList`, `ISBNOrNone` in [src/hxbooks/main.py](src/hxbooks/main.py)
- **Modern syntax**: `str | None` not `Optional[str]`, `model_validate()` not `parse_obj()`
**Flask Patterns:** **Flask Patterns:**
- Blueprints with `@bp.route()` decorators - Blueprints with `@bp.route()` decorators
@@ -56,6 +82,12 @@ python -m hxbooks # Dev server with livereload (port 5000)
- Response types: `str | Response` (template string or redirect) - Response types: `str | Response` (template string or redirect)
- Error handling: Dict mapping for template display `{field: error_msg}` - Error handling: Dict mapping for template display `{field: error_msg}`
**Testing Conventions:**
- **pytest**: Class-based organization (`TestBookAddCommand`, `TestFieldFilters`)
- **Parametrized tests**: `@pytest.mark.parametrize` for multiple scenario testing
- **Type hints**: Full annotations on test methods and fixtures
- **Assertions**: Include context with f-strings for debugging
**Frontend (HTMX + Alpine.js):** **Frontend (HTMX + Alpine.js):**
- Templates: `.j2` extension, fragments for partial updates - Templates: `.j2` extension, fragments for partial updates
- HTMX: Dynamic updates with `hx-get`, `hx-post`, `hx-target` - HTMX: Dynamic updates with `hx-get`, `hx-post`, `hx-target`
@@ -70,26 +102,33 @@ python -m hxbooks # Dev server with livereload (port 5000)
- [.vscode/launch.json](.vscode/launch.json): Debug configuration - [.vscode/launch.json](.vscode/launch.json): Debug configuration
**Core Application:** **Core Application:**
- [src/hxbooks/__init__.py](src/hxbooks/__init__.py): Flask app factory - [src/hxbooks/app.py](src/hxbooks/app.py): Flask app factory
- [src/hxbooks/db.py](src/hxbooks/db.py): SQLAlchemy setup with auto-table creation - [src/hxbooks/db.py](src/hxbooks/db.py): SQLAlchemy setup with auto-table creation
- [src/hxbooks/models.py](src/hxbooks/models.py): Database models - [src/hxbooks/models.py](src/hxbooks/models.py): Database models
**Feature Modules:** **Feature Modules:**
- [src/hxbooks/auth.py](src/hxbooks/auth.py): User authentication (username-based) - [src/hxbooks/main.py](src/hxbooks/main.py): Web routes and form handling
- [src/hxbooks/book.py](src/hxbooks/book.py): Book CRUD, search, filtering - [src/hxbooks/library.py](src/hxbooks/library.py): Business logic service layer
- [src/hxbooks/cli.py](src/hxbooks/cli.py): Click CLI commands
- [src/hxbooks/search.py](src/hxbooks/search.py): Query parser and search logic
- [src/hxbooks/gbooks.py](src/hxbooks/gbooks.py): Google Books API integration - [src/hxbooks/gbooks.py](src/hxbooks/gbooks.py): Google Books API integration
**Testing Infrastructure:**
- [tests/conftest.py](tests/conftest.py): Pytest fixtures and test configuration
- [tests/test_cli.py](tests/test_cli.py): CLI command tests
- [tests/test_search.py](tests/test_search.py): Query parser and search tests
## Conventions ## Conventions
**Database Patterns:** **Database Patterns:**
- JSON columns for arrays: `authors: list`, `genres: list`, `saved_searches: dict` - Normalized schema: `Author` and `Genre` as separate entities with many-to-many relationships
- Cascade deletes for dependent entities - Cascade deletes for dependent entities
- Foreign key constraints explicitly defined - Foreign key constraints explicitly defined
**Search Implementation:** **Search Implementation:**
- Full-text search using SQLite FTS with `func.match()` - Advanced query parser using pyparsing with field filters (`author:tolkien`, `rating>=4`)
- Complex query builder returning `Select` statements - Complex query builder returning `Select` statements
- Saved searches stored as JSON in User model - Support for negation (`-genre:romance`) and special operators (`is:reading`)
**HTMX Integration:** **HTMX Integration:**
- Partial page updates using Jinja2-Fragments - Partial page updates using Jinja2-Fragments
@@ -97,7 +136,7 @@ python -m hxbooks # Dev server with livereload (port 5000)
- Form validation errors returned as template fragments - Form validation errors returned as template fragments
**Development Notes:** **Development Notes:**
- No testing framework configured yet - Testing framework: pytest with parametrized tests for comprehensive coverage
- No linting/formatting tools setup - Linting/formatting: ruff configured with strict rules
- Instance folder for environment-specific config (gitignored) - Instance folder for environment-specific config (gitignored)
- Production requires either `requirements.txt` generation or Dockerfile updates for UV - Production requires either `requirements.txt` generation or Dockerfile updates for UV

View File

@@ -304,7 +304,7 @@ def search_books(
with app.app_context(): with app.app_context():
try: try:
books = library.search_books_advanced( books, _ = library.search_books_advanced(
query_string=query, limit=limit, username=username query_string=query, limit=limit, username=username
) )
if output_format == "json": if output_format == "json":

View File

@@ -276,9 +276,13 @@ query_parser = QueryParser()
def search_books_advanced( def search_books_advanced(
query_string: str, limit: int = 50, username: str | None = None query_string: str, limit: int = 50, offset: int = 0, username: str | None = None
) -> Sequence[Book]: ) -> tuple[Sequence[Book], int]:
"""Advanced search with field filters supporting comparison operators.""" """Advanced search with field filters supporting comparison operators.
Returns:
tuple: (books, total_count)
"""
parsed_query = query_parser.parse(query_string) parsed_query = query_parser.parse(query_string)
query = ( query = (
@@ -332,10 +336,17 @@ def search_books_advanced(
sort_columns.append(Book.added_date.desc()) sort_columns.append(Book.added_date.desc())
query = query.order_by(*sort_columns) query = query.order_by(*sort_columns)
query = query.distinct().limit(limit) # Get total count before applying limit/offset
count_query = select(func.count()).select_from(query.distinct().subquery())
total_count = db.session.execute(count_query).scalar() or 0
# Apply pagination
query = query.distinct().limit(limit).offset(offset)
result = db.session.execute(query) result = db.session.execute(query)
return result.scalars().unique().all() books = result.scalars().unique().all()
return books, total_count
def _build_field_condition( def _build_field_condition(

View File

@@ -116,26 +116,55 @@ def inject_template_vars() -> dict[str, Any]:
} }
RESULTS_PER_PAGE = 10
@bp.route("/") @bp.route("/")
def index() -> ResponseReturnValue: def index() -> ResponseReturnValue:
"""Book list view - main application page.""" """Book list view - main application page."""
# Get search parameters # Get search parameters
query = request.args.get("q", "") query = request.args.get("q", "")
page = request.args.get("page", 1, type=int)
# Ensure valid pagination values
page = max(1, page)
offset = (page - 1) * RESULTS_PER_PAGE
# Get current viewing user # Get current viewing user
viewing_user = session.get("viewing_as_user") viewing_user = session.get("viewing_as_user")
try: try:
books = library.search_books_advanced(query, limit=100, username=viewing_user) books, total_count = library.search_books_advanced(
query, limit=RESULTS_PER_PAGE, offset=offset, username=viewing_user
)
except Exception as e: except Exception as e:
flash(f"Search error: {e}", "error") flash(f"Search error: {e}", "error")
# print traceback for debugging # print traceback for debugging
traceback.print_exc() traceback.print_exc()
books, total_count = [], 0
books = [] # Calculate pagination info
total_pages = (total_count + RESULTS_PER_PAGE - 1) // RESULTS_PER_PAGE
has_prev = page > 1
has_next = page < total_pages
prev_num = page - 1 if has_prev else None
next_num = page + 1 if has_next else None
return render_template("book/list.html.j2", books=books, query=query) return render_template(
"book/list.html.j2",
books=books,
query=query,
pagination={
"page": page,
"per_page": RESULTS_PER_PAGE,
"total": total_count,
"pages": total_pages,
"has_prev": has_prev,
"has_next": has_next,
"prev_num": prev_num,
"next_num": next_num,
},
)
@bp.route("/book/<int:book_id>") @bp.route("/book/<int:book_id>")

View File

@@ -40,6 +40,76 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<nav aria-label="Book pagination" class="mt-4">
<ul class="pagination justify-content-center">
<!-- Previous Page -->
<li class="page-item {{ 'disabled' if not pagination.has_prev }}">
{% if pagination.has_prev %}
<a class="page-link" href="{{ url_for('main.index', q=query, page=pagination.prev_num) }}">&laquo;
Previous</a>
{% else %}
<span class="page-link">&laquo; Previous</span>
{% endif %}
</li>
<!-- Page Numbers -->
{% set start_page = [1, pagination.page - 2]|max %}
{% set end_page = [pagination.pages, pagination.page + 2]|min %}
{% if start_page > 1 %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.index', q=query, page=1) }}">1</a>
</li>
{% if start_page > 2 %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
{% endif %}
{% for page_num in range(start_page, end_page + 1) %}
<li class="page-item {{ 'active' if page_num == pagination.page }}">
{% if page_num == pagination.page %}
<span class="page-link">{{ page_num }}</span>
{% else %}
<a class="page-link" href="{{ url_for('main.index', q=query, page=page_num) }}">{{ page_num }}</a>
{% endif %}
</li>
{% endfor %}
{% if end_page < pagination.pages %} {% if end_page < pagination.pages - 1 %} <li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.index', q=query, page=pagination.pages) }}">{{
pagination.pages }}</a>
</li>
{% endif %}
<!-- Next Page -->
<li class="page-item {{ 'disabled' if not pagination.has_next }}">
{% if pagination.has_next %}
<a class="page-link" href="{{ url_for('main.index', q=query, page=pagination.next_num) }}">Next
&raquo;</a>
{% else %}
<span class="page-link">Next &raquo;</span>
{% endif %}
</li>
</ul>
<!-- Results Info -->
<div class="text-center text-muted mt-2">
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) if books else 0 }}
to {{ [pagination.page * pagination.per_page, pagination.total]|min }}
of {{ pagination.total }} books
</div>
</nav>
{% endif %}
{% else %} {% else %}
<div class="text-center py-5"> <div class="text-center py-5">
<div class="mb-3"> <div class="mb-3">