Rework data model and add migrations
This commit is contained in:
103
.github/copilot-instructions.md
vendored
Normal file
103
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# HXBooks Project Guidelines
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
HXBooks is a personal book library management application built with Flask, HTMX, and SQLAlchemy. It provides dynamic book searching, reading tracking, and library management without heavy JavaScript frameworks.
|
||||||
|
|
||||||
|
**Core Technologies:**
|
||||||
|
- **Backend**: Flask 3.1+ with SQLAlchemy 2.0 (modern `Mapped[]` annotations)
|
||||||
|
- **Frontend**: HTMX + Alpine.js + Bootstrap (minimal JavaScript approach)
|
||||||
|
- **Validation**: Pydantic 2.x schemas for request/response validation
|
||||||
|
- **Templates**: Jinja2 with fragments for partial page updates
|
||||||
|
- **Database**: SQLite with JSON columns for flexible arrays
|
||||||
|
- **Package Manager**: UV with Python 3.14
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**Application Factory Pattern:**
|
||||||
|
- `create_app()` in [src/hxbooks/__init__.py](src/hxbooks/__init__.py)
|
||||||
|
- Blueprint organization: `auth.py` (authentication), `book.py` (main features)
|
||||||
|
- Models: User, Book, Reading, Wishlist with cascade relationships
|
||||||
|
|
||||||
|
**Key Components:**
|
||||||
|
- [src/hxbooks/models.py](src/hxbooks/models.py): SQLAlchemy models with modern `Mapped[]` syntax
|
||||||
|
- [src/hxbooks/book.py](src/hxbooks/book.py): Complex search with Pydantic validation
|
||||||
|
- [src/hxbooks/gbooks.py](src/hxbooks/gbooks.py): Google Books API integration
|
||||||
|
- [src/hxbooks/templates/](src/hxbooks/templates/): Jinja2 templates with HTMX fragments
|
||||||
|
|
||||||
|
## Build and Development
|
||||||
|
|
||||||
|
**Setup & Run:**
|
||||||
|
```bash
|
||||||
|
uv sync # Install dependencies
|
||||||
|
python -m hxbooks # Dev server with livereload (port 5000)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Development Server:**
|
||||||
|
- [src/hxbooks/__main__.py](src/hxbooks/__main__.py): Livereload server watching templates
|
||||||
|
- VS Code debugging: Use "Python Debugger: Flask" launch configuration
|
||||||
|
- Config: Instance folder pattern (`instance/config.py` for local overrides)
|
||||||
|
|
||||||
|
**Production Deployment:**
|
||||||
|
- Dockerfile uses Gunicorn on port 8080
|
||||||
|
- **Note**: Current Dockerfile expects `requirements.txt` but project uses `pyproject.toml`
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
**Python Conventions:**
|
||||||
|
- **Types**: Modern annotations (`str | Response`, `Mapped[str]`, `Optional[Type]`)
|
||||||
|
- **Naming**: snake_case functions/vars, PascalCase classes, UPPERCASE constants
|
||||||
|
- **SQLAlchemy**: Use `mapped_column()` and `relationship()` with `back_populates`
|
||||||
|
- **Validation**: Pydantic schemas with `{Entity}RequestSchema`/`{Entity}ResultSchema` pattern
|
||||||
|
|
||||||
|
**Flask Patterns:**
|
||||||
|
- Blueprints with `@bp.route()` decorators
|
||||||
|
- `@login_required` decorator for protected routes
|
||||||
|
- Response types: `str | Response` (template string or redirect)
|
||||||
|
- Error handling: Dict mapping for template display `{field: error_msg}`
|
||||||
|
|
||||||
|
**Frontend (HTMX + Alpine.js):**
|
||||||
|
- Templates: `.j2` extension, fragments for partial updates
|
||||||
|
- HTMX: Dynamic updates with `hx-get`, `hx-post`, `hx-target`
|
||||||
|
- Alpine.js: Minimal state management (`x-data`, `x-show`, `@click`)
|
||||||
|
- Styling: Bootstrap CSS classes
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
**Entry Points:**
|
||||||
|
- [src/hxbooks/__main__.py](src/hxbooks/__main__.py): Development server
|
||||||
|
- [main.py](main.py): Unused placeholder
|
||||||
|
- [.vscode/launch.json](.vscode/launch.json): Debug configuration
|
||||||
|
|
||||||
|
**Core Application:**
|
||||||
|
- [src/hxbooks/__init__.py](src/hxbooks/__init__.py): Flask app factory
|
||||||
|
- [src/hxbooks/db.py](src/hxbooks/db.py): SQLAlchemy setup with auto-table creation
|
||||||
|
- [src/hxbooks/models.py](src/hxbooks/models.py): Database models
|
||||||
|
|
||||||
|
**Feature Modules:**
|
||||||
|
- [src/hxbooks/auth.py](src/hxbooks/auth.py): User authentication (username-based)
|
||||||
|
- [src/hxbooks/book.py](src/hxbooks/book.py): Book CRUD, search, filtering
|
||||||
|
- [src/hxbooks/gbooks.py](src/hxbooks/gbooks.py): Google Books API integration
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
**Database Patterns:**
|
||||||
|
- JSON columns for arrays: `authors: list`, `genres: list`, `saved_searches: dict`
|
||||||
|
- Cascade deletes for dependent entities
|
||||||
|
- Foreign key constraints explicitly defined
|
||||||
|
|
||||||
|
**Search Implementation:**
|
||||||
|
- Full-text search using SQLite FTS with `func.match()`
|
||||||
|
- Complex query builder returning `Select` statements
|
||||||
|
- Saved searches stored as JSON in User model
|
||||||
|
|
||||||
|
**HTMX Integration:**
|
||||||
|
- Partial page updates using Jinja2-Fragments
|
||||||
|
- Search results rendered as blocks without full page reload
|
||||||
|
- Form validation errors returned as template fragments
|
||||||
|
|
||||||
|
**Development Notes:**
|
||||||
|
- No testing framework configured yet
|
||||||
|
- No linting/formatting tools setup
|
||||||
|
- Instance folder for environment-specific config (gitignored)
|
||||||
|
- Production requires either `requirements.txt` generation or Dockerfile updates for UV
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
instance/
|
instance/
|
||||||
|
*.sqlite
|
||||||
213
docs/development-plan.md
Normal file
213
docs/development-plan.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# HXBooks Development Plan & Progress
|
||||||
|
|
||||||
|
## User's Priorities (March 2026)
|
||||||
|
1. ✅ **Fix the domain and data model**
|
||||||
|
2. ✅ **Make sure everything related to the database is good**
|
||||||
|
3. 🚧 **Make a CLI so I can test things manually** (In Progress)
|
||||||
|
4. **Make sure search and other basic functionality is good and can be accessed through CLI**
|
||||||
|
5. **Set up automated tests**
|
||||||
|
6. **Fully rework the GUI**
|
||||||
|
|
||||||
|
*Everything else will come later.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ COMPLETED: Domain Model & Database (Phase 1-2)
|
||||||
|
|
||||||
|
### Domain Model Decisions Made
|
||||||
|
- **No book/instance separation**: Keep it simple, treat duplicate editions as separate books
|
||||||
|
- **Author/Genre relationships**: Proper many-to-many instead of JSON fields
|
||||||
|
- **Location hierarchy**: `location_place` + `location_bookshelf` + `location_shelf` (numeric)
|
||||||
|
- **Auto-complete approach**: Authors/genres created on-demand with nice UI later
|
||||||
|
- **Multiple readings**: Separate records per reading session
|
||||||
|
- **Simple loaning**: `loaned_to` string + `loaned_date` for tracking
|
||||||
|
|
||||||
|
### Database Infrastructure ✅ DONE
|
||||||
|
- ✅ Flask-Migrate + Alembic set up
|
||||||
|
- ✅ Initial migration created and applied
|
||||||
|
- ✅ Fixed instance folder location (project root instead of src/instance)
|
||||||
|
- ✅ Database in correct location: `/hxbooks.sqlite`
|
||||||
|
- ✅ All tables created: author, genre, book, book_author, book_genre, reading, wishlist
|
||||||
|
|
||||||
|
### New Data Model ✅ IMPLEMENTED
|
||||||
|
```sql
|
||||||
|
-- Core entities
|
||||||
|
Author(id, name)
|
||||||
|
Genre(id, name)
|
||||||
|
Book(id, title, description, isbn, edition, publisher, notes,
|
||||||
|
added_date, bought_date,
|
||||||
|
location_place, location_bookshelf, location_shelf,
|
||||||
|
loaned_to, loaned_date, owner_id)
|
||||||
|
|
||||||
|
-- Many-to-many relationships
|
||||||
|
BookAuthor(book_id, author_id)
|
||||||
|
BookGenre(book_id, genre_id)
|
||||||
|
|
||||||
|
-- User activity
|
||||||
|
Reading(id, user_id, book_id, start_date, end_date,
|
||||||
|
finished, dropped, rating, comments)
|
||||||
|
Wishlist(id, user_id, book_id, wishlisted_date)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚧 IN PROGRESS: CLI Development (Phase 3)
|
||||||
|
|
||||||
|
### CLI Requirements for Manual Testing
|
||||||
|
- [ ] Book CRUD operations (add, edit, delete, list)
|
||||||
|
- [ ] Author/Genre management (auto-create, list)
|
||||||
|
- [ ] Location management (place, bookshelf, shelf)
|
||||||
|
- [ ] Reading tracking (start, finish, rate)
|
||||||
|
- [ ] Search functionality testing
|
||||||
|
- [ ] Data import from old format
|
||||||
|
- [ ] Loaning operations
|
||||||
|
|
||||||
|
### CLI Commands Planned
|
||||||
|
```bash
|
||||||
|
hx book add "Title" --authors "Author1,Author2" --genres "Fiction"
|
||||||
|
hx book list --location "my house" --shelf 2
|
||||||
|
hx book search "keyword"
|
||||||
|
hx reading start <book_id>
|
||||||
|
hx reading finish <book_id> --rating 4
|
||||||
|
hx loan <book_id> --to "Alice"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 TODO: Remaining Phases
|
||||||
|
|
||||||
|
### Phase 4: Search & Core Features
|
||||||
|
- [ ] Implement proper FTS with new schema
|
||||||
|
- [ ] Add faceted search (by author, genre, location)
|
||||||
|
- [ ] Create search result serializers
|
||||||
|
- [ ] Add pagination
|
||||||
|
- [ ] Optimize query performance with proper indexes
|
||||||
|
|
||||||
|
### Phase 5: Testing Framework
|
||||||
|
- [ ] Set up pytest with database fixtures
|
||||||
|
- [ ] API endpoint tests
|
||||||
|
- [ ] Search functionality tests
|
||||||
|
- [ ] CLI command tests
|
||||||
|
- [ ] Migration tests
|
||||||
|
|
||||||
|
### Phase 6: GUI Rework
|
||||||
|
- [ ] Update templates for new data model
|
||||||
|
- [ ] Mobile-first responsive design
|
||||||
|
- [ ] Author/Genre autocomplete interfaces
|
||||||
|
- [ ] Location hierarchy picker
|
||||||
|
- [ ] Touch-optimized interactions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗄️ Original Critique Archive
|
||||||
|
|
||||||
|
### Critical Issues RESOLVED ✅
|
||||||
|
- ❌ **Book ownership model**: Fixed - no artificial scarcity
|
||||||
|
- ❌ **JSON denormalization**: Fixed - proper Author/Genre relationships
|
||||||
|
- ❌ **Mixed properties**: Fixed - structured location hierarchy
|
||||||
|
- ❌ **No migrations**: Fixed - Alembic set up and working
|
||||||
|
- ❌ **Poor folder structure**: Fixed - database in project root
|
||||||
|
|
||||||
|
### Issues for Later Phases
|
||||||
|
- **Authentication**: Username-only insufficient (Phase 6+)
|
||||||
|
- **Configuration management**: No environment handling (Phase 6+)
|
||||||
|
- **Mobile UX**: Tables don't work on mobile (Phase 6)
|
||||||
|
- **Testing infrastructure**: No framework yet (Phase 5)
|
||||||
|
- **Error handling**: No proper boundaries (Phase 6+)
|
||||||
|
- **Performance**: No indexing strategy yet (Phase 4)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: March 14, 2026*
|
||||||
|
*Status: Phase 1-2 Complete ✅ | Phase 3 In Progress 🚧*
|
||||||
|
|
||||||
|
### Medium Priority Issues (Priority 3-4: CLI & Search)
|
||||||
|
|
||||||
|
#### Search & Discovery
|
||||||
|
- **Limited FTS capabilities**: Current implementation incomplete
|
||||||
|
- **No faceted search**: Missing filters by author, genre, year, etc.
|
||||||
|
- **Performance concerns**: JSON contains operations will be slow
|
||||||
|
- **Missing recommendations**: No "similar books" functionality
|
||||||
|
|
||||||
|
#### CLI Requirements for Testing
|
||||||
|
- Book CRUD operations
|
||||||
|
- Search functionality testing
|
||||||
|
- User management
|
||||||
|
- Reading tracking
|
||||||
|
- Data import/export capabilities
|
||||||
|
|
||||||
|
### Lower Priority Issues (Priority 5-6: Testing & GUI)
|
||||||
|
|
||||||
|
#### Testing Infrastructure Missing
|
||||||
|
- No testing framework configured
|
||||||
|
- No test database setup
|
||||||
|
- No fixtures or mock data
|
||||||
|
- No CI/CD pipeline
|
||||||
|
|
||||||
|
#### GUI/UX Issues
|
||||||
|
- Mobile responsiveness needs work
|
||||||
|
- No offline capabilities
|
||||||
|
- Tables don't work well on mobile
|
||||||
|
- Missing accessibility features
|
||||||
|
|
||||||
|
### Security & DevOps (Future)
|
||||||
|
- **Authentication**: Username-only is insufficient
|
||||||
|
- **Configuration management**: No environment handling
|
||||||
|
- **Deployment**: Dockerfile/requirements.txt mismatch
|
||||||
|
- **Secret management**: Hardcoded dev secrets
|
||||||
|
|
||||||
|
### Technical Debt
|
||||||
|
- **Python 3.14 requirement**: Too aggressive (doesn't exist yet)
|
||||||
|
- **Error handling**: No proper error boundaries
|
||||||
|
- **Logging**: No production logging configuration
|
||||||
|
- **Code quality**: Missing linting, formatting tools
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Phase 1: Domain Model Rework
|
||||||
|
- [ ] Design new schema with proper relationships
|
||||||
|
- [ ] Create migration system
|
||||||
|
- [ ] Implement Author and Genre entities
|
||||||
|
- [ ] Separate Book from BookInstance
|
||||||
|
- [ ] Update all models with proper typing
|
||||||
|
|
||||||
|
### Phase 2: Database Infrastructure
|
||||||
|
- [ ] Set up Alembic migrations
|
||||||
|
- [ ] Add proper indexing
|
||||||
|
- [ ] Implement FTS correctly
|
||||||
|
- [ ] Add database constraints
|
||||||
|
- [ ] Create seed data
|
||||||
|
|
||||||
|
### Phase 3: CLI Development
|
||||||
|
- [ ] Create Click-based CLI
|
||||||
|
- [ ] Book management commands
|
||||||
|
- [ ] Search functionality
|
||||||
|
- [ ] User operations
|
||||||
|
- [ ] Import/export tools
|
||||||
|
|
||||||
|
### Phase 4: Search & Core Features
|
||||||
|
- [ ] Implement proper FTS
|
||||||
|
- [ ] Add faceted search
|
||||||
|
- [ ] Create search result serializers
|
||||||
|
- [ ] Add pagination
|
||||||
|
- [ ] Optimize query performance
|
||||||
|
|
||||||
|
### Phase 5: Testing Framework
|
||||||
|
- [ ] Set up pytest
|
||||||
|
- [ ] Database testing fixtures
|
||||||
|
- [ ] API endpoint tests
|
||||||
|
- [ ] Search functionality tests
|
||||||
|
- [ ] CLI command tests
|
||||||
|
|
||||||
|
### Phase 6: GUI Rework
|
||||||
|
- [ ] Mobile-first responsive design
|
||||||
|
- [ ] Progressive enhancement
|
||||||
|
- [ ] Accessibility improvements
|
||||||
|
- [ ] Modern HTMX patterns
|
||||||
|
- [ ] Touch-optimized interactions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: March 14, 2026*
|
||||||
6
main.py
6
main.py
@@ -1,6 +0,0 @@
|
|||||||
def main():
|
|
||||||
print("Hello from hxbooks!")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Single-database configuration for Flask.
|
||||||
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# template used to generate migration files
|
||||||
|
# file_template = %%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic,flask_migrate
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[logger_flask_migrate]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = flask_migrate
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
113
migrations/env.py
Normal file
113
migrations/env.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import logging
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
logger = logging.getLogger('alembic.env')
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
try:
|
||||||
|
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||||
|
return current_app.extensions['migrate'].db.get_engine()
|
||||||
|
except (TypeError, AttributeError):
|
||||||
|
# this works with Flask-SQLAlchemy>=3
|
||||||
|
return current_app.extensions['migrate'].db.engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine_url():
|
||||||
|
try:
|
||||||
|
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||||
|
'%', '%%')
|
||||||
|
except AttributeError:
|
||||||
|
return str(get_engine().url).replace('%', '%%')
|
||||||
|
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||||
|
target_db = current_app.extensions['migrate'].db
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def get_metadata():
|
||||||
|
if hasattr(target_db, 'metadatas'):
|
||||||
|
return target_db.metadatas[None]
|
||||||
|
return target_db.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# this callback is used to prevent an auto-migration from being generated
|
||||||
|
# when there are no changes to the schema
|
||||||
|
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||||
|
def process_revision_directives(context, revision, directives):
|
||||||
|
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||||
|
script = directives[0]
|
||||||
|
if script.upgrade_ops.is_empty():
|
||||||
|
directives[:] = []
|
||||||
|
logger.info('No changes in schema detected.')
|
||||||
|
|
||||||
|
conf_args = current_app.extensions['migrate'].configure_args
|
||||||
|
if conf_args.get("process_revision_directives") is None:
|
||||||
|
conf_args["process_revision_directives"] = process_revision_directives
|
||||||
|
|
||||||
|
connectable = get_engine()
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=get_metadata(),
|
||||||
|
**conf_args
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
107
migrations/versions/75e81e4ab7b6_initial_migration.py
Normal file
107
migrations/versions/75e81e4ab7b6_initial_migration.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""Initial migration
|
||||||
|
|
||||||
|
Revision ID: 75e81e4ab7b6
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-03-14 22:51:20.059755
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '75e81e4ab7b6'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('author',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=200), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('genre',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('user',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('username', sa.String(), nullable=False),
|
||||||
|
sa.Column('saved_searches', sa.JSON(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('book',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('description', sa.String(), nullable=False),
|
||||||
|
sa.Column('first_published', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('edition', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('publisher', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('isbn', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('notes', sa.String(), nullable=False),
|
||||||
|
sa.Column('added_date', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('bought_date', sa.Date(), nullable=True),
|
||||||
|
sa.Column('location_place', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('location_bookshelf', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('location_shelf', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('loaned_to', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('loaned_date', sa.Date(), nullable=True),
|
||||||
|
sa.Column('owner_id', sa.Integer(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('book_author',
|
||||||
|
sa.Column('book_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('author_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['author_id'], ['author.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['book_id'], ['book.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('book_id', 'author_id')
|
||||||
|
)
|
||||||
|
op.create_table('book_genre',
|
||||||
|
sa.Column('book_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('genre_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['book_id'], ['book.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['genre_id'], ['genre.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('book_id', 'genre_id')
|
||||||
|
)
|
||||||
|
op.create_table('reading',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('start_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('end_date', sa.Date(), nullable=True),
|
||||||
|
sa.Column('finished', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('dropped', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('rating', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('comments', sa.String(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('book_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['book_id'], ['book.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('wishlist',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('wishlisted_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('book_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['book_id'], ['book.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('wishlist')
|
||||||
|
op.drop_table('reading')
|
||||||
|
op.drop_table('book_genre')
|
||||||
|
op.drop_table('book_author')
|
||||||
|
op.drop_table('book')
|
||||||
|
op.drop_table('user')
|
||||||
|
op.drop_table('genre')
|
||||||
|
op.drop_table('author')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -5,8 +5,11 @@ description = "Add your description here"
|
|||||||
readme = "README.md"
|
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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -11,5 +11,3 @@ db = SQLAlchemy(model_class=Base)
|
|||||||
|
|
||||||
def init_app(app: Flask) -> None:
|
def init_app(app: Flask) -> None:
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
with app.app_context():
|
|
||||||
db.create_all()
|
|
||||||
|
|||||||
@@ -1,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
48
uv.lock
generated
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user