Skip to content

Commit 1f74822

Browse files
author
Aegis Stack
committed
Backported Pulse Insights
1 parent 7ac22e9 commit 1f74822

35 files changed

Lines changed: 4631 additions & 800 deletions

aegis/core/migration_generator.py

Lines changed: 121 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,37 @@ class ServiceMigrationSpec:
111111
],
112112
indexes=[IndexSpec("ix_user_email", ["email"], unique=True)],
113113
),
114+
# UserOAuthIdentity - links a user to a third-party identity.
115+
# One user can have many identities (GitHub + Google). The
116+
# (provider, provider_user_id) pair is unique to prevent identity
117+
# hijacking across accounts.
118+
TableSpec(
119+
name="user_oauth_identity",
120+
columns=[
121+
ColumnSpec("id", "sa.Integer()", nullable=False, primary_key=True),
122+
ColumnSpec("user_id", "sa.Integer()", nullable=False),
123+
ColumnSpec("provider", "sa.String(32)", nullable=False),
124+
# Stored as string to avoid caring whether the provider
125+
# uses int IDs (GitHub) or UUIDs (some others).
126+
ColumnSpec("provider_user_id", "sa.String(128)", nullable=False),
127+
ColumnSpec("provider_username", "sa.String(128)", nullable=True),
128+
ColumnSpec("provider_email", "sa.String(255)", nullable=True),
129+
ColumnSpec("avatar_url", "sa.String(512)", nullable=True),
130+
ColumnSpec("created_at", "sa.DateTime()", nullable=False),
131+
ColumnSpec("updated_at", "sa.DateTime()", nullable=False),
132+
],
133+
indexes=[
134+
IndexSpec(
135+
"uq_user_oauth_identity_provider_pid",
136+
["provider", "provider_user_id"],
137+
unique=True,
138+
),
139+
IndexSpec("ix_user_oauth_identity_user_id", ["user_id"]),
140+
],
141+
foreign_keys=[
142+
ForeignKeySpec(["user_id"], "user", ["id"]),
143+
],
144+
),
114145
],
115146
)
116147

