136 lines
5.6 KiB
Python
136 lines
5.6 KiB
Python
from datetime import datetime, timezone
|
|
import secrets
|
|
import uuid
|
|
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 CoachChatIn, CoachChatOut, EntryIn, EntryOut, ProfileIn, ProfileOut, Register, Token
|
|
from .coach import chat_with_peg, peg_provider_health
|
|
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.get("/health/ai")
|
|
async def ai_health():
|
|
return await peg_provider_health(cfg)
|
|
|
|
|
|
@app.post("/coach/chat", response_model=CoachChatOut)
|
|
async def coach_chat(data: CoachChatIn, user: User = Depends(current_user)):
|
|
return await chat_with_peg(data, cfg)
|
|
|
|
|
|
@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/guest", response_model=Token, status_code=201)
|
|
def guest_session(db: Session = Depends(get_db)):
|
|
"""Create a private pseudonymous session for a first app launch."""
|
|
user = User(
|
|
email=f"guest-{uuid.uuid4()}@local.invalid",
|
|
password_hash=hash_password(secrets.token_urlsafe(32)),
|
|
)
|
|
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)
|