Files
2026-06-28 13:48:15 +02:00

271 lines
9.4 KiB
Python

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