Issue 1: repo scaffold and project structure

This commit is contained in:
Johnny 5
2026-06-07 19:20:02 +00:00
parent e0b31009b7
commit a70bdeaa5e
52 changed files with 6750 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Router package
@@ -0,0 +1,31 @@
"""Audit log endpoint."""
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session, select, col
from ten31portal.auth import require_audit_reader
from ten31portal.database import get_session
from ten31portal.models import AuditLog, User
from ten31portal.schemas import AuditLogResponse
router = APIRouter(prefix="/api/audit", tags=["audit"])
@router.get("")
def list_audit(
object_type: str | None = None,
object_id: int | None = None,
page: int = Query(default=1, ge=1),
per_page: int = Query(default=50, ge=1, le=200),
user: User = Depends(require_audit_reader),
session: Session = Depends(get_session),
) -> list[AuditLogResponse]:
stmt = select(AuditLog)
if object_type is not None:
stmt = stmt.where(AuditLog.object_type == object_type)
if object_id is not None:
stmt = stmt.where(AuditLog.object_id == object_id)
stmt = stmt.order_by(col(AuditLog.id).desc())
stmt = stmt.offset((page - 1) * per_page).limit(per_page)
rows = session.exec(stmt).all()
return [AuditLogResponse.model_validate(r, from_attributes=True) for r in rows]
@@ -0,0 +1,37 @@
"""Authentication endpoints."""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select
from ten31portal.auth import get_current_user, hash_password, verify_password
from ten31portal.database import get_session
from ten31portal.models import User
from ten31portal.schemas import LoginRequest, UserResponse
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login")
def login(
body: LoginRequest,
request: Request,
session: Session = Depends(get_session),
) -> UserResponse:
user = session.exec(select(User).where(User.email == body.email)).first()
if user is None or not verify_password(body.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid email or password")
if not user.is_active:
raise HTTPException(status_code=401, detail="Account disabled")
request.session["user_id"] = user.id
return UserResponse.model_validate(user, from_attributes=True)
@router.post("/logout")
def logout(request: Request) -> dict[str, str]:
request.session.clear()
return {"status": "ok"}
@router.get("/me")
def me(user: User = Depends(get_current_user)) -> UserResponse:
return UserResponse.model_validate(user, from_attributes=True)
@@ -0,0 +1,69 @@
"""Entity CRUD endpoints."""
from fastapi import APIRouter, Depends, HTTPException
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, User
from ten31portal.schemas import EntityCreate, EntityResponse, EntityUpdate
router = APIRouter(prefix="/api/entities", tags=["entities"])
@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)
@@ -0,0 +1,83 @@
"""Holding CRUD endpoints."""
from fastapi import APIRouter, Depends, HTTPException
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, 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(get_current_user),
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"}
@@ -0,0 +1,437 @@
"""CSV import endpoints for entities and schedule of investments."""
import csv
import io
import re
from datetime import date, datetime
from typing import Any
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from sqlmodel import Session, select
from ten31portal.audit import record_audit
from ten31portal.auth import require_role
from ten31portal.database import get_session
from ten31portal.models import (
Entity, EntityStatus, EntityType, Holding, Position,
User, UserRole, Valuation, ValuationRound, RoundStatus,
)
router = APIRouter(prefix="/api/import", tags=["import"])
# --- Column maps ---
# These must be confirmed against real Carta CSV exports.
# Mark as UNCONFIRMED until a sample file is validated.
ENTITY_COLUMN_MAP: dict[str, str] = {
# Carta export header -> model field
# UNCONFIRMED: update these after inspecting a real Carta entities export
"Entity Name": "name",
"Entity Type": "type",
"Vintage Year": "vintage_year",
"Fund Size": "fund_size_cents",
}
ENTITY_TYPE_MAP: dict[str, EntityType] = {
"Fund": EntityType.fund,
"fund": EntityType.fund,
"SPV": EntityType.spv,
"spv": EntityType.spv,
"GP": EntityType.gp,
"gp": EntityType.gp,
"Mgmt Co": EntityType.mgmt_co,
"mgmt_co": EntityType.mgmt_co,
"Management Company": EntityType.mgmt_co,
}
SCHEDULE_COLUMN_MAP: dict[str, str] = {
# UNCONFIRMED: update after inspecting a real Carta schedule-of-investments export
"Company": "company_name",
"Security": "security_name",
"Investment Date": "investment_date",
"Shares": "shares",
"Cost": "cost_cents",
"Value": "value_cents",
}
def _parse_money(raw: str) -> int | None:
"""Parse dollar strings like '$3.3M', '$61.9M', '$1,234,567', '3300000' to cents."""
if not raw or not raw.strip():
return None
s = raw.strip().replace(",", "").replace("$", "")
multiplier = 1
if s.upper().endswith("M"):
multiplier = 1_000_000
s = s[:-1]
elif s.upper().endswith("K"):
multiplier = 1_000
s = s[:-1]
elif s.upper().endswith("B"):
multiplier = 1_000_000_000
s = s[:-1]
try:
return round(float(s) * multiplier * 100)
except ValueError:
return None
def _parse_date(raw: str) -> date | None:
"""Try common date formats."""
for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%m/%d/%y", "%Y/%m/%d"):
try:
return datetime.strptime(raw.strip(), fmt).date()
except ValueError:
continue
return None
def _parse_shares(raw: str) -> str | None:
"""Parse share count, return as string (Decimal stored as string)."""
if not raw or not raw.strip():
return None
s = raw.strip().replace(",", "")
try:
float(s) # Validate it's numeric
return s
except ValueError:
return None
# --- Entity import ---
@router.post("/entities")
def import_entities(
file: UploadFile = File(...),
commit: bool = Query(default=True),
user: User = Depends(require_role(UserRole.approver, UserRole.cfo)),
session: Session = Depends(get_session),
) -> dict[str, Any]:
content = file.file.read().decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(content))
results: list[dict] = []
errors: list[dict] = []
created = 0
updated = 0
for i, row in enumerate(reader, start=2): # row 1 is header
mapped: dict[str, Any] = {}
unmapped_cols: list[str] = []
for csv_col, model_field in ENTITY_COLUMN_MAP.items():
val = row.get(csv_col)
if val is None:
# Try case-insensitive match
for k, v in row.items():
if k.strip().lower() == csv_col.lower():
val = v
break
if val is not None:
mapped[model_field] = val.strip()
else:
unmapped_cols.append(csv_col)
# Parse fields
parsed: dict[str, Any] = {}
row_errors: list[str] = []
name = mapped.get("name")
if not name:
row_errors.append("Missing entity name")
else:
parsed["name"] = name
type_raw = mapped.get("type", "")
entity_type = ENTITY_TYPE_MAP.get(type_raw)
if entity_type is None and type_raw:
row_errors.append(f"Unknown entity type: {type_raw}")
elif entity_type:
parsed["type"] = entity_type
vy = mapped.get("vintage_year")
if vy:
try:
parsed["vintage_year"] = int(vy)
except ValueError:
row_errors.append(f"Invalid vintage year: {vy}")
fs = mapped.get("fund_size_cents")
if fs:
cents = _parse_money(fs)
if cents is None:
row_errors.append(f"Cannot parse fund size: {fs}")
else:
parsed["fund_size_cents"] = cents
if row_errors:
errors.append({"row": i, "errors": row_errors, "raw": dict(row)})
continue
if not parsed.get("name"):
continue
# Check existing
existing = session.exec(select(Entity).where(Entity.name == parsed["name"])).first()
action = "update" if existing else "create"
results.append({
"row": i,
"action": action,
"name": parsed["name"],
"type": parsed.get("type", EntityType.fund).value if parsed.get("type") else None,
"vintage_year": parsed.get("vintage_year"),
"fund_size_cents": parsed.get("fund_size_cents"),
})
if commit:
if existing:
if "type" in parsed:
existing.type = parsed["type"]
if "vintage_year" in parsed:
existing.vintage_year = parsed["vintage_year"]
if "fund_size_cents" in parsed:
existing.fund_size_cents = parsed["fund_size_cents"]
session.add(existing)
updated += 1
else:
entity = Entity(
name=parsed["name"],
type=parsed.get("type", EntityType.fund),
vintage_year=parsed.get("vintage_year"),
fund_size_cents=parsed.get("fund_size_cents"),
)
session.add(entity)
created += 1
if commit:
record_audit(session, user.id, "import_entities", "entity", None, {
"created": created, "updated": updated, "errors": len(errors),
})
session.commit()
return {
"committed": commit,
"preview": results,
"errors": errors,
"summary": {"created": created, "updated": updated, "error_rows": len(errors)},
}
# --- Schedule of investments import ---
@router.post("/schedule")
def import_schedule(
file: UploadFile = File(...),
entity_id: int = Query(...),
as_of: date = Query(...),
commit: bool = Query(default=True),
user: User = Depends(require_role(UserRole.approver, UserRole.cfo)),
session: Session = Depends(get_session),
) -> dict[str, Any]:
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
# Check for existing non-seed round at this quarter
existing_round = session.exec(
select(ValuationRound).where(
ValuationRound.entity_id == entity_id,
ValuationRound.quarter_end == as_of,
ValuationRound.is_seed == False,
)
).first()
if existing_round:
raise HTTPException(
status_code=409,
detail="A non-seed valuation round already exists for this entity and quarter.",
)
content = file.file.read().decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(content))
holdings_preview: list[dict] = []
positions_preview: list[dict] = []
errors: list[dict] = []
current_company: str | None = None
for i, row in enumerate(reader, start=2):
mapped: dict[str, Any] = {}
for csv_col, model_field in SCHEDULE_COLUMN_MAP.items():
val = row.get(csv_col)
if val is None:
for k, v in row.items():
if k.strip().lower() == csv_col.lower():
val = v
break
if val is not None:
mapped[model_field] = val.strip()
company = mapped.get("company_name", "").strip()
security = mapped.get("security_name", "").strip()
# Grouped layout: company row has company name but may lack security detail
if company and not security:
current_company = company
holdings_preview.append({"row": i, "company_name": company})
continue
# Position row: may inherit company from grouping
if not company and current_company:
company = current_company
elif company:
# Both company and security on same row
if company not in [h["company_name"] for h in holdings_preview]:
holdings_preview.append({"row": i, "company_name": company})
current_company = company
if not security:
continue # Skip empty/subtotal rows
row_errors: list[str] = []
inv_date = None
if mapped.get("investment_date"):
inv_date = _parse_date(mapped["investment_date"])
if inv_date is None:
row_errors.append(f"Cannot parse date: {mapped['investment_date']}")
shares = _parse_shares(mapped.get("shares", ""))
cost_cents = None
if mapped.get("cost_cents"):
cost_cents = _parse_money(mapped["cost_cents"])
if cost_cents is None:
row_errors.append(f"Cannot parse cost: {mapped['cost_cents']}")
value_cents = None
if mapped.get("value_cents"):
value_cents = _parse_money(mapped["value_cents"])
if value_cents is None:
row_errors.append(f"Cannot parse value: {mapped['value_cents']}")
if row_errors:
errors.append({"row": i, "errors": row_errors, "raw": dict(row)})
continue
positions_preview.append({
"row": i,
"company_name": company,
"security_name": security,
"investment_date": str(inv_date) if inv_date else None,
"shares": shares,
"cost_cents": cost_cents,
"value_cents": value_cents,
})
if not commit:
return {
"committed": False,
"holdings": holdings_preview,
"positions": positions_preview,
"errors": errors,
"seed_round": {"entity_id": entity_id, "quarter_end": str(as_of)},
}
# Commit: create holdings, positions, seed round
holding_map: dict[str, Holding] = {}
for hp in holdings_preview:
name = hp["company_name"]
existing = session.exec(
select(Holding).where(
Holding.entity_id == entity_id,
Holding.company_name == name,
)
).first()
if existing:
holding_map[name] = existing
else:
h = Holding(entity_id=entity_id, company_name=name)
session.add(h)
session.flush()
holding_map[name] = h
# Create seed round
seed_round = ValuationRound(
entity_id=entity_id,
quarter_end=as_of,
status=RoundStatus.approved,
is_seed=True,
approved_by=user.id,
approved_at=datetime.utcnow(),
)
session.add(seed_round)
session.flush()
positions_created = 0
for pp in positions_preview:
company = pp["company_name"]
holding = holding_map.get(company)
if holding is None:
# Create holding on the fly for positions with inline company
existing = session.exec(
select(Holding).where(
Holding.entity_id == entity_id,
Holding.company_name == company,
)
).first()
if existing:
holding = existing
else:
holding = Holding(entity_id=entity_id, company_name=company)
session.add(holding)
session.flush()
holding_map[company] = holding
# Upsert position by (holding, security_name)
pos = session.exec(
select(Position).where(
Position.holding_id == holding.id,
Position.security_name == pp["security_name"],
)
).first()
if pos is None:
pos = Position(
holding_id=holding.id,
security_name=pp["security_name"],
investment_date=_parse_date(pp["investment_date"]) if pp["investment_date"] else as_of,
shares=pp["shares"],
cost_cents=pp["cost_cents"] or 0,
)
session.add(pos)
session.flush()
positions_created += 1
else:
if pp["investment_date"]:
pos.investment_date = _parse_date(pp["investment_date"])
if pp["shares"]:
pos.shares = pp["shares"]
if pp["cost_cents"] is not None:
pos.cost_cents = pp["cost_cents"]
session.add(pos)
session.flush()
# Attach valuation to seed round
val = Valuation(
round_id=seed_round.id,
position_id=pos.id,
value_cents=pp["value_cents"] or 0,
)
session.add(val)
record_audit(session, user.id, "import_schedule", "entity", entity_id, {
"holdings": len(holding_map),
"positions": positions_created,
"seed_quarter": str(as_of),
})
session.commit()
return {
"committed": True,
"holdings_count": len(holding_map),
"positions_count": len(positions_preview),
"seed_round_id": seed_round.id,
"errors": errors,
}
@@ -0,0 +1,117 @@
"""Position CRUD endpoints."""
import math
from fastapi import APIRouter, Depends, HTTPException
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 Holding, Position, Valuation, ValuationRound, RoundStatus, User
from ten31portal.schemas import PositionCreate, PositionResponse, PositionUpdate
router = APIRouter(tags=["positions"])
def _dollars_to_cents(dollars: float) -> int:
"""Convert dollar amount to integer cents, rounding to nearest cent."""
return round(dollars * 100)
@router.get("/api/holdings/{holding_id}/positions")
def list_positions(
holding_id: int,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> list[PositionResponse]:
holding = session.get(Holding, holding_id)
if holding is None:
raise HTTPException(status_code=404, detail="Holding not found")
rows = session.exec(select(Position).where(Position.holding_id == holding_id)).all()
return [PositionResponse.model_validate(r, from_attributes=True) for r in rows]
@router.post("/api/holdings/{holding_id}/positions", status_code=201)
def create_position(
holding_id: int,
body: PositionCreate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> PositionResponse:
holding = session.get(Holding, holding_id)
if holding is None:
raise HTTPException(status_code=404, detail="Holding not found")
position = Position(
holding_id=holding_id,
security_name=body.security_name,
investment_date=body.investment_date,
shares=body.shares,
cost_cents=_dollars_to_cents(body.cost_dollars),
)
session.add(position)
session.flush()
record_audit(session, user.id, "create", "position", position.id, {
"holding_id": holding_id,
"security_name": body.security_name,
"cost_dollars": body.cost_dollars,
})
session.commit()
session.refresh(position)
return PositionResponse.model_validate(position, from_attributes=True)
@router.patch("/api/positions/{position_id}")
def update_position(
position_id: int,
body: PositionUpdate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> PositionResponse:
position = session.get(Position, position_id)
if position is None:
raise HTTPException(status_code=404, detail="Position not found")
changes = body.model_dump(exclude_unset=True)
if "cost_dollars" in changes:
position.cost_cents = _dollars_to_cents(changes.pop("cost_dollars"))
for key, val in changes.items():
setattr(position, key, val)
session.add(position)
session.flush()
record_audit(session, user.id, "update", "position", position.id, body.model_dump(exclude_unset=True))
session.commit()
session.refresh(position)
return PositionResponse.model_validate(position, from_attributes=True)
@router.delete("/api/positions/{position_id}")
def delete_position(
position_id: int,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> dict[str, str]:
position = session.get(Position, position_id)
if position is None:
raise HTTPException(status_code=404, detail="Position not found")
# Refuse if any approved valuation references this position
approved_vals = session.exec(
select(Valuation)
.join(ValuationRound)
.where(
Valuation.position_id == position_id,
ValuationRound.status == RoundStatus.approved,
)
).all()
if approved_vals:
raise HTTPException(
status_code=409,
detail="Cannot delete position referenced by an approved valuation round.",
)
record_audit(session, user.id, "delete", "position", position.id, {
"security_name": position.security_name,
})
session.delete(position)
session.commit()
return {"status": "deleted"}
+221
View File
@@ -0,0 +1,221 @@
"""Valuation round workflow endpoints."""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select, col
from ten31portal.audit import record_audit
from ten31portal.auth import get_current_user, require_writer, require_approver
from ten31portal.database import get_session
from ten31portal.models import (
Entity, Holding, Position, Valuation, ValuationRound,
RoundStatus, User,
)
from ten31portal.schemas import (
RoundCreate, RoundResponse, ValuationBulkUpdate, ValuationResponse, ReturnNote,
)
router = APIRouter(tags=["rounds"])
def _round_response(round: ValuationRound, session: Session) -> RoundResponse:
vals = session.exec(select(Valuation).where(Valuation.round_id == round.id)).all()
return RoundResponse(
**round.model_dump(),
valuations=[ValuationResponse.model_validate(v, from_attributes=True) for v in vals],
)
@router.get("/api/entities/{entity_id}/rounds")
def list_rounds(
entity_id: int,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> list[RoundResponse]:
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
rounds = session.exec(
select(ValuationRound)
.where(ValuationRound.entity_id == entity_id)
.order_by(col(ValuationRound.quarter_end).desc())
).all()
return [_round_response(r, session) for r in rounds]
@router.get("/api/rounds/{round_id}")
def get_round(
round_id: int,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> RoundResponse:
round = session.get(ValuationRound, round_id)
if round is None:
raise HTTPException(status_code=404, detail="Round not found")
return _round_response(round, session)
@router.post("/api/entities/{entity_id}/rounds", status_code=201)
def create_round(
entity_id: int,
body: RoundCreate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> RoundResponse:
"""Create a draft round. Pre-populate valuations with last approved marks (carry forward)."""
entity = session.get(Entity, entity_id)
if entity is None:
raise HTTPException(status_code=404, detail="Entity not found")
# Check for existing non-returned round at this quarter
existing = session.exec(
select(ValuationRound).where(
ValuationRound.entity_id == entity_id,
ValuationRound.quarter_end == body.quarter_end,
ValuationRound.status != RoundStatus.returned,
)
).first()
if existing:
raise HTTPException(status_code=409, detail="A round already exists for this entity and quarter.")
round = ValuationRound(entity_id=entity_id, quarter_end=body.quarter_end)
session.add(round)
session.flush()
# Get all positions for this entity
holdings = session.exec(select(Holding).where(Holding.entity_id == entity_id)).all()
for holding in holdings:
positions = session.exec(select(Position).where(Position.holding_id == holding.id)).all()
for pos in positions:
# Find most recent approved value for this position
last_val = session.exec(
select(Valuation)
.join(ValuationRound)
.where(
Valuation.position_id == pos.id,
ValuationRound.status == RoundStatus.approved,
)
.order_by(col(ValuationRound.quarter_end).desc())
).first()
val = Valuation(
round_id=round.id,
position_id=pos.id,
value_cents=last_val.value_cents if last_val else 0,
)
session.add(val)
record_audit(session, user.id, "create", "round", round.id, {
"entity_id": entity_id,
"quarter_end": str(body.quarter_end),
})
session.commit()
session.refresh(round)
return _round_response(round, session)
@router.patch("/api/rounds/{round_id}/valuations")
def bulk_update_valuations(
round_id: int,
body: ValuationBulkUpdate,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> RoundResponse:
"""Bulk set value_cents for positions in a draft or returned round."""
round = session.get(ValuationRound, round_id)
if round is None:
raise HTTPException(status_code=404, detail="Round not found")
if round.status not in (RoundStatus.draft, RoundStatus.returned):
raise HTTPException(status_code=409, detail="Can only edit valuations in draft or returned rounds.")
for item in body.valuations:
val = session.exec(
select(Valuation).where(
Valuation.round_id == round_id,
Valuation.position_id == item.position_id,
)
).first()
if val is None:
raise HTTPException(status_code=404, detail=f"Valuation not found for position {item.position_id}")
val.value_cents = item.value_cents
session.add(val)
record_audit(session, user.id, "update_valuations", "round", round.id, {
"count": len(body.valuations),
})
session.commit()
session.refresh(round)
return _round_response(round, session)
@router.post("/api/rounds/{round_id}/submit")
def submit_round(
round_id: int,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> RoundResponse:
round = session.get(ValuationRound, round_id)
if round is None:
raise HTTPException(status_code=404, detail="Round not found")
if round.status not in (RoundStatus.draft, RoundStatus.returned):
raise HTTPException(status_code=409, detail="Only draft or returned rounds can be submitted.")
round.status = RoundStatus.submitted
round.submitted_by = user.id
round.submitted_at = datetime.utcnow()
session.add(round)
record_audit(session, user.id, "submit", "round", round.id, {
"quarter_end": str(round.quarter_end),
})
session.commit()
session.refresh(round)
return _round_response(round, session)
@router.post("/api/rounds/{round_id}/approve")
def approve_round(
round_id: int,
user: User = Depends(require_approver),
session: Session = Depends(get_session),
) -> RoundResponse:
round = session.get(ValuationRound, round_id)
if round is None:
raise HTTPException(status_code=404, detail="Round not found")
if round.status != RoundStatus.submitted:
raise HTTPException(status_code=409, detail="Only submitted rounds can be approved.")
round.status = RoundStatus.approved
round.approved_by = user.id
round.approved_at = datetime.utcnow()
session.add(round)
# Record both roles if self-approving
detail = {"quarter_end": str(round.quarter_end)}
if round.submitted_by == user.id:
detail["note"] = "Self-approved (submitter and approver are the same user)"
record_audit(session, user.id, "approve", "round", round.id, detail)
session.commit()
session.refresh(round)
return _round_response(round, session)
@router.post("/api/rounds/{round_id}/return")
def return_round(
round_id: int,
body: ReturnNote,
user: User = Depends(require_approver),
session: Session = Depends(get_session),
) -> RoundResponse:
round = session.get(ValuationRound, round_id)
if round is None:
raise HTTPException(status_code=404, detail="Round not found")
if round.status != RoundStatus.submitted:
raise HTTPException(status_code=409, detail="Only submitted rounds can be returned.")
round.status = RoundStatus.returned
round.return_note = body.note
session.add(round)
record_audit(session, user.id, "return", "round", round.id, {
"quarter_end": str(round.quarter_end),
"note": body.note,
})
session.commit()
session.refresh(round)
return _round_response(round, session)