feat: evolve MetaBloom into full product foundation
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user