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