feat(student-management): sync imported progress with composite model and add inline expected format to CSV headers

- Sync imported progress (completed, scored, done, reset) to composite progress model and recompute composite_percent
- Prevent caching of /progress-report.json with Cache-Control: no-store and frontend cache-busting timestamp
- Provide inline expected format guides in exported CSV lesson headers (e.g. [completed], [<earned>/<total>], [done:<1|0>:<earned>/N])
- Support robust slug extraction from formatted headers in CSV roundtrip import with full backward compatibility
- Add example composite lesson (exercise + quiz) in examples/content/dasar/composite_test.md
- Add comprehensive unit and integration tests covering progress synchronization, header formatting, and roundtrip workflows
This commit is contained in:
a2nr 2026-09-08 19:01:33 +07:00
parent 17713303bb
commit 9bfecfbcab
10 changed files with 425 additions and 17 deletions

View File

@ -0,0 +1,108 @@
---LESSON_INFO---
**Learning Objectives:**
- Memahami konsep materi komposit di Elemes (latihan koding + kuis)
- Menyelesaikan latihan program C/Python dan kuis evaluasi pemahaman
**Prerequisites:**
- variabel
---END_LESSON_INFO---
# Uji Coba Komposit (Latihan dan Kuis)
Pelajaran ini merupakan contoh materi **komposit**, yaitu materi pembelajaran yang menggabungkan:
1. **Latihan Pemrograman (Exercise):** Menulis kode program langsung di editor dan memverifikasi keluarannya.
2. **Kuis Evaluasi (Quiz):** Menjawab pertanyaan pilihan ganda terkait materi.
### Perhitungan Nilai Komposit
Nilai akhir siswa dihitung dari kombinasi kedua komponen:
- Bobot Latihan: 70%
- Bobot Kuis: 30%
- Ambang Tuntas (*Done*): $\ge$ 75%
---EXERCISE---
### Latihan: Pengecekan Angka Positif
Buatlah program yang memeriksa nilai variabel `angka`. Jika `angka > 0`, cetak:
```
Positif
```
---INITIAL_CODE---
#include <stdio.h>
int main() {
int angka = 10;
// Tulis logika percabangan if di bawah ini:
if (angka > 0) {
printf("Positif\n");
}
return 0;
}
---END_INITIAL_CODE---
---EXPECTED_OUTPUT---
Positif
---END_EXPECTED_OUTPUT---
---INITIAL_PYTHON---
angka = 10
# Tulis logika percabangan if di bawah ini:
if angka > 0:
print("Positif")
---END_INITIAL_PYTHON---
---EXPECTED_OUTPUT_PYTHON---
Positif
---END_EXPECTED_OUTPUT_PYTHON---
---SOLUTION_CODE---
#include <stdio.h>
int main() {
int angka = 10;
if (angka > 0) {
printf("Positif\n");
}
return 0;
}
---END_SOLUTION_CODE---
---SOLUTION_PYTHON---
angka = 10
if angka > 0:
print("Positif")
---END_SOLUTION_PYTHON---
---KEY_TEXT---
if
printf
print
---END_KEY_TEXT---
---QUIZ_FLASHCARD---
### Manakah sintaks penulisan kondisi `if` yang benar dalam bahasa C?
- [x] `if (kondisi) { ... }`
- [] `if kondisi then { ... }`
- [] `if [kondisi] { ... }`
- [] `if {kondisi} then { ... }`
> Penjelasan: Dalam bahasa C, ekspresi logika kondisi pada `if` wajib berada di dalam tanda kurung biasa `( )`.
### Apa keluaran dari kode berikut jika `x = 5`?
```c
if (x > 10) {
printf("Besar\n");
} else {
printf("Kecil\n");
}
```
- [] Besar
- [x] Kecil
- [] Error sintaks
- [] Tidak ada keluaran
> Penjelasan: Karena `5 > 10` bernilai salah (false), maka blok `else` yang akan dieksekusi, menghasilkan output "Kecil".
### Berapa bobot penilaian komponen latihan koding dalam penentuan nilai komposit standar Elemes?
- [] 30%
- [] 50%
- [x] 70%
- [] 100%
> Penjelasan: Secara default pada konfigurasi Elemes, komponen latihan koding memiliki bobot 70% dan kuis memiliki bobot 30%.
---END_QUIZ_FLASHCARD---

