fix: show lesson progress on sub-home /bab pages

The /bab/<folder> endpoint returned raw get_sub_home_data() output, so
lessons had no completed/locked fields and green checkmarks never
appeared. api_bab now reads the student token, injects progress via
get_ordered_lessons_with_learning_objectives (scoped to the folder's
sub-home.md), and computes lock status from prerequisites — matching
/lessons. The bab page load also forwards the student token, and tests
cover progress injection and prerequisite locking.
This commit is contained in:
a2nr 2026-08-14 05:10:59 +07:00
parent 67ed58a327
commit 432a8c2eca
5 changed files with 122 additions and 2 deletions

View File

@ -67,6 +67,7 @@ Parses Markdown files to extract content and configuration.
- `def get_lessons(source_path=None):` Returns lessons listed in the `Available_Lessons` section of `home.md` (or of `source_path` when given, e.g. a `sub-home.md`).
- `def get_ordered_lessons_with_learning_objectives(progress=None, source_path=None):` Returns lessons ordered as they appear in `home.md` (or `source_path`), optionally injected with user progress status.
- `def find_sub_home_for_lesson(file_path):` Returns `(sub_home_path, folder_name)` when the lesson's folder (one level inside `content/`) has a `sub-home.md`, else `(None, None)`.
- `def get_sub_home_path(folder_name):` Returns the absolute path to a folder's `sub-home.md` (or `None`), using the same `CONTENT_DIR` as `get_sub_home_data`.
- `def get_sub_home_data(folder_name):` Parses a folder's `sub-home.md` (title, intro HTML, lesson list) with an mtime-based cache so edits to the file are picked up without restart.
- `def render_markdown_content(file_path):` The core parsing function. Uses regex to extract markers like `---INITIAL_CODE---`, `---VELXIO_CIRCUIT---`, etc. It identifies the `active_tabs` needed for the frontend.
- `def _parse_flashcards(text):` Specifically parses `---QUIZ_FLASHCARD---` blocks into a structured JSON array for the frontend MCQ/Flashcard component.

View File

