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}
|
||||
|
||||
|
||||
@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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue