"""Pydantic schemas for API request/response shapes.""" from datetime import date, datetime from typing import Optional from pydantic import BaseModel from ten31portal.models import ( UserRole, EntityType, EntityStatus, RoundStatus, DocumentCategory, ) # --- Auth --- class LoginRequest(BaseModel): login: str # username or email password: str class UserResponse(BaseModel): id: int name: str username: str email: str | None role: UserRole is_active: bool is_service_admin: bool = False primary_account_id: int | None = None # set when this account logs in under another created_at: datetime # --- User administration --- class UserCreate(BaseModel): name: str username: str password: str role: UserRole email: str | None = None entity_ids: list[int] = [] class UserUpdate(BaseModel): name: str | None = None username: str | None = None email: str | None = None role: UserRole | None = None is_active: bool | None = None entity_ids: list[int] | None = None # full replacement of grants when provided class PasswordReset(BaseModel): password: str class ChangePasswordRequest(BaseModel): current_password: str new_password: str class LinkedAccount(BaseModel): id: int name: str username: str class UserDetailResponse(BaseModel): id: int name: str username: str email: str | None role: UserRole is_active: bool is_service_admin: bool = False primary_account_id: int | None = None primary_account_name: str | None = None # the login this account is linked under, if any linked_accounts: list[LinkedAccount] = [] # secondary names that log in under this account created_at: datetime entity_ids: list[int] = [] class AccountLink(BaseModel): # null detaches the account so it logs in on its own again primary_account_id: int | None = None # --- Entity --- class EntityCreate(BaseModel): name: str type: EntityType vintage_year: int | None = None fund_size_cents: int | None = None class EntityUpdate(BaseModel): name: str | None = None type: EntityType | None = None vintage_year: int | None = None fund_size_cents: int | None = None status: EntityStatus | None = None class EntityResponse(BaseModel): id: int name: str type: EntityType vintage_year: int | None fund_size_cents: int | None status: EntityStatus created_at: datetime # --- Holding --- class HoldingCreate(BaseModel): company_name: str class HoldingUpdate(BaseModel): company_name: str | None = None class HoldingResponse(BaseModel): id: int entity_id: int company_name: str created_at: datetime # --- Position --- class PositionCreate(BaseModel): security_name: str investment_date: date shares: str | None = None cost_dollars: float # Accept dollars at API boundary, store as cents class PositionUpdate(BaseModel): security_name: str | None = None investment_date: date | None = None shares: str | None = None cost_dollars: float | None = None class PositionResponse(BaseModel): id: int holding_id: int security_name: str investment_date: date shares: str | None cost_cents: int created_at: datetime # --- Valuation Round --- class RoundCreate(BaseModel): quarter_end: date class ValuationBulkItem(BaseModel): position_id: int value_cents: int class ValuationBulkUpdate(BaseModel): valuations: list[ValuationBulkItem] class ReturnNote(BaseModel): note: str class ValuationResponse(BaseModel): id: int round_id: int position_id: int value_cents: int created_at: datetime class RoundResponse(BaseModel): id: int entity_id: int quarter_end: date status: RoundStatus submitted_by: int | None submitted_at: datetime | None approved_by: int | None approved_at: datetime | None return_note: str | None is_seed: bool created_at: datetime valuations: list[ValuationResponse] = [] # --- Document --- class DocumentResponse(BaseModel): id: int entity_id: int investor_user_id: int | None category: DocumentCategory title: str original_filename: str content_type: str size_bytes: int uploaded_by: int | None created_at: datetime # --- Capital account --- class CapitalAccountCreate(BaseModel): entity_id: int investor_user_id: int as_of_date: date commitment_dollars: float = 0 beginning_balance_dollars: float = 0 contributions_dollars: float = 0 distributions_dollars: float = 0 ending_balance_dollars: float = 0 document_id: int | None = None class CapitalAccountResponse(BaseModel): id: int entity_id: int investor_user_id: int investor_name: str | None = None # legal name of the account this statement belongs to as_of_date: date commitment_cents: int beginning_balance_cents: int contributions_cents: int distributions_cents: int ending_balance_cents: int document_id: int | None created_at: datetime # --- Partners (members of an entity) --- class PartnerResponse(BaseModel): user_id: int name: str username: str external_investor_id: str | None is_active: bool login_enabled: bool latest_commitment_cents: int | None = None latest_contributions_cents: int | None = None latest_distributions_cents: int | None = None latest_value_cents: int | None latest_as_of: date | None statements_count: int # --- Access matrix --- class AccessGrant(BaseModel): user_id: int entity_id: int class AccessMatrixResponse(BaseModel): users: list[UserResponse] entities: list[EntityResponse] grants: list[AccessGrant] # --- Capital account import (review-and-confirm) --- class ImportInvestorPreview(BaseModel): source_name: str # name as it appears in the spreadsheet column_index: int # 0-based column it was read from value_dollars: float # current capital value (ending balance) commitment_dollars: float = 0 contributions_dollars: float = 0 distributions_dollars: float = 0 external_id: str | None = None # fund-admin INVESTOR ID, when present matched_user_id: int | None = None matched_username: str | None = None suggested_username: str | None = None # for unmatched: a safe default class ImportValueRow(BaseModel): row_index: int # 0-based sheet row label: str # col A label (e.g. "LTPF1") class CapitalImportPreview(BaseModel): as_of_date: date | None value_rows: list[ImportValueRow] # candidate rows holding per-investor balances chosen_row_index: int # the row used for the values below investors: list[ImportInvestorPreview] class ImportCommitInvestor(BaseModel): action: str # "match" | "create" | "skip" value_dollars: float # current capital value (ending balance) commitment_dollars: float = 0 contributions_dollars: float = 0 distributions_dollars: float = 0 user_id: int | None = None # for action=match 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 external_id: str | None = None # fund-admin INVESTOR ID, stored for re-import matching class CapitalImportCommit(BaseModel): entity_id: int as_of_date: date investors: list[ImportCommitInvestor] # --- Entity stakes (a GP/mgmt entity's interest in the funds it manages) --- class EntityStakeCreate(BaseModel): fund_entity_id: int ownership_pct: float | None = None value_dollars: float | None = None note: str | None = None class EntityStakeResponse(BaseModel): id: int holder_entity_id: int fund_entity_id: int fund_name: str | None = None fund_type: EntityType | None = None ownership_pct: float | None value_cents: int | None note: str | None created_at: datetime # --- Investor View (admin reconstruction of what one investor sees) --- class InvestorViewResponse(BaseModel): user: UserResponse entities: list[EntityResponse] = [] capital_accounts: list[CapitalAccountResponse] = [] documents: list[DocumentResponse] = [] # --- Audit --- class AuditLogResponse(BaseModel): id: int actor_user_id: int | None action: str object_type: str object_id: int | None detail: dict | list | str | None created_at: datetime