Skip to content

Commit 93012d8

Browse files
authored
feat(wave3): telemetry and content-loader hardening for ai-ml-fundamentals probe (#158)
1 parent d45abbf commit 93012d8

12 files changed

Lines changed: 415 additions & 7 deletions

File tree

backend/app/api/analytics.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,43 @@ def analytics_summary(
225225
""")
226226
).all()
227227

228+
# Per-category quiz completions
229+
category_quizzes = session.exec(
230+
text("""
231+
SELECT q.category, COUNT(*) AS completions
232+
FROM userquizattempt uqa
233+
JOIN quiz q ON q.id = uqa.quiz_id
234+
WHERE q.category IS NOT NULL
235+
GROUP BY q.category
236+
ORDER BY completions DESC
237+
""")
238+
).all()
239+
240+
# Per-category anonymous lesson views (from event table)
241+
category_anon_lesson_views = session.exec(
242+
text("""
243+
SELECT payload->>'category' AS cat, COUNT(*) AS cnt
244+
FROM event
245+
WHERE event_type = 'lesson_view'
246+
AND user_id IS NULL
247+
AND payload->>'category' IS NOT NULL
248+
GROUP BY cat
249+
ORDER BY cnt DESC
250+
""")
251+
).all()
252+
253+
# Per-category flashcard reviews (sum review repetitions proxy via userflashcard + flashcard join)
254+
category_flashcard_reviews = session.exec(
255+
text("""
256+
SELECT f.category, SUM(uf.repetitions) AS reviews
257+
FROM userflashcard uf
258+
JOIN flashcard f ON f.id = uf.flashcard_id
259+
WHERE f.category IS NOT NULL
260+
GROUP BY f.category
261+
ORDER BY reviews DESC
262+
""")
263+
).all()
264+
228265
return {
229266
"total_sessions": total_sessions,
230267
"anonymous_sessions": anonymous_sessions,
@@ -259,4 +296,7 @@ def analytics_summary(
259296
"users_with_problem_reviews": problem_user_row[0] or 0,
260297
},
261298
"category_problem_submissions": {r[0]: r[1] for r in category_problems},
299+
"quiz_completions_by_category": {r[0]: r[1] for r in category_quizzes},
300+
"anon_lesson_views_by_category": {r[0]: r[1] for r in category_anon_lesson_views},
301+
"flashcard_reviews_by_category": {r[0]: int(r[1]) for r in category_flashcard_reviews},
262302
}

backend/app/api/users.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# app/api/users.py
22

33
import os
4+
import uuid
45
from datetime import datetime, timedelta, timezone
56
from typing import Optional
67

@@ -13,7 +14,7 @@
1314

1415
from ..database import get_session
1516
from ..limiter import limiter
16-
from ..models import StreakOut, StudySession, Token, User, UserCreate
17+
from ..models import Event, StreakOut, StudySession, Token, User, UserCreate
1718

1819
# ─── CONFIG ──────────────────────────────────────────────────────────────
1920

@@ -137,6 +138,16 @@ def signup(
137138
hashed_password=get_password_hash(data.password),
138139
)
139140
session.add(user)
141+
session.flush()
142+
143+
session_id = request.cookies.get("session_id") or str(uuid.uuid4())
144+
event = Event(
145+
session_id=session_id,
146+
user_id=user.id,
147+
event_type="signup",
148+
payload={"referrer_category": data.referrer_category},
149+
)
150+
session.add(event)
140151
session.commit()
141152
access_token = create_access_token({"sub": data.username})
142153
return Token(access_token=access_token)

backend/app/loader.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,28 @@ def load_yaml_flashcards() -> None:
7373
if not isinstance(data, list):
7474
logger.warning("Skipping %s: root is %s, not list", file, type(data).__name__)
7575
continue
76-
for raw in data:
76+
for idx, raw in enumerate(data):
77+
if not isinstance(raw, dict):
78+
logger.warning(
79+
"Skipping card %d in %s: expected dict, got %s",
80+
idx,
81+
file,
82+
type(raw).__name__,
83+
)
84+
continue
85+
missing_key = None
86+
for required in ("title", "Front", "Back"):
87+
if required not in raw:
88+
missing_key = required
89+
break
90+
if missing_key is not None:
91+
logger.warning(
92+
"Skipping card %d in %s: missing required field '%s'",
93+
idx,
94+
file,
95+
missing_key,
96+
)
97+
continue
7798
card = Flashcard(
7899
title=raw["title"],
79100
front=raw["Front"],

backend/app/models.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"docker": "Docker",
2222
"linux": "Linux",
2323
"networking": "Networking",
24+
"ai-ml-fundamentals": "AI/ML Fundamentals",
2425
}
2526

2627

@@ -56,6 +57,20 @@ class User(SQLModel, table=True):
5657
class UserCreate(BaseModel):
5758
username: str = PydanticField(min_length=3, pattern=r'^[a-zA-Z0-9_]+$')
5859
password: str = PydanticField(min_length=8)
60+
referrer_category: Optional[str] = PydanticField(default=None, max_length=64)
61+
62+
@field_validator("referrer_category")
63+
@classmethod
64+
def normalize_referrer_category(cls, value: Optional[str]) -> Optional[str]:
65+
if value is None:
66+
return None
67+
normalized = value.strip().lower()
68+
if not normalized or len(normalized) > 64:
69+
return None
70+
parts = normalized.split("-")
71+
if any(not part or not part.isalnum() for part in parts):
72+
return None
73+
return normalized
5974

6075

6176
class Token(BaseModel):

backend/tests/api/test_analytics.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,16 @@
77
from app.api.analytics import summary_router
88
from app.api.users import router as user_router
99
from app.database import get_session
10-
from app.models import CodingProblem, Event, UserCodingProblem
10+
from app.models import (
11+
CodingProblem,
12+
Event,
13+
Flashcard,
14+
Lesson,
15+
Quiz,
16+
UserCodingProblem,
17+
UserFlashcard,
18+
UserQuizAttempt,
19+
)
1120

1221

1322
@pytest.fixture(name="app")
@@ -235,6 +244,96 @@ def test_summary_with_problem_metrics(client, session, create_user, get_token):
235244
assert data["category_problem_submissions"]["data-structures"] == 3
236245

237246

247+
def test_per_category_breakdowns(client, session, create_user, get_token):
248+
"""quiz_completions_by_category, anon_lesson_views_by_category, and
249+
flashcard_reviews_by_category are all present and correctly counted."""
250+
admin = create_user(is_admin=True)
251+
token = get_token(client, "user", "password")
252+
253+
# Seed two categories
254+
lesson_a = Lesson(
255+
title="Lesson A",
256+
slug="lesson-a",
257+
category="cat-a",
258+
content="Content",
259+
summary="Summary",
260+
reading_time_minutes=1,
261+
order=0,
262+
)
263+
lesson_b = Lesson(
264+
title="Lesson B",
265+
slug="lesson-b",
266+
category="cat-b",
267+
content="Content",
268+
summary="Summary",
269+
reading_time_minutes=1,
270+
order=0,
271+
)
272+
session.add(lesson_a)
273+
session.add(lesson_b)
274+
session.commit()
275+
session.refresh(lesson_a)
276+
session.refresh(lesson_b)
277+
278+
quiz_a = Quiz(title="Quiz A", slug="quiz-a", category="cat-a", lesson_slug="lesson-a")
279+
quiz_b = Quiz(title="Quiz B", slug="quiz-b", category="cat-b", lesson_slug="lesson-b")
280+
session.add(quiz_a)
281+
session.add(quiz_b)
282+
session.commit()
283+
session.refresh(quiz_a)
284+
session.refresh(quiz_b)
285+
286+
# 2 quiz completions for cat-a, 1 for cat-b
287+
session.add(UserQuizAttempt(user_id=admin.id, quiz_id=quiz_a.id, score=4, total=5))
288+
session.commit()
289+
second_user = create_user(username="user2")
290+
session.add(UserQuizAttempt(user_id=second_user.id, quiz_id=quiz_a.id, score=3, total=5))
291+
session.add(UserQuizAttempt(user_id=second_user.id, quiz_id=quiz_b.id, score=5, total=5))
292+
session.commit()
293+
294+
# Anon lesson views: 3 for cat-a, 1 for cat-b
295+
for _ in range(3):
296+
session.add(Event(
297+
session_id="anon-s",
298+
user_id=None,
299+
event_type="lesson_view",
300+
payload={"category": "cat-a", "slug": "lesson-a"},
301+
))
302+
session.add(Event(
303+
session_id="anon-s2",
304+
user_id=None,
305+
event_type="lesson_view",
306+
payload={"category": "cat-b", "slug": "lesson-b"},
307+
))
308+
session.commit()
309+
310+
# Flashcard reviews: seed a card in cat-a with repetitions=5, cat-b with repetitions=2
311+
card_a = Flashcard(title="Card A", front="Q", back="A", category="cat-a", tags=[])
312+
card_b = Flashcard(title="Card B", front="Q", back="A", category="cat-b", tags=[])
313+
session.add(card_a)
314+
session.add(card_b)
315+
session.commit()
316+
session.refresh(card_a)
317+
session.refresh(card_b)
318+
319+
session.add(UserFlashcard(user_id=admin.id, flashcard_id=card_a.id, repetitions=5))
320+
session.add(UserFlashcard(user_id=admin.id, flashcard_id=card_b.id, repetitions=2))
321+
session.commit()
322+
323+
resp = client.get("/analytics/summary", headers={"Authorization": f"Bearer {token}"})
324+
assert resp.status_code == 200
325+
data = resp.json()
326+
327+
assert data["quiz_completions_by_category"]["cat-a"] == 2
328+
assert data["quiz_completions_by_category"]["cat-b"] == 1
329+
330+
assert data["anon_lesson_views_by_category"]["cat-a"] == 3
331+
assert data["anon_lesson_views_by_category"]["cat-b"] == 1
332+
333+
assert data["flashcard_reviews_by_category"]["cat-a"] == 5
334+
assert data["flashcard_reviews_by_category"]["cat-b"] == 2
335+
336+
238337
def test_users_me_returns_is_admin_false(client, session, create_user, get_token):
239338
create_user(is_admin=False)
240339
token = get_token(client, "user", "password")

backend/tests/api/test_quizzes.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from app.api.users import get_current_user, get_optional_user
88
from app.api.users import router as user_router
99
from app.database import get_session
10-
from app.models import UserQuizAttempt
10+
from app.models import Flashcard, Lesson, UserFlashcard, UserQuizAttempt
1111
from tests.conftest import get_test_session
1212

1313

@@ -246,3 +246,52 @@ def test_submit_quiz_zero_score(client, create_quiz, create_quiz_question):
246246
for r in data["results"]:
247247
assert "correct_index" in r
248248
assert "explanation" in r
249+
250+
251+
def test_seed_flashcards_fresh_category(client, session, create_user, create_quiz, create_quiz_question):
252+
"""Submitting a quiz for a fresh category seeds UserFlashcard rows for all linked flashcards."""
253+
create_user(username="user", password="password")
254+
lesson_slug = "ai-foundations"
255+
category = "ai-ml-fundamentals"
256+
257+
lesson = Lesson(
258+
title="AI Foundations",
259+
slug=lesson_slug,
260+
category=category,
261+
content="Content",
262+
summary="Summary",
263+
reading_time_minutes=5,
264+
order=0,
265+
)
266+
session.add(lesson)
267+
session.commit()
268+
269+
quiz = create_quiz(slug="ai-foundations", category=category, lesson_slug=lesson_slug)
270+
q1 = create_quiz_question(quiz_id=quiz.id, correct_index=0, order=0)
271+
272+
for i in range(10):
273+
card = Flashcard(
274+
title=f"AI Card {i}",
275+
front=f"Q{i}",
276+
back=f"A{i}",
277+
category=category,
278+
lesson_slug=lesson_slug,
279+
tags=[],
280+
)
281+
session.add(card)
282+
session.commit()
283+
284+
response = client.post(
285+
f"/quizzes/{quiz.slug}/submit",
286+
json={"answers": {str(q1.id): 0}},
287+
)
288+
assert response.status_code == 200
289+
290+
uf_rows = session.exec(
291+
select(UserFlashcard).where(UserFlashcard.user_id == FakeUser.id)
292+
).all()
293+
assert len(uf_rows) == 10
294+
for uf in uf_rows:
295+
flashcard = session.get(Flashcard, uf.flashcard_id)
296+
assert flashcard is not None
297+
assert flashcard.lesson_slug == lesson_slug

backend/tests/api/test_users.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
from fastapi.testclient import TestClient
44
from slowapi import _rate_limit_exceeded_handler
55
from slowapi.errors import RateLimitExceeded
6+
from sqlmodel import select
67

78
from app.api.users import ALGORITHM, SECRET_KEY, User, get_password_hash
89
from app.api.users import router as user_router
910
from app.database import get_session
1011
from app.limiter import limiter
12+
from app.models import Event
1113
from tests.conftest import get_test_session
1214

1315

@@ -123,3 +125,31 @@ def test_login_sets_last_login(session):
123125

124126
session.refresh(user)
125127
assert user.last_login is not None
128+
129+
130+
def test_signup_referrer_category_emits_event(session):
131+
"""Signing up with a referrer_category stores a signup Event with that value."""
132+
client = TestClient(anon_app(session))
133+
resp = client.post(
134+
"/signup",
135+
json={"username": "referred_user", "password": "pass1234", "referrer_category": "ai-ml-fundamentals"},
136+
)
137+
assert resp.status_code == 201
138+
139+
events = session.exec(select(Event).where(Event.event_type == "signup")).all()
140+
assert len(events) == 1
141+
assert events[0].payload["referrer_category"] == "ai-ml-fundamentals"
142+
143+
144+
def test_signup_no_referrer_category_emits_event_with_none(session):
145+
"""Signing up without referrer_category emits a signup Event with null value."""
146+
client = TestClient(anon_app(session))
147+
resp = client.post(
148+
"/signup",
149+
json={"username": "no_referral_user", "password": "pass1234"},
150+
)
151+
assert resp.status_code == 201
152+
153+
events = session.exec(select(Event).where(Event.event_type == "signup")).all()
154+
assert len(events) == 1
155+
assert events[0].payload["referrer_category"] is None

0 commit comments

Comments
 (0)