From 3ac792720e99e9845359fdacddc8d3a022883162 Mon Sep 17 00:00:00 2001 From: Francisco Penedo Alvarez Date: Sat, 21 Mar 2026 20:07:45 +0100 Subject: [PATCH] Add pagination --- .github/copilot-instructions.md | 79 ++++++++++++++++++------- src/hxbooks/cli.py | 2 +- src/hxbooks/library.py | 21 +++++-- src/hxbooks/main.py | 37 ++++++++++-- src/hxbooks/templates/book/list.html.j2 | 70 ++++++++++++++++++++++ 5 files changed, 179 insertions(+), 30 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d00d6e0..454a5a8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,26 +2,29 @@ ## 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:** - **Backend**: Flask 3.1+ with SQLAlchemy 2.0 (modern `Mapped[]` annotations) - **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 -- **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 +- **CLI**: Click-based comprehensive command interface ## Architecture -**Application Factory Pattern:** -- `create_app()` in [src/hxbooks/__init__.py](src/hxbooks/__init__.py) -- Blueprint organization: `auth.py` (authentication), `book.py` (main features) -- Models: User, Book, Reading, Wishlist with cascade relationships +**Service Layer Pattern:** +- Flask app factory in [src/hxbooks/app.py](src/hxbooks/app.py) +- Service layer: [src/hxbooks/library.py](src/hxbooks/library.py) - business logic shared between web and CLI +- 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:** -- [src/hxbooks/models.py](src/hxbooks/models.py): SQLAlchemy models with modern `Mapped[]` syntax -- [src/hxbooks/book.py](src/hxbooks/book.py): Complex search with Pydantic validation +- [src/hxbooks/models.py](src/hxbooks/models.py): SQLAlchemy models with proper normalization and relationships +- [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/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 uv sync # Install dependencies 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 +hxbooks reading finish --rating 5 --comments "Excellent!" +hxbooks wishlist add ``` **Development Server:** -- [src/hxbooks/__main__.py](src/hxbooks/__main__.py): Livereload server watching templates -- VS Code debugging: Use "Python Debugger: Flask" launch configuration +- CLI command `hxbooks serve`: Livereload server watching templates +- 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) +**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:** - Dockerfile uses Gunicorn on port 8080 - **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]`) - **Naming**: snake_case functions/vars, PascalCase classes, UPPERCASE constants - **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:** - 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) - 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):** - Templates: `.j2` extension, fragments for partial updates - 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 **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/models.py](src/hxbooks/models.py): Database models **Feature Modules:** -- [src/hxbooks/auth.py](src/hxbooks/auth.py): User authentication (username-based) -- [src/hxbooks/book.py](src/hxbooks/book.py): Book CRUD, search, filtering +- [src/hxbooks/main.py](src/hxbooks/main.py): Web routes and form handling +- [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 +**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 **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 - Foreign key constraints explicitly defined **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 -- Saved searches stored as JSON in User model +- Support for negation (`-genre:romance`) and special operators (`is:reading`) **HTMX Integration:** - 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 **Development Notes:** -- No testing framework configured yet -- No linting/formatting tools setup +- Testing framework: pytest with parametrized tests for comprehensive coverage +- Linting/formatting: ruff configured with strict rules - Instance folder for environment-specific config (gitignored) - Production requires either `requirements.txt` generation or Dockerfile updates for UV \ No newline at end of file diff --git a/src/hxbooks/cli.py b/src/hxbooks/cli.py index 30b0339..3b7a45e 100644 --- a/src/hxbooks/cli.py +++ b/src/hxbooks/cli.py @@ -304,7 +304,7 @@ def search_books( with app.app_context(): try: - books = library.search_books_advanced( + books, _ = library.search_books_advanced( query_string=query, limit=limit, username=username ) if output_format == "json": diff --git a/src/hxbooks/library.py b/src/hxbooks/library.py index 9f8f73e..ad751c5 100644 --- a/src/hxbooks/library.py +++ b/src/hxbooks/library.py @@ -276,9 +276,13 @@ query_parser = QueryParser() def search_books_advanced( - query_string: str, limit: int = 50, username: str | None = None -) -> Sequence[Book]: - """Advanced search with field filters supporting comparison operators.""" + query_string: str, limit: int = 50, offset: int = 0, username: str | None = None +) -> tuple[Sequence[Book], int]: + """Advanced search with field filters supporting comparison operators. + + Returns: + tuple: (books, total_count) + """ parsed_query = query_parser.parse(query_string) query = ( @@ -332,10 +336,17 @@ def search_books_advanced( sort_columns.append(Book.added_date.desc()) 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) - return result.scalars().unique().all() + books = result.scalars().unique().all() + + return books, total_count def _build_field_condition( diff --git a/src/hxbooks/main.py b/src/hxbooks/main.py index 51cb487..59558bf 100644 --- a/src/hxbooks/main.py +++ b/src/hxbooks/main.py @@ -116,26 +116,55 @@ def inject_template_vars() -> dict[str, Any]: } +RESULTS_PER_PAGE = 10 + + @bp.route("/") def index() -> ResponseReturnValue: """Book list view - main application page.""" # Get search parameters 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 viewing_user = session.get("viewing_as_user") 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: flash(f"Search error: {e}", "error") # print traceback for debugging - 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/") diff --git a/src/hxbooks/templates/book/list.html.j2 b/src/hxbooks/templates/book/list.html.j2 index f51f8d8..6dd54b2 100644 --- a/src/hxbooks/templates/book/list.html.j2 +++ b/src/hxbooks/templates/book/list.html.j2 @@ -40,6 +40,76 @@ {% endfor %} + + +{% if pagination.pages > 1 %} + +{% endif %} + {% else %}