# HomeHub Phase 1 — Socle Technique > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Poser toute l'infrastructure de HomeHub : Docker Compose (3 services obligatoires), FastAPI async avec PostgreSQL multi-schémas, module media avec génération de miniatures, et scaffold React PWA avec design system Gruvbox. **Architecture:** FastAPI (Python 3.12, async SQLAlchemy 2.0) connecté à PostgreSQL 16 via asyncpg. Alembic crée 3 schémas (`todos`, `shopping`, `notes`) avec toutes les tables. Le module media gère l'upload, la validation et la génération de miniatures Pillow. Le frontend React 18 + Vite + TypeScript intègre les tokens CSS Gruvbox, vite-plugin-pwa, et un layout responsive mobile/laptop. **Tech Stack:** FastAPI 0.115 · SQLAlchemy 2.0 async · Alembic · Pillow · pytest-asyncio · React 18 · Vite 5 · TypeScript 5 · Tailwind CSS 3 · vite-plugin-pwa · react-router-dom v6 --- ## Cartographie des fichiers ``` home_hub/ ├── backend/ │ ├── app/ │ │ ├── __init__.py │ │ ├── main.py # App FastAPI, routers, CORS │ │ ├── api/ │ │ │ ├── __init__.py │ │ │ ├── health.py # GET /api/health │ │ │ └── media.py # POST /api/media/upload, DELETE /api/media/{uuid} │ │ ├── core/ │ │ │ ├── __init__.py │ │ │ ├── config.py # Settings via pydantic-settings │ │ │ └── database.py # Engine async, session, Base │ │ ├── models/ │ │ │ ├── __init__.py │ │ │ ├── todos.py # TodoItem │ │ │ ├── shopping.py # Product, Store, PriceHistory, ShoppingList, ListItem │ │ │ └── notes.py # NoteItem, NoteAttachment │ │ ├── schemas/ │ │ │ ├── __init__.py │ │ │ └── media.py # MediaUploadResponse │ │ ├── services/ │ │ │ ├── __init__.py │ │ │ └── media.py # save_image(), save_audio(), delete_media() │ │ └── data/ │ │ ├── seed.py # Chargement seed_products.json + seed_stores.json │ │ ├── seed_products.json # déjà présent │ │ └── seed_stores.json # déjà présent │ ├── tests/ │ │ ├── __init__.py │ │ ├── conftest.py # Fixtures pytest (client HTTPX) │ │ ├── test_health.py │ │ └── test_media.py │ ├── alembic/ │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions/ │ │ └── 001_initial.py # Schémas + toutes les tables │ ├── alembic.ini │ ├── Dockerfile │ ├── pytest.ini │ └── requirements.txt ├── frontend/ │ ├── public/ │ │ ├── manifest.json │ │ └── icons/ │ │ ├── icon-192.png # placeholder SVG→PNG │ │ └── icon-512.png │ ├── src/ │ │ ├── main.tsx │ │ ├── App.tsx │ │ ├── index.css # @import tokens.css + tailwind directives │ │ ├── design-system/ │ │ │ ├── tokens.css # copié depuis design_system/tokens/tokens.css │ │ │ └── ui-kit.tsx # adapté depuis design_system/components/ui-kit.jsx │ │ ├── components/layout/ │ │ │ ├── Layout.tsx │ │ │ ├── BottomNav.tsx │ │ │ └── SideNav.tsx │ │ └── pages/ │ │ └── HomePage.tsx │ ├── index.html │ ├── package.json │ ├── vite.config.ts │ ├── tailwind.config.ts │ ├── postcss.config.js │ ├── tsconfig.json │ └── Dockerfile ├── docker-compose.yml ├── docker-compose.dev.yml ├── .env.example └── dev.sh ``` --- ## Tâche 1 : Infrastructure Docker et variables d'environnement **Fichiers :** - Créer : `docker-compose.yml` - Créer : `.env.example` - Créer : `backend/` (structure de dossiers) - [ ] **Étape 1 : Créer la structure de dossiers backend** ```bash mkdir -p backend/app/{api,core,models,schemas,services,data} mkdir -p backend/tests mkdir -p backend/alembic/versions touch backend/app/__init__.py touch backend/app/api/__init__.py touch backend/app/core/__init__.py touch backend/app/models/__init__.py touch backend/app/schemas/__init__.py touch backend/app/services/__init__.py touch backend/tests/__init__.py ``` - [ ] **Étape 2 : Créer `.env.example`** ```bash # .env.example DATABASE_URL=postgresql+asyncpg://homehub:homehub@db:5432/homehub UPLOAD_DIR=/uploads CORS_ORIGINS=http://localhost:3000,http://frontend:3000 ``` - [ ] **Étape 3 : Créer `docker-compose.yml`** ```yaml # docker-compose.yml services: db: image: postgres:16-alpine environment: POSTGRES_USER: homehub POSTGRES_PASSWORD: homehub POSTGRES_DB: homehub volumes: - db_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U homehub"] interval: 5s timeout: 5s retries: 10 backend: build: ./backend environment: DATABASE_URL: postgresql+asyncpg://homehub:homehub@db:5432/homehub UPLOAD_DIR: /uploads CORS_ORIGINS: http://localhost:3000,http://frontend:3000 volumes: - uploads:/uploads ports: - "8000:8000" depends_on: db: condition: service_healthy frontend: build: ./frontend ports: - "3000:80" depends_on: - backend volumes: db_data: uploads: ``` - [ ] **Étape 4 : Committer** ```bash git add docker-compose.yml .env.example backend/ git commit -m "chore: structure initiale backend et docker-compose" ``` --- ## Tâche 2 : Backend — requirements.txt et Dockerfile **Fichiers :** - Créer : `backend/requirements.txt` - Créer : `backend/Dockerfile` - Créer : `backend/pytest.ini` - [ ] **Étape 1 : Créer `backend/requirements.txt`** ``` fastapi==0.115.5 uvicorn[standard]==0.32.1 sqlalchemy[asyncio]==2.0.36 asyncpg==0.30.0 alembic==1.14.0 pydantic-settings==2.6.1 pillow==11.1.0 python-multipart==0.0.20 httpx==0.28.0 pytest==8.3.4 pytest-asyncio==0.24.0 ``` - [ ] **Étape 2 : Créer `backend/Dockerfile`** ```dockerfile # backend/Dockerfile FROM python:3.12-slim WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ libpq-dev gcc \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] ``` - [ ] **Étape 3 : Créer `backend/pytest.ini`** ```ini [pytest] asyncio_mode = auto testpaths = tests ``` - [ ] **Étape 4 : Committer** ```bash git add backend/requirements.txt backend/Dockerfile backend/pytest.ini git commit -m "chore: dockerfile backend et dépendances python" ``` --- ## Tâche 3 : Backend — Configuration et base de données **Fichiers :** - Créer : `backend/app/core/config.py` - Créer : `backend/app/core/database.py` - [ ] **Étape 1 : Créer `backend/app/core/config.py`** ```python # backend/app/core/config.py from pydantic_settings import BaseSettings, SettingsConfigDict from pathlib import Path class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") database_url: str = "postgresql+asyncpg://homehub:homehub@localhost:5432/homehub" upload_dir: str = "/uploads" cors_origins: str = "http://localhost:3000" @property def cors_origins_list(self) -> list[str]: return [o.strip() for o in self.cors_origins.split(",")] @property def upload_path(self) -> Path: return Path(self.upload_dir) settings = Settings() ``` - [ ] **Étape 2 : Créer `backend/app/core/database.py`** ```python # backend/app/core/database.py from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession from sqlalchemy.orm import DeclarativeBase from app.core.config import settings engine = create_async_engine(settings.database_url, pool_pre_ping=True) AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) class Base(DeclarativeBase): pass async def get_session() -> AsyncSession: async with AsyncSessionLocal() as session: yield session ``` - [ ] **Étape 3 : Committer** ```bash git add backend/app/core/ git commit -m "feat: configuration FastAPI et moteur SQLAlchemy async" ``` --- ## Tâche 4 : Backend — Modèles SQLAlchemy **Fichiers :** - Créer : `backend/app/models/todos.py` - Créer : `backend/app/models/shopping.py` - Créer : `backend/app/models/notes.py` - [ ] **Étape 1 : Créer `backend/app/models/todos.py`** ```python # backend/app/models/todos.py import uuid from datetime import datetime from sqlalchemy import String, Text, Integer, TIMESTAMP, text from sqlalchemy.dialects.postgresql import UUID, ARRAY from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base class TodoItem(Base): __tablename__ = "items" __table_args__ = {"schema": "todos"} id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) title: Mapped[str] = mapped_column(String(255), nullable=False) body: Mapped[str | None] = mapped_column(Text) url: Mapped[str | None] = mapped_column(Text) domain: Mapped[str | None] = mapped_column(String(50)) category: Mapped[str | None] = mapped_column(String(50)) tags: Mapped[list[str]] = mapped_column(ARRAY(String(50)), server_default=text("'{}'::varchar[]")) status: Mapped[str] = mapped_column(String(20), server_default="pending") priority: Mapped[str] = mapped_column(String(10), server_default="medium") due_date: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) postponed_count: Mapped[int] = mapped_column(Integer, server_default=text("0")) created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()")) updated_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) ``` - [ ] **Étape 2 : Créer `backend/app/models/shopping.py`** ```python # backend/app/models/shopping.py import uuid from datetime import datetime, date from decimal import Decimal from sqlalchemy import String, Text, Integer, TIMESTAMP, Date, Numeric, Boolean, ForeignKey, text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base class Product(Base): __tablename__ = "products" __table_args__ = {"schema": "shopping"} id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name: Mapped[str] = mapped_column(String(150), nullable=False) brand: Mapped[str | None] = mapped_column(String(100)) category: Mapped[str | None] = mapped_column(String(50)) image_path: Mapped[str | None] = mapped_column(String(255)) thumbnail_path: Mapped[str | None] = mapped_column(String(255)) default_unit: Mapped[str | None] = mapped_column(String(20)) barcode: Mapped[str | None] = mapped_column(String(50)) frequency_score: Mapped[int] = mapped_column(Integer, server_default=text("0")) owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()")) class Store(Base): __tablename__ = "stores" __table_args__ = {"schema": "shopping"} id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name: Mapped[str] = mapped_column(String(100), nullable=False) location: Mapped[str | None] = mapped_column(Text) owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) class PriceHistory(Base): __tablename__ = "price_history" __table_args__ = {"schema": "shopping"} id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) product_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.products.id", ondelete="CASCADE"), nullable=False) store_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.stores.id", ondelete="SET NULL")) price: Mapped[Decimal] = mapped_column(Numeric(8, 2), nullable=False) unit: Mapped[str | None] = mapped_column(String(20)) quantity: Mapped[Decimal | None] = mapped_column(Numeric(8, 3)) source: Mapped[str] = mapped_column(String(20), server_default="manual") recorded_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()")) class ShoppingList(Base): __tablename__ = "lists" __table_args__ = {"schema": "shopping"} id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) name: Mapped[str | None] = mapped_column(String(100)) store_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.stores.id", ondelete="SET NULL")) week_date: Mapped[date | None] = mapped_column(Date) status: Mapped[str] = mapped_column(String(20), server_default="draft") owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()")) items: Mapped[list["ListItem"]] = relationship("ListItem", back_populates="shopping_list", cascade="all, delete-orphan") class ListItem(Base): __tablename__ = "list_items" __table_args__ = {"schema": "shopping"} id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) list_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.lists.id", ondelete="CASCADE"), nullable=False) product_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.products.id", ondelete="SET NULL")) custom_name: Mapped[str | None] = mapped_column(String(150)) quantity: Mapped[Decimal | None] = mapped_column(Numeric(8, 3)) unit: Mapped[str | None] = mapped_column(String(20)) is_checked: Mapped[bool] = mapped_column(Boolean, server_default=text("false")) price_recorded: Mapped[Decimal | None] = mapped_column(Numeric(8, 2)) carried_over: Mapped[bool] = mapped_column(Boolean, server_default=text("false")) sort_order: Mapped[int | None] = mapped_column(Integer) shopping_list: Mapped["ShoppingList"] = relationship("ShoppingList", back_populates="items") ``` - [ ] **Étape 3 : Créer `backend/app/models/notes.py`** ```python # backend/app/models/notes.py import uuid from datetime import datetime from decimal import Decimal from sqlalchemy import String, Text, TIMESTAMP, Numeric, ForeignKey, text from sqlalchemy.dialects.postgresql import UUID, ARRAY, JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base class NoteItem(Base): __tablename__ = "items" __table_args__ = {"schema": "notes"} id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) title: Mapped[str | None] = mapped_column(String(255)) content: Mapped[str] = mapped_column(Text, nullable=False) category: Mapped[str | None] = mapped_column(String(50)) tags: Mapped[list[str]] = mapped_column(ARRAY(String(50)), server_default=text("'{}'::varchar[]")) gps_lat: Mapped[Decimal | None] = mapped_column(Numeric(10, 7)) gps_lon: Mapped[Decimal | None] = mapped_column(Numeric(10, 7)) metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB) created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()")) owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) attachments: Mapped[list["NoteAttachment"]] = relationship("NoteAttachment", back_populates="note", cascade="all, delete-orphan") class NoteAttachment(Base): __tablename__ = "attachments" __table_args__ = {"schema": "notes"} id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) note_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("notes.items.id", ondelete="CASCADE"), nullable=False) file_path: Mapped[str | None] = mapped_column(String(255)) thumbnail_path: Mapped[str | None] = mapped_column(String(255)) file_type: Mapped[str | None] = mapped_column(String(20)) original_name: Mapped[str | None] = mapped_column(String(255)) created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()")) note: Mapped["NoteItem"] = relationship("NoteItem", back_populates="attachments") ``` - [ ] **Étape 4 : Committer** ```bash git add backend/app/models/ git commit -m "feat: modèles SQLAlchemy pour todos, shopping et notes" ``` --- ## Tâche 5 : Backend — main.py et endpoint health **Fichiers :** - Créer : `backend/app/main.py` - Créer : `backend/app/api/health.py` - Créer : `backend/tests/conftest.py` - Créer : `backend/tests/test_health.py` - [ ] **Étape 1 : Écrire le test en premier** ```python # backend/tests/test_health.py async def test_health_retourne_ok(client): response = await client.get("/api/health") assert response.status_code == 200 assert response.json() == {"status": "ok"} ``` - [ ] **Étape 2 : Créer `backend/tests/conftest.py`** ```python # backend/tests/conftest.py import pytest from httpx import AsyncClient, ASGITransport from app.main import app @pytest.fixture async def client(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: yield ac ``` - [ ] **Étape 3 : Lancer le test — doit échouer** ```bash cd backend && python -m pytest tests/test_health.py -v ``` Résultat attendu : `FAILED` — `ImportError: cannot import name 'app'` - [ ] **Étape 4 : Créer `backend/app/api/health.py`** ```python # backend/app/api/health.py from fastapi import APIRouter router = APIRouter() @router.get("/health") async def health(): return {"status": "ok"} ``` - [ ] **Étape 5 : Créer `backend/app/main.py`** ```python # backend/app/main.py from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.health import router as health_router from app.api.media import router as media_router from app.core.config import settings app = FastAPI(title="HomeHub API", version="0.1.0") app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(health_router, prefix="/api") app.include_router(media_router, prefix="/api/media") ``` Note : `media_router` sera créé à la tâche 7. Pour l'instant, créer un routeur vide temporaire : ```python # backend/app/api/media.py (version temporaire) from fastapi import APIRouter router = APIRouter() ``` - [ ] **Étape 6 : Lancer le test — doit passer** ```bash cd backend && python -m pytest tests/test_health.py -v ``` Résultat attendu : ``` tests/test_health.py::test_health_retourne_ok PASSED ``` - [ ] **Étape 7 : Committer** ```bash git add backend/app/main.py backend/app/api/health.py backend/app/api/media.py backend/tests/ git commit -m "feat: endpoint GET /api/health + tests" ``` --- ## Tâche 6 : Alembic — migration initiale (schémas + tables) **Fichiers :** - Créer : `backend/alembic.ini` - Créer : `backend/alembic/env.py` - Créer : `backend/alembic/script.py.mako` - Créer : `backend/alembic/versions/001_initial.py` - [ ] **Étape 1 : Créer `backend/alembic.ini`** ```ini [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 ``` - [ ] **Étape 2 : Créer `backend/alembic/script.py.mako`** ```mako """${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"} ``` - [ ] **Étape 3 : Créer `backend/alembic/env.py`** ```python # backend/alembic/env.py 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() ``` - [ ] **Étape 4 : Créer `backend/alembic/versions/001_initial.py`** ```python # backend/alembic/versions/001_initial.py """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") ``` - [ ] **Étape 5 : Lancer les migrations (PostgreSQL doit tourner)** ```bash cd backend && docker compose up db -d # Attendre que postgres soit prêt (healthcheck) DATABASE_URL=postgresql+asyncpg://homehub:homehub@localhost:5432/homehub alembic upgrade head ``` Résultat attendu : ``` INFO [alembic.runtime.migration] Running upgrade -> 001, Schémas initiaux et toutes les tables ``` - [ ] **Étape 6 : Vérifier les schémas en base** ```bash docker exec -it homehub-db-1 psql -U homehub -c "\dn" ``` Résultat attendu : 3 schémas `notes`, `shopping`, `todos` listés. - [ ] **Étape 7 : Committer** ```bash git add backend/alembic.ini backend/alembic/ git commit -m "feat: migration initiale — schémas todos/shopping/notes et toutes les tables" ``` --- ## Tâche 7 : Module media — service, schéma et endpoint **Fichiers :** - Créer : `backend/app/services/media.py` - Créer : `backend/app/schemas/media.py` - Modifier : `backend/app/api/media.py` - Créer : `backend/tests/test_media.py` - [ ] **Étape 1 : Écrire les tests en premier** ```python # backend/tests/test_media.py import io import pytest from PIL import Image from pathlib import Path def _make_jpeg(width: int = 200, height: int = 200) -> bytes: img = Image.new("RGB", (width, height), color=(120, 80, 40)) buf = io.BytesIO() img.save(buf, format="JPEG") return buf.getvalue() async def test_upload_image_retourne_chemins(client, tmp_path, monkeypatch): from app.services import media as media_svc monkeypatch.setattr(media_svc, "UPLOAD_DIR", tmp_path) response = await client.post( "/api/media/upload", files={"file": ("photo.jpg", _make_jpeg(), "image/jpeg")}, params={"context": "note"}, ) assert response.status_code == 200 data = response.json() assert "file_id" in data assert data["file_path"].startswith("images/originals/") assert data["thumbnail_path"].startswith("images/thumbnails/") async def test_upload_format_invalide_retourne_400(client, tmp_path, monkeypatch): from app.services import media as media_svc monkeypatch.setattr(media_svc, "UPLOAD_DIR", tmp_path) response = await client.post( "/api/media/upload", files={"file": ("doc.pdf", b"contenu pdf", "application/pdf")}, ) assert response.status_code == 400 async def test_miniature_produit_max_150px(client, tmp_path, monkeypatch): from app.services import media as media_svc monkeypatch.setattr(media_svc, "UPLOAD_DIR", tmp_path) response = await client.post( "/api/media/upload", files={"file": ("prod.jpg", _make_jpeg(800, 600), "image/jpeg")}, params={"context": "product"}, ) assert response.status_code == 200 data = response.json() thumb = Image.open(tmp_path / data["thumbnail_path"]) assert thumb.width <= 150 assert thumb.height <= 150 async def test_upload_audio_retourne_chemin(client, tmp_path, monkeypatch): from app.services import media as media_svc monkeypatch.setattr(media_svc, "UPLOAD_DIR", tmp_path) response = await client.post( "/api/media/upload", files={"file": ("enreg.webm", b"fake webm bytes", "audio/webm")}, params={"context": "note"}, ) assert response.status_code == 200 data = response.json() assert data["file_path"].startswith("audio/") assert data["thumbnail_path"] is None async def test_suppression_media(client, tmp_path, monkeypatch): from app.services import media as media_svc monkeypatch.setattr(media_svc, "UPLOAD_DIR", tmp_path) # Upload d'abord up = await client.post( "/api/media/upload", files={"file": ("photo.jpg", _make_jpeg(), "image/jpeg")}, params={"context": "note"}, ) data = up.json() file_id = data["file_id"] # Suppression response = await client.delete(f"/api/media/{file_id}") assert response.status_code == 204 assert not (tmp_path / data["file_path"]).exists() ``` - [ ] **Étape 2 : Lancer les tests — doivent échouer** ```bash cd backend && python -m pytest tests/test_media.py -v ``` Résultat attendu : `FAILED` — les endpoints n'existent pas encore. - [ ] **Étape 3 : Créer `backend/app/schemas/media.py`** ```python # backend/app/schemas/media.py from pydantic import BaseModel class MediaUploadResponse(BaseModel): file_id: str file_path: str thumbnail_path: str | None file_type: str ``` - [ ] **Étape 4 : Créer `backend/app/services/media.py`** ```python # backend/app/services/media.py import io import uuid from pathlib import Path from fastapi import HTTPException, UploadFile from PIL import Image from app.core.config import settings UPLOAD_DIR: Path = settings.upload_path ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/svg+xml"} ALLOWED_AUDIO_TYPES = {"audio/webm", "audio/mp4"} THUMBNAIL_SIZES = { "product": (150, 150), "note": (300, 300), "attachment": (400, 300), } async def save_image(file: UploadFile, context: str = "note") -> dict: if file.content_type not in ALLOWED_IMAGE_TYPES: raise HTTPException(status_code=400, detail=f"Format non supporté : {file.content_type}") file_id = str(uuid.uuid4()) content = await file.read() orig_dir = UPLOAD_DIR / "images" / "originals" orig_dir.mkdir(parents=True, exist_ok=True) orig_path = orig_dir / f"{file_id}.webp" if file.content_type == "image/svg+xml": orig_path.write_bytes(content) return { "file_id": file_id, "file_path": str(orig_path.relative_to(UPLOAD_DIR)), "thumbnail_path": None, "file_type": "image", } img = Image.open(io.BytesIO(content)).convert("RGB") img.save(orig_path, "WEBP", quality=85) thumb_dir = UPLOAD_DIR / "images" / "thumbnails" thumb_dir.mkdir(parents=True, exist_ok=True) thumb_path = thumb_dir / f"{file_id}_thumb.webp" size = THUMBNAIL_SIZES.get(context, (300, 300)) img_thumb = Image.open(io.BytesIO(content)).convert("RGB") img_thumb.thumbnail(size, Image.LANCZOS) img_thumb.save(thumb_path, "WEBP", quality=80) return { "file_id": file_id, "file_path": str(orig_path.relative_to(UPLOAD_DIR)), "thumbnail_path": str(thumb_path.relative_to(UPLOAD_DIR)), "file_type": "image", } async def save_audio(file: UploadFile) -> dict: if file.content_type not in ALLOWED_AUDIO_TYPES: raise HTTPException(status_code=400, detail=f"Format audio non supporté : {file.content_type}") file_id = str(uuid.uuid4()) audio_dir = UPLOAD_DIR / "audio" audio_dir.mkdir(parents=True, exist_ok=True) ext = ".webm" if "webm" in (file.content_type or "") else ".m4a" audio_path = audio_dir / f"{file_id}{ext}" audio_path.write_bytes(await file.read()) return { "file_id": file_id, "file_path": str(audio_path.relative_to(UPLOAD_DIR)), "thumbnail_path": None, "file_type": "audio", } def delete_media(file_id: str, file_path: str, thumbnail_path: str | None = None) -> None: (UPLOAD_DIR / file_path).unlink(missing_ok=True) if thumbnail_path: (UPLOAD_DIR / thumbnail_path).unlink(missing_ok=True) ``` - [ ] **Étape 5 : Remplacer `backend/app/api/media.py` (version complète)** ```python # backend/app/api/media.py from fastapi import APIRouter, UploadFile, File, Query from fastapi.responses import Response from app.schemas.media import MediaUploadResponse from app.services.media import save_image, save_audio, delete_media, ALLOWED_IMAGE_TYPES, ALLOWED_AUDIO_TYPES router = APIRouter() @router.post("/upload", response_model=MediaUploadResponse) async def upload_media( file: UploadFile = File(...), context: str = Query(default="note", pattern="^(product|note|attachment)$"), ): if file.content_type in ALLOWED_IMAGE_TYPES: result = await save_image(file, context=context) elif file.content_type in ALLOWED_AUDIO_TYPES: result = await save_audio(file) else: from fastapi import HTTPException raise HTTPException(status_code=400, detail=f"Type de fichier non supporté : {file.content_type}") return MediaUploadResponse(**result) @router.delete("/{file_id}", status_code=204) async def delete_media_endpoint(file_id: str, file_path: str = Query(...), thumbnail_path: str | None = Query(default=None)): delete_media(file_id=file_id, file_path=file_path, thumbnail_path=thumbnail_path) return Response(status_code=204) ``` - [ ] **Étape 6 : Lancer les tests — doivent passer** ```bash cd backend && python -m pytest tests/ -v ``` Résultat attendu : ``` tests/test_health.py::test_health_retourne_ok PASSED tests/test_media.py::test_upload_image_retourne_chemins PASSED tests/test_media.py::test_upload_format_invalide_retourne_400 PASSED tests/test_media.py::test_miniature_produit_max_150px PASSED tests/test_media.py::test_upload_audio_retourne_chemin PASSED tests/test_media.py::test_suppression_media PASSED ``` - [ ] **Étape 7 : Committer** ```bash git add backend/app/services/media.py backend/app/schemas/media.py backend/app/api/media.py backend/tests/test_media.py git commit -m "feat: module media — upload, miniatures Pillow et suppression" ``` --- ## Tâche 8 : Script de seed — données initiales **Fichiers :** - Créer : `backend/app/data/seed.py` - [ ] **Étape 1 : Créer `backend/app/data/seed.py`** ```python # backend/app/data/seed.py """ Lance le seed uniquement si la table shopping.stores est vide. Usage : python -m app.data.seed """ import asyncio import json from pathlib import Path from sqlalchemy import select, text from app.core.database import AsyncSessionLocal from app.models.shopping import Product, Store DATA_DIR = Path(__file__).parent async def run_seed() -> None: async with AsyncSessionLocal() as session: # Ne rien faire si des données existent déjà count = await session.scalar(select(Store).limit(1)) if count is not None: print("Seed déjà chargé — rien à faire.") return stores_raw = json.loads((DATA_DIR / "seed_stores.json").read_text()) for s in stores_raw: session.add(Store(name=s["name"], location=s.get("location"))) products_raw = json.loads((DATA_DIR / "seed_products.json").read_text()) for p in products_raw: session.add(Product( name=p["name"], category=p.get("category"), default_unit=p.get("default_unit"), frequency_score=p.get("frequency_score", 0), )) await session.commit() print(f"Seed : {len(stores_raw)} magasins, {len(products_raw)} produits chargés.") if __name__ == "__main__": asyncio.run(run_seed()) ``` - [ ] **Étape 2 : Appeler le seed au démarrage de l'app** Dans `backend/app/main.py`, ajouter un lifespan : ```python # backend/app/main.py from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.health import router as health_router from app.api.media import router as media_router from app.core.config import settings from app.data.seed import run_seed @asynccontextmanager async def lifespan(app: FastAPI): await run_seed() yield app = FastAPI(title="HomeHub API", version="0.1.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(health_router, prefix="/api") app.include_router(media_router, prefix="/api/media") ``` - [ ] **Étape 3 : Vérifier que les tests passent toujours** ```bash cd backend && python -m pytest tests/ -v ``` Résultat attendu : tous les tests passent (le lifespan ne se déclenche pas dans les tests HTTPX). - [ ] **Étape 4 : Committer** ```bash git add backend/app/data/seed.py backend/app/main.py git commit -m "feat: seed données initiales (113 produits + 9 magasins) au démarrage" ``` --- ## Tâche 9 : Frontend — scaffold Vite + React + TypeScript **Fichiers :** - Créer : `frontend/package.json` - Créer : `frontend/vite.config.ts` - Créer : `frontend/tsconfig.json` - Créer : `frontend/index.html` - Créer : `frontend/src/main.tsx` - Créer : `frontend/src/App.tsx` - [ ] **Étape 1 : Créer la structure de dossiers frontend** ```bash mkdir -p frontend/src/{components/layout,pages,design-system} mkdir -p frontend/public/icons ``` - [ ] **Étape 2 : Créer `frontend/package.json`** ```json { "name": "homehub-frontend", "private": true, "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview" }, "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.28.0" }, "devDependencies": { "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", "autoprefixer": "^10.4.20", "postcss": "^8.4.49", "tailwindcss": "^3.4.15", "typescript": "^5.6.3", "vite": "^5.4.10", "vite-plugin-pwa": "^0.21.1" } } ``` - [ ] **Étape 3 : Créer `frontend/tsconfig.json`** ```json { "files": [], "references": [ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" } ] } ``` Créer aussi `frontend/tsconfig.app.json` : ```json { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", "strict": true }, "include": ["src"] } ``` Créer aussi `frontend/tsconfig.node.json` : ```json { "compilerOptions": { "target": "ES2022", "lib": ["ES2023"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true }, "include": ["vite.config.ts"] } ``` - [ ] **Étape 4 : Créer `frontend/index.html`** ```html
Application d'organisation personnelle