T11.1: UsageEvent model + migration (CR-019)
New usage_events table, separate from audit_log - see the model docstring for why. Verified: applies and downgrades cleanly on SQLite, and the postgresql-dialect --sql render has no risky defaults (the BL-027 class of defect). No app wiring yet - that's T11.2.
This commit is contained in:
46
server/alembic/versions/8e2cb3003f8a_usage_events.py
Normal file
46
server/alembic/versions/8e2cb3003f8a_usage_events.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""usage events (CR-019, wave 11)
|
||||||
|
|
||||||
|
Append-only navigation/session activity, separate from audit_log on purpose —
|
||||||
|
see the UsageEvent docstring in server/models.py. Retention is indefinite by
|
||||||
|
decision (docs/waves/decisions-2026-09-17.md); nothing here schedules a purge.
|
||||||
|
|
||||||
|
Revision ID: 8e2cb3003f8a
|
||||||
|
Revises: 1d60a608bb51
|
||||||
|
Create Date: 2026-09-23 00:00:00.000000
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '8e2cb3003f8a'
|
||||||
|
down_revision = '1d60a608bb51'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table('usage_events',
|
||||||
|
sa.Column('id', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('username', sa.String(length=200), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=40), nullable=True),
|
||||||
|
sa.Column('tool', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('event', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('detail', sa.JSON(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_usage_events_at'), 'usage_events', ['at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_usage_events_username'), 'usage_events', ['username'], unique=False)
|
||||||
|
op.create_index(op.f('ix_usage_events_project_id'), 'usage_events', ['project_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_usage_events_tool'), 'usage_events', ['tool'], unique=False)
|
||||||
|
op.create_index(op.f('ix_usage_events_event'), 'usage_events', ['event'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f('ix_usage_events_event'), table_name='usage_events')
|
||||||
|
op.drop_index(op.f('ix_usage_events_tool'), table_name='usage_events')
|
||||||
|
op.drop_index(op.f('ix_usage_events_project_id'), table_name='usage_events')
|
||||||
|
op.drop_index(op.f('ix_usage_events_username'), table_name='usage_events')
|
||||||
|
op.drop_index(op.f('ix_usage_events_at'), table_name='usage_events')
|
||||||
|
op.drop_table('usage_events')
|
||||||
@@ -337,6 +337,45 @@ class AuditLog(Base):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UsageEvent(Base):
|
||||||
|
"""CR-019: append-only record of who used the suite, when, and which tool —
|
||||||
|
navigation/session activity, not business mutations. Deliberately a SEPARATE
|
||||||
|
table from AuditLog rather than a new `action` value there: AuditLog answers
|
||||||
|
"who changed what" and is read by people auditing a specific record's
|
||||||
|
history; mixing in a `page_open` row for every authenticated page load
|
||||||
|
would make that trail noisy for its existing purpose. This table answers a
|
||||||
|
different question — "who is active, and on what" — and CR-019's admin
|
||||||
|
console reads from here, not from AuditLog.
|
||||||
|
|
||||||
|
Not a ForeignKey to `users`, matching AuditLog's own reasoning: a user who
|
||||||
|
is later removed should still show up in historical activity rather than
|
||||||
|
silently vanishing from it, and `D18`'s deprovisioning sync only ever sets
|
||||||
|
`is_active=False` — it never deletes a row — so this is defensive symmetry
|
||||||
|
rather than a live concern today.
|
||||||
|
|
||||||
|
Retention is indefinite (decided 2026-09-17, `decisions-2026-09-17.md`) —
|
||||||
|
nothing purges rows written here; that is a deliberate product decision,
|
||||||
|
not an oversight to fix later."""
|
||||||
|
__tablename__ = "usage_events"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(40), primary_key=True)
|
||||||
|
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(200), default="", index=True)
|
||||||
|
project_id: Mapped[Optional[str]] = mapped_column(String(40), nullable=True, index=True)
|
||||||
|
# creator | wizard | field_view | dashboard | admin | directory | ...
|
||||||
|
tool: Mapped[str] = mapped_column(String(40), default="", index=True)
|
||||||
|
# page_open | login
|
||||||
|
event: Mapped[str] = mapped_column(String(40), default="", index=True)
|
||||||
|
detail: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id, "at": _iso(self.at), "username": self.username,
|
||||||
|
"project_id": self.project_id, "tool": self.tool, "event": self.event,
|
||||||
|
"detail": self.detail or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class AppSetting(Base):
|
class AppSetting(Base):
|
||||||
"""Admin-editable application settings (feature flags, SMTP config, …) stored
|
"""Admin-editable application settings (feature flags, SMTP config, …) stored
|
||||||
as key -> JSON value. Read/written via /api/settings (admin only). Secrets like
|
as key -> JSON value. Read/written via /api/settings (admin only). Secrets like
|
||||||
|
|||||||
Reference in New Issue
Block a user