Issue 1: repo scaffold and project structure
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
# Use forward slashes (/) also on windows to provide an os agnostic path
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
# version_path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
version_path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Alembic environment configuration."""
|
||||
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from ten31portal.config import DB_PATH
|
||||
|
||||
# Import all models so metadata is populated
|
||||
from ten31portal.models import ( # noqa: F401
|
||||
User, Entity, Holding, Position,
|
||||
ValuationRound, Valuation, AuditLog,
|
||||
)
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = SQLModel.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = f"sqlite:///{DB_PATH}"
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
render_as_batch=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
cfg = config.get_section(config.config_ini_section, {})
|
||||
cfg["sqlalchemy.url"] = f"sqlite:///{DB_PATH}"
|
||||
connectable = engine_from_config(
|
||||
cfg,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
render_as_batch=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,29 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,118 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 2792c4ff4612
|
||||
Revises:
|
||||
Create Date: 2026-06-07 19:03:02.057772
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '2792c4ff4612'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('entities',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('type', sa.Enum('fund', 'spv', 'gp', 'mgmt_co', name='entitytype'), nullable=False),
|
||||
sa.Column('vintage_year', sa.Integer(), nullable=True),
|
||||
sa.Column('fund_size_cents', sa.Integer(), nullable=True),
|
||||
sa.Column('status', sa.Enum('active', 'closed', name='entitystatus'), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('email', sa.String(), nullable=False),
|
||||
sa.Column('password_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('role', sa.Enum('approver', 'cfo', 'fund_admin', 'viewer', name='userrole'), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('email')
|
||||
)
|
||||
op.create_table('audit_log',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('actor_user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('action', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('object_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('object_id', sa.Integer(), nullable=True),
|
||||
sa.Column('detail', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['actor_user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('holdings',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('entity_id', sa.Integer(), nullable=False),
|
||||
sa.Column('company_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('valuation_rounds',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('entity_id', sa.Integer(), nullable=False),
|
||||
sa.Column('quarter_end', sa.Date(), nullable=False),
|
||||
sa.Column('status', sa.Enum('draft', 'submitted', 'approved', 'returned', name='roundstatus'), nullable=False),
|
||||
sa.Column('submitted_by', sa.Integer(), nullable=True),
|
||||
sa.Column('submitted_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('approved_by', sa.Integer(), nullable=True),
|
||||
sa.Column('approved_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('return_note', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('is_seed', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['approved_by'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ),
|
||||
sa.ForeignKeyConstraint(['submitted_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('entity_id', 'quarter_end', name='uq_round_entity_quarter')
|
||||
)
|
||||
op.create_table('positions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('holding_id', sa.Integer(), nullable=False),
|
||||
sa.Column('security_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('investment_date', sa.Date(), nullable=False),
|
||||
sa.Column('shares', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('cost_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['holding_id'], ['holdings.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('valuations',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('round_id', sa.Integer(), nullable=False),
|
||||
sa.Column('position_id', sa.Integer(), nullable=False),
|
||||
sa.Column('value_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['position_id'], ['positions.id'], ),
|
||||
sa.ForeignKeyConstraint(['round_id'], ['valuation_rounds.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('round_id', 'position_id', name='uq_valuation_round_position')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('valuations')
|
||||
op.drop_table('positions')
|
||||
op.drop_table('valuation_rounds')
|
||||
op.drop_table('holdings')
|
||||
op.drop_table('audit_log')
|
||||
op.drop_table('users')
|
||||
op.drop_table('entities')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,24 @@
|
||||
[project]
|
||||
name = "ten31portal"
|
||||
version = "0.1.0"
|
||||
description = "Internal system of record for Ten31 entities, holdings, and valuations."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi==0.115.12",
|
||||
"uvicorn[standard]==0.34.3",
|
||||
"sqlmodel==0.0.24",
|
||||
"alembic==1.15.2",
|
||||
"argon2-cffi==23.1.0",
|
||||
"python-multipart==0.0.20",
|
||||
"itsdangerous==2.2.0",
|
||||
"aiosqlite==0.21.0",
|
||||
"starlette-session==0.4.3",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
ten31portal = "ten31portal.main:cli"
|
||||
ten31portal-cli = "ten31portal.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
@@ -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