View File

@ -9,5 +9,6 @@ Materi fundamental untuk memulai belajar pemrograman C.
4. [Uji Coba Kuis](lesson/quiz_test.md)
5. [Uji Coba LaTeX](lesson/latex_test.md)
6. [Uji Coba Slide](lesson/test_slides.md)
7. [Uji Coba Komposit](lesson/composite_test.md)

View File

@ -56,7 +56,9 @@
loading = true;
try {
const res = await fetch(`/api/progress-report.json?token=${encodeURIComponent($authToken)}`);
const res = await fetch(`/api/progress-report.json?token=${encodeURIComponent($authToken)}&_t=${Date.now()}`, {
cache: 'no-store'
});
const data = await res.json();
students = data.students ?? [];
lessons = data.lessons ?? [];
@ -286,6 +288,7 @@
</td>
{#each lessons as lesson}
{@const key = lesson.filename.replace('.md', '')}
{@const status = student[key]}
{@const composite = student[key + '_composite']}
{@const exercisePassed = student[key + '_exercise_passed']}
{@const quizScore = student[key + '_quiz_score']}
@ -305,6 +308,10 @@
{#if quizScore}
<span class="badge quiz-score" title="Skor quiz">{quizScore}</span>
{/if}
{:else if status === 'completed'}
<span class="badge exercise-done" title="Selesai">&#10003;</span>
{:else if status && status !== 'not_started'}
<span class="badge score" title="Status progres">{status}</span>
{:else}
<span class="badge empty">&mdash;</span>
{/if}
@ -333,7 +340,7 @@
{/if}
{/if}
{#if composite !== null && composite !== undefined}
{#if (composite !== null && composite !== undefined) || (status && status !== 'not_started')}
<button
class="btn-reset-mini"
onclick={() => handleReset(student.id as string, key, student.nama_siswa)}

View File

@ -85,10 +85,12 @@ def api_progress_report():
get_lessons_with_learning_objectives,
)
return jsonify({
resp = jsonify({
'students': all_students_progress,
'lessons': [{'filename': l['filename'], 'title': l['title']} for l in ordered_lessons],
})
resp.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
return resp
@progress_bp.route('/progress-report/export-csv')

View File

@ -1304,3 +1304,35 @@ def get_lesson_components(lesson_name: str) -> tuple[bool, bool]:
has_quiz = "quiz" in tabs
return (has_exercise, has_quiz)
def get_lesson_quiz_total(lesson_name: str) -> int | None:
"""Kembalikan jumlah soal kuis untuk lesson jika ada, atau None."""
file_path = find_lesson_file(f"{lesson_name}.md")
if not file_path:
return None
try:
parsed = render_markdown_content(file_path)
quiz_data = parsed.get("quiz_data") or []
return len(quiz_data) if quiz_data else None
except Exception:
return None
def get_lesson_expected_format(lesson_name: str) -> str:
"""Tentukan panduan expected format kolom lesson berdasarkan komponennya.
- Exercise only: 'completed'
- Quiz only: '<earned>/N' jika total soal diketahui, atau '<earned>/<total>'
- Exercise + Quiz: 'done:<1|0>:<earned>/N' (atau '<total>')
- Reading only: 'completed'
"""
has_exercise, has_quiz = get_lesson_components(lesson_name)
quiz_total = get_lesson_quiz_total(lesson_name) if has_quiz else None
if has_exercise and has_quiz:
q_fmt = f"<earned>/{quiz_total}" if quiz_total else "<earned>/<total>"
return f"done:<1|0>:{q_fmt}"
if has_quiz:
return f"<earned>/{quiz_total}" if quiz_total else "<earned>/<total>"
return "completed"

View File

@ -17,7 +17,7 @@ from sqlalchemy.orm import Session
from config import LESSON_EXERCISE_WEIGHT, LESSON_QUIZ_WEIGHT, LESSON_DONE_MIN_PERCENT
from services.models import AccessToken, ContentDraft, Lesson, QuizAttempt, StudentProgress, User
from services.evaluation import compute_composite
from services.lesson_service import get_lesson_components
from services.lesson_service import get_lesson_components, get_lesson_quiz_total
from services.progress_status import ParsedProgress
from services.student_roundtrip import (
RoundTripImportError,
@ -483,6 +483,24 @@ def list_students_for_export(
)
def get_lesson_expected_format(slug: str) -> str:
"""Tentukan panduan expected format kolom lesson berdasarkan komponennya.
- Exercise only: 'completed'
- Quiz only: '<earned>/N' jika total soal diketahui, atau '<earned>/<total>'
- Exercise + Quiz: 'done:<1|0>:<earned>/N' (atau '<total>')
- Reading only: 'completed'
"""
has_exercise, has_quiz = get_lesson_components(slug)
quiz_total = get_lesson_quiz_total(slug) if has_quiz else None
if has_exercise and has_quiz:
q_fmt = f"<earned>/{quiz_total}" if quiz_total else "<earned>/<total>"
return f"done:<1|0>:{q_fmt}"
if has_quiz:
return f"<earned>/{quiz_total}" if quiz_total else "<earned>/<total>"
return "completed"
def export_students_csv(
db: Session,
student_ids: list[str] | None = None,
@ -490,10 +508,15 @@ def export_students_csv(
"""Export siswa (selection atau seluruh) + progress → CSV round-trip bytes.
Kolom token selalu kosong; teacher tidak pernah ikut; export tidak mengubah DB.
Header kolom lesson dilengkapi format yang diharapkan (mis. 'hello_world [completed]').
"""
users = list_students_for_export(db, student_ids)
lessons = list_active_lessons(db)
lesson_slugs = [lesson.slug for lesson in lessons]
lesson_headers = {
lesson.slug: f"{lesson.slug} [{get_lesson_expected_format(lesson.slug)}]"
for lesson in lessons
}
rows: list[StudentRoundTripRow] = []
for user in users:
@ -527,7 +550,7 @@ def export_students_csv(
progress=progress,
)
)
return serialize_export_csv(rows, lesson_slugs)
return serialize_export_csv(rows, lesson_slugs, lesson_headers=lesson_headers)
def find_student_import_conflicts(
@ -707,6 +730,47 @@ def run_student_import(
quiz_weight=LESSON_QUIZ_WEIGHT,
done_min_percent=LESSON_DONE_MIN_PERCENT,
)
elif parsed.state == "completed":
has_exercise, has_quiz = get_lesson_components(slug)
progress = set_progress(
db,
user_id=user.id,
lesson_id=lesson.id,
state="completed",
)
if progress is not None:
if has_exercise:
progress.exercise_passed = True
progress.composite_percent = 100.0
db.flush()
elif parsed.state == "scored":
has_exercise, has_quiz = get_lesson_components(slug)
effective_has_quiz = has_quiz or (
parsed.score_total is not None and parsed.score_total > 0
)
progress = set_progress(
db,
user_id=user.id,
lesson_id=lesson.id,
state="scored",
score_earned=parsed.score_earned,
score_total=parsed.score_total,
)
if progress is not None:
progress.quiz_score_earned = parsed.score_earned
progress.quiz_score_total = parsed.score_total
result = compute_composite(
exercise_passed=progress.exercise_passed,
quiz_earned=progress.quiz_score_earned,
quiz_total=progress.quiz_score_total,
has_exercise=has_exercise,
has_quiz=effective_has_quiz,
exercise_weight=LESSON_EXERCISE_WEIGHT,
quiz_weight=LESSON_QUIZ_WEIGHT,
done_min_percent=LESSON_DONE_MIN_PERCENT,
)
progress.composite_percent = result.composite_percent
db.flush()
elif parsed.state == "reset":
set_progress(db, user_id=user.id, lesson_id=lesson.id, state="not_started")
progress_reset += 1

View File

@ -172,6 +172,40 @@ def _row_is_empty(record: dict) -> bool:
return all(not (v or "").strip() for v in record.values())
def extract_slug_from_header(header: str, active_slugs: set[str] | None = None) -> str:
"""Ekstrak slug lesson canonical dari nama kolom header.
Mendukung format:
- Bare slug: "hello_world"
- Slug + expected format: "hello_world [completed]", "quiz_test [<earned>/4]"
- Title + (slug) + format: "Hello World (hello_world) [completed]"
- Title + (slug): "Hello World (hello_world)"
"""
raw = (header or "").strip()
if not raw:
return ""
if active_slugs and raw in active_slugs:
return raw
# 1. Hapus kurung siku [expected format] di akhir bila ada
base = raw
if base.endswith("]") and "[" in base:
base = base[: base.rfind("[")].strip()
if active_slugs and base in active_slugs:
return base
# 2. Bila ada kurung biasa (slug) di akhir: "Title (slug)"
if base.endswith(")") and "(" in base:
inside = base[base.rfind("(") + 1 : -1].strip()
if active_slugs and inside in active_slugs:
return inside
if inside:
return inside
return base
def parse_roundtrip_csv(
content: str | bytes,
active_lesson_slugs: list[str] | set[str],
@ -226,14 +260,20 @@ def parse_roundtrip_csv(
errors.append(f"Header duplikat: {col!r}")
seen_headers.add(col)
lesson_columns: list[str] = []
header_to_slug: dict[str, str] = {}
seen_slugs: set[str] = set()
for col in fieldnames:
if col in REQUIRED_COLUMNS:
continue
if col not in active:
slug = extract_slug_from_header(col, active)
if slug not in active:
errors.append(f"Kolom lesson tidak dikenal atau tidak aktif: {col!r}")
continue
lesson_columns.append(col)
if slug in seen_slugs:
errors.append(f"Header duplikat untuk lesson: {col!r}")
continue
seen_slugs.add(slug)
header_to_slug[col] = slug
if errors:
raise RoundTripImportError(errors)
@ -322,12 +362,12 @@ def parse_roundtrip_csv(
# progress per lesson
progress: dict[str, ParsedProgress] = {}
for slug in lesson_columns:
value = record.get(slug) or ""
for col, slug in header_to_slug.items():
value = record.get(col) or ""
try:
parsed = parse_roundtrip_cell(value)
except ValueError as exc:
errors.append(f"Baris {line}, kolom {slug}: {exc}")
errors.append(f"Baris {line}, kolom {col}: {exc}")
continue
if parsed is not None:
progress[slug] = parsed
@ -350,15 +390,23 @@ def parse_roundtrip_csv(
def serialize_export_csv(
rows: list[StudentRoundTripRow],
lesson_slugs: list[str],
lesson_headers: dict[str, str] | None = None,
) -> bytes:
"""Serialize row export → CSV UTF-8 BOM, delimiter ';', token selalu kosong.
- Baris pertama: legend grammar sel progress (# Format kolom lesson...).
- Header: student_id;token;nama_siswa;<lesson_slugs...> sesuai urutan.
- Header: student_id;token;nama_siswa;<lesson_headers...> sesuai urutan.
- Status ditulis dengan grammar roundtrip (kosong/completed/<earned>/<total>/done:...).
- `completed_count`, role, hash token, dan metadata internal tidak diekspor.
"""
fieldnames = ["student_id", "token", "nama_siswa", *lesson_slugs]
headers_map = lesson_headers or {}
resolved_columns: list[tuple[str, str]] = []
for item in lesson_slugs:
header_label = headers_map.get(item, item)
canonical_slug = extract_slug_from_header(item)
resolved_columns.append((canonical_slug, header_label))
fieldnames = ["student_id", "token", "nama_siswa", *[lbl for _, lbl in resolved_columns]]
out = io.StringIO()
out.write(f"{LEGEND_COMMENT_LINE}\n")
writer = csv.DictWriter(
@ -371,7 +419,7 @@ def serialize_export_csv(
"token": "", # selalu kosong — tidak ada export/recovery token
"nama_siswa": row.display_name,
}
for slug in lesson_slugs:
record[slug] = format_roundtrip_cell(row.progress.get(slug))
for slug, col_name in resolved_columns:
record[col_name] = format_roundtrip_cell(row.progress.get(slug))
writer.writerow(record)
return out.getvalue().encode("utf-8-sig")

View File

@ -150,7 +150,7 @@ def test_export_csv_content_and_no_leak():
assert csv_bytes.startswith("\ufeff".encode("utf-8"))
lines = text.strip().splitlines()
assert lines[0].startswith("# Format kolom lesson:")
assert lines[1] == "student_id;token;nama_siswa;hello_world;variabel"
assert lines[1] == "student_id;token;nama_siswa;hello_world [completed];variabel [completed]"
assert len(lines) == 3 # hanya 1 siswa (teacher tidak ikut)
cols = lines[2].split(";")
assert cols[0] == s1.id
@ -612,7 +612,7 @@ def test_roundtrip_export_restore_delete_recreate():
text = csv_bytes.decode("utf-8-sig")
lines = text.strip().splitlines()
assert lines[0].startswith("# Format kolom lesson:")
assert lines[1] == "student_id;token;nama_siswa;hello_world;variabel"
assert lines[1] == "student_id;token;nama_siswa;hello_world [completed];variabel [completed]"
cols = lines[2].split(";")
assert cols[0] == s1.id and cols[1] == "" # token kosong
assert cols[2] == "Budi Santoso" and cols[3] == "completed" and cols[4] == "3/4"

View File

@ -691,3 +691,98 @@ def test_add_student_empty_name_rejected(client):
assert data["success"] is False
assert resp.status_code == 400
def test_export_modify_import_progress_sync_roundtrip(client):
"""Verifikasi bahwa modifikasi progres di CSV (completed, skor, RESET)
tersinkronisasi ke model progres komposit dan terefleksi di /progress-report.json.
"""
seeded = _seed_teacher_and_students()
_login_teacher(client)
s1, s2 = seeded["students"]
# 1) Export CSV awal
resp_export = client.post("/students/export-csv", json={"student_ids": [s1.id, s2.id]})
assert resp_export.status_code == 200
# 2) Ubah progres: s1 dapat 'completed' di hello_world, s2 dapat '3/4' di variabel
modified_rows = [
f"{s1.id};;{s1.display_name};completed;",
f"{s2.id};;{s2.display_name};;3/4",
]
csv_content = _valid_csv(modified_rows)
# 3) Import CSV kembali
resp_import = _upload(client, "/students/import", csv_content)
assert resp_import.status_code == 200
assert resp_import.get_json()["success"] is True
# 4) Periksa /progress-report.json
resp_report = client.get("/progress-report.json")
assert resp_report.status_code == 200
assert "no-store" in resp_report.headers.get("Cache-Control", "")
data = resp_report.get_json()
students = {st["id"]: st for st in data["students"]}
# s1 hello_world harus punya composite 100 dan status completed
st1 = students[s1.id]
assert st1["hello_world_composite"] == 100
assert st1["hello_world"] == "completed"
# s2 variabel harus punya quiz_score '3/4' dan composite terhitung
st2 = students[s2.id]
assert st2["variabel_quiz_score"] == "3/4"
assert st2["variabel_composite"] is not None
# 5) Reset progres s1 via CSV 'RESET'
reset_rows = [
f"{s1.id};;{s1.display_name};RESET;",
f"{s2.id};;{s2.display_name};;3/4",
]
resp_reset = _upload(client, "/students/import", _valid_csv(reset_rows))
assert resp_reset.status_code == 200
resp_report_after = client.get("/progress-report.json")
st1_after = next(s for s in resp_report_after.get_json()["students"] if s["id"] == s1.id)
assert st1_after["hello_world_composite"] is None
def test_export_csv_headers_contain_expected_format_guide(client):
"""Verifikasi bahwa file CSV yang diekspor memuat expected format di header kolom lesson
dan file tersebut dapat dimodifikasi serta diimpor kembali dengan sukses.
"""
seeded = _seed_teacher_and_students()
_login_teacher(client)
s1, s2 = seeded["students"]
resp_export = client.post("/students/export-csv", json={"student_ids": [s1.id, s2.id]})
assert resp_export.status_code == 200
text = resp_export.data.decode("utf-8-sig")
lines = text.strip().splitlines()
assert lines[0].startswith("# Format kolom lesson:")
header_line = lines[1]
assert "hello_world [completed]" in header_line
assert "variabel [completed]" in header_line
# Modifikasi langsung baris CSV hasil export dengan format baru
# Pertahankan header asli berformat: student_id;token;nama_siswa;hello_world [completed];variabel [completed]
modified_csv = (
f"{lines[0]}\n"
f"{header_line}\n"
f"{s1.id};;{s1.display_name};completed;\n"
f"{s2.id};;{s2.display_name};;3/4\n"
).encode("utf-8-sig")
resp_import = _upload(client, "/students/import", modified_csv)
assert resp_import.status_code == 200
assert resp_import.get_json()["success"] is True
resp_report = client.get("/progress-report.json")
assert resp_report.status_code == 200
data = resp_report.get_json()
students = {st["id"]: st for st in data["students"]}
assert students[s1.id]["hello_world_composite"] == 100
assert students[s2.id]["variabel_quiz_score"] == "3/4"

View File

@ -27,6 +27,7 @@ from services.student_roundtrip import (
StudentRoundTripRow,
is_canonical_uuid,
mask_student_id,
extract_slug_from_header,
parse_roundtrip_csv,
serialize_export_csv,
)
@ -699,3 +700,53 @@ def test_parse_roundtrip_csv_without_legend_row_backward_compatible():
"done", exercise_passed=True, quiz_earned=3, quiz_total=6
)
def test_extract_slug_from_header_various_formats():
active = {"hello_world", "quiz_test", "c_loop"}
assert extract_slug_from_header("hello_world", active) == "hello_world"
assert extract_slug_from_header("hello_world [completed]", active) == "hello_world"
assert extract_slug_from_header("quiz_test [<earned>/4]", active) == "quiz_test"
assert extract_slug_from_header("quiz_test [<earned>/<total>]", active) == "quiz_test"
assert extract_slug_from_header("c_loop [done:<1|0>:<earned>/5]", active) == "c_loop"
assert extract_slug_from_header("Hello World (hello_world) [completed]", active) == "hello_world"
assert extract_slug_from_header("Hello World (hello_world)", active) == "hello_world"
assert extract_slug_from_header("unknown_col [completed]", active) == "unknown_col"
def test_export_serializer_with_lesson_headers_mapping():
rows = _sample_export_rows()
lesson_headers = {
"hello_world": "hello_world [completed]",
"variabel": "variabel [<earned>/4]",
}
data = serialize_export_csv(rows, ["hello_world", "variabel"], lesson_headers=lesson_headers)
text = data.decode("utf-8-sig")
lines = text.strip().splitlines()
assert lines[1] == "student_id;token;nama_siswa;hello_world [completed];variabel [<earned>/4]"
assert lines[2].startswith(f"{UUID_1};;Nama Siswa 1;completed;3/4")
def test_parse_roundtrip_csv_with_inline_format_headers():
content = (
f"{LEGEND_COMMENT_LINE}\n"
"student_id;token;nama_siswa;hello_world [completed];variabel [<earned>/4]\n"
f"{UUID_1};;Budi;completed;3/4\n"
)
rows = parse_roundtrip_csv(content, ["hello_world", "variabel"])
assert len(rows) == 1
assert rows[0].student_id == UUID_1
assert rows[0].progress["hello_world"] == ParsedProgress("completed")
assert rows[0].progress["variabel"] == ParsedProgress("scored", 3, 4)
def test_parse_roundtrip_csv_error_with_inline_format_header_reports_column_name():
content = (
f"{LEGEND_COMMENT_LINE}\n"
"student_id;token;nama_siswa;hello_world [completed]\n"
f"{UUID_1};;Budi;invalid_format\n"
)
with pytest.raises(RoundTripImportError) as exc:
parse_roundtrip_csv(content, ["hello_world"])
assert "kolom hello_world [completed]" in "; ".join(exc.value.errors)