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:
@@ -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"}
|
||||
Reference in New Issue
Block a user