@@ -606,7 +637,9 @@ class ServiceMigrationSpec:
606637
ForeignKeySpec(["metric_type_id"], "insight_metric_type", ["id"]),
607638
],
608639
),
609-
# InsightEvent - contextual markers
640+
# InsightEvent - contextual markers. The user-coupled
641+
# `created_by_user_id` column + FK + index are added by the
642+
# `insights_auth_link` migration when auth is also included.
610643
TableSpec(
611644
name="insight_event",
612645
columns=[
@@ -615,11 +648,80 @@ class ServiceMigrationSpec:
615648
ColumnSpec("event_type", "sa.String(64)", nullable=False),
616649
ColumnSpec("description", "sa.String(1024)", nullable=False),
617650
ColumnSpec("metadata", "sa.JSON()", nullable=False, default="{}"),
651+
# `origin` distinguishes collector output from user-created
652+
# annotations so the API/UI only exposes user rows for editing
653+
# and collector cleanups stay scoped to their own rows.
654+
ColumnSpec(
655+
"origin", "sa.String(16)", nullable=False, default="'collector'"
656+
),
618657
ColumnSpec("created_at", "sa.DateTime()", nullable=False),
619658
],
620659
indexes=[
621660
IndexSpec("ix_insight_event_date", ["date"]),
622661
IndexSpec("ix_insight_event_type_date", ["event_type", "date"]),
662+
IndexSpec("ix_insight_event_origin", ["origin"]),
663+
IndexSpec("ix_insight_event_origin_date", ["origin", "date"]),
664+
],
665+
),
666+
],
667+
)
668+
669+
# Insights + Auth: glue migration that adds the user-FK column to
670+
# insight_event and creates the user-scoped `insight_goal` table. Only runs
671+
# when both services are included; runs after both base migrations so the
672+
# `user` table exists when the FK is created.
673+
INSIGHTS_AUTH_LINK_MIGRATION = ServiceMigrationSpec(
674+
service_name="insights_auth_link",
675+
description="Link insight_event/insight_goal to user.id (auth + insights)",
676+
tables=[
677+
# InsightGoal - per-user metric goals (target value + window/date,
678+
# status). Goals are scoped to a user and a project_slug; many goals
679+
# per user is supported. See goal_service for progress calculation.
680+
TableSpec(
681+
name="insight_goal",
682+
columns=[
683+
ColumnSpec("id", "sa.Integer()", nullable=False, primary_key=True),
684+
ColumnSpec("user_id", "sa.Integer()", nullable=False),
685+
ColumnSpec("source_project_slug", "sa.String(64)", nullable=False),
686+
ColumnSpec("metric_key", "sa.String(64)", nullable=False),
687+
ColumnSpec("kind", "sa.String(16)", nullable=False),
688+
ColumnSpec("target_value", "sa.Float()", nullable=False),
689+
ColumnSpec("window_days", "sa.Integer()", nullable=True),
690+
ColumnSpec("target_date", "sa.Date()", nullable=True),
691+
ColumnSpec(
692+
"status", "sa.String(16)", nullable=False, default="'active'"
693+
),
694+
ColumnSpec("created_at", "sa.DateTime()", nullable=False),
695+
ColumnSpec("updated_at", "sa.DateTime()", nullable=False),
696+
],
697+
indexes=[
698+
IndexSpec("ix_insight_goal_user_id", ["user_id"]),
699+
IndexSpec(
700+
"ix_insight_goal_source_project_slug",
701+
["source_project_slug"],
702+
),
703+
IndexSpec("ix_insight_goal_metric_key", ["metric_key"]),
704+
IndexSpec("ix_insight_goal_user_status", ["user_id", "status"]),
705+
IndexSpec(
706+
"ix_insight_goal_project_metric",
707+
["source_project_slug", "metric_key"],
708+
),
709+
],
710+
foreign_keys=[
711+
ForeignKeySpec(["user_id"], "user", ["id"]),
712+
],
713+
),
714+
],
715+
alter_tables=[
716+
AlterTableSpec(
717+
name="insight_event",
718+
add_columns=[
719+
# Audit trail for user-created events. Null on collector and
720+
# CLI-created rows.
721+
ColumnSpec("created_by_user_id", "sa.Integer()", nullable=True),
722+
],
723+
add_foreign_keys=[
724+
ForeignKeySpec(["created_by_user_id"], "user", ["id"]),
623725
],
624726
),
625727
],
@@ -814,6 +916,7 @@ class ServiceMigrationSpec:
814916
"payment": PAYMENT_MIGRATION,
815917
"payment_auth_link": PAYMENT_AUTH_LINK_MIGRATION,
816918
"insights": INSIGHTS_MIGRATION,
919+
"insights_auth_link": INSIGHTS_AUTH_LINK_MIGRATION,
817920
}
818921

