feat(backend): add idempotent CRUD layer for items

This commit is contained in:
pyr0ball 2026-07-13 13:38:36 -07:00
parent c3e9f2551c
commit 8f5526c2c0
2 changed files with 140 additions and 0 deletions

77
backend/app/crud.py Normal file
View file

@ -0,0 +1,77 @@
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Item
from app.stages import is_valid_stage
DONE_STAGES = {"done", "logged_in_bookmark"}
def create_item(
session: Session,
*,
modality: str,
raw_content: str,
captured_at: datetime,
discord_message_id: str | None,
sender_id: str | None = None,
) -> tuple[Item, bool]:
if discord_message_id is not None:
existing = session.scalar(
select(Item).where(Item.discord_message_id == discord_message_id)
)
if existing is not None:
return existing, False
item = Item(
modality=modality,
raw_content=raw_content,
captured_at=captured_at,
discord_message_id=discord_message_id,
sender_id=sender_id,
stage="new",
)
session.add(item)
session.commit()
session.refresh(item)
return item, True
def list_items(
session: Session,
*,
stage: str | None = None,
modality: str | None = None,
include_done: bool = False,
) -> list[Item]:
stmt = select(Item)
if stage is not None:
stmt = stmt.where(Item.stage == stage)
if modality is not None:
stmt = stmt.where(Item.modality == modality)
if not include_done:
stmt = stmt.where(Item.stage.not_in(DONE_STAGES))
stmt = stmt.order_by(Item.captured_at.desc())
return list(session.scalars(stmt))
def get_item(session: Session, item_id: int) -> Item | None:
return session.get(Item, item_id)
def update_item(session: Session, item_id: int, **fields) -> Item | None:
item = session.get(Item, item_id)
if item is None:
return None
new_type = fields.get("type", item.type)
new_stage = fields.get("stage", item.stage)
if "stage" in fields and not is_valid_stage(new_type, new_stage):
raise ValueError(f"'{new_stage}' is not a valid stage for type '{new_type}'")
for key, value in fields.items():
setattr(item, key, value)
session.commit()
session.refresh(item)
return item

View file

@ -0,0 +1,63 @@
from datetime import datetime, timezone
import pytest
from app import crud
def _now():
return datetime.now(timezone.utc)
def test_create_item_sets_defaults(db_session):
item, created = crud.create_item(
db_session,
modality="bh_email",
raw_content="Hi, I have books to donate",
captured_at=_now(),
discord_message_id="msg-1",
)
assert created is True
assert item.id is not None
assert item.stage == "new"
assert item.type is None
def test_create_item_is_idempotent_by_discord_message_id(db_session):
first, first_created = crud.create_item(
db_session, modality="bh_email", raw_content="a", captured_at=_now(),
discord_message_id="dup-1",
)
second, second_created = crud.create_item(
db_session, modality="bh_email", raw_content="b (should be ignored)",
captured_at=_now(), discord_message_id="dup-1",
)
assert first_created is True
assert second_created is False
assert second.id == first.id
assert second.raw_content == "a"
def test_list_items_excludes_done_by_default(db_session):
crud.create_item(db_session, modality="voice", raw_content="x", captured_at=_now(),
discord_message_id="m1")
done_item, _ = crud.create_item(db_session, modality="voice", raw_content="y",
captured_at=_now(), discord_message_id="m2")
crud.update_item(db_session, done_item.id, type="other", stage="new")
crud.update_item(db_session, done_item.id, stage="done")
results = crud.list_items(db_session)
assert done_item.id not in [i.id for i in results]
results_with_done = crud.list_items(db_session, include_done=True)
assert done_item.id in [i.id for i in results_with_done]
def test_update_item_rejects_invalid_stage_for_type(db_session):
item, _ = crud.create_item(db_session, modality="voice", raw_content="x",
captured_at=_now(), discord_message_id="m3")
crud.update_item(db_session, item.id, type="other")
with pytest.raises(ValueError):
crud.update_item(db_session, item.id, stage="pickup_scheduled")
def test_update_item_returns_none_for_missing_id(db_session):
assert crud.update_item(db_session, 9999, notes="x") is None