diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cb15d02 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# ── PostgreSQL ────────────────────────────────────────── +POSTGRES_USER=budget +POSTGRES_PASSWORD=budget +POSTGRES_DB=budget_tracker +DB_PORT=5432 + +# ── Backend (FastAPI) ────────────────────────────────── +DATABASE_URL=postgresql+asyncpg://budget:budget@db:5432/budget_tracker +SECRET_KEY=change-me-in-production +ACCESS_TOKEN_EXPIRE_MINUTES=15 +REFRESH_TOKEN_EXPIRE_DAYS=7 +CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"] +DEBUG=false +BACKEND_PORT=8000 + +# ── Frontend ─────────────────────────────────────────── +FRONTEND_PORT=3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc2510f --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +.venv/ +venv/ + +# Node +node_modules/ +frontend/dist/ + +# Environment +.env + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Docker +pgdata/ + +# Testing +.coverage +htmlcov/ +.pytest_cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..58e2210 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# Budget Tracker + +Application web de suivi de budget personnel. + +## Stack technique + +- **Backend** : FastAPI, SQLAlchemy 2.0 async, Alembic, PostgreSQL 16 +- **Frontend** : React 18, TypeScript, Vite, Tailwind CSS, shadcn/ui +- **Graphiques** : Recharts +- **Export** : CSV, PDF (WeasyPrint) + +## Démarrage rapide + +```bash +# Copier et configurer les variables d'environnement +cp .env.example .env + +# Lancer tous les services (dev avec hot-reload) +docker compose up + +# Backend : http://localhost:8000 +# Frontend : http://localhost:5173 +# Health check : http://localhost:8000/health +``` + +## Développement + +```bash +# Linting backend +cd backend && ruff check . + +# Tests backend +cd backend && pytest + +# Format frontend +cd frontend && npm run format +``` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c7852c4 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# System deps for WeasyPrint +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libgdk-pixbuf2.0-0 \ + libffi-dev \ + libcairo2 \ + libglib2.0-0 && \ + rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..68f455c --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +sqlalchemy.url = driver://user:pass@localhost/dbname + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..69499b4 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,57 @@ +import asyncio +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import create_async_engine + +from app.database import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def get_database_url() -> str: + return os.environ.get( + "DATABASE_URL", + "postgresql+asyncpg://budget:budget@db:5432/budget_tracker", + ) + + +def run_migrations_offline() -> None: + url = get_database_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): # noqa: ANN001 + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + connectable = create_async_engine( + get_database_url(), + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..691da51 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,17 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + DATABASE_URL: str = "postgresql+asyncpg://budget:budget@db:5432/budget_tracker" + SECRET_KEY: str = "change-me-in-production" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 15 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + CORS_ORIGINS: list[str] = ["http://localhost:5173"] + DEBUG: bool = False + + model_config = {"env_file": ".env", "env_file_encoding": "utf-8"} + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..46d6e38 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,31 @@ +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase + +from app.config import settings + +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.DEBUG, + pool_pre_ping=True, +) + +async_session_factory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +class Base(DeclarativeBase): + pass + + +async def get_session() -> AsyncGenerator[AsyncSession, None]: + async with async_session_factory() as session: + yield session diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..1a65281 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,39 @@ +from contextlib import asynccontextmanager +from collections.abc import AsyncIterator + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings +from app.database import engine + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + yield + await engine.dispose() + + +def create_app() -> FastAPI: + app = FastAPI( + title="Budget Tracker API", + version="0.1.0", + lifespan=lifespan, + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + @app.get("/health") + async def health_check() -> dict[str, str]: + return {"status": "ok"} + + return app + + +app = create_app() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..3c112f6 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "budget-tracker-backend" +version = "0.1.0" +requires-python = ">=3.12" + +[tool.ruff] +target-version = "py312" +line-length = 99 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify + "TCH", # flake8-type-checking +] + +[tool.ruff.lint.isort] +known-first-party = ["app"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..c11f611 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy[asyncio]==2.0.36 +asyncpg==0.30.0 +alembic==1.14.1 +pydantic==2.10.4 +pydantic-settings==2.7.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.20 +httpx==0.28.1 +weasyprint==63.1 +pytest==8.3.4 +pytest-asyncio==0.25.0 diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..e81870f --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,25 @@ +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + volumes: + - ./backend:/app + environment: + DEBUG: "true" + + frontend: + image: node:20-alpine + working_dir: /app + command: sh -c "npm install && npm run dev -- --host 0.0.0.0" + volumes: + - ./frontend:/app + - frontend_node_modules:/app/node_modules + ports: + - "5173:5173" + environment: + NODE_ENV: development + +volumes: + frontend_node_modules: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d7d7aaf --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,46 @@ +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-budget} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-budget} + POSTGRES_DB: ${POSTGRES_DB:-budget_tracker} + ports: + - "${DB_PORT:-5432}:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-budget}"] + interval: 5s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + restart: unless-stopped + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-budget}:${POSTGRES_PASSWORD:-budget}@db:5432/${POSTGRES_DB:-budget_tracker} + SECRET_KEY: ${SECRET_KEY:-change-me-in-production} + CORS_ORIGINS: '["http://localhost:5173","http://localhost:3000"]' + DEBUG: ${DEBUG:-false} + ports: + - "${BACKEND_PORT:-8000}:8000" + depends_on: + db: + condition: service_healthy + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "${FRONTEND_PORT:-3000}:80" + depends_on: + - backend + +volumes: + pgdata: diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..3444cde --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "all", + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..1ebf397 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install + +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 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b2f4d7a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + +
+ + + ++ Gérez votre budget personnel en toute simplicité. +
+ {health && ( ++ API: {health.status} +
+ )} +