271 lines
9.4 KiB
Python
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()
|
|
)
|