Files
Ten31-Portal/backend/ten31portal/routers/entity_router.py
T
Johnny 5 30d2284feb Issues 19+20: entity-aware schedule import and rollup endpoint
Issue 19:
- entity_id now optional on POST /api/import/schedule
- Resolution: explicit entity_id > name match from row 1 > auto-create
- New params: create_entity_type (default fund), create_vintage_year
- Dry-run reports resolution (will_create/matched/existing)
- Frontend: radio toggle between 'Create from file' and 'Use existing'

Issue 20:
- GET /api/entities/rollup: invested_cents and last_signed_value_cents
  per entity in one pass (SQL aggregates, no waterfall)
- EntitiesList uses rollup instead of N+1 API calls
2026-06-08 03:02:59 +00:00

136 lines
4.3 KiB
Python

"""Entity CRUD and rollup endpoints."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, literal
from sqlmodel import Session, select
from ten31portal.audit import record_audit
from ten31portal.auth import get_current_user, require_writer
from ten31portal.database import get_session
from ten31portal.models import (
Entity, EntityStatus, Holding, Position,
Valuation, ValuationRound, RoundStatus, User,
)
from ten31portal.schemas import EntityCreate, EntityResponse, EntityUpdate
router = APIRouter(prefix="/api/entities", tags=["entities"])
class EntityRollupItem(BaseModel):
id: int
name: str
type: str
vintage_year: int | None
fund_size_cents: int | None
status: str
invested_cents: int
last_signed_value_cents: int
@router.get("/rollup")
def entity_rollup(
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> list[EntityRollupItem]:
"""Per-entity invested and last-signed-value in a single pass."""
entities = session.exec(select(Entity)).all()
result: list[EntityRollupItem] = []
for ent in entities:
# Sum cost_cents across all positions in this entity's holdings
invested_row = session.exec(
select(func.coalesce(func.sum(Position.cost_cents), 0))
.join(Holding, Position.holding_id == Holding.id)
.where(Holding.entity_id == ent.id)
).one()
invested_cents = int(invested_row)
# Find the latest approved round for this entity
latest_round = session.exec(
select(ValuationRound)
.where(
ValuationRound.entity_id == ent.id,
ValuationRound.status == RoundStatus.approved,
)
.order_by(ValuationRound.quarter_end.desc()) # type: ignore[union-attr]
).first()
last_signed_value_cents = 0
if latest_round:
val_sum = session.exec(
select(func.coalesce(func.sum(Valuation.value_cents), 0))
.where(Valuation.round_id == latest_round.id)
).one()
last_signed_value_cents = int(val_sum)
result.append(EntityRollupItem(
id=ent.id,
name=ent.name,
type=ent.type.value,
vintage_year=ent.vintage_year,
fund_size_cents=ent.fund_size_cents,
status=ent.status.value,
invested_cents=invested_cents,
last_signed_value_cents=last_signed_value_cents,
))
return result
@router.get("")
def list_entities(
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> list[EntityResponse]:
rows = session.exec(select(Entity)).all()
return [EntityResponse.model_validate(r, from_attributes=True) for r in rows]
@router.get("/{entity_id}")
def get_entity(
entity_id: int,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> EntityResponse:
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
return EntityResponse.model_validate(entity, from_attributes=True)
@router.post("", status_code=201)
def create_entity(
body: EntityCreate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> EntityResponse:
entity = Entity(**body.model_dump())
session.add(entity)
session.flush()
record_audit(session, user.id, "create", "entity", entity.id, body.model_dump())
session.commit()
session.refresh(entity)
return EntityResponse.model_validate(entity, from_attributes=True)
@router.patch("/{entity_id}")
def update_entity(
entity_id: int,
body: EntityUpdate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> EntityResponse:
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
changes = body.model_dump(exclude_unset=True)
for key, val in changes.items():
setattr(entity, key, val)
session.add(entity)
session.flush()
record_audit(session, user.id, "update", "entity", entity.id, changes)
session.commit()
session.refresh(entity)
return EntityResponse.model_validate(entity, from_attributes=True)