Add SCORM data persistence across sessions

SCORM runtime data (bookmarks, scores, lesson position) now saved to
Redis via GET/PUT /courses/{id}/lessons/{id}/scorm/data endpoints.
Data loads before iframe renders and saves on Commit/Finish/Terminate.
90-day TTL on stored data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-11 01:53:53 +02:00
parent 84c899a814
commit d44ad883e9
2 changed files with 79 additions and 47 deletions

View file

@ -1057,6 +1057,43 @@ def get_scorm_status(
return {"completed": progress.status == "completed" if progress else False}
@router.get("/{course_id}/lessons/{lesson_id}/scorm/data")
def get_scorm_data(
course_id: int,
lesson_id: int,
current_user: User = Depends(get_current_user),
):
"""Get saved SCORM runtime data for this user/lesson."""
import json
try:
import redis as redis_lib
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
key = f"scorm_data:{current_user.id}:{course_id}:{lesson_id}"
data = r.get(key)
return json.loads(data) if data else {}
except Exception:
return {}
@router.put("/{course_id}/lessons/{lesson_id}/scorm/data")
def save_scorm_data(
course_id: int,
lesson_id: int,
data: dict,
current_user: User = Depends(get_current_user),
):
"""Save SCORM runtime data for this user/lesson. Persists bookmarks, scores, and state."""
import json
try:
import redis as redis_lib
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
key = f"scorm_data:{current_user.id}:{course_id}:{lesson_id}"
r.set(key, json.dumps(data), ex=60 * 60 * 24 * 90) # 90 day expiry
return {"saved": True}
except Exception:
raise HTTPException(status_code=500, detail="Failed to save SCORM data")
# ── Enrollment ───────────────────────────────────────────────────────
@router.get("/{course_id}/enrollees")

View file

@ -9,73 +9,68 @@ import 'katex/dist/katex.min.css'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
// SCORM Player with API shim
// SCORM Player with API shim + persistence
function ScormPlayer({ src, title, lessonId, courseId, onComplete }) {
const iframeRef = useRef(null)
const dataRef = useRef({})
const completedRef = useRef(false)
const [ready, setReady] = useState(false)
const setupApi = useCallback(() => {
// Load saved SCORM data from backend before exposing API
useEffect(() => {
api.get(`/courses/${courseId}/lessons/${lessonId}/scorm/data`)
.then(res => { dataRef.current = res.data || {} })
.catch(() => {})
.finally(() => setReady(true))
}, [courseId, lessonId])
// Save SCORM data to backend (debounced called on Commit/Finish/Terminate)
const persist = useCallback(() => {
api.put(`/courses/${courseId}/lessons/${lessonId}/scorm/data`, dataRef.current).catch(() => {})
}, [courseId, lessonId])
const checkComplete = useCallback(() => {
const data = dataRef.current
const s12 = data["cmi.core.lesson_status"]
const s04 = data["cmi.completion_status"]
if ((s12 === "completed" || s12 === "passed" || s04 === "completed") && !completedRef.current) {
completedRef.current = true
onComplete?.()
}
}, [onComplete])
useEffect(() => {
if (!ready) return
const data = dataRef.current
// SCORM 1.2 API
const API = {
LMSInitialize: () => { return "true" },
LMSFinish: () => {
const status = data["cmi.core.lesson_status"]
if ((status === "completed" || status === "passed") && !completedRef.current) {
completedRef.current = true
onComplete?.()
}
return "true"
},
LMSGetValue: (key) => data[key] || "",
window.API = {
LMSInitialize: () => "true",
LMSFinish: () => { persist(); checkComplete(); return "true" },
LMSGetValue: (key) => data[key] ?? "",
LMSSetValue: (key, val) => { data[key] = val; return "true" },
LMSCommit: () => {
const status = data["cmi.core.lesson_status"]
if ((status === "completed" || status === "passed") && !completedRef.current) {
completedRef.current = true
onComplete?.()
}
return "true"
},
LMSCommit: () => { persist(); checkComplete(); return "true" },
LMSGetLastError: () => "0",
LMSGetErrorString: () => "No error",
LMSGetDiagnostic: () => "No diagnostic",
}
// SCORM 2004 API
const API_1484_11 = {
window.API_1484_11 = {
Initialize: () => "true",
Terminate: () => {
const status = data["cmi.completion_status"]
if ((status === "completed") && !completedRef.current) {
completedRef.current = true
onComplete?.()
}
return "true"
},
GetValue: (key) => data[key] || "",
Terminate: () => { persist(); checkComplete(); return "true" },
GetValue: (key) => data[key] ?? "",
SetValue: (key, val) => { data[key] = val; return "true" },
Commit: () => {
const status = data["cmi.completion_status"]
if ((status === "completed") && !completedRef.current) {
completedRef.current = true
onComplete?.()
}
return "true"
},
Commit: () => { persist(); checkComplete(); return "true" },
GetLastError: () => "0",
GetErrorString: () => "No error",
GetDiagnostic: () => "No diagnostic",
}
// Expose on window so iframe content can find it by walking up parent frames
window.API = API
window.API_1484_11 = API_1484_11
}, [onComplete])
useEffect(() => {
setupApi()
return () => { delete window.API; delete window.API_1484_11 }
}, [setupApi])
return () => { persist(); delete window.API; delete window.API_1484_11 }
}, [ready, persist, checkComplete])
if (!ready) return <div className="loading"><div className="spinner" /> Loading SCORM content...</div>
return (
<iframe