b87c96ceab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2170 lines
65 KiB
Markdown
2170 lines
65 KiB
Markdown
# 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
|
||
<!doctype html>
|
||
<html lang="fr" data-theme="dark">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<meta name="theme-color" content="#fe8019" />
|
||
<link rel="manifest" href="/manifest.json" />
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet" />
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
|
||
<title>HomeHub</title>
|
||
</head>
|
||
<body>
|
||
<div id="root"></div>
|
||
<script type="module" src="/src/main.tsx"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
- [ ] **Étape 5 : Créer `frontend/src/main.tsx`**
|
||
|
||
```tsx
|
||
// frontend/src/main.tsx
|
||
import { StrictMode } from 'react'
|
||
import { createRoot } from 'react-dom/client'
|
||
import './index.css'
|
||
import App from './App'
|
||
|
||
createRoot(document.getElementById('root')!).render(
|
||
<StrictMode>
|
||
<App />
|
||
</StrictMode>,
|
||
)
|
||
```
|
||
|
||
- [ ] **Étape 6 : Créer `frontend/src/App.tsx`**
|
||
|
||
```tsx
|
||
// frontend/src/App.tsx
|
||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||
import Layout from './components/layout/Layout'
|
||
import HomePage from './pages/HomePage'
|
||
|
||
export default function App() {
|
||
return (
|
||
<BrowserRouter>
|
||
<Routes>
|
||
<Route path="/" element={<Layout />}>
|
||
<Route index element={<HomePage />} />
|
||
</Route>
|
||
</Routes>
|
||
</BrowserRouter>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Étape 7 : Installer les dépendances et vérifier que ça compile**
|
||
|
||
```bash
|
||
cd frontend && npm install && npm run build
|
||
```
|
||
|
||
Résultat attendu : build réussi dans `frontend/dist/`.
|
||
|
||
- [ ] **Étape 8 : Committer**
|
||
|
||
```bash
|
||
git add frontend/
|
||
git commit -m "feat: scaffold frontend React 18 + Vite 5 + TypeScript"
|
||
```
|
||
|
||
---
|
||
|
||
## Tâche 10 : Frontend — Tailwind CSS et Design System Gruvbox
|
||
|
||
**Fichiers :**
|
||
- Créer : `frontend/tailwind.config.ts`
|
||
- Créer : `frontend/postcss.config.js`
|
||
- Créer : `frontend/src/index.css`
|
||
- Créer : `frontend/src/design-system/tokens.css`
|
||
- Créer : `frontend/src/design-system/ui-kit.tsx`
|
||
|
||
- [ ] **Étape 1 : Créer `frontend/postcss.config.js`**
|
||
|
||
```js
|
||
// frontend/postcss.config.js
|
||
export default {
|
||
plugins: {
|
||
tailwindcss: {},
|
||
autoprefixer: {},
|
||
},
|
||
}
|
||
```
|
||
|
||
- [ ] **Étape 2 : Créer `frontend/tailwind.config.ts`**
|
||
|
||
```typescript
|
||
// frontend/tailwind.config.ts
|
||
import type { Config } from 'tailwindcss'
|
||
|
||
export default {
|
||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
accent: 'var(--accent)',
|
||
'accent-soft': 'var(--accent-soft)',
|
||
'bg-0': 'var(--bg-0)',
|
||
'bg-1': 'var(--bg-1)',
|
||
'bg-2': 'var(--bg-2)',
|
||
'bg-3': 'var(--bg-3)',
|
||
'bg-4': 'var(--bg-4)',
|
||
'bg-5': 'var(--bg-5)',
|
||
'ink-1': 'var(--ink-1)',
|
||
'ink-2': 'var(--ink-2)',
|
||
'ink-3': 'var(--ink-3)',
|
||
'ink-4': 'var(--ink-4)',
|
||
ok: 'var(--ok)',
|
||
warn: 'var(--warn)',
|
||
err: 'var(--err)',
|
||
info: 'var(--info)',
|
||
blue: 'var(--blue)',
|
||
purple: 'var(--purple)',
|
||
},
|
||
fontFamily: {
|
||
ui: ['Inter', 'system-ui', 'sans-serif'],
|
||
mono: ['"JetBrains Mono"', 'monospace'],
|
||
terminal: ['"Share Tech Mono"', 'monospace'],
|
||
},
|
||
minHeight: {
|
||
touch: '48px',
|
||
},
|
||
},
|
||
},
|
||
plugins: [],
|
||
} satisfies Config
|
||
```
|
||
|
||
- [ ] **Étape 3 : Copier les tokens CSS**
|
||
|
||
```bash
|
||
cp design_system/tokens/tokens.css frontend/src/design-system/tokens.css
|
||
```
|
||
|
||
- [ ] **Étape 4 : Créer `frontend/src/index.css`**
|
||
|
||
```css
|
||
/* frontend/src/index.css */
|
||
@import './design-system/tokens.css';
|
||
|
||
@tailwind base;
|
||
@tailwind components;
|
||
@tailwind utilities;
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: var(--font-ui);
|
||
background-color: var(--bg-1);
|
||
color: var(--ink-1);
|
||
margin: 0;
|
||
}
|
||
```
|
||
|
||
- [ ] **Étape 5 : Adapter `ui-kit.jsx` en module TypeScript**
|
||
|
||
Copier `design_system/components/ui-kit.jsx` vers `frontend/src/design-system/ui-kit.tsx`, puis ajouter en tête du fichier :
|
||
|
||
```tsx
|
||
// frontend/src/design-system/ui-kit.tsx
|
||
// Adapté depuis design_system/components/ui-kit.jsx
|
||
import { useState, useRef, useEffect } from 'react'
|
||
```
|
||
|
||
Et supprimer la ligne d'origine :
|
||
```
|
||
const { useState, useRef, useEffect } = React;
|
||
```
|
||
|
||
Puis ajouter à la fin du fichier les exports des composants existants :
|
||
|
||
```tsx
|
||
export { Button, IconButton, Toggle, Tooltip, StatusLed, BatteryGauge, RadialGauge, BigRadialGauge, Popup, TreeNav, Sparkline, LineChart, Icon }
|
||
```
|
||
|
||
- [ ] **Étape 6 : Vérifier la compilation**
|
||
|
||
```bash
|
||
cd frontend && npm run build
|
||
```
|
||
|
||
Résultat attendu : build réussi sans erreur.
|
||
|
||
- [ ] **Étape 7 : Committer**
|
||
|
||
```bash
|
||
git add frontend/tailwind.config.ts frontend/postcss.config.js frontend/src/index.css frontend/src/design-system/
|
||
git commit -m "feat: tailwind CSS + design system Gruvbox seventies intégré"
|
||
```
|
||
|
||
---
|
||
|
||
## Tâche 11 : Frontend — Configuration PWA (vite-plugin-pwa + manifest)
|
||
|
||
**Fichiers :**
|
||
- Modifier : `frontend/vite.config.ts`
|
||
- Créer : `frontend/public/manifest.json`
|
||
- Créer : `frontend/public/icons/icon-192.png` (placeholder)
|
||
- Créer : `frontend/public/icons/icon-512.png` (placeholder)
|
||
|
||
- [ ] **Étape 1 : Créer `frontend/vite.config.ts`**
|
||
|
||
```typescript
|
||
// frontend/vite.config.ts
|
||
import { defineConfig } from 'vite'
|
||
import react from '@vitejs/plugin-react'
|
||
import { VitePWA } from 'vite-plugin-pwa'
|
||
|
||
export default defineConfig({
|
||
plugins: [
|
||
react(),
|
||
VitePWA({
|
||
registerType: 'autoUpdate',
|
||
workbox: {
|
||
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff2}'],
|
||
runtimeCaching: [
|
||
{
|
||
urlPattern: /^\/api\//,
|
||
handler: 'NetworkFirst',
|
||
options: {
|
||
cacheName: 'api-cache',
|
||
expiration: { maxEntries: 100, maxAgeSeconds: 86400 },
|
||
},
|
||
},
|
||
],
|
||
},
|
||
manifest: {
|
||
name: 'HomeHub',
|
||
short_name: 'HomeHub',
|
||
description: 'Organisation personnelle auto-hébergée',
|
||
theme_color: '#fe8019',
|
||
background_color: '#2a231d',
|
||
display: 'standalone',
|
||
orientation: 'portrait-primary',
|
||
start_url: '/',
|
||
icons: [
|
||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
|
||
],
|
||
},
|
||
}),
|
||
],
|
||
server: {
|
||
proxy: {
|
||
'/api': { target: 'http://localhost:8000', changeOrigin: true },
|
||
},
|
||
},
|
||
})
|
||
```
|
||
|
||
- [ ] **Étape 2 : Créer les icônes placeholder**
|
||
|
||
```bash
|
||
# Générer des icônes SVG→PNG avec Python (Pillow déjà installé en backend)
|
||
cd backend && python - <<'EOF'
|
||
from PIL import Image, ImageDraw
|
||
import os
|
||
|
||
os.makedirs("../frontend/public/icons", exist_ok=True)
|
||
|
||
for size in [192, 512]:
|
||
img = Image.new("RGB", (size, size), color="#2a231d")
|
||
draw = ImageDraw.Draw(img)
|
||
draw.ellipse([size//4, size//4, size*3//4, size*3//4], fill="#fe8019")
|
||
img.save(f"../frontend/public/icons/icon-{size}.png")
|
||
print(f"Icône {size}x{size} créée")
|
||
EOF
|
||
```
|
||
|
||
- [ ] **Étape 3 : Vérifier le build avec PWA**
|
||
|
||
```bash
|
||
cd frontend && npm run build && ls dist/
|
||
```
|
||
|
||
Résultat attendu : dossier `dist/` avec `sw.js`, `manifest.webmanifest`, `index.html`, assets JS/CSS.
|
||
|
||
- [ ] **Étape 4 : Committer**
|
||
|
||
```bash
|
||
git add frontend/vite.config.ts frontend/public/
|
||
git commit -m "feat: PWA configurée — service worker, manifest, icônes"
|
||
```
|
||
|
||
---
|
||
|
||
## Tâche 12 : Frontend — Layout responsive et navigation
|
||
|
||
**Fichiers :**
|
||
- Créer : `frontend/src/components/layout/Layout.tsx`
|
||
- Créer : `frontend/src/components/layout/BottomNav.tsx`
|
||
- Créer : `frontend/src/components/layout/SideNav.tsx`
|
||
- Créer : `frontend/src/pages/HomePage.tsx`
|
||
|
||
- [ ] **Étape 1 : Créer `frontend/src/pages/HomePage.tsx`**
|
||
|
||
```tsx
|
||
// frontend/src/pages/HomePage.tsx
|
||
export default function HomePage() {
|
||
return (
|
||
<div className="p-4">
|
||
<div className="glass" style={{ padding: 20, borderRadius: 10, marginBottom: 16 }}>
|
||
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0 }}>
|
||
HomeHub
|
||
</h1>
|
||
<p style={{ color: 'var(--ink-2)', marginTop: 8 }}>
|
||
Application d'organisation personnelle
|
||
</p>
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
|
||
{[
|
||
{ label: 'Todos', icon: 'list', path: '/todos' },
|
||
{ label: 'Courses', icon: 'cart-shopping', path: '/shopping' },
|
||
{ label: 'Notes', icon: 'note-sticky', path: '/notes' },
|
||
].map((item) => (
|
||
<div
|
||
key={item.path}
|
||
className="glass interactive"
|
||
style={{ padding: 20, borderRadius: 10, textAlign: 'center', cursor: 'pointer', minHeight: 80, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8 }}
|
||
>
|
||
<i className={`fa-solid fa-${item.icon}`} style={{ fontSize: 24, color: 'var(--accent)' }} />
|
||
<span style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14 }}>{item.label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Étape 2 : Créer `frontend/src/components/layout/BottomNav.tsx`**
|
||
|
||
```tsx
|
||
// frontend/src/components/layout/BottomNav.tsx
|
||
import { NavLink } from 'react-router-dom'
|
||
|
||
const NAV_ITEMS = [
|
||
{ to: '/', icon: 'house', label: 'Accueil' },
|
||
{ to: '/todos', icon: 'list-check', label: 'Todos' },
|
||
{ to: '/shopping', icon: 'cart-shopping', label: 'Courses' },
|
||
{ to: '/notes', icon: 'note-sticky', label: 'Notes' },
|
||
]
|
||
|
||
export default function BottomNav() {
|
||
return (
|
||
<nav style={{
|
||
display: 'flex',
|
||
background: 'var(--bg-2)',
|
||
borderTop: '1px solid var(--border-2)',
|
||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||
}}>
|
||
{NAV_ITEMS.map((item) => (
|
||
<NavLink
|
||
key={item.to}
|
||
to={item.to}
|
||
end={item.to === '/'}
|
||
style={({ isActive }) => ({
|
||
flex: 1,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
minHeight: 56,
|
||
gap: 4,
|
||
color: isActive ? 'var(--accent)' : 'var(--ink-3)',
|
||
textDecoration: 'none',
|
||
fontSize: 10,
|
||
fontFamily: 'var(--font-ui)',
|
||
letterSpacing: '0.05em',
|
||
textTransform: 'uppercase',
|
||
})}
|
||
>
|
||
<i className={`fa-solid fa-${item.icon}`} style={{ fontSize: 20 }} />
|
||
{item.label}
|
||
</NavLink>
|
||
))}
|
||
</nav>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Étape 3 : Créer `frontend/src/components/layout/SideNav.tsx`**
|
||
|
||
```tsx
|
||
// frontend/src/components/layout/SideNav.tsx
|
||
import { NavLink } from 'react-router-dom'
|
||
|
||
const NAV_ITEMS = [
|
||
{ to: '/', icon: 'house', label: 'Accueil' },
|
||
{ to: '/todos', icon: 'list-check', label: 'Todos' },
|
||
{ to: '/shopping', icon: 'cart-shopping', label: 'Courses' },
|
||
{ to: '/notes', icon: 'note-sticky', label: 'Notes' },
|
||
]
|
||
|
||
export default function SideNav() {
|
||
return (
|
||
<nav style={{
|
||
width: 220,
|
||
background: 'var(--bg-2)',
|
||
borderRight: '1px solid var(--border-1)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
padding: '16px 0',
|
||
height: '100%',
|
||
}}>
|
||
<div style={{ padding: '0 16px 16px', borderBottom: '1px solid var(--border-1)', marginBottom: 8 }}>
|
||
<span style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', fontWeight: 700, fontSize: 16 }}>
|
||
HomeHub
|
||
</span>
|
||
</div>
|
||
{NAV_ITEMS.map((item) => (
|
||
<NavLink
|
||
key={item.to}
|
||
to={item.to}
|
||
end={item.to === '/'}
|
||
style={({ isActive }) => ({
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
padding: '10px 16px',
|
||
color: isActive ? 'var(--accent)' : 'var(--ink-2)',
|
||
background: isActive ? 'var(--accent-tint)' : 'transparent',
|
||
borderLeft: isActive ? '2px solid var(--accent)' : '2px solid transparent',
|
||
textDecoration: 'none',
|
||
fontFamily: 'var(--font-ui)',
|
||
fontSize: 14,
|
||
minHeight: 40,
|
||
})}
|
||
>
|
||
<i className={`fa-solid fa-${item.icon}`} style={{ width: 18 }} />
|
||
{item.label}
|
||
</NavLink>
|
||
))}
|
||
</nav>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Étape 4 : Créer `frontend/src/components/layout/Layout.tsx`**
|
||
|
||
```tsx
|
||
// frontend/src/components/layout/Layout.tsx
|
||
import { Outlet } from 'react-router-dom'
|
||
import BottomNav from './BottomNav'
|
||
import SideNav from './SideNav'
|
||
|
||
export default function Layout() {
|
||
return (
|
||
<div style={{ display: 'flex', height: '100dvh', background: 'var(--bg-1)' }}>
|
||
{/* Sidebar — visible uniquement sur laptop (lg et +) */}
|
||
<div className="hidden lg:flex" style={{ flexShrink: 0 }}>
|
||
<SideNav />
|
||
</div>
|
||
|
||
{/* Contenu principal */}
|
||
<main style={{ flex: 1, overflow: 'auto', paddingBottom: 0 }} className="lg:pb-0 pb-14">
|
||
<Outlet />
|
||
</main>
|
||
|
||
{/* Navigation bas — visible uniquement sur mobile */}
|
||
<div className="lg:hidden" style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 50 }}>
|
||
<BottomNav />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Étape 5 : Vérifier la compilation**
|
||
|
||
```bash
|
||
cd frontend && npm run build
|
||
```
|
||
|
||
Résultat attendu : build réussi, 0 erreur TypeScript.
|
||
|
||
- [ ] **Étape 6 : Committer**
|
||
|
||
```bash
|
||
git add frontend/src/
|
||
git commit -m "feat: layout responsive mobile (bottom nav) / laptop (sidebar) + page d'accueil"
|
||
```
|
||
|
||
---
|
||
|
||
## Tâche 13 : Frontend — Dockerfile
|
||
|
||
**Fichiers :**
|
||
- Créer : `frontend/Dockerfile`
|
||
- Modifier : `docker-compose.yml` (déjà référence `./frontend`)
|
||
|
||
- [ ] **Étape 1 : Créer `frontend/Dockerfile`**
|
||
|
||
```dockerfile
|
||
# frontend/Dockerfile
|
||
FROM node:20-alpine AS build
|
||
WORKDIR /app
|
||
COPY package*.json ./
|
||
RUN npm ci
|
||
COPY . .
|
||
RUN npm run build
|
||
|
||
FROM nginx:alpine
|
||
COPY --from=build /app/dist /usr/share/nginx/html
|
||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||
EXPOSE 80
|
||
```
|
||
|
||
- [ ] **Étape 2 : Créer `frontend/nginx.conf`**
|
||
|
||
```nginx
|
||
# frontend/nginx.conf
|
||
server {
|
||
listen 80;
|
||
server_name _;
|
||
root /usr/share/nginx/html;
|
||
index index.html;
|
||
|
||
# Compression gzip
|
||
gzip on;
|
||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
|
||
|
||
# Cache assets statiques (JS/CSS avec hash)
|
||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|webp|woff2|svg)$ {
|
||
expires 1y;
|
||
add_header Cache-Control "public, immutable";
|
||
}
|
||
|
||
# PWA : toujours servir index.html (SPA routing)
|
||
location / {
|
||
try_files $uri $uri/ /index.html;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Étape 3 : Build du conteneur Docker frontend**
|
||
|
||
```bash
|
||
docker build -t homehub-frontend ./frontend
|
||
```
|
||
|
||
Résultat attendu : image construite avec succès.
|
||
|
||
- [ ] **Étape 4 : Committer**
|
||
|
||
```bash
|
||
git add frontend/Dockerfile frontend/nginx.conf
|
||
git commit -m "feat: dockerfile frontend nginx + cache statique"
|
||
```
|
||
|
||
---
|
||
|
||
## Tâche 14 : Environnement de développement local
|
||
|
||
**Fichiers :**
|
||
- Créer : `docker-compose.dev.yml`
|
||
- Créer : `dev.sh`
|
||
|
||
- [ ] **Étape 1 : Créer `docker-compose.dev.yml`**
|
||
|
||
```yaml
|
||
# docker-compose.dev.yml
|
||
# Surcharge docker-compose.yml pour le développement local
|
||
# Utilisation : docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||
services:
|
||
db:
|
||
ports:
|
||
- "5432:5432" # Accès direct depuis la machine host
|
||
|
||
backend:
|
||
build:
|
||
context: ./backend
|
||
volumes:
|
||
- ./backend:/app # Hot reload : les fichiers Python sont montés
|
||
- uploads:/uploads
|
||
environment:
|
||
DATABASE_URL: postgresql+asyncpg://homehub:homehub@db:5432/homehub
|
||
UPLOAD_DIR: /uploads
|
||
CORS_ORIGINS: http://localhost:3000
|
||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||
ports:
|
||
- "8000:8000"
|
||
|
||
frontend:
|
||
image: node:20-alpine
|
||
working_dir: /app
|
||
volumes:
|
||
- ./frontend:/app
|
||
- /app/node_modules # Volume anonyme pour node_modules (évite l'écrasement)
|
||
environment:
|
||
VITE_API_URL: http://localhost:8000
|
||
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
||
ports:
|
||
- "3000:5173"
|
||
depends_on:
|
||
- backend
|
||
```
|
||
|
||
- [ ] **Étape 2 : Créer `dev.sh`**
|
||
|
||
```bash
|
||
#!/bin/bash
|
||
# dev.sh — démarre l'environnement de développement local HomeHub
|
||
set -e
|
||
|
||
echo "=== HomeHub Dev ==="
|
||
|
||
# Copier .env.example si .env n'existe pas
|
||
if [ ! -f .env ]; then
|
||
cp .env.example .env
|
||
echo "✓ .env créé depuis .env.example"
|
||
fi
|
||
|
||
# Démarrer les services
|
||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up "$@"
|
||
```
|
||
|
||
```bash
|
||
chmod +x dev.sh
|
||
```
|
||
|
||
- [ ] **Étape 3 : Tester le démarrage complet**
|
||
|
||
```bash
|
||
./dev.sh
|
||
```
|
||
|
||
Résultat attendu dans les logs :
|
||
```
|
||
backend-1 | INFO: Application startup complete.
|
||
backend-1 | Seed : 9 magasins, 113 produits chargés.
|
||
frontend-1 | VITE v5.x.x ready in XXXms
|
||
frontend-1 | ➜ Local: http://0.0.0.0:5173/
|
||
```
|
||
|
||
Ouvrir `http://localhost:3000` dans un navigateur.
|
||
Vérifier : fond brun Gruvbox, titre "HomeHub" en orange, 3 tuiles (Todos, Courses, Notes), navigation bas sur mobile / sidebar sur laptop.
|
||
|
||
- [ ] **Étape 4 : Vérifier l'endpoint health**
|
||
|
||
```bash
|
||
curl http://localhost:8000/api/health
|
||
```
|
||
|
||
Résultat attendu : `{"status":"ok"}`
|
||
|
||
- [ ] **Étape 5 : Committer**
|
||
|
||
```bash
|
||
git add docker-compose.dev.yml dev.sh
|
||
git commit -m "chore: environnement dev local (hot reload backend + frontend)"
|
||
```
|
||
|
||
---
|
||
|
||
## Tâche 15 : Migration en production et vérification finale
|
||
|
||
- [ ] **Étape 1 : Build production complet**
|
||
|
||
```bash
|
||
docker compose build
|
||
docker compose up -d
|
||
```
|
||
|
||
- [ ] **Étape 2 : Appliquer les migrations en production**
|
||
|
||
```bash
|
||
docker compose exec backend alembic upgrade head
|
||
```
|
||
|
||
Résultat attendu :
|
||
```
|
||
INFO [alembic.runtime.migration] Running upgrade -> 001, Schémas initiaux et toutes les tables
|
||
```
|
||
|
||
- [ ] **Étape 3 : Vérifier les schémas PostgreSQL**
|
||
|
||
```bash
|
||
docker compose exec db psql -U homehub -c "\dn"
|
||
```
|
||
|
||
Résultat attendu :
|
||
```
|
||
List of schemas
|
||
Name | Owner
|
||
----------+---------
|
||
notes | homehub
|
||
public | pg_database_owner
|
||
shopping | homehub
|
||
todos | homehub
|
||
```
|
||
|
||
- [ ] **Étape 4 : Vérifier le seed**
|
||
|
||
```bash
|
||
docker compose exec db psql -U homehub -c "SELECT COUNT(*) FROM shopping.products;"
|
||
```
|
||
|
||
Résultat attendu : `113`
|
||
|
||
```bash
|
||
docker compose exec db psql -U homehub -c "SELECT name FROM shopping.stores ORDER BY name;"
|
||
```
|
||
|
||
Résultat attendu : 9 magasins listés (Bricocash, Cosi, Gamm Vert, Intermarché, Lidl, Marie Blachère, Super U, Tinel, Weldom).
|
||
|
||
- [ ] **Étape 5 : Lancer la suite de tests complète**
|
||
|
||
```bash
|
||
cd backend && python -m pytest tests/ -v
|
||
```
|
||
|
||
Résultat attendu : tous les tests passent.
|
||
|
||
- [ ] **Étape 6 : Committer et taguer la Phase 1**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "chore: Phase 1 complète — socle technique HomeHub opérationnel"
|
||
git tag v0.1.0-phase1
|
||
```
|
||
|
||
---
|
||
|
||
## Auto-révision du plan
|
||
|
||
**Couverture spec :**
|
||
- ✅ 3 services Docker obligatoires (frontend, backend, db)
|
||
- ✅ PostgreSQL 16 avec schémas `todos`, `shopping`, `notes`
|
||
- ✅ Toutes les tables et colonnes définies dans la spec section 3
|
||
- ✅ Colonnes `owner_id` nullable dans toutes les tables
|
||
- ✅ Index GIN sur `todos.tags`, `notes.tags`, `notes.FTS`
|
||
- ✅ Module media : upload, validation formats, miniatures Pillow (150×150/300×300/400×300)
|
||
- ✅ Structure `/uploads/images/originals/` + `/uploads/images/thumbnails/` + `/uploads/audio/`
|
||
- ✅ Design system Gruvbox : tokens CSS importés, composants adaptés
|
||
- ✅ Tailwind configuré avec variables CSS Gruvbox
|
||
- ✅ PWA : vite-plugin-pwa, manifest.json, icônes, service worker NetworkFirst pour /api/
|
||
- ✅ Layout responsive : bottom nav (mobile) + sidebar (laptop ≥ lg)
|
||
- ✅ Seed : 113 produits + 9 magasins au démarrage
|
||
- ✅ Script `dev.sh` + `docker-compose.dev.yml` (hot reload)
|
||
- ✅ CORS configuré pour réseau local
|
||
- ✅ Tests : health + media (upload, validation, taille thumbnail, audio, suppression)
|
||
|
||
**Hors scope Phase 1 (prévu phases suivantes) :**
|
||
- Endpoints métier todos/shopping/notes → Phase 2/3/4
|
||
- Services OCR, product-search, SearXNG → Phase 4b/5
|
||
- Authentification JWT → Phase 8
|