The admin read-only Investor View built capital-account responses without exited_on, so an exited position showed the active card with $0s (the LP's own portal was correct). Extracted exit_dates() into capital_account_router and stamp it in investor_view too; regression test added. A fund card where every position is exited now renders greyed (bg + title) so it reads as closed at a glance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
381 lines
15 KiB
Python
381 lines
15 KiB
Python
"""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 {}
|
|
# Mirror the investor's own portal exactly — including exit status, so an exited
|
|
# position shows its badge here too instead of a phantom -100%.
|
|
from ten31portal.routers.capital_account_router import exit_dates
|
|
exits = exit_dates(session, cap_rows)
|
|
for r in cap_rows:
|
|
d = CapitalAccountResponse.model_validate(r, from_attributes=True)
|
|
d.investor_name = names.get(r.investor_user_id)
|
|
d.exited_on = exits.get((r.investor_user_id, r.entity_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"}
|