New external role administrator_viewer: signs into the admin interface and reads everything for its granted funds and SPVs (overview, partners, capital accounts with every investor's statements, documents, valuation history) but every write is refused: no imports, uploads, deletions, entity edits, exit marking, or account management. No migration needed; roles are stored as strings. Internal admins can flip an Administrator between full management and view only via a new Access level dropdown in Users > Manage. The user-list endpoint is read-widened for the viewer role so investor names resolve on its screens; all mutating endpoints keep the stricter gate.
270 lines
11 KiB
Python
270 lines
11 KiB
Python
"""SQLModel table definitions for Ten31Portal."""
|
|
|
|
import enum
|
|
from datetime import date, datetime
|
|
from datetime import date as _date # for fields literally named "date"
|
|
from decimal import Decimal
|
|
from typing import Optional
|
|
|
|
from sqlmodel import Field, SQLModel, Column, Date, 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" # "Administrator" — manages its funds
|
|
administrator_viewer = "administrator_viewer" # "Administrator (view only)" — reads its funds
|
|
|
|
|
|
# External roles see only the entities granted to them.
|
|
EXTERNAL_ROLES = (UserRole.investor, UserRole.fund_administrator, UserRole.administrator_viewer)
|
|
|
|
|
|
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"
|
|
carry = "carry"
|
|
|
|
|
|
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))
|
|
# When this investor last loaded their documents list — docs newer than this get a "New"
|
|
# badge in the portal. Null until their first visit (nothing badged for brand-new logins).
|
|
docs_seen_at: datetime | None = Field(default=None)
|
|
# Two-factor auth (optional, per-user opt-in). The secret is set at setup time but only
|
|
# counts once totp_enabled is True (enrollment is confirmed with a first valid code).
|
|
totp_secret: str | None = Field(default=None)
|
|
totp_enabled: bool = Field(default=False)
|
|
# JSON list of sha256 hex digests of unused one-time recovery codes.
|
|
totp_recovery_codes: str | None = Field(default=None)
|
|
# True while the account is on the shared default password — the portal forces a
|
|
# password change before anything else. Cleared by change-password / admin reset.
|
|
must_change_password: bool = Field(default=False)
|
|
# When the investor finished (or skipped) the first-login welcome flow. Null = show it.
|
|
onboarded_at: datetime | None = Field(default=None)
|
|
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)
|
|
# For a GP/mgmt entity that is also an LP with capital accounts (e.g. Ten31 LLC), link to
|
|
# its investor account so its Assets view can pull real per-fund balances from the eNAV.
|
|
linked_user_id: int | None = Field(default=None, foreign_key="users.id", index=True)
|
|
# Final close of the fund/SPV — the BTC entry mark: paid-in capital is valued at the BTC
|
|
# price on this date for the bitcoin-denominated view. Null = no BTC view for this fund.
|
|
close_date: date | None = Field(default=None)
|
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
|
|
|
|
class BtcPrice(SQLModel, table=True):
|
|
"""Daily (or as-uploaded) BTC/USD closing prices from the admin's CSV.
|
|
|
|
Statements are valued at the newest price on or before their as-of date, so the CSV
|
|
doesn't need every calendar day — quarter-end rows are enough."""
|
|
|
|
__tablename__ = "btc_prices"
|
|
|
|
id: int | None = Field(default=None, primary_key=True)
|
|
date: _date = Field(sa_column=Column(Date, unique=True, nullable=False))
|
|
price_cents: int
|
|
|
|
|
|
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)
|
|
# Set when this member sold/transferred their stake (e.g. a secondary sale): the fund's
|
|
# books show a $0 balance with no distribution, which is NOT a loss. An exited position
|
|
# shows a badge instead of gain/loss, keeps its documents, and drops out of totals.
|
|
exited_on: date | None = Field(default=None)
|
|
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)
|
|
|
|
|
|
class EntityStake(SQLModel, table=True):
|
|
"""A holder entity's ownership stake in a fund/SPV it manages.
|
|
|
|
Models the assets of a GP or management company: its interest in each fund it manages,
|
|
which are entities in their own right rather than portfolio-company holdings.
|
|
"""
|
|
__tablename__ = "entity_stakes"
|
|
__table_args__ = (
|
|
UniqueConstraint("holder_entity_id", "fund_entity_id", name="uq_stake_holder_fund"),
|
|
)
|
|
|
|
id: int | None = Field(default=None, primary_key=True)
|
|
holder_entity_id: int = Field(foreign_key="entities.id", index=True) # the GP / mgmt co
|
|
fund_entity_id: int = Field(foreign_key="entities.id", index=True) # the fund/SPV held
|
|
ownership_pct: float | None = None # e.g. 20.0 for a 20% interest
|
|
value_cents: int | None = None # optional current value of the stake
|
|
note: str | None = None
|
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|