chore: initial project setup

Phase 0 — full project scaffold with:
- Backend: FastAPI + SQLAlchemy 2.0 async + Alembic + PostgreSQL 16
- Frontend: React 18 + TypeScript + Vite + Tailwind CSS + shadcn/ui
- Docker Compose (prod + dev override with hot-reload)
- Health endpoint, CORS config, API proxy, env template

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nox (OpenClaw)
2026-03-17 15:20:50 +00:00
parent 6895609edc
commit d8c2048a9b
33 changed files with 751 additions and 0 deletions
+17
View File
@@ -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
+32
View File
@@ -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/
+37
View File
@@ -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
```
+26
View File
@@ -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"]
+36
View File
@@ -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
+57
View File
@@ -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())
+26
View File
@@ -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"}
View File
+17
View File
@@ -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()
+31
View File
@@ -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
+39
View File
@@ -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()
View File
+28
View File
@@ -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"]
+14
View File
@@ -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
+25
View File
@@ -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:
+46
View File
@@ -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:
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "all",
"plugins": ["prettier-plugin-tailwindcss"]
}
+15
View File
@@ -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;"]
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Budget Tracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+15
View File
@@ -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;
}
}
+54
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+10
View File
@@ -0,0 +1,10 @@
import { Routes, Route } from "react-router-dom";
import HomePage from "./pages/HomePage";
export default function App() {
return (
<Routes>
<Route path="/" element={<HomePage />} />
</Routes>
);
}
+8
View File
@@ -0,0 +1,8 @@
import axios from "axios";
export const apiClient = axios.create({
baseURL: "/api",
headers: {
"Content-Type": "application/json",
},
});
View File
View File
+37
View File
@@ -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;
}
}
+25
View File
@@ -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(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
);
+25
View File
@@ -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 (
<div className="flex min-h-screen flex-col items-center justify-center bg-background text-foreground">
<div className="text-center">
<h1 className="text-4xl font-bold tracking-tight">Budget Tracker</h1>
<p className="mt-4 text-lg text-muted-foreground">
Gérez votre budget personnel en toute simplicité.
</p>
{health && (
<p className="mt-2 text-sm text-muted-foreground">
API: {health.status}
</p>
)}
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+57
View File
@@ -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")],
};
+25
View File
@@ -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"]
}
+22
View File
@@ -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/, ""),
},
},
},
});