Files
Ten31-Portal/backend/ten31portal/routers/holding_router.py
T
Jonathan Kirkwood f0f8fd15c6 Release 0.2.22: capital chart, Investor View, GP stakes, doc folders
Snapshot commit bringing the uncommitted phase-2 work into version control
together with four new features and the 0.2.22 version bump.

New features:
- Investor capital-over-time chart (value, paid-in, distributions per
  quarter), rendered from existing capital-account history.
- Admin Investor View: read-only reconstruction of an investor's portal
  (GET /api/users/{id}/investor-view), reusing the investor portal UI.
- Document upload scoped to the selected fund's own investors, with an
  explicit upload-target confirmation to prevent mis-attaching.
- GP/mgmt entities gain an Assets tab listing their stakes in the funds
  they manage (new entity_stakes table + /api/entities/{id}/stakes).
- Edit-entity form (change type/status/etc.), so GP entities can be
  categorized correctly.

Verified: 11/11 backend tests pass; alembic upgrades to head b8c9d0e1f2a3;
frontend tsc + vite build clean; s9pk packs at 0.2.22:0 (x86_64).
Also: ignore .DS_Store and *.s9pk artifacts.
2026-07-01 14:25:50 -05:00

84 lines
3.1 KiB
Python

"""Holding CRUD endpoints."""
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from ten31portal.audit import record_audit
from ten31portal.auth import require_internal, require_writer
from ten31portal.database import get_session
from ten31portal.models import Entity, Holding, Position, User
from ten31portal.schemas import HoldingCreate, HoldingResponse, HoldingUpdate
router = APIRouter(tags=["holdings"])
@router.get("/api/entities/{entity_id}/holdings")
def list_holdings(
entity_id: int,
user: User = Depends(require_internal),
session: Session = Depends(get_session),
) -> list[HoldingResponse]:
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
rows = session.exec(select(Holding).where(Holding.entity_id == entity_id)).all()
return [HoldingResponse.model_validate(r, from_attributes=True) for r in rows]
@router.post("/api/entities/{entity_id}/holdings", status_code=201)
def create_holding(
entity_id: int,
body: HoldingCreate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> HoldingResponse:
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
holding = Holding(entity_id=entity_id, company_name=body.company_name)
session.add(holding)
session.flush()
record_audit(session, user.id, "create", "holding", holding.id, {"entity_id": entity_id, **body.model_dump()})
session.commit()
session.refresh(holding)
return HoldingResponse.model_validate(holding, from_attributes=True)
@router.patch("/api/holdings/{holding_id}")
def update_holding(
holding_id: int,
body: HoldingUpdate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> HoldingResponse:
holding = session.get(Holding, holding_id)
if holding is None:
raise HTTPException(status_code=404, detail="Holding not found")
changes = body.model_dump(exclude_unset=True)
for key, val in changes.items():
setattr(holding, key, val)
session.add(holding)
session.flush()
record_audit(session, user.id, "update", "holding", holding.id, changes)
session.commit()
session.refresh(holding)
return HoldingResponse.model_validate(holding, from_attributes=True)
@router.delete("/api/holdings/{holding_id}")
def delete_holding(
holding_id: int,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> dict[str, str]:
holding = session.get(Holding, holding_id)
if holding is None:
raise HTTPException(status_code=404, detail="Holding not found")
positions = session.exec(select(Position).where(Position.holding_id == holding_id)).all()
if positions:
raise HTTPException(status_code=409, detail="Cannot delete holding with existing positions. Remove positions first.")
record_audit(session, user.id, "delete", "holding", holding.id, {"company_name": holding.company_name})
session.delete(holding)
session.commit()
return {"status": "deleted"}