feat(backend): add idempotent CRUD layer for items
This commit is contained in:
parent
c3e9f2551c
commit
8f5526c2c0
2 changed files with 140 additions and 0 deletions
77
backend/app/crud.py
Normal file
77
backend/app/crud.py
Normal 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
|
||||||
63
backend/tests/test_crud.py
Normal file
63
backend/tests/test_crud.py
Normal 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
|
||||||
Loading…
Reference in a new issue