819922
# ============================================================================
@@ -867,24 +970,28 @@ def upgrade() -> None:
867970
868971
{% endfor %}
869972
{% for alter in alter_tables %}
870-
# Alter {{ alter.name }} table
973+
# Alter {{ alter.name }} table — batch_alter_table is required for
974+
# SQLite, which doesn't support ALTER for FK constraints. Postgres
975+
# treats it as plain ALTER, so this is portable across both backends.
976+
with op.batch_alter_table('{{ alter.name }}') as batch_op:
871977
{% for column in alter.add_columns %}
872-
op.add_column('{{ alter.name }}', sa.Column('{{ column.name }}', {{ column.type }}, nullable={{ column.nullable }}{% if column.server_default %}, server_default={{ column.server_default }}{% endif %}))
978+
batch_op.add_column(sa.Column('{{ column.name }}', {{ column.type }}, nullable={{ column.nullable }}{% if column.server_default %}, server_default={{ column.server_default }}{% endif %}))
873979
{% endfor %}
874980
{% for fk in alter.add_foreign_keys %}
875-
op.create_foreign_key('fk_{{ alter.name }}_{{ fk.columns[0] }}_{{ fk.ref_table }}', '{{ alter.name }}', '{{ fk.ref_table }}', {{ fk.columns }}, {{ fk.ref_columns }})
981+
batch_op.create_foreign_key('fk_{{ alter.name }}_{{ fk.columns[0] }}_{{ fk.ref_table }}', '{{ fk.ref_table }}', {{ fk.columns }}, {{ fk.ref_columns }})
876982
{% endfor %}
877983
878984
{% endfor %}
879985
880986
def downgrade() -> None:
881987
"""Reverse {{ service_name }} migration."""
882988
{% for alter in alter_tables|reverse %}
989+
with op.batch_alter_table('{{ alter.name }}') as batch_op:
883990
{% for fk in alter.add_foreign_keys|reverse %}
884-
op.drop_constraint('fk_{{ alter.name }}_{{ fk.columns[0] }}_{{ fk.ref_table }}', '{{ alter.name }}', type_='foreignkey')
991+
batch_op.drop_constraint('fk_{{ alter.name }}_{{ fk.columns[0] }}_{{ fk.ref_table }}', type_='foreignkey')
885992
{% endfor %}
886993
{% for column in alter.add_columns|reverse %}
887-
op.drop_column('{{ alter.name }}', '{{ column.name }}')
994+
batch_op.drop_column('{{ column.name }}')
888995
{% endfor %}
889996
{% endfor %}
890997
{% for table in tables|reverse %}
@@ -1194,7 +1301,8 @@ def get_services_needing_migrations(context: dict[str, Any]) -> list[str]:
11941301

11951302
# Insights service (always needs database)
11961303
include_insights = context.get("include_insights")
1197-
if include_insights == "yes" or include_insights is True:
1304+
include_insights_on = include_insights == "yes" or include_insights is True
1305+
if include_insights_on:
11981306
services.append("insights")
11991307

12001308
# Payment service (always needs database)
@@ -1210,6 +1318,12 @@ def get_services_needing_migrations(context: dict[str, Any]) -> list[str]:
12101318
if include_payment_on and include_auth_on:
12111319
services.append("payment_auth_link")
12121320

1321+
# Insights + Auth: add insight_event.created_by_user_id FK and
1322+
# create the user-scoped insight_goal table. Runs after both base
1323+
# migrations so the `user` table exists when the FK is created.
1324+
if include_insights_on and include_auth_on:
1325+
services.append("insights_auth_link")
1326+
12131327
return services
12141328

12151329

aegis/core/post_gen_tasks.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,10 @@ def _rename_backend_files(suffix: str) -> set[str]:
493493
remove_file(project_path, "tests/services/test_auth_service.py")
494494
remove_file(project_path, "tests/services/test_auth_integration.py")
495495
remove_file(project_path, "tests/models/test_user.py")
496+
# Goal service is auth-coupled (Goal.user_id FK to user table), so its
497+
# tests are only meaningful when auth is on. The source file lives
498+
# under app/services/insights/ but follows the auth cleanup path.
499+
remove_file(project_path, "tests/services/test_goal_service.py")
496500
# Note: alembic removal is handled below based on whether ANY service needs migrations
497501

498502
# Remove auth org files if org level not selected (but auth is enabled)
@@ -664,6 +668,10 @@ def _rename_backend_files(suffix: str) -> set[str]:
664668
remove_file(project_path, "tests/services/test_collector_pypi.py")
665669
remove_file(project_path, "tests/services/test_collector_plausible.py")
666670
remove_file(project_path, "tests/services/test_collector_reddit.py")
671+
# Goal service tests import from app.services.insights, so they
672+
# must be removed whenever insights is off — not just when auth is
673+
# off (the auth-coupled cleanup above handles the auth-off path).
674+
remove_file(project_path, "tests/services/test_goal_service.py")
667675
remove_file(project_path, "tests/api/test_insights_endpoints.py")
668676
remove_file(project_path, "tests/test_bulk_response.py")
669677
remove_file(project_path, "tests/test_cache_integration.py")

