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.orm import sessionmaker, DeclarativeBase
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
|
|
@ -7,8 +8,16 @@ class Base(DeclarativeBase):
|
|||
|
||||
|
||||
def get_engine(db_url: str):
|
||||
connect_args = {"check_same_thread": False} if db_url.startswith("sqlite") else {}
|
||||
return create_engine(db_url, connect_args=connect_args)
|
||||
if db_url.startswith("sqlite"):
|
||||
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):
|
||||
|
|
|
|||
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