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:
co-authored by
Claude Opus 4.8
parent
69f12b0519
commit
4215d4478f
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user