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,101 @@
|
||||
"""investor access: username, entity access, documents, capital accounts
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 2792c4ff4612
|
||||
Create Date: 2026-06-26 14:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a1b2c3d4e5f6'
|
||||
down_revision: Union[str, None] = '2792c4ff4612'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# users: add username (login handle), make email optional.
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column('username', sqlmodel.sql.sqltypes.AutoString(), nullable=True)
|
||||
)
|
||||
batch_op.alter_column('email', existing_type=sa.String(), nullable=True)
|
||||
|
||||
# Backfill username for any existing internal accounts so the NOT NULL holds.
|
||||
op.execute("UPDATE users SET username = email WHERE username IS NULL")
|
||||
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.alter_column(
|
||||
'username',
|
||||
existing_type=sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=False,
|
||||
)
|
||||
batch_op.create_unique_constraint('uq_users_username', ['username'])
|
||||
|
||||
op.create_table(
|
||||
'entity_access',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('entity_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id', 'entity_id', name='uq_access_user_entity'),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'documents',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('entity_id', sa.Integer(), nullable=False),
|
||||
sa.Column('investor_user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('category', sa.Enum('capital_account', 'k1', 'statement', 'tax', 'other', name='documentcategory'), nullable=False),
|
||||
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('original_filename', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('content_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('size_bytes', sa.Integer(), nullable=False),
|
||||
sa.Column('storage_path', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('uploaded_by', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ),
|
||||
sa.ForeignKeyConstraint(['investor_user_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'capital_account_statements',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('entity_id', sa.Integer(), nullable=False),
|
||||
sa.Column('investor_user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('as_of_date', sa.Date(), nullable=False),
|
||||
sa.Column('beginning_balance_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('contributions_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('distributions_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('ending_balance_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('document_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['document_id'], ['documents.id'], ),
|
||||
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], ),
|
||||
sa.ForeignKeyConstraint(['investor_user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('entity_id', 'investor_user_id', 'as_of_date', name='uq_capacct_entity_investor_date'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_table('capital_account_statements')
|
||||
op.drop_table('documents')
|
||||
op.drop_table('entity_access')
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_constraint('uq_users_username', type_='unique')
|
||||
batch_op.alter_column('email', existing_type=sa.String(), nullable=False)
|
||||
batch_op.drop_column('username')
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add users.external_investor_id
|
||||
|
||||
Revision ID: b2c3d4e5f6a7
|
||||
Revises: a1b2c3d4e5f6
|
||||
Create Date: 2026-06-28 11:10:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
revision: str = 'b2c3d4e5f6a7'
|
||||
down_revision: Union[str, None] = 'a1b2c3d4e5f6'
|
||||
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('external_investor_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_column('external_investor_id')
|
||||
@@ -0,0 +1,41 @@
|
||||
"""add entity_stakes (a holder entity's stake in the funds it manages)
|
||||
|
||||
Revision ID: b8c9d0e1f2a3
|
||||
Revises: a7b8c9d0e1f2
|
||||
Create Date: 2026-07-01 10:15:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'b8c9d0e1f2a3'
|
||||
down_revision: Union[str, None] = 'a7b8c9d0e1f2'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'entity_stakes',
|
||||
sa.Column('id', sa.Integer(), primary_key=True, nullable=False),
|
||||
sa.Column('holder_entity_id', sa.Integer(), nullable=False),
|
||||
sa.Column('fund_entity_id', sa.Integer(), nullable=False),
|
||||
sa.Column('ownership_pct', sa.Float(), nullable=True),
|
||||
sa.Column('value_cents', sa.Integer(), nullable=True),
|
||||
sa.Column('note', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['holder_entity_id'], ['entities.id']),
|
||||
sa.ForeignKeyConstraint(['fund_entity_id'], ['entities.id']),
|
||||
sa.UniqueConstraint('holder_entity_id', 'fund_entity_id', name='uq_stake_holder_fund'),
|
||||
)
|
||||
op.create_index('ix_entity_stakes_holder_entity_id', 'entity_stakes', ['holder_entity_id'])
|
||||
op.create_index('ix_entity_stakes_fund_entity_id', 'entity_stakes', ['fund_entity_id'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_entity_stakes_fund_entity_id', table_name='entity_stakes')
|
||||
op.drop_index('ix_entity_stakes_holder_entity_id', table_name='entity_stakes')
|
||||
op.drop_table('entity_stakes')
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add users.login_enabled
|
||||
|
||||
Revision ID: c3d4e5f6a7b8
|
||||
Revises: b2c3d4e5f6a7
|
||||
Create Date: 2026-06-28 11:45:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'c3d4e5f6a7b8'
|
||||
down_revision: Union[str, None] = 'b2c3d4e5f6a7'
|
||||
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('login_enabled', sa.Boolean(), nullable=False, server_default=sa.true())
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_column('login_enabled')
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add capital_account_statements.commitment_cents
|
||||
|
||||
Revision ID: d4e5f6a7b8c9
|
||||
Revises: c3d4e5f6a7b8
|
||||
Create Date: 2026-06-28 16:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'd4e5f6a7b8c9'
|
||||
down_revision: Union[str, None] = 'c3d4e5f6a7b8'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table('capital_account_statements', schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column('commitment_cents', sa.Integer(), nullable=False, server_default='0')
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('capital_account_statements', schema=None) as batch_op:
|
||||
batch_op.drop_column('commitment_cents')
|
||||
@@ -0,0 +1,33 @@
|
||||
"""add users.primary_account_id (linked investor logins)
|
||||
|
||||
Revision ID: e5f6a7b8c9d0
|
||||
Revises: d4e5f6a7b8c9
|
||||
Create Date: 2026-06-28 17:10:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'e5f6a7b8c9d0'
|
||||
down_revision: Union[str, None] = 'd4e5f6a7b8c9'
|
||||
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('primary_account_id', sa.Integer(), nullable=True)
|
||||
)
|
||||
batch_op.create_foreign_key(
|
||||
'fk_users_primary_account_id', 'users', ['primary_account_id'], ['id']
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_constraint('fk_users_primary_account_id', type_='foreignkey')
|
||||
batch_op.drop_column('primary_account_id')
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add users.is_service_admin and flag the bootstrap admin
|
||||
|
||||
Revision ID: f6a7b8c9d0e1
|
||||
Revises: e5f6a7b8c9d0
|
||||
Create Date: 2026-06-29 09:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'f6a7b8c9d0e1'
|
||||
down_revision: Union[str, None] = 'e5f6a7b8c9d0'
|
||||
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('is_service_admin', sa.Boolean(), nullable=False, server_default=sa.false())
|
||||
)
|
||||
# The first account created (the first-boot bootstrap admin) is the Service Admin.
|
||||
op.execute(
|
||||
"UPDATE users SET is_service_admin = 1 "
|
||||
"WHERE id = (SELECT MIN(id) FROM users)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
||||
batch_op.drop_column('is_service_admin')
|
||||
Reference in New Issue
Block a user