Release 0.2.22: capital chart, Investor View, GP stakes, doc folders

Snapshot commit bringing the uncommitted phase-2 work into version control
together with four new features and the 0.2.22 version bump.

New features:
- Investor capital-over-time chart (value, paid-in, distributions per
  quarter), rendered from existing capital-account history.
- Admin Investor View: read-only reconstruction of an investor's portal
  (GET /api/users/{id}/investor-view), reusing the investor portal UI.
- Document upload scoped to the selected fund's own investors, with an
  explicit upload-target confirmation to prevent mis-attaching.
- GP/mgmt entities gain an Assets tab listing their stakes in the funds
  they manage (new entity_stakes table + /api/entities/{id}/stakes).
- Edit-entity form (change type/status/etc.), so GP entities can be
  categorized correctly.

Verified: 11/11 backend tests pass; alembic upgrades to head b8c9d0e1f2a3;
frontend tsc + vite build clean; s9pk packs at 0.2.22:0 (x86_64).
Also: ignore .DS_Store and *.s9pk artifacts.
This commit is contained in:
Jonathan Kirkwood
2026-07-01 14:25:50 -05:00
parent 7fc78d7058
commit f0f8fd15c6
69 changed files with 5492 additions and 740 deletions
+48 -5
View File
@@ -5,10 +5,10 @@ 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 sqlmodel import Session, select, col
from ten31portal.database import get_session
from ten31portal.models import User, UserRole
from ten31portal.models import EntityAccess, User, UserRole, EXTERNAL_ROLES
ph = PasswordHasher()
@@ -44,8 +44,51 @@ def require_role(*roles: UserRole):
return checker
def require_internal(user: User = Depends(get_current_user)) -> User:
"""Block external (entity-scoped) accounts from internal staff endpoints."""
if user.role in EXTERNAL_ROLES:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return user
def household_user_ids(user: User, session: Session) -> list[int]:
"""All account ids that share this user's login.
An investor who invests under several legal names has one "primary" account (the login)
and one or more secondary accounts linked to it via ``primary_account_id``. Signing in as
the primary should surface every linked name's entities, statements, and documents. For a
standalone account this is just ``[user.id]``.
"""
root_id = user.primary_account_id or user.id
linked = session.exec(
select(User.id).where(User.primary_account_id == root_id)
).all()
return list({root_id, user.id, *linked})
def accessible_entity_ids(user: User, session: Session) -> set[int] | None:
"""Entity ids an external account may view. None means unrestricted (internal staff)."""
if user.role not in EXTERNAL_ROLES:
return None
rows = session.exec(
select(EntityAccess.entity_id).where(
col(EntityAccess.user_id).in_(household_user_ids(user, session))
)
).all()
return set(rows)
def can_access_entity(user: User, entity_id: int, session: Session) -> bool:
allowed = accessible_entity_ids(user, session)
return allowed is None or entity_id in allowed
# 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)
require_writer = require_role(
UserRole.fund_admin, UserRole.cfo, UserRole.approver, UserRole.operations
)
require_approver = require_role(UserRole.approver) # final sign-off — Managing Partners only
require_audit_reader = require_role(UserRole.approver, UserRole.cfo, UserRole.operations)
# Account administration (create/manage users, documents, capital accounts)
require_internal_admin = require_role(UserRole.approver, UserRole.cfo, UserRole.operations)