From c30ad57051b3c57a98e698620763129de730f349 Mon Sep 17 00:00:00 2001 From: Francisco Penedo Alvarez Date: Sun, 15 Mar 2026 00:39:23 +0100 Subject: [PATCH] Rework data model and add migrations --- .github/copilot-instructions.md | 103 +++++++++ .gitignore | 3 +- docs/development-plan.md | 213 ++++++++++++++++++ main.py | 6 - migrations/README | 1 + migrations/alembic.ini | 50 ++++ migrations/env.py | 113 ++++++++++ migrations/script.py.mako | 24 ++ .../75e81e4ab7b6_initial_migration.py | 107 +++++++++ pyproject.toml | 7 + src/hxbooks/__init__.py | 15 +- src/hxbooks/db.py | 2 - src/hxbooks/models.py | 82 +++++-- uv.lock | 48 +++- 14 files changed, 744 insertions(+), 30 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 docs/development-plan.md delete mode 100644 main.py create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/75e81e4ab7b6_initial_migration.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..d00d6e0 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8b973a3..363580b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -instance/ \ No newline at end of file +instance/ +*.sqlite \ No newline at end of file diff --git a/docs/development-plan.md b/docs/development-plan.md new file mode 100644 index 0000000..5897079 --- /dev/null +++ b/docs/development-plan.md @@ -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 +hx reading finish --rating 4 +hx loan --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* \ No newline at end of file diff --git a/main.py b/main.py deleted file mode 100644 index c86df8e..0000000 --- a/main.py +++ /dev/null @@ -1,6 +0,0 @@ -def main(): - print("Hello from hxbooks!") - - -if __name__ == "__main__": - main() diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -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 diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -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() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -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"} diff --git a/migrations/versions/75e81e4ab7b6_initial_migration.py b/migrations/versions/75e81e4ab7b6_initial_migration.py new file mode 100644 index 0000000..7035509 --- /dev/null +++ b/migrations/versions/75e81e4ab7b6_initial_migration.py @@ -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 ### diff --git a/pyproject.toml b/pyproject.toml index 5f10efa..20b2ed4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/hxbooks/__init__.py b/src/hxbooks/__init__.py index 0e46241..d79515c 100644 --- a/src/hxbooks/__init__.py +++ b/src/hxbooks/__init__.py @@ -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) diff --git a/src/hxbooks/db.py b/src/hxbooks/db.py index 73036a0..f72f9f8 100644 --- a/src/hxbooks/db.py +++ b/src/hxbooks/db.py @@ -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() diff --git a/src/hxbooks/models.py b/src/hxbooks/models.py index 7187555..552f8d5 100644 --- a/src/hxbooks/models.py +++ b/src/hxbooks/models.py @@ -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") diff --git a/uv.lock b/uv.lock index 391712c..11311ce 100644 --- a/uv.lock +++ b/uv.lock @@ -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"