feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542) - #550

Merged
AndresL230 merged 3 commits into
mainfrom
feat/542-quiz-attempt-lifecycle
Aug 13, 2026
Merged

feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542)#550
AndresL230 merged 3 commits into
mainfrom
feat/542-quiz-attempt-lifecycle

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Closes#542. Workstream D of the pre-revamp quiz repair batch (epic #537). The attempt lifecycle leaked orphans and leaked the answer key; the plaintext scalars kept for analytics in #521/#527 had no reader at all.

What

D1 — mastery snapshot persisted.mastery_before / mastery_after land on the attempt row at submit (migration 20260813013547, plaintext scalars per the #521 rationale). Without them a replayed or audited submit can't reconstruct what the student saw — the reason the #129 replay guard had to 409 instead of replaying the original 200.

D2 — explicit status, derived not stored.completed_atcompleted, abandoned_atabandoned, else in_progress; an in-progress row past QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented: a quiz is one sitting, but generous enough that stepping away for hours still resumes) reads as abandoned even before it's stamped. A lazy per-user sweep on the read paths stamps abandoned_at via conditional-update filters — same idiom as the submit claim, no scheduler. GET /api/quiz/attempts/{id} returns resume state: questions without the answer key plus the responses recorded through /answer.

D3 — achievements count completed attempts only.generate writes the attempt row before the student answers anything, so the unfiltered count let "generate and close the tab" advance quizzes_10.

Blast radius (measured on staging, 2026-08-12): 1 user, 2 attempts, 0 completed, quizzes_10 (threshold 10) never granted. Nobody loses a badge; one user's progress number drops 2 → 0. The achievement system is idempotent on (user_id, achievement_id) and grants are never revoked by this change — a recount can only withhold a badge not yet earned, and any already-granted badge stays. Prod recheck before merge is noted below.

D4 — GET /api/quiz/attempts. Paginated history for the signed-in user: concept, course, score, total, difficulty, mastery delta, dates. No question payloads, so no keys.

Wire-contract notes

All three endpoints are additive; no existing response shape changes. submit gains two persisted columns (response unchanged). No client code depends on the new routes yet — they exist for the #537 flow.

