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 @@
# Ten31Portal backend
+29
View File
@@ -0,0 +1,29 @@
"""Audit log helper. Every state-changing endpoint must call record_audit."""
import json
from typing import Any
from sqlmodel import Session
from ten31portal.models import AuditLog
def record_audit(
session: Session,
actor_user_id: int | None,
action: str,
object_type: str,
object_id: int | None = None,
detail: Any = None,
) -> AuditLog:
"""Write one audit log entry and flush it."""
entry = AuditLog(
actor_user_id=actor_user_id,
action=action,
object_type=object_type,
object_id=object_id,
detail=detail,
)
session.add(entry)
session.flush()
return entry
+51
View File
@@ -0,0 +1,51 @@
"""Authentication, session management, and role enforcement."""
from typing import Annotated
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import Depends, HTTPException, Request
from sqlmodel import Session, select
from ten31portal.database import get_session
from ten31portal.models import User, UserRole
ph = PasswordHasher()
def hash_password(password: str) -> str:
return ph.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
try:
return ph.verify(password_hash, password)
except VerifyMismatchError:
return False
def get_current_user(request: Request, session: Session = Depends(get_session)) -> User:
"""FastAPI dependency: extract user from session cookie."""
user_id = request.session.get("user_id")
if user_id is None:
raise HTTPException(status_code=401, detail="Not authenticated")
user = session.get(User, user_id)
if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
def require_role(*roles: UserRole):
"""Return a dependency that enforces one of the given roles."""
def checker(user: User = Depends(get_current_user)) -> User:
if user.role not in roles:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return user
return checker
# Convenience aliases
require_user = get_current_user
require_writer = require_role(UserRole.fund_admin, UserRole.cfo, UserRole.approver)
require_approver = require_role(UserRole.approver)
require_audit_reader = require_role(UserRole.approver, UserRole.cfo)
+59
View File
@@ -0,0 +1,59 @@
"""CLI commands for Ten31Portal."""
import argparse
import sys
from sqlmodel import Session, select
from ten31portal.auth import hash_password
from ten31portal.database import engine
from ten31portal.db_init import run_migrations
from ten31portal.models import User, UserRole
def create_user(args: argparse.Namespace) -> None:
"""Create a new user account."""
run_migrations()
with Session(engine) as session:
existing = session.exec(select(User).where(User.email == args.email)).first()
if existing:
print(f"Error: user with email {args.email} already exists.", file=sys.stderr)
sys.exit(1)
try:
role = UserRole(args.role)
except ValueError:
print(f"Error: invalid role '{args.role}'. Must be one of: {', '.join(r.value for r in UserRole)}", file=sys.stderr)
sys.exit(1)
user = User(
name=args.name,
email=args.email,
password_hash=hash_password(args.password),
role=role,
)
session.add(user)
session.commit()
print(f"Created user: {user.name} ({user.email}) with role {user.role.value}")
def main() -> None:
parser = argparse.ArgumentParser(prog="ten31portal-cli")
sub = parser.add_subparsers(dest="command")
create = sub.add_parser("create-user", help="Provision a new user")
create.add_argument("--name", required=True)
create.add_argument("--email", required=True)
create.add_argument("--role", required=True, choices=[r.value for r in UserRole])
create.add_argument("--password", required=True)
args = parser.parse_args()
if args.command == "create-user":
create_user(args)
else:
parser.print_help()
if __name__ == "__main__":
main()
+7
View File
@@ -0,0 +1,7 @@
"""Application configuration via environment variables."""
import os
DB_PATH: str = os.getenv("TEN31_DB_PATH", "/data/ten31portal/portal.db")
SESSION_SECRET: str = os.getenv("TEN31_SESSION_SECRET", "change-me-in-production")
+13
View File
@@ -0,0 +1,13 @@
"""Database engine and session management."""
from sqlmodel import Session, create_engine
from ten31portal.config import DB_PATH
engine = create_engine(f"sqlite:///{DB_PATH}", echo=False)
def get_session():
"""FastAPI dependency that yields a database session."""
with Session(engine) as session:
yield session
+15
View File
@@ -0,0 +1,15 @@
"""Run Alembic migrations to head on startup."""
import os
from pathlib import Path
from alembic import command
from alembic.config import Config
def run_migrations() -> None:
"""Apply all pending Alembic migrations."""
backend_dir = Path(__file__).resolve().parent.parent
alembic_cfg = Config(str(backend_dir / "alembic.ini"))
alembic_cfg.set_main_option("script_location", str(backend_dir / "alembic"))
command.upgrade(alembic_cfg, "head")
+47
View File
@@ -0,0 +1,47 @@
"""Ten31Portal FastAPI application."""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware
from ten31portal.config import SESSION_SECRET
from ten31portal.db_init import run_migrations
from ten31portal.routers.auth_router import router as auth_router
from ten31portal.routers.audit_router import router as audit_router
from ten31portal.routers.entity_router import router as entity_router
from ten31portal.routers.holding_router import router as holding_router
from ten31portal.routers.position_router import router as position_router
from ten31portal.routers.round_router import router as round_router
from ten31portal.routers.import_router import router as import_router
@asynccontextmanager
async def lifespan(app: FastAPI):
run_migrations()
yield
app = FastAPI(title="Ten31Portal", version="0.1.0", lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET)
app.include_router(auth_router)
app.include_router(audit_router)
app.include_router(entity_router)
app.include_router(holding_router)
app.include_router(position_router)
app.include_router(round_router)
app.include_router(import_router)
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
def cli() -> None:
import uvicorn
uvicorn.run("ten31portal.main:app", host="0.0.0.0", port=8000, reload=True)
if __name__ == "__main__":
cli()
+127
View File
@@ -0,0 +1,127 @@
"""SQLModel table definitions for Ten31Portal."""
import enum
from datetime import date, datetime
from decimal import Decimal
from typing import Optional
from sqlmodel import Field, SQLModel, Column, String, JSON, UniqueConstraint
# --- Enums ---
class UserRole(str, enum.Enum):
approver = "approver"
cfo = "cfo"
fund_admin = "fund_admin"
viewer = "viewer"
class EntityType(str, enum.Enum):
fund = "fund"
spv = "spv"
gp = "gp"
mgmt_co = "mgmt_co"
class EntityStatus(str, enum.Enum):
active = "active"
closed = "closed"
class RoundStatus(str, enum.Enum):
draft = "draft"
submitted = "submitted"
approved = "approved"
returned = "returned"
# --- Tables ---
class User(SQLModel, table=True):
__tablename__ = "users"
id: int | None = Field(default=None, primary_key=True)
name: str
email: str = Field(sa_column=Column(String, unique=True, nullable=False))
password_hash: str
role: UserRole
is_active: bool = Field(default=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
class Entity(SQLModel, table=True):
__tablename__ = "entities"
id: int | None = Field(default=None, primary_key=True)
name: str
type: EntityType
vintage_year: int | None = None
fund_size_cents: int | None = None
status: EntityStatus = Field(default=EntityStatus.active)
created_at: datetime = Field(default_factory=datetime.utcnow)
class Holding(SQLModel, table=True):
__tablename__ = "holdings"
id: int | None = Field(default=None, primary_key=True)
entity_id: int = Field(foreign_key="entities.id")
company_name: str
created_at: datetime = Field(default_factory=datetime.utcnow)
class Position(SQLModel, table=True):
__tablename__ = "positions"
id: int | None = Field(default=None, primary_key=True)
holding_id: int = Field(foreign_key="holdings.id")
security_name: str
investment_date: date
shares: str | None = None # Decimal stored as string
cost_cents: int
created_at: datetime = Field(default_factory=datetime.utcnow)
class ValuationRound(SQLModel, table=True):
__tablename__ = "valuation_rounds"
__table_args__ = (
UniqueConstraint("entity_id", "quarter_end", name="uq_round_entity_quarter"),
)
id: int | None = Field(default=None, primary_key=True)
entity_id: int = Field(foreign_key="entities.id")
quarter_end: date
status: RoundStatus = Field(default=RoundStatus.draft)
submitted_by: int | None = Field(default=None, foreign_key="users.id")
submitted_at: datetime | None = None
approved_by: int | None = Field(default=None, foreign_key="users.id")
approved_at: datetime | None = None
return_note: str | None = None
is_seed: bool = Field(default=False)
created_at: datetime = Field(default_factory=datetime.utcnow)
class Valuation(SQLModel, table=True):
__tablename__ = "valuations"
__table_args__ = (
UniqueConstraint("round_id", "position_id", name="uq_valuation_round_position"),
)
id: int | None = Field(default=None, primary_key=True)
round_id: int = Field(foreign_key="valuation_rounds.id")
position_id: int = Field(foreign_key="positions.id")
value_cents: int
created_at: datetime = Field(default_factory=datetime.utcnow)
class AuditLog(SQLModel, table=True):
__tablename__ = "audit_log"
id: int | None = Field(default=None, primary_key=True)
actor_user_id: int | None = Field(default=None, foreign_key="users.id")
action: str
object_type: str
object_id: int | None = None
detail: str | None = Field(default=None, sa_column=Column(JSON, nullable=True))
created_at: datetime = Field(default_factory=datetime.utcnow)
+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)
+148
View File
@@ -0,0 +1,148 @@
"""Pydantic schemas for API request/response shapes."""
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel
from ten31portal.models import UserRole, EntityType, EntityStatus, RoundStatus
# --- Auth ---
class LoginRequest(BaseModel):
email: str
password: str
class UserResponse(BaseModel):
id: int
name: str
email: str
role: UserRole
is_active: bool
created_at: datetime
# --- Entity ---
class EntityCreate(BaseModel):
name: str
type: EntityType
vintage_year: int | None = None
fund_size_cents: int | None = None
class EntityUpdate(BaseModel):
name: str | None = None
type: EntityType | None = None
vintage_year: int | None = None
fund_size_cents: int | None = None
status: EntityStatus | None = None
class EntityResponse(BaseModel):
id: int
name: str
type: EntityType
vintage_year: int | None
fund_size_cents: int | None
status: EntityStatus
created_at: datetime
# --- Holding ---
class HoldingCreate(BaseModel):
company_name: str
class HoldingUpdate(BaseModel):
company_name: str | None = None
class HoldingResponse(BaseModel):
id: int
entity_id: int
company_name: str
created_at: datetime
# --- Position ---
class PositionCreate(BaseModel):
security_name: str
investment_date: date
shares: str | None = None
cost_dollars: float # Accept dollars at API boundary, store as cents
class PositionUpdate(BaseModel):
security_name: str | None = None
investment_date: date | None = None
shares: str | None = None
cost_dollars: float | None = None
class PositionResponse(BaseModel):
id: int
holding_id: int
security_name: str
investment_date: date
shares: str | None
cost_cents: int
created_at: datetime
# --- Valuation Round ---
class RoundCreate(BaseModel):
quarter_end: date
class ValuationBulkItem(BaseModel):
position_id: int
value_cents: int
class ValuationBulkUpdate(BaseModel):
valuations: list[ValuationBulkItem]
class ReturnNote(BaseModel):
note: str
class ValuationResponse(BaseModel):
id: int
round_id: int
position_id: int
value_cents: int
created_at: datetime
class RoundResponse(BaseModel):
id: int
entity_id: int
quarter_end: date
status: RoundStatus
submitted_by: int | None
submitted_at: datetime | None
approved_by: int | None
approved_at: datetime | None
return_note: str | None
is_seed: bool
created_at: datetime
valuations: list[ValuationResponse] = []
# --- Audit ---
class AuditLogResponse(BaseModel):
id: int
actor_user_id: int | None
action: str
object_type: str
object_id: int | None
detail: dict | list | str | None
created_at: datetime