Rework data model and add migrations
This commit is contained in:
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/
|
||||
*.sqlite
|
||||
213
docs/development-plan.md
Normal file
213
docs/development-plan.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# 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** (In Progress)
|
||||
4. **Make sure search and other basic functionality is good and can be accessed through CLI**
|
||||
5. **Set up automated tests**
|
||||
6. **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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚧 IN PROGRESS: CLI Development (Phase 3)
|
||||
|
||||
### CLI Requirements for Manual Testing
|
||||
- [ ] Book CRUD operations (add, edit, delete, list)
|
||||
- [ ] Author/Genre management (auto-create, list)
|
||||
- [ ] Location management (place, bookshelf, shelf)
|
||||
- [ ] Reading tracking (start, finish, rate)
|
||||
- [ ] Search functionality testing
|
||||
- [ ] Data import from old format
|
||||
- [ ] Loaning operations
|
||||
|
||||
### CLI Commands Planned
|
||||
```bash
|
||||
hx book add "Title" --authors "Author1,Author2" --genres "Fiction"
|
||||
hx book list --location "my house" --shelf 2
|
||||
hx book search "keyword"
|
||||
hx reading start <book_id>
|
||||
hx reading finish <book_id> --rating 4
|
||||
hx loan <book_id> --to "Alice"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 TODO: Remaining Phases
|
||||
|
||||
### Phase 4: Search & Core Features
|
||||
- [ ] Implement proper FTS with new schema
|
||||
- [ ] Add faceted search (by author, genre, location)
|
||||
- [ ] Create search result serializers
|
||||
- [ ] Add pagination
|
||||
- [ ] Optimize query performance with proper indexes
|
||||
|
||||
### Phase 5: Testing Framework
|
||||
- [ ] Set up pytest with database fixtures
|
||||
- [ ] API endpoint tests
|
||||
- [ ] Search functionality tests
|
||||
- [ ] CLI command tests
|
||||
- [ ] Migration tests
|
||||
|
||||
### 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 (Phase 6+)
|
||||
- **Configuration management**: No environment handling (Phase 6+)
|
||||
- **Mobile UX**: Tables don't work on mobile (Phase 6)
|
||||
- **Testing infrastructure**: No framework yet (Phase 5)
|
||||
- **Error handling**: No proper boundaries (Phase 6+)
|
||||
- **Performance**: No indexing strategy yet (Phase 4)
|
||||
|
||||
---
|
||||
|
||||
*Last updated: March 14, 2026*
|
||||
*Status: Phase 1-2 Complete ✅ | Phase 3 In Progress 🚧*
|
||||
|
||||
### 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*
|
||||
6
main.py
6
main.py
@@ -1,6 +0,0 @@
|
||||
def main():
|
||||
print("Hello from hxbooks!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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
|
||||
113
migrations/env.py
Normal file
113
migrations/env.py
Normal file
@@ -0,0 +1,113 @@
|
||||
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"}
|
||||
107
migrations/versions/75e81e4ab7b6_initial_migration.py
Normal file
107
migrations/versions/75e81e4ab7b6_initial_migration.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""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 ###
|
||||
@@ -5,8 +5,11 @@ description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"alembic>=1.13.0",
|
||||
"click>=8.3.1",
|
||||
"flask>=3.1.3",
|
||||
"flask-htmx>=0.4.0",
|
||||
"flask-migrate>=4.0.0",
|
||||
"flask-sqlalchemy>=3.1.1",
|
||||
"gunicorn>=25.1.0",
|
||||
"jinja2-fragments>=1.11.0",
|
||||
@@ -14,3 +17,7 @@ dependencies = [
|
||||
"requests>=2.32.5",
|
||||
"sqlalchemy>=2.0.48",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.10.10,<0.11.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from flask import Flask
|
||||
from flask_migrate import Migrate
|
||||
|
||||
from . import auth, book, db
|
||||
from .htmx import htmx
|
||||
|
||||
# Get the project root (parent of src/)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
def create_app(test_config: Optional[dict] = None) -> Flask:
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
# Set instance folder to project root/instance
|
||||
app = Flask(__name__, instance_path=str(PROJECT_ROOT / "instance"))
|
||||
|
||||
app.config.from_mapping(
|
||||
SECRET_KEY="dev",
|
||||
SQLALCHEMY_DATABASE_URI="sqlite:///hxbooks.sqlite",
|
||||
# Put database in project root
|
||||
SQLALCHEMY_DATABASE_URI=f"sqlite:///{PROJECT_ROOT / 'hxbooks.sqlite'}",
|
||||
)
|
||||
|
||||
if test_config is None:
|
||||
@@ -30,6 +38,9 @@ def create_app(test_config: Optional[dict] = None) -> Flask:
|
||||
db.init_app(app)
|
||||
htmx.init_app(app)
|
||||
|
||||
# Initialize migrations
|
||||
migrate = Migrate(app, db.db)
|
||||
|
||||
app.register_blueprint(auth.bp)
|
||||
app.register_blueprint(book.bp)
|
||||
|
||||
|
||||
@@ -11,5 +11,3 @@ db = SQLAlchemy(model_class=Base)
|
||||
|
||||
def init_app(app: Flask) -> None:
|
||||
db.init_app(app)
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 .db import db
|
||||
@@ -16,24 +16,66 @@ class User(db.Model): # type: ignore[name-defined]
|
||||
wishes: Mapped[list["Wishlist"]] = relationship(back_populates="user")
|
||||
|
||||
|
||||
class Author(db.Model): # type: ignore[name-defined]
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
books: Mapped[list["Book"]] = relationship(
|
||||
secondary="book_author", back_populates="authors"
|
||||
)
|
||||
|
||||
|
||||
class Genre(db.Model): # type: ignore[name-defined]
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
class BookAuthor(db.Model): # type: ignore[name-defined]
|
||||
__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): # type: ignore[name-defined]
|
||||
__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): # type: ignore[name-defined]
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(default="")
|
||||
title: Mapped[str] = mapped_column(String(500), default="")
|
||||
description: Mapped[str] = mapped_column(default="")
|
||||
first_published: Mapped[Optional[int]] = mapped_column(default=None)
|
||||
edition: Mapped[str] = mapped_column(default="")
|
||||
added: Mapped[datetime] = mapped_column(default=datetime.now)
|
||||
edition: Mapped[str] = mapped_column(String(200), default="")
|
||||
publisher: Mapped[str] = mapped_column(String(200), default="")
|
||||
isbn: Mapped[str] = mapped_column(String(20), default="")
|
||||
notes: Mapped[str] = mapped_column(default="")
|
||||
isbn: Mapped[str] = mapped_column(default="")
|
||||
authors: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
genres: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
publisher: Mapped[str] = mapped_column(default="")
|
||||
added_date: Mapped[datetime] = mapped_column(default=datetime.now)
|
||||
bought_date: Mapped[Optional[date]] = mapped_column(default=None)
|
||||
|
||||
# Location hierarchy
|
||||
location_place: Mapped[str] = mapped_column(String(100), default="")
|
||||
location_bookshelf: Mapped[str] = mapped_column(String(100), default="")
|
||||
location_shelf: Mapped[Optional[int]] = mapped_column(default=None)
|
||||
|
||||
# Loaning
|
||||
loaned_to: Mapped[str] = mapped_column(String(200), default="")
|
||||
loaned_date: Mapped[Optional[date]] = mapped_column(default=None)
|
||||
|
||||
# Relationships
|
||||
owner_id: Mapped[Optional[int]] = mapped_column(ForeignKey("user.id"))
|
||||
bought: Mapped[date] = mapped_column(default=datetime.today)
|
||||
location: Mapped[str] = mapped_column(default="billy salon")
|
||||
loaned_to: Mapped[str] = mapped_column(default="")
|
||||
loaned_from: Mapped[str] = mapped_column(default="")
|
||||
owner: Mapped[Optional[User]] = 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"
|
||||
)
|
||||
@@ -44,22 +86,26 @@ class Book(db.Model): # type: ignore[name-defined]
|
||||
|
||||
class Reading(db.Model): # type: ignore[name-defined]
|
||||
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)
|
||||
finished: Mapped[bool] = mapped_column(default=False)
|
||||
dropped: Mapped[bool] = mapped_column(default=False)
|
||||
rating: Mapped[Optional[int]] = mapped_column(default=None)
|
||||
comments: Mapped[str] = mapped_column(default="")
|
||||
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("user.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]
|
||||
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"))
|
||||
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")
|
||||
|
||||
48
uv.lock
generated
48
uv.lock
generated
@@ -2,6 +2,20 @@ 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"
|
||||
@@ -104,6 +118,20 @@ 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"
|
||||
@@ -155,10 +183,13 @@ wheels = [
|
||||
[[package]]
|
||||
name = "hxbooks"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "click" },
|
||||
{ name = "flask" },
|
||||
{ name = "flask-htmx" },
|
||||
{ name = "flask-migrate" },
|
||||
{ name = "flask-sqlalchemy" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "jinja2-fragments" },
|
||||
@@ -169,8 +200,11 @@ dependencies = [
|
||||
|
||||
[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" },
|
||||
@@ -221,6 +255,18 @@ 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 = "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"
|
||||
|
||||
Reference in New Issue
Block a user