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.
210 lines
7.7 KiB
Python
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)
|