first ruggedy draft
This commit is contained in:
270
apps/api/mousehold_api/routers/events.py
Normal file
270
apps/api/mousehold_api/routers/events.py
Normal file
@@ -0,0 +1,270 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from mousehold_domain import FinancialEventStatus, RelationType
|
||||
from mousehold_matching import compute_normalized_transaction_fingerprint
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from .. import models
|
||||
from ..db import get_session
|
||||
from ..schemas import (
|
||||
EventCreate,
|
||||
EventDetailRead,
|
||||
EventObservationLinkRead,
|
||||
EventRead,
|
||||
EventRelationCreate,
|
||||
EventRelationRead,
|
||||
EventTagsUpdate,
|
||||
EventUpdate,
|
||||
SplitLineCreate,
|
||||
SplitLineRead,
|
||||
TagRead,
|
||||
)
|
||||
from ..services.ingestion import confirm_event
|
||||
|
||||
router = APIRouter(tags=["events"])
|
||||
|
||||
|
||||
def _get_event(session: Session, event_id: str) -> models.FinancialEvent:
|
||||
event = session.scalars(
|
||||
select(models.FinancialEvent)
|
||||
.where(models.FinancialEvent.id == event_id)
|
||||
.options(selectinload(models.FinancialEvent.split_lines))
|
||||
).first()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
_attach_event_tags(session, [event])
|
||||
return event
|
||||
|
||||
|
||||
def _attach_event_tags(session: Session, events: list[models.FinancialEvent]) -> None:
|
||||
if not events:
|
||||
return
|
||||
event_ids = [event.id for event in events]
|
||||
rows = session.execute(
|
||||
select(models.EventTag.event_id, models.EventTag.tag_id).where(
|
||||
models.EventTag.event_id.in_(event_ids)
|
||||
)
|
||||
).all()
|
||||
tags_by_event: dict[str, list[str]] = {event_id: [] for event_id in event_ids}
|
||||
for event_id, tag_id in rows:
|
||||
tags_by_event.setdefault(event_id, []).append(tag_id)
|
||||
for event in events:
|
||||
event.tag_ids = tags_by_event.get(event.id, [])
|
||||
|
||||
|
||||
@router.post("/events", response_model=EventRead)
|
||||
def create_event(
|
||||
payload: EventCreate, session: Session = Depends(get_session)
|
||||
) -> models.FinancialEvent:
|
||||
fingerprint = compute_normalized_transaction_fingerprint(
|
||||
amount=payload.amount,
|
||||
currency=payload.currency,
|
||||
event_date=payload.event_date,
|
||||
merchant=payload.merchant,
|
||||
counterparty=payload.counterparty,
|
||||
)
|
||||
event = models.FinancialEvent(
|
||||
**payload.model_dump(exclude={"split_lines"}),
|
||||
fingerprint=fingerprint,
|
||||
)
|
||||
session.add(event)
|
||||
session.flush()
|
||||
for split in payload.split_lines:
|
||||
session.add(models.SplitLine(event_id=event.id, **split.model_dump()))
|
||||
session.commit()
|
||||
return _get_event(session, event.id)
|
||||
|
||||
|
||||
@router.get("/events", response_model=list[EventRead])
|
||||
def list_events(
|
||||
household_id: str,
|
||||
status: FinancialEventStatus | None = None,
|
||||
hide_related_confirmations: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[models.FinancialEvent]:
|
||||
query = (
|
||||
select(models.FinancialEvent)
|
||||
.where(models.FinancialEvent.household_id == household_id)
|
||||
.options(selectinload(models.FinancialEvent.split_lines))
|
||||
.order_by(models.FinancialEvent.event_date.desc(), models.FinancialEvent.created_at.desc())
|
||||
)
|
||||
if status:
|
||||
query = query.where(models.FinancialEvent.status == status)
|
||||
if hide_related_confirmations:
|
||||
household_event_ids = select(models.FinancialEvent.id).where(
|
||||
models.FinancialEvent.household_id == household_id
|
||||
)
|
||||
hidden_ids = select(models.EventRelation.to_event_id).where(
|
||||
models.EventRelation.from_event_id.in_(household_event_ids),
|
||||
models.EventRelation.relation_type.in_(
|
||||
[RelationType.SAME_ECONOMIC_EVENT_AS, RelationType.DUPLICATE_OF]
|
||||
),
|
||||
)
|
||||
query = query.where(models.FinancialEvent.id.not_in(hidden_ids))
|
||||
events = list(session.scalars(query).all())
|
||||
_attach_event_tags(session, events)
|
||||
return events
|
||||
|
||||
|
||||
@router.get("/events/{event_id}", response_model=EventRead)
|
||||
def get_event(event_id: str, session: Session = Depends(get_session)) -> models.FinancialEvent:
|
||||
return _get_event(session, event_id)
|
||||
|
||||
|
||||
@router.get("/events/{event_id}/details", response_model=EventDetailRead)
|
||||
def get_event_details(event_id: str, session: Session = Depends(get_session)) -> EventDetailRead:
|
||||
event = _get_event(session, event_id)
|
||||
observation_links = list(
|
||||
session.scalars(
|
||||
select(models.EventObservationLink)
|
||||
.where(models.EventObservationLink.event_id == event_id)
|
||||
.order_by(models.EventObservationLink.created_at)
|
||||
).all()
|
||||
)
|
||||
observations: list[models.RawObservation] = []
|
||||
if observation_links:
|
||||
observations = list(
|
||||
session.scalars(
|
||||
select(models.RawObservation).where(
|
||||
models.RawObservation.id.in_([link.observation_id for link in observation_links])
|
||||
)
|
||||
).all()
|
||||
)
|
||||
relations = list(
|
||||
session.scalars(
|
||||
select(models.EventRelation)
|
||||
.where(
|
||||
or_(
|
||||
models.EventRelation.from_event_id == event_id,
|
||||
models.EventRelation.to_event_id == event_id,
|
||||
)
|
||||
)
|
||||
.order_by(models.EventRelation.created_at)
|
||||
).all()
|
||||
)
|
||||
related_event_ids = {
|
||||
relation.to_event_id if relation.from_event_id == event_id else relation.from_event_id
|
||||
for relation in relations
|
||||
}
|
||||
related_events: list[models.FinancialEvent] = []
|
||||
if related_event_ids:
|
||||
related_events = list(
|
||||
session.scalars(
|
||||
select(models.FinancialEvent)
|
||||
.where(models.FinancialEvent.id.in_(related_event_ids))
|
||||
.options(selectinload(models.FinancialEvent.split_lines))
|
||||
.order_by(models.FinancialEvent.event_date.desc())
|
||||
).all()
|
||||
)
|
||||
_attach_event_tags(session, related_events)
|
||||
category = session.get(models.Category, event.category_id) if event.category_id else None
|
||||
tags = (
|
||||
list(session.scalars(select(models.Tag).where(models.Tag.id.in_(event.tag_ids))).all())
|
||||
if event.tag_ids
|
||||
else []
|
||||
)
|
||||
return EventDetailRead(
|
||||
event=event,
|
||||
category=category,
|
||||
tags=tags,
|
||||
observation_links=[EventObservationLinkRead.model_validate(link) for link in observation_links],
|
||||
observations=observations,
|
||||
relations=[EventRelationRead.model_validate(relation) for relation in relations],
|
||||
related_events=related_events,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/events/{event_id}", response_model=EventRead)
|
||||
def update_event(
|
||||
event_id: str,
|
||||
payload: EventUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.FinancialEvent:
|
||||
event = _get_event(session, event_id)
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(event, field, value)
|
||||
event.fingerprint = compute_normalized_transaction_fingerprint(
|
||||
amount=event.amount,
|
||||
currency=event.currency,
|
||||
event_date=event.event_date,
|
||||
merchant=event.merchant,
|
||||
counterparty=event.counterparty,
|
||||
)
|
||||
session.add(event)
|
||||
session.commit()
|
||||
return _get_event(session, event_id)
|
||||
|
||||
|
||||
@router.patch("/events/{event_id}/tags", response_model=list[TagRead])
|
||||
def update_event_tags(
|
||||
event_id: str,
|
||||
payload: EventTagsUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[models.Tag]:
|
||||
event = _get_event(session, event_id)
|
||||
tags = (
|
||||
list(
|
||||
session.scalars(
|
||||
select(models.Tag).where(
|
||||
models.Tag.id.in_(payload.tag_ids),
|
||||
models.Tag.household_id == event.household_id,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
if payload.tag_ids
|
||||
else []
|
||||
)
|
||||
if len(tags) != len(set(payload.tag_ids)):
|
||||
raise HTTPException(status_code=400, detail="One or more tags do not belong to this household")
|
||||
session.query(models.EventTag).filter(models.EventTag.event_id == event_id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
session.add_all([models.EventTag(event_id=event_id, tag_id=tag.id) for tag in tags])
|
||||
session.commit()
|
||||
return tags
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/confirm", response_model=EventRead)
|
||||
def confirm_event_endpoint(
|
||||
event_id: str, session: Session = Depends(get_session)
|
||||
) -> models.FinancialEvent:
|
||||
event = confirm_event(session, _get_event(session, event_id))
|
||||
session.commit()
|
||||
return _get_event(session, event.id)
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/relations", response_model=EventRelationRead)
|
||||
def add_event_relation(
|
||||
event_id: str,
|
||||
payload: EventRelationCreate,
|
||||
session: Session = Depends(get_session),
|
||||
) -> models.EventRelation:
|
||||
if not session.get(models.FinancialEvent, event_id) or not session.get(
|
||||
models.FinancialEvent, payload.to_event_id
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
relation = models.EventRelation(from_event_id=event_id, **payload.model_dump())
|
||||
session.add(relation)
|
||||
session.commit()
|
||||
session.refresh(relation)
|
||||
return relation
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/splits", response_model=list[SplitLineRead])
|
||||
def replace_event_splits(
|
||||
event_id: str,
|
||||
payload: list[SplitLineCreate],
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[models.SplitLine]:
|
||||
event = _get_event(session, event_id)
|
||||
for split in list(event.split_lines):
|
||||
session.delete(split)
|
||||
session.flush()
|
||||
new_splits = [models.SplitLine(event_id=event_id, **split.model_dump()) for split in payload]
|
||||
session.add_all(new_splits)
|
||||
session.commit()
|
||||
return list(
|
||||
session.scalars(select(models.SplitLine).where(models.SplitLine.event_id == event_id)).all()
|
||||
)
|
||||
Reference in New Issue
Block a user