2026-03-25 09:39:51 +07:00
|
|
|
"""
|
|
|
|
|
Lesson JSON API routes consumed by the SvelteKit frontend.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
feat(assets): optimasi serving gambar (WebP on-the-fly + cache disk)
Arsitektur: upload tetap menyimpan original apa adanya; optimasi terjadi
saat serving — gambar raster di-resize (maks 1600px) + encode WebP
(quality 80), di-generate on-the-fly lalu di-cache di disk.
- config.CACHE_DIR = sibling ASSETS_DIR ('assets-cache'), WAJIB di luar
ASSETS_DIR supaya export_bundle (os.walk tanpa filter) tetap lossless
- services/image_cache.py: varian WebP, cache key path+mtime+size+width,
invalidasi otomatis via mtime, atomic write, fallback original bila gagal
- routes/lessons.py::send_assets: handler (bukan static serve) —
.png/.jpg/.jpeg/.webp/.gif -> varian WebP; .svg & GIF animasi pass-through;
?orig=1 untuk original; ?w= untuk lebar alternatif; ETag + Cache-Control
- Dockerfile: buat /app/assets-cache writable
Test (services/tests/test_asset_cache.py, 20 test): lokasi cache di luar
ASSETS_DIR, cache reuse, invalidasi mtime, passthrough SVG/GIF animasi,
fallback file korup, ETag/304, dan acceptance test — upload -> request ->
export bundle berisi HANYA original tanpa .webp turunan.
2026-10-25 19:19:07 +07:00
|
|
|
from flask import Blueprint, Response, abort, request, jsonify, send_from_directory
|
|
|
|
|
from werkzeug.utils import safe_join, secure_filename
|
2026-03-25 09:39:51 +07:00
|
|
|
|
|
|
|
|
from compiler import compiler_factory
|
feat(assets): optimasi serving gambar (WebP on-the-fly + cache disk)
Arsitektur: upload tetap menyimpan original apa adanya; optimasi terjadi
saat serving — gambar raster di-resize (maks 1600px) + encode WebP
(quality 80), di-generate on-the-fly lalu di-cache di disk.
- config.CACHE_DIR = sibling ASSETS_DIR ('assets-cache'), WAJIB di luar
ASSETS_DIR supaya export_bundle (os.walk tanpa filter) tetap lossless
- services/image_cache.py: varian WebP, cache key path+mtime+size+width,
invalidasi otomatis via mtime, atomic write, fallback original bila gagal
- routes/lessons.py::send_assets: handler (bukan static serve) —
.png/.jpg/.jpeg/.webp/.gif -> varian WebP; .svg & GIF animasi pass-through;
?orig=1 untuk original; ?w= untuk lebar alternatif; ETag + Cache-Control
- Dockerfile: buat /app/assets-cache writable
Test (services/tests/test_asset_cache.py, 20 test): lokasi cache di luar
ASSETS_DIR, cache reuse, invalidasi mtime, passthrough SVG/GIF animasi,
fallback file korup, ETag/304, dan acceptance test — upload -> request ->
export bundle berisi HANYA original tanpa .webp turunan.
2026-10-25 19:19:07 +07:00
|
|
|
from config import ASSET_CACHE_MAX_AGE, ASSETS_DIR
|
|
|
|
|
from services import image_cache
|
2026-03-25 09:39:51 +07:00
|
|
|
from services.lesson_service import (
|
2026-08-17 21:57:10 +07:00
|
|
|
get_missing_prerequisites,
|
2026-03-25 09:39:51 +07:00
|
|
|
get_ordered_lessons_with_learning_objectives,
|
2026-07-27 22:01:01 +07:00
|
|
|
get_sub_home_data,
|
2026-08-14 05:10:59 +07:00
|
|
|
get_sub_home_path,
|
2026-07-27 22:01:01 +07:00
|
|
|
find_sub_home_for_lesson,
|
2026-03-25 09:39:51 +07:00
|
|
|
render_markdown_content,
|
|
|
|
|
render_home_content,
|
2026-05-14 19:45:10 +07:00
|
|
|
find_lesson_file,
|
2026-03-25 09:39:51 +07:00
|
|
|
)
|
feat(quiz): izinkan guru melanjutkan attempt terminated dari jawaban tersimpan
Kebijakan termination tetap strict, tapi guru pegang override untuk kendala
tak terduga (false-positive blur dari dialog OS, browser crash, refresh
tak sengaja). Selain reset (siswa mengulang dari nol), guru kini bisa
memberi izin resume — siswa melanjutkan dari jawaban yang sudah tersimpan.
- Tambah kolom quiz_attempts.resumed_at (migrasi 0006) sebagai penanda izin
guru melanjutkan attempt terminated
- Tambah endpoint teacher-only POST /resume-quiz-attempt — kontrak keamanan
sama dengan /reset-progress (guru cukup student_id anonim, tanpa token siswa)
- Syarat: hanya attempt ber-status terminated yang bisa di-resume; submitted
ditolak karena kuis sudah selesai normal
- has_quiz_attempt meloloskan attempt ber-izin sehingga gate one-attempt
terbuka selama sesi lanjutan
- finalize_quiz_attempt menimpa row attempt sekali (status/skor/answers baru)
lalu clear resumed_at; retry berikutnya kembali ke jalur idempotent normal
- Sinyal ke frontend siswa lewat quiz_resumable pada data lesson; laporan guru
menampilkan badge "Dilanjutkan" dan tombol "Lanjutkan" untuk attempt
terminated
- Frontend: restoreQuizSession() merekonstruksi sesi dari jawaban tersimpan per
question_id, is_correct dihitung ulang dari source sebagai guard bila
konten soal berubah; option_id tak dikenal dianggap belum dijawab
- Frontend: firstUnansweredIndex() menentukan soal awal sesi lanjutan;
resolveQuizTabView() memusatkan keputusan view agar transisi resume dapat
diuji Vitest
- UI siswa: panel "Lanjutkan Kuis" + snackbar mode lanjutan (sekali muncul).
Attempt di-refetch segar sebelum sesi dimulai agar izin yang sudah dicabut
guru tidak memulai sesi yang jawabannya akan terbuang
- Test: 16 integrasi backend (services/tests/test_resume_quiz.py), 9 unit
quiz-session, 10 unit quiz-view
- Docs: alur resume + keterbatasan di 12-quiz-integrity.md, ringkasan di
07-quiz-authoring.md, kontrak endpoint di 02-backend-flask.md
2026-10-20 19:24:40 +07:00
|
|
|
from services.token_service import get_student_progress, has_quiz_attempt, is_quiz_attempt_resumable
|
2026-03-25 09:39:51 +07:00
|
|
|
|
|
|
|
|
lessons_bp = Blueprint('lessons', __name__)
|
|
|
|
|
|
|
|
|
|
|
feat(assets): optimasi serving gambar (WebP on-the-fly + cache disk)
Arsitektur: upload tetap menyimpan original apa adanya; optimasi terjadi
saat serving — gambar raster di-resize (maks 1600px) + encode WebP
(quality 80), di-generate on-the-fly lalu di-cache di disk.
- config.CACHE_DIR = sibling ASSETS_DIR ('assets-cache'), WAJIB di luar
ASSETS_DIR supaya export_bundle (os.walk tanpa filter) tetap lossless
- services/image_cache.py: varian WebP, cache key path+mtime+size+width,
invalidasi otomatis via mtime, atomic write, fallback original bila gagal
- routes/lessons.py::send_assets: handler (bukan static serve) —
.png/.jpg/.jpeg/.webp/.gif -> varian WebP; .svg & GIF animasi pass-through;
?orig=1 untuk original; ?w= untuk lebar alternatif; ETag + Cache-Control
- Dockerfile: buat /app/assets-cache writable
Test (services/tests/test_asset_cache.py, 20 test): lokasi cache di luar
ASSETS_DIR, cache reuse, invalidasi mtime, passthrough SVG/GIF animasi,
fallback file korup, ETag/304, dan acceptance test — upload -> request ->
export bundle berisi HANYA original tanpa .webp turunan.
2026-10-25 19:19:07 +07:00
|
|
|
def _parse_variant_width(raw: str | None) -> int | None:
|
|
|
|
|
"""Parse parameter `?w=` (lebar alternatif, px).
|
|
|
|
|
|
|
|
|
|
Nilai tidak valid / di luar rentang aman → None (pakai lebar default).
|
|
|
|
|
"""
|
|
|
|
|
if not raw:
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
width = int(raw)
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
return None
|
|
|
|
|
if width < 1 or width > 4096:
|
|
|
|
|
return None
|
|
|
|
|
return width
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 21:57:10 +07:00
|
|
|
def _prereq_specs(lesson):
|
|
|
|
|
"""Spec prasyarat untuk pengecekan kunci.
|
|
|
|
|
|
|
|
|
|
Prioritas `prerequisite_specs` (slug + min_percent); fallback ke
|
|
|
|
|
`prerequisites` (slug saja) bila spec belum ada (data legacy).
|
|
|
|
|
"""
|
|
|
|
|
return lesson.get('prerequisite_specs') or lesson.get('prerequisites', [])
|
|
|
|
|
|
|
|
|
|
|
2026-03-25 09:39:51 +07:00
|
|
|
@lessons_bp.route('/lessons')
|
|
|
|
|
def api_lessons():
|
|
|
|
|
"""Return lesson list + home content as JSON."""
|
|
|
|
|
token = request.args.get('token', '') or request.cookies.get('student_token', '')
|
|
|
|
|
|
|
|
|
|
progress = None
|
|
|
|
|
if token:
|
|
|
|
|
progress = get_student_progress(token)
|
|
|
|
|
|
|
|
|
|
lessons = get_ordered_lessons_with_learning_objectives(progress)
|
2026-08-17 21:57:10 +07:00
|
|
|
|
|
|
|
|
# Calculate locked status — prasyarat terpenuhi bila completed ATAU sudah
|
|
|
|
|
# discor (skor berapa pun, atau di atas ambang `min: N%` bila dideklarasikan).
|
2026-05-07 10:54:42 +07:00
|
|
|
for lesson in lessons:
|
2026-08-17 21:57:10 +07:00
|
|
|
lesson['locked'] = bool(get_missing_prerequisites(progress, _prereq_specs(lesson)))
|
2026-05-07 10:54:42 +07:00
|
|
|
|
2026-03-25 09:39:51 +07:00
|
|
|
home_content = render_home_content()
|
|
|
|
|
|
|
|
|
|
return jsonify({
|
|
|
|
|
'lessons': lessons,
|
|
|
|
|
'home_content': home_content,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 22:01:01 +07:00
|
|
|
@lessons_bp.route('/bab/<folder>')
|
|
|
|
|
def api_bab(folder):
|
|
|
|
|
"""Return sub-home data for a given folder (e.g. 'dasar', 'arduino')."""
|
|
|
|
|
data = get_sub_home_data(folder)
|
|
|
|
|
if not data:
|
|
|
|
|
return jsonify({'error': 'Bab not found'}), 404
|
2026-08-14 05:10:59 +07:00
|
|
|
|
|
|
|
|
# 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:
|
2026-08-17 21:57:10 +07:00
|
|
|
lesson['locked'] = bool(get_missing_prerequisites(progress, _prereq_specs(lesson)))
|
2026-08-14 05:10:59 +07:00
|
|
|
data['lessons'] = ordered
|
|
|
|
|
|
2026-07-27 22:01:01 +07:00
|
|
|
return jsonify(data)
|
|
|
|
|
|
|
|
|
|
|
2026-03-25 09:39:51 +07:00
|
|
|
@lessons_bp.route('/lesson/<filename>.json')
|
|
|
|
|
def api_lesson(filename):
|
|
|
|
|
"""Return single lesson data as JSON."""
|
2026-05-14 19:45:10 +07:00
|
|
|
full_filename = filename if filename.endswith('.md') else f'{filename}.md'
|
|
|
|
|
file_path = find_lesson_file(full_filename)
|
|
|
|
|
if not file_path:
|
2026-03-25 09:39:51 +07:00
|
|
|
return jsonify({'error': 'Lesson not found'}), 404
|
|
|
|
|
|
2026-03-29 17:25:09 +07:00
|
|
|
parsed_data = render_markdown_content(file_path)
|
|
|
|
|
lesson_html = parsed_data['lesson_html']
|
|
|
|
|
exercise_html = parsed_data['exercise_html']
|
|
|
|
|
expected_output = parsed_data['expected_output']
|
2026-05-01 15:32:55 +07:00
|
|
|
expected_output_python = parsed_data.get('expected_output_python', '')
|
2026-03-31 14:31:15 +07:00
|
|
|
expected_circuit_output = parsed_data.get('expected_circuit_output', '')
|
|
|
|
|
key_text_circuit = parsed_data.get('key_text_circuit', '')
|
2026-03-29 17:25:09 +07:00
|
|
|
lesson_info = parsed_data['lesson_info']
|
|
|
|
|
initial_code = parsed_data['initial_code']
|
|
|
|
|
solution_code = parsed_data['solution_code']
|
|
|
|
|
solution_circuit = parsed_data.get('solution_circuit', '')
|
2026-04-10 14:00:35 +07:00
|
|
|
solution_python = parsed_data.get('solution_python', '')
|
2026-03-29 17:25:09 +07:00
|
|
|
key_text = parsed_data['key_text']
|
|
|
|
|
active_tabs = parsed_data['active_tabs']
|
2026-05-08 09:50:07 +07:00
|
|
|
quiz_data = parsed_data.get('quiz_data', [])
|
2026-03-29 17:25:09 +07:00
|
|
|
|
|
|
|
|
# New specific fields for hybrid lessons
|
|
|
|
|
initial_circuit = parsed_data.get('initial_circuit', '')
|
|
|
|
|
initial_code_c = parsed_data.get('initial_code_c', '')
|
|
|
|
|
initial_python = parsed_data.get('initial_python', '')
|
2026-05-01 09:09:45 +07:00
|
|
|
initial_flowchart = parsed_data.get('initial_flowchart', None)
|
2026-03-29 17:25:09 +07:00
|
|
|
initial_quiz = parsed_data.get('initial_quiz', '')
|
2026-03-25 09:39:51 +07:00
|
|
|
|
2026-04-09 10:51:36 +07:00
|
|
|
# Arduino/Velxio fields
|
|
|
|
|
initial_code_arduino = parsed_data.get('initial_code_arduino', '')
|
|
|
|
|
velxio_circuit = parsed_data.get('velxio_circuit', '')
|
|
|
|
|
expected_serial_output = parsed_data.get('expected_serial_output', '')
|
|
|
|
|
expected_wiring = parsed_data.get('expected_wiring', '')
|
2026-05-02 18:47:46 +07:00
|
|
|
expected_flowchart = parsed_data.get('expected_flowchart', '')
|
2026-04-16 11:11:25 +07:00
|
|
|
|
|
|
|
|
evaluation_config_raw = parsed_data.get('evaluation_config', '')
|
|
|
|
|
evaluation_config = {}
|
|
|
|
|
if evaluation_config_raw:
|
|
|
|
|
import json
|
|
|
|
|
try:
|
|
|
|
|
evaluation_config = json.loads(evaluation_config_raw)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
2026-04-09 10:51:36 +07:00
|
|
|
|
2026-03-25 09:39:51 +07:00
|
|
|
if not initial_code:
|
|
|
|
|
initial_code = (
|
|
|
|
|
'#include <stdio.h>\n\nint main() {\n'
|
|
|
|
|
' // Write your code here\n'
|
|
|
|
|
' printf("Hello, World!\\n");\n'
|
|
|
|
|
' return 0;\n}'
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
token = request.args.get('token', '') or request.cookies.get('student_token', '')
|
|
|
|
|
progress = None
|
|
|
|
|
lesson_completed = False
|
2026-05-09 13:04:42 +07:00
|
|
|
lesson_progress_status = ''
|
2026-09-09 07:33:52 +07:00
|
|
|
lesson_slug = full_filename.replace('.md', '')
|
|
|
|
|
user_has_quiz_attempt = False
|
feat(quiz): izinkan guru melanjutkan attempt terminated dari jawaban tersimpan
Kebijakan termination tetap strict, tapi guru pegang override untuk kendala
tak terduga (false-positive blur dari dialog OS, browser crash, refresh
tak sengaja). Selain reset (siswa mengulang dari nol), guru kini bisa
memberi izin resume — siswa melanjutkan dari jawaban yang sudah tersimpan.
- Tambah kolom quiz_attempts.resumed_at (migrasi 0006) sebagai penanda izin
guru melanjutkan attempt terminated
- Tambah endpoint teacher-only POST /resume-quiz-attempt — kontrak keamanan
sama dengan /reset-progress (guru cukup student_id anonim, tanpa token siswa)
- Syarat: hanya attempt ber-status terminated yang bisa di-resume; submitted
ditolak karena kuis sudah selesai normal
- has_quiz_attempt meloloskan attempt ber-izin sehingga gate one-attempt
terbuka selama sesi lanjutan
- finalize_quiz_attempt menimpa row attempt sekali (status/skor/answers baru)
lalu clear resumed_at; retry berikutnya kembali ke jalur idempotent normal
- Sinyal ke frontend siswa lewat quiz_resumable pada data lesson; laporan guru
menampilkan badge "Dilanjutkan" dan tombol "Lanjutkan" untuk attempt
terminated
- Frontend: restoreQuizSession() merekonstruksi sesi dari jawaban tersimpan per
question_id, is_correct dihitung ulang dari source sebagai guard bila
konten soal berubah; option_id tak dikenal dianggap belum dijawab
- Frontend: firstUnansweredIndex() menentukan soal awal sesi lanjutan;
resolveQuizTabView() memusatkan keputusan view agar transisi resume dapat
diuji Vitest
- UI siswa: panel "Lanjutkan Kuis" + snackbar mode lanjutan (sekali muncul).
Attempt di-refetch segar sebelum sesi dimulai agar izin yang sudah dicabut
guru tidak memulai sesi yang jawabannya akan terbuang
- Test: 16 integrasi backend (services/tests/test_resume_quiz.py), 9 unit
quiz-session, 10 unit quiz-view
- Docs: alur resume + keterbatasan di 12-quiz-integrity.md, ringkasan di
07-quiz-authoring.md, kontrak endpoint di 02-backend-flask.md
2026-10-20 19:24:40 +07:00
|
|
|
quiz_resumable = False
|
2026-03-25 09:39:51 +07:00
|
|
|
if token:
|
|
|
|
|
progress = get_student_progress(token)
|
2026-05-09 13:04:42 +07:00
|
|
|
if progress:
|
2026-09-09 07:33:52 +07:00
|
|
|
status = progress.get(lesson_slug, '')
|
2026-05-09 13:04:42 +07:00
|
|
|
lesson_progress_status = status
|
|
|
|
|
lesson_completed = status not in (None, '', 'not_started')
|
2026-09-09 07:33:52 +07:00
|
|
|
user_has_quiz_attempt = has_quiz_attempt(token, lesson_slug)
|
feat(quiz): izinkan guru melanjutkan attempt terminated dari jawaban tersimpan
Kebijakan termination tetap strict, tapi guru pegang override untuk kendala
tak terduga (false-positive blur dari dialog OS, browser crash, refresh
tak sengaja). Selain reset (siswa mengulang dari nol), guru kini bisa
memberi izin resume — siswa melanjutkan dari jawaban yang sudah tersimpan.
- Tambah kolom quiz_attempts.resumed_at (migrasi 0006) sebagai penanda izin
guru melanjutkan attempt terminated
- Tambah endpoint teacher-only POST /resume-quiz-attempt — kontrak keamanan
sama dengan /reset-progress (guru cukup student_id anonim, tanpa token siswa)
- Syarat: hanya attempt ber-status terminated yang bisa di-resume; submitted
ditolak karena kuis sudah selesai normal
- has_quiz_attempt meloloskan attempt ber-izin sehingga gate one-attempt
terbuka selama sesi lanjutan
- finalize_quiz_attempt menimpa row attempt sekali (status/skor/answers baru)
lalu clear resumed_at; retry berikutnya kembali ke jalur idempotent normal
- Sinyal ke frontend siswa lewat quiz_resumable pada data lesson; laporan guru
menampilkan badge "Dilanjutkan" dan tombol "Lanjutkan" untuk attempt
terminated
- Frontend: restoreQuizSession() merekonstruksi sesi dari jawaban tersimpan per
question_id, is_correct dihitung ulang dari source sebagai guard bila
konten soal berubah; option_id tak dikenal dianggap belum dijawab
- Frontend: firstUnansweredIndex() menentukan soal awal sesi lanjutan;
resolveQuizTabView() memusatkan keputusan view agar transisi resume dapat
diuji Vitest
- UI siswa: panel "Lanjutkan Kuis" + snackbar mode lanjutan (sekali muncul).
Attempt di-refetch segar sebelum sesi dimulai agar izin yang sudah dicabut
guru tidak memulai sesi yang jawabannya akan terbuang
- Test: 16 integrasi backend (services/tests/test_resume_quiz.py), 9 unit
quiz-session, 10 unit quiz-view
- Docs: alur resume + keterbatasan di 12-quiz-integrity.md, ringkasan di
07-quiz-authoring.md, kontrak endpoint di 02-backend-flask.md
2026-10-20 19:24:40 +07:00
|
|
|
quiz_resumable = is_quiz_attempt_resumable(token, lesson_slug)
|
2026-03-25 09:39:51 +07:00
|
|
|
|
2026-08-13 20:08:50 +07:00
|
|
|
# Detect sub-home for this lesson's folder (untuk sidebar & navigasi)
|
|
|
|
|
sub_home_path, sub_home_folder = find_sub_home_for_lesson(file_path)
|
|
|
|
|
sub_home = None
|
|
|
|
|
if sub_home_path and sub_home_folder:
|
|
|
|
|
sub_home_data = get_sub_home_data(sub_home_folder)
|
|
|
|
|
sub_home = {
|
|
|
|
|
'folder': sub_home_folder,
|
|
|
|
|
'url': f'/bab/{sub_home_folder}',
|
|
|
|
|
'title': sub_home_data['title'] if sub_home_data else sub_home_folder.replace('_', ' ').title(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ordered_lessons: daftar materi scoped ke sub-home.md bila folder memilikinya,
|
|
|
|
|
# fallback ke daftar global home.md bila tidak.
|
|
|
|
|
all_lessons = get_ordered_lessons_with_learning_objectives(
|
|
|
|
|
progress,
|
|
|
|
|
source_path=sub_home_path if (sub_home_path and sub_home_folder) else None,
|
|
|
|
|
)
|
2026-03-25 09:39:51 +07:00
|
|
|
|
|
|
|
|
current_idx = -1
|
2026-05-07 10:54:42 +07:00
|
|
|
current_lesson_meta = None
|
2026-03-25 09:39:51 +07:00
|
|
|
for i, les in enumerate(all_lessons):
|
|
|
|
|
if les['filename'] == full_filename:
|
|
|
|
|
current_idx = i
|
2026-05-07 10:54:42 +07:00
|
|
|
current_lesson_meta = les
|
2026-03-25 09:39:51 +07:00
|
|
|
break
|
|
|
|
|
|
2026-08-13 20:08:50 +07:00
|
|
|
# Bila lesson tidak ada di daftar sub-home (mis. belum dicantumkan di
|
|
|
|
|
# sub-home.md), fallback ke daftar global home.md agar sidebar tidak kosong.
|
|
|
|
|
if current_idx < 0:
|
|
|
|
|
all_lessons = get_ordered_lessons_with_learning_objectives(progress)
|
|
|
|
|
for i, les in enumerate(all_lessons):
|
|
|
|
|
if les['filename'] == full_filename:
|
|
|
|
|
current_idx = i
|
|
|
|
|
current_lesson_meta = les
|
|
|
|
|
break
|
|
|
|
|
|
2026-08-17 21:57:10 +07:00
|
|
|
# Prerequisite check — status scored (mis. "3/4") juga memenuhi prasyarat,
|
|
|
|
|
# kecuali ada ambang `min: N%` yang belum tercapai.
|
2026-05-07 10:54:42 +07:00
|
|
|
missing_prereqs = []
|
|
|
|
|
if current_lesson_meta:
|
2026-08-17 21:57:10 +07:00
|
|
|
missing_prereqs = get_missing_prerequisites(progress, _prereq_specs(current_lesson_meta))
|
|
|
|
|
is_locked = bool(missing_prereqs)
|
2026-05-07 10:54:42 +07:00
|
|
|
|
2026-03-25 09:39:51 +07:00
|
|
|
prev_lesson = all_lessons[current_idx - 1] if current_idx > 0 else None
|
|
|
|
|
next_lesson = all_lessons[current_idx + 1] if 0 <= current_idx < len(all_lessons) - 1 else None
|
|
|
|
|
|
2026-04-01 12:56:27 +07:00
|
|
|
# Derive default language from active_tabs (frontend manages switching)
|
|
|
|
|
if 'python' in active_tabs and 'c' not in active_tabs:
|
2026-04-01 09:37:09 +07:00
|
|
|
programming_language = 'python'
|
|
|
|
|
else:
|
|
|
|
|
programming_language = 'c'
|
2026-03-25 09:39:51 +07:00
|
|
|
language_display_name = compiler_factory.get_language_display_name(programming_language)
|
|
|
|
|
|
2026-05-07 10:54:42 +07:00
|
|
|
if is_locked:
|
|
|
|
|
# Strip sensitive data to prevent inspection bypass
|
|
|
|
|
exercise_html = ""
|
|
|
|
|
expected_output = ""
|
|
|
|
|
expected_output_python = ""
|
|
|
|
|
expected_circuit_output = ""
|
|
|
|
|
initial_code = "// Konten terkunci. Selesaikan prasyarat untuk melihat."
|
|
|
|
|
initial_circuit = ""
|
|
|
|
|
initial_code_c = ""
|
|
|
|
|
initial_python = ""
|
|
|
|
|
initial_flowchart = None
|
|
|
|
|
initial_quiz = ""
|
|
|
|
|
initial_code_arduino = ""
|
|
|
|
|
velxio_circuit = ""
|
|
|
|
|
expected_serial_output = ""
|
|
|
|
|
expected_wiring = ""
|
|
|
|
|
expected_flowchart = ""
|
|
|
|
|
solution_code = ""
|
|
|
|
|
solution_circuit = ""
|
|
|
|
|
solution_python = ""
|
|
|
|
|
key_text = ""
|
|
|
|
|
key_text_circuit = ""
|
2026-05-08 09:50:07 +07:00
|
|
|
quiz_data = []
|
2026-05-14 18:53:46 +07:00
|
|
|
parsed_data['slides'] = []
|
2026-05-07 10:54:42 +07:00
|
|
|
# Keep lesson_html, lesson_info, etc. for reading
|
|
|
|
|
|
2026-08-30 10:55:22 +07:00
|
|
|
|
|
|
|
|
# Anti-cache: tiap request lesson selalu fresh dari disk (bukan cache
|
|
|
|
|
# browser/SW/proxy). Ini mencegah soal kuis "stale" saat file lesson diubah
|
|
|
|
|
# — terutama setelah navigasi antar tipe soal (mcq ↔ flashcard).
|
|
|
|
|
resp = jsonify({
|
2026-03-25 09:39:51 +07:00
|
|
|
'lesson_content': lesson_html,
|
|
|
|
|
'exercise_content': exercise_html,
|
|
|
|
|
'expected_output': expected_output,
|
2026-05-01 15:32:55 +07:00
|
|
|
'expected_output_python': expected_output_python,
|
2026-03-31 14:31:15 +07:00
|
|
|
'expected_circuit_output': expected_circuit_output,
|
2026-03-25 09:39:51 +07:00
|
|
|
'lesson_info': lesson_info,
|
|
|
|
|
'initial_code': initial_code,
|
2026-03-29 17:25:09 +07:00
|
|
|
'initial_circuit': initial_circuit,
|
|
|
|
|
'initial_code_c': initial_code_c,
|
|
|
|
|
'initial_python': initial_python,
|
2026-05-01 09:09:45 +07:00
|
|
|
'initial_flowchart': initial_flowchart,
|
2026-03-29 17:25:09 +07:00
|
|
|
'initial_quiz': initial_quiz,
|
2026-04-09 10:51:36 +07:00
|
|
|
'initial_code_arduino': initial_code_arduino,
|
|
|
|
|
'velxio_circuit': velxio_circuit,
|
|
|
|
|
'expected_serial_output': expected_serial_output,
|
|
|
|
|
'expected_wiring': expected_wiring,
|
2026-05-02 18:47:46 +07:00
|
|
|
'expected_flowchart': expected_flowchart,
|
2026-04-16 11:11:25 +07:00
|
|
|
'evaluation_config': evaluation_config,
|
2026-03-25 09:39:51 +07:00
|
|
|
'solution_code': solution_code,
|
2026-03-29 17:25:09 +07:00
|
|
|
'solution_circuit': solution_circuit,
|
2026-04-10 14:00:35 +07:00
|
|
|
'solution_python': solution_python,
|
2026-03-25 09:39:51 +07:00
|
|
|
'key_text': key_text,
|
2026-03-31 14:31:15 +07:00
|
|
|
'key_text_circuit': key_text_circuit,
|
2026-03-29 17:25:09 +07:00
|
|
|
'active_tabs': active_tabs,
|
2026-05-08 09:50:07 +07:00
|
|
|
'quiz_data': quiz_data,
|
2026-05-14 18:53:46 +07:00
|
|
|
'slides': parsed_data.get('slides', []),
|
2026-05-09 13:04:42 +07:00
|
|
|
'lesson_progress_status': lesson_progress_status,
|
2026-07-27 22:01:01 +07:00
|
|
|
'lesson_title': current_lesson_meta['title'] if current_lesson_meta else full_filename.replace('.md', '').replace('_', ' ').title(),
|
2026-03-25 09:39:51 +07:00
|
|
|
'lesson_completed': lesson_completed,
|
2026-09-09 07:33:52 +07:00
|
|
|
'has_quiz_attempt': user_has_quiz_attempt,
|
feat(quiz): izinkan guru melanjutkan attempt terminated dari jawaban tersimpan
Kebijakan termination tetap strict, tapi guru pegang override untuk kendala
tak terduga (false-positive blur dari dialog OS, browser crash, refresh
tak sengaja). Selain reset (siswa mengulang dari nol), guru kini bisa
memberi izin resume — siswa melanjutkan dari jawaban yang sudah tersimpan.
- Tambah kolom quiz_attempts.resumed_at (migrasi 0006) sebagai penanda izin
guru melanjutkan attempt terminated
- Tambah endpoint teacher-only POST /resume-quiz-attempt — kontrak keamanan
sama dengan /reset-progress (guru cukup student_id anonim, tanpa token siswa)
- Syarat: hanya attempt ber-status terminated yang bisa di-resume; submitted
ditolak karena kuis sudah selesai normal
- has_quiz_attempt meloloskan attempt ber-izin sehingga gate one-attempt
terbuka selama sesi lanjutan
- finalize_quiz_attempt menimpa row attempt sekali (status/skor/answers baru)
lalu clear resumed_at; retry berikutnya kembali ke jalur idempotent normal
- Sinyal ke frontend siswa lewat quiz_resumable pada data lesson; laporan guru
menampilkan badge "Dilanjutkan" dan tombol "Lanjutkan" untuk attempt
terminated
- Frontend: restoreQuizSession() merekonstruksi sesi dari jawaban tersimpan per
question_id, is_correct dihitung ulang dari source sebagai guard bila
konten soal berubah; option_id tak dikenal dianggap belum dijawab
- Frontend: firstUnansweredIndex() menentukan soal awal sesi lanjutan;
resolveQuizTabView() memusatkan keputusan view agar transisi resume dapat
diuji Vitest
- UI siswa: panel "Lanjutkan Kuis" + snackbar mode lanjutan (sekali muncul).
Attempt di-refetch segar sebelum sesi dimulai agar izin yang sudah dicabut
guru tidak memulai sesi yang jawabannya akan terbuang
- Test: 16 integrasi backend (services/tests/test_resume_quiz.py), 9 unit
quiz-session, 10 unit quiz-view
- Docs: alur resume + keterbatasan di 12-quiz-integrity.md, ringkasan di
07-quiz-authoring.md, kontrak endpoint di 02-backend-flask.md
2026-10-20 19:24:40 +07:00
|
|
|
'quiz_resumable': quiz_resumable,
|
2026-05-07 10:54:42 +07:00
|
|
|
'locked': is_locked,
|
|
|
|
|
'missing_prerequisites': missing_prereqs,
|
2026-03-25 09:39:51 +07:00
|
|
|
'prev_lesson': prev_lesson,
|
|
|
|
|
'next_lesson': next_lesson,
|
|
|
|
|
'ordered_lessons': all_lessons,
|
2026-07-27 22:01:01 +07:00
|
|
|
'sub_home': sub_home,
|
2026-03-25 09:39:51 +07:00
|
|
|
'language': programming_language,
|
|
|
|
|
'language_display_name': language_display_name,
|
|
|
|
|
})
|
2026-08-30 10:55:22 +07:00
|
|
|
resp.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
|
|
|
|
resp.headers['Pragma'] = 'no-cache'
|
|
|
|
|
return resp
|
2026-03-25 09:39:51 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@lessons_bp.route('/get-key-text/<filename>')
|
|
|
|
|
def get_key_text(filename):
|
|
|
|
|
"""Get the key text for a specific lesson."""
|
2026-05-14 19:45:10 +07:00
|
|
|
file_path = find_lesson_file(filename)
|
|
|
|
|
if not file_path:
|
2026-03-25 09:39:51 +07:00
|
|
|
return jsonify({'success': False, 'error': 'Lesson not found'}), 404
|
|
|
|
|
|
2026-03-29 17:25:09 +07:00
|
|
|
parsed_data = render_markdown_content(file_path)
|
|
|
|
|
return jsonify({'success': True, 'key_text': parsed_data['key_text']})
|
2026-03-25 09:39:51 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@lessons_bp.route('/assets/<path:path>')
|
|
|
|
|
def send_assets(path):
|
feat(assets): optimasi serving gambar (WebP on-the-fly + cache disk)
Arsitektur: upload tetap menyimpan original apa adanya; optimasi terjadi
saat serving — gambar raster di-resize (maks 1600px) + encode WebP
(quality 80), di-generate on-the-fly lalu di-cache di disk.
- config.CACHE_DIR = sibling ASSETS_DIR ('assets-cache'), WAJIB di luar
ASSETS_DIR supaya export_bundle (os.walk tanpa filter) tetap lossless
- services/image_cache.py: varian WebP, cache key path+mtime+size+width,
invalidasi otomatis via mtime, atomic write, fallback original bila gagal
- routes/lessons.py::send_assets: handler (bukan static serve) —
.png/.jpg/.jpeg/.webp/.gif -> varian WebP; .svg & GIF animasi pass-through;
?orig=1 untuk original; ?w= untuk lebar alternatif; ETag + Cache-Control
- Dockerfile: buat /app/assets-cache writable
Test (services/tests/test_asset_cache.py, 20 test): lokasi cache di luar
ASSETS_DIR, cache reuse, invalidasi mtime, passthrough SVG/GIF animasi,
fallback file korup, ETag/304, dan acceptance test — upload -> request ->
export bundle berisi HANYA original tanpa .webp turunan.
2026-10-25 19:19:07 +07:00
|
|
|
"""Serve asset files (images, etc.) from ASSETS_DIR.
|
|
|
|
|
|
|
|
|
|
Gambar raster (png/jpg/jpeg/webp/gif) di-serve sebagai versi ringan:
|
|
|
|
|
resize (maks dimensi) + WebP, di-generate on-the-fly lalu di-cache di
|
|
|
|
|
`CACHE_DIR` (di luar ASSETS_DIR — lihat config.py). Original tetap utuh
|
|
|
|
|
di storage dan bisa diakses lewat `?orig=1`.
|
|
|
|
|
|
|
|
|
|
SVG & GIF animasi di-serve apa adanya. File korrupt / Pillow gagal →
|
|
|
|
|
fallback ke original.
|
|
|
|
|
"""
|
|
|
|
|
abs_path = safe_join(ASSETS_DIR, path)
|
|
|
|
|
if abs_path is None or not os.path.isfile(abs_path):
|
|
|
|
|
abort(404)
|
|
|
|
|
|
|
|
|
|
want_original = request.args.get('orig') in ('1', 'true', 'yes')
|
|
|
|
|
width = _parse_variant_width(request.args.get('w'))
|
|
|
|
|
|
|
|
|
|
if not want_original:
|
|
|
|
|
variant = image_cache.get_variant(abs_path, path, width)
|
|
|
|
|
if variant is not None:
|
|
|
|
|
data, etag = variant
|
|
|
|
|
if request.if_none_match and etag in request.if_none_match:
|
|
|
|
|
resp = Response(status=304)
|
|
|
|
|
else:
|
|
|
|
|
resp = Response(data, mimetype='image/webp')
|
|
|
|
|
resp.headers['Content-Length'] = str(len(data))
|
|
|
|
|
resp.headers['ETag'] = f'"{etag}"'
|
|
|
|
|
resp.headers['Cache-Control'] = f'public, max-age={ASSET_CACHE_MAX_AGE}'
|
|
|
|
|
resp.headers['X-Asset-Variant'] = 'webp'
|
|
|
|
|
return resp
|
|
|
|
|
|
|
|
|
|
# Pass-through: original apa adanya (SVG, GIF animasi, ?orig=1, raster
|
|
|
|
|
# yang gagal di-variant-kan).
|
2026-10-03 08:03:28 +07:00
|
|
|
response = send_from_directory(ASSETS_DIR, path)
|
|
|
|
|
# Mitigasi stored-XSS: SVG bisa memuat <script>. Route ini publik
|
|
|
|
|
# (same-origin) — kalau SVG dibuka langsung di tab browser, script-nya
|
|
|
|
|
# bisa membaca localStorage (token siswa/guru). CSP ketat mematikan
|
|
|
|
|
# semua eksekusi di dokumen tersebut; sandbox melarang navigasi/form.
|
|
|
|
|
if path.lower().endswith(".svg"):
|
|
|
|
|
response.headers["Content-Security-Policy"] = "default-src 'none'; sandbox"
|
feat(assets): optimasi serving gambar (WebP on-the-fly + cache disk)
Arsitektur: upload tetap menyimpan original apa adanya; optimasi terjadi
saat serving — gambar raster di-resize (maks 1600px) + encode WebP
(quality 80), di-generate on-the-fly lalu di-cache di disk.
- config.CACHE_DIR = sibling ASSETS_DIR ('assets-cache'), WAJIB di luar
ASSETS_DIR supaya export_bundle (os.walk tanpa filter) tetap lossless
- services/image_cache.py: varian WebP, cache key path+mtime+size+width,
invalidasi otomatis via mtime, atomic write, fallback original bila gagal
- routes/lessons.py::send_assets: handler (bukan static serve) —
.png/.jpg/.jpeg/.webp/.gif -> varian WebP; .svg & GIF animasi pass-through;
?orig=1 untuk original; ?w= untuk lebar alternatif; ETag + Cache-Control
- Dockerfile: buat /app/assets-cache writable
Test (services/tests/test_asset_cache.py, 20 test): lokasi cache di luar
ASSETS_DIR, cache reuse, invalidasi mtime, passthrough SVG/GIF animasi,
fallback file korup, ETag/304, dan acceptance test — upload -> request ->
export bundle berisi HANYA original tanpa .webp turunan.
2026-10-25 19:19:07 +07:00
|
|
|
response.headers['Cache-Control'] = f'public, max-age={ASSET_CACHE_MAX_AGE}'
|
|
|
|
|
response.headers['X-Asset-Variant'] = 'original'
|
2026-10-03 08:03:28 +07:00
|
|
|
return response
|