feat: migration initiale — schémas todos/shopping/notes et toutes les tables

This commit is contained in:
2026-05-24 05:02:24 +02:00
parent 94b971cdf3
commit 199565e77c
4 changed files with 275 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
[alembic]
script_location = alembic
file_template = %%(rev)s_%%(slug)s
prepend_sys_path = .
sqlalchemy.url = postgresql+asyncpg://homehub:homehub@localhost:5432/homehub
[loggers]
keys = root,sqlalchemy,alembic
[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
[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
+52
View File
@@ -0,0 +1,52 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.core.config import settings
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
from app.core.database import Base
from app.models import todos, shopping, notes # noqa: F401 — enregistre les modèles
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+22
View File
@@ -0,0 +1,22 @@
"""${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 = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+162
View File
@@ -0,0 +1,162 @@
"""Schémas initiaux et toutes les tables
Revision ID: 001
Revises:
Create Date: 2026-05-24
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("CREATE SCHEMA IF NOT EXISTS todos")
op.execute("CREATE SCHEMA IF NOT EXISTS shopping")
op.execute("CREATE SCHEMA IF NOT EXISTS notes")
# ── todos.items ──────────────────────────────────────────────
op.create_table(
"items",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("body", sa.Text),
sa.Column("url", sa.Text),
sa.Column("domain", sa.String(50)),
sa.Column("category", sa.String(50)),
sa.Column("tags", postgresql.ARRAY(sa.String(50)), server_default=sa.text("'{}'::varchar[]")),
sa.Column("status", sa.String(20), server_default="pending"),
sa.Column("priority", sa.String(10), server_default="medium"),
sa.Column("due_date", sa.TIMESTAMP(timezone=True)),
sa.Column("postponed_count", sa.Integer, server_default="0"),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()")),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True)),
sa.Column("owner_id", postgresql.UUID(as_uuid=True)),
schema="todos",
)
op.create_index("idx_todos_tags", "items", [sa.text("tags")], schema="todos", postgresql_using="gin")
# ── shopping.products ────────────────────────────────────────
op.create_table(
"products",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("name", sa.String(150), nullable=False),
sa.Column("brand", sa.String(100)),
sa.Column("category", sa.String(50)),
sa.Column("image_path", sa.String(255)),
sa.Column("thumbnail_path", sa.String(255)),
sa.Column("default_unit", sa.String(20)),
sa.Column("barcode", sa.String(50)),
sa.Column("frequency_score", sa.Integer, server_default="0"),
sa.Column("owner_id", postgresql.UUID(as_uuid=True)),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()")),
schema="shopping",
)
# ── shopping.stores ──────────────────────────────────────────
op.create_table(
"stores",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("location", sa.Text),
sa.Column("owner_id", postgresql.UUID(as_uuid=True)),
schema="shopping",
)
# ── shopping.price_history ───────────────────────────────────
op.create_table(
"price_history",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("product_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("shopping.products.id", ondelete="CASCADE"), nullable=False),
sa.Column("store_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("shopping.stores.id", ondelete="SET NULL")),
sa.Column("price", sa.Numeric(8, 2), nullable=False),
sa.Column("unit", sa.String(20)),
sa.Column("quantity", sa.Numeric(8, 3)),
sa.Column("source", sa.String(20), server_default="manual"),
sa.Column("recorded_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()")),
schema="shopping",
)
# ── shopping.lists ───────────────────────────────────────────
op.create_table(
"lists",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("name", sa.String(100)),
sa.Column("store_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("shopping.stores.id", ondelete="SET NULL")),
sa.Column("week_date", sa.Date),
sa.Column("status", sa.String(20), server_default="draft"),
sa.Column("owner_id", postgresql.UUID(as_uuid=True)),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()")),
schema="shopping",
)
# ── shopping.list_items ──────────────────────────────────────
op.create_table(
"list_items",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("list_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("shopping.lists.id", ondelete="CASCADE"), nullable=False),
sa.Column("product_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("shopping.products.id", ondelete="SET NULL")),
sa.Column("custom_name", sa.String(150)),
sa.Column("quantity", sa.Numeric(8, 3)),
sa.Column("unit", sa.String(20)),
sa.Column("is_checked", sa.Boolean, server_default="false"),
sa.Column("price_recorded", sa.Numeric(8, 2)),
sa.Column("carried_over", sa.Boolean, server_default="false"),
sa.Column("sort_order", sa.Integer),
schema="shopping",
)
# ── notes.items ──────────────────────────────────────────────
op.create_table(
"items",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("title", sa.String(255)),
sa.Column("content", sa.Text, nullable=False),
sa.Column("category", sa.String(50)),
sa.Column("tags", postgresql.ARRAY(sa.String(50)), server_default=sa.text("'{}'::varchar[]")),
sa.Column("gps_lat", sa.Numeric(10, 7)),
sa.Column("gps_lon", sa.Numeric(10, 7)),
sa.Column("metadata", postgresql.JSONB),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()")),
sa.Column("owner_id", postgresql.UUID(as_uuid=True)),
schema="notes",
)
op.create_index("idx_notes_tags", "items", [sa.text("tags")], schema="notes", postgresql_using="gin")
op.create_index(
"idx_notes_fts",
"items",
[sa.text("to_tsvector('french', coalesce(title,'') || ' ' || content)")],
schema="notes",
postgresql_using="gin",
)
# ── notes.attachments ────────────────────────────────────────
op.create_table(
"attachments",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("note_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("notes.items.id", ondelete="CASCADE"), nullable=False),
sa.Column("file_path", sa.String(255)),
sa.Column("thumbnail_path", sa.String(255)),
sa.Column("file_type", sa.String(20)),
sa.Column("original_name", sa.String(255)),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()")),
schema="notes",
)
def downgrade() -> None:
op.drop_table("attachments", schema="notes")
op.drop_table("items", schema="notes")
op.drop_table("list_items", schema="shopping")
op.drop_table("lists", schema="shopping")
op.drop_table("price_history", schema="shopping")
op.drop_table("stores", schema="shopping")
op.drop_table("products", schema="shopping")
op.drop_table("items", schema="todos")
op.execute("DROP SCHEMA IF EXISTS notes CASCADE")
op.execute("DROP SCHEMA IF EXISTS shopping CASCADE")
op.execute("DROP SCHEMA IF EXISTS todos CASCADE")