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 @@ + + + + + + + Budget Tracker + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..5ae2afb --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,15 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backend:8000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a2974ad --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,54 @@ +{ + "name": "budget-tracker-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint .", + "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@radix-ui/react-dialog": "1.1.4", + "@radix-ui/react-dropdown-menu": "2.1.4", + "@radix-ui/react-label": "2.1.1", + "@radix-ui/react-popover": "1.1.4", + "@radix-ui/react-select": "2.1.4", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-toast": "1.2.4", + "@tanstack/react-query": "5.62.8", + "axios": "1.7.9", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "lucide-react": "0.468.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router-dom": "7.1.1", + "recharts": "2.15.0", + "tailwind-merge": "2.6.0", + "tailwindcss-animate": "1.0.7" + }, + "devDependencies": { + "@eslint/js": "9.17.0", + "@types/react": "18.3.18", + "@types/react-dom": "18.3.5", + "@vitejs/plugin-react": "4.3.4", + "autoprefixer": "10.4.20", + "eslint": "9.17.0", + "eslint-plugin-react-hooks": "5.1.0", + "eslint-plugin-react-refresh": "0.4.16", + "globals": "15.14.0", + "postcss": "8.4.49", + "prettier": "3.4.2", + "prettier-plugin-tailwindcss": "0.6.9", + "tailwindcss": "3.4.17", + "typescript": "5.7.2", + "typescript-eslint": "8.18.2", + "vite": "6.0.6", + "vitest": "2.1.8" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..a87086b --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,10 @@ +import { Routes, Route } from "react-router-dom"; +import HomePage from "./pages/HomePage"; + +export default function App() { + return ( + + } /> + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..906efcf --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,8 @@ +import axios from "axios"; + +export const apiClient = axios.create({ + baseURL: "/api", + headers: { + "Content-Type": "application/json", + }, +}); diff --git a/frontend/src/components/.gitkeep b/frontend/src/components/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/hooks/.gitkeep b/frontend/src/hooks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..54b4713 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,37 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; + } + + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + font-feature-settings: "rlig" 1, "calt" 1; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..4ddff52 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import "./index.css"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + retry: 1, + }, + }, +}); + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..9e16f18 --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -0,0 +1,25 @@ +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "../api/client"; + +export default function HomePage() { + const { data: health } = useQuery({ + queryKey: ["health"], + queryFn: () => apiClient.get("/health").then((r) => r.data), + }); + + return ( +
+
+

Budget Tracker

+

+ Gérez votre budget personnel en toute simplicité. +

+ {health && ( +

+ API: {health.status} +

+ )} +
+
+ ); +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..263fe66 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,57 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ["class"], + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..5a3af71 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "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, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..b899439 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "path"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + server: { + port: 5173, + proxy: { + "/api": { + target: "http://localhost:8000", + changeOrigin: true, + rewrite: (p) => p.replace(/^\/api/, ""), + }, + }, + }, +});