Files
impactflow_discovery/app/routers/activity.py
T
Joel Salmon b8f176bb31 Initial commit: ImpactFlow Discovery + Google OAuth auth layer
Discovery service (pre-existing): FastAPI + async SQLAlchemy + Alembic +
SQLite + Anthropic, with a five-prompt static UI that produces an Enneagram
+ Ikigai profile.

Auth implementation (this change set) follows
Impact_Flow_Auth_Plan_OAuth.html, adapted to the discovery_conversation /
discovery_profile schema:

- app/auth.py: Google OAuth registration, JWT issue/decode, dual-auth
  dependency (Bearer JWT or X-API-Key), refresh-token hashing, domain
  allow-list, synthetic api-key-admin user
- app/tracking.py: ActivityTrackingMiddleware + log_activity helper;
  tags machine-to-machine calls source=mcp
- app/routers/auth.py: /api/auth/{login,callback,refresh,logout},
  /api/me, /api/me/{stats,sessions,sessions/{id}}
- app/routers/activity.py: /api/activity, /api/activity/summary,
  /api/admin/activity, plus prune_old_activity (90-day retention)
- app/routers/discovery.py: every route now user-scoped via the auth
  dependency; /discovery/profile/{user_id} -> /discovery/profile/me
- alembic/versions/002_add_auth.py: users, refresh_tokens, activity_log
- tests/test_auth.py: 8 tests covering 401 paths, X-API-Key admin
  resolution, JWT round-trip, admin gating, domain allow-list
- README.md: Authentication section, expanded env-var table, updated
  data-model and API-reference tables
- .env.example: new GOOGLE_*, JWT_*, IMPACTFLOW_API_KEY, CORS_*,
  ALLOWED_EMAIL_DOMAINS placeholders
- .gitignore: also exclude data/*.log

Tests: 19/19 pass (11 pre-existing + 8 new). smoke_test.py exercises the
full discovery flow under X-API-Key plus 401 paths, OAuth login redirect,
activity logging, and /api/me/stats.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:59:41 -05:00

136 lines
3.8 KiB
Python

"""Activity feed + admin endpoints."""
from collections import Counter
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_user, require_admin
from app.database import get_db
from app.models import ActivityLog, User
router = APIRouter(prefix="/api", tags=["activity"])
class ActivityOut(BaseModel):
id: str
user_id: str
action: str
resource: str
resource_id: str | None
source: str
created_at: datetime
ip_address: str | None
user_agent: str | None
class ActivitySummary(BaseModel):
days: int
total: int
actions_per_day: dict[str, int]
top_resources: list[tuple[str, int]]
web_count: int
mcp_count: int
def _to_out(row: ActivityLog) -> ActivityOut:
return ActivityOut(
id=row.id,
user_id=row.user_id,
action=row.action,
resource=row.resource,
resource_id=row.resource_id,
source=row.source,
created_at=row.created_at,
ip_address=row.ip_address,
user_agent=row.user_agent,
)
@router.get("/activity", response_model=list[ActivityOut])
async def list_my_activity(
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=200),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
stmt = (
select(ActivityLog)
.where(ActivityLog.user_id == user.id)
.order_by(ActivityLog.created_at.desc())
.offset((page - 1) * limit)
.limit(limit)
)
rows = (await db.execute(stmt)).scalars().all()
return [_to_out(r) for r in rows]
@router.get("/activity/summary", response_model=ActivitySummary)
async def my_activity_summary(
days: int = Query(30, ge=1, le=365),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
since = datetime.now(timezone.utc) - timedelta(days=days)
stmt = (
select(ActivityLog)
.where(ActivityLog.user_id == user.id)
.where(ActivityLog.created_at >= since)
)
rows = (await db.execute(stmt)).scalars().all()
per_day: Counter[str] = Counter()
resource_counts: Counter[str] = Counter()
web = mcp = 0
for r in rows:
per_day[r.created_at.date().isoformat()] += 1
resource_counts[r.resource] += 1
if r.source == "mcp":
mcp += 1
else:
web += 1
return ActivitySummary(
days=days,
total=len(rows),
actions_per_day=dict(per_day),
top_resources=resource_counts.most_common(5),
web_count=web,
mcp_count=mcp,
)
@router.get("/admin/activity", response_model=list[ActivityOut])
async def admin_list_activity(
user_id: str | None = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=500),
_admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
stmt = select(ActivityLog).order_by(ActivityLog.created_at.desc())
if user_id:
stmt = stmt.where(ActivityLog.user_id == user_id)
stmt = stmt.offset((page - 1) * limit).limit(limit)
rows = (await db.execute(stmt)).scalars().all()
return [_to_out(r) for r in rows]
async def prune_old_activity(db: AsyncSession, days: int = 90) -> int:
"""Delete rows older than N days. Returns rows deleted."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
stmt = select(func.count()).select_from(ActivityLog).where(
ActivityLog.created_at < cutoff
)
count = (await db.execute(stmt)).scalar_one()
if count:
from sqlalchemy import delete
await db.execute(delete(ActivityLog).where(
ActivityLog.created_at < cutoff
))
await db.commit()
return int(count or 0)