winebox 0.1.4__tar.gz → 0.1.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {winebox-0.1.4 → winebox-0.1.5}/PKG-INFO +1 -1
- {winebox-0.1.4 → winebox-0.1.5}/docs/index.md +2 -0
- {winebox-0.1.4 → winebox-0.1.5}/docs/user-guide.md +6 -0
- {winebox-0.1.4 → winebox-0.1.5}/pyproject.toml +1 -1
- winebox-0.1.5/scripts/__init__.py +1 -0
- winebox-0.1.5/scripts/migrations/__init__.py +20 -0
- winebox-0.1.5/scripts/migrations/db_migrate_0_to_1.py +46 -0
- winebox-0.1.5/scripts/migrations/db_revert_1_to_0.py +108 -0
- winebox-0.1.5/scripts/migrations/runner.py +606 -0
- {winebox-0.1.4 → winebox-0.1.5}/uv.lock +1 -1
- {winebox-0.1.4 → winebox-0.1.5}/winebox/__init__.py +1 -1
- {winebox-0.1.4 → winebox-0.1.5}/.github/workflows/ci.yml +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/.github/workflows/publish.yml +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/.gitignore +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/.python-version +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/LICENSE +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/README.md +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/docs/_static/screenshots/cellar.png +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/docs/_static/screenshots/checkin.png +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/docs/_static/screenshots/dashboard.png +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/docs/_static/screenshots/search.png +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/docs/api-reference.md +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/docs/conf.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/docs/screenshots/cellar.png +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/docs/screenshots/checkin.png +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/docs/screenshots/dashboard.png +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/docs/screenshots/login.png +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/tasks.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/tests/__init__.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/tests/conftest.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/tests/test_checkin_e2e.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/tests/test_ocr.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/tests/test_search.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/tests/test_transactions.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/tests/test_wines.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/cli/__init__.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/cli/server.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/cli/user_admin.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/config.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/database.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/main.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/models/__init__.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/models/inventory.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/models/transaction.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/models/user.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/models/wine.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/routers/__init__.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/routers/auth.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/routers/cellar.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/routers/search.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/routers/transactions.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/routers/wines.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/schemas/__init__.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/schemas/transaction.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/schemas/wine.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/services/__init__.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/services/auth.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/services/image_storage.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/services/ocr.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/services/vision.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/services/wine_parser.py +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/static/css/style.css +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/static/favicon.svg +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/static/index.html +0 -0
- {winebox-0.1.4 → winebox-0.1.5}/winebox/static/js/app.js +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: winebox
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Wine Cellar Management Application with OCR label scanning
|
|
5
5
|
Project-URL: Homepage, https://github.com/jdrumgoole/winebox
|
|
6
6
|
Project-URL: Repository, https://github.com/jdrumgoole/winebox
|
|
@@ -162,6 +162,8 @@ winebox/
|
|
|
162
162
|
│ ├── routers/ # API endpoints
|
|
163
163
|
│ ├── services/ # Business logic
|
|
164
164
|
│ └── static/ # Web interface
|
|
165
|
+
├── scripts/ # Utility scripts
|
|
166
|
+
│ └── migrations/ # Database migration system
|
|
165
167
|
├── tests/ # Test suite
|
|
166
168
|
├── docs/ # Documentation
|
|
167
169
|
├── data/ # Database and images
|
|
@@ -224,6 +224,12 @@ invoke init-db # Initialize database
|
|
|
224
224
|
invoke purge --force # Delete database and images
|
|
225
225
|
invoke purge-wines --force # Delete wines but keep users
|
|
226
226
|
|
|
227
|
+
# Database migrations
|
|
228
|
+
uv run python -m scripts.migrations.runner status # Show version
|
|
229
|
+
uv run python -m scripts.migrations.runner up # Migrate to latest
|
|
230
|
+
uv run python -m scripts.migrations.runner down --to 0 # Revert to version
|
|
231
|
+
uv run python -m scripts.migrations.runner history # Show history
|
|
232
|
+
|
|
227
233
|
# User management
|
|
228
234
|
invoke add-user <username> --password <pass>
|
|
229
235
|
invoke remove-user <username> --force
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Scripts package for WineBox utility scripts."""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Database migration system for WineBox.
|
|
2
|
+
|
|
3
|
+
This package provides versioned database migrations with forward and reverse support.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
# Show current version and available migrations
|
|
7
|
+
uv run python -m scripts.migrations.runner status
|
|
8
|
+
|
|
9
|
+
# Migrate to latest version
|
|
10
|
+
uv run python -m scripts.migrations.runner up
|
|
11
|
+
|
|
12
|
+
# Migrate to specific version
|
|
13
|
+
uv run python -m scripts.migrations.runner up --to 2
|
|
14
|
+
|
|
15
|
+
# Revert to specific version
|
|
16
|
+
uv run python -m scripts.migrations.runner down --to 0
|
|
17
|
+
|
|
18
|
+
# Show migration history
|
|
19
|
+
uv run python -m scripts.migrations.runner history
|
|
20
|
+
"""
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Migration: Add full_name and anthropic_api_key columns to users table.
|
|
3
|
+
|
|
4
|
+
This migration adds user settings columns:
|
|
5
|
+
- full_name: Optional display name for the user
|
|
6
|
+
- anthropic_api_key: Optional API key for Claude Vision (per-user override)
|
|
7
|
+
|
|
8
|
+
Version: 0 -> 1
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sqlite3
|
|
12
|
+
|
|
13
|
+
# Migration metadata
|
|
14
|
+
SOURCE_VERSION = 0
|
|
15
|
+
TARGET_VERSION = 1
|
|
16
|
+
DESCRIPTION = "Add full_name and anthropic_api_key to users table"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def migrate(cursor: sqlite3.Cursor) -> None:
|
|
20
|
+
"""Apply the forward migration.
|
|
21
|
+
|
|
22
|
+
Adds full_name and anthropic_api_key columns to the users table.
|
|
23
|
+
These are nullable columns, so existing users will have NULL values.
|
|
24
|
+
"""
|
|
25
|
+
# Check existing columns
|
|
26
|
+
cursor.execute("PRAGMA table_info(users)")
|
|
27
|
+
columns = {row[1] for row in cursor.fetchall()}
|
|
28
|
+
|
|
29
|
+
# Add full_name column if it doesn't exist
|
|
30
|
+
if "full_name" not in columns:
|
|
31
|
+
cursor.execute("ALTER TABLE users ADD COLUMN full_name VARCHAR(255)")
|
|
32
|
+
|
|
33
|
+
# Add anthropic_api_key column if it doesn't exist
|
|
34
|
+
if "anthropic_api_key" not in columns:
|
|
35
|
+
cursor.execute("ALTER TABLE users ADD COLUMN anthropic_api_key VARCHAR(255)")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate(cursor: sqlite3.Cursor) -> bool:
|
|
39
|
+
"""Validate the migration was successful.
|
|
40
|
+
|
|
41
|
+
Returns True if both columns exist in the users table.
|
|
42
|
+
"""
|
|
43
|
+
cursor.execute("PRAGMA table_info(users)")
|
|
44
|
+
columns = {row[1] for row in cursor.fetchall()}
|
|
45
|
+
|
|
46
|
+
return "full_name" in columns and "anthropic_api_key" in columns
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Revert migration: Remove full_name and anthropic_api_key columns from users table.
|
|
3
|
+
|
|
4
|
+
This revert removes the user settings columns added in version 1.
|
|
5
|
+
WARNING: Data in these columns will be lost!
|
|
6
|
+
|
|
7
|
+
Version: 1 -> 0
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
|
|
12
|
+
# Migration metadata
|
|
13
|
+
SOURCE_VERSION = 1
|
|
14
|
+
TARGET_VERSION = 0
|
|
15
|
+
DESCRIPTION = "Remove full_name and anthropic_api_key from users table"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def migrate(cursor: sqlite3.Cursor) -> None:
|
|
19
|
+
"""Apply the reverse migration (revert).
|
|
20
|
+
|
|
21
|
+
Removes full_name and anthropic_api_key columns from the users table.
|
|
22
|
+
|
|
23
|
+
Since SQLite doesn't support DROP COLUMN in older versions, we use
|
|
24
|
+
the table rebuild pattern:
|
|
25
|
+
1. Create new table without the columns
|
|
26
|
+
2. Copy data from old table
|
|
27
|
+
3. Drop old table
|
|
28
|
+
4. Rename new table
|
|
29
|
+
5. Recreate indexes
|
|
30
|
+
|
|
31
|
+
WARNING: Data in full_name and anthropic_api_key columns will be lost!
|
|
32
|
+
"""
|
|
33
|
+
# Check if columns exist (may have already been removed)
|
|
34
|
+
cursor.execute("PRAGMA table_info(users)")
|
|
35
|
+
columns = {row[1] for row in cursor.fetchall()}
|
|
36
|
+
|
|
37
|
+
if "full_name" not in columns and "anthropic_api_key" not in columns:
|
|
38
|
+
# Columns already removed, nothing to do
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
# Disable foreign key checks during table rebuild
|
|
42
|
+
cursor.execute("PRAGMA foreign_keys = OFF")
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
# 1. Create new table without full_name and anthropic_api_key
|
|
46
|
+
cursor.execute("""
|
|
47
|
+
CREATE TABLE users_new (
|
|
48
|
+
id CHAR(36) PRIMARY KEY,
|
|
49
|
+
username VARCHAR(50) NOT NULL UNIQUE,
|
|
50
|
+
email VARCHAR(255) UNIQUE,
|
|
51
|
+
hashed_password VARCHAR(255) NOT NULL,
|
|
52
|
+
is_active BOOLEAN DEFAULT 1 NOT NULL,
|
|
53
|
+
is_admin BOOLEAN DEFAULT 0 NOT NULL,
|
|
54
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
55
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
56
|
+
last_login DATETIME
|
|
57
|
+
)
|
|
58
|
+
""")
|
|
59
|
+
|
|
60
|
+
# 2. Copy data from old table (excluding removed columns)
|
|
61
|
+
cursor.execute("""
|
|
62
|
+
INSERT INTO users_new (
|
|
63
|
+
id, username, email, hashed_password,
|
|
64
|
+
is_active, is_admin, created_at, updated_at, last_login
|
|
65
|
+
)
|
|
66
|
+
SELECT
|
|
67
|
+
id, username, email, hashed_password,
|
|
68
|
+
is_active, is_admin, created_at, updated_at, last_login
|
|
69
|
+
FROM users
|
|
70
|
+
""")
|
|
71
|
+
|
|
72
|
+
# 3. Drop old table
|
|
73
|
+
cursor.execute("DROP TABLE users")
|
|
74
|
+
|
|
75
|
+
# 4. Rename new table
|
|
76
|
+
cursor.execute("ALTER TABLE users_new RENAME TO users")
|
|
77
|
+
|
|
78
|
+
# 5. Recreate indexes
|
|
79
|
+
cursor.execute(
|
|
80
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS ix_users_username ON users (username)"
|
|
81
|
+
)
|
|
82
|
+
cursor.execute(
|
|
83
|
+
"CREATE UNIQUE INDEX IF NOT EXISTS ix_users_email ON users (email)"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
finally:
|
|
87
|
+
# Re-enable foreign key checks
|
|
88
|
+
cursor.execute("PRAGMA foreign_keys = ON")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def validate(cursor: sqlite3.Cursor) -> bool:
|
|
92
|
+
"""Validate the revert was successful.
|
|
93
|
+
|
|
94
|
+
Returns True if both columns have been removed from the users table.
|
|
95
|
+
"""
|
|
96
|
+
cursor.execute("PRAGMA table_info(users)")
|
|
97
|
+
columns = {row[1] for row in cursor.fetchall()}
|
|
98
|
+
|
|
99
|
+
# Verify columns are removed
|
|
100
|
+
if "full_name" in columns or "anthropic_api_key" in columns:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
# Verify essential columns still exist
|
|
104
|
+
required_columns = {
|
|
105
|
+
"id", "username", "email", "hashed_password",
|
|
106
|
+
"is_active", "is_admin", "created_at", "updated_at", "last_login"
|
|
107
|
+
}
|
|
108
|
+
return required_columns.issubset(columns)
|
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Database migration runner for WineBox.
|
|
3
|
+
|
|
4
|
+
This module provides the main migration runner that handles:
|
|
5
|
+
- Schema version tracking via a schema_version table
|
|
6
|
+
- Forward migrations (up)
|
|
7
|
+
- Reverse migrations (down)
|
|
8
|
+
- Status reporting and history
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
uv run python -m scripts.migrations.runner status
|
|
12
|
+
uv run python -m scripts.migrations.runner up [--to VERSION]
|
|
13
|
+
uv run python -m scripts.migrations.runner down --to VERSION
|
|
14
|
+
uv run python -m scripts.migrations.runner history
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import importlib
|
|
19
|
+
import sqlite3
|
|
20
|
+
import sys
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Default database path
|
|
27
|
+
DEFAULT_DB_PATH = "data/winebox.db"
|
|
28
|
+
|
|
29
|
+
# Schema version table DDL
|
|
30
|
+
SCHEMA_VERSION_DDL = """
|
|
31
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
32
|
+
version INTEGER PRIMARY KEY,
|
|
33
|
+
migrate_script TEXT NOT NULL,
|
|
34
|
+
revert_script TEXT NOT NULL,
|
|
35
|
+
description TEXT NOT NULL,
|
|
36
|
+
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
|
37
|
+
)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_db_path(db_path: str | None = None) -> str:
|
|
42
|
+
"""Get the database path, with fallback to default."""
|
|
43
|
+
return db_path or DEFAULT_DB_PATH
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_connection(db_path: str) -> sqlite3.Connection:
|
|
47
|
+
"""Get a database connection."""
|
|
48
|
+
return sqlite3.connect(db_path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def ensure_schema_version_table(cursor: sqlite3.Cursor) -> None:
|
|
52
|
+
"""Ensure the schema_version table exists."""
|
|
53
|
+
cursor.execute(SCHEMA_VERSION_DDL)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_current_version(cursor: sqlite3.Cursor) -> int:
|
|
57
|
+
"""Get the current schema version from the database.
|
|
58
|
+
|
|
59
|
+
Returns 0 if no migrations have been applied.
|
|
60
|
+
"""
|
|
61
|
+
# Check if schema_version table exists
|
|
62
|
+
cursor.execute(
|
|
63
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'"
|
|
64
|
+
)
|
|
65
|
+
if not cursor.fetchone():
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
# Get max version
|
|
69
|
+
cursor.execute("SELECT MAX(version) FROM schema_version")
|
|
70
|
+
result = cursor.fetchone()
|
|
71
|
+
return result[0] if result[0] is not None else 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_table_columns(cursor: sqlite3.Cursor, table_name: str) -> set[str]:
|
|
75
|
+
"""Get column names for a table."""
|
|
76
|
+
cursor.execute(f"PRAGMA table_info({table_name})")
|
|
77
|
+
return {row[1] for row in cursor.fetchall()}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def detect_current_state(cursor: sqlite3.Cursor) -> int:
|
|
81
|
+
"""Detect the current database state based on schema.
|
|
82
|
+
|
|
83
|
+
This is used to bootstrap the schema_version table for existing databases.
|
|
84
|
+
Returns the detected version based on schema inspection.
|
|
85
|
+
"""
|
|
86
|
+
# Check if users table exists
|
|
87
|
+
cursor.execute(
|
|
88
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
|
89
|
+
)
|
|
90
|
+
if not cursor.fetchone():
|
|
91
|
+
return 0 # No users table means version 0
|
|
92
|
+
|
|
93
|
+
# Check for columns added in version 1
|
|
94
|
+
columns = get_table_columns(cursor, "users")
|
|
95
|
+
if "full_name" in columns and "anthropic_api_key" in columns:
|
|
96
|
+
return 1 # Has v1 columns
|
|
97
|
+
|
|
98
|
+
return 0 # Original schema
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def bootstrap_schema_version(cursor: sqlite3.Cursor) -> int:
|
|
102
|
+
"""Bootstrap the schema_version table for an existing database.
|
|
103
|
+
|
|
104
|
+
Detects the current state and inserts appropriate version records.
|
|
105
|
+
Returns the detected version.
|
|
106
|
+
"""
|
|
107
|
+
detected_version = detect_current_state(cursor)
|
|
108
|
+
|
|
109
|
+
if detected_version >= 1:
|
|
110
|
+
# Insert version 1 record (bootstrapped)
|
|
111
|
+
cursor.execute(
|
|
112
|
+
"""
|
|
113
|
+
INSERT OR IGNORE INTO schema_version
|
|
114
|
+
(version, migrate_script, revert_script, description, applied_at)
|
|
115
|
+
VALUES (?, ?, ?, ?, ?)
|
|
116
|
+
""",
|
|
117
|
+
(
|
|
118
|
+
1,
|
|
119
|
+
"db_migrate_0_to_1.py",
|
|
120
|
+
"db_revert_1_to_0.py",
|
|
121
|
+
"Add full_name and anthropic_api_key to users table (bootstrapped)",
|
|
122
|
+
datetime.now().isoformat(),
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return detected_version
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_migration_module(script_name: str) -> Any:
|
|
130
|
+
"""Load a migration module by name."""
|
|
131
|
+
module_name = f"scripts.migrations.{script_name.replace('.py', '')}"
|
|
132
|
+
return importlib.import_module(module_name)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get_available_migrations() -> list[dict[str, Any]]:
|
|
136
|
+
"""Get list of available migration scripts.
|
|
137
|
+
|
|
138
|
+
Returns a list of dicts with source_version, target_version, script_name, description.
|
|
139
|
+
"""
|
|
140
|
+
migrations_dir = Path(__file__).parent
|
|
141
|
+
migrations = []
|
|
142
|
+
|
|
143
|
+
for script_file in migrations_dir.glob("db_migrate_*.py"):
|
|
144
|
+
try:
|
|
145
|
+
module = load_migration_module(script_file.name)
|
|
146
|
+
migrations.append({
|
|
147
|
+
"source_version": module.SOURCE_VERSION,
|
|
148
|
+
"target_version": module.TARGET_VERSION,
|
|
149
|
+
"script_name": script_file.name,
|
|
150
|
+
"description": module.DESCRIPTION,
|
|
151
|
+
"type": "migrate",
|
|
152
|
+
})
|
|
153
|
+
except (ImportError, AttributeError) as e:
|
|
154
|
+
print(f"Warning: Could not load migration {script_file.name}: {e}")
|
|
155
|
+
|
|
156
|
+
for script_file in migrations_dir.glob("db_revert_*.py"):
|
|
157
|
+
try:
|
|
158
|
+
module = load_migration_module(script_file.name)
|
|
159
|
+
migrations.append({
|
|
160
|
+
"source_version": module.SOURCE_VERSION,
|
|
161
|
+
"target_version": module.TARGET_VERSION,
|
|
162
|
+
"script_name": script_file.name,
|
|
163
|
+
"description": module.DESCRIPTION,
|
|
164
|
+
"type": "revert",
|
|
165
|
+
})
|
|
166
|
+
except (ImportError, AttributeError) as e:
|
|
167
|
+
print(f"Warning: Could not load revert script {script_file.name}: {e}")
|
|
168
|
+
|
|
169
|
+
return migrations
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def find_migration_path(
|
|
173
|
+
current_version: int,
|
|
174
|
+
target_version: int,
|
|
175
|
+
migrations: list[dict[str, Any]],
|
|
176
|
+
) -> list[dict[str, Any]]:
|
|
177
|
+
"""Find the sequence of migrations to apply.
|
|
178
|
+
|
|
179
|
+
Returns list of migration dicts in order of application.
|
|
180
|
+
"""
|
|
181
|
+
if current_version == target_version:
|
|
182
|
+
return []
|
|
183
|
+
|
|
184
|
+
going_up = target_version > current_version
|
|
185
|
+
migration_type = "migrate" if going_up else "revert"
|
|
186
|
+
|
|
187
|
+
# Filter to relevant migrations
|
|
188
|
+
relevant = [m for m in migrations if m["type"] == migration_type]
|
|
189
|
+
|
|
190
|
+
path = []
|
|
191
|
+
version = current_version
|
|
192
|
+
|
|
193
|
+
while version != target_version:
|
|
194
|
+
# Find migration from current version
|
|
195
|
+
next_migration = None
|
|
196
|
+
for m in relevant:
|
|
197
|
+
if m["source_version"] == version:
|
|
198
|
+
if going_up and m["target_version"] > version:
|
|
199
|
+
next_migration = m
|
|
200
|
+
break
|
|
201
|
+
elif not going_up and m["target_version"] < version:
|
|
202
|
+
next_migration = m
|
|
203
|
+
break
|
|
204
|
+
|
|
205
|
+
if next_migration is None:
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"No migration found from version {version} "
|
|
208
|
+
f"{'up' if going_up else 'down'} to {target_version}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
path.append(next_migration)
|
|
212
|
+
version = next_migration["target_version"]
|
|
213
|
+
|
|
214
|
+
return path
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def get_latest_version(migrations: list[dict[str, Any]]) -> int:
|
|
218
|
+
"""Get the latest available version from migrations."""
|
|
219
|
+
versions = set()
|
|
220
|
+
for m in migrations:
|
|
221
|
+
versions.add(m["source_version"])
|
|
222
|
+
versions.add(m["target_version"])
|
|
223
|
+
return max(versions) if versions else 0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def apply_migration(
|
|
227
|
+
cursor: sqlite3.Cursor,
|
|
228
|
+
migration: dict[str, Any],
|
|
229
|
+
dry_run: bool = False,
|
|
230
|
+
) -> bool:
|
|
231
|
+
"""Apply a single migration.
|
|
232
|
+
|
|
233
|
+
Returns True if successful, False otherwise.
|
|
234
|
+
"""
|
|
235
|
+
script_name = migration["script_name"]
|
|
236
|
+
is_revert = migration["type"] == "revert"
|
|
237
|
+
|
|
238
|
+
print(f"Applying {script_name}...")
|
|
239
|
+
print(f" {migration['description']}")
|
|
240
|
+
|
|
241
|
+
if dry_run:
|
|
242
|
+
print(" [DRY RUN] Would apply migration")
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
module = load_migration_module(script_name)
|
|
247
|
+
module.migrate(cursor)
|
|
248
|
+
|
|
249
|
+
# Validate the migration
|
|
250
|
+
if hasattr(module, "validate"):
|
|
251
|
+
if not module.validate(cursor):
|
|
252
|
+
raise RuntimeError(f"Migration validation failed for {script_name}")
|
|
253
|
+
|
|
254
|
+
# Update schema_version table
|
|
255
|
+
if is_revert:
|
|
256
|
+
# Remove the version record we're reverting from
|
|
257
|
+
cursor.execute(
|
|
258
|
+
"DELETE FROM schema_version WHERE version = ?",
|
|
259
|
+
(migration["source_version"],),
|
|
260
|
+
)
|
|
261
|
+
else:
|
|
262
|
+
# Get revert script name
|
|
263
|
+
revert_script = f"db_revert_{migration['target_version']}_to_{migration['source_version']}.py"
|
|
264
|
+
cursor.execute(
|
|
265
|
+
"""
|
|
266
|
+
INSERT INTO schema_version
|
|
267
|
+
(version, migrate_script, revert_script, description, applied_at)
|
|
268
|
+
VALUES (?, ?, ?, ?, ?)
|
|
269
|
+
""",
|
|
270
|
+
(
|
|
271
|
+
migration["target_version"],
|
|
272
|
+
script_name,
|
|
273
|
+
revert_script,
|
|
274
|
+
migration["description"],
|
|
275
|
+
datetime.now().isoformat(),
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
print(" Done.")
|
|
280
|
+
return True
|
|
281
|
+
|
|
282
|
+
except Exception as e:
|
|
283
|
+
print(f" ERROR: {e}")
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
288
|
+
"""Show current database status."""
|
|
289
|
+
db_path = get_db_path(args.database)
|
|
290
|
+
|
|
291
|
+
if not Path(db_path).exists():
|
|
292
|
+
print(f"Database not found at: {db_path}")
|
|
293
|
+
print("Current version: 0 (database will be created when app starts)")
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
conn = get_connection(db_path)
|
|
297
|
+
cursor = conn.cursor()
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
ensure_schema_version_table(cursor)
|
|
301
|
+
current_version = get_current_version(cursor)
|
|
302
|
+
|
|
303
|
+
# If version is 0, check if we need to bootstrap
|
|
304
|
+
if current_version == 0:
|
|
305
|
+
detected = detect_current_state(cursor)
|
|
306
|
+
if detected > 0:
|
|
307
|
+
print(f"Database at: {db_path}")
|
|
308
|
+
print(f"Detected version: {detected} (schema_version table not initialized)")
|
|
309
|
+
print()
|
|
310
|
+
print("Run 'up' to initialize schema_version table and bring to latest version.")
|
|
311
|
+
conn.close()
|
|
312
|
+
return 0
|
|
313
|
+
|
|
314
|
+
migrations = get_available_migrations()
|
|
315
|
+
latest_version = get_latest_version(migrations)
|
|
316
|
+
|
|
317
|
+
print(f"Database at: {db_path}")
|
|
318
|
+
print(f"Current version: {current_version}")
|
|
319
|
+
print(f"Latest version: {latest_version}")
|
|
320
|
+
|
|
321
|
+
if current_version < latest_version:
|
|
322
|
+
print()
|
|
323
|
+
print("Available migrations:")
|
|
324
|
+
path = find_migration_path(current_version, latest_version, migrations)
|
|
325
|
+
for m in path:
|
|
326
|
+
print(f" {m['script_name']}: {m['description']}")
|
|
327
|
+
elif current_version == latest_version:
|
|
328
|
+
print()
|
|
329
|
+
print("Database is up to date.")
|
|
330
|
+
|
|
331
|
+
conn.close()
|
|
332
|
+
return 0
|
|
333
|
+
|
|
334
|
+
except Exception as e:
|
|
335
|
+
print(f"Error: {e}")
|
|
336
|
+
conn.close()
|
|
337
|
+
return 1
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def cmd_up(args: argparse.Namespace) -> int:
|
|
341
|
+
"""Migrate up to target version."""
|
|
342
|
+
db_path = get_db_path(args.database)
|
|
343
|
+
|
|
344
|
+
if not Path(db_path).exists():
|
|
345
|
+
print(f"Database not found at: {db_path}")
|
|
346
|
+
print("The database will be created when the app starts.")
|
|
347
|
+
print("Run the app first, then run migrations if needed.")
|
|
348
|
+
return 1
|
|
349
|
+
|
|
350
|
+
conn = get_connection(db_path)
|
|
351
|
+
cursor = conn.cursor()
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
ensure_schema_version_table(cursor)
|
|
355
|
+
conn.commit()
|
|
356
|
+
|
|
357
|
+
current_version = get_current_version(cursor)
|
|
358
|
+
|
|
359
|
+
# Bootstrap if needed
|
|
360
|
+
if current_version == 0:
|
|
361
|
+
detected = detect_current_state(cursor)
|
|
362
|
+
if detected > 0:
|
|
363
|
+
print(f"Bootstrapping schema_version table at version {detected}...")
|
|
364
|
+
bootstrap_schema_version(cursor)
|
|
365
|
+
conn.commit()
|
|
366
|
+
current_version = detected
|
|
367
|
+
|
|
368
|
+
migrations = get_available_migrations()
|
|
369
|
+
latest_version = get_latest_version(migrations)
|
|
370
|
+
target_version = args.to if args.to is not None else latest_version
|
|
371
|
+
|
|
372
|
+
if target_version < current_version:
|
|
373
|
+
print(f"Target version {target_version} is less than current version {current_version}.")
|
|
374
|
+
print("Use 'down' command to revert.")
|
|
375
|
+
conn.close()
|
|
376
|
+
return 1
|
|
377
|
+
|
|
378
|
+
if current_version == target_version:
|
|
379
|
+
print(f"Already at version {current_version}. Nothing to do.")
|
|
380
|
+
conn.close()
|
|
381
|
+
return 0
|
|
382
|
+
|
|
383
|
+
print(f"Migrating from version {current_version} to {target_version}...")
|
|
384
|
+
print()
|
|
385
|
+
|
|
386
|
+
path = find_migration_path(current_version, target_version, migrations)
|
|
387
|
+
|
|
388
|
+
for migration in path:
|
|
389
|
+
if not apply_migration(cursor, migration, args.dry_run):
|
|
390
|
+
print()
|
|
391
|
+
print("Migration failed. Rolling back...")
|
|
392
|
+
conn.rollback()
|
|
393
|
+
conn.close()
|
|
394
|
+
return 1
|
|
395
|
+
|
|
396
|
+
if not args.dry_run:
|
|
397
|
+
conn.commit()
|
|
398
|
+
|
|
399
|
+
print()
|
|
400
|
+
print(f"Successfully migrated to version {target_version}.")
|
|
401
|
+
conn.close()
|
|
402
|
+
return 0
|
|
403
|
+
|
|
404
|
+
except Exception as e:
|
|
405
|
+
print(f"Error: {e}")
|
|
406
|
+
conn.rollback()
|
|
407
|
+
conn.close()
|
|
408
|
+
return 1
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def cmd_down(args: argparse.Namespace) -> int:
|
|
412
|
+
"""Revert down to target version."""
|
|
413
|
+
db_path = get_db_path(args.database)
|
|
414
|
+
|
|
415
|
+
if not Path(db_path).exists():
|
|
416
|
+
print(f"Database not found at: {db_path}")
|
|
417
|
+
return 1
|
|
418
|
+
|
|
419
|
+
if args.to is None:
|
|
420
|
+
print("Error: --to VERSION is required for down command")
|
|
421
|
+
return 1
|
|
422
|
+
|
|
423
|
+
conn = get_connection(db_path)
|
|
424
|
+
cursor = conn.cursor()
|
|
425
|
+
|
|
426
|
+
try:
|
|
427
|
+
ensure_schema_version_table(cursor)
|
|
428
|
+
current_version = get_current_version(cursor)
|
|
429
|
+
|
|
430
|
+
# Bootstrap if needed
|
|
431
|
+
if current_version == 0:
|
|
432
|
+
detected = detect_current_state(cursor)
|
|
433
|
+
if detected > 0:
|
|
434
|
+
print(f"Bootstrapping schema_version table at version {detected}...")
|
|
435
|
+
bootstrap_schema_version(cursor)
|
|
436
|
+
conn.commit()
|
|
437
|
+
current_version = detected
|
|
438
|
+
|
|
439
|
+
target_version = args.to
|
|
440
|
+
|
|
441
|
+
if target_version > current_version:
|
|
442
|
+
print(f"Target version {target_version} is greater than current version {current_version}.")
|
|
443
|
+
print("Use 'up' command to migrate forward.")
|
|
444
|
+
conn.close()
|
|
445
|
+
return 1
|
|
446
|
+
|
|
447
|
+
if target_version < 0:
|
|
448
|
+
print("Target version cannot be negative.")
|
|
449
|
+
conn.close()
|
|
450
|
+
return 1
|
|
451
|
+
|
|
452
|
+
if current_version == target_version:
|
|
453
|
+
print(f"Already at version {current_version}. Nothing to do.")
|
|
454
|
+
conn.close()
|
|
455
|
+
return 0
|
|
456
|
+
|
|
457
|
+
print(f"Reverting from version {current_version} to {target_version}...")
|
|
458
|
+
print("WARNING: This may result in data loss!")
|
|
459
|
+
print()
|
|
460
|
+
|
|
461
|
+
migrations = get_available_migrations()
|
|
462
|
+
path = find_migration_path(current_version, target_version, migrations)
|
|
463
|
+
|
|
464
|
+
for migration in path:
|
|
465
|
+
if not apply_migration(cursor, migration, args.dry_run):
|
|
466
|
+
print()
|
|
467
|
+
print("Revert failed. Rolling back...")
|
|
468
|
+
conn.rollback()
|
|
469
|
+
conn.close()
|
|
470
|
+
return 1
|
|
471
|
+
|
|
472
|
+
if not args.dry_run:
|
|
473
|
+
conn.commit()
|
|
474
|
+
|
|
475
|
+
print()
|
|
476
|
+
print(f"Successfully reverted to version {target_version}.")
|
|
477
|
+
conn.close()
|
|
478
|
+
return 0
|
|
479
|
+
|
|
480
|
+
except Exception as e:
|
|
481
|
+
print(f"Error: {e}")
|
|
482
|
+
conn.rollback()
|
|
483
|
+
conn.close()
|
|
484
|
+
return 1
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def cmd_history(args: argparse.Namespace) -> int:
|
|
488
|
+
"""Show migration history."""
|
|
489
|
+
db_path = get_db_path(args.database)
|
|
490
|
+
|
|
491
|
+
if not Path(db_path).exists():
|
|
492
|
+
print(f"Database not found at: {db_path}")
|
|
493
|
+
return 1
|
|
494
|
+
|
|
495
|
+
conn = get_connection(db_path)
|
|
496
|
+
cursor = conn.cursor()
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
# Check if schema_version table exists
|
|
500
|
+
cursor.execute(
|
|
501
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'"
|
|
502
|
+
)
|
|
503
|
+
if not cursor.fetchone():
|
|
504
|
+
print("No migration history (schema_version table does not exist).")
|
|
505
|
+
conn.close()
|
|
506
|
+
return 0
|
|
507
|
+
|
|
508
|
+
cursor.execute(
|
|
509
|
+
"""
|
|
510
|
+
SELECT version, migrate_script, description, applied_at
|
|
511
|
+
FROM schema_version
|
|
512
|
+
ORDER BY version
|
|
513
|
+
"""
|
|
514
|
+
)
|
|
515
|
+
rows = cursor.fetchall()
|
|
516
|
+
|
|
517
|
+
if not rows:
|
|
518
|
+
print("No migrations have been applied.")
|
|
519
|
+
else:
|
|
520
|
+
print("Migration history:")
|
|
521
|
+
print()
|
|
522
|
+
print(f"{'Version':<10} {'Applied At':<25} {'Description'}")
|
|
523
|
+
print("-" * 80)
|
|
524
|
+
for row in rows:
|
|
525
|
+
version, script, description, applied_at = row
|
|
526
|
+
print(f"{version:<10} {applied_at:<25} {description}")
|
|
527
|
+
|
|
528
|
+
conn.close()
|
|
529
|
+
return 0
|
|
530
|
+
|
|
531
|
+
except Exception as e:
|
|
532
|
+
print(f"Error: {e}")
|
|
533
|
+
conn.close()
|
|
534
|
+
return 1
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def main() -> int:
|
|
538
|
+
"""Main entry point."""
|
|
539
|
+
parser = argparse.ArgumentParser(
|
|
540
|
+
description="Database migration runner for WineBox",
|
|
541
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
542
|
+
epilog="""
|
|
543
|
+
Examples:
|
|
544
|
+
%(prog)s status Show current version and available migrations
|
|
545
|
+
%(prog)s up Migrate to latest version
|
|
546
|
+
%(prog)s up --to 2 Migrate to specific version
|
|
547
|
+
%(prog)s down --to 0 Revert to specific version
|
|
548
|
+
%(prog)s history Show migration history
|
|
549
|
+
""",
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
parser.add_argument(
|
|
553
|
+
"-d", "--database",
|
|
554
|
+
help=f"Path to database file (default: {DEFAULT_DB_PATH})",
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
|
558
|
+
|
|
559
|
+
# status command
|
|
560
|
+
status_parser = subparsers.add_parser("status", help="Show current database status")
|
|
561
|
+
status_parser.set_defaults(func=cmd_status)
|
|
562
|
+
|
|
563
|
+
# up command
|
|
564
|
+
up_parser = subparsers.add_parser("up", help="Migrate up to target version")
|
|
565
|
+
up_parser.add_argument(
|
|
566
|
+
"--to",
|
|
567
|
+
type=int,
|
|
568
|
+
help="Target version (default: latest)",
|
|
569
|
+
)
|
|
570
|
+
up_parser.add_argument(
|
|
571
|
+
"--dry-run",
|
|
572
|
+
action="store_true",
|
|
573
|
+
help="Show what would be done without applying changes",
|
|
574
|
+
)
|
|
575
|
+
up_parser.set_defaults(func=cmd_up)
|
|
576
|
+
|
|
577
|
+
# down command
|
|
578
|
+
down_parser = subparsers.add_parser("down", help="Revert down to target version")
|
|
579
|
+
down_parser.add_argument(
|
|
580
|
+
"--to",
|
|
581
|
+
type=int,
|
|
582
|
+
required=True,
|
|
583
|
+
help="Target version to revert to",
|
|
584
|
+
)
|
|
585
|
+
down_parser.add_argument(
|
|
586
|
+
"--dry-run",
|
|
587
|
+
action="store_true",
|
|
588
|
+
help="Show what would be done without applying changes",
|
|
589
|
+
)
|
|
590
|
+
down_parser.set_defaults(func=cmd_down)
|
|
591
|
+
|
|
592
|
+
# history command
|
|
593
|
+
history_parser = subparsers.add_parser("history", help="Show migration history")
|
|
594
|
+
history_parser.set_defaults(func=cmd_history)
|
|
595
|
+
|
|
596
|
+
args = parser.parse_args()
|
|
597
|
+
|
|
598
|
+
if not args.command:
|
|
599
|
+
parser.print_help()
|
|
600
|
+
return 1
|
|
601
|
+
|
|
602
|
+
return args.func(args)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
if __name__ == "__main__":
|
|
606
|
+
sys.exit(main())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|