Verification

  • Hermetic suite: 1949 passed (7 new lifecycle tests, written first, watched fail).
  • Real-DB integration tests: lifecycle columns round-trip; the sweep touches the stale in-progress attempt and leaves a fresh one and a completed one alone (mocked tables can't see either).
  • Full local cycle under the stack lock: Playwright → oracles → integration → teardown.
  • ruff check clean. Migration applied to staging ahead of merge.

Prod check before promoting: re-run the D3 blast-radius query against production (SELECT count(*) FILTER (WHERE completed_at IS NULL) FROM quiz_attempts + granted quizzes_completed badges). Staging is clean; prod was not re-queried after this branch.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added quiz attempt history with pagination.
    • Added the ability to resume eligible in-progress quiz attempts.
    • Added automatic handling of stale attempts after 24 hours of inactivity.
    • Added clearer statuses and error messages for abandoned or non-resumable attempts.
    • Improved quiz completion tracking to count only fully completed and scored attempts.
  • Bug Fixes

    • Prevented answers and submissions from being added to abandoned attempts.
    • Protected answer keys from appearing in resume and history responses.

AndresL230and others added 2 commits August 12, 2026 22:07
…L sweep, resume, paginated history (#542)
Workstream D of the pre-revamp quiz repair batch (epic #537):
D1 — submit persists mastery_before/mastery_after on the attempt row
(migration 20260813013547; plaintext analytics scalars per #521), so a
replayed or audited submit can reconstruct what the student saw.
D2 — status is DERIVED, never stored: completed_at → completed,
abandoned_at → abandoned, else in_progress; an in-progress row past
QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented) reads as abandoned even
before the lazy per-user sweep stamps abandoned_at on the read paths
(no scheduler needed; conditional-update filters arbitrate).
GET /api/quiz/attempts/{id} returns resume state: questions WITHOUT the
answer key plus the responses recorded through /answer.
D3 — quizzes_completed counts completed attempts only; generate writes
the attempt row before the student answers anything, so the unfiltered
count let "generate and close the tab" advance quizzes_10. Blast radius
(measured on staging 2026-08-12): 1 user, 2 attempts, 0 completed,
badge never granted — nobody loses anything; prod recheck noted in the
PR. No revocation in any case.
D4 — GET /api/quiz/attempts: paginated history (concept, course, score,
total, difficulty, mastery delta, dates) — the plaintext scalars kept
for exactly this purpose in #521/#527 finally have a reader.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eep (#542)
The mastery snapshot and abandonment sweep are pure DB behaviour: a
mocked table() accepts columns the migration never added (#265 drift
class), and the conditional-update filters that decide WHICH attempts get
swept only mean something against Postgres. Asserts the columns
round-trip and that the sweep touches the stale in-progress attempt while
leaving a fresh one and a completed one alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 13, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging24a7203Commit Preview URL

Branch Preview URL
Aug 13 2026, 04:00 AM

@supabase

supabaseBot commented Aug 13, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5877551a-67b3-40a3-8246-bc43146465b9

📥 Commits

Reviewing files that changed from the base of the PR and between 73fb504 and 24a7203.

📒 Files selected for processing (8)
  • backend/db/connection.py
  • backend/db/migrations/20260813013547_quiz_attempts_lifecycle.sql
  • backend/routes/quiz.py
  • backend/services/achievement_service.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/integration/test_quiz_lifecycle_db.py
  • backend/tests/test_quiz_lifecycle_d.py

📝 Walkthrough

Walkthrough

Changes

Quiz attempt lifecycle

Layer / File(s)Summary
Lifecycle persistence and contracts
backend/db/connection.py, backend/db/migrations/..., backend/services/quiz_config.py, backend/services/quiz_errors.py, backend/tests/integration/test_quiz_lifecycle_db.py
Adds lifecycle columns, an open-attempt index, abandonment configuration, error codes, and database round-trip tests.
Status, resume, and history flow
backend/routes/quiz.py, backend/tests/test_quiz_lifecycle_d.py, backend/tests/integration/test_quiz_lifecycle_db.py
Adds status derivation, stale-attempt sweeping, secure question projection, resume retrieval, ownership checks, and paginated attempt history.
Submission persistence and achievement counting
backend/routes/quiz.py, backend/services/achievement_service.py, backend/tests/test_quiz_lifecycle_d.py
Rejects abandoned submissions, persists graph-derived mastery snapshots, and counts only completed attempts with scores.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant QuizRoutes
participant GraphUpdate
participant SupabaseQuizAttempts
Client->>QuizRoutes: Submit quiz attempt
QuizRoutes->>GraphUpdate: Apply mastery update
GraphUpdate-->>QuizRoutes: Return mastery values
QuizRoutes->>SupabaseQuizAttempts: Persist completion and snapshots
SupabaseQuizAttempts-->>QuizRoutes: Return completed attempt
QuizRoutes-->>Client: Return submission result
Loading

Possibly related PRs

Suggested reviewers:darkest-teddy

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/542-quiz-attempt-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…bandoned bite, harden history (#542)
Review findings (xhigh, 15 confirmed). The serious ones:
- The resume endpoint leaked the answer twice over: _strip_answer_key
dropped only per-option `correct` and shipped `explanation` (which
names the answer in prose), and being a DENYLIST it passed unknown
stored shapes straight through — the rich seed's legacy row exposes
its answer under `a` while getting an empty options list. It is an
ALLOWLIST now (id/question/concept_tested/difficulty + label/text),
and an unrecognised stored shape 409s QUIZ_ATTEMPT_NOT_RESUMABLE
rather than being projected at all.
- "Abandoned" was cosmetic: resume served the full question set and both
/answer and /submit accepted swept attempts, paying out mastery, XP and
achievements. Both write paths now 409 QUIZ_ATTEMPT_ABANDONED, and
resume returns no questions with resumable:false.
- quizzes_completed counted claimed-but-never-graded attempts, because
completed_at is stamped by the atomic claim BEFORE grading. It now also
requires a persisted score.
- The TTL keyed on created_at alone, so an attempt being actively
answered was swept. Status and sweep both consider the newest recorded
answer; the active attempt is exempted from the sweep.
- The stored mastery snapshot was submit's local prediction; it now
records what apply_graph_update actually wrote (it clamps and resolves
by concept name), so history can't show progression the graph refused.
- The sweep is a write on a GET: it now sends Prefer: return=minimal
(new db/connection.py option) instead of dragging every swept row —
encrypted blobs included — back on each history page load.
- history: offset clamped at the top (an unbounded value 500s as
bigint-out-of-range) and ordered created_at.desc,id.desc so rows
sharing a timestamp can't repeat or vanish across pages.
- _attempt_status parsed naive timestamps fine and then raised TypeError
comparing them to an aware cutoff, past the ValueError guard; a shared
_parse_ts assumes UTC for naive values.
- Migration DDL is idempotent (IF NOT EXISTS) per the repo rule, plus a
partial index for the sweep/history predicate.
Test gaps the review found: the history "no question payloads" assertion
was vacuous (it now asserts the SELECTED COLUMNS), and neither new GET
had ownership coverage — the hermetic lane structurally cannot provide it
(require_self is stubbed there), so the IDOR negatives live in the
integration lane with real sessions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit d071770 into mainAug 13, 2026
4 of 7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quiz D: attempt lifecycle — persist mastery snapshot, status + abandonment sweep, resume + paginated history, completed-only achievements

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542) - #550

Merged
AndresL230 merged 3 commits into
mainfrom
feat/542-quiz-attempt-lifecycle
Aug 13, 2026
Merged

feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542)#550
AndresL230 merged 3 commits into
mainfrom
feat/542-quiz-attempt-lifecycle

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Closes#542. Workstream D of the pre-revamp quiz repair batch (epic #537). The attempt lifecycle leaked orphans and leaked the answer key; the plaintext scalars kept for analytics in #521/#527 had no reader at all.

What

D1 — mastery snapshot persisted.mastery_before / mastery_after land on the attempt row at submit (migration 20260813013547, plaintext scalars per the #521 rationale). Without them a replayed or audited submit can't reconstruct what the student saw — the reason the #129 replay guard had to 409 instead of replaying the original 200.

D2 — explicit status, derived not stored.completed_atcompleted, abandoned_atabandoned, else in_progress; an in-progress row past QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented: a quiz is one sitting, but generous enough that stepping away for hours still resumes) reads as abandoned even before it's stamped. A lazy per-user sweep on the read paths stamps abandoned_at via conditional-update filters — same idiom as the submit claim, no scheduler. GET /api/quiz/attempts/{id} returns resume state: questions without the answer key plus the responses recorded through /answer.

D3 — achievements count completed attempts only.generate writes the attempt row before the student answers anything, so the unfiltered count let "generate and close the tab" advance quizzes_10.

Blast radius (measured on staging, 2026-08-12): 1 user, 2 attempts, 0 completed, quizzes_10 (threshold 10) never granted. Nobody loses a badge; one user's progress number drops 2 → 0. The achievement system is idempotent on (user_id, achievement_id) and grants are never revoked by this change — a recount can only withhold a badge not yet earned, and any already-granted badge stays. Prod recheck before merge is noted below.

D4 — GET /api/quiz/attempts. Paginated history for the signed-in user: concept, course, score, total, difficulty, mastery delta, dates. No question payloads, so no keys.

Wire-contract notes

All three endpoints are additive; no existing response shape changes. submit gains two persisted columns (response unchanged). No client code depends on the new routes yet — they exist for the #537 flow.

Verification

  • Hermetic suite: 1949 passed (7 new lifecycle tests, written first, watched fail).
  • Real-DB integration tests: lifecycle columns round-trip; the sweep touches the stale in-progress attempt and leaves a fresh one and a completed one alone (mocked tables can't see either).
  • Full local cycle under the stack lock: Playwright → oracles → integration → teardown.
  • ruff check clean. Migration applied to staging ahead of merge.

Prod check before promoting: re-run the D3 blast-radius query against production (SELECT count(*) FILTER (WHERE completed_at IS NULL) FROM quiz_attempts + granted quizzes_completed badges). Staging is clean; prod was not re-queried after this branch.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added quiz attempt history with pagination.
    • Added the ability to resume eligible in-progress quiz attempts.
    • Added automatic handling of stale attempts after 24 hours of inactivity.
    • Added clearer statuses and error messages for abandoned or non-resumable attempts.
    • Improved quiz completion tracking to count only fully completed and scored attempts.
  • Bug Fixes

    • Prevented answers and submissions from being added to abandoned attempts.
    • Protected answer keys from appearing in resume and history responses.

AndresL230and others added 2 commits August 12, 2026 22:07
…L sweep, resume, paginated history (#542)
Workstream D of the pre-revamp quiz repair batch (epic #537):
D1 — submit persists mastery_before/mastery_after on the attempt row
(migration 20260813013547; plaintext analytics scalars per #521), so a
replayed or audited submit can reconstruct what the student saw.
D2 — status is DERIVED, never stored: completed_at → completed,
abandoned_at → abandoned, else in_progress; an in-progress row past
QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented) reads as abandoned even
before the lazy per-user sweep stamps abandoned_at on the read paths
(no scheduler needed; conditional-update filters arbitrate).
GET /api/quiz/attempts/{id} returns resume state: questions WITHOUT the
answer key plus the responses recorded through /answer.
D3 — quizzes_completed counts completed attempts only; generate writes
the attempt row before the student answers anything, so the unfiltered
count let "generate and close the tab" advance quizzes_10. Blast radius
(measured on staging 2026-08-12): 1 user, 2 attempts, 0 completed,
badge never granted — nobody loses anything; prod recheck noted in the
PR. No revocation in any case.
D4 — GET /api/quiz/attempts: paginated history (concept, course, score,
total, difficulty, mastery delta, dates) — the plaintext scalars kept
for exactly this purpose in #521/#527 finally have a reader.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eep (#542)
The mastery snapshot and abandonment sweep are pure DB behaviour: a
mocked table() accepts columns the migration never added (#265 drift
class), and the conditional-update filters that decide WHICH attempts get
swept only mean something against Postgres. Asserts the columns
round-trip and that the sweep touches the stale in-progress attempt while
leaving a fresh one and a completed one alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 13, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging24a7203Commit Preview URL

Branch Preview URL
Aug 13 2026, 04:00 AM

@supabase

supabaseBot commented Aug 13, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5877551a-67b3-40a3-8246-bc43146465b9

📥 Commits

Reviewing files that changed from the base of the PR and between 73fb504 and 24a7203.

📒 Files selected for processing (8)
  • backend/db/connection.py
  • backend/db/migrations/20260813013547_quiz_attempts_lifecycle.sql
  • backend/routes/quiz.py
  • backend/services/achievement_service.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/integration/test_quiz_lifecycle_db.py
  • backend/tests/test_quiz_lifecycle_d.py

📝 Walkthrough

Walkthrough

Changes

Quiz attempt lifecycle

Layer / File(s)Summary
Lifecycle persistence and contracts
backend/db/connection.py, backend/db/migrations/..., backend/services/quiz_config.py, backend/services/quiz_errors.py, backend/tests/integration/test_quiz_lifecycle_db.py
Adds lifecycle columns, an open-attempt index, abandonment configuration, error codes, and database round-trip tests.
Status, resume, and history flow
backend/routes/quiz.py, backend/tests/test_quiz_lifecycle_d.py, backend/tests/integration/test_quiz_lifecycle_db.py
Adds status derivation, stale-attempt sweeping, secure question projection, resume retrieval, ownership checks, and paginated attempt history.
Submission persistence and achievement counting
backend/routes/quiz.py, backend/services/achievement_service.py, backend/tests/test_quiz_lifecycle_d.py
Rejects abandoned submissions, persists graph-derived mastery snapshots, and counts only completed attempts with scores.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant QuizRoutes
participant GraphUpdate
participant SupabaseQuizAttempts
Client->>QuizRoutes: Submit quiz attempt
QuizRoutes->>GraphUpdate: Apply mastery update
GraphUpdate-->>QuizRoutes: Return mastery values
QuizRoutes->>SupabaseQuizAttempts: Persist completion and snapshots
SupabaseQuizAttempts-->>QuizRoutes: Return completed attempt
QuizRoutes-->>Client: Return submission result
Loading

Possibly related PRs

Suggested reviewers:darkest-teddy

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/542-quiz-attempt-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…bandoned bite, harden history (#542)
Review findings (xhigh, 15 confirmed). The serious ones:
- The resume endpoint leaked the answer twice over: _strip_answer_key
dropped only per-option `correct` and shipped `explanation` (which
names the answer in prose), and being a DENYLIST it passed unknown
stored shapes straight through — the rich seed's legacy row exposes
its answer under `a` while getting an empty options list. It is an
ALLOWLIST now (id/question/concept_tested/difficulty + label/text),
and an unrecognised stored shape 409s QUIZ_ATTEMPT_NOT_RESUMABLE
rather than being projected at all.
- "Abandoned" was cosmetic: resume served the full question set and both
/answer and /submit accepted swept attempts, paying out mastery, XP and
achievements. Both write paths now 409 QUIZ_ATTEMPT_ABANDONED, and
resume returns no questions with resumable:false.
- quizzes_completed counted claimed-but-never-graded attempts, because
completed_at is stamped by the atomic claim BEFORE grading. It now also
requires a persisted score.
- The TTL keyed on created_at alone, so an attempt being actively
answered was swept. Status and sweep both consider the newest recorded
answer; the active attempt is exempted from the sweep.
- The stored mastery snapshot was submit's local prediction; it now
records what apply_graph_update actually wrote (it clamps and resolves
by concept name), so history can't show progression the graph refused.
- The sweep is a write on a GET: it now sends Prefer: return=minimal
(new db/connection.py option) instead of dragging every swept row —
encrypted blobs included — back on each history page load.
- history: offset clamped at the top (an unbounded value 500s as
bigint-out-of-range) and ordered created_at.desc,id.desc so rows
sharing a timestamp can't repeat or vanish across pages.
- _attempt_status parsed naive timestamps fine and then raised TypeError
comparing them to an aware cutoff, past the ValueError guard; a shared
_parse_ts assumes UTC for naive values.
- Migration DDL is idempotent (IF NOT EXISTS) per the repo rule, plus a
partial index for the sweep/history predicate.
Test gaps the review found: the history "no question payloads" assertion
was vacuous (it now asserts the SELECTED COLUMNS), and neither new GET
had ownership coverage — the hermetic lane structurally cannot provide it
(require_self is stubbed there), so the IDOR negatives live in the
integration lane with real sessions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit d071770 into mainAug 13, 2026
4 of 7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quiz D: attempt lifecycle — persist mastery snapshot, status + abandonment sweep, resume + paginated history, completed-only achievements

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542) - #550

Merged
AndresL230 merged 3 commits into
mainfrom
feat/542-quiz-attempt-lifecycle
Aug 13, 2026
Merged

feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542)#550
AndresL230 merged 3 commits into
mainfrom
feat/542-quiz-attempt-lifecycle

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Closes#542. Workstream D of the pre-revamp quiz repair batch (epic #537). The attempt lifecycle leaked orphans and leaked the answer key; the plaintext scalars kept for analytics in #521/#527 had no reader at all.

What

D1 — mastery snapshot persisted.mastery_before / mastery_after land on the attempt row at submit (migration 20260813013547, plaintext scalars per the #521 rationale). Without them a replayed or audited submit can't reconstruct what the student saw — the reason the #129 replay guard had to 409 instead of replaying the original 200.

D2 — explicit status, derived not stored.completed_atcompleted, abandoned_atabandoned, else in_progress; an in-progress row past QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented: a quiz is one sitting, but generous enough that stepping away for hours still resumes) reads as abandoned even before it's stamped. A lazy per-user sweep on the read paths stamps abandoned_at via conditional-update filters — same idiom as the submit claim, no scheduler. GET /api/quiz/attempts/{id} returns resume state: questions without the answer key plus the responses recorded through /answer.

D3 — achievements count completed attempts only.generate writes the attempt row before the student answers anything, so the unfiltered count let "generate and close the tab" advance quizzes_10.

Blast radius (measured on staging, 2026-08-12): 1 user, 2 attempts, 0 completed, quizzes_10 (threshold 10) never granted. Nobody loses a badge; one user's progress number drops 2 → 0. The achievement system is idempotent on (user_id, achievement_id) and grants are never revoked by this change — a recount can only withhold a badge not yet earned, and any already-granted badge stays. Prod recheck before merge is noted below.

D4 — GET /api/quiz/attempts. Paginated history for the signed-in user: concept, course, score, total, difficulty, mastery delta, dates. No question payloads, so no keys.

Wire-contract notes

All three endpoints are additive; no existing response shape changes. submit gains two persisted columns (response unchanged). No client code depends on the new routes yet — they exist for the #537 flow.

Verification

  • Hermetic suite: 1949 passed (7 new lifecycle tests, written first, watched fail).
  • Real-DB integration tests: lifecycle columns round-trip; the sweep touches the stale in-progress attempt and leaves a fresh one and a completed one alone (mocked tables can't see either).
  • Full local cycle under the stack lock: Playwright → oracles → integration → teardown.
  • ruff check clean. Migration applied to staging ahead of merge.

Prod check before promoting: re-run the D3 blast-radius query against production (SELECT count(*) FILTER (WHERE completed_at IS NULL) FROM quiz_attempts + granted quizzes_completed badges). Staging is clean; prod was not re-queried after this branch.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added quiz attempt history with pagination.
    • Added the ability to resume eligible in-progress quiz attempts.
    • Added automatic handling of stale attempts after 24 hours of inactivity.
    • Added clearer statuses and error messages for abandoned or non-resumable attempts.
    • Improved quiz completion tracking to count only fully completed and scored attempts.
  • Bug Fixes

    • Prevented answers and submissions from being added to abandoned attempts.
    • Protected answer keys from appearing in resume and history responses.

AndresL230and others added 2 commits August 12, 2026 22:07
…L sweep, resume, paginated history (#542)
Workstream D of the pre-revamp quiz repair batch (epic #537):
D1 — submit persists mastery_before/mastery_after on the attempt row
(migration 20260813013547; plaintext analytics scalars per #521), so a
replayed or audited submit can reconstruct what the student saw.
D2 — status is DERIVED, never stored: completed_at → completed,
abandoned_at → abandoned, else in_progress; an in-progress row past
QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented) reads as abandoned even
before the lazy per-user sweep stamps abandoned_at on the read paths
(no scheduler needed; conditional-update filters arbitrate).
GET /api/quiz/attempts/{id} returns resume state: questions WITHOUT the
answer key plus the responses recorded through /answer.
D3 — quizzes_completed counts completed attempts only; generate writes
the attempt row before the student answers anything, so the unfiltered
count let "generate and close the tab" advance quizzes_10. Blast radius
(measured on staging 2026-08-12): 1 user, 2 attempts, 0 completed,
badge never granted — nobody loses anything; prod recheck noted in the
PR. No revocation in any case.
D4 — GET /api/quiz/attempts: paginated history (concept, course, score,
total, difficulty, mastery delta, dates) — the plaintext scalars kept
for exactly this purpose in #521/#527 finally have a reader.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eep (#542)
The mastery snapshot and abandonment sweep are pure DB behaviour: a
mocked table() accepts columns the migration never added (#265 drift
class), and the conditional-update filters that decide WHICH attempts get
swept only mean something against Postgres. Asserts the columns
round-trip and that the sweep touches the stale in-progress attempt while
leaving a fresh one and a completed one alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 13, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging24a7203Commit Preview URL

Branch Preview URL
Aug 13 2026, 04:00 AM

@supabase

supabaseBot commented Aug 13, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5877551a-67b3-40a3-8246-bc43146465b9

📥 Commits

Reviewing files that changed from the base of the PR and between 73fb504 and 24a7203.

📒 Files selected for processing (8)
  • backend/db/connection.py
  • backend/db/migrations/20260813013547_quiz_attempts_lifecycle.sql
  • backend/routes/quiz.py
  • backend/services/achievement_service.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/integration/test_quiz_lifecycle_db.py
  • backend/tests/test_quiz_lifecycle_d.py

📝 Walkthrough

Walkthrough

Changes

Quiz attempt lifecycle

Layer / File(s)Summary
Lifecycle persistence and contracts
backend/db/connection.py, backend/db/migrations/..., backend/services/quiz_config.py, backend/services/quiz_errors.py, backend/tests/integration/test_quiz_lifecycle_db.py
Adds lifecycle columns, an open-attempt index, abandonment configuration, error codes, and database round-trip tests.
Status, resume, and history flow
backend/routes/quiz.py, backend/tests/test_quiz_lifecycle_d.py, backend/tests/integration/test_quiz_lifecycle_db.py
Adds status derivation, stale-attempt sweeping, secure question projection, resume retrieval, ownership checks, and paginated attempt history.
Submission persistence and achievement counting
backend/routes/quiz.py, backend/services/achievement_service.py, backend/tests/test_quiz_lifecycle_d.py
Rejects abandoned submissions, persists graph-derived mastery snapshots, and counts only completed attempts with scores.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant QuizRoutes
participant GraphUpdate
participant SupabaseQuizAttempts
Client->>QuizRoutes: Submit quiz attempt
QuizRoutes->>GraphUpdate: Apply mastery update
GraphUpdate-->>QuizRoutes: Return mastery values
QuizRoutes->>SupabaseQuizAttempts: Persist completion and snapshots
SupabaseQuizAttempts-->>QuizRoutes: Return completed attempt
QuizRoutes-->>Client: Return submission result
Loading

Possibly related PRs

Suggested reviewers:darkest-teddy

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/542-quiz-attempt-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…bandoned bite, harden history (#542)
Review findings (xhigh, 15 confirmed). The serious ones:
- The resume endpoint leaked the answer twice over: _strip_answer_key
dropped only per-option `correct` and shipped `explanation` (which
names the answer in prose), and being a DENYLIST it passed unknown
stored shapes straight through — the rich seed's legacy row exposes
its answer under `a` while getting an empty options list. It is an
ALLOWLIST now (id/question/concept_tested/difficulty + label/text),
and an unrecognised stored shape 409s QUIZ_ATTEMPT_NOT_RESUMABLE
rather than being projected at all.
- "Abandoned" was cosmetic: resume served the full question set and both
/answer and /submit accepted swept attempts, paying out mastery, XP and
achievements. Both write paths now 409 QUIZ_ATTEMPT_ABANDONED, and
resume returns no questions with resumable:false.
- quizzes_completed counted claimed-but-never-graded attempts, because
completed_at is stamped by the atomic claim BEFORE grading. It now also
requires a persisted score.
- The TTL keyed on created_at alone, so an attempt being actively
answered was swept. Status and sweep both consider the newest recorded
answer; the active attempt is exempted from the sweep.
- The stored mastery snapshot was submit's local prediction; it now
records what apply_graph_update actually wrote (it clamps and resolves
by concept name), so history can't show progression the graph refused.
- The sweep is a write on a GET: it now sends Prefer: return=minimal
(new db/connection.py option) instead of dragging every swept row —
encrypted blobs included — back on each history page load.
- history: offset clamped at the top (an unbounded value 500s as
bigint-out-of-range) and ordered created_at.desc,id.desc so rows
sharing a timestamp can't repeat or vanish across pages.
- _attempt_status parsed naive timestamps fine and then raised TypeError
comparing them to an aware cutoff, past the ValueError guard; a shared
_parse_ts assumes UTC for naive values.
- Migration DDL is idempotent (IF NOT EXISTS) per the repo rule, plus a
partial index for the sweep/history predicate.
Test gaps the review found: the history "no question payloads" assertion
was vacuous (it now asserts the SELECTED COLUMNS), and neither new GET
had ownership coverage — the hermetic lane structurally cannot provide it
(require_self is stubbed there), so the IDOR negatives live in the
integration lane with real sessions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit d071770 into mainAug 13, 2026
4 of 7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quiz D: attempt lifecycle — persist mastery snapshot, status + abandonment sweep, resume + paginated history, completed-only achievements

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542) - #550

Merged
AndresL230 merged 3 commits into
mainfrom
feat/542-quiz-attempt-lifecycle
Aug 13, 2026
Merged

feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542)#550
AndresL230 merged 3 commits into
mainfrom
feat/542-quiz-attempt-lifecycle

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Closes#542. Workstream D of the pre-revamp quiz repair batch (epic #537). The attempt lifecycle leaked orphans and leaked the answer key; the plaintext scalars kept for analytics in #521/#527 had no reader at all.

What

D1 — mastery snapshot persisted.mastery_before / mastery_after land on the attempt row at submit (migration 20260813013547, plaintext scalars per the #521 rationale). Without them a replayed or audited submit can't reconstruct what the student saw — the reason the #129 replay guard had to 409 instead of replaying the original 200.

D2 — explicit status, derived not stored.completed_atcompleted, abandoned_atabandoned, else in_progress; an in-progress row past QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented: a quiz is one sitting, but generous enough that stepping away for hours still resumes) reads as abandoned even before it's stamped. A lazy per-user sweep on the read paths stamps abandoned_at via conditional-update filters — same idiom as the submit claim, no scheduler. GET /api/quiz/attempts/{id} returns resume state: questions without the answer key plus the responses recorded through /answer.

D3 — achievements count completed attempts only.generate writes the attempt row before the student answers anything, so the unfiltered count let "generate and close the tab" advance quizzes_10.

Blast radius (measured on staging, 2026-08-12): 1 user, 2 attempts, 0 completed, quizzes_10 (threshold 10) never granted. Nobody loses a badge; one user's progress number drops 2 → 0. The achievement system is idempotent on (user_id, achievement_id) and grants are never revoked by this change — a recount can only withhold a badge not yet earned, and any already-granted badge stays. Prod recheck before merge is noted below.

D4 — GET /api/quiz/attempts. Paginated history for the signed-in user: concept, course, score, total, difficulty, mastery delta, dates. No question payloads, so no keys.

Wire-contract notes

All three endpoints are additive; no existing response shape changes. submit gains two persisted columns (response unchanged). No client code depends on the new routes yet — they exist for the #537 flow.

Verification

  • Hermetic suite: 1949 passed (7 new lifecycle tests, written first, watched fail).
  • Real-DB integration tests: lifecycle columns round-trip; the sweep touches the stale in-progress attempt and leaves a fresh one and a completed one alone (mocked tables can't see either).
  • Full local cycle under the stack lock: Playwright → oracles → integration → teardown.
  • ruff check clean. Migration applied to staging ahead of merge.

Prod check before promoting: re-run the D3 blast-radius query against production (SELECT count(*) FILTER (WHERE completed_at IS NULL) FROM quiz_attempts + granted quizzes_completed badges). Staging is clean; prod was not re-queried after this branch.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added quiz attempt history with pagination.
    • Added the ability to resume eligible in-progress quiz attempts.
    • Added automatic handling of stale attempts after 24 hours of inactivity.
    • Added clearer statuses and error messages for abandoned or non-resumable attempts.
    • Improved quiz completion tracking to count only fully completed and scored attempts.
  • Bug Fixes

    • Prevented answers and submissions from being added to abandoned attempts.
    • Protected answer keys from appearing in resume and history responses.

AndresL230and others added 2 commits August 12, 2026 22:07
…L sweep, resume, paginated history (#542)
Workstream D of the pre-revamp quiz repair batch (epic #537):
D1 — submit persists mastery_before/mastery_after on the attempt row
(migration 20260813013547; plaintext analytics scalars per #521), so a
replayed or audited submit can reconstruct what the student saw.
D2 — status is DERIVED, never stored: completed_at → completed,
abandoned_at → abandoned, else in_progress; an in-progress row past
QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented) reads as abandoned even
before the lazy per-user sweep stamps abandoned_at on the read paths
(no scheduler needed; conditional-update filters arbitrate).
GET /api/quiz/attempts/{id} returns resume state: questions WITHOUT the
answer key plus the responses recorded through /answer.
D3 — quizzes_completed counts completed attempts only; generate writes
the attempt row before the student answers anything, so the unfiltered
count let "generate and close the tab" advance quizzes_10. Blast radius
(measured on staging 2026-08-12): 1 user, 2 attempts, 0 completed,
badge never granted — nobody loses anything; prod recheck noted in the
PR. No revocation in any case.
D4 — GET /api/quiz/attempts: paginated history (concept, course, score,
total, difficulty, mastery delta, dates) — the plaintext scalars kept
for exactly this purpose in #521/#527 finally have a reader.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eep (#542)
The mastery snapshot and abandonment sweep are pure DB behaviour: a
mocked table() accepts columns the migration never added (#265 drift
class), and the conditional-update filters that decide WHICH attempts get
swept only mean something against Postgres. Asserts the columns
round-trip and that the sweep touches the stale in-progress attempt while
leaving a fresh one and a completed one alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 13, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging24a7203Commit Preview URL

Branch Preview URL
Aug 13 2026, 04:00 AM

@supabase

supabaseBot commented Aug 13, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5877551a-67b3-40a3-8246-bc43146465b9

📥 Commits

Reviewing files that changed from the base of the PR and between 73fb504 and 24a7203.

📒 Files selected for processing (8)
  • backend/db/connection.py
  • backend/db/migrations/20260813013547_quiz_attempts_lifecycle.sql
  • backend/routes/quiz.py
  • backend/services/achievement_service.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/integration/test_quiz_lifecycle_db.py
  • backend/tests/test_quiz_lifecycle_d.py

📝 Walkthrough

Walkthrough

Changes

Quiz attempt lifecycle

Layer / File(s)Summary
Lifecycle persistence and contracts
backend/db/connection.py, backend/db/migrations/..., backend/services/quiz_config.py, backend/services/quiz_errors.py, backend/tests/integration/test_quiz_lifecycle_db.py
Adds lifecycle columns, an open-attempt index, abandonment configuration, error codes, and database round-trip tests.
Status, resume, and history flow
backend/routes/quiz.py, backend/tests/test_quiz_lifecycle_d.py, backend/tests/integration/test_quiz_lifecycle_db.py
Adds status derivation, stale-attempt sweeping, secure question projection, resume retrieval, ownership checks, and paginated attempt history.
Submission persistence and achievement counting
backend/routes/quiz.py, backend/services/achievement_service.py, backend/tests/test_quiz_lifecycle_d.py
Rejects abandoned submissions, persists graph-derived mastery snapshots, and counts only completed attempts with scores.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant QuizRoutes
participant GraphUpdate
participant SupabaseQuizAttempts
Client->>QuizRoutes: Submit quiz attempt
QuizRoutes->>GraphUpdate: Apply mastery update
GraphUpdate-->>QuizRoutes: Return mastery values
QuizRoutes->>SupabaseQuizAttempts: Persist completion and snapshots
SupabaseQuizAttempts-->>QuizRoutes: Return completed attempt
QuizRoutes-->>Client: Return submission result
Loading

Possibly related PRs

Suggested reviewers:darkest-teddy

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/542-quiz-attempt-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…bandoned bite, harden history (#542)
Review findings (xhigh, 15 confirmed). The serious ones:
- The resume endpoint leaked the answer twice over: _strip_answer_key
dropped only per-option `correct` and shipped `explanation` (which
names the answer in prose), and being a DENYLIST it passed unknown
stored shapes straight through — the rich seed's legacy row exposes
its answer under `a` while getting an empty options list. It is an
ALLOWLIST now (id/question/concept_tested/difficulty + label/text),
and an unrecognised stored shape 409s QUIZ_ATTEMPT_NOT_RESUMABLE
rather than being projected at all.
- "Abandoned" was cosmetic: resume served the full question set and both
/answer and /submit accepted swept attempts, paying out mastery, XP and
achievements. Both write paths now 409 QUIZ_ATTEMPT_ABANDONED, and
resume returns no questions with resumable:false.
- quizzes_completed counted claimed-but-never-graded attempts, because
completed_at is stamped by the atomic claim BEFORE grading. It now also
requires a persisted score.
- The TTL keyed on created_at alone, so an attempt being actively
answered was swept. Status and sweep both consider the newest recorded
answer; the active attempt is exempted from the sweep.
- The stored mastery snapshot was submit's local prediction; it now
records what apply_graph_update actually wrote (it clamps and resolves
by concept name), so history can't show progression the graph refused.
- The sweep is a write on a GET: it now sends Prefer: return=minimal
(new db/connection.py option) instead of dragging every swept row —
encrypted blobs included — back on each history page load.
- history: offset clamped at the top (an unbounded value 500s as
bigint-out-of-range) and ordered created_at.desc,id.desc so rows
sharing a timestamp can't repeat or vanish across pages.
- _attempt_status parsed naive timestamps fine and then raised TypeError
comparing them to an aware cutoff, past the ValueError guard; a shared
_parse_ts assumes UTC for naive values.
- Migration DDL is idempotent (IF NOT EXISTS) per the repo rule, plus a
partial index for the sweep/history predicate.
Test gaps the review found: the history "no question payloads" assertion
was vacuous (it now asserts the SELECTED COLUMNS), and neither new GET
had ownership coverage — the hermetic lane structurally cannot provide it
(require_self is stubbed there), so the IDOR negatives live in the
integration lane with real sessions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit d071770 into mainAug 13, 2026
4 of 7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quiz D: attempt lifecycle — persist mastery snapshot, status + abandonment sweep, resume + paginated history, completed-only achievements

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542) - #550

Merged
AndresL230 merged 3 commits into
mainfrom
feat/542-quiz-attempt-lifecycle
Aug 13, 2026
Merged

feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542)#550
AndresL230 merged 3 commits into
mainfrom
feat/542-quiz-attempt-lifecycle

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Closes#542. Workstream D of the pre-revamp quiz repair batch (epic #537). The attempt lifecycle leaked orphans and leaked the answer key; the plaintext scalars kept for analytics in #521/#527 had no reader at all.

What

D1 — mastery snapshot persisted.mastery_before / mastery_after land on the attempt row at submit (migration 20260813013547, plaintext scalars per the #521 rationale). Without them a replayed or audited submit can't reconstruct what the student saw — the reason the #129 replay guard had to 409 instead of replaying the original 200.

D2 — explicit status, derived not stored.completed_atcompleted, abandoned_atabandoned, else in_progress; an in-progress row past QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented: a quiz is one sitting, but generous enough that stepping away for hours still resumes) reads as abandoned even before it's stamped. A lazy per-user sweep on the read paths stamps abandoned_at via conditional-update filters — same idiom as the submit claim, no scheduler. GET /api/quiz/attempts/{id} returns resume state: questions without the answer key plus the responses recorded through /answer.

D3 — achievements count completed attempts only.generate writes the attempt row before the student answers anything, so the unfiltered count let "generate and close the tab" advance quizzes_10.

Blast radius (measured on staging, 2026-08-12): 1 user, 2 attempts, 0 completed, quizzes_10 (threshold 10) never granted. Nobody loses a badge; one user's progress number drops 2 → 0. The achievement system is idempotent on (user_id, achievement_id) and grants are never revoked by this change — a recount can only withhold a badge not yet earned, and any already-granted badge stays. Prod recheck before merge is noted below.

D4 — GET /api/quiz/attempts. Paginated history for the signed-in user: concept, course, score, total, difficulty, mastery delta, dates. No question payloads, so no keys.

Wire-contract notes

All three endpoints are additive; no existing response shape changes. submit gains two persisted columns (response unchanged). No client code depends on the new routes yet — they exist for the #537 flow.

Verification

  • Hermetic suite: 1949 passed (7 new lifecycle tests, written first, watched fail).
  • Real-DB integration tests: lifecycle columns round-trip; the sweep touches the stale in-progress attempt and leaves a fresh one and a completed one alone (mocked tables can't see either).
  • Full local cycle under the stack lock: Playwright → oracles → integration → teardown.
  • ruff check clean. Migration applied to staging ahead of merge.

Prod check before promoting: re-run the D3 blast-radius query against production (SELECT count(*) FILTER (WHERE completed_at IS NULL) FROM quiz_attempts + granted quizzes_completed badges). Staging is clean; prod was not re-queried after this branch.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added quiz attempt history with pagination.
    • Added the ability to resume eligible in-progress quiz attempts.
    • Added automatic handling of stale attempts after 24 hours of inactivity.
    • Added clearer statuses and error messages for abandoned or non-resumable attempts.
    • Improved quiz completion tracking to count only fully completed and scored attempts.
  • Bug Fixes

    • Prevented answers and submissions from being added to abandoned attempts.
    • Protected answer keys from appearing in resume and history responses.

AndresL230and others added 2 commits August 12, 2026 22:07
…L sweep, resume, paginated history (#542)
Workstream D of the pre-revamp quiz repair batch (epic #537):
D1 — submit persists mastery_before/mastery_after on the attempt row
(migration 20260813013547; plaintext analytics scalars per #521), so a
replayed or audited submit can reconstruct what the student saw.
D2 — status is DERIVED, never stored: completed_at → completed,
abandoned_at → abandoned, else in_progress; an in-progress row past
QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented) reads as abandoned even
before the lazy per-user sweep stamps abandoned_at on the read paths
(no scheduler needed; conditional-update filters arbitrate).
GET /api/quiz/attempts/{id} returns resume state: questions WITHOUT the
answer key plus the responses recorded through /answer.
D3 — quizzes_completed counts completed attempts only; generate writes
the attempt row before the student answers anything, so the unfiltered
count let "generate and close the tab" advance quizzes_10. Blast radius
(measured on staging 2026-08-12): 1 user, 2 attempts, 0 completed,
badge never granted — nobody loses anything; prod recheck noted in the
PR. No revocation in any case.
D4 — GET /api/quiz/attempts: paginated history (concept, course, score,
total, difficulty, mastery delta, dates) — the plaintext scalars kept
for exactly this purpose in #521/#527 finally have a reader.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eep (#542)
The mastery snapshot and abandonment sweep are pure DB behaviour: a
mocked table() accepts columns the migration never added (#265 drift
class), and the conditional-update filters that decide WHICH attempts get
swept only mean something against Postgres. Asserts the columns
round-trip and that the sweep touches the stale in-progress attempt while
leaving a fresh one and a completed one alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 13, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging24a7203Commit Preview URL

Branch Preview URL
Aug 13 2026, 04:00 AM

@supabase

supabaseBot commented Aug 13, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5877551a-67b3-40a3-8246-bc43146465b9

📥 Commits

Reviewing files that changed from the base of the PR and between 73fb504 and 24a7203.

📒 Files selected for processing (8)
  • backend/db/connection.py
  • backend/db/migrations/20260813013547_quiz_attempts_lifecycle.sql
  • backend/routes/quiz.py
  • backend/services/achievement_service.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/integration/test_quiz_lifecycle_db.py
  • backend/tests/test_quiz_lifecycle_d.py

📝 Walkthrough

Walkthrough

Changes

Quiz attempt lifecycle

Layer / File(s)Summary
Lifecycle persistence and contracts
backend/db/connection.py, backend/db/migrations/..., backend/services/quiz_config.py, backend/services/quiz_errors.py, backend/tests/integration/test_quiz_lifecycle_db.py
Adds lifecycle columns, an open-attempt index, abandonment configuration, error codes, and database round-trip tests.
Status, resume, and history flow
backend/routes/quiz.py, backend/tests/test_quiz_lifecycle_d.py, backend/tests/integration/test_quiz_lifecycle_db.py
Adds status derivation, stale-attempt sweeping, secure question projection, resume retrieval, ownership checks, and paginated attempt history.
Submission persistence and achievement counting
backend/routes/quiz.py, backend/services/achievement_service.py, backend/tests/test_quiz_lifecycle_d.py
Rejects abandoned submissions, persists graph-derived mastery snapshots, and counts only completed attempts with scores.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant QuizRoutes
participant GraphUpdate
participant SupabaseQuizAttempts
Client->>QuizRoutes: Submit quiz attempt
QuizRoutes->>GraphUpdate: Apply mastery update
GraphUpdate-->>QuizRoutes: Return mastery values
QuizRoutes->>SupabaseQuizAttempts: Persist completion and snapshots
SupabaseQuizAttempts-->>QuizRoutes: Return completed attempt
QuizRoutes-->>Client: Return submission result
Loading

Possibly related PRs

Suggested reviewers:darkest-teddy

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/542-quiz-attempt-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…bandoned bite, harden history (#542)
Review findings (xhigh, 15 confirmed). The serious ones:
- The resume endpoint leaked the answer twice over: _strip_answer_key
dropped only per-option `correct` and shipped `explanation` (which
names the answer in prose), and being a DENYLIST it passed unknown
stored shapes straight through — the rich seed's legacy row exposes
its answer under `a` while getting an empty options list. It is an
ALLOWLIST now (id/question/concept_tested/difficulty + label/text),
and an unrecognised stored shape 409s QUIZ_ATTEMPT_NOT_RESUMABLE
rather than being projected at all.
- "Abandoned" was cosmetic: resume served the full question set and both
/answer and /submit accepted swept attempts, paying out mastery, XP and
achievements. Both write paths now 409 QUIZ_ATTEMPT_ABANDONED, and
resume returns no questions with resumable:false.
- quizzes_completed counted claimed-but-never-graded attempts, because
completed_at is stamped by the atomic claim BEFORE grading. It now also
requires a persisted score.
- The TTL keyed on created_at alone, so an attempt being actively
answered was swept. Status and sweep both consider the newest recorded
answer; the active attempt is exempted from the sweep.
- The stored mastery snapshot was submit's local prediction; it now
records what apply_graph_update actually wrote (it clamps and resolves
by concept name), so history can't show progression the graph refused.
- The sweep is a write on a GET: it now sends Prefer: return=minimal
(new db/connection.py option) instead of dragging every swept row —
encrypted blobs included — back on each history page load.
- history: offset clamped at the top (an unbounded value 500s as
bigint-out-of-range) and ordered created_at.desc,id.desc so rows
sharing a timestamp can't repeat or vanish across pages.
- _attempt_status parsed naive timestamps fine and then raised TypeError
comparing them to an aware cutoff, past the ValueError guard; a shared
_parse_ts assumes UTC for naive values.
- Migration DDL is idempotent (IF NOT EXISTS) per the repo rule, plus a
partial index for the sweep/history predicate.
Test gaps the review found: the history "no question payloads" assertion
was vacuous (it now asserts the SELECTED COLUMNS), and neither new GET
had ownership coverage — the hermetic lane structurally cannot provide it
(require_self is stubbed there), so the IDOR negatives live in the
integration lane with real sessions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit d071770 into mainAug 13, 2026
4 of 7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quiz D: attempt lifecycle — persist mastery snapshot, status + abandonment sweep, resume + paginated history, completed-only achievements

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542) - #550

Merged
AndresL230 merged 3 commits into
mainfrom
feat/542-quiz-attempt-lifecycle
Aug 13, 2026
Merged

feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542)#550
AndresL230 merged 3 commits into
mainfrom
feat/542-quiz-attempt-lifecycle

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Closes#542. Workstream D of the pre-revamp quiz repair batch (epic #537). The attempt lifecycle leaked orphans and leaked the answer key; the plaintext scalars kept for analytics in #521/#527 had no reader at all.

What

D1 — mastery snapshot persisted.mastery_before / mastery_after land on the attempt row at submit (migration 20260813013547, plaintext scalars per the #521 rationale). Without them a replayed or audited submit can't reconstruct what the student saw — the reason the #129 replay guard had to 409 instead of replaying the original 200.

D2 — explicit status, derived not stored.completed_atcompleted, abandoned_atabandoned, else in_progress; an in-progress row past QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented: a quiz is one sitting, but generous enough that stepping away for hours still resumes) reads as abandoned even before it's stamped. A lazy per-user sweep on the read paths stamps abandoned_at via conditional-update filters — same idiom as the submit claim, no scheduler. GET /api/quiz/attempts/{id} returns resume state: questions without the answer key plus the responses recorded through /answer.

D3 — achievements count completed attempts only.generate writes the attempt row before the student answers anything, so the unfiltered count let "generate and close the tab" advance quizzes_10.

Blast radius (measured on staging, 2026-08-12): 1 user, 2 attempts, 0 completed, quizzes_10 (threshold 10) never granted. Nobody loses a badge; one user's progress number drops 2 → 0. The achievement system is idempotent on (user_id, achievement_id) and grants are never revoked by this change — a recount can only withhold a badge not yet earned, and any already-granted badge stays. Prod recheck before merge is noted below.

D4 — GET /api/quiz/attempts. Paginated history for the signed-in user: concept, course, score, total, difficulty, mastery delta, dates. No question payloads, so no keys.

Wire-contract notes

All three endpoints are additive; no existing response shape changes. submit gains two persisted columns (response unchanged). No client code depends on the new routes yet — they exist for the #537 flow.

Verification

  • Hermetic suite: 1949 passed (7 new lifecycle tests, written first, watched fail).
  • Real-DB integration tests: lifecycle columns round-trip; the sweep touches the stale in-progress attempt and leaves a fresh one and a completed one alone (mocked tables can't see either).
  • Full local cycle under the stack lock: Playwright → oracles → integration → teardown.
  • ruff check clean. Migration applied to staging ahead of merge.

Prod check before promoting: re-run the D3 blast-radius query against production (SELECT count(*) FILTER (WHERE completed_at IS NULL) FROM quiz_attempts + granted quizzes_completed badges). Staging is clean; prod was not re-queried after this branch.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added quiz attempt history with pagination.
    • Added the ability to resume eligible in-progress quiz attempts.
    • Added automatic handling of stale attempts after 24 hours of inactivity.
    • Added clearer statuses and error messages for abandoned or non-resumable attempts.
    • Improved quiz completion tracking to count only fully completed and scored attempts.
  • Bug Fixes

    • Prevented answers and submissions from being added to abandoned attempts.
    • Protected answer keys from appearing in resume and history responses.

AndresL230and others added 2 commits August 12, 2026 22:07
…L sweep, resume, paginated history (#542)
Workstream D of the pre-revamp quiz repair batch (epic #537):
D1 — submit persists mastery_before/mastery_after on the attempt row
(migration 20260813013547; plaintext analytics scalars per #521), so a
replayed or audited submit can reconstruct what the student saw.
D2 — status is DERIVED, never stored: completed_at → completed,
abandoned_at → abandoned, else in_progress; an in-progress row past
QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented) reads as abandoned even
before the lazy per-user sweep stamps abandoned_at on the read paths
(no scheduler needed; conditional-update filters arbitrate).
GET /api/quiz/attempts/{id} returns resume state: questions WITHOUT the
answer key plus the responses recorded through /answer.
D3 — quizzes_completed counts completed attempts only; generate writes
the attempt row before the student answers anything, so the unfiltered
count let "generate and close the tab" advance quizzes_10. Blast radius
(measured on staging 2026-08-12): 1 user, 2 attempts, 0 completed,
badge never granted — nobody loses anything; prod recheck noted in the
PR. No revocation in any case.
D4 — GET /api/quiz/attempts: paginated history (concept, course, score,
total, difficulty, mastery delta, dates) — the plaintext scalars kept
for exactly this purpose in #521/#527 finally have a reader.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eep (#542)
The mastery snapshot and abandonment sweep are pure DB behaviour: a
mocked table() accepts columns the migration never added (#265 drift
class), and the conditional-update filters that decide WHICH attempts get
swept only mean something against Postgres. Asserts the columns
round-trip and that the sweep touches the stale in-progress attempt while
leaving a fresh one and a completed one alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 13, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging24a7203Commit Preview URL

Branch Preview URL
Aug 13 2026, 04:00 AM

@supabase

supabaseBot commented Aug 13, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5877551a-67b3-40a3-8246-bc43146465b9

📥 Commits

Reviewing files that changed from the base of the PR and between 73fb504 and 24a7203.

📒 Files selected for processing (8)
  • backend/db/connection.py
  • backend/db/migrations/20260813013547_quiz_attempts_lifecycle.sql
  • backend/routes/quiz.py
  • backend/services/achievement_service.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/integration/test_quiz_lifecycle_db.py
  • backend/tests/test_quiz_lifecycle_d.py

📝 Walkthrough

Walkthrough

Changes

Quiz attempt lifecycle

Layer / File(s)Summary
Lifecycle persistence and contracts
backend/db/connection.py, backend/db/migrations/..., backend/services/quiz_config.py, backend/services/quiz_errors.py, backend/tests/integration/test_quiz_lifecycle_db.py
Adds lifecycle columns, an open-attempt index, abandonment configuration, error codes, and database round-trip tests.
Status, resume, and history flow
backend/routes/quiz.py, backend/tests/test_quiz_lifecycle_d.py, backend/tests/integration/test_quiz_lifecycle_db.py
Adds status derivation, stale-attempt sweeping, secure question projection, resume retrieval, ownership checks, and paginated attempt history.
Submission persistence and achievement counting
backend/routes/quiz.py, backend/services/achievement_service.py, backend/tests/test_quiz_lifecycle_d.py
Rejects abandoned submissions, persists graph-derived mastery snapshots, and counts only completed attempts with scores.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant QuizRoutes
participant GraphUpdate
participant SupabaseQuizAttempts
Client->>QuizRoutes: Submit quiz attempt
QuizRoutes->>GraphUpdate: Apply mastery update
GraphUpdate-->>QuizRoutes: Return mastery values
QuizRoutes->>SupabaseQuizAttempts: Persist completion and snapshots
SupabaseQuizAttempts-->>QuizRoutes: Return completed attempt
QuizRoutes-->>Client: Return submission result
Loading

Possibly related PRs

Suggested reviewers:darkest-teddy

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/542-quiz-attempt-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…bandoned bite, harden history (#542)
Review findings (xhigh, 15 confirmed). The serious ones:
- The resume endpoint leaked the answer twice over: _strip_answer_key
dropped only per-option `correct` and shipped `explanation` (which
names the answer in prose), and being a DENYLIST it passed unknown
stored shapes straight through — the rich seed's legacy row exposes
its answer under `a` while getting an empty options list. It is an
ALLOWLIST now (id/question/concept_tested/difficulty + label/text),
and an unrecognised stored shape 409s QUIZ_ATTEMPT_NOT_RESUMABLE
rather than being projected at all.
- "Abandoned" was cosmetic: resume served the full question set and both
/answer and /submit accepted swept attempts, paying out mastery, XP and
achievements. Both write paths now 409 QUIZ_ATTEMPT_ABANDONED, and
resume returns no questions with resumable:false.
- quizzes_completed counted claimed-but-never-graded attempts, because
completed_at is stamped by the atomic claim BEFORE grading. It now also
requires a persisted score.
- The TTL keyed on created_at alone, so an attempt being actively
answered was swept. Status and sweep both consider the newest recorded
answer; the active attempt is exempted from the sweep.
- The stored mastery snapshot was submit's local prediction; it now
records what apply_graph_update actually wrote (it clamps and resolves
by concept name), so history can't show progression the graph refused.
- The sweep is a write on a GET: it now sends Prefer: return=minimal
(new db/connection.py option) instead of dragging every swept row —
encrypted blobs included — back on each history page load.
- history: offset clamped at the top (an unbounded value 500s as
bigint-out-of-range) and ordered created_at.desc,id.desc so rows
sharing a timestamp can't repeat or vanish across pages.
- _attempt_status parsed naive timestamps fine and then raised TypeError
comparing them to an aware cutoff, past the ValueError guard; a shared
_parse_ts assumes UTC for naive values.
- Migration DDL is idempotent (IF NOT EXISTS) per the repo rule, plus a
partial index for the sweep/history predicate.
Test gaps the review found: the history "no question payloads" assertion
was vacuous (it now asserts the SELECTED COLUMNS), and neither new GET
had ownership coverage — the hermetic lane structurally cannot provide it
(require_self is stubbed there), so the IDOR negatives live in the
integration lane with real sessions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit d071770 into mainAug 13, 2026
4 of 7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quiz D: attempt lifecycle — persist mastery snapshot, status + abandonment sweep, resume + paginated history, completed-only achievements

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542) - #550

Merged
AndresL230 merged 3 commits into
mainfrom
feat/542-quiz-attempt-lifecycle
Aug 13, 2026
Merged

feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542)#550
AndresL230 merged 3 commits into
mainfrom
feat/542-quiz-attempt-lifecycle

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Closes#542. Workstream D of the pre-revamp quiz repair batch (epic #537). The attempt lifecycle leaked orphans and leaked the answer key; the plaintext scalars kept for analytics in #521/#527 had no reader at all.

What

D1 — mastery snapshot persisted.mastery_before / mastery_after land on the attempt row at submit (migration 20260813013547, plaintext scalars per the #521 rationale). Without them a replayed or audited submit can't reconstruct what the student saw — the reason the #129 replay guard had to 409 instead of replaying the original 200.

D2 — explicit status, derived not stored.completed_atcompleted, abandoned_atabandoned, else in_progress; an in-progress row past QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented: a quiz is one sitting, but generous enough that stepping away for hours still resumes) reads as abandoned even before it's stamped. A lazy per-user sweep on the read paths stamps abandoned_at via conditional-update filters — same idiom as the submit claim, no scheduler. GET /api/quiz/attempts/{id} returns resume state: questions without the answer key plus the responses recorded through /answer.

D3 — achievements count completed attempts only.generate writes the attempt row before the student answers anything, so the unfiltered count let "generate and close the tab" advance quizzes_10.

Blast radius (measured on staging, 2026-08-12): 1 user, 2 attempts, 0 completed, quizzes_10 (threshold 10) never granted. Nobody loses a badge; one user's progress number drops 2 → 0. The achievement system is idempotent on (user_id, achievement_id) and grants are never revoked by this change — a recount can only withhold a badge not yet earned, and any already-granted badge stays. Prod recheck before merge is noted below.

D4 — GET /api/quiz/attempts. Paginated history for the signed-in user: concept, course, score, total, difficulty, mastery delta, dates. No question payloads, so no keys.

Wire-contract notes

All three endpoints are additive; no existing response shape changes. submit gains two persisted columns (response unchanged). No client code depends on the new routes yet — they exist for the #537 flow.

Verification

  • Hermetic suite: 1949 passed (7 new lifecycle tests, written first, watched fail).
  • Real-DB integration tests: lifecycle columns round-trip; the sweep touches the stale in-progress attempt and leaves a fresh one and a completed one alone (mocked tables can't see either).
  • Full local cycle under the stack lock: Playwright → oracles → integration → teardown.
  • ruff check clean. Migration applied to staging ahead of merge.

Prod check before promoting: re-run the D3 blast-radius query against production (SELECT count(*) FILTER (WHERE completed_at IS NULL) FROM quiz_attempts + granted quizzes_completed badges). Staging is clean; prod was not re-queried after this branch.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added quiz attempt history with pagination.
    • Added the ability to resume eligible in-progress quiz attempts.
    • Added automatic handling of stale attempts after 24 hours of inactivity.
    • Added clearer statuses and error messages for abandoned or non-resumable attempts.
    • Improved quiz completion tracking to count only fully completed and scored attempts.
  • Bug Fixes

    • Prevented answers and submissions from being added to abandoned attempts.
    • Protected answer keys from appearing in resume and history responses.

AndresL230and others added 2 commits August 12, 2026 22:07
…L sweep, resume, paginated history (#542)
Workstream D of the pre-revamp quiz repair batch (epic #537):
D1 — submit persists mastery_before/mastery_after on the attempt row
(migration 20260813013547; plaintext analytics scalars per #521), so a
replayed or audited submit can reconstruct what the student saw.
D2 — status is DERIVED, never stored: completed_at → completed,
abandoned_at → abandoned, else in_progress; an in-progress row past
QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented) reads as abandoned even
before the lazy per-user sweep stamps abandoned_at on the read paths
(no scheduler needed; conditional-update filters arbitrate).
GET /api/quiz/attempts/{id} returns resume state: questions WITHOUT the
answer key plus the responses recorded through /answer.
D3 — quizzes_completed counts completed attempts only; generate writes
the attempt row before the student answers anything, so the unfiltered
count let "generate and close the tab" advance quizzes_10. Blast radius
(measured on staging 2026-08-12): 1 user, 2 attempts, 0 completed,
badge never granted — nobody loses anything; prod recheck noted in the
PR. No revocation in any case.
D4 — GET /api/quiz/attempts: paginated history (concept, course, score,
total, difficulty, mastery delta, dates) — the plaintext scalars kept
for exactly this purpose in #521/#527 finally have a reader.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eep (#542)
The mastery snapshot and abandonment sweep are pure DB behaviour: a
mocked table() accepts columns the migration never added (#265 drift
class), and the conditional-update filters that decide WHICH attempts get
swept only mean something against Postgres. Asserts the columns
round-trip and that the sweep touches the stale in-progress attempt while
leaving a fresh one and a completed one alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 13, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging24a7203Commit Preview URL

Branch Preview URL
Aug 13 2026, 04:00 AM

@supabase

supabaseBot commented Aug 13, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5877551a-67b3-40a3-8246-bc43146465b9

📥 Commits

Reviewing files that changed from the base of the PR and between 73fb504 and 24a7203.

📒 Files selected for processing (8)
  • backend/db/connection.py
  • backend/db/migrations/20260813013547_quiz_attempts_lifecycle.sql
  • backend/routes/quiz.py
  • backend/services/achievement_service.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/integration/test_quiz_lifecycle_db.py
  • backend/tests/test_quiz_lifecycle_d.py

📝 Walkthrough

Walkthrough

Changes

Quiz attempt lifecycle

Layer / File(s)Summary
Lifecycle persistence and contracts
backend/db/connection.py, backend/db/migrations/..., backend/services/quiz_config.py, backend/services/quiz_errors.py, backend/tests/integration/test_quiz_lifecycle_db.py
Adds lifecycle columns, an open-attempt index, abandonment configuration, error codes, and database round-trip tests.
Status, resume, and history flow
backend/routes/quiz.py, backend/tests/test_quiz_lifecycle_d.py, backend/tests/integration/test_quiz_lifecycle_db.py
Adds status derivation, stale-attempt sweeping, secure question projection, resume retrieval, ownership checks, and paginated attempt history.
Submission persistence and achievement counting
backend/routes/quiz.py, backend/services/achievement_service.py, backend/tests/test_quiz_lifecycle_d.py
Rejects abandoned submissions, persists graph-derived mastery snapshots, and counts only completed attempts with scores.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant QuizRoutes
participant GraphUpdate
participant SupabaseQuizAttempts
Client->>QuizRoutes: Submit quiz attempt
QuizRoutes->>GraphUpdate: Apply mastery update
GraphUpdate-->>QuizRoutes: Return mastery values
QuizRoutes->>SupabaseQuizAttempts: Persist completion and snapshots
SupabaseQuizAttempts-->>QuizRoutes: Return completed attempt
QuizRoutes-->>Client: Return submission result
Loading

Possibly related PRs

Suggested reviewers:darkest-teddy

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/542-quiz-attempt-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…bandoned bite, harden history (#542)
Review findings (xhigh, 15 confirmed). The serious ones:
- The resume endpoint leaked the answer twice over: _strip_answer_key
dropped only per-option `correct` and shipped `explanation` (which
names the answer in prose), and being a DENYLIST it passed unknown
stored shapes straight through — the rich seed's legacy row exposes
its answer under `a` while getting an empty options list. It is an
ALLOWLIST now (id/question/concept_tested/difficulty + label/text),
and an unrecognised stored shape 409s QUIZ_ATTEMPT_NOT_RESUMABLE
rather than being projected at all.
- "Abandoned" was cosmetic: resume served the full question set and both
/answer and /submit accepted swept attempts, paying out mastery, XP and
achievements. Both write paths now 409 QUIZ_ATTEMPT_ABANDONED, and
resume returns no questions with resumable:false.
- quizzes_completed counted claimed-but-never-graded attempts, because
completed_at is stamped by the atomic claim BEFORE grading. It now also
requires a persisted score.
- The TTL keyed on created_at alone, so an attempt being actively
answered was swept. Status and sweep both consider the newest recorded
answer; the active attempt is exempted from the sweep.
- The stored mastery snapshot was submit's local prediction; it now
records what apply_graph_update actually wrote (it clamps and resolves
by concept name), so history can't show progression the graph refused.
- The sweep is a write on a GET: it now sends Prefer: return=minimal
(new db/connection.py option) instead of dragging every swept row —
encrypted blobs included — back on each history page load.
- history: offset clamped at the top (an unbounded value 500s as
bigint-out-of-range) and ordered created_at.desc,id.desc so rows
sharing a timestamp can't repeat or vanish across pages.
- _attempt_status parsed naive timestamps fine and then raised TypeError
comparing them to an aware cutoff, past the ValueError guard; a shared
_parse_ts assumes UTC for naive values.
- Migration DDL is idempotent (IF NOT EXISTS) per the repo rule, plus a
partial index for the sweep/history predicate.
Test gaps the review found: the history "no question payloads" assertion
was vacuous (it now asserts the SELECTED COLUMNS), and neither new GET
had ownership coverage — the hermetic lane structurally cannot provide it
(require_self is stubbed there), so the IDOR negatives live in the
integration lane with real sessions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit d071770 into mainAug 13, 2026
4 of 7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quiz D: attempt lifecycle — persist mastery snapshot, status + abandonment sweep, resume + paginated history, completed-only achievements

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542) - #550

Merged
AndresL230 merged 3 commits into
mainfrom
feat/542-quiz-attempt-lifecycle
Aug 13, 2026
Merged

feat(quiz): attempt lifecycle — mastery snapshot, derived status + TTL sweep, resume, paginated history (#542)#550
AndresL230 merged 3 commits into
mainfrom
feat/542-quiz-attempt-lifecycle

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Closes#542. Workstream D of the pre-revamp quiz repair batch (epic #537). The attempt lifecycle leaked orphans and leaked the answer key; the plaintext scalars kept for analytics in #521/#527 had no reader at all.

What

D1 — mastery snapshot persisted.mastery_before / mastery_after land on the attempt row at submit (migration 20260813013547, plaintext scalars per the #521 rationale). Without them a replayed or audited submit can't reconstruct what the student saw — the reason the #129 replay guard had to 409 instead of replaying the original 200.

D2 — explicit status, derived not stored.completed_atcompleted, abandoned_atabandoned, else in_progress; an in-progress row past QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented: a quiz is one sitting, but generous enough that stepping away for hours still resumes) reads as abandoned even before it's stamped. A lazy per-user sweep on the read paths stamps abandoned_at via conditional-update filters — same idiom as the submit claim, no scheduler. GET /api/quiz/attempts/{id} returns resume state: questions without the answer key plus the responses recorded through /answer.

D3 — achievements count completed attempts only.generate writes the attempt row before the student answers anything, so the unfiltered count let "generate and close the tab" advance quizzes_10.

Blast radius (measured on staging, 2026-08-12): 1 user, 2 attempts, 0 completed, quizzes_10 (threshold 10) never granted. Nobody loses a badge; one user's progress number drops 2 → 0. The achievement system is idempotent on (user_id, achievement_id) and grants are never revoked by this change — a recount can only withhold a badge not yet earned, and any already-granted badge stays. Prod recheck before merge is noted below.

D4 — GET /api/quiz/attempts. Paginated history for the signed-in user: concept, course, score, total, difficulty, mastery delta, dates. No question payloads, so no keys.

Wire-contract notes

All three endpoints are additive; no existing response shape changes. submit gains two persisted columns (response unchanged). No client code depends on the new routes yet — they exist for the #537 flow.

Verification

  • Hermetic suite: 1949 passed (7 new lifecycle tests, written first, watched fail).
  • Real-DB integration tests: lifecycle columns round-trip; the sweep touches the stale in-progress attempt and leaves a fresh one and a completed one alone (mocked tables can't see either).
  • Full local cycle under the stack lock: Playwright → oracles → integration → teardown.
  • ruff check clean. Migration applied to staging ahead of merge.

Prod check before promoting: re-run the D3 blast-radius query against production (SELECT count(*) FILTER (WHERE completed_at IS NULL) FROM quiz_attempts + granted quizzes_completed badges). Staging is clean; prod was not re-queried after this branch.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added quiz attempt history with pagination.
    • Added the ability to resume eligible in-progress quiz attempts.
    • Added automatic handling of stale attempts after 24 hours of inactivity.
    • Added clearer statuses and error messages for abandoned or non-resumable attempts.
    • Improved quiz completion tracking to count only fully completed and scored attempts.
  • Bug Fixes

    • Prevented answers and submissions from being added to abandoned attempts.
    • Protected answer keys from appearing in resume and history responses.

AndresL230and others added 2 commits August 12, 2026 22:07
…L sweep, resume, paginated history (#542)
Workstream D of the pre-revamp quiz repair batch (epic #537):
D1 — submit persists mastery_before/mastery_after on the attempt row
(migration 20260813013547; plaintext analytics scalars per #521), so a
replayed or audited submit can reconstruct what the student saw.
D2 — status is DERIVED, never stored: completed_at → completed,
abandoned_at → abandoned, else in_progress; an in-progress row past
QUIZ_ATTEMPT_ABANDON_TTL_HOURS (24h, documented) reads as abandoned even
before the lazy per-user sweep stamps abandoned_at on the read paths
(no scheduler needed; conditional-update filters arbitrate).
GET /api/quiz/attempts/{id} returns resume state: questions WITHOUT the
answer key plus the responses recorded through /answer.
D3 — quizzes_completed counts completed attempts only; generate writes
the attempt row before the student answers anything, so the unfiltered
count let "generate and close the tab" advance quizzes_10. Blast radius
(measured on staging 2026-08-12): 1 user, 2 attempts, 0 completed,
badge never granted — nobody loses anything; prod recheck noted in the
PR. No revocation in any case.
D4 — GET /api/quiz/attempts: paginated history (concept, course, score,
total, difficulty, mastery delta, dates) — the plaintext scalars kept
for exactly this purpose in #521/#527 finally have a reader.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eep (#542)
The mastery snapshot and abandonment sweep are pure DB behaviour: a
mocked table() accepts columns the migration never added (#265 drift
class), and the conditional-update filters that decide WHICH attempts get
swept only mean something against Postgres. Asserts the columns
round-trip and that the sweep touches the stale in-progress attempt while
leaving a fresh one and a completed one alone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 13, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging24a7203Commit Preview URL

Branch Preview URL
Aug 13 2026, 04:00 AM

@supabase

supabaseBot commented Aug 13, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@coderabbitai

coderabbitaiBot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5877551a-67b3-40a3-8246-bc43146465b9

📥 Commits

Reviewing files that changed from the base of the PR and between 73fb504 and 24a7203.

📒 Files selected for processing (8)
  • backend/db/connection.py
  • backend/db/migrations/20260813013547_quiz_attempts_lifecycle.sql
  • backend/routes/quiz.py
  • backend/services/achievement_service.py
  • backend/services/quiz_config.py
  • backend/services/quiz_errors.py
  • backend/tests/integration/test_quiz_lifecycle_db.py
  • backend/tests/test_quiz_lifecycle_d.py

📝 Walkthrough

Walkthrough

Changes

Quiz attempt lifecycle

Layer / File(s)Summary
Lifecycle persistence and contracts
backend/db/connection.py, backend/db/migrations/..., backend/services/quiz_config.py, backend/services/quiz_errors.py, backend/tests/integration/test_quiz_lifecycle_db.py
Adds lifecycle columns, an open-attempt index, abandonment configuration, error codes, and database round-trip tests.
Status, resume, and history flow
backend/routes/quiz.py, backend/tests/test_quiz_lifecycle_d.py, backend/tests/integration/test_quiz_lifecycle_db.py
Adds status derivation, stale-attempt sweeping, secure question projection, resume retrieval, ownership checks, and paginated attempt history.
Submission persistence and achievement counting
backend/routes/quiz.py, backend/services/achievement_service.py, backend/tests/test_quiz_lifecycle_d.py
Rejects abandoned submissions, persists graph-derived mastery snapshots, and counts only completed attempts with scores.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
participant Client
participant QuizRoutes
participant GraphUpdate
participant SupabaseQuizAttempts
Client->>QuizRoutes: Submit quiz attempt
QuizRoutes->>GraphUpdate: Apply mastery update
GraphUpdate-->>QuizRoutes: Return mastery values
QuizRoutes->>SupabaseQuizAttempts: Persist completion and snapshots
SupabaseQuizAttempts-->>QuizRoutes: Return completed attempt
QuizRoutes-->>Client: Return submission result
Loading

Possibly related PRs

Suggested reviewers:darkest-teddy

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/542-quiz-attempt-lifecycle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…bandoned bite, harden history (#542)
Review findings (xhigh, 15 confirmed). The serious ones:
- The resume endpoint leaked the answer twice over: _strip_answer_key
dropped only per-option `correct` and shipped `explanation` (which
names the answer in prose), and being a DENYLIST it passed unknown
stored shapes straight through — the rich seed's legacy row exposes
its answer under `a` while getting an empty options list. It is an
ALLOWLIST now (id/question/concept_tested/difficulty + label/text),
and an unrecognised stored shape 409s QUIZ_ATTEMPT_NOT_RESUMABLE
rather than being projected at all.
- "Abandoned" was cosmetic: resume served the full question set and both
/answer and /submit accepted swept attempts, paying out mastery, XP and
achievements. Both write paths now 409 QUIZ_ATTEMPT_ABANDONED, and
resume returns no questions with resumable:false.
- quizzes_completed counted claimed-but-never-graded attempts, because
completed_at is stamped by the atomic claim BEFORE grading. It now also
requires a persisted score.
- The TTL keyed on created_at alone, so an attempt being actively
answered was swept. Status and sweep both consider the newest recorded
answer; the active attempt is exempted from the sweep.
- The stored mastery snapshot was submit's local prediction; it now
records what apply_graph_update actually wrote (it clamps and resolves
by concept name), so history can't show progression the graph refused.
- The sweep is a write on a GET: it now sends Prefer: return=minimal
(new db/connection.py option) instead of dragging every swept row —
encrypted blobs included — back on each history page load.
- history: offset clamped at the top (an unbounded value 500s as
bigint-out-of-range) and ordered created_at.desc,id.desc so rows
sharing a timestamp can't repeat or vanish across pages.
- _attempt_status parsed naive timestamps fine and then raised TypeError
comparing them to an aware cutoff, past the ValueError guard; a shared
_parse_ts assumes UTC for naive values.
- Migration DDL is idempotent (IF NOT EXISTS) per the repo rule, plus a
partial index for the sweep/history predicate.
Test gaps the review found: the history "no question payloads" assertion
was vacuous (it now asserts the SELECTED COLUMNS), and neither new GET
had ownership coverage — the hermetic lane structurally cannot provide it
(require_self is stubbed there), so the IDOR negatives live in the
integration lane with real sessions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AndresL230
AndresL230 merged commit d071770 into mainAug 13, 2026
4 of 7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

quiz D: attempt lifecycle — persist mastery snapshot, status + abandonment sweep, resume + paginated history, completed-only achievements

1 participant

@AndresL230