Rework data model and add migrations

This commit is contained in:
2026-03-15 00:39:23 +01:00
parent 9232ac6133
commit c30ad57051
14 changed files with 744 additions and 30 deletions

103
.github/copilot-instructions.md vendored Normal file
View 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

3
.gitignore vendored
View File

@@ -1 +1,2 @@
instance/ instance/
*.sqlite

213
docs/development-plan.md Normal file
View 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*

View File

@@ -1,6 +0,0 @@
def main():
print("Hello from hxbooks!")
if __name__ == "__main__":
main()

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View 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
View 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
View 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"}

View 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 ###

View File

@@ -5,8 +5,11 @@ description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
"alembic>=1.13.0",
"click>=8.3.1",
"flask>=3.1.3", "flask>=3.1.3",
"flask-htmx>=0.4.0", "flask-htmx>=0.4.0",
"flask-migrate>=4.0.0",
"flask-sqlalchemy>=3.1.1", "flask-sqlalchemy>=3.1.1",
"gunicorn>=25.1.0", "gunicorn>=25.1.0",
"jinja2-fragments>=1.11.0", "jinja2-fragments>=1.11.0",
@@ -14,3 +17,7 @@ dependencies = [
"requests>=2.32.5", "requests>=2.32.5",
"sqlalchemy>=2.0.48", "sqlalchemy>=2.0.48",
] ]
[build-system]
requires = ["uv_build>=0.10.10,<0.11.0"]
build-backend = "uv_build"

View File

@@ -1,17 +1,25 @@
import os import os
from pathlib import Path
from typing import Optional from typing import Optional
from flask import Flask from flask import Flask
from flask_migrate import Migrate
from . import auth, book, db from . import auth, book, db
from .htmx import htmx 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: 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( app.config.from_mapping(
SECRET_KEY="dev", 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: if test_config is None:
@@ -30,6 +38,9 @@ def create_app(test_config: Optional[dict] = None) -> Flask:
db.init_app(app) db.init_app(app)
htmx.init_app(app) htmx.init_app(app)
# Initialize migrations
migrate = Migrate(app, db.db)
app.register_blueprint(auth.bp) app.register_blueprint(auth.bp)
app.register_blueprint(book.bp) app.register_blueprint(book.bp)

View File

@@ -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()

View File

@@ -1,7 +1,7 @@
from datetime import date, datetime from datetime import date, datetime
from typing import Optional 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
@@ -16,24 +16,66 @@ class User(db.Model): # type: ignore[name-defined]
wishes: Mapped[list["Wishlist"]] = relationship(back_populates="user") 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] class Book(db.Model): # type: ignore[name-defined]
id: Mapped[int] = mapped_column(primary_key=True) 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="") description: Mapped[str] = mapped_column(default="")
first_published: Mapped[Optional[int]] = mapped_column(default=None) first_published: Mapped[Optional[int]] = 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[Optional[date]] = mapped_column(default=None)
genres: Mapped[list[str]] = mapped_column(JSON, default=list)
publisher: Mapped[str] = mapped_column(default="") # 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")) 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") 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( readings: Mapped[list["Reading"]] = relationship(
back_populates="book", cascade="delete, delete-orphan" 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] class Reading(db.Model): # type: ignore[name-defined]
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[Optional[date]] = 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[Optional[int]] = 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): # type: ignore[name-defined]
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")

48
uv.lock generated
View File

@@ -2,6 +2,20 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.14" 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]] [[package]]
name = "annotated-types" name = "annotated-types"
version = "0.7.0" 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" }, { 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]] [[package]]
name = "flask-sqlalchemy" name = "flask-sqlalchemy"
version = "3.1.1" version = "3.1.1"
@@ -155,10 +183,13 @@ wheels = [
[[package]] [[package]]
name = "hxbooks" name = "hxbooks"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "alembic" },
{ name = "click" },
{ name = "flask" }, { name = "flask" },
{ name = "flask-htmx" }, { name = "flask-htmx" },
{ name = "flask-migrate" },
{ name = "flask-sqlalchemy" }, { name = "flask-sqlalchemy" },
{ name = "gunicorn" }, { name = "gunicorn" },
{ name = "jinja2-fragments" }, { name = "jinja2-fragments" },
@@ -169,8 +200,11 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.13.0" },
{ name = "click", specifier = ">=8.3.1" },
{ name = "flask", specifier = ">=3.1.3" }, { name = "flask", specifier = ">=3.1.3" },
{ name = "flask-htmx", specifier = ">=0.4.0" }, { name = "flask-htmx", specifier = ">=0.4.0" },
{ name = "flask-migrate", specifier = ">=4.0.0" },
{ name = "flask-sqlalchemy", specifier = ">=3.1.1" }, { name = "flask-sqlalchemy", specifier = ">=3.1.1" },
{ name = "gunicorn", specifier = ">=25.1.0" }, { name = "gunicorn", specifier = ">=25.1.0" },
{ name = "jinja2-fragments", specifier = ">=1.11.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" }, { 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]] [[package]]
name = "markupsafe" name = "markupsafe"
version = "3.0.3" version = "3.0.3"