Compare commits
12 Commits
20265e679b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 95e434a750 | |||
| 167b37f471 | |||
| 6b65d9cd15 | |||
| 04cd6dafb0 | |||
| cc03e60a4b | |||
| b231452ad0 | |||
| b06ceb0847 | |||
| 0083e3d896 | |||
| d427cec8d5 | |||
| 40ca08359f | |||
| c30ad57051 | |||
| 9232ac6133 |
103
.github/copilot-instructions.md
vendored
Normal file
103
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# HXBooks Project Guidelines
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
**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
|
||||||
|
- **Templates**: Jinja2 with fragments for partial page updates
|
||||||
|
- **Database**: SQLite with JSON columns for flexible arrays
|
||||||
|
- **Package Manager**: UV with Python 3.14
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
**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/gbooks.py](src/hxbooks/gbooks.py): Google Books API integration
|
||||||
|
- [src/hxbooks/templates/](src/hxbooks/templates/): Jinja2 templates with HTMX fragments
|
||||||
|
|
||||||
|
## Build and Development
|
||||||
|
|
||||||
|
**Setup & Run:**
|
||||||
|
```bash
|
||||||
|
uv sync # Install dependencies
|
||||||
|
python -m hxbooks # Dev server with livereload (port 5000)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Development Server:**
|
||||||
|
- [src/hxbooks/__main__.py](src/hxbooks/__main__.py): Livereload server watching templates
|
||||||
|
- VS Code debugging: Use "Python Debugger: Flask" launch configuration
|
||||||
|
- Config: Instance folder pattern (`instance/config.py` for local overrides)
|
||||||
|
|
||||||
|
**Production Deployment:**
|
||||||
|
- Dockerfile uses Gunicorn on port 8080
|
||||||
|
- **Note**: Current Dockerfile expects `requirements.txt` but project uses `pyproject.toml`
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
**Python Conventions:**
|
||||||
|
- **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
|
||||||
|
|
||||||
|
**Flask Patterns:**
|
||||||
|
- Blueprints with `@bp.route()` decorators
|
||||||
|
- `@login_required` decorator for protected routes
|
||||||
|
- Response types: `str | Response` (template string or redirect)
|
||||||
|
- Error handling: Dict mapping for template display `{field: error_msg}`
|
||||||
|
|
||||||
|
**Frontend (HTMX + Alpine.js):**
|
||||||
|
- Templates: `.j2` extension, fragments for partial updates
|
||||||
|
- HTMX: Dynamic updates with `hx-get`, `hx-post`, `hx-target`
|
||||||
|
- Alpine.js: Minimal state management (`x-data`, `x-show`, `@click`)
|
||||||
|
- Styling: Bootstrap CSS classes
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
**Entry Points:**
|
||||||
|
- [src/hxbooks/__main__.py](src/hxbooks/__main__.py): Development server
|
||||||
|
- [main.py](main.py): Unused placeholder
|
||||||
|
- [.vscode/launch.json](.vscode/launch.json): Debug configuration
|
||||||
|
|
||||||
|
**Core Application:**
|
||||||
|
- [src/hxbooks/__init__.py](src/hxbooks/__init__.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/gbooks.py](src/hxbooks/gbooks.py): Google Books API integration
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
**Database Patterns:**
|
||||||
|
- JSON columns for arrays: `authors: list`, `genres: list`, `saved_searches: dict`
|
||||||
|
- Cascade deletes for dependent entities
|
||||||
|
- Foreign key constraints explicitly defined
|
||||||
|
|
||||||
|
**Search Implementation:**
|
||||||
|
- Full-text search using SQLite FTS with `func.match()`
|
||||||
|
- Complex query builder returning `Select` statements
|
||||||
|
- Saved searches stored as JSON in User model
|
||||||
|
|
||||||
|
**HTMX Integration:**
|
||||||
|
- Partial page updates using Jinja2-Fragments
|
||||||
|
- Search results rendered as blocks without full page reload
|
||||||
|
- Form validation errors returned as template fragments
|
||||||
|
|
||||||
|
**Development Notes:**
|
||||||
|
- No testing framework configured yet
|
||||||
|
- No linting/formatting tools setup
|
||||||
|
- Instance folder for environment-specific config (gitignored)
|
||||||
|
- Production requires either `requirements.txt` generation or Dockerfile updates for UV
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
instance/
|
instance/
|
||||||
|
*.sqlite
|
||||||
15
.pre-commit-config.yaml
Normal file
15
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||||
|
# uv version.
|
||||||
|
rev: 0.10.10
|
||||||
|
hooks:
|
||||||
|
- id: uv-lock
|
||||||
|
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
# Ruff version.
|
||||||
|
rev: v0.15.6
|
||||||
|
hooks:
|
||||||
|
# Run the linter.
|
||||||
|
- id: ruff-check
|
||||||
|
# Run the formatter.
|
||||||
|
- id: ruff-format
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
247
docs/development-plan.md
Normal file
247
docs/development-plan.md
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
# HXBooks Development Plan & Progress
|
||||||
|
|
||||||
|
## User's Priorities (March 2026)
|
||||||
|
1. ✅ **Fix the domain and data model**
|
||||||
|
2. ✅ **Make sure everything related to the database is good**
|
||||||
|
3. ✅ **Make a CLI so I can test things manually**
|
||||||
|
4. ✅ **Make sure search and other basic functionality is good and can be accessed through CLI**
|
||||||
|
5. ✅ **Set up automated tests**
|
||||||
|
6. ✅ **Make sure search and other basic functionality is good**
|
||||||
|
7. **Fully rework the GUI**
|
||||||
|
|
||||||
|
*Everything else will come later.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ COMPLETED: Domain Model & Database (Phase 1-2)
|
||||||
|
|
||||||
|
### Domain Model Decisions Made
|
||||||
|
- **No book/instance separation**: Keep it simple, treat duplicate editions as separate books
|
||||||
|
- **Author/Genre relationships**: Proper many-to-many instead of JSON fields
|
||||||
|
- **Location hierarchy**: `location_place` + `location_bookshelf` + `location_shelf` (numeric)
|
||||||
|
- **Auto-complete approach**: Authors/genres created on-demand with nice UI later
|
||||||
|
- **Multiple readings**: Separate records per reading session
|
||||||
|
- **Simple loaning**: `loaned_to` string + `loaned_date` for tracking
|
||||||
|
|
||||||
|
### Database Infrastructure ✅ DONE
|
||||||
|
- ✅ Flask-Migrate + Alembic set up
|
||||||
|
- ✅ Initial migration created and applied
|
||||||
|
- ✅ Fixed instance folder location (project root instead of src/instance)
|
||||||
|
- ✅ Database in correct location: `/hxbooks.sqlite`
|
||||||
|
- ✅ All tables created: author, genre, book, book_author, book_genre, reading, wishlist
|
||||||
|
|
||||||
|
### New Data Model ✅ IMPLEMENTED
|
||||||
|
```sql
|
||||||
|
-- Core entities
|
||||||
|
Author(id, name)
|
||||||
|
Genre(id, name)
|
||||||
|
Book(id, title, description, isbn, edition, publisher, notes,
|
||||||
|
added_date, bought_date,
|
||||||
|
location_place, location_bookshelf, location_shelf,
|
||||||
|
loaned_to, loaned_date, owner_id)
|
||||||
|
|
||||||
|
-- Many-to-many relationships
|
||||||
|
BookAuthor(book_id, author_id)
|
||||||
|
BookGenre(book_id, genre_id)
|
||||||
|
|
||||||
|
-- User activity
|
||||||
|
Reading(id, user_id, book_id, start_date, end_date,
|
||||||
|
finished, dropped, rating, comments)
|
||||||
|
Wishlist(id, user_id, book_id, wishlisted_date)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ COMPLETED: CLI Development (Phase 3)
|
||||||
|
|
||||||
|
### CLI Implementation ✅ DONE
|
||||||
|
- ✅ **Business logic separation**: Clean `services.py` module independent from web concerns
|
||||||
|
- ✅ **Book CRUD operations**: Create, read, update, delete books with proper validation
|
||||||
|
- ✅ **Author/Genre management**: Auto-create on-demand with many-to-many relationships
|
||||||
|
- ✅ **Location management**: Place, bookshelf, shelf hierarchy with filtering
|
||||||
|
- ✅ **Reading tracking**: Start, finish, drop, rate reading sessions
|
||||||
|
- ✅ **Wishlist operations**: Add, remove, list wishlist items
|
||||||
|
- ✅ **Advanced search**: pyparsing-based query language with field filters and comparison operators
|
||||||
|
- ✅ **ISBN import**: Google Books API integration for book metadata
|
||||||
|
- ✅ **Database utilities**: Status, initialization, seed data commands
|
||||||
|
- ✅ **Output formats**: Human-readable tables and JSON for scripting
|
||||||
|
|
||||||
|
### Advanced Search Language ✅ IMPLEMENTED
|
||||||
|
```bash
|
||||||
|
# Working CLI commands:
|
||||||
|
hxbooks book add "Title" --owner alice --authors "Author1,Author2" --genres "Fiction"
|
||||||
|
hxbooks book list --place "home" --bookshelf "office" --shelf 2
|
||||||
|
hxbooks book search "author:tolkien genre:fantasy"
|
||||||
|
hxbooks book search "shelf>=5 title:\"Lord of Rings\""
|
||||||
|
hxbooks book search -- "-genre:romance" # Negation
|
||||||
|
hxbooks reading start <book_id> --owner alice
|
||||||
|
hxbooks reading finish <book_id> --rating 4 --comments "Great book!"
|
||||||
|
hxbooks wishlist add <book_id> --owner alice
|
||||||
|
hxbooks book import 9780441172719 --owner alice # ISBN import
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search Query Language Features
|
||||||
|
- **Field-specific searches**: `author:tolkien`, `genre:"science fiction"`
|
||||||
|
- **Comparison operators**: `shelf>=5`, `added>=2025-01-01`, `rating>3`
|
||||||
|
- **Quoted strings**: `title:"The Lord of the Rings"`
|
||||||
|
- **Negation**: `-genre:romance`
|
||||||
|
- **Date comparisons**: `added>=2026-03-01`, `bought<2025-12-31`
|
||||||
|
- **Multiple filters**: `author:herbert genre:scifi owner:alice`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ COMPLETED: Automated Testing (Phase 4)
|
||||||
|
|
||||||
|
### Testing Framework ✅ IMPLEMENTED
|
||||||
|
- ✅ **pytest infrastructure**: Database fixtures, isolated test environments
|
||||||
|
- ✅ **CLI command tests**: All 18 commands with happy paths and error scenarios (29+ tests)
|
||||||
|
- ✅ **Advanced search tests**: Parametrized tests for field filters and complex queries
|
||||||
|
- ✅ **Query parser unit tests**: Type conversion, operator logic, edge cases (36 tests)
|
||||||
|
- ✅ **Output format validation**: JSON and table formats for all commands
|
||||||
|
- ✅ **Database integration**: Full CLI → services → database → relationships flow testing
|
||||||
|
- ✅ **Error handling tests**: Invalid inputs, missing data, constraint violations
|
||||||
|
|
||||||
|
### Test Coverage Achieved
|
||||||
|
- **CLI Integration**: Book CRUD, reading tracking, wishlist operations, database utilities
|
||||||
|
- **Search functionality**: String filters, numeric filters, date filters, negation, complex queries
|
||||||
|
- **Parser robustness**: Edge cases, type conversion, fallback behavior, unicode support
|
||||||
|
- **Database validation**: Relationship integrity, user creation, data persistence
|
||||||
|
|
||||||
|
**Decision**: Migration tests deemed unnecessary for this simple personal app
|
||||||
|
**Status**: 65+ tests passing, comprehensive coverage for critical functionality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ COMPLETED: Search & Core Features Enhancement (Phase 5)
|
||||||
|
|
||||||
|
### Search Improvements ✅ IMPLEMENTED
|
||||||
|
- ✅ **Enhanced search functionality**: Additional improvements implemented
|
||||||
|
- ✅ **Query optimization**: Performance and usability enhancements
|
||||||
|
- ✅ **Core feature refinements**: Various search and functionality improvements
|
||||||
|
|
||||||
|
**Status**: Search functionality enhanced and ready for production use
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 TODO: Remaining Phases
|
||||||
|
|
||||||
|
### Phase 6: GUI Rework
|
||||||
|
- [ ] Update templates for new data model
|
||||||
|
- [ ] Mobile-first responsive design
|
||||||
|
- [ ] Author/Genre autocomplete interfaces
|
||||||
|
- [ ] Location hierarchy picker
|
||||||
|
- [ ] Touch-optimized interactions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗄️ Original Critique Archive
|
||||||
|
|
||||||
|
### Critical Issues RESOLVED ✅
|
||||||
|
- ❌ **Book ownership model**: Fixed - no artificial scarcity
|
||||||
|
- ❌ **JSON denormalization**: Fixed - proper Author/Genre relationships
|
||||||
|
- ❌ **Mixed properties**: Fixed - structured location hierarchy
|
||||||
|
- ❌ **No migrations**: Fixed - Alembic set up and working
|
||||||
|
- ❌ **Poor folder structure**: Fixed - database in project root
|
||||||
|
|
||||||
|
### Issues for Later Phases
|
||||||
|
- **Authentication**: Username-only insufficient (Future phases)
|
||||||
|
- **Configuration management**: No environment handling (Future phases)
|
||||||
|
- **Mobile UX**: Tables don't work on mobile (Phase 6)
|
||||||
|
- **Error handling**: No proper boundaries (Future phases)
|
||||||
|
- **Performance**: No indexing strategy yet (Phase 4)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: March 17, 2026*
|
||||||
|
*Status: Phases 1-5 Complete ✅ | Ready for Phase 6: GUI Rework 🎨*
|
||||||
|
|
||||||
|
### Medium Priority Issues (Priority 3-4: CLI & Search)
|
||||||
|
|
||||||
|
#### Search & Discovery
|
||||||
|
- **Limited FTS capabilities**: Current implementation incomplete
|
||||||
|
- **No faceted search**: Missing filters by author, genre, year, etc.
|
||||||
|
- **Performance concerns**: JSON contains operations will be slow
|
||||||
|
- **Missing recommendations**: No "similar books" functionality
|
||||||
|
|
||||||
|
#### CLI Requirements for Testing
|
||||||
|
- Book CRUD operations
|
||||||
|
- Search functionality testing
|
||||||
|
- User management
|
||||||
|
- Reading tracking
|
||||||
|
- Data import/export capabilities
|
||||||
|
|
||||||
|
### Lower Priority Issues (Priority 5-6: Testing & GUI)
|
||||||
|
|
||||||
|
#### Testing Infrastructure Missing
|
||||||
|
- No testing framework configured
|
||||||
|
- No test database setup
|
||||||
|
- No fixtures or mock data
|
||||||
|
- No CI/CD pipeline
|
||||||
|
|
||||||
|
#### GUI/UX Issues
|
||||||
|
- Mobile responsiveness needs work
|
||||||
|
- No offline capabilities
|
||||||
|
- Tables don't work well on mobile
|
||||||
|
- Missing accessibility features
|
||||||
|
|
||||||
|
### Security & DevOps (Future)
|
||||||
|
- **Authentication**: Username-only is insufficient
|
||||||
|
- **Configuration management**: No environment handling
|
||||||
|
- **Deployment**: Dockerfile/requirements.txt mismatch
|
||||||
|
- **Secret management**: Hardcoded dev secrets
|
||||||
|
|
||||||
|
### Technical Debt
|
||||||
|
- **Python 3.14 requirement**: Too aggressive (doesn't exist yet)
|
||||||
|
- **Error handling**: No proper error boundaries
|
||||||
|
- **Logging**: No production logging configuration
|
||||||
|
- **Code quality**: Missing linting, formatting tools
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Phase 1: Domain Model Rework
|
||||||
|
- [ ] Design new schema with proper relationships
|
||||||
|
- [ ] Create migration system
|
||||||
|
- [ ] Implement Author and Genre entities
|
||||||
|
- [ ] Separate Book from BookInstance
|
||||||
|
- [ ] Update all models with proper typing
|
||||||
|
|
||||||
|
### Phase 2: Database Infrastructure
|
||||||
|
- [ ] Set up Alembic migrations
|
||||||
|
- [ ] Add proper indexing
|
||||||
|
- [ ] Implement FTS correctly
|
||||||
|
- [ ] Add database constraints
|
||||||
|
- [ ] Create seed data
|
||||||
|
|
||||||
|
### Phase 3: CLI Development
|
||||||
|
- [ ] Create Click-based CLI
|
||||||
|
- [ ] Book management commands
|
||||||
|
- [ ] Search functionality
|
||||||
|
- [ ] User operations
|
||||||
|
- [ ] Import/export tools
|
||||||
|
|
||||||
|
### Phase 4: Search & Core Features
|
||||||
|
- [ ] Implement proper FTS
|
||||||
|
- [ ] Add faceted search
|
||||||
|
- [ ] Create search result serializers
|
||||||
|
- [ ] Add pagination
|
||||||
|
- [ ] Optimize query performance
|
||||||
|
|
||||||
|
### Phase 5: Testing Framework
|
||||||
|
- [ ] Set up pytest
|
||||||
|
- [ ] Database testing fixtures
|
||||||
|
- [ ] API endpoint tests
|
||||||
|
- [ ] Search functionality tests
|
||||||
|
- [ ] CLI command tests
|
||||||
|
|
||||||
|
### Phase 6: GUI Rework
|
||||||
|
- [ ] Mobile-first responsive design
|
||||||
|
- [ ] Progressive enhancement
|
||||||
|
- [ ] Accessibility improvements
|
||||||
|
- [ ] Modern HTMX patterns
|
||||||
|
- [ ] Touch-optimized interactions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: March 14, 2026*
|
||||||
281
docs/frontend-plan.md
Normal file
281
docs/frontend-plan.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# Frontend Rebuild Plan - Phase 1: Pure HTML Responsive Design
|
||||||
|
|
||||||
|
**Created**: March 17, 2026
|
||||||
|
**Completed**: March 20, 2026
|
||||||
|
**Status**: ✅ **PHASE 1 COMPLETE**
|
||||||
|
**Phase**: 1 of 3 (Pure HTML → HTMX → JavaScript Components)
|
||||||
|
|
||||||
|
## ✅ Phase 1 Completion Summary
|
||||||
|
|
||||||
|
**Phase 1 has been successfully completed** with all core objectives achieved and several enhancements beyond the original scope.
|
||||||
|
|
||||||
|
### 🎯 Major Achievements
|
||||||
|
|
||||||
|
**✅ Complete Responsive Redesign**
|
||||||
|
- Mobile-first responsive layout with 768px breakpoint
|
||||||
|
- JavaScript completely eliminated (HTML + CSS only approach)
|
||||||
|
- Component-based template architecture ready for HTMX integration
|
||||||
|
- Clean URL structure with search state persistence
|
||||||
|
|
||||||
|
**✅ Enhanced User Experience**
|
||||||
|
- User-specific status badges on book cards (reading, rating, wishlist)
|
||||||
|
- Expandable mobile status bar using CSS-only approach
|
||||||
|
- Fixed Bootstrap mobile dropdown positioning issues
|
||||||
|
- Optimized book card sizing and layout for better screen utilization
|
||||||
|
|
||||||
|
**✅ Technical Improvements**
|
||||||
|
- Pydantic 2.x server-side validation with proper error handling
|
||||||
|
- Shared template component system (`user_book_vars.html.j2`)
|
||||||
|
- Jinja2 import/export pattern for DRY template variables
|
||||||
|
- Individual form handling to avoid nested HTML form issues
|
||||||
|
|
||||||
|
**✅ Code Quality Enhancements**
|
||||||
|
- Eliminated code duplication across template components
|
||||||
|
- Proper Jinja2 scoping with `{% import %}` and `with context`
|
||||||
|
- Component isolation ready for HTMX partial updates
|
||||||
|
- Clean separation between presentation and business logic
|
||||||
|
|
||||||
|
### 📱 Mobile Responsiveness Achievements
|
||||||
|
- **Header**: Fixed user selector dropdown floating properly on mobile
|
||||||
|
- **Book Details**: Expandable status component that collapses on mobile, stays expanded on desktop
|
||||||
|
- **Book Cards**: Status badges properly positioned, optimized card sizing
|
||||||
|
- **Forms**: All forms work seamlessly across device sizes
|
||||||
|
|
||||||
|
### 🛠️ Technical Architecture Delivered
|
||||||
|
- Component-based templates in `src/hxbooks/templates/components/`
|
||||||
|
- Shared variables system for user book data across components
|
||||||
|
- Bootstrap 5.3 + custom CSS responsive framework
|
||||||
|
- Server-side validation with Pydantic schemas
|
||||||
|
- Clean URL routing with search state preservation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Rebuild HXBooks frontend with mobile-first responsive design using pure HTML and Bootstrap. Create clean, component-based template structure that will be HTMX-ready for Phase 2.
|
||||||
|
|
||||||
|
**Core Views:**
|
||||||
|
- **Book List**: Main view with search bar, sidebar, results grid
|
||||||
|
- **Book Details**: Full book data display and edit forms
|
||||||
|
|
||||||
|
**Design Decisions:**
|
||||||
|
- Mobile-first responsive (768px breakpoint)
|
||||||
|
- Visible sidebar (desktop), hamburger menu (mobile)
|
||||||
|
- User selector as filter dropdown (not login-style)
|
||||||
|
- Clean URLs with full search state persistence
|
||||||
|
- Component-based templates for HTMX compatibility
|
||||||
|
- HTML5 + server-side validation
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase A: Foundation Setup *(Steps 1-3)*
|
||||||
|
|
||||||
|
**Step 1: Base Layout & Components**
|
||||||
|
- `templates/base.html.j2` - Main layout with Bootstrap, responsive sidebar framework
|
||||||
|
- `templates/components/header.html.j2` - Header with user selector dropdown
|
||||||
|
- `templates/components/sidebar.html.j2` - Collapsible sidebar with search list
|
||||||
|
- `src/hxbooks/static/style.css` - Custom responsive CSS
|
||||||
|
|
||||||
|
**Step 2: Clean URL Routing**
|
||||||
|
- Rewrite `src/hxbooks/app.py` routes with clean URLs:
|
||||||
|
- `/` - Book list (main view)
|
||||||
|
- `/search?q=...&filters=...` - Search with URL persistence
|
||||||
|
- `/book/new` - Create book
|
||||||
|
- `/book/<id>` - Book details/edit
|
||||||
|
- `/book/<id>/delete` - Delete confirmation
|
||||||
|
- `/import` - Import from ISBN
|
||||||
|
|
||||||
|
**Step 3: Sidebar Component**
|
||||||
|
- Default searches (All, Owned, Wishlist, Reading, Read)
|
||||||
|
- Saved user searches with edit/delete
|
||||||
|
- Create/Import buttons in top section
|
||||||
|
- Responsive collapse behavior
|
||||||
|
|
||||||
|
### Phase B: Book List View *(Steps 4-7)*
|
||||||
|
|
||||||
|
**Step 4: Search Infrastructure**
|
||||||
|
- `templates/components/search_bar.html.j2` - Search input with suggestions
|
||||||
|
- `templates/components/search_filters.html.j2` - Advanced filters panel
|
||||||
|
- URL parameter handling for persistent search state
|
||||||
|
- Mobile-optimized search interface
|
||||||
|
|
||||||
|
**Step 5: Search Results Display**
|
||||||
|
- `templates/components/book_card.html.j2` - Individual book preview card
|
||||||
|
- `templates/book/list.html.j2` - Results grid with responsive columns
|
||||||
|
- Card layout optimized for future HTMX partial updates
|
||||||
|
- Loading state placeholders (simple for Phase 1)
|
||||||
|
|
||||||
|
**Step 6: Search State Persistence**
|
||||||
|
- URL serialization/deserialization for all search params
|
||||||
|
- Browser history support with clean URLs
|
||||||
|
- Bookmarkable search results
|
||||||
|
- Form state preservation on validation errors
|
||||||
|
|
||||||
|
**Step 7: Save Search Functionality**
|
||||||
|
- `templates/components/save_search_modal.html.j2` - Save search form
|
||||||
|
- Integration with user saved searches in sidebar
|
||||||
|
- Validation and error handling
|
||||||
|
|
||||||
|
### Phase C: Book Details View *(Steps 8-11)*
|
||||||
|
|
||||||
|
**Step 8: Book Details Structure**
|
||||||
|
- `templates/book/detail.html.j2` - Full book display and edit forms
|
||||||
|
- `templates/components/book_form.html.j2` - Reusable book edit form
|
||||||
|
- All metadata fields (title, authors, genres, location, notes)
|
||||||
|
- Responsive form layout for mobile/desktop
|
||||||
|
|
||||||
|
**Step 9: User-Specific Data**
|
||||||
|
- `templates/components/reading_status.html.j2` - Reading tracking component
|
||||||
|
- `templates/components/wishlist_status.html.j2` - Wishlist management
|
||||||
|
- Reading history display based on selected user context
|
||||||
|
- User-specific action buttons
|
||||||
|
|
||||||
|
**Step 10: Book Action Buttons**
|
||||||
|
- Accept/Discard changes with form state management
|
||||||
|
- Delete book with confirmation
|
||||||
|
- Start/Finish reading workflow
|
||||||
|
- Add/Remove from wishlist
|
||||||
|
- Action buttons optimized for HTMX target attributes
|
||||||
|
|
||||||
|
**Step 11: Book Creation Workflow**
|
||||||
|
- `templates/book/create.html.j2` - New book form
|
||||||
|
- `templates/components/import_modal.html.j2` - ISBN import modal
|
||||||
|
- Google Books API integration form
|
||||||
|
- Form validation with inline error display
|
||||||
|
|
||||||
|
### Phase D: Integration & Polish *(Steps 12-13)*
|
||||||
|
|
||||||
|
**Step 12: Form Validation**
|
||||||
|
- HTML5 client-side validation attributes
|
||||||
|
- Server-side Pydantic validation integration
|
||||||
|
- `templates/components/error_display.html.j2` - Error message component
|
||||||
|
- Form state preservation and error recovery
|
||||||
|
|
||||||
|
**Step 13: Responsive Testing & Cleanup**
|
||||||
|
- Cross-device testing (phone, tablet, desktop)
|
||||||
|
- Sidebar behavior verification at 768px breakpoint
|
||||||
|
- Form flow validation and error handling
|
||||||
|
- Performance optimization for large libraries
|
||||||
|
|
||||||
|
## Technical Architecture
|
||||||
|
|
||||||
|
### Component Structure
|
||||||
|
```
|
||||||
|
templates/
|
||||||
|
├── base.html.j2 # Main layout
|
||||||
|
├── components/ # Reusable components (HTMX-ready)
|
||||||
|
│ ├── header.html.j2 # User selector, branding
|
||||||
|
│ ├── sidebar.html.j2 # Search list, actions
|
||||||
|
│ ├── search_bar.html.j2 # Search input
|
||||||
|
│ ├── search_filters.html.j2 # Advanced filters
|
||||||
|
│ ├── book_card.html.j2 # Book preview card
|
||||||
|
│ ├── book_form.html.j2 # Book edit form
|
||||||
|
│ ├── reading_status.html.j2 # Reading tracking
|
||||||
|
│ ├── wishlist_status.html.j2 # Wishlist management
|
||||||
|
│ ├── save_search_modal.html.j2 # Save search dialog
|
||||||
|
│ ├── import_modal.html.j2 # ISBN import dialog
|
||||||
|
│ └── error_display.html.j2 # Error messages
|
||||||
|
└── book/
|
||||||
|
├── list.html.j2 # Book list with search results
|
||||||
|
├── detail.html.j2 # Book details and edit
|
||||||
|
└── create.html.j2 # New book creation
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTMX-Ready Design Patterns
|
||||||
|
- **Atomic Components**: Each component can be updated independently
|
||||||
|
- **Target Attributes**: Components designed for `hx-target` updates
|
||||||
|
- **Fragment Boundaries**: Clear separation for partial page updates
|
||||||
|
- **State Management**: URL-based state for HTMX navigation
|
||||||
|
- **Form Structure**: Post-redirect-get pattern for HTMX compatibility
|
||||||
|
|
||||||
|
### URL Structure (Clean)
|
||||||
|
```
|
||||||
|
/ # Book list (main view)
|
||||||
|
/search?q=tolkien&read=true # Search with filters (bookmarkable)
|
||||||
|
/book/new # Create new book
|
||||||
|
/book/123 # View/edit book details
|
||||||
|
/book/123/delete # Delete confirmation
|
||||||
|
/import # Import book from ISBN
|
||||||
|
```
|
||||||
|
|
||||||
|
### Responsive Breakpoints
|
||||||
|
- **Mobile**: < 768px (hamburger sidebar, stacked layout)
|
||||||
|
- **Desktop**: ≥ 768px (visible sidebar, grid layout)
|
||||||
|
- **Component Flexibility**: Cards adapt to container width
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Backend Integration
|
||||||
|
- Rewrite routes in `src/hxbooks/app.py` (clean implementation vs book.py)
|
||||||
|
- Use `library.py` as business logic interface (following CLI pattern)
|
||||||
|
- Mock missing functionality in `library.py` (saved searches handling, etc.)
|
||||||
|
- Utilize existing Pydantic schemas for validation
|
||||||
|
- **Note**: Implement missing `library.py` methods as needed during development
|
||||||
|
|
||||||
|
### Development Priorities
|
||||||
|
1. **Mobile-first**: Design components for mobile, enhance for desktop
|
||||||
|
2. **Component Isolation**: Prepare for HTMX partial updates
|
||||||
|
3. **Clean URLs**: Ensure bookmarkable and shareable search states
|
||||||
|
4. **Form Validation**: HTML5 + server-side with good error UX
|
||||||
|
|
||||||
|
### Excluded from Phase 1
|
||||||
|
- HTMX dynamic updates (Phase 2)
|
||||||
|
- JavaScript component enhancement (Phase 3)
|
||||||
|
- Advanced loading states and error recovery
|
||||||
|
- Mobile gesture optimization
|
||||||
|
- Accessibility features (deferred)
|
||||||
|
- Alternative view formats (table, detailed list)
|
||||||
|
|
||||||
|
## ✅ Verification Checklist - COMPLETED
|
||||||
|
|
||||||
|
### Responsive Behavior
|
||||||
|
- [x] Sidebar collapses to hamburger on mobile (< 768px)
|
||||||
|
- [x] Card grid adapts to screen width (optimized with better sizing)
|
||||||
|
- [x] Forms are usable on mobile devices
|
||||||
|
- [x] Header user selector works on all devices (fixed dropdown positioning)
|
||||||
|
|
||||||
|
### Search Functionality
|
||||||
|
- [x] URL persistence for all search parameters
|
||||||
|
- [x] Saved searches load correctly from sidebar
|
||||||
|
- [x] Search results display in responsive card grid
|
||||||
|
- [x] Navigation between search and details preserves context
|
||||||
|
|
||||||
|
### Book Management
|
||||||
|
- [x] Create new book workflow functions
|
||||||
|
- [x] ISBN import modal works
|
||||||
|
- [x] Book details editing with validation (Pydantic integration)
|
||||||
|
- [x] Accept/Discard/Delete actions work correctly
|
||||||
|
|
||||||
|
### User Context
|
||||||
|
- [x] User selector dropdown updates context
|
||||||
|
- [x] Reading status reflects selected user
|
||||||
|
- [x] Wishlist data shows for correct user
|
||||||
|
- [x] User-specific actions function properly
|
||||||
|
|
||||||
|
### Form Validation
|
||||||
|
- [x] HTML5 validation prevents submission of invalid data
|
||||||
|
- [x] Server-side Pydantic validation shows errors
|
||||||
|
- [x] Error messages display clearly
|
||||||
|
- [x] Form state preserved after validation errors
|
||||||
|
|
||||||
|
### ✨ Additional Features Delivered
|
||||||
|
- [x] **Status badges on book cards** - Reading status, ratings, and wishlist indicators
|
||||||
|
- [x] **Mobile expandable status component** - CSS-only solution for book details
|
||||||
|
- [x] **Shared template variables** - DRY approach with proper Jinja2 imports
|
||||||
|
- [x] **JavaScript elimination** - Pure HTML+CSS approach achieved
|
||||||
|
- [x] **Enhanced mobile UX** - Fixed dropdown issues, optimized layouts
|
||||||
|
- [x] **Code quality improvements** - Component refactoring and template organization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Phase 2 Preparation
|
||||||
|
|
||||||
|
**Ready for Phase 2**: The component-based architecture and clean URL structure are now ready for HTMX enhancement:
|
||||||
|
- Partial page updates (search results, form submissions)
|
||||||
|
- Inline editing capabilities
|
||||||
|
- Progressive enhancement of user interactions
|
||||||
|
- Dynamic form validation and feedback
|
||||||
|
|
||||||
|
**Key Files Ready for HTMX Integration:**
|
||||||
|
- All components in `src/hxbooks/templates/components/`
|
||||||
|
- Clean separation between data and presentation
|
||||||
|
- Atomic components designed for independent updates
|
||||||
|
- URL-based state management compatible with HTMX navigation
|
||||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Single-database configuration for Flask.
|
||||||
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# template used to generate migration files
|
||||||
|
# file_template = %%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic,flask_migrate
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[logger_flask_migrate]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = flask_migrate
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
108
migrations/env.py
Normal file
108
migrations/env.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import logging
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
logger = logging.getLogger("alembic.env")
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
try:
|
||||||
|
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||||
|
return current_app.extensions["migrate"].db.get_engine()
|
||||||
|
except TypeError, AttributeError:
|
||||||
|
# this works with Flask-SQLAlchemy>=3
|
||||||
|
return current_app.extensions["migrate"].db.engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine_url():
|
||||||
|
try:
|
||||||
|
return get_engine().url.render_as_string(hide_password=False).replace("%", "%%")
|
||||||
|
except AttributeError:
|
||||||
|
return str(get_engine().url).replace("%", "%%")
|
||||||
|
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
config.set_main_option("sqlalchemy.url", get_engine_url())
|
||||||
|
target_db = current_app.extensions["migrate"].db
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata():
|
||||||
|
if hasattr(target_db, "metadatas"):
|
||||||
|
return target_db.metadatas[None]
|
||||||
|
return target_db.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(url=url, target_metadata=get_metadata(), literal_binds=True)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# this callback is used to prevent an auto-migration from being generated
|
||||||
|
# when there are no changes to the schema
|
||||||
|
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||||
|
def process_revision_directives(context, revision, directives):
|
||||||
|
if getattr(config.cmd_opts, "autogenerate", False):
|
||||||
|
script = directives[0]
|
||||||
|
if script.upgrade_ops.is_empty():
|
||||||
|
directives[:] = []
|
||||||
|
logger.info("No changes in schema detected.")
|
||||||
|
|
||||||
|
conf_args = current_app.extensions["migrate"].configure_args
|
||||||
|
if conf_args.get("process_revision_directives") is None:
|
||||||
|
conf_args["process_revision_directives"] = process_revision_directives
|
||||||
|
|
||||||
|
connectable = get_engine()
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection, target_metadata=get_metadata(), **conf_args
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
143
migrations/versions/75e81e4ab7b6_initial_migration.py
Normal file
143
migrations/versions/75e81e4ab7b6_initial_migration.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""Initial migration
|
||||||
|
|
||||||
|
Revision ID: 75e81e4ab7b6
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-03-14 22:51:20.059755
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "75e81e4ab7b6"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"author",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=200), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"genre",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=100), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"user",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("username", sa.String(), nullable=False),
|
||||||
|
sa.Column("saved_searches", sa.JSON(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"book",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("description", sa.String(), nullable=False),
|
||||||
|
sa.Column("first_published", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("edition", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("publisher", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("isbn", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("notes", sa.String(), nullable=False),
|
||||||
|
sa.Column("added_date", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("bought_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("location_place", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("location_bookshelf", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("location_shelf", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("loaned_to", sa.String(length=200), nullable=False),
|
||||||
|
sa.Column("loaned_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("owner_id", sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["owner_id"],
|
||||||
|
["user.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"book_author",
|
||||||
|
sa.Column("book_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("author_id", sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["author_id"],
|
||||||
|
["author.id"],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["book_id"],
|
||||||
|
["book.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("book_id", "author_id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"book_genre",
|
||||||
|
sa.Column("book_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("genre_id", sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["book_id"],
|
||||||
|
["book.id"],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["genre_id"],
|
||||||
|
["genre.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("book_id", "genre_id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"reading",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("start_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("end_date", sa.Date(), nullable=True),
|
||||||
|
sa.Column("finished", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("dropped", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("rating", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("comments", sa.String(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("book_id", sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["book_id"],
|
||||||
|
["book.id"],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"],
|
||||||
|
["user.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"wishlist",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("wishlisted_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("book_id", sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["book_id"],
|
||||||
|
["book.id"],
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["user_id"],
|
||||||
|
["user.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table("wishlist")
|
||||||
|
op.drop_table("reading")
|
||||||
|
op.drop_table("book_genre")
|
||||||
|
op.drop_table("book_author")
|
||||||
|
op.drop_table("book")
|
||||||
|
op.drop_table("user")
|
||||||
|
op.drop_table("genre")
|
||||||
|
op.drop_table("author")
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -1,19 +1,65 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["setuptools>=61"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "hxbooks"
|
name = "hxbooks"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
requires-python = ">=3.11"
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flask",
|
"alembic>=1.13.0",
|
||||||
"flask_sqlalchemy",
|
"click>=8.3.1",
|
||||||
"flask-htmx",
|
"flask>=3.1.3",
|
||||||
"jinja2_fragments",
|
"flask-htmx>=0.4.0",
|
||||||
"sqlalchemy",
|
"flask-migrate>=4.0.0",
|
||||||
"pydantic",
|
"flask-sqlalchemy>=3.1.1",
|
||||||
"requests",
|
"gunicorn>=25.1.0",
|
||||||
"gunicorn",
|
"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",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
hxbooks = "hxbooks.cli:cli"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["uv_build>=0.10.10,<0.11.0"]
|
||||||
|
build-backend = "uv_build"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"livereload>=2.7.1",
|
||||||
|
"pre-commit>=4.5.1",
|
||||||
|
"pytest>=9.0.2",
|
||||||
|
"ruff>=0.15.6",
|
||||||
|
"ty>=0.0.23",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
addopts = ["-v", "--tb=short"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
preview = true
|
||||||
|
exclude = [
|
||||||
|
"migrations/**",
|
||||||
|
"src/hxbooks/book.py",
|
||||||
|
"src/hxbooks/util.py",
|
||||||
|
"src/hxbooks/auth.py",
|
||||||
|
"src/hxbooks/gbooks.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "B", "C90", "UP", "RUF", "FURB", "PL", "ANN"]
|
||||||
|
ignore = ["PLR09", "PLR2004", "E501", "C901", "PLC1901"]
|
||||||
|
per-file-ignores = { "tests/**.py" = ["PLR6301"] }
|
||||||
|
|
||||||
|
[tool.ty.src]
|
||||||
|
exclude = [
|
||||||
|
"migrations/**",
|
||||||
|
"src/hxbooks/book.py",
|
||||||
|
"src/hxbooks/util.py",
|
||||||
|
"src/hxbooks/auth.py",
|
||||||
|
"src/hxbooks/gbooks.py",
|
||||||
|
"src/hxbooks/htmx.py",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
#
|
|
||||||
# This file is autogenerated by pip-compile with Python 3.11
|
|
||||||
# by the following command:
|
|
||||||
#
|
|
||||||
# pip-compile
|
|
||||||
#
|
|
||||||
annotated-types==0.6.0
|
|
||||||
# via pydantic
|
|
||||||
blinker==1.7.0
|
|
||||||
# via flask
|
|
||||||
certifi==2024.2.2
|
|
||||||
# via requests
|
|
||||||
charset-normalizer==3.3.2
|
|
||||||
# via requests
|
|
||||||
click==8.1.7
|
|
||||||
# via flask
|
|
||||||
flask==3.0.3
|
|
||||||
# via
|
|
||||||
# flask-htmx
|
|
||||||
# flask-sqlalchemy
|
|
||||||
# hxbooks (pyproject.toml)
|
|
||||||
flask-htmx==0.3.2
|
|
||||||
# via hxbooks (pyproject.toml)
|
|
||||||
flask-sqlalchemy==3.1.1
|
|
||||||
# via hxbooks (pyproject.toml)
|
|
||||||
greenlet==3.0.3
|
|
||||||
# via sqlalchemy
|
|
||||||
gunicorn==22.0.0
|
|
||||||
# via hxbooks (pyproject.toml)
|
|
||||||
idna==3.7
|
|
||||||
# via requests
|
|
||||||
itsdangerous==2.2.0
|
|
||||||
# via flask
|
|
||||||
jinja2==3.1.3
|
|
||||||
# via
|
|
||||||
# flask
|
|
||||||
# jinja2-fragments
|
|
||||||
jinja2-fragments==1.3.0
|
|
||||||
# via hxbooks (pyproject.toml)
|
|
||||||
markupsafe==2.1.5
|
|
||||||
# via
|
|
||||||
# jinja2
|
|
||||||
# werkzeug
|
|
||||||
packaging==24.0
|
|
||||||
# via gunicorn
|
|
||||||
pydantic==2.7.1
|
|
||||||
# via hxbooks (pyproject.toml)
|
|
||||||
pydantic-core==2.18.2
|
|
||||||
# via pydantic
|
|
||||||
requests==2.31.0
|
|
||||||
# via hxbooks (pyproject.toml)
|
|
||||||
sqlalchemy==2.0.29
|
|
||||||
# via
|
|
||||||
# flask-sqlalchemy
|
|
||||||
# hxbooks (pyproject.toml)
|
|
||||||
typing-extensions==4.11.0
|
|
||||||
# via
|
|
||||||
# pydantic
|
|
||||||
# pydantic-core
|
|
||||||
# sqlalchemy
|
|
||||||
urllib3==2.2.1
|
|
||||||
# via requests
|
|
||||||
werkzeug==3.0.2
|
|
||||||
# via flask
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import os
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from flask import Flask
|
|
||||||
|
|
||||||
from . import auth, book, db
|
|
||||||
from .htmx import htmx
|
|
||||||
|
|
||||||
|
|
||||||
def create_app(test_config: Optional[dict] = None) -> Flask:
|
|
||||||
app = Flask(__name__, instance_relative_config=True)
|
|
||||||
app.config.from_mapping(
|
|
||||||
SECRET_KEY="dev",
|
|
||||||
SQLALCHEMY_DATABASE_URI="sqlite:///hxbooks.sqlite",
|
|
||||||
)
|
|
||||||
|
|
||||||
if test_config is None:
|
|
||||||
# load the instance config, if it exists, when not testing
|
|
||||||
app.config.from_pyfile("config.py", silent=True)
|
|
||||||
else:
|
|
||||||
# load the test config if passed in
|
|
||||||
app.config.from_mapping(test_config)
|
|
||||||
|
|
||||||
# ensure the instance folder exists
|
|
||||||
try:
|
|
||||||
os.makedirs(app.instance_path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
db.init_app(app)
|
|
||||||
htmx.init_app(app)
|
|
||||||
|
|
||||||
app.register_blueprint(auth.bp)
|
|
||||||
app.register_blueprint(book.bp)
|
|
||||||
|
|
||||||
app.add_url_rule("/", endpoint="books.books")
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app = create_app()
|
|
||||||
app.run()
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import livereload # type: ignore
|
|
||||||
|
|
||||||
from hxbooks import create_app
|
|
||||||
|
|
||||||
app = create_app()
|
|
||||||
app.debug = True
|
|
||||||
# app.run()
|
|
||||||
server = livereload.Server(app.wsgi_app)
|
|
||||||
server.watch("hxbooks/templates/**")
|
|
||||||
server.serve(port=5000, host="0.0.0.0")
|
|
||||||
48
src/hxbooks/app.py
Normal file
48
src/hxbooks/app.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
|
||||||
|
from . import auth, db
|
||||||
|
from .htmx import htmx
|
||||||
|
from .main import bp as main_bp
|
||||||
|
|
||||||
|
# Get the project root (parent of src/)
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(test_config: dict | None = None) -> Flask:
|
||||||
|
# Set instance folder to project root/instance
|
||||||
|
app = Flask(__name__, instance_path=str(PROJECT_ROOT / "instance"))
|
||||||
|
|
||||||
|
app.config.from_mapping(
|
||||||
|
SECRET_KEY="dev",
|
||||||
|
# Put database in project root
|
||||||
|
SQLALCHEMY_DATABASE_URI=f"sqlite:///{PROJECT_ROOT / 'hxbooks.sqlite'}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if test_config is None:
|
||||||
|
# load the instance config, if it exists, when not testing
|
||||||
|
app.config.from_pyfile("config.py", silent=True)
|
||||||
|
else:
|
||||||
|
# load the test config if passed in
|
||||||
|
app.config.from_mapping(test_config)
|
||||||
|
|
||||||
|
# ensure the instance folder exists
|
||||||
|
try:
|
||||||
|
os.makedirs(app.instance_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
db.init_app(app)
|
||||||
|
htmx.init_app(app)
|
||||||
|
|
||||||
|
# Initialize migrations
|
||||||
|
Migrate(app, db.db)
|
||||||
|
|
||||||
|
# Register blueprints
|
||||||
|
app.register_blueprint(auth.bp)
|
||||||
|
app.register_blueprint(main_bp)
|
||||||
|
|
||||||
|
return app
|
||||||
@@ -64,19 +64,19 @@ ResultColumn = Literal[
|
|||||||
|
|
||||||
class SearchRequestSchema(BaseModel, extra="forbid"):
|
class SearchRequestSchema(BaseModel, extra="forbid"):
|
||||||
q: str = ""
|
q: str = ""
|
||||||
wishlisted: Optional[bool] = None
|
wishlisted: bool | None = None
|
||||||
read: Optional[bool] = None
|
read: bool | None = None
|
||||||
reading: Optional[bool] = None
|
reading: bool | None = None
|
||||||
dropped: Optional[bool] = None
|
dropped: bool | None = None
|
||||||
bought_start: Optional[date] = None
|
bought_start: date | None = None
|
||||||
bought_end: Optional[date] = None
|
bought_end: date | None = None
|
||||||
started_reading_start: Optional[date] = None
|
started_reading_start: date | None = None
|
||||||
started_reading_end: Optional[date] = None
|
started_reading_end: date | None = None
|
||||||
finished_reading_start: Optional[date] = None
|
finished_reading_start: date | None = None
|
||||||
finished_reading_end: Optional[date] = None
|
finished_reading_end: date | None = None
|
||||||
sort_by: ResultColumn = "title"
|
sort_by: ResultColumn = "title"
|
||||||
sort_order: Literal["asc", "desc"] = "asc"
|
sort_order: Literal["asc", "desc"] = "asc"
|
||||||
saved_search: Optional[str] = None
|
saved_search: str | None = None
|
||||||
|
|
||||||
@field_validator(
|
@field_validator(
|
||||||
"wishlisted",
|
"wishlisted",
|
||||||
@@ -104,13 +104,13 @@ class BookResultSchema(BaseModel):
|
|||||||
authors: list[str]
|
authors: list[str]
|
||||||
genres: list[str]
|
genres: list[str]
|
||||||
publisher: str
|
publisher: str
|
||||||
first_published: Optional[int]
|
first_published: int | None
|
||||||
edition: str
|
edition: str
|
||||||
added: datetime
|
added: datetime
|
||||||
description: str
|
description: str
|
||||||
notes: str
|
notes: str
|
||||||
isbn: str
|
isbn: str
|
||||||
owner: Optional[str]
|
owner: str | None
|
||||||
bought: date
|
bought: date
|
||||||
location: str
|
location: str
|
||||||
loaned_to: str
|
loaned_to: str
|
||||||
@@ -119,8 +119,8 @@ class BookResultSchema(BaseModel):
|
|||||||
read: bool
|
read: bool
|
||||||
reading: bool
|
reading: bool
|
||||||
dropped: bool
|
dropped: bool
|
||||||
started_reading: Optional[date]
|
started_reading: date | None
|
||||||
finished_reading: Optional[date]
|
finished_reading: date | None
|
||||||
|
|
||||||
|
|
||||||
@bp.route("", methods=["GET"])
|
@bp.route("", methods=["GET"])
|
||||||
@@ -377,12 +377,10 @@ def get_default_searches(username: str) -> dict[str, SearchRequestSchema]:
|
|||||||
|
|
||||||
def get_saved_searches(user: User) -> dict[str, SearchRequestSchema]:
|
def get_saved_searches(user: User) -> dict[str, SearchRequestSchema]:
|
||||||
searches = get_default_searches(user.username).copy()
|
searches = get_default_searches(user.username).copy()
|
||||||
searches.update(
|
searches.update({
|
||||||
{
|
|
||||||
name: SearchRequestSchema.model_validate(value)
|
name: SearchRequestSchema.model_validate(value)
|
||||||
for name, value in user.saved_searches.items()
|
for name, value in user.saved_searches.items()
|
||||||
}
|
})
|
||||||
)
|
|
||||||
for name, search in searches.items():
|
for name, search in searches.items():
|
||||||
search.saved_search = name
|
search.saved_search = name
|
||||||
return searches
|
return searches
|
||||||
@@ -390,14 +388,14 @@ def get_saved_searches(user: User) -> dict[str, SearchRequestSchema]:
|
|||||||
|
|
||||||
class BookRequestSchema(BaseModel):
|
class BookRequestSchema(BaseModel):
|
||||||
title: str = Field(min_length=1)
|
title: str = Field(min_length=1)
|
||||||
first_published: Optional[int] = None
|
first_published: int | None = None
|
||||||
edition: str = ""
|
edition: str = ""
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
isbn: str = ""
|
isbn: str = ""
|
||||||
authors: list[str] = []
|
authors: list[str] = []
|
||||||
genres: list[str] = []
|
genres: list[str] = []
|
||||||
publisher: str = ""
|
publisher: str = ""
|
||||||
owner_id: Optional[int] = None
|
owner_id: int | None = None
|
||||||
bought: date = Field(default_factory=datetime.today)
|
bought: date = Field(default_factory=datetime.today)
|
||||||
location: str = "billy salon"
|
location: str = "billy salon"
|
||||||
loaned_to: str = ""
|
loaned_to: str = ""
|
||||||
@@ -456,7 +454,8 @@ def book(id: int) -> str | Response:
|
|||||||
"users": db.session.execute(select(User)).scalars().all(),
|
"users": db.session.execute(select(User)).scalars().all(),
|
||||||
"genres": get_distinct_json_list_values(Book.genres),
|
"genres": get_distinct_json_list_values(Book.genres),
|
||||||
"authors": get_distinct_json_list_values(Book.authors),
|
"authors": get_distinct_json_list_values(Book.authors),
|
||||||
"locations": db.session.execute(select(Book.location).distinct())
|
"locations": db.session
|
||||||
|
.execute(select(Book.location).distinct())
|
||||||
.scalars()
|
.scalars()
|
||||||
.all(),
|
.all(),
|
||||||
"wished_by": [wishlist.user.username for wishlist in book.wished_by],
|
"wished_by": [wishlist.user.username for wishlist in book.wished_by],
|
||||||
@@ -481,10 +480,10 @@ def readings_new(id: int) -> str:
|
|||||||
|
|
||||||
class ReadingRequestSchema(BaseModel):
|
class ReadingRequestSchema(BaseModel):
|
||||||
start_date: date = Field(default_factory=datetime.today)
|
start_date: date = Field(default_factory=datetime.today)
|
||||||
end_date: Optional[date] = None
|
end_date: date | None = None
|
||||||
finished: bool = False
|
finished: bool = False
|
||||||
dropped: bool = False
|
dropped: bool = False
|
||||||
rating: Optional[int] = None
|
rating: int | None = None
|
||||||
comments: str = ""
|
comments: str = ""
|
||||||
user_id: int
|
user_id: int
|
||||||
book_id: int
|
book_id: int
|
||||||
|
|||||||
732
src/hxbooks/cli.py
Normal file
732
src/hxbooks/cli.py
Normal file
@@ -0,0 +1,732 @@
|
|||||||
|
"""
|
||||||
|
HXBooks CLI - Command line interface for library management.
|
||||||
|
|
||||||
|
Provides commands for book management, reading tracking, and search functionality
|
||||||
|
while keeping business logic separate from web interface concerns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import click
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from . import library
|
||||||
|
from .app import create_app
|
||||||
|
from .db import db
|
||||||
|
from .models import Author, Book, Genre, Reading, User, Wishlist
|
||||||
|
|
||||||
|
|
||||||
|
def get_app() -> Flask:
|
||||||
|
"""Create and configure Flask app for CLI operations."""
|
||||||
|
return create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_user_exists(app: Flask, username: str) -> int:
|
||||||
|
"""Ensure a user exists and return their ID."""
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
user = db.session.execute(
|
||||||
|
db.select(User).filter_by(username=username)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
user = User(username=username)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
click.echo(f"Created user: {username}")
|
||||||
|
|
||||||
|
return user.id
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
@click.version_option()
|
||||||
|
def cli() -> None:
|
||||||
|
"""HXBooks - Personal library management system."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def book() -> None:
|
||||||
|
"""Book management commands."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def reading() -> None:
|
||||||
|
"""Reading tracking commands."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group()
|
||||||
|
def wishlist() -> None:
|
||||||
|
"""Wishlist management commands."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group("db")
|
||||||
|
def db_group() -> None:
|
||||||
|
"""Database management commands."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Book commands
|
||||||
|
@book.command("add")
|
||||||
|
@click.argument("title")
|
||||||
|
@click.option("--owner", help="Username of book owner")
|
||||||
|
@click.option("--authors", help="Comma-separated list of authors")
|
||||||
|
@click.option("--genres", help="Comma-separated list of genres")
|
||||||
|
@click.option("--isbn", help="ISBN number")
|
||||||
|
@click.option("--publisher", help="Publisher name")
|
||||||
|
@click.option("--edition", help="Edition information")
|
||||||
|
@click.option("--place", help="Location place (e.g., 'home', 'office')")
|
||||||
|
@click.option("--bookshelf", help="Bookshelf name")
|
||||||
|
@click.option("--shelf", type=int, help="Shelf number")
|
||||||
|
@click.option("--description", help="Book description")
|
||||||
|
@click.option("--notes", help="Personal notes")
|
||||||
|
def add_book(
|
||||||
|
title: str,
|
||||||
|
owner: str,
|
||||||
|
authors: str | None = None,
|
||||||
|
genres: str | None = None,
|
||||||
|
isbn: str | None = None,
|
||||||
|
publisher: str | None = None,
|
||||||
|
edition: str | None = None,
|
||||||
|
place: str | None = None,
|
||||||
|
bookshelf: str | None = None,
|
||||||
|
shelf: int | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Add a new book to the library."""
|
||||||
|
app = get_app()
|
||||||
|
if owner:
|
||||||
|
user_id = ensure_user_exists(app, owner)
|
||||||
|
else:
|
||||||
|
user_id = None
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
book = library.create_book(
|
||||||
|
title=title,
|
||||||
|
owner_id=user_id,
|
||||||
|
authors=authors.split(",") if authors else None,
|
||||||
|
genres=genres.split(",") if genres else None,
|
||||||
|
isbn=isbn,
|
||||||
|
publisher=publisher,
|
||||||
|
edition=edition,
|
||||||
|
location_place=place,
|
||||||
|
location_bookshelf=bookshelf,
|
||||||
|
location_shelf=shelf,
|
||||||
|
description=description,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
click.echo(f"Added book: {book.title} (ID: {book.id})")
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Error adding book: {e}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@book.command("get")
|
||||||
|
@click.argument("book_id", type=int)
|
||||||
|
def get_book(book_id: int) -> None:
|
||||||
|
"""Get detailed information about a book by ID."""
|
||||||
|
app = get_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
book = library.get_book(book_id=book_id)
|
||||||
|
if book is None:
|
||||||
|
click.echo(f"Book with ID {book_id} not found.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
book_info = {
|
||||||
|
"id": book.id,
|
||||||
|
"title": book.title,
|
||||||
|
"authors": [author.name for author in book.authors],
|
||||||
|
"genres": [genre.name for genre in book.genres],
|
||||||
|
"owner": book.owner.username if book.owner else None,
|
||||||
|
"isbn": book.isbn,
|
||||||
|
"publisher": book.publisher,
|
||||||
|
"edition": book.edition,
|
||||||
|
"description": book.description,
|
||||||
|
"notes": book.notes,
|
||||||
|
"location": {
|
||||||
|
"place": book.location_place,
|
||||||
|
"bookshelf": book.location_bookshelf,
|
||||||
|
"shelf": book.location_shelf,
|
||||||
|
},
|
||||||
|
"loaned_to": book.loaned_to,
|
||||||
|
"loaned_date": book.loaned_date.isoformat()
|
||||||
|
if book.loaned_date
|
||||||
|
else None,
|
||||||
|
"added_date": book.added_date.isoformat(),
|
||||||
|
"bought_date": book.bought_date.isoformat()
|
||||||
|
if book.bought_date
|
||||||
|
else None,
|
||||||
|
"readings": [
|
||||||
|
{
|
||||||
|
"user": reading.user.username if reading.user else None,
|
||||||
|
"start_date": reading.start_date.isoformat(),
|
||||||
|
"end_date": reading.end_date.isoformat()
|
||||||
|
if reading.end_date
|
||||||
|
else None,
|
||||||
|
"dropped": reading.dropped,
|
||||||
|
"rating": reading.rating,
|
||||||
|
"comments": reading.comments,
|
||||||
|
}
|
||||||
|
for reading in book.readings
|
||||||
|
],
|
||||||
|
"wished_by": [
|
||||||
|
{
|
||||||
|
"user": item.user.username if item.user else None,
|
||||||
|
"wishlisted_date": item.wishlisted_date.isoformat(),
|
||||||
|
}
|
||||||
|
for item in book.wished_by
|
||||||
|
],
|
||||||
|
}
|
||||||
|
click.echo(json.dumps(book_info, indent=2))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Error getting book: {e}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@book.command("delete")
|
||||||
|
@click.argument("book_id", type=int)
|
||||||
|
def delete_book(book_id: int) -> None:
|
||||||
|
"""Delete a book from the library."""
|
||||||
|
app = get_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
if library.delete_book(book_id=book_id):
|
||||||
|
click.echo(f"Deleted book with ID {book_id}")
|
||||||
|
else:
|
||||||
|
click.echo(f"Book with ID {book_id} not found.")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Error deleting book: {e}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@book.command("list")
|
||||||
|
@click.option("--owner", help="Filter by owner username")
|
||||||
|
@click.option("--place", help="Filter by location place")
|
||||||
|
@click.option("--bookshelf", help="Filter by bookshelf")
|
||||||
|
@click.option("--shelf", type=int, help="Filter by shelf number")
|
||||||
|
@click.option(
|
||||||
|
"--format",
|
||||||
|
"output_format",
|
||||||
|
type=click.Choice(["table", "json"]),
|
||||||
|
default="table",
|
||||||
|
help="Output format",
|
||||||
|
)
|
||||||
|
@click.option("--limit", type=int, default=50, help="Maximum number of books to show")
|
||||||
|
def list_books(
|
||||||
|
owner: str | None = None,
|
||||||
|
place: str | None = None,
|
||||||
|
bookshelf: str | None = None,
|
||||||
|
shelf: int | None = None,
|
||||||
|
output_format: str = "table",
|
||||||
|
limit: int = 50,
|
||||||
|
) -> None:
|
||||||
|
"""List books in the library."""
|
||||||
|
app = get_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
books = library.search_books(
|
||||||
|
owner_username=owner,
|
||||||
|
location_place=place,
|
||||||
|
location_bookshelf=bookshelf,
|
||||||
|
location_shelf=shelf,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
if output_format == "json":
|
||||||
|
book_data = []
|
||||||
|
for book in books:
|
||||||
|
book_data.append({
|
||||||
|
"id": book.id,
|
||||||
|
"title": book.title,
|
||||||
|
"authors": [a.name for a in book.authors],
|
||||||
|
"genres": [g.name for g in book.genres],
|
||||||
|
"owner": book.owner.username if book.owner else None,
|
||||||
|
"location": f"{book.location_place}/{book.location_bookshelf}/{book.location_shelf}",
|
||||||
|
"isbn": book.isbn,
|
||||||
|
})
|
||||||
|
click.echo(json.dumps(book_data, indent=2))
|
||||||
|
else:
|
||||||
|
# Table format
|
||||||
|
if not books:
|
||||||
|
click.echo("No books found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
click.echo(f"{'ID':<4} {'Title':<30} {'Authors':<25} {'Owner':<12}")
|
||||||
|
click.echo("-" * 75)
|
||||||
|
|
||||||
|
for book in books:
|
||||||
|
authors_str = ", ".join(a.name for a in book.authors)[:22]
|
||||||
|
if len(authors_str) == 22:
|
||||||
|
authors_str += "..."
|
||||||
|
owner_str = book.owner.username if book.owner else ""
|
||||||
|
click.echo(
|
||||||
|
f"{book.id:<4} {book.title[:27]:<30} {authors_str:<25} {owner_str:<12}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Error listing books: {e}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@book.command("search")
|
||||||
|
@click.argument("query")
|
||||||
|
@click.option(
|
||||||
|
"--username", help="Username to apply user-specific filters (e.g., for ratings)"
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--format",
|
||||||
|
"output_format",
|
||||||
|
type=click.Choice(["table", "json"]),
|
||||||
|
default="table",
|
||||||
|
help="Output format",
|
||||||
|
)
|
||||||
|
@click.option("--limit", type=int, default=20, help="Maximum number of results")
|
||||||
|
def search_books(
|
||||||
|
query: str,
|
||||||
|
username: str | None = None,
|
||||||
|
output_format: str = "table",
|
||||||
|
limit: int = 20,
|
||||||
|
) -> None:
|
||||||
|
"""Search books using query language (e.g., 'genre:thriller read>=2025-01-01')."""
|
||||||
|
app = get_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
books = library.search_books_advanced(
|
||||||
|
query_string=query, limit=limit, username=username
|
||||||
|
)
|
||||||
|
if output_format == "json":
|
||||||
|
results = []
|
||||||
|
for book in books:
|
||||||
|
results.append({
|
||||||
|
"id": book.id,
|
||||||
|
"title": book.title,
|
||||||
|
"authors": [author.name for author in book.authors],
|
||||||
|
"genres": [genre.name for genre in book.genres],
|
||||||
|
"owner": book.owner.username if book.owner else None,
|
||||||
|
"isbn": book.isbn,
|
||||||
|
"publisher": book.publisher,
|
||||||
|
"description": book.description,
|
||||||
|
"location": {
|
||||||
|
"place": book.location_place,
|
||||||
|
"bookshelf": book.location_bookshelf,
|
||||||
|
"shelf": book.location_shelf,
|
||||||
|
},
|
||||||
|
"loaned_to": book.loaned_to,
|
||||||
|
"loaned_date": book.loaned_date.isoformat()
|
||||||
|
if book.loaned_date
|
||||||
|
else None,
|
||||||
|
"added_date": book.added_date.isoformat(),
|
||||||
|
"bought_date": book.bought_date.isoformat()
|
||||||
|
if book.bought_date
|
||||||
|
else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
click.echo(json.dumps(results, indent=2))
|
||||||
|
else:
|
||||||
|
# Table format
|
||||||
|
if not books:
|
||||||
|
click.echo("No books found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
click.echo(f"{'ID':<4} {'Title':<35} {'Authors':<30}")
|
||||||
|
click.echo("-" * 72)
|
||||||
|
|
||||||
|
for book in books:
|
||||||
|
authors_str = ", ".join(a.name 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}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Error searching books: {e}", err=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@book.command("import")
|
||||||
|
@click.argument("isbn")
|
||||||
|
@click.option("--owner", help="Username of book owner")
|
||||||
|
@click.option("--place", help="Location place")
|
||||||
|
@click.option("--bookshelf", help="Bookshelf name")
|
||||||
|
@click.option("--shelf", type=int, help="Shelf number")
|
||||||
|
def import_book(
|
||||||
|
isbn: str,
|
||||||
|
owner: str | None = None,
|
||||||
|
place: str | None = None,
|
||||||
|
bookshelf: str | None = None,
|
||||||
|
shelf: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Import book data from ISBN using Google Books API."""
|
||||||
|
app = get_app()
|
||||||
|
if owner:
|
||||||
|
user_id = ensure_user_exists(app, owner)
|
||||||
|
else:
|
||||||
|
user_id = None
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
book = library.import_book_from_isbn(
|
||||||
|
isbn=isbn,
|
||||||
|
owner_id=user_id,
|
||||||
|
location_place=place,
|
||||||
|
location_bookshelf=bookshelf,
|
||||||
|
location_shelf=shelf,
|
||||||
|
)
|
||||||
|
click.echo(
|
||||||
|
f"Imported book: {book.title} by {', '.join(a.name for a in book.authors)} (ID: {book.id})"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Error importing book: {e}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# Reading commands
|
||||||
|
@reading.command("start")
|
||||||
|
@click.argument("book_id", type=int)
|
||||||
|
@click.option("--owner", required=True, help="Username of reader")
|
||||||
|
def start_reading(book_id: int, owner: str) -> None:
|
||||||
|
"""Start a new reading session for a book."""
|
||||||
|
app = get_app()
|
||||||
|
user_id = ensure_user_exists(app, owner)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
reading_session = library.start_reading(book_id=book_id, user_id=user_id)
|
||||||
|
click.echo(
|
||||||
|
f"Started reading session {reading_session.id} for book {book_id}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Error starting reading: {e}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@reading.command("finish")
|
||||||
|
@click.argument("reading_id", type=int)
|
||||||
|
@click.option("--rating", type=click.IntRange(1, 5), help="Rating from 1-5")
|
||||||
|
@click.option("--comments", help="Reading comments")
|
||||||
|
def finish_reading(
|
||||||
|
reading_id: int, rating: int | None = None, comments: str | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Finish a reading session."""
|
||||||
|
app = get_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
reading_session = library.finish_reading(
|
||||||
|
reading_id=reading_id,
|
||||||
|
rating=rating,
|
||||||
|
comments=comments,
|
||||||
|
)
|
||||||
|
book_title = reading_session.book.title
|
||||||
|
click.echo(f"Finished reading: {book_title}")
|
||||||
|
if rating:
|
||||||
|
click.echo(f"Rating: {rating}/5")
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Error finishing reading: {e}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@reading.command("drop")
|
||||||
|
@click.argument("reading_id", type=int)
|
||||||
|
@click.option("--comments", help="Comments about why dropped")
|
||||||
|
def drop_reading(reading_id: int, comments: str | None = None) -> None:
|
||||||
|
"""Mark a reading session as dropped."""
|
||||||
|
app = get_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
reading_session = library.drop_reading(
|
||||||
|
reading_id=reading_id, comments=comments
|
||||||
|
)
|
||||||
|
book_title = reading_session.book.title
|
||||||
|
click.echo(f"Dropped reading: {book_title}")
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Error dropping reading: {e}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@reading.command("list")
|
||||||
|
@click.option("--owner", required=True, help="Username to show readings for")
|
||||||
|
@click.option("--current", is_flag=True, help="Show only current (unfinished) readings")
|
||||||
|
@click.option(
|
||||||
|
"--format",
|
||||||
|
"output_format",
|
||||||
|
type=click.Choice(["table", "json"]),
|
||||||
|
default="table",
|
||||||
|
help="Output format",
|
||||||
|
)
|
||||||
|
def list_readings(
|
||||||
|
owner: str, current: bool = False, output_format: str = "table"
|
||||||
|
) -> None:
|
||||||
|
"""List reading sessions."""
|
||||||
|
app = get_app()
|
||||||
|
user_id = ensure_user_exists(app, owner)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
if current:
|
||||||
|
readings = library.get_current_readings(user_id=user_id)
|
||||||
|
else:
|
||||||
|
readings = library.get_reading_history(user_id=user_id)
|
||||||
|
|
||||||
|
if output_format == "json":
|
||||||
|
reading_data = []
|
||||||
|
for reading in readings:
|
||||||
|
reading_data.append({
|
||||||
|
"id": reading.id,
|
||||||
|
"book_id": reading.book_id,
|
||||||
|
"book_title": reading.book.title,
|
||||||
|
"start_date": reading.start_date.isoformat(),
|
||||||
|
"end_date": reading.end_date.isoformat()
|
||||||
|
if reading.end_date
|
||||||
|
else None,
|
||||||
|
"finished": reading.finished,
|
||||||
|
"dropped": reading.dropped,
|
||||||
|
"rating": reading.rating,
|
||||||
|
"comments": reading.comments,
|
||||||
|
})
|
||||||
|
click.echo(json.dumps(reading_data, indent=2))
|
||||||
|
else:
|
||||||
|
# Table format
|
||||||
|
if not readings:
|
||||||
|
msg = "No current readings." if current else "No reading history."
|
||||||
|
click.echo(msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
click.echo(
|
||||||
|
f"{'ID':<4} {'Book':<30} {'Started':<12} {'Status':<10} {'Rating':<6}"
|
||||||
|
)
|
||||||
|
click.echo("-" * 65)
|
||||||
|
|
||||||
|
for reading in readings:
|
||||||
|
status = (
|
||||||
|
"Reading"
|
||||||
|
if not reading.end_date
|
||||||
|
else ("Finished" if reading.finished else "Dropped")
|
||||||
|
)
|
||||||
|
rating = f"{reading.rating}/5" if reading.rating else ""
|
||||||
|
|
||||||
|
click.echo(
|
||||||
|
f"{reading.id:<4} {reading.book.title[:27]:<30} {reading.start_date.strftime('%Y-%m-%d'):<12} {status:<10} {rating:<6}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Error listing readings: {e}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# Wishlist commands
|
||||||
|
@wishlist.command("add")
|
||||||
|
@click.argument("book_id", type=int)
|
||||||
|
@click.option("--owner", required=True, help="Username")
|
||||||
|
def add_to_wishlist(book_id: int, owner: str) -> None:
|
||||||
|
"""Add a book to wishlist."""
|
||||||
|
app = get_app()
|
||||||
|
user_id = ensure_user_exists(app, owner)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
wishlist_item = library.add_to_wishlist(book_id=book_id, user_id=user_id)
|
||||||
|
book_title = wishlist_item.book.title
|
||||||
|
click.echo(f"Added '{book_title}' to wishlist")
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Error adding to wishlist: {e}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@wishlist.command("remove")
|
||||||
|
@click.argument("book_id", type=int)
|
||||||
|
@click.option("--owner", required=True, help="Username")
|
||||||
|
def remove_from_wishlist(book_id: int, owner: str) -> None:
|
||||||
|
"""Remove a book from wishlist."""
|
||||||
|
app = get_app()
|
||||||
|
user_id = ensure_user_exists(app, owner)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
if library.remove_from_wishlist(book_id=book_id, user_id=user_id):
|
||||||
|
click.echo(f"Removed book {book_id} from wishlist")
|
||||||
|
else:
|
||||||
|
click.echo(f"Book {book_id} was not in wishlist")
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Error removing from wishlist: {e}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@wishlist.command("list")
|
||||||
|
@click.option("--owner", required=True, help="Username")
|
||||||
|
@click.option(
|
||||||
|
"--format",
|
||||||
|
"output_format",
|
||||||
|
type=click.Choice(["table", "json"]),
|
||||||
|
default="table",
|
||||||
|
help="Output format",
|
||||||
|
)
|
||||||
|
def list_wishlist(owner: str, output_format: str = "table") -> None:
|
||||||
|
"""Show user's wishlist."""
|
||||||
|
app = get_app()
|
||||||
|
user_id = ensure_user_exists(app, owner)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
wishlist_items = library.get_wishlist(user_id=user_id)
|
||||||
|
|
||||||
|
if output_format == "json":
|
||||||
|
wishlist_data = []
|
||||||
|
for item in wishlist_items:
|
||||||
|
wishlist_data.append({
|
||||||
|
"book_id": item.book_id,
|
||||||
|
"title": item.book.title,
|
||||||
|
"authors": [author.name for author in item.book.authors],
|
||||||
|
"wishlisted_date": item.wishlisted_date.isoformat(),
|
||||||
|
})
|
||||||
|
click.echo(json.dumps(wishlist_data, indent=2))
|
||||||
|
else:
|
||||||
|
# Table format
|
||||||
|
if not wishlist_items:
|
||||||
|
click.echo("Wishlist is empty.")
|
||||||
|
return
|
||||||
|
|
||||||
|
click.echo(f"{'ID':<4} {'Title':<35} {'Authors':<25} {'Added':<12}")
|
||||||
|
click.echo("-" * 78)
|
||||||
|
|
||||||
|
for item in wishlist_items:
|
||||||
|
authors_str = ", ".join(a.name for a in item.book.authors)[:22]
|
||||||
|
if len(authors_str) == 22:
|
||||||
|
authors_str += "..."
|
||||||
|
|
||||||
|
click.echo(
|
||||||
|
f"{item.book_id:<4} {item.book.title[:32]:<35} {authors_str:<25} {item.wishlisted_date.strftime('%Y-%m-%d'):<12}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Error listing wishlist: {e}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# Database commands
|
||||||
|
@db_group.command("init")
|
||||||
|
def init_db() -> None:
|
||||||
|
"""Initialize the database."""
|
||||||
|
app = get_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
click.echo("Database initialized.")
|
||||||
|
|
||||||
|
|
||||||
|
@db_group.command("seed")
|
||||||
|
@click.option("--owner", default="test_user", help="Default owner for seed data")
|
||||||
|
def seed_db(owner: str) -> None:
|
||||||
|
"""Create some sample data for testing."""
|
||||||
|
app = get_app()
|
||||||
|
user_id = ensure_user_exists(app, owner)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
sample_books = [
|
||||||
|
{
|
||||||
|
"title": "The Hobbit",
|
||||||
|
"authors": ["J.R.R. Tolkien"],
|
||||||
|
"genres": ["Fantasy", "Adventure"],
|
||||||
|
"publisher": "Allen & Unwin",
|
||||||
|
"description": "A hobbit's unexpected journey to help a group of dwarves reclaim their homeland.",
|
||||||
|
"location_place": "home",
|
||||||
|
"location_bookshelf": "fantasy",
|
||||||
|
"location_shelf": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Dune",
|
||||||
|
"authors": ["Frank Herbert"],
|
||||||
|
"genres": ["Science Fiction"],
|
||||||
|
"publisher": "Chilton Books",
|
||||||
|
"description": "A science fiction epic set in the distant future on the desert planet Arrakis.",
|
||||||
|
"location_place": "home",
|
||||||
|
"location_bookshelf": "sci-fi",
|
||||||
|
"location_shelf": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "The Pragmatic Programmer",
|
||||||
|
"authors": ["David Thomas", "Andrew Hunt"],
|
||||||
|
"genres": ["Technology", "Programming"],
|
||||||
|
"publisher": "Addison-Wesley",
|
||||||
|
"description": "From journeyman to master - essential programming techniques.",
|
||||||
|
"location_place": "office",
|
||||||
|
"location_bookshelf": "tech",
|
||||||
|
"location_shelf": 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
created_books = []
|
||||||
|
for book_data in sample_books:
|
||||||
|
try:
|
||||||
|
book = library.create_book(owner_id=user_id, **book_data) # ty:ignore[invalid-argument-type]
|
||||||
|
created_books.append(book)
|
||||||
|
click.echo(f"Created: {book.title}")
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Error creating book '{book_data['title']}': {e}")
|
||||||
|
|
||||||
|
click.echo(f"Created {len(created_books)} sample books for user '{owner}'")
|
||||||
|
|
||||||
|
|
||||||
|
@db_group.command("status")
|
||||||
|
def db_status() -> None:
|
||||||
|
"""Show database status and statistics."""
|
||||||
|
app = get_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
book_count = db.session.execute(db.select(db.func.count(Book.id))).scalar()
|
||||||
|
author_count = db.session.execute(
|
||||||
|
db.select(db.func.count(Author.id))
|
||||||
|
).scalar()
|
||||||
|
genre_count = db.session.execute(
|
||||||
|
db.select(db.func.count(Genre.id))
|
||||||
|
).scalar()
|
||||||
|
user_count = db.session.execute(db.select(db.func.count(User.id))).scalar()
|
||||||
|
reading_count = db.session.execute(
|
||||||
|
db.select(db.func.count(Reading.id))
|
||||||
|
).scalar()
|
||||||
|
wishlist_count = db.session.execute(
|
||||||
|
db.select(db.func.count(Wishlist.id))
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
click.echo("Database Statistics:")
|
||||||
|
click.echo(f" Books: {book_count}")
|
||||||
|
click.echo(f" Authors: {author_count}")
|
||||||
|
click.echo(f" Genres: {genre_count}")
|
||||||
|
click.echo(f" Users: {user_count}")
|
||||||
|
click.echo(f" Reading sessions: {reading_count}")
|
||||||
|
click.echo(f" Wishlist items: {wishlist_count}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(f"Error getting database status: {e}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def serve() -> None:
|
||||||
|
"""Start the web server."""
|
||||||
|
import livereload # noqa: PLC0415
|
||||||
|
|
||||||
|
app = get_app()
|
||||||
|
app.debug = True
|
||||||
|
# app.run()
|
||||||
|
server = livereload.Server(app.wsgi_app)
|
||||||
|
server.watch("hxbooks/templates/**")
|
||||||
|
server.serve(port=5000, host="0.0.0.0")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
||||||
@@ -11,5 +11,3 @@ db = SQLAlchemy(model_class=Base)
|
|||||||
|
|
||||||
def init_app(app: Flask) -> None:
|
def init_app(app: Flask) -> None:
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
with app.app_context():
|
|
||||||
db.create_all()
|
|
||||||
|
|||||||
@@ -1,59 +1,16 @@
|
|||||||
|
from os import environ
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Any, Optional
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
# {
|
|
||||||
# "title": "Concilio de Sombras (Sombras de Magia 2)",
|
|
||||||
# "authors": [
|
|
||||||
# "Victoria Schwab"
|
|
||||||
# ],
|
|
||||||
# "publisher": "Urano World",
|
|
||||||
# "publishedDate": "2023-11-14",
|
|
||||||
# "description": "Four months have passed since the shadow stone fell into Kell's possession. Four months since his path was crossed with Delilah Bard. Four months since Rhy was wounded and the Dane twins fell, and the stone was cast with Holland's dying body through the rift, and into Black London. In many ways, things have almost returned to normal, though Rhy is more sober, and Kell is now plagued by his guilt. Restless, and having been given up smuggling, Kell is visited by dreams of ominous magical events, waking only to think of Lila, who disappeared from the docks like she always meant to do. As Red London finalizes preparations for the Element Games-an extravagant international competition of magic, meant to entertain and keep the ties between neighboring countries healthy- a certain pirate ship draws closer, carrying old friends back into port. But while Red London is caught up in the pageantry and thrills of the Games, another London is coming back to life, and those who were thought to be forever gone have returned. After all, a shadow that was gone in the night reappears in the morning, and so it seems Black London has risen again-and so to keep magic's balance, another London must fall.",
|
|
||||||
# "industryIdentifiers": [
|
|
||||||
# {
|
|
||||||
# "type": "ISBN_10",
|
|
||||||
# "identifier": "8419030503"
|
|
||||||
# },
|
|
||||||
# {
|
|
||||||
# "type": "ISBN_13",
|
|
||||||
# "identifier": "9788419030504"
|
|
||||||
# }
|
|
||||||
# ],
|
|
||||||
# "readingModes": {
|
|
||||||
# "text": false,
|
|
||||||
# "image": false
|
|
||||||
# },
|
|
||||||
# "pageCount": 0,
|
|
||||||
# "printType": "BOOK",
|
|
||||||
# "categories": [
|
|
||||||
# "Fiction"
|
|
||||||
# ],
|
|
||||||
# "maturityRating": "NOT_MATURE",
|
|
||||||
# "allowAnonLogging": false,
|
|
||||||
# "contentVersion": "preview-1.0.0",
|
|
||||||
# "panelizationSummary": {
|
|
||||||
# "containsEpubBubbles": false,
|
|
||||||
# "containsImageBubbles": false
|
|
||||||
# },
|
|
||||||
# "imageLinks": {
|
|
||||||
# "smallThumbnail": "http://books.google.com/books/content?id=GM4G0AEACAAJ&printsec=frontcover&img=1&zoom=5&source=gbs_api",
|
|
||||||
# "thumbnail": "http://books.google.com/books/content?id=GM4G0AEACAAJ&printsec=frontcover&img=1&zoom=1&source=gbs_api"
|
|
||||||
# },
|
|
||||||
# "language": "es",
|
|
||||||
# "previewLink": "http://books.google.es/books?id=GM4G0AEACAAJ&dq=isbn:9788419030504&hl=&cd=1&source=gbs_api",
|
|
||||||
# "infoLink": "http://books.google.es/books?id=GM4G0AEACAAJ&dq=isbn:9788419030504&hl=&source=gbs_api",
|
|
||||||
# "canonicalVolumeLink": "https://books.google.com/books/about/Concilio_de_Sombras_Sombras_de_Magia_2.html?hl=&id=GM4G0AEACAAJ"
|
|
||||||
# }
|
|
||||||
|
|
||||||
|
|
||||||
class GoogleBook(BaseModel):
|
class GoogleBook(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
authors: list[str] = []
|
authors: list[str] = []
|
||||||
publisher: str = ""
|
publisher: str = ""
|
||||||
publishedDate: Optional[date | int] = None
|
publishedDate: date | int | None = None
|
||||||
description: str = ""
|
description: str = ""
|
||||||
industryIdentifiers: list[dict[str, str]] = []
|
industryIdentifiers: list[dict[str, str]] = []
|
||||||
pageCount: int = 0
|
pageCount: int = 0
|
||||||
@@ -83,8 +40,9 @@ class GoogleBook(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
def fetch_google_book_data(isbn: str) -> GoogleBook:
|
def fetch_google_book_data(isbn: str) -> GoogleBook:
|
||||||
|
api_key = environ.get("GOOGLE_BOOKS_API_KEY")
|
||||||
req = requests.get(
|
req = requests.get(
|
||||||
"https://www.googleapis.com/books/v1/volumes", params={"q": f"isbn:{isbn}"}
|
"https://www.googleapis.com/books/v1/volumes", params={"q": f"isbn:{isbn}", "key": api_key}
|
||||||
)
|
)
|
||||||
req.raise_for_status()
|
req.raise_for_status()
|
||||||
data = req.json()
|
data = req.json()
|
||||||
|
|||||||
874
src/hxbooks/library.py
Normal file
874
src/hxbooks/library.py
Normal file
@@ -0,0 +1,874 @@
|
|||||||
|
"""
|
||||||
|
Business logic services for HXBooks.
|
||||||
|
|
||||||
|
Clean service layer for book management, reading tracking, and wishlist operations.
|
||||||
|
Separated from web interface concerns to enable both CLI and web access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import assert_never
|
||||||
|
|
||||||
|
from sqlalchemy import ColumnElement, Select, and_, func, or_, select
|
||||||
|
from sqlalchemy.orm import InstrumentedAttribute, joinedload
|
||||||
|
|
||||||
|
from hxbooks.search import IsOperatorValue, QueryParser, SortDirection, ValueT
|
||||||
|
|
||||||
|
from .db import db
|
||||||
|
from .gbooks import fetch_google_book_data
|
||||||
|
from .models import Author, Book, Genre, Reading, User, Wishlist
|
||||||
|
from .search import ComparisonOperator, Field, FieldFilter
|
||||||
|
|
||||||
|
|
||||||
|
def create_book(
|
||||||
|
title: str,
|
||||||
|
owner_id: int | None = None,
|
||||||
|
authors: list[str] | None = None,
|
||||||
|
genres: list[str] | None = None,
|
||||||
|
isbn: str | None = None,
|
||||||
|
publisher: str | None = None,
|
||||||
|
edition: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
location_place: str | None = None,
|
||||||
|
location_bookshelf: str | None = None,
|
||||||
|
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(
|
||||||
|
title=title,
|
||||||
|
owner_id=owner_id,
|
||||||
|
isbn=isbn or "",
|
||||||
|
publisher=publisher or "",
|
||||||
|
edition=edition or "",
|
||||||
|
description=description or "",
|
||||||
|
notes=notes or "",
|
||||||
|
location_place=location_place or "",
|
||||||
|
location_bookshelf=location_bookshelf or "",
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Handle authors
|
||||||
|
if authors:
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Handle genres
|
||||||
|
if genres:
|
||||||
|
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)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return book
|
||||||
|
|
||||||
|
|
||||||
|
def get_book(book_id: int) -> Book | None:
|
||||||
|
"""Get a book by ID with all relationships loaded."""
|
||||||
|
return (
|
||||||
|
db.session
|
||||||
|
.execute(
|
||||||
|
select(Book)
|
||||||
|
.options(
|
||||||
|
joinedload(Book.authors),
|
||||||
|
joinedload(Book.genres),
|
||||||
|
joinedload(Book.owner),
|
||||||
|
)
|
||||||
|
.filter(Book.id == book_id)
|
||||||
|
)
|
||||||
|
.unique()
|
||||||
|
.scalar_one_or_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,
|
||||||
|
isbn: str | None = None,
|
||||||
|
publisher: str | None = None,
|
||||||
|
edition: str | None = None,
|
||||||
|
description: str | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
location_place: str | None = None,
|
||||||
|
location_bookshelf: str | None = None,
|
||||||
|
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)
|
||||||
|
if not book:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Update scalar fields
|
||||||
|
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 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, (
|
||||||
|
"Publisher is required when set_all_fields is True"
|
||||||
|
)
|
||||||
|
book.publisher = publisher
|
||||||
|
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 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 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 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 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 or set_all_fields:
|
||||||
|
book.location_shelf = location_shelf
|
||||||
|
if first_published is not None or set_all_fields:
|
||||||
|
book.first_published = first_published
|
||||||
|
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 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 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
|
||||||
|
|
||||||
|
|
||||||
|
def delete_book(book_id: int) -> bool:
|
||||||
|
"""Delete a book and all related data."""
|
||||||
|
book = get_book(book_id)
|
||||||
|
if not book:
|
||||||
|
return False
|
||||||
|
|
||||||
|
db.session.delete(book)
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def search_books(
|
||||||
|
text_query: str | None = None,
|
||||||
|
owner_username: str | None = None,
|
||||||
|
location_place: str | None = None,
|
||||||
|
location_bookshelf: str | None = None,
|
||||||
|
location_shelf: int | None = None,
|
||||||
|
author_name: str | None = None,
|
||||||
|
genre_name: str | None = None,
|
||||||
|
isbn: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> Sequence[Book]:
|
||||||
|
"""
|
||||||
|
Search books with various filters.
|
||||||
|
|
||||||
|
For now implements basic filtering - advanced query parsing will be added later.
|
||||||
|
"""
|
||||||
|
query = select(Book).options(
|
||||||
|
joinedload(Book.authors), joinedload(Book.genres), joinedload(Book.owner)
|
||||||
|
)
|
||||||
|
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
# Text search across multiple fields
|
||||||
|
if text_query:
|
||||||
|
text_query = text_query.strip()
|
||||||
|
if text_query:
|
||||||
|
text_conditions = []
|
||||||
|
# Search in title, description, notes
|
||||||
|
text_conditions.extend((
|
||||||
|
Book.title.icontains(text_query),
|
||||||
|
Book.description.icontains(text_query),
|
||||||
|
Book.notes.icontains(text_query),
|
||||||
|
Book.publisher.icontains(text_query),
|
||||||
|
Book.authors.any(Author.name.icontains(text_query)),
|
||||||
|
Book.genres.any(Genre.name.icontains(text_query)),
|
||||||
|
))
|
||||||
|
|
||||||
|
conditions.append(or_(*text_conditions))
|
||||||
|
|
||||||
|
# Owner filter
|
||||||
|
if owner_username:
|
||||||
|
conditions.append(Book.owner.has(User.username == owner_username))
|
||||||
|
|
||||||
|
# Location filters
|
||||||
|
if location_place:
|
||||||
|
conditions.append(Book.location_place.icontains(location_place))
|
||||||
|
if location_bookshelf:
|
||||||
|
conditions.append(Book.location_bookshelf.icontains(location_bookshelf))
|
||||||
|
if location_shelf is not None:
|
||||||
|
conditions.append(Book.location_shelf == location_shelf)
|
||||||
|
|
||||||
|
# Author filter
|
||||||
|
if author_name:
|
||||||
|
conditions.append(Book.authors.any(Author.name.icontains(author_name)))
|
||||||
|
|
||||||
|
# Genre filter
|
||||||
|
if genre_name:
|
||||||
|
conditions.append(Book.genres.any(Genre.name.icontains(genre_name)))
|
||||||
|
|
||||||
|
# ISBN filter
|
||||||
|
if isbn:
|
||||||
|
conditions.append(Book.isbn == isbn)
|
||||||
|
|
||||||
|
# Apply all conditions
|
||||||
|
if conditions:
|
||||||
|
query = query.filter(and_(*conditions))
|
||||||
|
|
||||||
|
query = query.distinct().limit(limit)
|
||||||
|
|
||||||
|
result = db.session.execute(query)
|
||||||
|
return result.scalars().unique().all()
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
parsed_query = query_parser.parse(query_string)
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(Book)
|
||||||
|
.options(
|
||||||
|
joinedload(Book.authors), joinedload(Book.genres), joinedload(Book.owner)
|
||||||
|
)
|
||||||
|
.outerjoin(User)
|
||||||
|
)
|
||||||
|
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
# Text search across multiple fields (same as basic search)
|
||||||
|
if parsed_query.text_terms:
|
||||||
|
for text_query in [
|
||||||
|
t_strip for t in parsed_query.text_terms if (t_strip := t.strip())
|
||||||
|
]:
|
||||||
|
text_conditions = []
|
||||||
|
# Search in title, description, notes
|
||||||
|
text_conditions.extend((
|
||||||
|
Book.title.icontains(text_query),
|
||||||
|
Book.description.icontains(text_query),
|
||||||
|
Book.notes.icontains(text_query),
|
||||||
|
Book.publisher.icontains(text_query),
|
||||||
|
Book.authors.any(Author.name.icontains(text_query)),
|
||||||
|
Book.genres.any(Genre.name.icontains(text_query)),
|
||||||
|
))
|
||||||
|
|
||||||
|
conditions.append(or_(*text_conditions))
|
||||||
|
|
||||||
|
# Advanced field filters
|
||||||
|
sort_columns = []
|
||||||
|
for field_filter in parsed_query.field_filters:
|
||||||
|
if field_filter.field == Field.SORT:
|
||||||
|
query, sort_column = _build_sort_column(query, field_filter.value, username)
|
||||||
|
if sort_column is not None:
|
||||||
|
sort_columns.append(sort_column)
|
||||||
|
else:
|
||||||
|
condition = _build_field_condition(field_filter, username)
|
||||||
|
|
||||||
|
if condition is not None:
|
||||||
|
if field_filter.negated:
|
||||||
|
condition = ~condition
|
||||||
|
conditions.append(condition)
|
||||||
|
|
||||||
|
# Apply all conditions
|
||||||
|
if conditions:
|
||||||
|
query = query.filter(and_(*conditions))
|
||||||
|
|
||||||
|
# Ensure deterministic order by adding added date as tiebreaker
|
||||||
|
sort_columns.append(Book.added_date.desc())
|
||||||
|
query = query.order_by(*sort_columns)
|
||||||
|
|
||||||
|
query = query.distinct().limit(limit)
|
||||||
|
|
||||||
|
result = db.session.execute(query)
|
||||||
|
return result.scalars().unique().all()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_field_condition(
|
||||||
|
field_filter: FieldFilter, username: str | None = None
|
||||||
|
) -> ColumnElement | None:
|
||||||
|
"""
|
||||||
|
Build a SQLAlchemy condition for a field filter.
|
||||||
|
"""
|
||||||
|
field = field_filter.field
|
||||||
|
operator = field_filter.operator
|
||||||
|
value = field_filter.value
|
||||||
|
|
||||||
|
# Map field names to Book attributes or special handling
|
||||||
|
match field:
|
||||||
|
case Field.TITLE:
|
||||||
|
field_attr = Book.title
|
||||||
|
case Field.AUTHOR:
|
||||||
|
return Book.authors.any(_apply_operator(Author.name, operator, value))
|
||||||
|
case Field.GENRE:
|
||||||
|
return Book.genres.any(_apply_operator(Genre.name, operator, value))
|
||||||
|
case Field.ISBN:
|
||||||
|
field_attr = Book.isbn
|
||||||
|
case Field.PLACE:
|
||||||
|
field_attr = Book.location_place
|
||||||
|
case Field.BOOKSHELF:
|
||||||
|
field_attr = Book.location_bookshelf
|
||||||
|
case Field.SHELF:
|
||||||
|
field_attr = Book.location_shelf
|
||||||
|
case Field.ADDED_DATE:
|
||||||
|
field_attr = Book.added_date
|
||||||
|
case Field.BOUGHT_DATE:
|
||||||
|
field_attr = Book.bought_date
|
||||||
|
case Field.LOANED_DATE:
|
||||||
|
field_attr = Book.loaned_date
|
||||||
|
case Field.OWNER:
|
||||||
|
field_attr = User.username
|
||||||
|
case Field.YEAR:
|
||||||
|
field_attr = Book.first_published
|
||||||
|
case Field.RATING:
|
||||||
|
any_condition = _apply_operator(Reading.rating, operator, value)
|
||||||
|
if username:
|
||||||
|
any_condition &= Reading.user.has(User.username == username)
|
||||||
|
return Book.readings.any(any_condition)
|
||||||
|
case Field.READ_DATE:
|
||||||
|
any_condition = _apply_operator(Reading.end_date, operator, value)
|
||||||
|
if username:
|
||||||
|
any_condition &= Reading.user.has(User.username == username)
|
||||||
|
return Book.readings.any(any_condition)
|
||||||
|
case Field.IS:
|
||||||
|
assert isinstance(value, IsOperatorValue)
|
||||||
|
match value:
|
||||||
|
case IsOperatorValue.LOANED:
|
||||||
|
return Book.loaned_to != ""
|
||||||
|
case IsOperatorValue.READING:
|
||||||
|
any_condition = Reading.end_date.is_(None)
|
||||||
|
if username:
|
||||||
|
any_condition &= Reading.user.has(User.username == username)
|
||||||
|
return Book.readings.any(any_condition)
|
||||||
|
case IsOperatorValue.READ:
|
||||||
|
any_condition = (~Reading.end_date.is_(None)) & Reading.dropped.is_(
|
||||||
|
False
|
||||||
|
)
|
||||||
|
if username:
|
||||||
|
any_condition &= Reading.user.has(User.username == username)
|
||||||
|
return Book.readings.any(any_condition)
|
||||||
|
case IsOperatorValue.DROPPED:
|
||||||
|
any_condition = (~Reading.end_date.is_(None)) & Reading.dropped.is_(
|
||||||
|
True
|
||||||
|
)
|
||||||
|
if username:
|
||||||
|
any_condition &= Reading.user.has(User.username == username)
|
||||||
|
return Book.readings.any(any_condition)
|
||||||
|
case IsOperatorValue.WISHED:
|
||||||
|
return Book.wished_by.any(
|
||||||
|
Wishlist.user.has(User.username == username)
|
||||||
|
if username
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
case IsOperatorValue.UNKNOWN:
|
||||||
|
return None
|
||||||
|
case _:
|
||||||
|
assert_never(value)
|
||||||
|
case Field.SORT:
|
||||||
|
return None
|
||||||
|
case _:
|
||||||
|
assert_never(field)
|
||||||
|
|
||||||
|
condition = _apply_operator(field_attr, operator, value)
|
||||||
|
return condition
|
||||||
|
|
||||||
|
|
||||||
|
def _build_sort_column(
|
||||||
|
query: Select, value: ValueT, username: str | None = None
|
||||||
|
) -> tuple[Select, ColumnElement | None]:
|
||||||
|
"""Build a sort column for the 'sort' field."""
|
||||||
|
assert isinstance(value, tuple) and len(value) == 2
|
||||||
|
field, direction = value
|
||||||
|
assert isinstance(field, Field) and isinstance(direction, SortDirection)
|
||||||
|
|
||||||
|
match field:
|
||||||
|
case Field.TITLE:
|
||||||
|
column = Book.title
|
||||||
|
case Field.YEAR:
|
||||||
|
column = Book.first_published
|
||||||
|
case Field.ADDED_DATE:
|
||||||
|
column = Book.added_date
|
||||||
|
case Field.BOUGHT_DATE:
|
||||||
|
column = Book.bought_date
|
||||||
|
case Field.LOANED_DATE:
|
||||||
|
column = Book.loaned_date
|
||||||
|
case Field.ISBN:
|
||||||
|
column = Book.isbn
|
||||||
|
case Field.PLACE:
|
||||||
|
column = Book.location_place
|
||||||
|
case Field.BOOKSHELF:
|
||||||
|
column = Book.location_bookshelf
|
||||||
|
case Field.SHELF:
|
||||||
|
column = Book.location_shelf
|
||||||
|
case Field.OWNER:
|
||||||
|
column = User.username
|
||||||
|
case Field.READ_DATE:
|
||||||
|
# Special handling for sorting by read date - sort by latest reading end
|
||||||
|
# date
|
||||||
|
subq = (
|
||||||
|
select(
|
||||||
|
Reading.book_id,
|
||||||
|
func.max(Reading.end_date).label("latest_read_date"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Reading.user.has(User.username == username),
|
||||||
|
Reading.end_date.isnot(None),
|
||||||
|
)
|
||||||
|
.group_by(Reading.book_id)
|
||||||
|
.subquery("latest_readings")
|
||||||
|
)
|
||||||
|
query = query.outerjoin(subq, Book.id == subq.c.book_id)
|
||||||
|
column = subq.c.latest_read_date
|
||||||
|
case Field.RATING:
|
||||||
|
# Special handling for sorting by rating - sort by latest reading rating
|
||||||
|
subq = (
|
||||||
|
select(
|
||||||
|
Reading.book_id,
|
||||||
|
Reading.rating.label("latest_rating"),
|
||||||
|
func
|
||||||
|
.row_number()
|
||||||
|
.over(
|
||||||
|
partition_by=Reading.book_id,
|
||||||
|
order_by=Reading.end_date.desc(),
|
||||||
|
)
|
||||||
|
.label("rn"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Reading.user.has(User.username == username),
|
||||||
|
Reading.rating.isnot(None),
|
||||||
|
Reading.end_date.isnot(None),
|
||||||
|
)
|
||||||
|
.subquery("latest_ratings")
|
||||||
|
)
|
||||||
|
query = query.outerjoin(
|
||||||
|
subq, (Book.id == subq.c.book_id) & (subq.c.rn == 1)
|
||||||
|
)
|
||||||
|
column = subq.c.latest_rating
|
||||||
|
# These fields don't make sense to sort by
|
||||||
|
case Field.AUTHOR | Field.GENRE | Field.IS | Field.SORT:
|
||||||
|
return query, None
|
||||||
|
case _:
|
||||||
|
assert_never(field)
|
||||||
|
|
||||||
|
if direction == SortDirection.ASC:
|
||||||
|
return query, column.asc().nullslast()
|
||||||
|
elif direction == SortDirection.DESC:
|
||||||
|
return query, column.desc().nullslast()
|
||||||
|
else:
|
||||||
|
return query, None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_operator(
|
||||||
|
field_attr: InstrumentedAttribute, operator: ComparisonOperator, value: ValueT
|
||||||
|
) -> ColumnElement:
|
||||||
|
"""Apply a comparison operator to a field attribute."""
|
||||||
|
if operator == ComparisonOperator.EQUALS:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return field_attr.icontains(value) # Case-insensitive contains for strings
|
||||||
|
else:
|
||||||
|
return field_attr == value
|
||||||
|
elif operator == ComparisonOperator.GREATER:
|
||||||
|
return field_attr > value
|
||||||
|
elif operator == ComparisonOperator.GREATER_EQUAL:
|
||||||
|
return field_attr >= value
|
||||||
|
elif operator == ComparisonOperator.LESS:
|
||||||
|
return field_attr < value
|
||||||
|
elif operator == ComparisonOperator.LESS_EQUAL:
|
||||||
|
return field_attr <= value
|
||||||
|
elif operator == ComparisonOperator.NOT_EQUALS:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return ~field_attr.icontains(value)
|
||||||
|
else:
|
||||||
|
return field_attr != value
|
||||||
|
else:
|
||||||
|
# Default to equals
|
||||||
|
return field_attr == value
|
||||||
|
|
||||||
|
|
||||||
|
def import_book_from_isbn(
|
||||||
|
isbn: str,
|
||||||
|
owner_id: int | None = None,
|
||||||
|
location_place: str | None = None,
|
||||||
|
location_bookshelf: str | None = None,
|
||||||
|
location_shelf: int | None = None,
|
||||||
|
) -> Book:
|
||||||
|
"""Import book data from Google Books API using ISBN."""
|
||||||
|
google_book_data = fetch_google_book_data(isbn)
|
||||||
|
if not google_book_data:
|
||||||
|
raise ValueError(f"No book data found for ISBN: {isbn}")
|
||||||
|
|
||||||
|
# Convert Google Books data to our format
|
||||||
|
authors = []
|
||||||
|
if google_book_data.authors:
|
||||||
|
authors = google_book_data.authors
|
||||||
|
|
||||||
|
genres = []
|
||||||
|
if google_book_data.categories:
|
||||||
|
genres = google_book_data.categories
|
||||||
|
|
||||||
|
first_published = None
|
||||||
|
if google_book_data.publishedDate:
|
||||||
|
if isinstance(google_book_data.publishedDate, date):
|
||||||
|
first_published = google_book_data.publishedDate.year
|
||||||
|
elif isinstance(google_book_data.publishedDate, int):
|
||||||
|
first_published = google_book_data.publishedDate
|
||||||
|
|
||||||
|
return create_book(
|
||||||
|
title=google_book_data.title,
|
||||||
|
owner_id=owner_id,
|
||||||
|
authors=authors,
|
||||||
|
genres=genres,
|
||||||
|
isbn=isbn,
|
||||||
|
publisher=google_book_data.publisher or "",
|
||||||
|
description=google_book_data.description or "",
|
||||||
|
first_published=first_published,
|
||||||
|
location_place=location_place,
|
||||||
|
location_bookshelf=location_bookshelf,
|
||||||
|
location_shelf=location_shelf,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_books_by_location(
|
||||||
|
place: str, bookshelf: str | None = None, shelf: int | None = None
|
||||||
|
) -> Sequence[Book]:
|
||||||
|
"""Get all books at a specific location."""
|
||||||
|
return search_books(
|
||||||
|
location_place=place,
|
||||||
|
location_bookshelf=bookshelf,
|
||||||
|
location_shelf=shelf,
|
||||||
|
limit=1000, # Large limit for location queries
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_author(name: str) -> Author:
|
||||||
|
"""Get existing author or create a new one."""
|
||||||
|
author = db.session.execute(
|
||||||
|
select(Author).filter(Author.name == name)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if author is None:
|
||||||
|
author = Author(name=name)
|
||||||
|
db.session.add(author)
|
||||||
|
# Don't commit here - let the caller handle the transaction
|
||||||
|
|
||||||
|
return author
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_genre(name: str) -> Genre:
|
||||||
|
"""Get existing genre or create a new one."""
|
||||||
|
genre = db.session.execute(
|
||||||
|
select(Genre).filter(Genre.name == name)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if genre is None:
|
||||||
|
genre = Genre(name=name)
|
||||||
|
db.session.add(genre)
|
||||||
|
# Don't commit here - let the caller handle the transaction
|
||||||
|
|
||||||
|
return genre
|
||||||
|
|
||||||
|
|
||||||
|
def start_reading(
|
||||||
|
book_id: int, user_id: int, start_date: date | None = None
|
||||||
|
) -> Reading:
|
||||||
|
"""Start a new reading session."""
|
||||||
|
# Check if book exists
|
||||||
|
book = db.session.get(Book, book_id)
|
||||||
|
if not book:
|
||||||
|
raise ValueError(f"Book not found: {book_id}")
|
||||||
|
|
||||||
|
# Check if user exists
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
raise ValueError(f"User not found: {user_id}")
|
||||||
|
|
||||||
|
# Check if already reading this book
|
||||||
|
existing_reading = db.session.execute(
|
||||||
|
select(Reading).filter(
|
||||||
|
and_(
|
||||||
|
Reading.book_id == book_id,
|
||||||
|
Reading.user_id == user_id,
|
||||||
|
Reading.end_date.is_(None), # Not finished yet
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_reading:
|
||||||
|
raise ValueError(
|
||||||
|
f"Already reading this book (reading session {existing_reading.id})"
|
||||||
|
)
|
||||||
|
|
||||||
|
reading = Reading(
|
||||||
|
book_id=book_id,
|
||||||
|
user_id=user_id,
|
||||||
|
start_date=start_date or datetime.now().date(),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(reading)
|
||||||
|
db.session.commit()
|
||||||
|
return reading
|
||||||
|
|
||||||
|
|
||||||
|
def finish_reading(
|
||||||
|
reading_id: int,
|
||||||
|
rating: int | None = None,
|
||||||
|
comments: str | None = None,
|
||||||
|
end_date: date | None = None,
|
||||||
|
) -> Reading:
|
||||||
|
"""Finish a reading session."""
|
||||||
|
reading = db.session.execute(
|
||||||
|
select(Reading)
|
||||||
|
.options(joinedload(Reading.book))
|
||||||
|
.filter(Reading.id == reading_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not reading:
|
||||||
|
raise ValueError(f"Reading session not found: {reading_id}")
|
||||||
|
|
||||||
|
if reading.end_date is not None:
|
||||||
|
raise ValueError(f"Reading session {reading_id} is already finished")
|
||||||
|
|
||||||
|
reading.end_date = end_date or datetime.now().date()
|
||||||
|
reading.finished = True
|
||||||
|
reading.dropped = False
|
||||||
|
|
||||||
|
if rating is not None:
|
||||||
|
if not (1 <= rating <= 5):
|
||||||
|
raise ValueError("Rating must be between 1 and 5")
|
||||||
|
reading.rating = rating
|
||||||
|
|
||||||
|
if comments is not None:
|
||||||
|
reading.comments = comments
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return reading
|
||||||
|
|
||||||
|
|
||||||
|
def drop_reading(
|
||||||
|
reading_id: int,
|
||||||
|
comments: str | None = None,
|
||||||
|
end_date: date | None = None,
|
||||||
|
) -> Reading:
|
||||||
|
"""Mark a reading session as dropped."""
|
||||||
|
reading = db.session.execute(
|
||||||
|
select(Reading)
|
||||||
|
.options(joinedload(Reading.book))
|
||||||
|
.filter(Reading.id == reading_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not reading:
|
||||||
|
raise ValueError(f"Reading session not found: {reading_id}")
|
||||||
|
|
||||||
|
if reading.end_date is not None:
|
||||||
|
raise ValueError(f"Reading session {reading_id} is already finished")
|
||||||
|
|
||||||
|
reading.end_date = end_date or datetime.now().date()
|
||||||
|
reading.finished = False
|
||||||
|
reading.dropped = True
|
||||||
|
|
||||||
|
if comments is not None:
|
||||||
|
reading.comments = comments
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return reading
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_readings(user_id: int) -> Sequence[Reading]:
|
||||||
|
"""Get all current (unfinished) readings for a user."""
|
||||||
|
return (
|
||||||
|
db.session
|
||||||
|
.execute(
|
||||||
|
select(Reading)
|
||||||
|
.options(joinedload(Reading.book).joinedload(Book.authors))
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
Reading.user_id == user_id,
|
||||||
|
Reading.end_date.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Reading.start_date.desc())
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.unique()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_reading_history(user_id: int, limit: int = 50) -> Sequence[Reading]:
|
||||||
|
"""Get reading history for a user."""
|
||||||
|
return (
|
||||||
|
db.session
|
||||||
|
.execute(
|
||||||
|
select(Reading)
|
||||||
|
.options(joinedload(Reading.book).joinedload(Book.authors))
|
||||||
|
.filter(Reading.user_id == user_id)
|
||||||
|
.order_by(Reading.start_date.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.unique()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
book = db.session.get(Book, book_id)
|
||||||
|
if not book:
|
||||||
|
raise ValueError(f"Book not found: {book_id}")
|
||||||
|
|
||||||
|
# Check if user exists
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
raise ValueError(f"User not found: {user_id}")
|
||||||
|
|
||||||
|
# Check if already in wishlist
|
||||||
|
existing = db.session.execute(
|
||||||
|
select(Wishlist).filter(
|
||||||
|
and_(
|
||||||
|
Wishlist.book_id == book_id,
|
||||||
|
Wishlist.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
raise ValueError("Book is already in wishlist")
|
||||||
|
|
||||||
|
wishlist_item = Wishlist(
|
||||||
|
book_id=book_id,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.add(wishlist_item)
|
||||||
|
db.session.commit()
|
||||||
|
return wishlist_item
|
||||||
|
|
||||||
|
|
||||||
|
def remove_from_wishlist(book_id: int, user_id: int) -> bool:
|
||||||
|
"""Remove a book from user's wishlist."""
|
||||||
|
wishlist_item = db.session.execute(
|
||||||
|
select(Wishlist).filter(
|
||||||
|
and_(
|
||||||
|
Wishlist.book_id == book_id,
|
||||||
|
Wishlist.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not wishlist_item:
|
||||||
|
return False
|
||||||
|
|
||||||
|
db.session.delete(wishlist_item)
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_wishlist(user_id: int) -> Sequence[Wishlist]:
|
||||||
|
"""Get user's wishlist."""
|
||||||
|
return (
|
||||||
|
db.session
|
||||||
|
.execute(
|
||||||
|
select(Wishlist)
|
||||||
|
.options(joinedload(Wishlist.book).joinedload(Book.authors))
|
||||||
|
.filter(Wishlist.user_id == user_id)
|
||||||
|
.order_by(Wishlist.wishlisted_date.desc())
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.unique()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_user(username: str) -> User:
|
||||||
|
"""Create a new user."""
|
||||||
|
# Check if username already exists
|
||||||
|
existing = db.session.execute(
|
||||||
|
select(User).filter(User.username == username)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
raise ValueError(f"Username '{username}' already exists")
|
||||||
|
|
||||||
|
user = User(username=username)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_username(username: str) -> User | None:
|
||||||
|
"""Get a user by username."""
|
||||||
|
return db.session.execute(
|
||||||
|
select(User).filter(User.username == username)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def list_users() -> Sequence[User]:
|
||||||
|
"""List all users."""
|
||||||
|
return db.session.execute(select(User).order_by(User.username)).scalars().all()
|
||||||
572
src/hxbooks/main.py
Normal file
572
src/hxbooks/main.py
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
"""
|
||||||
|
Main application routes for HXBooks frontend.
|
||||||
|
|
||||||
|
Provides clean URL structure and integrates with library.py business logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
from datetime import date
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from flask import (
|
||||||
|
Blueprint,
|
||||||
|
flash,
|
||||||
|
g,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
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 Reading, User
|
||||||
|
|
||||||
|
from . import library
|
||||||
|
from .db import db
|
||||||
|
|
||||||
|
bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# 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)]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
def load_users() -> None:
|
||||||
|
"""Load all users and current viewing context."""
|
||||||
|
# Get all users for the user selector
|
||||||
|
all_users = library.list_users()
|
||||||
|
|
||||||
|
# Set template context
|
||||||
|
g.users = all_users
|
||||||
|
g.viewing_user = next(
|
||||||
|
(user for user in all_users if user.username == session.get("viewing_as_user")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
g.saved_searches = {}
|
||||||
|
|
||||||
|
# Load saved searches if viewing as a specific user
|
||||||
|
if g.viewing_user:
|
||||||
|
g.saved_searches = g.viewing_user.saved_searches or {}
|
||||||
|
|
||||||
|
|
||||||
|
# Template context processor to make users and searches available in templates
|
||||||
|
@bp.app_context_processor
|
||||||
|
def inject_template_vars() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"users": getattr(g, "users", []),
|
||||||
|
"saved_searches": getattr(g, "saved_searches", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/")
|
||||||
|
def index() -> ResponseReturnValue:
|
||||||
|
"""Book list view - main application page."""
|
||||||
|
# Get search parameters
|
||||||
|
query = request.args.get("q", "")
|
||||||
|
|
||||||
|
# Get current viewing user
|
||||||
|
viewing_user = session.get("viewing_as_user")
|
||||||
|
|
||||||
|
try:
|
||||||
|
books = library.search_books_advanced(query, limit=100, username=viewing_user)
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Search error: {e}", "error")
|
||||||
|
# print traceback for debugging
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
books = []
|
||||||
|
|
||||||
|
return render_template("book/list.html.j2", books=books, query=query)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/book/<int:book_id>")
|
||||||
|
def book_detail(book_id: int) -> ResponseReturnValue:
|
||||||
|
"""Book details and edit page."""
|
||||||
|
book = library.get_book(book_id)
|
||||||
|
if not book:
|
||||||
|
flash("Book not found", "error")
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
|
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":
|
||||||
|
try:
|
||||||
|
# Validate form data with Pydantic
|
||||||
|
form_data = BookFormData.model_validate(dict(request.form))
|
||||||
|
|
||||||
|
# Get owner ID if provided
|
||||||
|
owner_id = _get_or_create_user(form_data.owner) if form_data.owner else None
|
||||||
|
|
||||||
|
# Create book with validated data
|
||||||
|
book = library.create_book(
|
||||||
|
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 '{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")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/book/<int:book_id>/edit", methods=["POST"])
|
||||||
|
def update_book(book_id: int) -> ResponseReturnValue:
|
||||||
|
"""Update an existing book."""
|
||||||
|
book = library.get_book(book_id)
|
||||||
|
if not book:
|
||||||
|
flash("Book not found", "error")
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate form data with Pydantic
|
||||||
|
form_data = BookFormData.model_validate(dict(request.form))
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
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")
|
||||||
|
|
||||||
|
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/book/<int:book_id>/delete", methods=["GET", "POST"])
|
||||||
|
def delete_book(book_id: int) -> ResponseReturnValue:
|
||||||
|
"""Delete a book (GET shows confirmation, POST performs deletion)."""
|
||||||
|
book = library.get_book(book_id)
|
||||||
|
if not book:
|
||||||
|
flash("Book not found", "error")
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
# Perform the actual deletion
|
||||||
|
try:
|
||||||
|
title = book.title
|
||||||
|
library.delete_book(book_id)
|
||||||
|
flash(f"Book '{title}' deleted successfully!", "success")
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Error deleting book: {e}", "error")
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
|
# Show confirmation page
|
||||||
|
return render_template("book/delete_confirm.html.j2", book=book)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/import", methods=["GET", "POST"])
|
||||||
|
def import_book() -> ResponseReturnValue:
|
||||||
|
"""Import a book from ISBN."""
|
||||||
|
if request.method == "POST":
|
||||||
|
isbn = request.form.get("isbn", "").strip()
|
||||||
|
if not isbn:
|
||||||
|
flash("ISBN is required", "error")
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get current viewing user as owner
|
||||||
|
viewing_user = g.get("viewing_user")
|
||||||
|
|
||||||
|
# Import book from ISBN
|
||||||
|
book = library.import_book_from_isbn(
|
||||||
|
isbn=isbn, owner_id=viewing_user.id if viewing_user else None
|
||||||
|
)
|
||||||
|
|
||||||
|
flash(f"Book '{book.title}' imported successfully!", "success")
|
||||||
|
return redirect(url_for("main.book_detail", book_id=book.id))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Import error: {e}", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/set-user/<username>")
|
||||||
|
@bp.route("/set-user/")
|
||||||
|
def set_viewing_user(username: str = "") -> ResponseReturnValue:
|
||||||
|
"""Set the current viewing user context."""
|
||||||
|
if username:
|
||||||
|
user = library.get_user_by_username(username)
|
||||||
|
if user:
|
||||||
|
session["viewing_as_user"] = user.username
|
||||||
|
flash(f"Now viewing as {username.title()}", "info")
|
||||||
|
else:
|
||||||
|
flash(f"User '{username}' not found", "error")
|
||||||
|
else:
|
||||||
|
session.pop("viewing_as_user", None)
|
||||||
|
flash("Now viewing all users", "info")
|
||||||
|
|
||||||
|
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."""
|
||||||
|
viewing_user = g.get("viewing_user")
|
||||||
|
if not viewing_user:
|
||||||
|
flash("You must select a user to save searches", "error")
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
|
search_name = request.form.get("name", "").strip()
|
||||||
|
query_params = request.form.get("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)
|
||||||
|
if success:
|
||||||
|
flash(f"Search '{search_name}' saved successfully!", "success")
|
||||||
|
else:
|
||||||
|
flash("Error saving search", "error")
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Error saving search: {e}", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("main.index", q=query_params))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/book/<int:book_id>/reading/start", methods=["POST"])
|
||||||
|
def start_reading_route(book_id: int) -> ResponseReturnValue:
|
||||||
|
"""Start reading a book."""
|
||||||
|
viewing_user = g.get("viewing_user")
|
||||||
|
if not viewing_user:
|
||||||
|
flash("You must select a user to start reading", "error")
|
||||||
|
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
library.start_reading(book_id=book_id, user_id=viewing_user.id)
|
||||||
|
flash("Started reading!", "success")
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Error starting reading: {e}", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/book/<int:book_id>/reading/finish", methods=["POST"])
|
||||||
|
def finish_reading_route(book_id: int) -> ResponseReturnValue:
|
||||||
|
"""Finish reading a book."""
|
||||||
|
viewing_user = g.get("viewing_user")
|
||||||
|
if not viewing_user:
|
||||||
|
flash("You must select a user to finish reading", "error")
|
||||||
|
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Find current reading for this user and book
|
||||||
|
current_readings = library.get_current_readings(user_id=viewing_user.id)
|
||||||
|
current_reading = next(
|
||||||
|
(r for r in current_readings if r.book_id == book_id), None
|
||||||
|
)
|
||||||
|
|
||||||
|
if not current_reading:
|
||||||
|
flash("No active reading session found", "error")
|
||||||
|
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||||
|
|
||||||
|
library.finish_reading(reading_id=current_reading.id)
|
||||||
|
flash("Finished reading!", "success")
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Error finishing reading: {e}", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/book/<int:book_id>/reading/drop", methods=["POST"])
|
||||||
|
def drop_reading_route(book_id: int) -> ResponseReturnValue:
|
||||||
|
"""Drop reading a book."""
|
||||||
|
viewing_user = g.get("viewing_user")
|
||||||
|
if not viewing_user:
|
||||||
|
flash("You must select a user to drop reading", "error")
|
||||||
|
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Find current reading for this user and book
|
||||||
|
current_readings = library.get_current_readings(user_id=viewing_user.id)
|
||||||
|
current_reading = next(
|
||||||
|
(r for r in current_readings if r.book_id == book_id), None
|
||||||
|
)
|
||||||
|
|
||||||
|
if not current_reading:
|
||||||
|
flash("No active reading session found", "error")
|
||||||
|
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||||
|
|
||||||
|
library.drop_reading(reading_id=current_reading.id)
|
||||||
|
flash("Dropped reading", "info")
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Error dropping reading: {e}", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/book/<int:book_id>/wishlist/add", methods=["POST"])
|
||||||
|
def add_to_wishlist_route(book_id: int) -> ResponseReturnValue:
|
||||||
|
"""Add book to wishlist."""
|
||||||
|
viewing_user = g.get("viewing_user")
|
||||||
|
if not viewing_user:
|
||||||
|
flash("You must select a user to add to wishlist", "error")
|
||||||
|
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
library.add_to_wishlist(book_id=book_id, user_id=viewing_user.id)
|
||||||
|
flash("Added to wishlist!", "success")
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Error adding to wishlist: {e}", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/book/<int:book_id>/wishlist/remove", methods=["POST"])
|
||||||
|
def remove_from_wishlist_route(book_id: int) -> ResponseReturnValue:
|
||||||
|
"""Remove book from wishlist."""
|
||||||
|
viewing_user = g.get("viewing_user")
|
||||||
|
if not viewing_user:
|
||||||
|
flash("You must select a user to remove from wishlist", "error")
|
||||||
|
return redirect(url_for("main.book_detail", book_id=book_id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
removed = library.remove_from_wishlist(book_id=book_id, user_id=viewing_user.id)
|
||||||
|
if removed:
|
||||||
|
flash("Removed from wishlist", "info")
|
||||||
|
else:
|
||||||
|
flash("Book was not in wishlist", "warning")
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Error removing from wishlist: {e}", "error")
|
||||||
|
|
||||||
|
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)."""
|
||||||
|
viewing_user = g.get("viewing_user")
|
||||||
|
if not viewing_user:
|
||||||
|
flash("You must select a user to manage saved searches", "error")
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
|
# Check if search exists
|
||||||
|
saved_searches = viewing_user.saved_searches or {}
|
||||||
|
if search_name not in saved_searches:
|
||||||
|
flash(f"Saved search '{search_name}' not found", "error")
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
# Perform the actual deletion
|
||||||
|
try:
|
||||||
|
success = _delete_saved_search(viewing_user, search_name)
|
||||||
|
if success:
|
||||||
|
flash(f"Saved search '{search_name}' deleted successfully!", "success")
|
||||||
|
else:
|
||||||
|
flash("Error deleting saved search", "error")
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Error deleting saved search: {e}", "error")
|
||||||
|
return redirect(url_for("main.index"))
|
||||||
|
|
||||||
|
# Show confirmation page
|
||||||
|
return render_template(
|
||||||
|
"components/delete_search_confirm.html.j2",
|
||||||
|
search_name=search_name,
|
||||||
|
search_params=saved_searches[search_name],
|
||||||
|
)
|
||||||
@@ -1,65 +1,116 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from sqlalchemy import JSON, ForeignKey
|
from sqlalchemy import JSON, ForeignKey, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from .db import db
|
from .db import db
|
||||||
|
|
||||||
|
|
||||||
class User(db.Model): # type: ignore[name-defined]
|
class User(db.Model): # ty:ignore[unsupported-base]
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
username: Mapped[str] = mapped_column()
|
username: Mapped[str] = mapped_column()
|
||||||
saved_searches: Mapped[dict] = mapped_column(JSON, default=dict)
|
saved_searches: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
readings: Mapped[list["Reading"]] = relationship(back_populates="user")
|
readings: Mapped[list[Reading]] = relationship(back_populates="user")
|
||||||
owned_books: Mapped[list["Book"]] = relationship(back_populates="owner")
|
owned_books: Mapped[list[Book]] = relationship(back_populates="owner")
|
||||||
wishes: Mapped[list["Wishlist"]] = relationship(back_populates="user")
|
wishes: Mapped[list[Wishlist]] = relationship(back_populates="user")
|
||||||
|
|
||||||
|
|
||||||
class Book(db.Model): # type: ignore[name-defined]
|
class Author(db.Model): # ty:ignore[unsupported-base]
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
title: Mapped[str] = mapped_column(default="")
|
name: Mapped[str] = mapped_column(String(200))
|
||||||
|
books: Mapped[list[Book]] = relationship(
|
||||||
|
secondary="book_author", back_populates="authors"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Genre(db.Model): # ty:ignore[unsupported-base]
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(100))
|
||||||
|
books: Mapped[list[Book]] = relationship(
|
||||||
|
secondary="book_genre", back_populates="genres"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class BookAuthor(db.Model): # ty:ignore[unsupported-base]
|
||||||
|
__tablename__ = "book_author"
|
||||||
|
book_id: Mapped[int] = mapped_column(ForeignKey("book.id"), primary_key=True)
|
||||||
|
author_id: Mapped[int] = mapped_column(ForeignKey("author.id"), primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
|
class BookGenre(db.Model): # ty:ignore[unsupported-base]
|
||||||
|
__tablename__ = "book_genre"
|
||||||
|
book_id: Mapped[int] = mapped_column(ForeignKey("book.id"), primary_key=True)
|
||||||
|
genre_id: Mapped[int] = mapped_column(ForeignKey("genre.id"), primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Book(db.Model): # ty:ignore[unsupported-base]
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(500), default="")
|
||||||
description: Mapped[str] = mapped_column(default="")
|
description: Mapped[str] = mapped_column(default="")
|
||||||
first_published: Mapped[Optional[int]] = mapped_column(default=None)
|
first_published: Mapped[int | None] = mapped_column(default=None)
|
||||||
edition: Mapped[str] = mapped_column(default="")
|
edition: Mapped[str] = mapped_column(String(200), default="")
|
||||||
added: Mapped[datetime] = mapped_column(default=datetime.now)
|
publisher: Mapped[str] = mapped_column(String(200), default="")
|
||||||
|
isbn: Mapped[str] = mapped_column(String(20), default="")
|
||||||
notes: Mapped[str] = mapped_column(default="")
|
notes: Mapped[str] = mapped_column(default="")
|
||||||
isbn: Mapped[str] = mapped_column(default="")
|
added_date: Mapped[datetime] = mapped_column(default=datetime.now)
|
||||||
authors: Mapped[list[str]] = mapped_column(JSON, default=list)
|
bought_date: Mapped[date | None] = mapped_column(default=None)
|
||||||
genres: Mapped[list[str]] = mapped_column(JSON, default=list)
|
|
||||||
publisher: Mapped[str] = mapped_column(default="")
|
# Location hierarchy
|
||||||
owner_id: Mapped[Optional[int]] = mapped_column(ForeignKey("user.id"))
|
location_place: Mapped[str] = mapped_column(String(100), default="")
|
||||||
bought: Mapped[date] = mapped_column(default=datetime.today)
|
location_bookshelf: Mapped[str] = mapped_column(String(100), default="")
|
||||||
location: Mapped[str] = mapped_column(default="billy salon")
|
location_shelf: Mapped[int | None] = mapped_column(default=None)
|
||||||
loaned_to: Mapped[str] = mapped_column(default="")
|
|
||||||
loaned_from: Mapped[str] = mapped_column(default="")
|
# Loaning
|
||||||
owner: Mapped[Optional[User]] = relationship(back_populates="owned_books")
|
loaned_to: Mapped[str] = mapped_column(String(200), default="")
|
||||||
readings: Mapped[list["Reading"]] = relationship(
|
loaned_date: Mapped[date | None] = mapped_column(default=None)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
owner_id: Mapped[int | None] = mapped_column(ForeignKey("user.id"))
|
||||||
|
owner: Mapped[User | None] = relationship(back_populates="owned_books")
|
||||||
|
|
||||||
|
authors: Mapped[list[Author]] = relationship(
|
||||||
|
secondary="book_author", back_populates="books"
|
||||||
|
)
|
||||||
|
genres: Mapped[list[Genre]] = relationship(
|
||||||
|
secondary="book_genre", back_populates="books"
|
||||||
|
)
|
||||||
|
|
||||||
|
readings: Mapped[list[Reading]] = relationship(
|
||||||
back_populates="book", cascade="delete, delete-orphan"
|
back_populates="book", cascade="delete, delete-orphan"
|
||||||
)
|
)
|
||||||
wished_by: Mapped[list["Wishlist"]] = relationship(
|
wished_by: Mapped[list[Wishlist]] = relationship(
|
||||||
back_populates="book", cascade="delete, delete-orphan"
|
back_populates="book", cascade="delete, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Reading(db.Model): # type: ignore[name-defined]
|
class Reading(db.Model): # ty:ignore[unsupported-base]
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
start_date: Mapped[date] = mapped_column(default=datetime.today)
|
start_date: Mapped[date] = mapped_column(default=lambda: datetime.now().date())
|
||||||
end_date: Mapped[Optional[date]] = mapped_column(default=None)
|
end_date: Mapped[date | None] = mapped_column(default=None)
|
||||||
finished: Mapped[bool] = mapped_column(default=False)
|
finished: Mapped[bool] = mapped_column(default=False)
|
||||||
dropped: Mapped[bool] = mapped_column(default=False)
|
dropped: Mapped[bool] = mapped_column(default=False)
|
||||||
rating: Mapped[Optional[int]] = mapped_column(default=None)
|
rating: Mapped[int | None] = mapped_column(default=None)
|
||||||
comments: Mapped[str] = mapped_column(default="")
|
comments: Mapped[str] = mapped_column(default="")
|
||||||
|
|
||||||
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
|
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
|
||||||
book_id: Mapped[int] = mapped_column(ForeignKey("book.id"))
|
book_id: Mapped[int] = mapped_column(ForeignKey("book.id"))
|
||||||
user: Mapped["User"] = relationship(back_populates="readings")
|
|
||||||
book: Mapped["Book"] = relationship(back_populates="readings")
|
user: Mapped[User] = relationship(back_populates="readings")
|
||||||
|
book: Mapped[Book] = relationship(back_populates="readings")
|
||||||
|
|
||||||
|
|
||||||
class Wishlist(db.Model): # type: ignore[name-defined]
|
class Wishlist(db.Model): # ty:ignore[unsupported-base]
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
wishlisted: Mapped[date] = mapped_column(default=datetime.today)
|
wishlisted_date: Mapped[date] = mapped_column(default=lambda: datetime.now().date())
|
||||||
|
|
||||||
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
|
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
|
||||||
book_id: Mapped[int] = mapped_column(ForeignKey("book.id"))
|
book_id: Mapped[int] = mapped_column(ForeignKey("book.id"))
|
||||||
user: Mapped["User"] = relationship(back_populates="wishes")
|
|
||||||
book: Mapped["Book"] = relationship(back_populates="wished_by")
|
user: Mapped[User] = relationship(back_populates="wishes")
|
||||||
|
book: Mapped[Book] = relationship(back_populates="wished_by")
|
||||||
|
|||||||
246
src/hxbooks/search.py
Normal file
246
src/hxbooks/search.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
"""
|
||||||
|
Search functionality for HXBooks.
|
||||||
|
|
||||||
|
Provides query parsing and search logic for finding books with advanced syntax.
|
||||||
|
Currently implements basic search - will be enhanced with pyparsing for advanced queries
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, datetime
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import assert_never
|
||||||
|
|
||||||
|
import pyparsing as pp
|
||||||
|
|
||||||
|
|
||||||
|
class ComparisonOperator(StrEnum):
|
||||||
|
"""Supported comparison operators for search queries."""
|
||||||
|
|
||||||
|
EQUALS = "="
|
||||||
|
GREATER = ">"
|
||||||
|
GREATER_EQUAL = ">="
|
||||||
|
LESS = "<"
|
||||||
|
LESS_EQUAL = "<="
|
||||||
|
NOT_EQUALS = "!="
|
||||||
|
|
||||||
|
|
||||||
|
class Field(StrEnum):
|
||||||
|
"""Supported fields for field-specific searches."""
|
||||||
|
|
||||||
|
TITLE = "title"
|
||||||
|
AUTHOR = "author"
|
||||||
|
ISBN = "isbn"
|
||||||
|
GENRE = "genre"
|
||||||
|
YEAR = "year"
|
||||||
|
RATING = "rating"
|
||||||
|
PLACE = "place"
|
||||||
|
BOOKSHELF = "bookshelf"
|
||||||
|
SHELF = "shelf"
|
||||||
|
READ_DATE = "read"
|
||||||
|
BOUGHT_DATE = "bought"
|
||||||
|
ADDED_DATE = "added"
|
||||||
|
LOANED_DATE = "loaned"
|
||||||
|
OWNER = "owner"
|
||||||
|
IS = "is"
|
||||||
|
SORT = "sort"
|
||||||
|
|
||||||
|
|
||||||
|
class SortDirection(StrEnum):
|
||||||
|
"""Supported sort directions for 'sort' field."""
|
||||||
|
|
||||||
|
ASC = "asc"
|
||||||
|
DESC = "desc"
|
||||||
|
|
||||||
|
|
||||||
|
class IsOperatorValue(StrEnum):
|
||||||
|
"""Supported values for 'is' operator."""
|
||||||
|
|
||||||
|
LOANED = "loaned"
|
||||||
|
READ = "read"
|
||||||
|
READING = "reading"
|
||||||
|
DROPPED = "dropped"
|
||||||
|
WISHED = "wished"
|
||||||
|
UNKNOWN = "_unknown_"
|
||||||
|
|
||||||
|
|
||||||
|
ValueT = str | int | float | date | IsOperatorValue | tuple[Field, SortDirection]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FieldFilter:
|
||||||
|
"""Represents a field-specific search filter."""
|
||||||
|
|
||||||
|
field: Field
|
||||||
|
operator: ComparisonOperator
|
||||||
|
value: ValueT
|
||||||
|
negated: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SearchQuery:
|
||||||
|
"""Enhanced structured representation of a search query."""
|
||||||
|
|
||||||
|
text_terms: list[str] = field(default_factory=list)
|
||||||
|
field_filters: list[FieldFilter] = field(default_factory=list)
|
||||||
|
boolean_operator: str = "AND" # Default to AND for multiple terms
|
||||||
|
|
||||||
|
|
||||||
|
class QueryParser:
|
||||||
|
"""
|
||||||
|
Advanced query parser using pyparsing for sophisticated search syntax.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Field-specific searches: title:"The Hobbit" author:tolkien
|
||||||
|
- Date comparisons: read>=2025-01-01 bought<2024-12-31
|
||||||
|
- Numeric comparisons: rating>=4 shelf>2
|
||||||
|
- Boolean operators: genre:fantasy AND rating>=4
|
||||||
|
- Quoted strings: "science fiction"
|
||||||
|
- Negation: -genre:romance
|
||||||
|
- Parentheses: (genre:fantasy OR genre:scifi) AND rating>=4
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize the pyparsing grammar."""
|
||||||
|
self._build_grammar()
|
||||||
|
|
||||||
|
def _build_grammar(self) -> None:
|
||||||
|
"""Build the pyparsing grammar for the query language."""
|
||||||
|
|
||||||
|
# Basic tokens
|
||||||
|
field_name = pp.Regex(r"[a-zA-Z_][a-zA-Z0-9_]*")
|
||||||
|
|
||||||
|
# Operators
|
||||||
|
comparison_op = pp.one_of(">= <= != > < =")
|
||||||
|
|
||||||
|
# Values
|
||||||
|
quoted_string = pp.QuotedString('"', esc_char="\\")
|
||||||
|
date_value = pp.Regex(r"\d{4}-\d{2}-\d{2}")
|
||||||
|
number_value = pp.Regex(r"\d+(?:\.\d+)?")
|
||||||
|
unquoted_word = pp.Regex(r'[^\s()"]+') # Any non-whitespace, non-special chars
|
||||||
|
|
||||||
|
value = quoted_string | date_value | number_value | unquoted_word
|
||||||
|
|
||||||
|
# Field filters: field:value or field>=value etc.
|
||||||
|
field_filter = pp.Group(
|
||||||
|
pp.Optional("-").set_results_name("negated")
|
||||||
|
+ field_name.set_results_name("field")
|
||||||
|
+ (comparison_op | ":").set_results_name("operator")
|
||||||
|
+ value.set_results_name("value")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Free text terms (not field:value)
|
||||||
|
text_term = quoted_string | pp.Regex(r'[^\s():"]+(?![:\<\>=!])')
|
||||||
|
|
||||||
|
# Boolean operators
|
||||||
|
# and_op = pp.CaselessKeyword("AND")
|
||||||
|
# or_op = pp.CaselessKeyword("OR")
|
||||||
|
# not_op = pp.CaselessKeyword("NOT")
|
||||||
|
|
||||||
|
# Basic search element
|
||||||
|
search_element = field_filter | text_term
|
||||||
|
|
||||||
|
# For now, keep it simple - just parse field filters and text terms
|
||||||
|
# Full boolean logic can be added later if needed
|
||||||
|
query = pp.ZeroOrMore(search_element)
|
||||||
|
|
||||||
|
self.grammar = query
|
||||||
|
|
||||||
|
def parse(self, query_string: str) -> SearchQuery:
|
||||||
|
"""
|
||||||
|
Parse a search query string into structured components.
|
||||||
|
"""
|
||||||
|
if not query_string.strip():
|
||||||
|
return SearchQuery()
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_elements = self.grammar.parse_string(query_string, parse_all=True)
|
||||||
|
except pp.ParseException:
|
||||||
|
# If parsing fails, fall back to simple text search
|
||||||
|
return SearchQuery(text_terms=[query_string])
|
||||||
|
|
||||||
|
text_terms = []
|
||||||
|
field_filters = []
|
||||||
|
|
||||||
|
for element in parsed_elements:
|
||||||
|
if (
|
||||||
|
isinstance(element, pp.ParseResults)
|
||||||
|
and "field" in element
|
||||||
|
and element["field"] in Field
|
||||||
|
):
|
||||||
|
# This is a field filter
|
||||||
|
field = Field(element["field"])
|
||||||
|
operator_str = element["operator"]
|
||||||
|
value_str = element["value"]
|
||||||
|
negated = bool(element.get("negated"))
|
||||||
|
|
||||||
|
# Convert operator string to enum
|
||||||
|
if operator_str in ComparisonOperator:
|
||||||
|
operator = ComparisonOperator(operator_str)
|
||||||
|
else:
|
||||||
|
operator = ComparisonOperator.EQUALS
|
||||||
|
|
||||||
|
# Convert value to appropriate type
|
||||||
|
value = _convert_value(field, value_str)
|
||||||
|
|
||||||
|
field_filters.append(
|
||||||
|
FieldFilter(
|
||||||
|
field=field, operator=operator, value=value, negated=negated
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# This is a text term
|
||||||
|
text_terms.append(str(element))
|
||||||
|
|
||||||
|
return SearchQuery(text_terms=text_terms, field_filters=field_filters)
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_value(field: Field, value_str: str) -> ValueT:
|
||||||
|
"""Convert string value to appropriate type based on field."""
|
||||||
|
|
||||||
|
match field:
|
||||||
|
# Date fields
|
||||||
|
case Field.READ_DATE | Field.BOUGHT_DATE | Field.ADDED_DATE | Field.LOANED_DATE:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(value_str, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
return value_str
|
||||||
|
# Numeric fields
|
||||||
|
case Field.RATING | Field.SHELF | Field.YEAR:
|
||||||
|
try:
|
||||||
|
if "." in value_str:
|
||||||
|
return float(value_str)
|
||||||
|
else:
|
||||||
|
return int(value_str)
|
||||||
|
except ValueError:
|
||||||
|
return value_str
|
||||||
|
# String fields
|
||||||
|
case (
|
||||||
|
Field.OWNER
|
||||||
|
| Field.TITLE
|
||||||
|
| Field.AUTHOR
|
||||||
|
| Field.ISBN
|
||||||
|
| Field.GENRE
|
||||||
|
| Field.PLACE
|
||||||
|
| Field.BOOKSHELF
|
||||||
|
):
|
||||||
|
return value_str
|
||||||
|
case Field.IS:
|
||||||
|
if value_str in IsOperatorValue:
|
||||||
|
return IsOperatorValue(value_str)
|
||||||
|
else:
|
||||||
|
return IsOperatorValue.UNKNOWN
|
||||||
|
case Field.SORT:
|
||||||
|
parts = value_str.split("-")
|
||||||
|
if (
|
||||||
|
len(parts) == 2
|
||||||
|
and parts[0] in Field
|
||||||
|
and parts[0] not in {Field.IS, Field.SORT}
|
||||||
|
and parts[1] in SortDirection
|
||||||
|
):
|
||||||
|
return (Field(parts[0]), SortDirection(parts[1]))
|
||||||
|
elif len(parts) == 1 and parts[0] in Field:
|
||||||
|
return (Field(parts[0]), SortDirection.ASC)
|
||||||
|
else:
|
||||||
|
return (Field.SORT, SortDirection.ASC) # Default sort if invalid
|
||||||
|
case _:
|
||||||
|
assert_never(field)
|
||||||
2
src/hxbooks/static/htmx.min.js
vendored
2
src/hxbooks/static/htmx.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,3 +1,4 @@
|
|||||||
|
/* Custom Bootstrap Variables */
|
||||||
.form-check-input:checked {
|
.form-check-input:checked {
|
||||||
background-color: var(--bs-secondary);
|
background-color: var(--bs-secondary);
|
||||||
border-color: var(--bs-secondary);
|
border-color: var(--bs-secondary);
|
||||||
@@ -20,6 +21,95 @@
|
|||||||
color: var(--bs-body-color);
|
color: var(--bs-body-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Layout Styles */
|
||||||
|
.sidebar-container {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Navbar Dropdown Fix */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.navbar .dropdown.position-static {
|
||||||
|
position: static !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .dropdown-menu.mobile-dropdown {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 60px !important;
|
||||||
|
right: 15px !important;
|
||||||
|
left: auto !important;
|
||||||
|
transform: none !important;
|
||||||
|
margin-top: 0 !important;
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
|
||||||
|
z-index: 1050 !important;
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Sidebar */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Book Cards */
|
||||||
|
.book-card {
|
||||||
|
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card .card-img-top {
|
||||||
|
height: 200px;
|
||||||
|
object-fit: cover;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-status-badges {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-card-footer {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Styles */
|
||||||
|
.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 {
|
||||||
|
opacity: .65;
|
||||||
|
transform: scale(.85) translateY(-.5rem) translateX(.15rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Table Responsiveness */
|
||||||
@media screen and (max-width: 576px) {
|
@media screen and (max-width: 576px) {
|
||||||
table.collapse-rows thead {
|
table.collapse-rows thead {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
@@ -48,5 +138,89 @@
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading States */
|
||||||
|
.loading-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
border: 0.125em solid currentColor;
|
||||||
|
border-right-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spinner-border .75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spinner-border {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User Status Mobile Component */
|
||||||
|
.status-toggle-checkbox {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-status-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
display: block;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-arrow {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expandable-content {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-toggle-checkbox:checked~.user-status-card .expandable-content {
|
||||||
|
max-height: 2000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-toggle-checkbox:checked~.user-status-card .expand-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide mobile status component on desktop */
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.user-status-card {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility Classes */
|
||||||
|
.text-truncate-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-truncate-3 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
87
src/hxbooks/templates.old/base.html.j2
Normal file
87
src/hxbooks/templates.old/base.html.j2
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en"
|
||||||
|
x-init="$el.setAttribute('data-bs-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<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='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>
|
||||||
|
htmx.on('htmx:beforeHistorySave', function () {
|
||||||
|
// find all TomSelect elements
|
||||||
|
document.querySelectorAll('.tomselect')
|
||||||
|
.forEach(elt => elt.tomselect ? elt.tomselect.destroy() : null) // and call destroy() on them
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('htmx:beforeSwap', function (evt) {
|
||||||
|
// alert on errors
|
||||||
|
if (evt.detail.xhr.status >= 400) {
|
||||||
|
error_dialog = document.querySelector('#error-alert');
|
||||||
|
error_dialog.querySelector('.modal-title').textContent = 'Error ' + evt.detail.xhr.status;
|
||||||
|
error_dialog.querySelector('.modal-body').innerHTML = evt.detail.xhr.response;
|
||||||
|
error_dialog.showModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body hx-boost="true" hx-push-url="true" hx-target="body">
|
||||||
|
<header class="container-sm">
|
||||||
|
<nav class="navbar navbar-expand-sm bg-primary">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="{{ url_for('books.books') }}">
|
||||||
|
<img src="{{ url_for('static', filename='favicon-32x32.png') }}" alt="hxbooks" width="32" height="32"> hxBooks
|
||||||
|
</a>
|
||||||
|
<ul class="navbar-nav ms-auto">
|
||||||
|
{% if g.user %}
|
||||||
|
<li><span class="navbar-item pe-2">{{ g.user.username.title() }}</span></li>
|
||||||
|
<li><a class="navbar-item" href="{{ url_for('auth.logout') }}">Log Out</a></li>
|
||||||
|
{% else %}
|
||||||
|
<li><a class="navbar-item pe-2" href="{{ url_for('auth.register') }}">Register</a></li>
|
||||||
|
<li><a class="navbar-item" href="{{ url_for('auth.login') }}">Log In</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="content container-sm">
|
||||||
|
<header class="row">
|
||||||
|
{% block header %}{% endblock %}
|
||||||
|
</header>
|
||||||
|
{% for message in get_flashed_messages() %}
|
||||||
|
<div class="flash row">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
|
<dialog id="error-alert">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"></h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary"
|
||||||
|
onClick="document.querySelector('#error-alert').close()">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -5,83 +5,91 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<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',
|
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicon-32x32.png') }}">
|
||||||
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.on('htmx:beforeHistorySave', function () {
|
// HTMX error handling
|
||||||
// find all TomSelect elements
|
document.addEventListener('htmx:responseError', function (evt) {
|
||||||
document.querySelectorAll('.tomselect')
|
const error_dialog = document.querySelector('#error-alert');
|
||||||
.forEach(elt => elt.tomselect ? elt.tomselect.destroy() : null) // and call destroy() on them
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('htmx:beforeSwap', function (evt) {
|
|
||||||
// alert on errors
|
|
||||||
if (evt.detail.xhr.status >= 400) {
|
|
||||||
error_dialog = document.querySelector('#error-alert');
|
|
||||||
error_dialog.querySelector('.modal-title').textContent = 'Error ' + evt.detail.xhr.status;
|
error_dialog.querySelector('.modal-title').textContent = 'Error ' + evt.detail.xhr.status;
|
||||||
error_dialog.querySelector('.modal-body').innerHTML = evt.detail.xhr.response;
|
error_dialog.querySelector('.modal-body').innerHTML = evt.detail.xhr.response;
|
||||||
error_dialog.showModal();
|
new bootstrap.Modal(error_dialog).show();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body hx-boost="true" hx-push-url="true" hx-target="body">
|
<body>
|
||||||
<header class="container-sm">
|
<!-- Header -->
|
||||||
<nav class="navbar navbar-expand-sm bg-primary">
|
{% include 'components/header.html.j2' %}
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand" href="{{ url_for('books.books') }}">
|
|
||||||
<img src="{{ url_for('static', filename='favicon-32x32.png') }}" alt="hxbooks" width="32" height="32"> hxBooks
|
|
||||||
</a>
|
|
||||||
<ul class="navbar-nav ms-auto">
|
|
||||||
{% if g.user %}
|
|
||||||
<li><span class="navbar-item pe-2">{{ g.user.username.title() }}</span></li>
|
|
||||||
<li><a class="navbar-item" href="{{ url_for('auth.logout') }}">Log Out</a></li>
|
|
||||||
{% else %}
|
|
||||||
<li><a class="navbar-item pe-2" href="{{ url_for('auth.register') }}">Register</a></li>
|
|
||||||
<li><a class="navbar-item" href="{{ url_for('auth.login') }}">Log In</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<main class="content container-sm">
|
|
||||||
<header class="row">
|
|
||||||
{% block header %}{% endblock %}
|
|
||||||
</header>
|
|
||||||
{% for message in get_flashed_messages() %}
|
|
||||||
<div class="flash row">{{ message }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
|
|
||||||
<dialog id="error-alert">
|
<!-- Main Layout -->
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<nav class="col-md-3 col-lg-2 sidebar-container">
|
||||||
|
{% include 'components/sidebar.html.j2' %}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="col-md-9 col-lg-10 main-content">
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<!-- Flash Messages -->
|
||||||
|
{% for category, message in get_flashed_messages(with_categories=true) %}
|
||||||
|
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show"
|
||||||
|
role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
{% block header %}{% endblock %}
|
||||||
|
|
||||||
|
<!-- Page Content -->
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Dialog -->
|
||||||
|
<div class="modal fade" id="error-alert" tabindex="-1">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title"></h5>
|
<h5 class="modal-title">Error</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
<!-- Error content will be inserted here -->
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary"
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
onClick="document.querySelector('#error-alert').close()">Close</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</div>
|
||||||
</main>
|
|
||||||
|
{% include 'components/import_modal.html.j2' %}
|
||||||
|
{% include 'components/save_search_modal.html.j2' %}
|
||||||
|
|
||||||
|
<!-- Bootstrap JS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
33
src/hxbooks/templates/book/create.html.j2
Normal file
33
src/hxbooks/templates/book/create.html.j2
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "base.html.j2" %}
|
||||||
|
|
||||||
|
{% block title %}Create Book{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<a href="/" class="btn btn-outline-secondary me-3">← Back</a>
|
||||||
|
<h1 class="h3 mb-0">Create New Book</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="/book/new" method="POST">
|
||||||
|
{% include 'components/book_form.html.j2' %}
|
||||||
|
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col">
|
||||||
|
<button type="submit" class="btn btn-primary">📚 Create Book</button>
|
||||||
|
<a href="/" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
47
src/hxbooks/templates/book/delete_confirm.html.j2
Normal file
47
src/hxbooks/templates/book/delete_confirm.html.j2
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "base.html.j2" %}
|
||||||
|
|
||||||
|
{% block title %}Delete {{ book.title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
<div class="d-flex align-items-center mb-4">
|
||||||
|
<a href="/book/{{ book.id }}" class="btn btn-outline-secondary me-3">← Back</a>
|
||||||
|
<h1 class="h3 mb-0 text-danger">🗑️ Delete Book</h1>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card border-danger">
|
||||||
|
<div class="card-header bg-danger text-white">
|
||||||
|
<h5 class="mb-0">⚠️ Confirm Deletion</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-3">Are you sure you want to delete this book?</p>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
{% endif %}
|
||||||
|
{% if book.isbn %}
|
||||||
|
<p class="text-muted small mb-0">ISBN: {{ book.isbn }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<strong>Warning:</strong> This action cannot be undone. All reading history and data associated with
|
||||||
|
this book will also be deleted.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<form action="/book/{{ book.id }}/delete" method="POST" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-danger">🗑️ Delete Book</button>
|
||||||
|
</form>
|
||||||
|
<a href="/book/{{ book.id }}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
117
src/hxbooks/templates/book/detail.html.j2
Normal file
117
src/hxbooks/templates/book/detail.html.j2
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
{% extends "base.html.j2" %}
|
||||||
|
|
||||||
|
{% block title %}{{ book.title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<a href="/" class="btn btn-outline-secondary me-3">← Back</a>
|
||||||
|
<h1 class="h3 mb-0">{{ book.title }}</h1>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/book/{{ book.id }}/delete" class="btn btn-outline-danger">
|
||||||
|
🗑️ Delete
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<!-- Mobile-Only Status Bar -->
|
||||||
|
{% if session.get('viewing_as_user') %}
|
||||||
|
{% 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">
|
||||||
|
<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">
|
||||||
|
<strong>{{ session.get('viewing_as_user').title() }}'s Status:</strong>
|
||||||
|
{% if vars.current_reading %}
|
||||||
|
<span class="badge bg-primary">▶️ Reading</span>
|
||||||
|
{% elif vars.user_readings | selectattr('dropped', 'true') | list %}
|
||||||
|
<span class="badge bg-secondary">⏸ Dropped</span>
|
||||||
|
{% elif vars.completed_readings %}
|
||||||
|
<span class="badge bg-success">✓ Completed</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-light text-dark">Not Started</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if vars.latest_rated_reading and vars.latest_rated_reading.rating %}
|
||||||
|
<span class="badge bg-warning">{{ vars.latest_rated_reading.rating }} ⭐</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if vars.user_wishlist %}
|
||||||
|
<span class="badge bg-info">💖</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="expand-arrow">▼</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="expandable-content">
|
||||||
|
<div class="card-body pt-2">
|
||||||
|
{% include 'components/reading_status.html.j2' %}
|
||||||
|
{% include 'components/wishlist_status.html.j2' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- Book Details Form -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<form id="book-form" action="/book/{{ book.id }}/edit" method="POST">
|
||||||
|
{% include 'components/book_form.html.j2' %}
|
||||||
|
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col">
|
||||||
|
<button type="submit" class="btn btn-primary">💾 Save Changes</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="location.reload()">↶ Discard Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User-Specific Data Sidebar -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
{% if session.get('viewing_as_user') %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">{{ session.get('viewing_as_user').title() }}'s Data</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% include 'components/reading_status.html.j2' %}
|
||||||
|
{% include 'components/wishlist_status.html.j2' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="card bg-light">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<p class="mb-0 text-muted">Select a user to see reading status and wishlist data.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Simple form change detection
|
||||||
|
let originalFormData = new FormData(document.getElementById('book-form'));
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
document.getElementById('book-form').addEventListener('input', function () {
|
||||||
|
hasChanges = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('book-form').addEventListener('submit', function () {
|
||||||
|
hasChanges = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', function (e) {
|
||||||
|
if (hasChanges) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
62
src/hxbooks/templates/book/list.html.j2
Normal file
62
src/hxbooks/templates/book/list.html.j2
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{% extends "base.html.j2" %}
|
||||||
|
|
||||||
|
{% block title %}Books{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
{# <h1 class="h3 mb-0">Library</h1> #}
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="search-container mx-3 flex-grow-1">
|
||||||
|
<form method="GET" action="/">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" name="q" value="{{ query }}"
|
||||||
|
placeholder="Search books, authors, genres...">
|
||||||
|
<button class="btn btn-outline-secondary" type="submit">
|
||||||
|
🔍 Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save Search Button -->
|
||||||
|
{% if query %}
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal"
|
||||||
|
data-bs-target="#save-search-modal">
|
||||||
|
💾 Save Search
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<!-- Book Grid -->
|
||||||
|
{% if books %}
|
||||||
|
<div class="row row-cols-2 row-cols-sm-2 row-cols-md-3 row-cols-lg-5 g-2">
|
||||||
|
{% for book in books %}
|
||||||
|
<div class="col">
|
||||||
|
{% include 'components/book_card.html.j2' %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<div class="mb-3">
|
||||||
|
<span style="font-size: 4rem;">📚</span>
|
||||||
|
</div>
|
||||||
|
<h4 class="text-muted">No books found</h4>
|
||||||
|
<p class="text-muted">
|
||||||
|
{% if query %}
|
||||||
|
Try adjusting your search terms or
|
||||||
|
{% else %}
|
||||||
|
Start building your library by
|
||||||
|
{% endif %}
|
||||||
|
<a href="/book/new">adding a book</a> or
|
||||||
|
<button type="button" class="btn btn-link p-0" data-bs-toggle="modal" data-bs-target="#import-modal">
|
||||||
|
importing from ISBN
|
||||||
|
</button>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
76
src/hxbooks/templates/components/book_card.html.j2
Normal file
76
src/hxbooks/templates/components/book_card.html.j2
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<div class="card book-card h-100" onclick="window.location.href='/book/{{ book.id }}'">
|
||||||
|
<div class="position-relative">
|
||||||
|
<!-- TODO: Book cover image -->
|
||||||
|
<div class="card-img-top bg-light d-flex align-items-center justify-content-center text-muted">
|
||||||
|
📖
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Badges -->
|
||||||
|
<div class="book-status-badges">
|
||||||
|
{% if g.viewing_user %}
|
||||||
|
{% import 'components/user_book_vars.html.j2' as vars with context %}
|
||||||
|
|
||||||
|
<!-- Reading Status Badge -->
|
||||||
|
{% if vars.current_reading %}
|
||||||
|
<span class="badge bg-primary badge-sm">▶️ Reading</span>
|
||||||
|
{% elif vars.user_readings | selectattr('dropped', 'true') | list %}
|
||||||
|
<span class="badge bg-secondary badge-sm">⏸ Dropped</span>
|
||||||
|
{% elif vars.completed_readings %}
|
||||||
|
<span class="badge bg-success badge-sm">✓ Completed</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Rating Badge -->
|
||||||
|
{% if vars.latest_rated_reading and vars.latest_rated_reading.rating %}
|
||||||
|
<span class="badge bg-warning badge-sm">{{ vars.latest_rated_reading.rating }} ⭐</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Wishlist Badge -->
|
||||||
|
{% if vars.user_wishlist %}
|
||||||
|
<span class="badge bg-info badge-sm">💖 Wishlist</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body d-flex flex-column">
|
||||||
|
<h6 class="card-title text-truncate-2 mb-2">{{ book.title }}</h6>
|
||||||
|
|
||||||
|
{% if book.authors %}
|
||||||
|
<p class="card-text text-muted small text-truncate mb-2">
|
||||||
|
by {{ book.authors | join(', ') }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# {% if book.description %}
|
||||||
|
<p class="card-text small text-truncate-3 flex-grow-1 mb-2">
|
||||||
|
{{ book.description }}
|
||||||
|
</p>
|
||||||
|
{% endif %} #}
|
||||||
|
|
||||||
|
<div class="mt-auto">
|
||||||
|
{% if book.first_published %}
|
||||||
|
<small class="text-muted">{{ book.first_published }}</small>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% 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>
|
||||||
|
{% endfor %}
|
||||||
|
{% if book.genres|length > 2 %}
|
||||||
|
<span class="badge bg-light text-dark small">+{{ book.genres|length - 2 }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer book-card-footer">
|
||||||
|
{% if book.owner %}
|
||||||
|
<small class="text-muted">📍 {{ book.owner.username.title() }}</small>
|
||||||
|
{% endif %}
|
||||||
|
{% if book.location_place %}
|
||||||
|
<small class="text-muted"> • {{ book.location_place }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
99
src/hxbooks/templates/components/book_form.html.j2
Normal file
99
src/hxbooks/templates/components/book_form.html.j2
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<!-- Basic Information -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<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-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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Authors and Genres -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<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>
|
||||||
|
<div class="form-text">Enter one author per line</div>
|
||||||
|
</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>
|
||||||
|
<div class="form-text">Enter one genre per line</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Publication Info -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="first_published" class="form-label">Year Published</label>
|
||||||
|
<input type="number" class="form-control" id="first_published" name="first_published"
|
||||||
|
value="{{ book.first_published if book and book.first_published else '' }}" min="1000" max="2030">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="publisher" class="form-label">Publisher</label>
|
||||||
|
<input type="text" class="form-control" id="publisher" name="publisher"
|
||||||
|
value="{{ book.publisher if book else '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="edition" class="form-label">Edition</label>
|
||||||
|
<input type="text" class="form-control" id="edition" name="edition" value="{{ book.edition if book else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="description" class="form-label">Description</label>
|
||||||
|
<textarea class="form-control" id="description" name="description"
|
||||||
|
rows="3">{{ book.description if book else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location Information -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="location_place" class="form-label">Location (Place)</label>
|
||||||
|
<input type="text" class="form-control" id="location_place" name="location_place"
|
||||||
|
value="{{ book.location_place if book else '' }}" placeholder="Home, Office, etc.">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="location_bookshelf" class="form-label">Bookshelf</label>
|
||||||
|
<input type="text" class="form-control" id="location_bookshelf" name="location_bookshelf"
|
||||||
|
value="{{ book.location_bookshelf if book else '' }}" placeholder="Living room, Bedroom, etc.">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="location_shelf" class="form-label">Shelf Number</label>
|
||||||
|
<input type="number" class="form-control" id="location_shelf" name="location_shelf"
|
||||||
|
value="{{ book.location_shelf if book and book.location_shelf else '' }}" min="1">
|
||||||
|
</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>
|
||||||
|
<textarea class="form-control" id="notes" name="notes" rows="3"
|
||||||
|
placeholder="Your personal notes about this book...">{{ book.notes if book else '' }}</textarea>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "base.html.j2" %}
|
||||||
|
|
||||||
|
{% block title %}Delete Saved Search{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
<div class="d-flex align-items-center mb-4">
|
||||||
|
<a href="/" class="btn btn-outline-secondary me-3">← Back</a>
|
||||||
|
<h1 class="h3 mb-0 text-danger">🗑️ Delete Saved Search</h1>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card border-warning">
|
||||||
|
<div class="card-header bg-warning text-dark">
|
||||||
|
<h5 class="mb-0">⚠️ Confirm Deletion</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-3">Are you sure you want to delete this saved search?</p>
|
||||||
|
|
||||||
|
<div class="bg-light p-3 rounded mb-4">
|
||||||
|
<h6 class="fw-bold">🔍 {{ search_name }}</h6>
|
||||||
|
<p class="text-muted small mb-0">Search: {{ search_params }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<form action="/saved-search/{{ search_name }}/delete" method="POST" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-warning">🗑️ Delete Search</button>
|
||||||
|
</form>
|
||||||
|
<a href="/" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
53
src/hxbooks/templates/components/header.html.j2
Normal file
53
src/hxbooks/templates/components/header.html.j2
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<header class="navbar navbar-expand-lg navbar-dark bg-primary sticky-top">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<!-- Mobile sidebar toggle -->
|
||||||
|
<button class="navbar-toggler d-md-none" type="button" data-bs-toggle="offcanvas" data-bs-target="#sidebar">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Brand -->
|
||||||
|
<a class="navbar-brand d-flex align-items-center" href="/">
|
||||||
|
<img src="{{ url_for('static', filename='favicon-32x32.png') }}" alt="HXBooks" width="32" height="32"
|
||||||
|
class="me-2">
|
||||||
|
HXBooks
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- User Selector -->
|
||||||
|
<div class="navbar-nav ms-auto">
|
||||||
|
<div class="nav-item dropdown position-static">
|
||||||
|
<button class="btn btn-outline-light dropdown-toggle" type="button" data-bs-toggle="dropdown"
|
||||||
|
data-bs-boundary="viewport" data-bs-reference="parent">
|
||||||
|
{% if session.get('viewing_as_user') %}
|
||||||
|
👤 {{ session.get('viewing_as_user') }}
|
||||||
|
{% else %}
|
||||||
|
🌐 All Users
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end mobile-dropdown">
|
||||||
|
<li>
|
||||||
|
<h6 class="dropdown-header">View as User</h6>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item {% if not session.get('viewing_as_user') %}active{% endif %}"
|
||||||
|
href="{{ url_for('main.set_viewing_user', username='') }}">
|
||||||
|
🌐 All Users
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% if users %}
|
||||||
|
<li>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
</li>
|
||||||
|
{% for user in users %}
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item {% if session.get('viewing_as_user') == user.username %}active{% endif %}"
|
||||||
|
href="{{ url_for('main.set_viewing_user', username=user.username) }}">
|
||||||
|
👤 {{ user.username.title() }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
42
src/hxbooks/templates/components/import_modal.html.j2
Normal file
42
src/hxbooks/templates/components/import_modal.html.j2
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!-- Import Modal -->
|
||||||
|
<div class="modal fade" id="import-modal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Import Book from ISBN</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form action="/import" method="POST">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="isbn" class="form-label">ISBN</label>
|
||||||
|
<input type="text" class="form-control" id="isbn" name="isbn"
|
||||||
|
placeholder="Enter ISBN-10 or ISBN-13"
|
||||||
|
pattern="[0-9X\-]{10,17}"
|
||||||
|
title="Enter a valid ISBN (10 or 13 digits, may contain hyphens)"
|
||||||
|
required>
|
||||||
|
<div class="form-text">
|
||||||
|
Enter the ISBN (International Standard Book Number) to automatically fetch book details from Google Books.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if session.get('viewing_as_user') %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="set-owner" name="set_owner" checked>
|
||||||
|
<label class="form-check-label" for="set-owner">
|
||||||
|
Set {{ session.get('viewing_as_user').title() }} as owner
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-cloud-download me-1"></i> Import Book
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
123
src/hxbooks/templates/components/reading_status.html.j2
Normal file
123
src/hxbooks/templates/components/reading_status.html.j2
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<!-- Reading Status Component -->
|
||||||
|
{% if g.viewing_user %}
|
||||||
|
{% import 'components/user_book_vars.html.j2' as vars with context %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6 class="text-muted mb-2">📖 Reading Status</h6>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="mb-3">
|
||||||
|
{% 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>
|
||||||
|
</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 vars.user_readings %}
|
||||||
|
|
||||||
|
<!-- Current Book Rating (if any completed readings) -->
|
||||||
|
{% if vars.completed_readings %}
|
||||||
|
<div 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">
|
||||||
|
<!-- 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="comments" value="{{ vars.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 vars.latest_rated_reading and vars.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 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">
|
||||||
|
<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 == vars.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 %}
|
||||||
24
src/hxbooks/templates/components/save_search_modal.html.j2
Normal file
24
src/hxbooks/templates/components/save_search_modal.html.j2
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!-- Save Search Modal -->
|
||||||
|
<div class="modal fade" id="save-search-modal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Save Current Search</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<form action="/saved-search" method="POST">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="search-name" class="form-label">Search Name</label>
|
||||||
|
<input type="text" class="form-control" id="search-name" name="name" required>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="query_params" value="{{ request.args.q }}">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Search</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
15
src/hxbooks/templates/components/sidebar.html.j2
Normal file
15
src/hxbooks/templates/components/sidebar.html.j2
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!-- Desktop Sidebar -->
|
||||||
|
<div class="sidebar d-none d-md-block bg-light border-end">
|
||||||
|
{% include 'components/sidebar_content.html.j2' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Offcanvas Sidebar -->
|
||||||
|
<div class="offcanvas offcanvas-start d-md-none" tabindex="-1" id="sidebar">
|
||||||
|
<div class="offcanvas-header">
|
||||||
|
<h5 class="offcanvas-title">Library</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>
|
||||||
|
</div>
|
||||||
|
<div class="offcanvas-body">
|
||||||
|
{% include 'components/sidebar_content.html.j2' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
69
src/hxbooks/templates/components/sidebar_content.html.j2
Normal file
69
src/hxbooks/templates/components/sidebar_content.html.j2
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<div class="sidebar-content h-100 d-flex flex-column">
|
||||||
|
<!-- Actions Section -->
|
||||||
|
<div class="p-3 border-bottom">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<a href="/book/new" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i> Create Book
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal"
|
||||||
|
data-bs-target="#import-modal">
|
||||||
|
<i class="bi bi-cloud-download me-1"></i> Import Book
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Default Searches -->
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="p-3">
|
||||||
|
<h6 class="text-muted mb-3">Quick Searches</h6>
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
<a href="/"
|
||||||
|
class="list-group-item list-group-item-action {% if request.endpoint == 'main.index' and not request.args %}active{% endif %}">
|
||||||
|
<i class="bi bi-collection me-2"></i> All Books
|
||||||
|
</a>
|
||||||
|
{% if session.get('viewing_as_user') %}
|
||||||
|
<a href="/?q=owner:{{ session.get('viewing_as_user') }}"
|
||||||
|
class="list-group-item list-group-item-action{% if request.args.get('q') == 'owner:' ~ session.get('viewing_as_user') %} active{% endif %}">
|
||||||
|
<i class="bi bi-house me-2"></i> My Books
|
||||||
|
</a>
|
||||||
|
<a href="/?q=is:wished"
|
||||||
|
class="list-group-item list-group-item-action{% if request.args.get('q') == 'is:wished' %} active{% endif %}">
|
||||||
|
<i class="bi bi-heart me-2"></i> Wishlist
|
||||||
|
</a>
|
||||||
|
<a href="/?q=is:reading"
|
||||||
|
class="list-group-item list-group-item-action{% if request.args.get('q') == 'is:reading' %} active{% endif %}">
|
||||||
|
<i class="bi bi-book me-2"></i> Currently Reading
|
||||||
|
</a>
|
||||||
|
<a href="/?q=is:read"
|
||||||
|
class="list-group-item list-group-item-action{% if request.args.get('q') == 'is:read' %} active{% endif %}">
|
||||||
|
<i class="bi bi-check-circle me-2"></i> Finished
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Saved Searches -->
|
||||||
|
{% if session.get('viewing_as_user') and saved_searches %}
|
||||||
|
<div class="p-3 border-top">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6 class="text-muted mb-0">Saved Searches</h6>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#save-search-modal">
|
||||||
|
➕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for search_name, search_params in saved_searches.items() %}
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<a href="/?q={{ search_params | urlencode }}" class="flex-grow-1 text-decoration-none">
|
||||||
|
🔍 {{ search_name }}
|
||||||
|
</a>
|
||||||
|
<a href="/saved-search/{{ search_name }}/delete" class="btn btn-sm btn-outline-danger">
|
||||||
|
🗑️
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
6
src/hxbooks/templates/components/user_book_vars.html.j2
Normal file
6
src/hxbooks/templates/components/user_book_vars.html.j2
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<!-- User Book Variables Component - Include to get user-specific book data -->
|
||||||
|
{% 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 completed_readings = user_readings | selectattr('end_date') | sort(attribute='end_date', reverse=true) %}
|
||||||
|
{% set latest_rated_reading = completed_readings | selectattr('rating') | first %}
|
||||||
|
{% set user_wishlist = book.wished_by | selectattr('user_id', 'equalto', g.viewing_user.id) | first %}
|
||||||
22
src/hxbooks/templates/components/wishlist_status.html.j2
Normal file
22
src/hxbooks/templates/components/wishlist_status.html.j2
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!-- Wishlist Status Component -->
|
||||||
|
{% if g.viewing_user %}
|
||||||
|
{% import 'components/user_book_vars.html.j2' as vars with context %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6 class="text-muted mb-2">💝 Wishlist</h6>
|
||||||
|
|
||||||
|
{% if vars.user_wishlist %}
|
||||||
|
<div class="alert alert-warning py-2">
|
||||||
|
<small>Added to wishlist: {{ vars.user_wishlist.wishlisted_date.strftime('%B %d, %Y') }}</small>
|
||||||
|
</div>
|
||||||
|
<form action="/book/{{ book.id }}/wishlist/remove" method="POST" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-outline-danger btn-sm">💔 Remove from Wishlist</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted small mb-2">Not in wishlist</p>
|
||||||
|
<form action="/book/{{ book.id }}/wishlist/add" method="POST" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-outline-primary btn-sm">💖 Add to Wishlist</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
71
tests/conftest.py
Normal file
71
tests/conftest.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""
|
||||||
|
Test configuration and fixtures for HXBooks.
|
||||||
|
|
||||||
|
Provides isolated test database, Flask app instances, and CLI testing utilities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
from flask import Flask
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from hxbooks import cli
|
||||||
|
from hxbooks.app import create_app
|
||||||
|
from hxbooks.db import db
|
||||||
|
from hxbooks.models import User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Flask:
|
||||||
|
"""Create Flask app with test configuration."""
|
||||||
|
test_db_path = tmp_path / "test.db"
|
||||||
|
test_config = {
|
||||||
|
"TESTING": True,
|
||||||
|
"SQLALCHEMY_DATABASE_URI": f"sqlite:///{test_db_path}",
|
||||||
|
"SECRET_KEY": "test-secret-key",
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
app = create_app(test_config)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "get_app", lambda: app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app: Flask) -> FlaskClient:
|
||||||
|
"""Create test client for Flask app."""
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cli_runner() -> CliRunner:
|
||||||
|
"""Create Click CLI test runner."""
|
||||||
|
return CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def test_user(app: Flask) -> User:
|
||||||
|
"""Create a test user in the database."""
|
||||||
|
with app.app_context():
|
||||||
|
user = User(username="testuser")
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Refresh to get the ID
|
||||||
|
db.session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_session(app: Flask) -> Generator[Session]:
|
||||||
|
"""Create database session for direct database testing."""
|
||||||
|
with app.app_context():
|
||||||
|
yield db.session
|
||||||
964
tests/test_cli.py
Normal file
964
tests/test_cli.py
Normal file
@@ -0,0 +1,964 @@
|
|||||||
|
"""
|
||||||
|
CLI command tests for HXBooks.
|
||||||
|
|
||||||
|
Tests all CLI commands for correct behavior, database integration, and output formatting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
from hxbooks.cli import cli
|
||||||
|
from hxbooks.db import db
|
||||||
|
from hxbooks.models import Author, Book, Genre, Reading, User
|
||||||
|
|
||||||
|
|
||||||
|
class TestBookAddCommand:
|
||||||
|
"""Test the 'hxbooks book add' command."""
|
||||||
|
|
||||||
|
def test_book_add_basic(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test basic book addition with title and owner."""
|
||||||
|
# Run the CLI command
|
||||||
|
result = cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"book",
|
||||||
|
"add",
|
||||||
|
"The Hobbit",
|
||||||
|
"--owner",
|
||||||
|
"frodo",
|
||||||
|
"--authors",
|
||||||
|
"J.R.R. Tolkien",
|
||||||
|
"--genres",
|
||||||
|
"Fantasy,Adventure",
|
||||||
|
"--isbn",
|
||||||
|
"9780547928227",
|
||||||
|
"--publisher",
|
||||||
|
"Houghton Mifflin Harcourt",
|
||||||
|
"--place",
|
||||||
|
"home",
|
||||||
|
"--bookshelf",
|
||||||
|
"living room",
|
||||||
|
"--shelf",
|
||||||
|
"2",
|
||||||
|
"--description",
|
||||||
|
"A classic fantasy tale",
|
||||||
|
"--notes",
|
||||||
|
"First edition",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify CLI command succeeded
|
||||||
|
assert result.exit_code == 0, f"CLI command failed with output: {result.output}"
|
||||||
|
|
||||||
|
# Verify success message format
|
||||||
|
assert "Added book: The Hobbit (ID:" in result.output
|
||||||
|
assert "Created user: frodo" in result.output
|
||||||
|
|
||||||
|
# Verify database state
|
||||||
|
with app.app_context():
|
||||||
|
# Check user was created
|
||||||
|
users = db.session.execute(db.select(User)).scalars().all()
|
||||||
|
assert len(users) == 1
|
||||||
|
user = users[0]
|
||||||
|
assert user.username == "frodo"
|
||||||
|
|
||||||
|
# Check book was created with correct fields
|
||||||
|
books = (
|
||||||
|
db.session
|
||||||
|
.execute(db.select(Book).join(Book.authors).join(Book.genres))
|
||||||
|
.unique()
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert len(books) == 1
|
||||||
|
book = books[0]
|
||||||
|
|
||||||
|
assert book.title == "The Hobbit"
|
||||||
|
assert book.owner_id == user.id
|
||||||
|
assert book.isbn == "9780547928227"
|
||||||
|
assert book.publisher == "Houghton Mifflin Harcourt"
|
||||||
|
assert book.location_place == "home"
|
||||||
|
assert book.location_bookshelf == "living room"
|
||||||
|
assert book.location_shelf == 2
|
||||||
|
assert book.description == "A classic fantasy tale"
|
||||||
|
assert book.notes == "First edition"
|
||||||
|
|
||||||
|
# Check authors were created and linked
|
||||||
|
authors = db.session.execute(db.select(Author)).scalars().all()
|
||||||
|
assert len(authors) == 1
|
||||||
|
author = authors[0]
|
||||||
|
assert author.name == "J.R.R. Tolkien"
|
||||||
|
assert book in author.books
|
||||||
|
assert author in book.authors
|
||||||
|
|
||||||
|
# Check genres were created and linked
|
||||||
|
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"}
|
||||||
|
|
||||||
|
for genre in genres:
|
||||||
|
assert book in genre.books
|
||||||
|
assert genre in book.genres
|
||||||
|
|
||||||
|
def test_book_add_minimal_fields(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test book addition with only required fields."""
|
||||||
|
result = cli_runner.invoke(
|
||||||
|
cli, ["book", "add", "Minimal Book", "--owner", "alice"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Added book: Minimal Book (ID:" in result.output
|
||||||
|
|
||||||
|
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.publisher == ""
|
||||||
|
assert book.location_shelf is None # Default None
|
||||||
|
assert len(book.authors) == 0 # No authors provided
|
||||||
|
assert len(book.genres) == 0 # No genres provided
|
||||||
|
|
||||||
|
|
||||||
|
class TestBookListCommand:
|
||||||
|
"""Test the 'hxbooks book list' command."""
|
||||||
|
|
||||||
|
def test_book_list_empty(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test listing books when database is empty."""
|
||||||
|
result = cli_runner.invoke(cli, ["book", "list"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "No books found." in result.output
|
||||||
|
|
||||||
|
def test_book_list_with_books(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test listing books in table format."""
|
||||||
|
# Add test data
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
["book", "add", "Book One", "--owner", "alice", "--authors", "Author A"],
|
||||||
|
)
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli, ["book", "add", "Book Two", "--owner", "bob", "--authors", "Author B"]
|
||||||
|
)
|
||||||
|
|
||||||
|
result = cli_runner.invoke(cli, ["book", "list"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Book One" in result.output
|
||||||
|
assert "Book Two" in result.output
|
||||||
|
assert "Author A" in result.output
|
||||||
|
assert "Author B" in result.output
|
||||||
|
assert "alice" in result.output
|
||||||
|
assert "bob" in result.output
|
||||||
|
|
||||||
|
def test_book_list_json_format(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test listing books in JSON format."""
|
||||||
|
# Add test data
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"book",
|
||||||
|
"add",
|
||||||
|
"Test Book",
|
||||||
|
"--owner",
|
||||||
|
"alice",
|
||||||
|
"--authors",
|
||||||
|
"Test Author",
|
||||||
|
"--isbn",
|
||||||
|
"1234567890",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = cli_runner.invoke(cli, ["book", "list", "--format", "json"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
books_data = json.loads(result.output)
|
||||||
|
assert len(books_data) == 1
|
||||||
|
book = books_data[0]
|
||||||
|
assert book["title"] == "Test Book"
|
||||||
|
assert book["authors"] == ["Test Author"]
|
||||||
|
assert book["owner"] == "alice"
|
||||||
|
assert book["isbn"] == "1234567890"
|
||||||
|
|
||||||
|
def test_book_list_filter_by_owner(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test filtering books by owner."""
|
||||||
|
# Add books for different owners
|
||||||
|
cli_runner.invoke(cli, ["book", "add", "Alice Book", "--owner", "alice"])
|
||||||
|
cli_runner.invoke(cli, ["book", "add", "Bob Book", "--owner", "bob"])
|
||||||
|
|
||||||
|
result = cli_runner.invoke(cli, ["book", "list", "--owner", "alice"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Alice Book" in result.output
|
||||||
|
assert "Bob Book" not in result.output
|
||||||
|
|
||||||
|
def test_book_list_filter_by_location(
|
||||||
|
self, app: Flask, cli_runner: CliRunner
|
||||||
|
) -> None:
|
||||||
|
"""Test filtering books by location."""
|
||||||
|
# Add books in different locations
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"book",
|
||||||
|
"add",
|
||||||
|
"Home Book",
|
||||||
|
"--owner",
|
||||||
|
"alice",
|
||||||
|
"--place",
|
||||||
|
"home",
|
||||||
|
"--bookshelf",
|
||||||
|
"living",
|
||||||
|
"--shelf",
|
||||||
|
"1",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"book",
|
||||||
|
"add",
|
||||||
|
"Office Book",
|
||||||
|
"--owner",
|
||||||
|
"alice",
|
||||||
|
"--place",
|
||||||
|
"office",
|
||||||
|
"--bookshelf",
|
||||||
|
"work",
|
||||||
|
"--shelf",
|
||||||
|
"2",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"book",
|
||||||
|
"list",
|
||||||
|
"--place",
|
||||||
|
"home",
|
||||||
|
"--bookshelf",
|
||||||
|
"living",
|
||||||
|
"--shelf",
|
||||||
|
"1",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Home Book" in result.output
|
||||||
|
assert "Office Book" not in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestBookSearchCommand:
|
||||||
|
"""Test the 'hxbooks book search' command."""
|
||||||
|
|
||||||
|
def test_book_search_basic(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test basic book search functionality."""
|
||||||
|
# Add test books
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"book",
|
||||||
|
"add",
|
||||||
|
"The Hobbit",
|
||||||
|
"--owner",
|
||||||
|
"alice",
|
||||||
|
"--authors",
|
||||||
|
"Tolkien",
|
||||||
|
"--genres",
|
||||||
|
"Fantasy",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"book",
|
||||||
|
"add",
|
||||||
|
"Dune",
|
||||||
|
"--owner",
|
||||||
|
"alice",
|
||||||
|
"--authors",
|
||||||
|
"Herbert",
|
||||||
|
"--genres",
|
||||||
|
"Sci-Fi",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = cli_runner.invoke(cli, ["book", "search", "Hobbit"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "The Hobbit" in result.output
|
||||||
|
assert "Dune" not in result.output
|
||||||
|
|
||||||
|
def test_book_search_no_results(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test search with no matching results."""
|
||||||
|
result = cli_runner.invoke(cli, ["book", "search", "nonexistent"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "No books found." in result.output
|
||||||
|
|
||||||
|
def test_book_search_json_format(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test book search with JSON output."""
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"book",
|
||||||
|
"add",
|
||||||
|
"Test Book",
|
||||||
|
"--owner",
|
||||||
|
"alice",
|
||||||
|
"--authors",
|
||||||
|
"Test Author",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = cli_runner.invoke(cli, ["book", "search", "Test", "--format", "json"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
search_results = json.loads(result.output)
|
||||||
|
assert len(search_results) >= 1
|
||||||
|
# Results format depends on BookService.search_books_advanced implementation
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query,username,expected_titles",
|
||||||
|
[
|
||||||
|
# String field filters
|
||||||
|
("title:Hobbit", "", ["The Hobbit"]),
|
||||||
|
("author:Tolkien", "", ["The Fellowship", "The Hobbit"]),
|
||||||
|
("genre:Fantasy", "", ["The Fellowship", "The Hobbit"]),
|
||||||
|
("owner:alice", "", ["Dune", "The Fellowship", "The Hobbit"]),
|
||||||
|
("place:home", "", ["Programming Book", "The Hobbit"]),
|
||||||
|
("bookshelf:fantasy", "", ["The Fellowship", "The Hobbit"]),
|
||||||
|
# Numeric field filters
|
||||||
|
("rating>=4", "", ["Programming Book", "The Hobbit"]),
|
||||||
|
("rating=3", "", ["Dune"]),
|
||||||
|
("shelf>1", "", ["Programming Book", "The Fellowship"]),
|
||||||
|
("year>=1954", "", ["Programming Book", "Dune", "The Fellowship"]),
|
||||||
|
# Date field filters
|
||||||
|
(
|
||||||
|
"added>=2026-03-15",
|
||||||
|
"",
|
||||||
|
["Programming Book", "Dune", "The Fellowship", "The Hobbit"],
|
||||||
|
),
|
||||||
|
("bought<2026-01-01", "", ["Programming Book"]),
|
||||||
|
# Negation
|
||||||
|
("-genre:Fantasy", "", ["Programming Book", "Dune"]),
|
||||||
|
("-owner:bob", "", ["Dune", "The Fellowship", "The Hobbit"]),
|
||||||
|
# Complex query with multiple filters
|
||||||
|
("-genre:Fantasy owner:alice", "", ["Dune"]),
|
||||||
|
# User-specific queries
|
||||||
|
("rating>=4", "alice", ["The Hobbit"]),
|
||||||
|
("is:reading", "alice", ["The Fellowship"]),
|
||||||
|
("is:read", "alice", ["Dune", "The Hobbit"]),
|
||||||
|
("is:wished", "alice", ["Programming Book"]),
|
||||||
|
# Sorting
|
||||||
|
(
|
||||||
|
"sort:added-desc",
|
||||||
|
"",
|
||||||
|
["Programming Book", "Dune", "The Fellowship", "The Hobbit"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sort:added-asc",
|
||||||
|
"",
|
||||||
|
["The Hobbit", "The Fellowship", "Dune", "Programming Book"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sort:read-desc",
|
||||||
|
"alice",
|
||||||
|
["Dune", "The Hobbit", "Programming Book", "The Fellowship"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sort:owner-asc",
|
||||||
|
"",
|
||||||
|
["Dune", "The Fellowship", "The Hobbit", "Programming Book"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sort:rating-desc",
|
||||||
|
"alice",
|
||||||
|
["The Hobbit", "Dune", "Programming Book", "The Fellowship"],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_book_search_advanced_queries(
|
||||||
|
self,
|
||||||
|
app: Flask,
|
||||||
|
cli_runner: CliRunner,
|
||||||
|
query: str,
|
||||||
|
username: str,
|
||||||
|
expected_titles: list[str],
|
||||||
|
) -> None:
|
||||||
|
"""Test advanced search queries with various field filters."""
|
||||||
|
# Set up comprehensive test data
|
||||||
|
self._setup_search_test_data(app, cli_runner)
|
||||||
|
|
||||||
|
# Execute the search query
|
||||||
|
result = cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
["book", "search", "--format", "json", "--username", username, "--", query],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, f"Search query '{query}' failed: {result.output}"
|
||||||
|
|
||||||
|
# Parse results and extract titles
|
||||||
|
search_results = json.loads(result.output)
|
||||||
|
actual_titles = [book["title"] for book in search_results]
|
||||||
|
|
||||||
|
# Verify expected titles are present (order doesn't matter)
|
||||||
|
assert expected_titles == actual_titles, (
|
||||||
|
f"Query '{query}' expected {expected_titles}, got {actual_titles}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _setup_search_test_data(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Set up comprehensive test data for advanced search testing."""
|
||||||
|
# Book 1: The Hobbit - Fantasy, high rating, shelf 1, home
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"book",
|
||||||
|
"add",
|
||||||
|
"The Hobbit",
|
||||||
|
"--owner",
|
||||||
|
"alice",
|
||||||
|
"--authors",
|
||||||
|
"J.R.R. Tolkien",
|
||||||
|
"--genres",
|
||||||
|
"Fantasy,Adventure",
|
||||||
|
"--place",
|
||||||
|
"home",
|
||||||
|
"--bookshelf",
|
||||||
|
"fantasy",
|
||||||
|
"--shelf",
|
||||||
|
"1",
|
||||||
|
"--publisher",
|
||||||
|
"Allen & Unwin",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Book 2: The Fellowship - Fantasy, high rating, shelf 2, office
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"book",
|
||||||
|
"add",
|
||||||
|
"The Fellowship",
|
||||||
|
"--owner",
|
||||||
|
"alice",
|
||||||
|
"--authors",
|
||||||
|
"J.R.R. Tolkien",
|
||||||
|
"--genres",
|
||||||
|
"Fantasy,Epic",
|
||||||
|
"--place",
|
||||||
|
"office",
|
||||||
|
"--bookshelf",
|
||||||
|
"fantasy",
|
||||||
|
"--shelf",
|
||||||
|
"2",
|
||||||
|
"--publisher",
|
||||||
|
"Allen & Unwin",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Book 3: Dune - Sci-Fi, medium rating, shelf 1, office
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"book",
|
||||||
|
"add",
|
||||||
|
"Dune",
|
||||||
|
"--owner",
|
||||||
|
"alice",
|
||||||
|
"--authors",
|
||||||
|
"Frank Herbert",
|
||||||
|
"--genres",
|
||||||
|
"Science Fiction",
|
||||||
|
"--place",
|
||||||
|
"office",
|
||||||
|
"--bookshelf",
|
||||||
|
"scifi",
|
||||||
|
"--shelf",
|
||||||
|
"1",
|
||||||
|
"--publisher",
|
||||||
|
"Chilton Books",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Book 4: Programming Book - Tech, high rating, shelf 2, home, different owner
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"book",
|
||||||
|
"add",
|
||||||
|
"Programming Book",
|
||||||
|
"--owner",
|
||||||
|
"bob",
|
||||||
|
"--authors",
|
||||||
|
"Tech Author",
|
||||||
|
"--genres",
|
||||||
|
"Technology,Programming",
|
||||||
|
"--place",
|
||||||
|
"home",
|
||||||
|
"--bookshelf",
|
||||||
|
"tech",
|
||||||
|
"--shelf",
|
||||||
|
"2",
|
||||||
|
"--publisher",
|
||||||
|
"Tech Press",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add some readings and ratings to test rating filters
|
||||||
|
with app.app_context():
|
||||||
|
# Get book IDs
|
||||||
|
books = (
|
||||||
|
db.session.execute(db.select(Book).order_by(Book.id)).scalars().all()
|
||||||
|
)
|
||||||
|
hobbit_id = next(b.id for b in books if b.title == "The Hobbit")
|
||||||
|
fellowship_id = next(b.id for b in books if b.title == "The Fellowship")
|
||||||
|
dune_id = next(b.id for b in books if b.title == "Dune")
|
||||||
|
prog_id = next(b.id for b in books if b.title == "Programming Book")
|
||||||
|
|
||||||
|
# Start and finish reading sessions with ratings
|
||||||
|
cli_runner.invoke(cli, ["reading", "start", str(hobbit_id), "--owner", "alice"])
|
||||||
|
cli_runner.invoke(cli, ["reading", "start", str(dune_id), "--owner", "alice"])
|
||||||
|
cli_runner.invoke(cli, ["reading", "start", str(prog_id), "--owner", "bob"])
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli, ["reading", "start", str(fellowship_id), "--owner", "alice"]
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
# Get reading session IDs
|
||||||
|
readings = (
|
||||||
|
db.session
|
||||||
|
.execute(db.select(Reading).order_by(Reading.id))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
hobbit_reading = next(r for r in readings if r.book_id == hobbit_id)
|
||||||
|
dune_reading = next(r for r in readings if r.book_id == dune_id)
|
||||||
|
prog_reading = next(r for r in readings if r.book_id == prog_id)
|
||||||
|
|
||||||
|
# Finish with different ratings
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli, ["reading", "finish", str(hobbit_reading.id), "--rating", "5"]
|
||||||
|
)
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli, ["reading", "finish", str(dune_reading.id), "--rating", "3"]
|
||||||
|
)
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli, ["reading", "finish", str(prog_reading.id), "--rating", "4"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update one book with bought_date for date filter testing
|
||||||
|
with app.app_context():
|
||||||
|
prog_book = db.session.get(Book, prog_id)
|
||||||
|
assert prog_book is not None
|
||||||
|
prog_book.bought_date = date(2025, 12, 1) # Before 2026-01-01
|
||||||
|
prog_book.first_published = 2000
|
||||||
|
|
||||||
|
hobbit_book = db.session.get(Book, hobbit_id)
|
||||||
|
assert hobbit_book is not None
|
||||||
|
hobbit_book.first_published = 1937
|
||||||
|
|
||||||
|
fellowship_book = db.session.get(Book, fellowship_id)
|
||||||
|
assert fellowship_book is not None
|
||||||
|
fellowship_book.first_published = 1954
|
||||||
|
|
||||||
|
dune_book = db.session.get(Book, dune_id)
|
||||||
|
assert dune_book is not None
|
||||||
|
dune_book.first_published = 1965
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Add a book to wishlist
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
["wishlist", "add", str(prog_id), "--owner", "alice"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadingCommands:
|
||||||
|
"""Test reading-related CLI commands."""
|
||||||
|
|
||||||
|
def test_reading_start_basic(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test starting a reading session."""
|
||||||
|
# Add a book first
|
||||||
|
result = cli_runner.invoke(
|
||||||
|
cli, ["book", "add", "Test Book", "--owner", "alice"]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
# Extract book ID from output
|
||||||
|
|
||||||
|
book_id_match = re.search(r"ID: (\d+)", result.output)
|
||||||
|
assert book_id_match
|
||||||
|
book_id = book_id_match.group(1)
|
||||||
|
|
||||||
|
# Start reading session
|
||||||
|
result = cli_runner.invoke(
|
||||||
|
cli, ["reading", "start", book_id, "--owner", "alice"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Started reading session" in result.output
|
||||||
|
assert f"for book {book_id}" in result.output
|
||||||
|
|
||||||
|
def test_reading_finish_with_rating(
|
||||||
|
self, app: Flask, cli_runner: CliRunner
|
||||||
|
) -> None:
|
||||||
|
"""Test finishing a reading session with rating."""
|
||||||
|
# Add book and start reading
|
||||||
|
cli_runner.invoke(cli, ["book", "add", "Test Book", "--owner", "alice"])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
# Get the book ID from database
|
||||||
|
book = db.session.execute(db.select(Book)).scalar_one()
|
||||||
|
book_id = book.id
|
||||||
|
|
||||||
|
result = cli_runner.invoke(
|
||||||
|
cli, ["reading", "start", str(book_id), "--owner", "alice"]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
# Extract reading session ID
|
||||||
|
|
||||||
|
reading_id_match = re.search(r"Started reading session (\d+)", result.output)
|
||||||
|
assert reading_id_match
|
||||||
|
reading_id = reading_id_match.group(1)
|
||||||
|
|
||||||
|
# Finish reading with rating
|
||||||
|
result = cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"reading",
|
||||||
|
"finish",
|
||||||
|
reading_id,
|
||||||
|
"--rating",
|
||||||
|
"4",
|
||||||
|
"--comments",
|
||||||
|
"Great book!",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Finished reading: Test Book" in result.output
|
||||||
|
assert "Rating: 4/5" in result.output
|
||||||
|
|
||||||
|
def test_reading_drop(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test dropping a reading session."""
|
||||||
|
# Add book and start reading
|
||||||
|
cli_runner.invoke(cli, ["book", "add", "Boring Book", "--owner", "alice"])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
book = db.session.execute(db.select(Book)).scalar_one()
|
||||||
|
book_id = book.id
|
||||||
|
|
||||||
|
result = cli_runner.invoke(
|
||||||
|
cli, ["reading", "start", str(book_id), "--owner", "alice"]
|
||||||
|
)
|
||||||
|
|
||||||
|
reading_id_match = re.search(r"Started reading session (\d+)", result.output)
|
||||||
|
assert reading_id_match is not None
|
||||||
|
reading_id = reading_id_match.group(1)
|
||||||
|
|
||||||
|
# Drop the reading
|
||||||
|
result = cli_runner.invoke(
|
||||||
|
cli, ["reading", "drop", reading_id, "--comments", "Too boring"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Dropped reading: Boring Book" in result.output
|
||||||
|
|
||||||
|
def test_reading_list_current(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test listing current (unfinished) readings."""
|
||||||
|
# Add book and start reading
|
||||||
|
cli_runner.invoke(cli, ["book", "add", "Current Book", "--owner", "alice"])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
book = db.session.execute(db.select(Book)).scalar_one()
|
||||||
|
book_id = book.id
|
||||||
|
|
||||||
|
cli_runner.invoke(cli, ["reading", "start", str(book_id), "--owner", "alice"])
|
||||||
|
|
||||||
|
result = cli_runner.invoke(
|
||||||
|
cli, ["reading", "list", "--owner", "alice", "--current"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, f"CLI command failed with output: {result.output}"
|
||||||
|
assert "Current Book" in result.output
|
||||||
|
assert "Reading" in result.output
|
||||||
|
|
||||||
|
def test_reading_list_json_format(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test listing readings in JSON format."""
|
||||||
|
# Add book and start reading
|
||||||
|
cli_runner.invoke(cli, ["book", "add", "JSON Book", "--owner", "alice"])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
book = db.session.execute(db.select(Book)).scalar_one()
|
||||||
|
book_id = book.id
|
||||||
|
|
||||||
|
cli_runner.invoke(cli, ["reading", "start", str(book_id), "--owner", "alice"])
|
||||||
|
|
||||||
|
result = cli_runner.invoke(
|
||||||
|
cli, ["reading", "list", "--owner", "alice", "--format", "json"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
readings_data = json.loads(result.output)
|
||||||
|
assert len(readings_data) == 1
|
||||||
|
reading = readings_data[0]
|
||||||
|
assert reading["book_title"] == "JSON Book"
|
||||||
|
assert reading["finished"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestWishlistCommands:
|
||||||
|
"""Test wishlist-related CLI commands."""
|
||||||
|
|
||||||
|
def test_wishlist_add(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test adding a book to wishlist."""
|
||||||
|
# Add a book first
|
||||||
|
cli_runner.invoke(cli, ["book", "add", "Desired Book", "--owner", "alice"])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
book = db.session.execute(db.select(Book)).scalar_one()
|
||||||
|
book_id = book.id
|
||||||
|
|
||||||
|
result = cli_runner.invoke(
|
||||||
|
cli, ["wishlist", "add", str(book_id), "--owner", "alice"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Added 'Desired Book' to wishlist" in result.output
|
||||||
|
|
||||||
|
def test_wishlist_remove(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test removing a book from wishlist."""
|
||||||
|
# Add book and add to wishlist
|
||||||
|
cli_runner.invoke(cli, ["book", "add", "Unwanted Book", "--owner", "alice"])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
book = db.session.execute(db.select(Book)).scalar_one()
|
||||||
|
book_id = book.id
|
||||||
|
|
||||||
|
cli_runner.invoke(cli, ["wishlist", "add", str(book_id), "--owner", "alice"])
|
||||||
|
|
||||||
|
result = cli_runner.invoke(
|
||||||
|
cli, ["wishlist", "remove", str(book_id), "--owner", "alice"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert f"Removed book {book_id} from wishlist" in result.output
|
||||||
|
|
||||||
|
def test_wishlist_remove_not_in_list(
|
||||||
|
self, app: Flask, cli_runner: CliRunner
|
||||||
|
) -> None:
|
||||||
|
"""Test removing a book that's not in wishlist."""
|
||||||
|
# Add book but don't add to wishlist
|
||||||
|
cli_runner.invoke(cli, ["book", "add", "Not Wished Book", "--owner", "alice"])
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
book = db.session.execute(db.select(Book)).scalar_one()
|
||||||
|
book_id = book.id
|
||||||
|
|
||||||
|
result = cli_runner.invoke(
|
||||||
|
cli, ["wishlist", "remove", str(book_id), "--owner", "alice"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert f"Book {book_id} was not in wishlist" in result.output
|
||||||
|
|
||||||
|
def test_wishlist_list_empty(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test listing empty wishlist."""
|
||||||
|
result = cli_runner.invoke(cli, ["wishlist", "list", "--owner", "alice"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Wishlist is empty." in result.output
|
||||||
|
|
||||||
|
def test_wishlist_list_with_items(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test listing wishlist with items."""
|
||||||
|
# Add books and add to wishlist
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"book",
|
||||||
|
"add",
|
||||||
|
"Wished Book 1",
|
||||||
|
"--owner",
|
||||||
|
"alice",
|
||||||
|
"--authors",
|
||||||
|
"Author One",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"book",
|
||||||
|
"add",
|
||||||
|
"Wished Book 2",
|
||||||
|
"--owner",
|
||||||
|
"alice",
|
||||||
|
"--authors",
|
||||||
|
"Author Two",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
books = db.session.execute(db.select(Book)).scalars().all()
|
||||||
|
for book in books:
|
||||||
|
result = cli_runner.invoke(
|
||||||
|
cli, ["wishlist", "add", str(book.id), "--owner", "alice"]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
result = cli_runner.invoke(cli, ["wishlist", "list", "--owner", "alice"])
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def test_wishlist_list_json_format(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test listing wishlist in JSON format."""
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"book",
|
||||||
|
"add",
|
||||||
|
"JSON Wished Book",
|
||||||
|
"--owner",
|
||||||
|
"alice",
|
||||||
|
"--authors",
|
||||||
|
"JSON Author",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
book = db.session.execute(db.select(Book)).scalar_one()
|
||||||
|
book_id = book.id
|
||||||
|
|
||||||
|
cli_runner.invoke(cli, ["wishlist", "add", str(book_id), "--owner", "alice"])
|
||||||
|
|
||||||
|
result = cli_runner.invoke(
|
||||||
|
cli, ["wishlist", "list", "--owner", "alice", "--format", "json"]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
wishlist_data = json.loads(result.output)
|
||||||
|
assert len(wishlist_data) == 1
|
||||||
|
item = wishlist_data[0]
|
||||||
|
assert item["title"] == "JSON Wished Book"
|
||||||
|
assert item["authors"] == ["JSON Author"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDatabaseCommands:
|
||||||
|
"""Test database management CLI commands."""
|
||||||
|
|
||||||
|
def test_db_init(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test database initialization."""
|
||||||
|
result = cli_runner.invoke(cli, ["db", "init"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Database initialized." in result.output
|
||||||
|
|
||||||
|
def test_db_seed(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test database seeding with sample data."""
|
||||||
|
result = cli_runner.invoke(cli, ["db", "seed", "--owner", "test_owner"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Created: The Hobbit" in result.output
|
||||||
|
assert "Created: Dune" in result.output
|
||||||
|
assert "Created: The Pragmatic Programmer" in result.output
|
||||||
|
assert "Created 3 sample books for user 'test_owner'" in result.output
|
||||||
|
|
||||||
|
# Verify books were actually created
|
||||||
|
with app.app_context():
|
||||||
|
books = db.session.execute(db.select(Book)).scalars().all()
|
||||||
|
assert len(books) == 3
|
||||||
|
titles = {book.title for book in books}
|
||||||
|
assert "The Hobbit" in titles
|
||||||
|
assert "Dune" in titles
|
||||||
|
assert "The Pragmatic Programmer" in titles
|
||||||
|
|
||||||
|
def test_db_status_empty(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test database status with empty database."""
|
||||||
|
result = cli_runner.invoke(cli, ["db", "status"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Database Statistics:" in result.output
|
||||||
|
assert "Books: 0" in result.output
|
||||||
|
assert "Authors: 0" in result.output
|
||||||
|
assert "Genres: 0" in result.output
|
||||||
|
assert "Users: 0" in result.output
|
||||||
|
assert "Reading sessions: 0" in result.output
|
||||||
|
assert "Wishlist items: 0" in result.output
|
||||||
|
|
||||||
|
def test_db_status_with_data(self, app: Flask, cli_runner: CliRunner) -> None:
|
||||||
|
"""Test database status with sample data."""
|
||||||
|
# Add some test data
|
||||||
|
cli_runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"book",
|
||||||
|
"add",
|
||||||
|
"Status Book",
|
||||||
|
"--owner",
|
||||||
|
"alice",
|
||||||
|
"--authors",
|
||||||
|
"Status Author",
|
||||||
|
"--genres",
|
||||||
|
"Status Genre",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
book = db.session.execute(db.select(Book)).scalar_one()
|
||||||
|
book_id = book.id
|
||||||
|
|
||||||
|
# Add reading and wishlist entries
|
||||||
|
cli_runner.invoke(cli, ["reading", "start", str(book_id), "--owner", "alice"])
|
||||||
|
cli_runner.invoke(cli, ["wishlist", "add", str(book_id), "--owner", "bob"])
|
||||||
|
|
||||||
|
result = cli_runner.invoke(cli, ["db", "status"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Books: 1" in result.output
|
||||||
|
assert "Authors: 1" in result.output
|
||||||
|
assert "Genres: 1" in result.output
|
||||||
|
assert "Users: 2" in result.output # alice and bob
|
||||||
|
assert "Reading sessions: 1" in result.output
|
||||||
|
assert "Wishlist items: 1" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorScenarios:
|
||||||
|
"""Test error handling and edge cases."""
|
||||||
|
|
||||||
|
def test_reading_start_invalid_book_id(
|
||||||
|
self, app: Flask, cli_runner: CliRunner
|
||||||
|
) -> None:
|
||||||
|
"""Test starting reading with non-existent book ID."""
|
||||||
|
result = cli_runner.invoke(cli, ["reading", "start", "999", "--owner", "alice"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Error starting reading:" in result.output
|
||||||
|
|
||||||
|
def test_wishlist_add_invalid_book_id(
|
||||||
|
self, app: Flask, cli_runner: CliRunner
|
||||||
|
) -> None:
|
||||||
|
"""Test adding non-existent book to wishlist."""
|
||||||
|
result = cli_runner.invoke(cli, ["wishlist", "add", "999", "--owner", "alice"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Error adding to wishlist:" in result.output
|
||||||
|
|
||||||
|
def test_reading_finish_invalid_reading_id(
|
||||||
|
self, app: Flask, cli_runner: CliRunner
|
||||||
|
) -> None:
|
||||||
|
"""Test finishing non-existent reading session."""
|
||||||
|
result = cli_runner.invoke(cli, ["reading", "finish", "999"])
|
||||||
|
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Error finishing reading:" in result.output
|
||||||
449
tests/test_search.py
Normal file
449
tests/test_search.py
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
"""
|
||||||
|
Query parser tests for HXBooks search functionality.
|
||||||
|
|
||||||
|
Tests the QueryParser class methods for type conversion, operator parsing,
|
||||||
|
field filters, and edge case handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from hxbooks.search import (
|
||||||
|
ComparisonOperator,
|
||||||
|
Field,
|
||||||
|
IsOperatorValue,
|
||||||
|
QueryParser,
|
||||||
|
SearchQuery,
|
||||||
|
SortDirection,
|
||||||
|
_convert_value, # noqa: PLC2701
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def parser() -> QueryParser:
|
||||||
|
"""Create a QueryParser instance for testing."""
|
||||||
|
return QueryParser()
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueryParser:
|
||||||
|
"""Test the QueryParser class functionality."""
|
||||||
|
|
||||||
|
def test_parse_empty_query(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing an empty query string."""
|
||||||
|
result = parser.parse("")
|
||||||
|
assert result.text_terms == []
|
||||||
|
assert result.field_filters == []
|
||||||
|
|
||||||
|
def test_parse_whitespace_only(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing a query with only whitespace."""
|
||||||
|
result = parser.parse(" \t\n ")
|
||||||
|
assert result.text_terms == []
|
||||||
|
assert result.field_filters == []
|
||||||
|
|
||||||
|
def test_parse_simple_text_terms(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing simple text search terms."""
|
||||||
|
result = parser.parse("hobbit tolkien")
|
||||||
|
assert result.text_terms == ["hobbit", "tolkien"]
|
||||||
|
assert result.field_filters == []
|
||||||
|
|
||||||
|
def test_parse_quoted_text_terms(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing quoted text search terms."""
|
||||||
|
result = parser.parse('"the hobbit" tolkien')
|
||||||
|
assert result.text_terms == ["the hobbit", "tolkien"]
|
||||||
|
assert result.field_filters == []
|
||||||
|
|
||||||
|
def test_parse_quoted_text_with_spaces(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing quoted text containing multiple spaces."""
|
||||||
|
result = parser.parse('"lord of the rings"')
|
||||||
|
assert result.text_terms == ["lord of the rings"]
|
||||||
|
assert result.field_filters == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestFieldFilters:
|
||||||
|
"""Test field filter parsing."""
|
||||||
|
|
||||||
|
def test_parse_title_filter(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing title field filter."""
|
||||||
|
result = parser.parse("title:hobbit")
|
||||||
|
assert len(result.field_filters) == 1
|
||||||
|
filter = result.field_filters[0]
|
||||||
|
assert filter.field == Field.TITLE
|
||||||
|
assert filter.operator == ComparisonOperator.EQUALS
|
||||||
|
assert filter.value == "hobbit"
|
||||||
|
assert filter.negated is False
|
||||||
|
|
||||||
|
def test_parse_quoted_title_filter(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing quoted title field filter."""
|
||||||
|
result = parser.parse('title:"the hobbit"')
|
||||||
|
assert len(result.field_filters) == 1
|
||||||
|
filter = result.field_filters[0]
|
||||||
|
assert filter.field == Field.TITLE
|
||||||
|
assert filter.value == "the hobbit"
|
||||||
|
|
||||||
|
def test_parse_author_filter(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing author field filter."""
|
||||||
|
result = parser.parse("author:tolkien")
|
||||||
|
assert len(result.field_filters) == 1
|
||||||
|
filter = result.field_filters[0]
|
||||||
|
assert filter.field == Field.AUTHOR
|
||||||
|
assert filter.value == "tolkien"
|
||||||
|
|
||||||
|
def test_parse_is_filter(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing 'is' operator field filter."""
|
||||||
|
result = parser.parse("is:reading")
|
||||||
|
assert len(result.field_filters) == 1
|
||||||
|
filter = result.field_filters[0]
|
||||||
|
assert filter.field == Field.IS
|
||||||
|
assert filter.value == IsOperatorValue.READING
|
||||||
|
|
||||||
|
def test_parse_negated_filter(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing negated field filter."""
|
||||||
|
result = parser.parse("-genre:romance")
|
||||||
|
assert len(result.field_filters) == 1
|
||||||
|
filter = result.field_filters[0]
|
||||||
|
assert filter.field == Field.GENRE
|
||||||
|
assert filter.value == "romance"
|
||||||
|
assert filter.negated is True
|
||||||
|
|
||||||
|
def test_parse_multiple_filters(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing multiple field filters."""
|
||||||
|
result = parser.parse("author:tolkien genre:fantasy")
|
||||||
|
assert len(result.field_filters) == 2
|
||||||
|
|
||||||
|
author_filter = next(f for f in result.field_filters if f.field == Field.AUTHOR)
|
||||||
|
assert author_filter.value == "tolkien"
|
||||||
|
|
||||||
|
genre_filter = next(f for f in result.field_filters if f.field == Field.GENRE)
|
||||||
|
assert genre_filter.value == "fantasy"
|
||||||
|
|
||||||
|
def test_parse_mixed_filters_and_text(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing mix of field filters and text terms."""
|
||||||
|
result = parser.parse('epic author:tolkien "middle earth"')
|
||||||
|
assert "epic" in result.text_terms
|
||||||
|
assert "middle earth" in result.text_terms
|
||||||
|
assert len(result.field_filters) == 1
|
||||||
|
assert result.field_filters[0].field == Field.AUTHOR
|
||||||
|
|
||||||
|
|
||||||
|
class TestComparisonOperators:
|
||||||
|
"""Test comparison operator parsing."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"operator_str,expected_operator",
|
||||||
|
[
|
||||||
|
(">=", ComparisonOperator.GREATER_EQUAL),
|
||||||
|
("<=", ComparisonOperator.LESS_EQUAL),
|
||||||
|
(">", ComparisonOperator.GREATER),
|
||||||
|
("<", ComparisonOperator.LESS),
|
||||||
|
("=", ComparisonOperator.EQUALS),
|
||||||
|
("!=", ComparisonOperator.NOT_EQUALS),
|
||||||
|
(":", ComparisonOperator.EQUALS), # : defaults to equals
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_comparison_operators(
|
||||||
|
self,
|
||||||
|
parser: QueryParser,
|
||||||
|
operator_str: str,
|
||||||
|
expected_operator: ComparisonOperator,
|
||||||
|
) -> None:
|
||||||
|
"""Test parsing all supported comparison operators."""
|
||||||
|
query = f"rating{operator_str}4"
|
||||||
|
result = parser.parse(query)
|
||||||
|
|
||||||
|
assert len(result.field_filters) == 1
|
||||||
|
filter = result.field_filters[0]
|
||||||
|
assert filter.field == Field.RATING
|
||||||
|
assert filter.operator == expected_operator
|
||||||
|
assert filter.value == 4
|
||||||
|
|
||||||
|
def test_parse_date_comparison(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing date comparison operators."""
|
||||||
|
result = parser.parse("added>=2026-03-15")
|
||||||
|
assert len(result.field_filters) == 1
|
||||||
|
filter = result.field_filters[0]
|
||||||
|
assert filter.field == Field.ADDED_DATE
|
||||||
|
assert filter.operator == ComparisonOperator.GREATER_EQUAL
|
||||||
|
assert filter.value == date(2026, 3, 15)
|
||||||
|
|
||||||
|
def test_parse_numeric_comparison(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing numeric comparison operators."""
|
||||||
|
result = parser.parse("shelf>2")
|
||||||
|
assert len(result.field_filters) == 1
|
||||||
|
filter = result.field_filters[0]
|
||||||
|
assert filter.field == Field.SHELF
|
||||||
|
assert filter.operator == ComparisonOperator.GREATER
|
||||||
|
assert filter.value == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestTypeConversion:
|
||||||
|
"""Test the _convert_value method for different field types."""
|
||||||
|
|
||||||
|
def test_convert_date_field_valid(self, parser: QueryParser) -> None:
|
||||||
|
"""Test converting valid date strings for date fields."""
|
||||||
|
result = _convert_value(Field.BOUGHT_DATE, "2026-03-15")
|
||||||
|
assert result == date(2026, 3, 15)
|
||||||
|
|
||||||
|
result = _convert_value(Field.READ_DATE, "2025-12-31")
|
||||||
|
assert result == date(2025, 12, 31)
|
||||||
|
|
||||||
|
result = _convert_value(Field.ADDED_DATE, "2024-01-01")
|
||||||
|
assert result == date(2024, 1, 1)
|
||||||
|
|
||||||
|
def test_convert_date_field_invalid(self, parser: QueryParser) -> None:
|
||||||
|
"""Test converting invalid date strings falls back to string."""
|
||||||
|
result = _convert_value(Field.BOUGHT_DATE, "invalid-date")
|
||||||
|
assert result == "invalid-date"
|
||||||
|
|
||||||
|
result = _convert_value(Field.READ_DATE, "2026-13-45") # Invalid month/day
|
||||||
|
assert result == "2026-13-45"
|
||||||
|
|
||||||
|
result = _convert_value(Field.ADDED_DATE, "not-a-date")
|
||||||
|
assert result == "not-a-date"
|
||||||
|
|
||||||
|
def test_convert_numeric_field_integers(self, parser: QueryParser) -> None:
|
||||||
|
"""Test converting integer strings for numeric fields."""
|
||||||
|
result = _convert_value(Field.RATING, "5")
|
||||||
|
assert result == 5
|
||||||
|
assert isinstance(result, int)
|
||||||
|
|
||||||
|
result = _convert_value(Field.SHELF, "10")
|
||||||
|
assert result == 10
|
||||||
|
|
||||||
|
result = _convert_value(Field.YEAR, "2026")
|
||||||
|
assert result == 2026
|
||||||
|
|
||||||
|
def test_convert_numeric_field_floats(self, parser: QueryParser) -> None:
|
||||||
|
"""Test converting float strings for numeric fields."""
|
||||||
|
result = _convert_value(Field.RATING, "4.5")
|
||||||
|
assert result == pytest.approx(4.5)
|
||||||
|
assert isinstance(result, float)
|
||||||
|
|
||||||
|
result = _convert_value(Field.SHELF, "2.0")
|
||||||
|
assert result == pytest.approx(2.0)
|
||||||
|
|
||||||
|
def test_convert_numeric_field_invalid(self, parser: QueryParser) -> None:
|
||||||
|
"""Test converting invalid numeric strings falls back to string."""
|
||||||
|
result = _convert_value(Field.RATING, "not-a-number")
|
||||||
|
assert result == "not-a-number"
|
||||||
|
|
||||||
|
result = _convert_value(Field.SHELF, "abc")
|
||||||
|
assert result == "abc"
|
||||||
|
|
||||||
|
result = _convert_value(Field.YEAR, "twenty-twenty-six")
|
||||||
|
assert result == "twenty-twenty-six"
|
||||||
|
|
||||||
|
def test_convert_string_fields(self, parser: QueryParser) -> None:
|
||||||
|
"""Test converting values for string fields returns as-is."""
|
||||||
|
result = _convert_value(Field.TITLE, "The Hobbit")
|
||||||
|
assert result == "The Hobbit"
|
||||||
|
|
||||||
|
result = _convert_value(Field.AUTHOR, "Tolkien")
|
||||||
|
assert result == "Tolkien"
|
||||||
|
|
||||||
|
result = _convert_value(Field.GENRE, "Fantasy")
|
||||||
|
assert result == "Fantasy"
|
||||||
|
|
||||||
|
# Even things that look like dates/numbers should stay as strings for string fields
|
||||||
|
result = _convert_value(Field.TITLE, "2026-03-15")
|
||||||
|
assert result == "2026-03-15"
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
result = _convert_value(Field.AUTHOR, "123")
|
||||||
|
assert result == "123"
|
||||||
|
assert isinstance(result, str)
|
||||||
|
|
||||||
|
def test_convert_is_operator(self, parser: QueryParser) -> None:
|
||||||
|
"""Test converting values for 'is' operator fields."""
|
||||||
|
result = _convert_value(Field.IS, "reading")
|
||||||
|
assert result == IsOperatorValue.READING
|
||||||
|
|
||||||
|
result = _convert_value(Field.IS, "dropped")
|
||||||
|
assert result == IsOperatorValue.DROPPED
|
||||||
|
|
||||||
|
result = _convert_value(Field.IS, "wished")
|
||||||
|
assert result == IsOperatorValue.WISHED
|
||||||
|
|
||||||
|
# Invalid value should return UNKNOWN
|
||||||
|
result = _convert_value(Field.IS, "invalid-status")
|
||||||
|
assert result == IsOperatorValue.UNKNOWN
|
||||||
|
|
||||||
|
def test_convert_sort_field(self, parser: QueryParser) -> None:
|
||||||
|
"""Test converting values for 'sort' field."""
|
||||||
|
result = _convert_value(Field.SORT, "added")
|
||||||
|
assert result == (Field.ADDED_DATE, SortDirection.ASC)
|
||||||
|
|
||||||
|
result = _convert_value(Field.SORT, "added-desc")
|
||||||
|
assert result == (Field.ADDED_DATE, SortDirection.DESC)
|
||||||
|
|
||||||
|
# Invalid field or direction should fallback to a default value
|
||||||
|
result = _convert_value(Field.SORT, "added-invalid")
|
||||||
|
assert result == (Field.SORT, SortDirection.ASC)
|
||||||
|
|
||||||
|
result = _convert_value(Field.SORT, "invalid-asc")
|
||||||
|
assert result == (Field.SORT, SortDirection.ASC)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParsingEdgeCases:
|
||||||
|
"""Test edge cases and error handling in query parsing."""
|
||||||
|
|
||||||
|
def test_parse_invalid_field_name(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing with invalid field names falls back to text search."""
|
||||||
|
result = parser.parse("invalid_field:value")
|
||||||
|
# Should fall back to treating the whole thing as text
|
||||||
|
assert len(result.text_terms) >= 1 or len(result.field_filters) == 0
|
||||||
|
|
||||||
|
def test_parse_mixed_quotes_and_operators(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing complex queries with quotes and operators."""
|
||||||
|
result = parser.parse('title:"The Lord" author:tolkien rating>=4')
|
||||||
|
|
||||||
|
# Should have both field filters
|
||||||
|
title_filter = next(
|
||||||
|
(f for f in result.field_filters if f.field == Field.TITLE), None
|
||||||
|
)
|
||||||
|
author_filter = next(
|
||||||
|
(f for f in result.field_filters if f.field == Field.AUTHOR), None
|
||||||
|
)
|
||||||
|
rating_filter = next(
|
||||||
|
(f for f in result.field_filters if f.field == Field.RATING), None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert title_filter is not None
|
||||||
|
assert title_filter.value == "The Lord"
|
||||||
|
|
||||||
|
assert author_filter is not None
|
||||||
|
assert author_filter.value == "tolkien"
|
||||||
|
|
||||||
|
assert rating_filter is not None
|
||||||
|
assert rating_filter.value == 4
|
||||||
|
assert rating_filter.operator == ComparisonOperator.GREATER_EQUAL
|
||||||
|
|
||||||
|
def test_parse_escaped_quotes(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing strings with escaped quotes."""
|
||||||
|
result = parser.parse(r'title:"She said \"hello\""')
|
||||||
|
if result.field_filters:
|
||||||
|
# If parsing succeeds, check the escaped quote handling
|
||||||
|
filter = result.field_filters[0]
|
||||||
|
assert isinstance(filter.value, str)
|
||||||
|
assert "hello" in filter.value
|
||||||
|
# If parsing fails, it should fall back gracefully
|
||||||
|
|
||||||
|
def test_parse_special_characters(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing queries with special characters."""
|
||||||
|
result = parser.parse("title:C++ author:Stroustrup")
|
||||||
|
# Should handle the + characters gracefully
|
||||||
|
assert len(result.field_filters) >= 1 or len(result.text_terms) >= 1
|
||||||
|
|
||||||
|
def test_parse_very_long_query(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing very long query strings."""
|
||||||
|
long_value = "a" * 1000
|
||||||
|
result = parser.parse(f"title:{long_value}")
|
||||||
|
# Should handle long strings without crashing
|
||||||
|
assert isinstance(result, SearchQuery)
|
||||||
|
|
||||||
|
def test_parse_unicode_characters(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing queries with unicode characters."""
|
||||||
|
result = parser.parse("title:Café author:José")
|
||||||
|
# Should handle unicode gracefully
|
||||||
|
assert isinstance(result, SearchQuery)
|
||||||
|
|
||||||
|
def test_fallback_behavior_on_parse_error(self, parser: QueryParser) -> None:
|
||||||
|
"""Test that invalid syntax falls back to text search."""
|
||||||
|
# Construct a query that should cause parse errors
|
||||||
|
invalid_queries = [
|
||||||
|
"(((", # Unmatched parentheses
|
||||||
|
"field::", # Double colon
|
||||||
|
":", # Just a colon
|
||||||
|
">=<=", # Invalid operator combination
|
||||||
|
]
|
||||||
|
|
||||||
|
for query in invalid_queries:
|
||||||
|
result = parser.parse(query)
|
||||||
|
# Should not crash and should return some kind of result
|
||||||
|
assert isinstance(result, SearchQuery)
|
||||||
|
# Most likely falls back to text terms
|
||||||
|
assert len(result.text_terms) >= 1 or len(result.field_filters) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestComplexQueries:
|
||||||
|
"""Test parsing of complex, real-world query examples."""
|
||||||
|
|
||||||
|
def test_parse_realistic_book_search(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing realistic book search queries."""
|
||||||
|
result = parser.parse(
|
||||||
|
'author:tolkien genre:fantasy -genre:romance rating>=4 "middle earth"'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should have multiple field filters and text terms
|
||||||
|
assert len(result.field_filters) >= 3
|
||||||
|
assert "middle earth" in result.text_terms
|
||||||
|
|
||||||
|
# Check specific filters
|
||||||
|
tolkien_filter = next(
|
||||||
|
(f for f in result.field_filters if f.field == Field.AUTHOR), None
|
||||||
|
)
|
||||||
|
assert tolkien_filter is not None
|
||||||
|
assert tolkien_filter.value == "tolkien"
|
||||||
|
|
||||||
|
fantasy_filter = next(
|
||||||
|
(
|
||||||
|
f
|
||||||
|
for f in result.field_filters
|
||||||
|
if f.field == Field.GENRE and not f.negated
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert fantasy_filter is not None
|
||||||
|
assert fantasy_filter.value == "fantasy"
|
||||||
|
|
||||||
|
romance_filter = next(
|
||||||
|
(f for f in result.field_filters if f.field == Field.GENRE and f.negated),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert romance_filter is not None
|
||||||
|
assert romance_filter.value == "romance"
|
||||||
|
assert romance_filter.negated is True
|
||||||
|
|
||||||
|
def test_parse_location_and_date_filters(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing location and date-based queries."""
|
||||||
|
result = parser.parse("place:home bookshelf:fantasy shelf>=2 added>=2026-01-01")
|
||||||
|
|
||||||
|
assert len(result.field_filters) == 4
|
||||||
|
|
||||||
|
place_filter = next(
|
||||||
|
(f for f in result.field_filters if f.field == Field.PLACE), None
|
||||||
|
)
|
||||||
|
assert place_filter is not None
|
||||||
|
assert place_filter.value == "home"
|
||||||
|
|
||||||
|
shelf_filter = next(
|
||||||
|
(f for f in result.field_filters if f.field == Field.SHELF), None
|
||||||
|
)
|
||||||
|
assert shelf_filter is not None
|
||||||
|
assert shelf_filter.value == 2
|
||||||
|
assert shelf_filter.operator == ComparisonOperator.GREATER_EQUAL
|
||||||
|
|
||||||
|
added_filter = next(
|
||||||
|
(f for f in result.field_filters if f.field == Field.ADDED_DATE), None
|
||||||
|
)
|
||||||
|
assert added_filter is not None
|
||||||
|
assert added_filter.value == date(2026, 1, 1)
|
||||||
|
assert added_filter.operator == ComparisonOperator.GREATER_EQUAL
|
||||||
|
|
||||||
|
def test_parse_mixed_types_comprehensive(self, parser: QueryParser) -> None:
|
||||||
|
"""Test parsing query with all major field types."""
|
||||||
|
query = 'title:"Complex Book" author:Author year=2020 rating>=4 bought<=2025-12-31 -genre:boring epic adventure'
|
||||||
|
result = parser.parse(query)
|
||||||
|
|
||||||
|
# Should have a good mix of field filters and text terms
|
||||||
|
assert len(result.field_filters) >= 5
|
||||||
|
assert len(result.text_terms) >= 2
|
||||||
|
|
||||||
|
# Verify we got the expected mix of string, numeric, and date fields
|
||||||
|
field_types = {f.field for f in result.field_filters}
|
||||||
|
assert Field.TITLE in field_types
|
||||||
|
assert Field.AUTHOR in field_types
|
||||||
|
assert Field.YEAR in field_types
|
||||||
|
assert Field.RATING in field_types
|
||||||
|
assert Field.BOUGHT_DATE in field_types
|
||||||
|
assert Field.GENRE in field_types
|
||||||
733
uv.lock
generated
Normal file
733
uv.lock
generated
Normal file
@@ -0,0 +1,733 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alembic"
|
||||||
|
version = "1.18.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mako" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blinker"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.2.25"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfgv"
|
||||||
|
version = "3.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "distlib"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filelock"
|
||||||
|
version = "3.25.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask"
|
||||||
|
version = "3.1.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "blinker" },
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "itsdangerous" },
|
||||||
|
{ name = "jinja2" },
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
{ name = "werkzeug" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask-htmx"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "flask" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4a/b7/1ba8b722ccc12b72b44af949f438a85111ba8db9e39f973dff4a47da068e/flask_htmx-0.4.0.tar.gz", hash = "sha256:2d367fb27c8da99d031a0c566b7e562637139722e2d4e8ec67c7f941addb22fd", size = 5815, upload-time = "2024-09-22T04:14:20.006Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/8e/7e75c2210567ba11df9ea7d031eb5b8f45e82f6112cc8be885cb0ce86c7d/flask_htmx-0.4.0-py3-none-any.whl", hash = "sha256:ac0ef976638bc635537a47c4ae622c91aef1e69d8bf52880aa9ae0db089ce7d2", size = 6773, upload-time = "2024-09-22T04:14:18.41Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask-migrate"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "alembic" },
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "flask-sqlalchemy" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/47c7b3c93855ceffc2eabfa271782332942443321a07de193e4198f920cf/flask_migrate-4.1.0.tar.gz", hash = "sha256:1a336b06eb2c3ace005f5f2ded8641d534c18798d64061f6ff11f79e1434126d", size = 21965, upload-time = "2025-01-10T18:51:11.848Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/c4/3f329b23d769fe7628a5fc57ad36956f1fb7132cf8837be6da762b197327/Flask_Migrate-4.1.0-py3-none-any.whl", hash = "sha256:24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d", size = 21237, upload-time = "2025-01-10T18:51:09.527Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask-sqlalchemy"
|
||||||
|
version = "3.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899, upload-time = "2023-09-11T21:42:36.147Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125, upload-time = "2023-09-11T21:42:34.514Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "greenlet"
|
||||||
|
version = "3.3.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gunicorn"
|
||||||
|
version = "25.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "packaging" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hxbooks"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "alembic" },
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "flask-htmx" },
|
||||||
|
{ name = "flask-migrate" },
|
||||||
|
{ name = "flask-sqlalchemy" },
|
||||||
|
{ name = "gunicorn" },
|
||||||
|
{ name = "jinja2-fragments" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "pydantic-extra-types" },
|
||||||
|
{ name = "pyparsing" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "livereload" },
|
||||||
|
{ name = "pre-commit" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
{ name = "ty" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "alembic", specifier = ">=1.13.0" },
|
||||||
|
{ name = "click", specifier = ">=8.3.1" },
|
||||||
|
{ name = "flask", specifier = ">=3.1.3" },
|
||||||
|
{ name = "flask-htmx", specifier = ">=0.4.0" },
|
||||||
|
{ name = "flask-migrate", specifier = ">=4.0.0" },
|
||||||
|
{ name = "flask-sqlalchemy", specifier = ">=3.1.1" },
|
||||||
|
{ 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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "livereload", specifier = ">=2.7.1" },
|
||||||
|
{ name = "pre-commit", specifier = ">=4.5.1" },
|
||||||
|
{ name = "pytest", specifier = ">=9.0.2" },
|
||||||
|
{ name = "ruff", specifier = ">=0.15.6" },
|
||||||
|
{ name = "ty", specifier = ">=0.0.23" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "identify"
|
||||||
|
version = "2.6.18"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.11"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itsdangerous"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jinja2"
|
||||||
|
version = "3.1.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jinja2-fragments"
|
||||||
|
version = "1.11.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "jinja2" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d0/06/51681ecdfe06a51c458da481f353bfc9325d56491fec2be138b63e93e2bb/jinja2_fragments-1.11.0.tar.gz", hash = "sha256:240eabb7faaa379110cf8e43acb81fb8731fd6ae39c7a1ae232e4421c4804248", size = 20980, upload-time = "2025-11-20T21:39:48.503Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/4d/b65f80e4aca3a630105f48192dac6ed16699e6d53197899840da2d67c3a5/jinja2_fragments-1.11.0-py3-none-any.whl", hash = "sha256:3b37105d565b96129e2e34df040d1b7bb71c8a76014f7b5e1aa914ccf3f9256c", size = 15999, upload-time = "2025-11-20T21:39:47.516Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "livereload"
|
||||||
|
version = "2.7.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "tornado" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/6e/f2748665839812a9bbe5c75d3f983edbf3ab05fa5cd2f7c2f36fffdf65bd/livereload-2.7.1.tar.gz", hash = "sha256:3d9bf7c05673df06e32bea23b494b8d36ca6d10f7d5c3c8a6989608c09c986a9", size = 22255, upload-time = "2024-12-18T13:42:01.461Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/3e/de54dc7f199e85e6ca37e2e5dae2ec3bce2151e9e28f8eb9076d71e83d56/livereload-2.7.1-py3-none-any.whl", hash = "sha256:5201740078c1b9433f4b2ba22cd2729a39b9d0ec0a2cc6b4d3df257df5ad0564", size = 22657, upload-time = "2024-12-18T13:41:56.35Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mako"
|
||||||
|
version = "1.3.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markupsafe"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nodeenv"
|
||||||
|
version = "1.10.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "platformdirs"
|
||||||
|
version = "4.9.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pre-commit"
|
||||||
|
version = "4.5.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cfgv" },
|
||||||
|
{ name = "identify" },
|
||||||
|
{ name = "nodeenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "virtualenv" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic"
|
||||||
|
version = "2.12.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.41.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
||||||
|
{ 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"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyparsing"
|
||||||
|
version = "3.3.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-discovery"
|
||||||
|
version = "1.1.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "filelock" },
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/7e/9f3b0dd3a074a6c3e1e79f35e465b1f2ee4b262d619de00cfce523cc9b24/python_discovery-1.1.3.tar.gz", hash = "sha256:7acca36e818cd88e9b2ba03e045ad7e93e1713e29c6bbfba5d90202310b7baa5", size = 56945, upload-time = "2026-03-10T15:08:15.038Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e", size = 31485, upload-time = "2026-03-10T15:08:13.06Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.32.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.15.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlalchemy"
|
||||||
|
version = "2.0.48"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tornado"
|
||||||
|
version = "6.5.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ty"
|
||||||
|
version = "0.0.23"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/75/ba/d3c998ff4cf6b5d75b39356db55fe1b7caceecc522b9586174e6a5dee6f7/ty-0.0.23.tar.gz", hash = "sha256:5fb05db58f202af366f80ef70f806e48f5237807fe424ec787c9f289e3f3a4ef", size = 5341461, upload-time = "2026-03-13T12:34:23.125Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/21/aab32603dfdfacd4819e52fa8c6074e7bd578218a5142729452fc6a62db6/ty-0.0.23-py3-none-linux_armv6l.whl", hash = "sha256:e810eef1a5f1cfc0731a58af8d2f334906a96835829767aed00026f1334a8dd7", size = 10329096, upload-time = "2026-03-13T12:34:09.432Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/a9/dd3287a82dce3df546ec560296208d4905dcf06346b6e18c2f3c63523bd1/ty-0.0.23-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e43d36bd89a151ddcad01acaeff7dcc507cb73ff164c1878d2d11549d39a061c", size = 10156631, upload-time = "2026-03-13T12:34:53.122Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/01/3f25909b02fac29bb0a62b2251f8d62e65d697781ffa4cf6b47a4c075c85/ty-0.0.23-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bd6a340969577b4645f231572c4e46012acba2d10d4c0c6570fe1ab74e76ae00", size = 9653211, upload-time = "2026-03-13T12:34:15.049Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/60/bfc0479572a6f4b90501c869635faf8d84c8c68ffc5dd87d04f049affabc/ty-0.0.23-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:341441783e626eeb7b1ec2160432956aed5734932ab2d1c26f94d0c98b229937", size = 10156143, upload-time = "2026-03-13T12:34:34.468Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/81/8a93e923535a340f54bea20ff196f6b2787782b2f2f399bd191c4bc132d6/ty-0.0.23-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ce1dc66c26d4167e2c78d12fa870ef5a7ec9cc344d2baaa6243297cfa88bd52", size = 10136632, upload-time = "2026-03-13T12:34:28.832Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/cb/2ac81c850c58acc9f976814404d28389c9c1c939676e32287b9cff61381e/ty-0.0.23-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bae1e7a294bf8528836f7617dc5c360ea2dddb63789fc9471ae6753534adca05", size = 10655025, upload-time = "2026-03-13T12:34:37.105Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/9b/bac771774c198c318ae699fc013d8cd99ed9caf993f661fba11238759244/ty-0.0.23-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b162768764d9dc177c83fb497a51532bb67cbebe57b8fa0f2668436bf53f3c", size = 11230107, upload-time = "2026-03-13T12:34:20.751Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/09/7644fb0e297265e18243f878aca343593323b9bb19ed5278dcbc63781be0/ty-0.0.23-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d28384e48ca03b34e4e2beee0e230c39bbfb68994bb44927fec61ef3642900da", size = 10934177, upload-time = "2026-03-13T12:34:17.904Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/14/69a25a0cad493fb6a947302471b579a03516a3b00e7bece77fdc6b4afb9b/ty-0.0.23-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:559d9a299df793cb7a7902caed5eda8a720ff69164c31c979673e928f02251ee", size = 10752487, upload-time = "2026-03-13T12:34:31.785Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/2a/42fc3cbccf95af0a62308ebed67e084798ab7a85ef073c9986ef18032743/ty-0.0.23-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:32a7b8a14a98e1d20a9d8d2af23637ed7efdb297ac1fa2450b8e465d05b94482", size = 10133007, upload-time = "2026-03-13T12:34:42.838Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/69/307833f1b52fa3670e0a1d496e43ef7df556ecde838192d3fcb9b35e360d/ty-0.0.23-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6f803b9b9cca87af793467973b9abdd4b83e6b96d9b5e749d662cff7ead70b6d", size = 10169698, upload-time = "2026-03-13T12:34:12.351Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/ae/5dd379ec22d0b1cba410d7af31c366fcedff191d5b867145913a64889f66/ty-0.0.23-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4a0bf086ec8e2197b7ea7ebfcf4be36cb6a52b235f8be61647ef1b2d99d6ffd3", size = 10346080, upload-time = "2026-03-13T12:34:40.012Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/c7/dfc83203d37998620bba9c4873a080c8850a784a8a46f56f8163c5b4e320/ty-0.0.23-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:252539c3fcd7aeb9b8d5c14e2040682c3e1d7ff640906d63fd2c4ce35865a4ba", size = 10848162, upload-time = "2026-03-13T12:34:45.421Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/08/05481511cfbcc1fd834b6c67aaae090cb609a079189ddf2032139ccfc490/ty-0.0.23-py3-none-win32.whl", hash = "sha256:51b591d19eef23bbc3807aef77d38fa1f003c354e1da908aa80ea2dca0993f77", size = 9748283, upload-time = "2026-03-13T12:34:50.607Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/2e/eaed4ff5c85e857a02415084c394e02c30476b65e158eec1938fdaa9a205/ty-0.0.23-py3-none-win_amd64.whl", hash = "sha256:1e137e955f05c501cfbb81dd2190c8fb7d01ec037c7e287024129c722a83c9ad", size = 10698355, upload-time = "2026-03-13T12:34:26.134Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/29/b32cb7b4c7d56b9ed50117f8ad6e45834aec293e4cb14749daab4e9236d5/ty-0.0.23-py3-none-win_arm64.whl", hash = "sha256:a0399bd13fd2cd6683fd0a2d59b9355155d46546d8203e152c556ddbdeb20842", size = 10155890, upload-time = "2026-03-13T12:34:48.082Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.6.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "virtualenv"
|
||||||
|
version = "21.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "distlib" },
|
||||||
|
{ name = "filelock" },
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
{ name = "python-discovery" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "werkzeug"
|
||||||
|
version = "3.1.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user