From 199565e77ce4da15d1b97cef033ddb288c5f61c2 Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Sun, 24 May 2026 05:02:24 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20migration=20initiale=20=E2=80=94=20sch?= =?UTF-8?q?=C3=A9mas=20todos/shopping/notes=20et=20toutes=20les=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/alembic.ini | 39 ++++++ backend/alembic/env.py | 52 ++++++++ backend/alembic/script.py.mako | 22 ++++ backend/alembic/versions/001_initial.py | 162 ++++++++++++++++++++++++ 4 files changed, 275 insertions(+) create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/001_initial.py diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..994cbc6 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..88ea650 --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..36cd8ef --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/001_initial.py b/backend/alembic/versions/001_initial.py new file mode 100644 index 0000000..6718fe8 --- /dev/null +++ b/backend/alembic/versions/001_initial.py @@ -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")