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
136 lines
4.3 KiB
Python
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)
|