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"
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"

View File

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

View File

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

View File

@@ -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
View File

@@ -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"