aegis/templates/copier-aegis-project/{{ project_slug }}/app/models/user.py.jinja

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,55 @@
11
"""User data models."""
22

33
from datetime import UTC, datetime
4+
from enum import StrEnum
45

56
from pydantic import EmailStr
7+
from sqlalchemy import Column, String, UniqueConstraint
68
from sqlmodel import Field, SQLModel
79

810

11+
class OAuthProvider(StrEnum):
12+
"""Supported third-party identity providers. Add a new value here when
13+
wiring another provider (Microsoft, Apple, etc.); no DB change needed."""
14+
15+
GITHUB = "github"
16+
GOOGLE = "google"
17+
18+
19+
class UserOAuthIdentity(SQLModel, table=True):
20+
"""Links a User row to a third-party identity.
21+
22+
One user can have many identities (future: GitHub + Google for the same
23+
account). A given (provider, provider_user_id) can only map to one user,
24+
enforced by `uq_user_oauth_identity_provider_pid` — prevents a drive-by
25+
attacker from hijacking someone's external identity.
26+
"""
27+
28+
__tablename__ = "user_oauth_identity"
29+
__table_args__ = (
30+
UniqueConstraint(
31+
"provider", "provider_user_id",
32+
name="uq_user_oauth_identity_provider_pid",
33+
),
34+
)
35+
36+
id: int | None = Field(default=None, primary_key=True)
37+
user_id: int = Field(foreign_key="user.id", index=True)
38+
provider: OAuthProvider = Field(
39+
sa_column=Column("provider", String(32), nullable=False),
40+
)
41+
provider_user_id: str = Field(max_length=128) # str to support int or uuid
42+
provider_username: str | None = Field(default=None, max_length=128)
43+
provider_email: str | None = Field(default=None, max_length=255)
44+
avatar_url: str | None = Field(default=None, max_length=512)
45+
created_at: datetime = Field(
46+
default_factory=lambda: datetime.now(UTC).replace(tzinfo=None),
47+
)
48+
updated_at: datetime = Field(
49+
default_factory=lambda: datetime.now(UTC).replace(tzinfo=None),
50+
)
51+
52+
953
class PasswordResetToken(SQLModel, table=True):
1054
"""Token for password reset requests."""
1155

@@ -53,6 +97,13 @@ class PasswordResetConfirm(SQLModel):
5397
new_password: str = Field(min_length=8)
5498

5599

100+
class ChangePasswordRequest(SQLModel):
101+
"""Request body for the authenticated change-password endpoint."""
102+
103+
current_password: str
104+
new_password: str = Field(min_length=8)
105+
106+
56107
class UserBase(SQLModel):
57108
"""Base user model with shared fields."""
58109

@@ -99,3 +150,17 @@ class UserResponse(UserBase):
99150
last_login: datetime | None = None
100151
created_at: datetime
101152
updated_at: datetime | None = None
153+
# Whether the account has a local password set. Drives UI decisions like
154+
# "can this user safely disconnect an OAuth provider without getting
155+
# locked out?". Computed — not a DB column.
156+
has_password: bool = False
157+
158+
@classmethod
159+
def from_user(cls, user: "User") -> "UserResponse":
160+
"""Single constructor so every UserResponse across the API carries
161+
the same computed fields. Keep API callers on this, not
162+
`model_validate`, to avoid drift."""
163+
return cls.model_validate({
164+
**user.model_dump(),
165+
"has_password": bool(user.hashed_password),
166+
})

0 commit comments

Comments
 (0)