feat: evolve MetaBloom into full product foundation
MetaBloom CI / app (push) Canceled after 0s
MetaBloom CI / api (push) Canceled after 0s

This commit is contained in:
AI Company
2026-07-21 19:24:59 +00:00
parent 082cd868d4
commit ea8b5be058
38 changed files with 610 additions and 58 deletions
+7
View File
@@ -0,0 +1,7 @@
.env
.venv
__pycache__
.pytest_cache
tests
*.db
+6
View File
@@ -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
+10
View File
@@ -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"]
+51
View File
@@ -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. LAPI ne diagnostique pas, ninterprè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` daprè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 dentré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 daudit 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.
+1
View File
@@ -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.
+22
View File
@@ -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()
+22
View File
@@ -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()
+109
View File
@@ -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)
+57
View File
@@ -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")
+64
View File
@@ -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)
+43
View File
@@ -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
+27
View File
@@ -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:
+4
View File
@@ -0,0 +1,4 @@
-r requirements.txt
pytest==8.3.4
httpx==0.28.1
+10
View File
@@ -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
+46
View File
@@ -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"