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:
parent
84c899a814
commit
d44ad883e9
2 changed files with 79 additions and 47 deletions
|
|
@ -1057,6 +1057,43 @@ def get_scorm_status(
|
||||||
return {"completed": progress.status == "completed" if progress else False}
|
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 ───────────────────────────────────────────────────────
|
# ── Enrollment ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/{course_id}/enrollees")
|
@router.get("/{course_id}/enrollees")
|
||||||
|
|
|
||||||
|
|
@ -9,73 +9,68 @@ import 'katex/dist/katex.min.css'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
|
|
||||||
// ── SCORM Player with API shim ──────────────────────────────────────
|
// ── SCORM Player with API shim + persistence ───────────────────────
|
||||||
function ScormPlayer({ src, title, lessonId, courseId, onComplete }) {
|
function ScormPlayer({ src, title, lessonId, courseId, onComplete }) {
|
||||||
const iframeRef = useRef(null)
|
const iframeRef = useRef(null)
|
||||||
const dataRef = useRef({})
|
const dataRef = useRef({})
|
||||||
const completedRef = useRef(false)
|
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 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
|
// SCORM 1.2 API
|
||||||
const API = {
|
window.API = {
|
||||||
LMSInitialize: () => { return "true" },
|
LMSInitialize: () => "true",
|
||||||
LMSFinish: () => {
|
LMSFinish: () => { persist(); checkComplete(); return "true" },
|
||||||
const status = data["cmi.core.lesson_status"]
|
LMSGetValue: (key) => data[key] ?? "",
|
||||||
if ((status === "completed" || status === "passed") && !completedRef.current) {
|
|
||||||
completedRef.current = true
|
|
||||||
onComplete?.()
|
|
||||||
}
|
|
||||||
return "true"
|
|
||||||
},
|
|
||||||
LMSGetValue: (key) => data[key] || "",
|
|
||||||
LMSSetValue: (key, val) => { data[key] = val; return "true" },
|
LMSSetValue: (key, val) => { data[key] = val; return "true" },
|
||||||
LMSCommit: () => {
|
LMSCommit: () => { persist(); checkComplete(); return "true" },
|
||||||
const status = data["cmi.core.lesson_status"]
|
|
||||||
if ((status === "completed" || status === "passed") && !completedRef.current) {
|
|
||||||
completedRef.current = true
|
|
||||||
onComplete?.()
|
|
||||||
}
|
|
||||||
return "true"
|
|
||||||
},
|
|
||||||
LMSGetLastError: () => "0",
|
LMSGetLastError: () => "0",
|
||||||
LMSGetErrorString: () => "No error",
|
LMSGetErrorString: () => "No error",
|
||||||
LMSGetDiagnostic: () => "No diagnostic",
|
LMSGetDiagnostic: () => "No diagnostic",
|
||||||
}
|
}
|
||||||
|
|
||||||
// SCORM 2004 API
|
// SCORM 2004 API
|
||||||
const API_1484_11 = {
|
window.API_1484_11 = {
|
||||||
Initialize: () => "true",
|
Initialize: () => "true",
|
||||||
Terminate: () => {
|
Terminate: () => { persist(); checkComplete(); return "true" },
|
||||||
const status = data["cmi.completion_status"]
|
GetValue: (key) => data[key] ?? "",
|
||||||
if ((status === "completed") && !completedRef.current) {
|
|
||||||
completedRef.current = true
|
|
||||||
onComplete?.()
|
|
||||||
}
|
|
||||||
return "true"
|
|
||||||
},
|
|
||||||
GetValue: (key) => data[key] || "",
|
|
||||||
SetValue: (key, val) => { data[key] = val; return "true" },
|
SetValue: (key, val) => { data[key] = val; return "true" },
|
||||||
Commit: () => {
|
Commit: () => { persist(); checkComplete(); return "true" },
|
||||||
const status = data["cmi.completion_status"]
|
|
||||||
if ((status === "completed") && !completedRef.current) {
|
|
||||||
completedRef.current = true
|
|
||||||
onComplete?.()
|
|
||||||
}
|
|
||||||
return "true"
|
|
||||||
},
|
|
||||||
GetLastError: () => "0",
|
GetLastError: () => "0",
|
||||||
GetErrorString: () => "No error",
|
GetErrorString: () => "No error",
|
||||||
GetDiagnostic: () => "No diagnostic",
|
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(() => {
|
return () => { persist(); delete window.API; delete window.API_1484_11 }
|
||||||
setupApi()
|
}, [ready, persist, checkComplete])
|
||||||
return () => { delete window.API; delete window.API_1484_11 }
|
|
||||||
}, [setupApi])
|
if (!ready) return <div className="loading"><div className="spinner" /> Loading SCORM content...</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<iframe
|
<iframe
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue