feat: evolve MetaBloom into full product foundation
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.venv
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
tests
|
||||
*.db
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
DATABASE_URL=postgresql+psycopg://metabloom:change-me@localhost:5432/metabloom
|
||||
JWT_SECRET=generate-a-random-secret-of-at-least-32-characters
|
||||
ENVIRONMENT=production
|
||||
ALLOWED_ORIGINS=https://app.example.com
|
||||
POSTGRES_PASSWORD=change-me
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app ./app
|
||||
RUN useradd --create-home appuser
|
||||
USER appuser
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# MetaBloom API
|
||||
|
||||
Backend FastAPI isolé pour synchroniser les profils et journaux MetaBloom. Il fournit JWT, Argon2, séparation stricte des données par utilisateur, consentement explicite, CRUD, export JSON et suppression de compte.
|
||||
|
||||
> MetaBloom est un outil de bien-être, pas un dispositif médical. L’API ne diagnostique pas, n’interprète pas les mesures et ne recommande aucun traitement. En cas de valeur inquiétante ou de symptômes, contacter un professionnel ou les urgences.
|
||||
|
||||
## Démarrage local
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python -m venv .venv && . .venv/bin/activate
|
||||
pip install -r requirements-dev.txt
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
Documentation : `http://localhost:8000/docs`. SQLite est utilisé en développement. Pour PostgreSQL, renseigner `DATABASE_URL` d’après `.env.example`.
|
||||
|
||||
## Docker
|
||||
|
||||
Copier `.env.example` vers `.env`, remplacer **tous** les exemples de secrets, puis :
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.example.yml --env-file .env up --build
|
||||
```
|
||||
|
||||
Le port n’écoute que sur localhost ; placer un reverse proxy TLS devant en production.
|
||||
|
||||
## API
|
||||
|
||||
- `POST /auth/register`, `POST /auth/token`
|
||||
- `GET/PUT /profile`
|
||||
- `GET/POST /entries`, `PUT/DELETE /entries/{id}`
|
||||
- `GET /account/export`, `DELETE /account`
|
||||
- `GET /health`
|
||||
|
||||
Envoyer `Authorization: Bearer <token>`. La création d’entrées exige `consent_health_data=true`. Les limites de saisie préviennent les erreurs grossières mais ne constituent pas une validation médicale.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pytest -q
|
||||
```
|
||||
|
||||
## Avant production
|
||||
|
||||
- gérer les schémas avec Alembic plutôt que `create_all` ;
|
||||
- utiliser un gestionnaire de secrets, TLS, sauvegardes chiffrées, rotation et révocation des jetons ;
|
||||
- ajouter rate limiting, journal d’audit sans donnée médicale, MFA et tests de sécurité ;
|
||||
- effectuer AIPD/RGPD, définir conservation/export, contrats de sous-traitance et hébergement HDS si applicable ;
|
||||
- faire qualifier juridiquement la finalité et le statut MDR avant toute fonction clinique.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,22 @@
|
||||
from functools import lru_cache
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
database_url: str = "sqlite:///./metabloom.db"
|
||||
jwt_secret: str = "change-me-in-production-at-least-32-characters"
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_minutes: int = 30
|
||||
allowed_origins: str = "http://localhost:8081,http://localhost:19006"
|
||||
environment: str = "development"
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
@property
|
||||
def origins(self) -> list[str]:
|
||||
return [item.strip() for item in self.allowed_origins.split(",") if item.strip()]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=connect_args)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import Depends, FastAPI, HTTPException, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from .config import get_settings
|
||||
from .db import Base, engine, get_db
|
||||
from .models import Entry, Profile, User
|
||||
from .schemas import EntryIn, EntryOut, ProfileIn, ProfileOut, Register, Token
|
||||
from .security import current_user, hash_password, make_token, verify_password
|
||||
|
||||
cfg = get_settings()
|
||||
if cfg.environment == "production" and (len(cfg.jwt_secret) < 32 or cfg.jwt_secret.startswith("change-me")):
|
||||
raise RuntimeError("Set a strong JWT_SECRET in production")
|
||||
|
||||
app = FastAPI(title="MetaBloom API", version="1.0.0", description="Wellness tracking API — not a medical device.")
|
||||
app.add_middleware(CORSMiddleware, allow_origins=cfg.origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "medical_notice": "MetaBloom does not diagnose or replace medical care."}
|
||||
|
||||
|
||||
@app.post("/auth/register", response_model=Token, status_code=201)
|
||||
def register(data: Register, db: Session = Depends(get_db)):
|
||||
email = data.email.lower()
|
||||
if db.scalar(select(User).where(User.email == email)):
|
||||
raise HTTPException(409, "Email already registered")
|
||||
user = User(email=email, password_hash=hash_password(data.password))
|
||||
db.add(user); db.flush()
|
||||
db.add(Profile(user_id=user.id)); db.commit()
|
||||
return Token(access_token=make_token(user.id))
|
||||
|
||||
|
||||
@app.post("/auth/token", response_model=Token)
|
||||
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
user = db.scalar(select(User).where(User.email == form.username.lower()))
|
||||
if not user or not verify_password(form.password, user.password_hash):
|
||||
raise HTTPException(401, "Invalid credentials", headers={"WWW-Authenticate": "Bearer"})
|
||||
return Token(access_token=make_token(user.id))
|
||||
|
||||
|
||||
@app.get("/profile", response_model=ProfileOut)
|
||||
def get_profile(user: User = Depends(current_user)):
|
||||
return user.profile
|
||||
|
||||
|
||||
@app.put("/profile", response_model=ProfileOut)
|
||||
def put_profile(data: ProfileIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
profile = user.profile or Profile(user_id=user.id)
|
||||
for key, value in data.model_dump().items(): setattr(profile, key, value)
|
||||
db.add(profile); db.commit(); db.refresh(profile)
|
||||
return profile
|
||||
|
||||
|
||||
@app.get("/entries", response_model=list[EntryOut])
|
||||
def list_entries(kind: str | None = None, limit: int = 100, user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
limit = min(max(limit, 1), 500)
|
||||
query = select(Entry).where(Entry.user_id == user.id)
|
||||
if kind: query = query.where(Entry.kind == kind)
|
||||
return db.scalars(query.order_by(Entry.recorded_at.desc()).limit(limit)).all()
|
||||
|
||||
|
||||
@app.post("/entries", response_model=EntryOut, status_code=201)
|
||||
def create_entry(data: EntryIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
if not user.profile or not user.profile.consent_health_data:
|
||||
raise HTTPException(403, "Health-data consent is required")
|
||||
entry = Entry(user_id=user.id, **data.model_dump(exclude_none=True))
|
||||
db.add(entry); db.commit(); db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def owned_entry(entry_id: str, user: User, db: Session) -> Entry:
|
||||
entry = db.scalar(select(Entry).where(Entry.id == entry_id, Entry.user_id == user.id))
|
||||
if not entry: raise HTTPException(404, "Entry not found")
|
||||
return entry
|
||||
|
||||
|
||||
@app.put("/entries/{entry_id}", response_model=EntryOut)
|
||||
def update_entry(entry_id: str, data: EntryIn, user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
entry = owned_entry(entry_id, user, db)
|
||||
for key, value in data.model_dump(exclude_none=True).items(): setattr(entry, key, value)
|
||||
db.commit(); db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@app.delete("/entries/{entry_id}", status_code=204)
|
||||
def delete_entry(entry_id: str, user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
db.delete(owned_entry(entry_id, user, db)); db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@app.get("/account/export")
|
||||
def export_account(user: User = Depends(current_user)):
|
||||
return {"exported_at": datetime.now(timezone.utc), "medical_notice": "Informational data only; consult a professional for medical interpretation.", "profile": ProfileOut.model_validate(user.profile), "entries": [EntryOut.model_validate(x) for x in user.entries]}
|
||||
|
||||
|
||||
@app.delete("/account", status_code=204)
|
||||
def delete_account(user: User = Depends(current_user), db: Session = Depends(get_db)):
|
||||
db.delete(user); db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from sqlalchemy import Boolean, Date, DateTime, Enum, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from .db import Base
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class EntryKind(str, enum.Enum):
|
||||
weight = "weight"
|
||||
glucose = "glucose"
|
||||
water = "water"
|
||||
activity = "activity"
|
||||
sleep = "sleep"
|
||||
mood = "mood"
|
||||
meal = "meal"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
profile: Mapped["Profile | None"] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
entries: Mapped[list["Entry"]] = relationship(back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Profile(Base):
|
||||
__tablename__ = "profiles"
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(80))
|
||||
birth_date: Mapped[date | None] = mapped_column(Date)
|
||||
height_cm: Mapped[float | None] = mapped_column(Float)
|
||||
objective: Mapped[str | None] = mapped_column(String(120))
|
||||
health_context: Mapped[str | None] = mapped_column(Text)
|
||||
consent_health_data: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
|
||||
user: Mapped[User] = relationship(back_populates="profile")
|
||||
|
||||
|
||||
class Entry(Base):
|
||||
__tablename__ = "entries"
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
kind: Mapped[EntryKind] = mapped_column(Enum(EntryKind))
|
||||
value: Mapped[float] = mapped_column(Float)
|
||||
unit: Mapped[str] = mapped_column(String(24))
|
||||
note: Mapped[str | None] = mapped_column(String(500))
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
user: Mapped[User] = relationship(back_populates="entries")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
from datetime import date, datetime
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator
|
||||
from .models import EntryKind
|
||||
|
||||
|
||||
class Register(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=12, max_length=128)
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class ProfileIn(BaseModel):
|
||||
display_name: str | None = Field(None, max_length=80)
|
||||
birth_date: date | None = None
|
||||
height_cm: float | None = Field(None, ge=80, le=250)
|
||||
objective: str | None = Field(None, max_length=120)
|
||||
health_context: str | None = Field(None, max_length=1000)
|
||||
consent_health_data: bool = False
|
||||
|
||||
|
||||
class ProfileOut(ProfileIn):
|
||||
user_id: str
|
||||
updated_at: datetime
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
RANGES = {
|
||||
EntryKind.weight: (20, 500), EntryKind.glucose: (20, 600), EntryKind.water: (0, 15000),
|
||||
EntryKind.activity: (0, 1440), EntryKind.sleep: (0, 24), EntryKind.mood: (1, 5),
|
||||
EntryKind.meal: (0, 10000),
|
||||
}
|
||||
|
||||
|
||||
class EntryIn(BaseModel):
|
||||
kind: EntryKind
|
||||
value: float
|
||||
unit: str = Field(min_length=1, max_length=24)
|
||||
note: str | None = Field(None, max_length=500)
|
||||
recorded_at: datetime | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def sensible_range(self):
|
||||
if self.kind == EntryKind.glucose:
|
||||
glucose_ranges = {"mg/dL": (20, 600), "mmol/L": (1.1, 33.3)}
|
||||
if self.unit not in glucose_ranges:
|
||||
raise ValueError("glucose unit must be mg/dL or mmol/L")
|
||||
low, high = glucose_ranges[self.unit]
|
||||
else:
|
||||
low, high = RANGES[self.kind]
|
||||
if not low <= self.value <= high:
|
||||
raise ValueError(f"value must be between {low} and {high} for {self.kind.value}")
|
||||
return self
|
||||
|
||||
|
||||
class EntryOut(EntryIn):
|
||||
id: str
|
||||
user_id: str
|
||||
recorded_at: datetime
|
||||
created_at: datetime
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,43 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from .config import get_settings
|
||||
from .db import get_db
|
||||
from .models import User
|
||||
|
||||
hasher = PasswordHasher()
|
||||
oauth2 = OAuth2PasswordBearer(tokenUrl="/auth/token")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, digest: str) -> bool:
|
||||
try:
|
||||
return hasher.verify(digest, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
|
||||
def make_token(user_id: str) -> str:
|
||||
cfg = get_settings()
|
||||
now = datetime.now(timezone.utc)
|
||||
return jwt.encode({"sub": user_id, "iat": now, "exp": now + timedelta(minutes=cfg.access_token_minutes)}, cfg.jwt_secret, algorithm=cfg.jwt_algorithm)
|
||||
|
||||
|
||||
def current_user(token: str = Depends(oauth2), db: Session = Depends(get_db)) -> User:
|
||||
cfg = get_settings()
|
||||
try:
|
||||
user_id = jwt.decode(token, cfg.jwt_secret, algorithms=[cfg.jwt_algorithm])["sub"]
|
||||
except (jwt.PyJWTError, KeyError):
|
||||
raise HTTPException(401, "Invalid or expired token", headers={"WWW-Authenticate": "Bearer"})
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(401, "Account no longer exists")
|
||||
return user
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
services:
|
||||
api:
|
||||
build: .
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://metabloom:${POSTGRES_PASSWORD}@db:5432/metabloom
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
ENVIRONMENT: production
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:8081}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports: ["127.0.0.1:8000:8000"]
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: metabloom
|
||||
POSTGRES_USER: metabloom
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes: ["postgres_data:/var/lib/postgresql/data"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U metabloom"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-r requirements.txt
|
||||
pytest==8.3.4
|
||||
httpx==0.28.1
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
psycopg[binary]==3.2.3
|
||||
pydantic-settings==2.7.0
|
||||
PyJWT==2.10.1
|
||||
argon2-cffi==23.1.0
|
||||
email-validator==2.2.0
|
||||
python-multipart==0.0.20
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,46 @@
|
||||
import os
|
||||
os.environ["DATABASE_URL"] = "sqlite:///./test_metabloom.db"
|
||||
os.environ["JWT_SECRET"] = "test-secret-that-is-long-enough-for-tests"
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from app.db import Base, engine
|
||||
from app.main import app
|
||||
|
||||
|
||||
def setup_function():
|
||||
Base.metadata.drop_all(engine); Base.metadata.create_all(engine)
|
||||
|
||||
|
||||
def auth(client):
|
||||
response = client.post("/auth/register", json={"email": "user@example.com", "password": "a-very-safe-password"})
|
||||
assert response.status_code == 201
|
||||
return {"Authorization": f"Bearer {response.json()['access_token']}"}
|
||||
|
||||
|
||||
def test_health_auth_crud_export_delete():
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/health").status_code == 200
|
||||
headers = auth(client)
|
||||
assert client.post("/entries", headers=headers, json={"kind": "weight", "value": 90, "unit": "kg"}).status_code == 403
|
||||
profile = client.put("/profile", headers=headers, json={"display_name": "Alex", "height_cm": 175, "consent_health_data": True})
|
||||
assert profile.status_code == 200
|
||||
created = client.post("/entries", headers=headers, json={"kind": "weight", "value": 90, "unit": "kg"})
|
||||
assert created.status_code == 201
|
||||
entry_id = created.json()["id"]
|
||||
assert len(client.get("/entries", headers=headers).json()) == 1
|
||||
assert client.put(f"/entries/{entry_id}", headers=headers, json={"kind": "weight", "value": 89, "unit": "kg"}).json()["value"] == 89
|
||||
assert client.get("/account/export", headers=headers).status_code == 200
|
||||
assert client.delete(f"/entries/{entry_id}", headers=headers).status_code == 204
|
||||
assert client.delete("/account", headers=headers).status_code == 204
|
||||
|
||||
|
||||
def test_validation_and_isolation():
|
||||
with TestClient(app) as client:
|
||||
headers = auth(client)
|
||||
client.put("/profile", headers=headers, json={"consent_health_data": True})
|
||||
assert client.post("/entries", headers=headers, json={"kind": "glucose", "value": 999, "unit": "mg/dL"}).status_code == 422
|
||||
assert client.post("/entries", headers=headers, json={"kind": "glucose", "value": 5.5, "unit": "mmol/L"}).status_code == 201
|
||||
assert client.post("/entries", headers=headers, json={"kind": "glucose", "value": 5.5, "unit": "mg/dL"}).status_code == 422
|
||||
entries = client.get("/entries", headers=headers).json()
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["unit"] == "mmol/L"
|
||||
Reference in New Issue
Block a user