diff --git a/server/alembic/versions/8e2cb3003f8a_usage_events.py b/server/alembic/versions/8e2cb3003f8a_usage_events.py new file mode 100644 index 0000000..88f407f --- /dev/null +++ b/server/alembic/versions/8e2cb3003f8a_usage_events.py @@ -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') diff --git a/server/models.py b/server/models.py index 05d1b19..6e02226 100644 --- a/server/models.py +++ b/server/models.py @@ -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): """Admin-editable application settings (feature flags, SMTP config, …) stored as key -> JSON value. Read/written via /api/settings (admin only). Secrets like