diff --git a/docs/02-backend-flask.md b/docs/02-backend-flask.md index 2bd6373..a2e96dc 100644 --- a/docs/02-backend-flask.md +++ b/docs/02-backend-flask.md @@ -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. diff --git a/frontend/src/routes/bab/[folder]/+page.ts b/frontend/src/routes/bab/[folder]/+page.ts index 77fd9a7..4c16fb4 100644 --- a/frontend/src/routes/bab/[folder]/+page.ts +++ b/frontend/src/routes/bab/[folder]/+page.ts @@ -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'); } diff --git a/routes/lessons.py b/routes/lessons.py index 03ae68b..0972b80 100644 --- a/routes/lessons.py +++ b/routes/lessons.py @@ -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) diff --git a/services/lesson_service.py b/services/lesson_service.py index e47015a..08a15f0 100644 --- a/services/lesson_service.py +++ b/services/lesson_service.py @@ -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). diff --git a/services/tests/test_sub_home_api.py b/services/tests/test_sub_home_api.py index 9bb3a00..d7a0d80 100644 --- a/services/tests/test_sub_home_api.py +++ b/services/tests/test_sub_home_api.py @@ -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