Files
Ten31-Portal/backend/ten31portal/models.py
T
Jonathan Kirkwood 8247c28243 Implement adjudicated DO items across backend, frontend, deploy
From the ROADMAP adjudication (12 of 13 DO items; D2 is a commit action).

Backend:
- B3: pytest suite (auth, entity CRUD, rollup) + dev deps + pytest config
- B4: cap document uploads at TEN31_MAX_UPLOAD_SIZE (default 50MB), stream-
  checked with partial-file cleanup, 413 on overflow
- B7: type AuditLog.detail as dict|list|str|None to match the JSON column
- B10: index foreign-key columns (migration a7b8c9d0e1f2 + index=True)
- B11: cli delete-user logs file-removal errors instead of swallowing them

Frontend:
- F2: distinguish "server unreachable" from "logged out"; retry prompt
- F4: confirm before destructive holdings-replace on import; step progress
- F6: expandable audit-log detail with full JSON
- F7: empty-state on the Investments page
- F8: shared role helpers (WRITER_ROLES/canEditRound/isApprover), used by
  EntitiesList, AuditLog, Import, ValuationWorkflow

Deploy:
- D5: run tsc --noEmit before packaging (build script)
- D6: TEN31_LOG_LEVEL env var (defaults to info)

Verified: 8/8 backend tests pass; alembic upgrades to head with 13 FK
indexes; upload limit rejects oversized + cleans up; frontend tsc + vite
build clean; dev server serves and proxies to the API.
2026-07-01 13:33:40 -05:00

210 lines
7.7 KiB
Python

"""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):
# Internal staff
approver = "approver" # "Managing Partner" — full access incl. valuation sign-off
operations = "operations" # full access except final sign-off
cfo = "cfo"
fund_admin = "fund_admin"
viewer = "viewer"
# External accounts (entity-scoped via EntityAccess)
investor = "investor"
fund_administrator = "fund_administrator"
# External roles see only the entities granted to them.
EXTERNAL_ROLES = (UserRole.investor, UserRole.fund_administrator)
class DocumentCategory(str, enum.Enum):
capital_account = "capital_account"
k1 = "k1"
statement = "statement"
tax = "tax"
other = "other"
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
username: str = Field(sa_column=Column(String, unique=True, nullable=False))
email: str | None = Field(default=None, sa_column=Column(String, unique=True, nullable=True))
password_hash: str
role: UserRole
is_active: bool = Field(default=True)
# The built-in Service Admin (bootstrap account). Can be reset but never deleted.
is_service_admin: bool = Field(default=False)
# False for members imported without a password; set True when an admin sets one.
login_enabled: bool = Field(default=True)
# When an investor invests under several legal names (one per Partner/vehicle), each name
# is its own account. Linking the secondary accounts to one "primary" lets that person sign
# in once and see every name's investments. Null = this account logs in on its own.
primary_account_id: int | None = Field(default=None, foreign_key="users.id", index=True)
# Fund-administrator investor ID (from the eNAV ALLOC SI tab) for idempotent re-import.
external_investor_id: str | None = Field(default=None, sa_column=Column(String, nullable=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", index=True)
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", index=True)
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", index=True)
submitted_at: datetime | None = None
approved_by: int | None = Field(default=None, foreign_key="users.id", index=True)
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", index=True)
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", index=True)
action: str
object_type: str
object_id: int | None = None
# An action-specific JSON payload describing the change (stored in a JSON column). Most
# callers pass a dict of changed fields (e.g. an entity update); some pass an identifying
# field on delete, and some pass None. Matches AuditLogResponse.detail in schemas.py.
detail: dict | list | str | None = Field(default=None, sa_column=Column(JSON, nullable=True))
created_at: datetime = Field(default_factory=datetime.utcnow)
class EntityAccess(SQLModel, table=True):
"""Which entities an external account may view."""
__tablename__ = "entity_access"
__table_args__ = (
UniqueConstraint("user_id", "entity_id", name="uq_access_user_entity"),
)
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="users.id")
entity_id: int = Field(foreign_key="entities.id", index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
class Document(SQLModel, table=True):
"""An uploaded file. Shared to a fund (investor_user_id null) or private to one investor."""
__tablename__ = "documents"
id: int | None = Field(default=None, primary_key=True)
entity_id: int = Field(foreign_key="entities.id", index=True)
investor_user_id: int | None = Field(default=None, foreign_key="users.id", index=True)
category: DocumentCategory = Field(default=DocumentCategory.other)
title: str
original_filename: str
content_type: str
size_bytes: int
storage_path: str # opaque filename on the data volume, relative to DOCS_DIR
uploaded_by: int | None = Field(default=None, foreign_key="users.id", index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
class CapitalAccountStatement(SQLModel, table=True):
"""An investor's capital-account figures for one fund as of a date."""
__tablename__ = "capital_account_statements"
__table_args__ = (
UniqueConstraint("entity_id", "investor_user_id", "as_of_date",
name="uq_capacct_entity_investor_date"),
)
id: int | None = Field(default=None, primary_key=True)
entity_id: int = Field(foreign_key="entities.id")
investor_user_id: int = Field(foreign_key="users.id", index=True)
as_of_date: date
commitment_cents: int = 0 # initial capital commitment
beginning_balance_cents: int = 0
contributions_cents: int = 0 # paid-in capital
distributions_cents: int = 0 # capital returned (for DPI)
ending_balance_cents: int = 0 # current capital value
document_id: int | None = Field(default=None, foreign_key="documents.id", index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)