@ -3,7 +3,11 @@ import type { Lesson } from '$types/lesson';
import { error } from '@sveltejs/kit';
export const load: PageLoad = async ({ params, fetch }) => {
const res = await fetch(`/api/bab/${params.folder}`);
const token = typeof window !== 'undefined'
? localStorage.getItem('student_token') ?? ''
: '';
const query = token ? `?token=${encodeURIComponent(token)}` : '';
const res = await fetch(`/api/bab/${params.folder}${query}`);
if (!res.ok) {
throw error(404, 'Bab not found');
}

View File

@ -8,10 +8,11 @@ from flask import Blueprint, request, jsonify, send_from_directory
from werkzeug.utils import secure_filename
from compiler import compiler_factory
from config import CONTENT_DIR, ASSETS_DIR
from config import ASSETS_DIR
from services.lesson_service import (
get_ordered_lessons_with_learning_objectives,
get_sub_home_data,
get_sub_home_path,
find_sub_home_for_lesson,
render_markdown_content,
render_home_content,
@ -58,6 +59,30 @@ def api_bab(folder):
data = get_sub_home_data(folder)
if not data:
return jsonify({'error': 'Bab not found'}), 404
# Suntik progress & lock status per lesson (sama seperti /lessons) agar
# centang hijau & ikon kunci muncul di halaman bab.
token = request.args.get('token', '') or request.cookies.get('student_token', '')
progress = None
if token:
progress = get_student_progress(token)
source_path = get_sub_home_path(folder)
ordered = get_ordered_lessons_with_learning_objectives(
progress,
source_path=source_path if source_path else None,
)
for lesson in ordered:
prereqs = lesson.get('prerequisites', [])
is_locked = False
if prereqs:
for p_slug in prereqs:
if not progress or progress.get(p_slug) != 'completed':
is_locked = True
break
lesson['locked'] = is_locked
data['lessons'] = ordered
return jsonify(data)

View File

@ -384,6 +384,20 @@ def find_sub_home_for_lesson(file_path):
return sub_home_path, folder_name
def get_sub_home_path(folder_name):
"""Absolute path to a folder's sub-home.md, or None if missing.
Uses the same CONTENT_DIR as get_sub_home_data() so callers can feed the
result back into get_ordered_lessons_with_learning_objectives(source_path=...)
without re-deriving the path (and without mismatched CONTENT_DIR copies).
"""
folder_path = os.path.join(CONTENT_DIR, folder_name)
sub_home_path = os.path.join(folder_path, 'sub-home.md')
if os.path.isdir(folder_path) and os.path.exists(sub_home_path):
return sub_home_path
return None
def get_sub_home_data(folder_name):
"""Return parsed sub-home data for a given folder name (mtime-cached).

View File

@ -106,3 +106,79 @@ def test_lesson_prev_next_within_sub_home(client, content_dir):
data = resp.get_json()
assert data["prev_lesson"]["filename"] == "hello_world.md"
assert data["next_lesson"]["filename"] == "percabangan.md"
def test_bab_lessons_have_progress_fields(client, content_dir, monkeypatch):
from routes import lessons as lessons_routes
# Tanpa token → semua lesson punya field completed/locked, belum ada yang selesai
resp = client.get("/bab/bab1")
assert resp.status_code == 200
data = resp.get_json()
for lesson in data["lessons"]:
assert "completed" in lesson
assert "locked" in lesson
assert lesson["completed"] is False
# Dengan progress → completed dihitung dari status siswa
monkeypatch.setattr(
lessons_routes,
"get_student_progress",
lambda token: {"hello_world": "completed"},
)
resp = client.get("/bab/bab1?token=siswa1")
data = resp.get_json()
by_name = {l["filename"]: l for l in data["lessons"]}
assert by_name["hello_world.md"]["completed"] is True
assert by_name["variabel.md"]["completed"] is False
assert by_name["percabangan.md"]["completed"] is False
def test_bab_lesson_locked_by_prerequisites(client, tmp_path, monkeypatch):
bab = tmp_path / "bab1"
bab.mkdir()
(bab / "sub-home.md").write_text(
"# Bab Satu\n\n----Available_Lessons----\n"
"1. [Dasar](lesson/dasar.md)\n"
"2. [Lanjutan](lesson/lanjutan.md)\n",
encoding="utf-8",
)
(bab / "dasar.md").write_text("# Dasar\nMateri.\n", encoding="utf-8")
(bab / "lanjutan.md").write_text(
"# Lanjutan\n\n---LESSON_INFO---\n**Prerequisites:**\n- [Dasar](lesson/dasar.md)\n"
"---END_LESSON_INFO---\n\nMateri lanjutan.\n",
encoding="utf-8",
)
(tmp_path / "home.md").write_text("# Home\n\n----Available_Lessons----\n", encoding="utf-8")
monkeypatch.setattr("services.lesson_service.CONTENT_DIR", str(tmp_path))
lesson_service.find_lesson_file.cache_clear()
lesson_service.get_lessons.cache_clear()
lesson_service.get_lesson_names.cache_clear()
lesson_service.get_lessons_with_learning_objectives.cache_clear()
from routes import lessons as lessons_routes
# Prasyarat belum selesai → Lanjutan terkunci
monkeypatch.setattr(lessons_routes, "get_student_progress", lambda token: {})
resp = client.get("/bab/bab1?token=siswa1")
data = resp.get_json()
by_name = {l["filename"]: l for l in data["lessons"]}
assert by_name["dasar.md"]["completed"] is False
assert by_name["dasar.md"]["locked"] is False
assert by_name["lanjutan.md"]["completed"] is False
assert by_name["lanjutan.md"]["locked"] is True
# Prasyarat selesai → Lanjutan tidak terkunci lagi
monkeypatch.setattr(
lessons_routes,
"get_student_progress",
lambda token: {"dasar": "completed"},
)
resp = client.get("/bab/bab1?token=siswa1")
data = resp.get_json()
by_name = {l["filename"]: l for l in data["lessons"]}
assert by_name["dasar.md"]["completed"] is True
assert by_name["dasar.md"]["locked"] is False
assert by_name["lanjutan.md"]["completed"] is False
assert by_name["lanjutan.md"]["locked"] is False