0.2.27-0.2.32: LP portal polish, brand palette, default investor logins

Cumulative checkpoint since 0.2.26:
- 0.2.27/28: entity valuation-history table; investor gain/loss = NAV +
  distributions vs paid-in
- 0.2.29: Reset Fund Partners (endpoint, Partners-tab button, CLI, action)
- 0.2.30: "Current Capital Balance" label, %-only gain/loss
- 0.2.31: Management Entities rename, Carry Vehicle type, chart
  distributions-line gate
- 0.2.32: LP-facing polish pass
  * Ten31 brand palette from the logo (navy/mint); orange retired
  * portfolio summary card across funds; gain labeled "net of paid-in"
  * whole-dollar headline figures; "History · N quarters" toggle
  * documents grouped by year with a "New" badge (users.docs_seen_at)
  * eNAV-created members start on default password with login enabled;
    enable-investor-logins CLI + StartOS action for existing accounts
  * password minimum raised to 8 chars; login help line (Portal@ten31.xyz)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jonathan Kirkwood
2026-07-03 08:40:40 -05:00
co-authored by Claude Opus 4.8
parent 69f12b0519
commit 4215d4478f
47 changed files with 935 additions and 111 deletions
@@ -0,0 +1,27 @@
"""add users.docs_seen_at (portal "New" document badge watermark)
Revision ID: d0e1f2a3b4c5
Revises: c9d0e1f2a3b4
Create Date: 2026-07-03 09:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'd0e1f2a3b4c5'
down_revision: Union[str, None] = 'c9d0e1f2a3b4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.add_column(sa.Column('docs_seen_at', sa.DateTime(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_column('docs_seen_at')
+72
View File
@@ -87,6 +87,42 @@ def reset_password(args: argparse.Namespace) -> None:
print(f"Password reset for {user.name} ({user.username}).")
def enable_investor_logins(args: argparse.Namespace) -> None:
"""Give every no-login investor account the shared default password and enable sign-in.
Targets investor-role accounts with login_enabled=False that log in on their own
(linked secondary names are skipped — they sign in under their primary). Accounts that
already have a working login are never touched.
"""
run_migrations()
from ten31portal.models import UserRole
with Session(engine) as session:
users = session.exec(
select(User).where(
User.role == UserRole.investor,
User.login_enabled == False, # noqa: E712 — SQL expression
User.primary_account_id == None, # noqa: E711
).order_by(User.name) # type: ignore[arg-type]
).all()
if not users:
print("Nothing to do — every investor account already has a login.")
return
for u in users:
u.password_hash = hash_password(config.DEFAULT_INVESTOR_PASSWORD)
u.login_enabled = True
session.add(u)
session.commit()
for u in users:
print(f"Enabled login for {u.name} ({u.username})")
print(
f"\n{len(users)} investor account(s) set to the default password "
f"'{config.DEFAULT_INVESTOR_PASSWORD}'. Each investor should change it in the "
"portal (Change password)."
)
def show_admin_password(args: argparse.Namespace) -> None:
"""Print the randomly-generated initial admin password recorded on first boot."""
path = config.ADMIN_PASSWORD_FILE
@@ -234,6 +270,29 @@ def reset_holdings(args: argparse.Namespace) -> None:
f"Re-import the fund's NAV to repopulate it.")
def reset_partners(args: argparse.Namespace) -> None:
"""Remove all partners (capital-account statements + access grants) from one fund."""
run_migrations()
from ten31portal.models import Entity
from ten31portal.routers.capital_import_router import reset_entity_partners
with Session(engine) as session:
entity = None
if args.entity_id:
entity = session.get(Entity, args.entity_id)
elif args.name:
entity = session.exec(select(Entity).where(Entity.name == args.name)).first()
if entity is None:
print(f"Error: no fund found for '{args.name or args.entity_id}'. "
f"Check the exact name with list-funds.", file=sys.stderr)
sys.exit(1)
res = reset_entity_partners(entity.id, session)
session.commit()
print(f"Cleared partners from {entity.name}: removed {res['statements']} capital "
f"statement(s) and {res['access_grants']} access grant(s). "
f"Investor accounts were kept. Re-import the correct roster to repopulate.")
def list_funds(args: argparse.Namespace) -> None:
"""Print every entity's id and name (so the exact name is known for reset-holdings)."""
run_migrations()
@@ -268,6 +327,11 @@ def main() -> None:
sub.add_parser("list-users", help="List all user accounts")
sub.add_parser(
"enable-investor-logins",
help="Set every no-login investor account to the default password and enable sign-in",
)
sub.add_parser("show-admin-password", help="Show the initial admin password from first boot")
delete = sub.add_parser("delete-user", help="Delete a user (not the Service Admin)")
@@ -284,6 +348,10 @@ def main() -> None:
reset.add_argument("--name", required=False, default=None, help="Exact fund name")
reset.add_argument("--entity-id", type=int, required=False, default=None)
rparts = sub.add_parser("reset-partners", help="Remove all partners (capital accounts + access) from a fund")
rparts.add_argument("--name", required=False, default=None, help="Exact fund name")
rparts.add_argument("--entity-id", type=int, required=False, default=None)
args = parser.parse_args()
if args.command == "create-user":
create_user(args)
@@ -291,6 +359,8 @@ def main() -> None:
reset_password(args)
elif args.command == "list-users":
list_users(args)
elif args.command == "enable-investor-logins":
enable_investor_logins(args)
elif args.command == "show-admin-password":
show_admin_password(args)
elif args.command == "delete-user":
@@ -301,6 +371,8 @@ def main() -> None:
list_funds(args)
elif args.command == "reset-holdings":
reset_holdings(args)
elif args.command == "reset-partners":
reset_partners(args)
else:
parser.print_help()
+5
View File
@@ -11,6 +11,11 @@ DOCS_DIR: str = os.getenv("TEN31_DOCS_DIR", "/data/ten31portal/documents")
# also bounds spreadsheet imports (which must be read fully into memory to parse).
MAX_UPLOAD_SIZE: int = int(os.getenv("TEN31_MAX_UPLOAD_SIZE", str(50 * 1024 * 1024)))
# Investor accounts created by the eNAV import (and existing no-login accounts converted via
# the enable-investor-logins CLI/action) start with this password so the admin can hand out
# credentials easily; each investor changes it via the portal's own Change password.
DEFAULT_INVESTOR_PASSWORD: str = os.getenv("TEN31_DEFAULT_INVESTOR_PASSWORD", "Ten31Portal")
# Where start.sh records the randomly-generated initial admin password on first boot, so the
# operator can retrieve it once (via the "Show Initial Admin Password" service action) and then
# change it. Lives next to the DB on the 0600 data volume; removed once the password is reset.
+4
View File
@@ -39,6 +39,7 @@ class EntityType(str, enum.Enum):
spv = "spv"
gp = "gp"
mgmt_co = "mgmt_co"
carry = "carry"
class EntityStatus(str, enum.Enum):
@@ -75,6 +76,9 @@ class User(SQLModel, table=True):
primary_account_id: int | None = Field(default=None, foreign_key="users.id", index=True)
# Fund-administrator investor ID (from the eNAV ALLOC SI tab) for idempotent re-import.
external_investor_id: str | None = Field(default=None, sa_column=Column(String, nullable=True))
# When this investor last loaded their documents list — docs newer than this get a "New"
# badge in the portal. Null until their first visit (nothing badged for brand-new logins).
docs_seen_at: datetime | None = Field(default=None)
created_at: datetime = Field(default_factory=datetime.utcnow)
+2 -2
View File
@@ -87,8 +87,8 @@ def change_password(
"""Let the signed-in user set their own password (after confirming the current one)."""
if not verify_password(body.current_password, user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect.")
if len(body.new_password) < 4:
raise HTTPException(status_code=400, detail="New password must be at least 4 characters.")
if len(body.new_password) < 8:
raise HTTPException(status_code=400, detail="New password must be at least 8 characters.")
user.password_hash = hash_password(body.new_password)
session.add(user)
session.commit()
@@ -6,20 +6,19 @@ Fallback: a subaccounts sheet (investor names across columns, per-vehicle value
Encrypted workbooks are decrypted with the open password. Nothing is written on preview.
Commit matches existing members (by fund-admin investor ID, else name), creates new ones
(without a login unless a password is given), grants entity access, and loads each member's
capital-account statement (commitment, contributions, distributions, current value).
(on the shared default password unless one is given), grants entity access, and loads each
member's capital-account statement (commitment, contributions, distributions, current value).
"""
import io
import re
import secrets
from datetime import date, datetime
import openpyxl
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from sqlmodel import Session, select
from ten31portal import storage
from ten31portal import config, storage
from ten31portal.audit import record_audit
from ten31portal.auth import hash_password, require_internal_admin
from ten31portal.database import get_session
@@ -38,6 +37,39 @@ MAX_ROWS = 400
MAX_COLS = 90
def reset_entity_partners(entity_id: int, session: Session) -> dict[str, int]:
"""Remove every partner from one fund: delete its capital-account statements and the
investors' access grants to it. The investor *accounts* are kept — they usually also
belong to other funds — only their membership of THIS entity is cleared. Use to undo a
wrong members/ALLOC-SI import (e.g. Fund II's roster loaded into Fund III). Holdings/NAV
are untouched (see reset_entity_holdings for those). Caller commits.
"""
statements = 0
for s in session.exec(
select(CapitalAccountStatement).where(
CapitalAccountStatement.entity_id == entity_id
)
).all():
session.delete(s)
statements += 1
# Only drop investor memberships; a fund_administrator's access is not a "partner".
investor_ids = {
u.id for u in session.exec(
select(User).where(User.role == UserRole.investor)
).all()
}
access = 0
for a in session.exec(
select(EntityAccess).where(EntityAccess.entity_id == entity_id)
).all():
if a.user_id in investor_ids:
session.delete(a)
access += 1
return {"statements": statements, "access_grants": access}
def _slug_username(name: str) -> str:
base = re.sub(r"[^a-z0-9]+", "", name.lower())
return base or "investor"
@@ -282,14 +314,16 @@ def commit_import(
raise HTTPException(status_code=409, detail=f"Username '{inv.username}' already taken.")
if inv.email and session.exec(select(User).where(User.email == inv.email)).first():
raise HTTPException(status_code=409, detail=f"Email '{inv.email}' already in use.")
pw = inv.password or secrets.token_urlsafe(32)
# New members start on the shared default password (login enabled) so the admin
# can send credentials right away; each investor rotates it in the portal.
pw = inv.password or config.DEFAULT_INVESTOR_PASSWORD
user = User(
name=inv.name,
username=inv.username,
email=inv.email or None,
password_hash=hash_password(pw),
role=UserRole.investor,
login_enabled=bool(inv.password),
login_enabled=True,
external_investor_id=inv.external_id,
)
session.add(user)
+21 -1
View File
@@ -1,5 +1,7 @@
"""Document upload, listing, download, and deletion with per-account access control."""
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlmodel import Session, select
@@ -58,7 +60,25 @@ def list_documents(
query = query.where(Document.investor_user_id == investor_user_id)
rows = session.exec(query.order_by(Document.created_at.desc())).all() # type: ignore[union-attr]
visible = [d for d in rows if _can_view(user, d, session)]
return [DocumentResponse.model_validate(d, from_attributes=True) for d in visible]
# Badge docs that arrived since the investor's previous visit. Badges show on the first
# page-load after new documents arrive; the watermark advances at most once per 30 minutes
# so rapid refetches don't rewrite the row. First ever visit (no watermark) badges nothing —
# everything would be "new".
seen_before = user.docs_seen_at if user.role == UserRole.investor else None
if user.role == UserRole.investor:
now = datetime.utcnow()
if user.docs_seen_at is None or (now - user.docs_seen_at) > timedelta(minutes=30):
user.docs_seen_at = now
session.add(user)
session.commit()
return [
DocumentResponse.model_validate(d, from_attributes=True).model_copy(
update={"is_new": seen_before is not None and d.created_at > seen_before}
)
for d in visible
]
@router.post("", status_code=201)
@@ -148,6 +148,26 @@ def list_partners(
return result
@router.delete("/{entity_id}/partners")
def clear_partners(
entity_id: int,
user: User = Depends(require_writer),
session: Session = Depends(get_session),
) -> dict[str, int]:
"""Remove all partners from this fund — deletes its capital-account statements and the
investors' access grants, but keeps the investor accounts (they belong to other funds).
For undoing a wrong members import. Holdings/NAV are not affected."""
# Local import avoids a module-load cycle (capital_import_router imports import_router).
from ten31portal.routers.capital_import_router import reset_entity_partners
if session.get(Entity, entity_id) is None:
raise HTTPException(status_code=404, detail="Entity not found")
res = reset_entity_partners(entity_id, session)
record_audit(session, user.id, "clear_partners", "entity", entity_id, res)
session.commit()
return res
@router.get("")
def list_entities(
user: User = Depends(get_current_user),
+3 -1
View File
@@ -212,6 +212,8 @@ class DocumentResponse(BaseModel):
size_bytes: int
uploaded_by: int | None
created_at: datetime
# True for an investor when the doc arrived since their previous portal visit.
is_new: bool = False
# --- Capital account ---
@@ -310,7 +312,7 @@ class ImportCommitInvestor(BaseModel):
name: str | None = None # for action=create
username: str | None = None # for action=create
email: str | None = None
password: str | None = None # for action=create; omit to create without a login
password: str | None = None # for action=create; omit for the shared default password
external_id: str | None = None # fund-admin INVESTOR ID, stored for re-import matching
+60
View File
@@ -0,0 +1,60 @@
"""Clearing a fund's partners removes only that fund's statements + access grants,
never the investor accounts (which may belong to other funds)."""
from datetime import date
from ten31portal.models import (
CapitalAccountStatement, Entity, EntityAccess, EntityType, User, UserRole,
)
def _make_fund(session, name):
e = Entity(name=name, type=EntityType.fund)
session.add(e)
session.commit()
session.refresh(e)
return e
def _add_partner(session, entity_id, user_id, ending):
session.add(EntityAccess(user_id=user_id, entity_id=entity_id))
session.add(CapitalAccountStatement(
entity_id=entity_id, investor_user_id=user_id, as_of_date=date(2025, 12, 31),
commitment_cents=0, beginning_balance_cents=0, contributions_cents=0,
distributions_cents=0, ending_balance_cents=ending,
))
session.commit()
def test_clear_partners_scoped_to_one_fund(auth_client, session):
from tests.conftest import make_user
fund2 = _make_fund(session, "Low Time Preference Fund II, LLC")
fund3 = _make_fund(session, "Low Time Preference Fund III, LP")
lp = make_user(session, username="lp1", role=UserRole.investor, name="LP One")
# Same investor is a partner in BOTH funds (the real-world case that caused the mixup).
_add_partner(session, fund2.id, lp.id, 1_000_00)
_add_partner(session, fund3.id, lp.id, 2_000_00)
resp = auth_client.delete(f"/api/entities/{fund3.id}/partners")
assert resp.status_code == 200, resp.text
assert resp.json() == {"statements": 1, "access_grants": 1}
# Fund III is wiped of partners...
assert session.exec(
CapitalAccountStatement.__table__.select().where(
CapitalAccountStatement.entity_id == fund3.id
)
).first() is None
assert session.exec(
EntityAccess.__table__.select().where(EntityAccess.entity_id == fund3.id)
).first() is None
assert auth_client.get(f"/api/entities/{fund3.id}/partners").json() == []
# ...but Fund II keeps its partner, and the investor account still exists.
assert len(auth_client.get(f"/api/entities/{fund2.id}/partners").json()) == 1
assert session.get(User, lp.id) is not None
def test_clear_partners_missing_entity_404(auth_client):
assert auth_client.delete("/api/entities/99999/partners").status_code == 404
+134
View File
@@ -0,0 +1,134 @@
"""0.2.32 LP-facing behavior: 8-char password floor, default investor password,
enable-investor-logins conversion, and the documents "New" badge watermark."""
from datetime import datetime, timedelta
from sqlmodel import select
from ten31portal import config
from ten31portal.auth import hash_password, verify_password
from ten31portal.models import (
Document, Entity, EntityAccess, EntityType, User, UserRole,
)
from tests.conftest import make_user
def _login(client, username, password):
return client.post("/api/auth/login", json={"login": username, "password": password})
def test_change_password_requires_eight_chars(auth_client):
resp = auth_client.post(
"/api/auth/change-password",
json={"current_password": "password123", "new_password": "short7c"},
)
assert resp.status_code == 400
assert "8 characters" in resp.json()["detail"]
resp = auth_client.post(
"/api/auth/change-password",
json={"current_password": "password123", "new_password": "longenough8"},
)
assert resp.status_code == 200
def test_import_created_member_gets_default_password(auth_client, session):
entity = Entity(name="Test Fund", type=EntityType.fund)
session.add(entity)
session.commit()
resp = auth_client.post(
"/api/import/capital-accounts/commit",
json={
"entity_id": entity.id,
"as_of_date": "2026-03-31",
"investors": [{
"action": "create",
"name": "New LP",
"username": "newlp",
"value_dollars": 100_000,
}],
},
)
assert resp.status_code == 200, resp.text
user = session.exec(select(User).where(User.username == "newlp")).one()
assert user.login_enabled is True
assert verify_password(config.DEFAULT_INVESTOR_PASSWORD, user.password_hash)
# And the account can actually sign in with it.
resp = _login(auth_client, "newlp", config.DEFAULT_INVESTOR_PASSWORD)
assert resp.status_code == 200, resp.text
def test_enable_investor_logins_converts_only_no_login_accounts(session):
from ten31portal.cli import enable_investor_logins # imported here: cli pulls in argparse setup
no_login = make_user(session, username="dormant", role=UserRole.investor,
login_enabled=False, name="Dormant LP")
has_login = make_user(session, username="active-lp", role=UserRole.investor,
password="theirownpw", name="Active LP")
linked = make_user(session, username="linked", role=UserRole.investor,
login_enabled=False, primary_account_id=has_login.id, name="Linked Name")
old_active_hash = has_login.password_hash
# Run the conversion against the test engine (CLI normally uses the real one).
import ten31portal.cli as cli_mod
orig_engine, orig_migrate = cli_mod.engine, cli_mod.run_migrations
cli_mod.engine = session.get_bind()
cli_mod.run_migrations = lambda: None
try:
enable_investor_logins(None)
finally:
cli_mod.engine, cli_mod.run_migrations = orig_engine, orig_migrate
session.refresh(no_login)
session.refresh(has_login)
session.refresh(linked)
assert no_login.login_enabled is True
assert verify_password(config.DEFAULT_INVESTOR_PASSWORD, no_login.password_hash)
# Working logins and linked secondary names are untouched.
assert has_login.password_hash == old_active_hash
assert linked.login_enabled is False
def test_documents_new_badge(client, session):
entity = Entity(name="Badge Fund", type=EntityType.fund)
session.add(entity)
session.commit()
lp = make_user(session, username="lp", role=UserRole.investor, password="password123")
session.add(EntityAccess(user_id=lp.id, entity_id=entity.id))
old_doc = Document(
entity_id=entity.id, category="statement", title="Old statement",
original_filename="old.pdf", content_type="application/pdf", size_bytes=1,
storage_path="x-old", created_at=datetime.utcnow() - timedelta(days=30),
)
session.add(old_doc)
session.commit()
assert _login(client, "lp", "password123").status_code == 200
# First ever visit: nothing badged (no watermark yet), watermark gets set.
docs = client.get("/api/documents").json()
assert [d["is_new"] for d in docs] == [False]
# A doc uploaded after that visit is badged next time; pretend the visit was yesterday.
session.refresh(lp)
lp.docs_seen_at = datetime.utcnow() - timedelta(days=1)
session.add(lp)
new_doc = Document(
entity_id=entity.id, category="k1", title="Fresh K-1",
original_filename="k1.pdf", content_type="application/pdf", size_bytes=1,
storage_path="x-new",
)
session.add(new_doc)
session.commit()
docs = client.get("/api/documents").json()
flags = {d["title"]: d["is_new"] for d in docs}
assert flags == {"Fresh K-1": True, "Old statement": False}
# Within the same visit (watermark just advanced) the badge computation stays stable.
docs = client.get("/api/documents").json()
assert all(d["is_new"] is False for d in docs) # watermark now newer than both docs