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
+375
View File
@@ -0,0 +1,375 @@
"""User administration: create and manage accounts and their entity access."""
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select, col
from ten31portal.audit import record_audit
from ten31portal.auth import (
accessible_entity_ids, can_access_entity, get_current_user, hash_password,
household_user_ids, require_internal_admin,
)
from ten31portal.database import get_session
from ten31portal.models import (
CapitalAccountStatement, Document, Entity, EntityAccess, EXTERNAL_ROLES, User, UserRole,
)
from ten31portal.schemas import (
AccessGrant, AccessMatrixResponse, AccountLink, CapitalAccountResponse,
DocumentResponse, EntityResponse, InvestorViewResponse, LinkedAccount, PasswordReset,
UserCreate, UserDetailResponse, UserResponse, UserUpdate,
)
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("/access-matrix")
def access_matrix(
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> AccessMatrixResponse:
"""External accounts, all entities, and the grants linking them."""
users = session.exec(
select(User).where(col(User.role).in_(EXTERNAL_ROLES)).order_by(User.name) # type: ignore[arg-type]
).all()
entities = session.exec(select(Entity).order_by(Entity.name)).all() # type: ignore[arg-type]
grants = session.exec(select(EntityAccess)).all()
return AccessMatrixResponse(
users=[UserResponse.model_validate(u, from_attributes=True) for u in users],
entities=[EntityResponse.model_validate(e, from_attributes=True) for e in entities],
grants=[AccessGrant(user_id=g.user_id, entity_id=g.entity_id) for g in grants],
)
@router.put("/{user_id}/access/{entity_id}", status_code=200)
def grant_access(
user_id: int,
entity_id: int,
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> dict[str, str]:
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
if session.get(Entity, entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
existing = session.exec(
select(EntityAccess).where(
EntityAccess.user_id == user_id, EntityAccess.entity_id == entity_id
)
).first()
if existing is None:
session.add(EntityAccess(user_id=user_id, entity_id=entity_id))
record_audit(session, admin.id, "grant_access", "user", user_id, {"entity_id": entity_id})
session.commit()
return {"status": "ok"}
@router.delete("/{user_id}/access/{entity_id}")
def revoke_access(
user_id: int,
entity_id: int,
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> dict[str, str]:
existing = session.exec(
select(EntityAccess).where(
EntityAccess.user_id == user_id, EntityAccess.entity_id == entity_id
)
).first()
if existing is not None:
session.delete(existing)
record_audit(session, admin.id, "revoke_access", "user", user_id, {"entity_id": entity_id})
session.commit()
return {"status": "ok"}
@router.get("/investors-for-entity/{entity_id}")
def investors_for_entity(
entity_id: int,
user: User = Depends(get_current_user),
session: Session = Depends(get_session),
) -> list[UserResponse]:
"""Investor accounts with access to an entity. For internal staff and the entity's fund admins."""
if user.role not in (UserRole.approver, UserRole.cfo, UserRole.operations) and not (
user.role == UserRole.fund_administrator
and can_access_entity(user, entity_id, session)
):
raise HTTPException(status_code=403, detail="Insufficient permissions")
rows = session.exec(
select(User)
.join(EntityAccess, EntityAccess.user_id == User.id)
.where(EntityAccess.entity_id == entity_id, User.role == UserRole.investor)
.order_by(User.name) # type: ignore[arg-type]
).all()
return [UserResponse.model_validate(r, from_attributes=True) for r in rows]
@router.get("/{user_id}/investor-view")
def investor_view(
user_id: int,
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> InvestorViewResponse:
"""Reconstruct exactly what an investor sees in their portal — read-only, for admins.
No session impersonation: this returns the same data the investor's own portal would load
(their accessible entities, their capital statements, and the documents visible to them),
scoped with the same access helpers.
"""
target = session.get(User, user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
if target.role != UserRole.investor:
raise HTTPException(status_code=400, detail="Investor View is for investor accounts.")
allowed = accessible_entity_ids(target, session) or set()
household = household_user_ids(target, session)
entities = session.exec(
select(Entity).where(col(Entity.id).in_(allowed)).order_by(Entity.name) # type: ignore[arg-type]
).all() if allowed else []
caps: list[CapitalAccountResponse] = []
docs: list[DocumentResponse] = []
if allowed:
cap_rows = session.exec(
select(CapitalAccountStatement)
.where(
col(CapitalAccountStatement.investor_user_id).in_(household),
col(CapitalAccountStatement.entity_id).in_(allowed),
)
.order_by(col(CapitalAccountStatement.as_of_date).desc())
).all()
names = dict(session.exec(
select(User.id, User.name).where(
col(User.id).in_({r.investor_user_id for r in cap_rows})
)
).all()) if cap_rows else {}
for r in cap_rows:
d = CapitalAccountResponse.model_validate(r, from_attributes=True)
d.investor_name = names.get(r.investor_user_id)
caps.append(d)
doc_rows = session.exec(
select(Document)
.where(col(Document.entity_id).in_(allowed))
.order_by(col(Document.created_at).desc())
).all()
# Investor sees shared docs and those addressed to any of their linked names.
docs = [
DocumentResponse.model_validate(d, from_attributes=True)
for d in doc_rows
if d.investor_user_id is None or d.investor_user_id in household
]
return InvestorViewResponse(
user=UserResponse.model_validate(target, from_attributes=True),
entities=[EntityResponse.model_validate(e, from_attributes=True) for e in entities],
capital_accounts=caps,
documents=docs,
)
def _entity_ids_for(user_id: int, session: Session) -> list[int]:
return list(session.exec(
select(EntityAccess.entity_id).where(EntityAccess.user_id == user_id)
).all())
def _user_detail(user: User, session: Session) -> UserDetailResponse:
"""Build a full user detail, including the linked-account relationships."""
data = UserResponse.model_validate(user, from_attributes=True).model_dump()
primary_name = None
if user.primary_account_id:
primary = session.get(User, user.primary_account_id)
primary_name = primary.name if primary else None
linked = session.exec(
select(User).where(User.primary_account_id == user.id).order_by(User.name) # type: ignore[arg-type]
).all()
return UserDetailResponse(
**data,
primary_account_name=primary_name,
linked_accounts=[
LinkedAccount(id=u.id, name=u.name, username=u.username) for u in linked
],
entity_ids=_entity_ids_for(user.id, session),
)
def _set_entity_access(user_id: int, entity_ids: list[int], session: Session) -> None:
"""Replace a user's entity grants with the given set, ignoring unknown ids."""
valid = set(session.exec(
select(Entity.id).where(Entity.id.in_(entity_ids)) # type: ignore[union-attr]
).all()) if entity_ids else set()
existing = session.exec(
select(EntityAccess).where(EntityAccess.user_id == user_id)
).all()
current = {a.entity_id: a for a in existing}
# Remove grants no longer wanted.
for eid, access in current.items():
if eid not in valid:
session.delete(access)
# Add new grants.
for eid in valid:
if eid not in current:
session.add(EntityAccess(user_id=user_id, entity_id=eid))
@router.get("")
def list_users(
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> list[UserResponse]:
rows = session.exec(select(User).order_by(User.name)).all() # type: ignore[arg-type]
return [UserResponse.model_validate(r, from_attributes=True) for r in rows]
@router.get("/{user_id}")
def get_user(
user_id: int,
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> UserDetailResponse:
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return _user_detail(user, session)
@router.post("", status_code=201)
def create_user(
body: UserCreate,
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> UserDetailResponse:
if session.exec(select(User).where(User.username == body.username)).first():
raise HTTPException(status_code=409, detail="Username already taken")
if body.email and session.exec(select(User).where(User.email == body.email)).first():
raise HTTPException(status_code=409, detail="Email already in use")
user = User(
name=body.name,
username=body.username,
email=body.email or None,
password_hash=hash_password(body.password),
role=body.role,
)
session.add(user)
session.flush()
_set_entity_access(user.id, body.entity_ids, session)
record_audit(session, admin.id, "create", "user", user.id,
{"username": body.username, "role": body.role.value,
"entity_ids": body.entity_ids})
session.commit()
session.refresh(user)
return _user_detail(user, session)
@router.patch("/{user_id}")
def update_user(
user_id: int,
body: UserUpdate,
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> UserDetailResponse:
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
changes = body.model_dump(exclude_unset=True)
entity_ids = changes.pop("entity_ids", None)
if "username" in changes:
new_username = (changes["username"] or "").strip()
if not new_username:
raise HTTPException(status_code=400, detail="Username cannot be blank")
clash = session.exec(select(User).where(User.username == new_username)).first()
if clash and clash.id != user_id:
raise HTTPException(status_code=409, detail="Username already taken")
changes["username"] = new_username
if "email" in changes and changes["email"]:
clash = session.exec(select(User).where(User.email == changes["email"])).first()
if clash and clash.id != user_id:
raise HTTPException(status_code=409, detail="Email already in use")
for key, val in changes.items():
setattr(user, key, val)
session.add(user)
session.flush()
if entity_ids is not None:
_set_entity_access(user_id, entity_ids, session)
record_audit(session, admin.id, "update", "user", user_id,
{**changes, **({"entity_ids": entity_ids} if entity_ids is not None else {})})
session.commit()
session.refresh(user)
return _user_detail(user, session)
@router.put("/{user_id}/primary-account")
def link_account(
user_id: int,
body: AccountLink,
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> UserDetailResponse:
"""Link an investor account to a primary login (or detach it when null).
The primary becomes the single sign-on that sees every linked name's investments. The
linked account's own login is disabled so there is one set of credentials per person.
"""
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
if user.role != UserRole.investor:
raise HTTPException(status_code=400, detail="Only investor accounts can be linked.")
primary_id = body.primary_account_id
if primary_id is not None:
if primary_id == user_id:
raise HTTPException(status_code=400, detail="An account cannot link to itself.")
primary = session.get(User, primary_id)
if primary is None or primary.role != UserRole.investor:
raise HTTPException(status_code=400, detail="Primary must be an investor account.")
if primary.primary_account_id is not None:
raise HTTPException(
status_code=400,
detail="That account is itself linked to another login. Link to a primary instead.",
)
# Prevent chains: an account that other names log in under can't become a secondary.
if session.exec(select(User).where(User.primary_account_id == user_id)).first():
raise HTTPException(
status_code=400,
detail="This account is a primary for other names. Detach those first.",
)
# Login is blocked while primary_account_id is set (see auth_router.login), so we leave
# login_enabled untouched — unlinking then restores the account's own sign-in cleanly.
user.primary_account_id = primary_id
else:
user.primary_account_id = None
session.add(user)
record_audit(session, admin.id, "link_account", "user", user_id,
{"primary_account_id": primary_id})
session.commit()
session.refresh(user)
return _user_detail(user, session)
@router.post("/{user_id}/reset-password")
def reset_password(
user_id: int,
body: PasswordReset,
admin: User = Depends(require_internal_admin),
session: Session = Depends(get_session),
) -> dict[str, str]:
user = session.get(User, user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
user.password_hash = hash_password(body.password)
user.login_enabled = True # setting a password enables login
session.add(user)
record_audit(session, admin.id, "reset_password", "user", user_id, None)
session.commit()
return {"status": "ok"}