Issue 1: repo scaffold and project structure
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Ten31Portal backend
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user