feat(backend): add FastAPI routes for items
Wraps the Task 2 CRUD layer in a FastAPI app (schemas.py + main.py) with POST/GET/GET-by-id/PATCH routes for items, matching the exact paths and status codes the Discord bot's HTTP client will depend on. Also fixes a latent bug in db.get_engine: sqlite:///:memory: without a StaticPool gives each new session a fresh, empty database, which broke as soon as more than one session shared an engine (the API's per-request session pattern). Tasks 1-2 never hit this because their tests used a single session per engine.
This commit is contained in:
parent
8f5526c2c0
commit
a90c3fc172
4 changed files with 182 additions and 2 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
|
|
@ -7,8 +8,16 @@ class Base(DeclarativeBase):
|
||||||
|
|
||||||
|
|
||||||
def get_engine(db_url: str):
|
def get_engine(db_url: str):
|
||||||
connect_args = {"check_same_thread": False} if db_url.startswith("sqlite") else {}
|
if db_url.startswith("sqlite"):
|
||||||
return create_engine(db_url, connect_args=connect_args)
|
connect_args = {"check_same_thread": False}
|
||||||
|
# In-memory SQLite is per-connection; without a shared StaticPool,
|
||||||
|
# each new session would see a fresh, empty database.
|
||||||
|
if ":memory:" in db_url:
|
||||||
|
return create_engine(
|
||||||
|
db_url, connect_args=connect_args, poolclass=StaticPool
|
||||||
|
)
|
||||||
|
return create_engine(db_url, connect_args=connect_args)
|
||||||
|
return create_engine(db_url)
|
||||||
|
|
||||||
|
|
||||||
def make_session_factory(engine):
|
def make_session_factory(engine):
|
||||||
|
|
|
||||||
66
backend/app/main.py
Normal file
66
backend/app/main.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import os
|
||||||
|
from fastapi import FastAPI, HTTPException, Response
|
||||||
|
from app import crud
|
||||||
|
from app.db import get_engine, make_session_factory, init_db
|
||||||
|
from app.schemas import ItemCreate, ItemUpdate, ItemOut
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(session_factory=None) -> FastAPI:
|
||||||
|
if session_factory is None:
|
||||||
|
db_url = os.environ.get("CHORUS_DB_URL", "sqlite:///./chorus.db")
|
||||||
|
engine = get_engine(db_url)
|
||||||
|
init_db(engine)
|
||||||
|
session_factory = make_session_factory(engine)
|
||||||
|
|
||||||
|
app = FastAPI(title="Chorus API")
|
||||||
|
|
||||||
|
@app.post("/items", response_model=ItemOut)
|
||||||
|
def post_item(payload: ItemCreate, response: Response):
|
||||||
|
session = session_factory()
|
||||||
|
try:
|
||||||
|
item, created = crud.create_item(session, **payload.model_dump())
|
||||||
|
response.status_code = 201 if created else 200
|
||||||
|
return item
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
@app.get("/items", response_model=list[ItemOut])
|
||||||
|
def get_items(stage: str | None = None, modality: str | None = None,
|
||||||
|
include_done: bool = False):
|
||||||
|
session = session_factory()
|
||||||
|
try:
|
||||||
|
return crud.list_items(session, stage=stage, modality=modality,
|
||||||
|
include_done=include_done)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
@app.get("/items/{item_id}", response_model=ItemOut)
|
||||||
|
def get_item(item_id: int):
|
||||||
|
session = session_factory()
|
||||||
|
try:
|
||||||
|
item = crud.get_item(session, item_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
return item
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
@app.patch("/items/{item_id}", response_model=ItemOut)
|
||||||
|
def patch_item(item_id: int, payload: ItemUpdate):
|
||||||
|
session = session_factory()
|
||||||
|
try:
|
||||||
|
fields = {k: v for k, v in payload.model_dump().items() if v is not None}
|
||||||
|
try:
|
||||||
|
item = crud.update_item(session, item_id, **fields)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Item not found")
|
||||||
|
return item
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
35
backend/app/schemas.py
Normal file
35
backend/app/schemas.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from datetime import datetime, date
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class ItemCreate(BaseModel):
|
||||||
|
modality: str
|
||||||
|
raw_content: str
|
||||||
|
captured_at: datetime
|
||||||
|
discord_message_id: str | None = None
|
||||||
|
sender_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ItemUpdate(BaseModel):
|
||||||
|
sender_id: str | None = None
|
||||||
|
type: str | None = None
|
||||||
|
stage: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
follow_up_date: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ItemOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
modality: str
|
||||||
|
raw_content: str
|
||||||
|
captured_at: datetime
|
||||||
|
sender_id: str | None
|
||||||
|
type: str | None
|
||||||
|
stage: str
|
||||||
|
notes: str | None
|
||||||
|
follow_up_date: date | None
|
||||||
|
discord_message_id: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
70
backend/tests/test_items_api.py
Normal file
70
backend/tests/test_items_api.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app.db import get_engine, make_session_factory, init_db
|
||||||
|
from app.main import create_app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client():
|
||||||
|
engine = get_engine("sqlite:///:memory:")
|
||||||
|
init_db(engine)
|
||||||
|
SessionLocal = make_session_factory(engine)
|
||||||
|
app = create_app(SessionLocal)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_item_returns_201(client):
|
||||||
|
resp = client.post("/items", json={
|
||||||
|
"modality": "bh_email",
|
||||||
|
"raw_content": "Books to donate",
|
||||||
|
"captured_at": "2026-07-13T07:00:00Z",
|
||||||
|
"discord_message_id": "abc-1",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
body = resp.json()
|
||||||
|
assert body["stage"] == "new"
|
||||||
|
assert body["modality"] == "bh_email"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_item_idempotent_returns_200(client):
|
||||||
|
payload = {
|
||||||
|
"modality": "bh_email", "raw_content": "x",
|
||||||
|
"captured_at": "2026-07-13T07:00:00Z", "discord_message_id": "dup-9",
|
||||||
|
}
|
||||||
|
first = client.post("/items", json=payload)
|
||||||
|
second = client.post("/items", json=payload)
|
||||||
|
assert first.status_code == 201
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert first.json()["id"] == second.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_items_excludes_done_by_default(client):
|
||||||
|
resp = client.post("/items", json={
|
||||||
|
"modality": "voice", "raw_content": "call note",
|
||||||
|
"captured_at": "2026-07-13T07:00:00Z", "discord_message_id": "v-1",
|
||||||
|
})
|
||||||
|
item_id = resp.json()["id"]
|
||||||
|
client.patch(f"/items/{item_id}", json={"type": "other"})
|
||||||
|
client.patch(f"/items/{item_id}", json={"stage": "done"})
|
||||||
|
|
||||||
|
listed = client.get("/items").json()
|
||||||
|
assert item_id not in [i["id"] for i in listed]
|
||||||
|
|
||||||
|
listed_all = client.get("/items?include_done=true").json()
|
||||||
|
assert item_id in [i["id"] for i in listed_all]
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_invalid_stage_returns_422(client):
|
||||||
|
resp = client.post("/items", json={
|
||||||
|
"modality": "voice", "raw_content": "x",
|
||||||
|
"captured_at": "2026-07-13T07:00:00Z", "discord_message_id": "v-2",
|
||||||
|
})
|
||||||
|
item_id = resp.json()["id"]
|
||||||
|
client.patch(f"/items/{item_id}", json={"type": "other"})
|
||||||
|
bad = client.patch(f"/items/{item_id}", json={"stage": "pickup_scheduled"})
|
||||||
|
assert bad.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_missing_item_returns_404(client):
|
||||||
|
resp = client.get("/items/999999")
|
||||||
|
assert resp.status_code == 404
|
||||||
Loading…
Reference in a new issue