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() )