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:
parent
17713303bb
commit
9bfecfbcab
|
|
@ -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---
|
||||||
|
|
@ -9,5 +9,6 @@ Materi fundamental untuk memulai belajar pemrograman C.
|
||||||
4. [Uji Coba Kuis](lesson/quiz_test.md)
|
4. [Uji Coba Kuis](lesson/quiz_test.md)
|
||||||
5. [Uji Coba LaTeX](lesson/latex_test.md)
|
5. [Uji Coba LaTeX](lesson/latex_test.md)
|
||||||
6. [Uji Coba Slide](lesson/test_slides.md)
|
6. [Uji Coba Slide](lesson/test_slides.md)
|
||||||
|
7. [Uji Coba Komposit](lesson/composite_test.md)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,9 @@
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
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();
|
const data = await res.json();
|
||||||
students = data.students ?? [];
|
students = data.students ?? [];
|
||||||
lessons = data.lessons ?? [];
|
lessons = data.lessons ?? [];
|
||||||
|
|
@ -286,6 +288,7 @@
|
||||||
</td>
|
</td>
|
||||||
{#each lessons as lesson}
|
{#each lessons as lesson}
|
||||||
{@const key = lesson.filename.replace('.md', '')}
|
{@const key = lesson.filename.replace('.md', '')}
|
||||||
|
{@const status = student[key]}
|
||||||
{@const composite = student[key + '_composite']}
|
{@const composite = student[key + '_composite']}
|
||||||
{@const exercisePassed = student[key + '_exercise_passed']}
|
{@const exercisePassed = student[key + '_exercise_passed']}
|
||||||
{@const quizScore = student[key + '_quiz_score']}
|
{@const quizScore = student[key + '_quiz_score']}
|
||||||
|
|
@ -305,6 +308,10 @@
|
||||||
{#if quizScore}
|
{#if quizScore}
|
||||||
<span class="badge quiz-score" title="Skor quiz">{quizScore}</span>
|
<span class="badge quiz-score" title="Skor quiz">{quizScore}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if status === 'completed'}
|
||||||
|
<span class="badge exercise-done" title="Selesai">✓</span>
|
||||||
|
{:else if status && status !== 'not_started'}
|
||||||
|
<span class="badge score" title="Status progres">{status}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="badge empty">—</span>
|
<span class="badge empty">—</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -333,7 +340,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if composite !== null && composite !== undefined}
|
{#if (composite !== null && composite !== undefined) || (status && status !== 'not_started')}
|
||||||
<button
|
<button
|
||||||
class="btn-reset-mini"
|
class="btn-reset-mini"
|
||||||
onclick={() => handleReset(student.id as string, key, student.nama_siswa)}
|
onclick={() => handleReset(student.id as string, key, student.nama_siswa)}
|
||||||
|
|
|
||||||
|
|
@ -85,10 +85,12 @@ def api_progress_report():
|
||||||
get_lessons_with_learning_objectives,
|
get_lessons_with_learning_objectives,
|
||||||
)
|
)
|
||||||
|
|
||||||
return jsonify({
|
resp = jsonify({
|
||||||
'students': all_students_progress,
|
'students': all_students_progress,
|
||||||
'lessons': [{'filename': l['filename'], 'title': l['title']} for l in ordered_lessons],
|
'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')
|
@progress_bp.route('/progress-report/export-csv')
|
||||||
|
|
|
||||||
|
|
@ -1304,3 +1304,35 @@ def get_lesson_components(lesson_name: str) -> tuple[bool, bool]:
|
||||||
has_quiz = "quiz" in tabs
|
has_quiz = "quiz" in tabs
|
||||||
return (has_exercise, has_quiz)
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from sqlalchemy.orm import Session
|
||||||
from config import LESSON_EXERCISE_WEIGHT, LESSON_QUIZ_WEIGHT, LESSON_DONE_MIN_PERCENT
|
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.models import AccessToken, ContentDraft, Lesson, QuizAttempt, StudentProgress, User
|
||||||
from services.evaluation import compute_composite
|
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.progress_status import ParsedProgress
|
||||||
from services.student_roundtrip import (
|
from services.student_roundtrip import (
|
||||||
RoundTripImportError,
|
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(
|
def export_students_csv(
|
||||||
db: Session,
|
db: Session,
|
||||||
student_ids: list[str] | None = None,
|
student_ids: list[str] | None = None,
|
||||||
|
|
@ -490,10 +508,15 @@ def export_students_csv(
|
||||||
"""Export siswa (selection atau seluruh) + progress → CSV round-trip bytes.
|
"""Export siswa (selection atau seluruh) + progress → CSV round-trip bytes.
|
||||||
|
|
||||||
Kolom token selalu kosong; teacher tidak pernah ikut; export tidak mengubah DB.
|
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)
|
users = list_students_for_export(db, student_ids)
|
||||||
lessons = list_active_lessons(db)
|
lessons = list_active_lessons(db)
|
||||||
lesson_slugs = [lesson.slug for lesson in lessons]
|
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] = []
|
rows: list[StudentRoundTripRow] = []
|
||||||
for user in users:
|
for user in users:
|
||||||
|
|
@ -527,7 +550,7 @@ def export_students_csv(
|
||||||
progress=progress,
|
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(
|
def find_student_import_conflicts(
|
||||||
|
|
@ -707,6 +730,47 @@ def run_student_import(
|
||||||
quiz_weight=LESSON_QUIZ_WEIGHT,
|
quiz_weight=LESSON_QUIZ_WEIGHT,
|
||||||
done_min_percent=LESSON_DONE_MIN_PERCENT,
|
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":
|
elif parsed.state == "reset":
|
||||||
set_progress(db, user_id=user.id, lesson_id=lesson.id, state="not_started")
|
set_progress(db, user_id=user.id, lesson_id=lesson.id, state="not_started")
|
||||||
progress_reset += 1
|
progress_reset += 1
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,40 @@ def _row_is_empty(record: dict) -> bool:
|
||||||
return all(not (v or "").strip() for v in record.values())
|
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(
|
def parse_roundtrip_csv(
|
||||||
content: str | bytes,
|
content: str | bytes,
|
||||||
active_lesson_slugs: list[str] | set[str],
|
active_lesson_slugs: list[str] | set[str],
|
||||||
|
|
@ -226,14 +260,20 @@ def parse_roundtrip_csv(
|
||||||
errors.append(f"Header duplikat: {col!r}")
|
errors.append(f"Header duplikat: {col!r}")
|
||||||
seen_headers.add(col)
|
seen_headers.add(col)
|
||||||
|
|
||||||
lesson_columns: list[str] = []
|
header_to_slug: dict[str, str] = {}
|
||||||
|
seen_slugs: set[str] = set()
|
||||||
for col in fieldnames:
|
for col in fieldnames:
|
||||||
if col in REQUIRED_COLUMNS:
|
if col in REQUIRED_COLUMNS:
|
||||||
continue
|
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}")
|
errors.append(f"Kolom lesson tidak dikenal atau tidak aktif: {col!r}")
|
||||||
continue
|
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:
|
if errors:
|
||||||
raise RoundTripImportError(errors)
|
raise RoundTripImportError(errors)
|
||||||
|
|
@ -322,12 +362,12 @@ def parse_roundtrip_csv(
|
||||||
|
|
||||||
# progress per lesson
|
# progress per lesson
|
||||||
progress: dict[str, ParsedProgress] = {}
|
progress: dict[str, ParsedProgress] = {}
|
||||||
for slug in lesson_columns:
|
for col, slug in header_to_slug.items():
|
||||||
value = record.get(slug) or ""
|
value = record.get(col) or ""
|
||||||
try:
|
try:
|
||||||
parsed = parse_roundtrip_cell(value)
|
parsed = parse_roundtrip_cell(value)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
errors.append(f"Baris {line}, kolom {slug}: {exc}")
|
errors.append(f"Baris {line}, kolom {col}: {exc}")
|
||||||
continue
|
continue
|
||||||
if parsed is not None:
|
if parsed is not None:
|
||||||
progress[slug] = parsed
|
progress[slug] = parsed
|
||||||
|
|
@ -350,15 +390,23 @@ def parse_roundtrip_csv(
|
||||||
def serialize_export_csv(
|
def serialize_export_csv(
|
||||||
rows: list[StudentRoundTripRow],
|
rows: list[StudentRoundTripRow],
|
||||||
lesson_slugs: list[str],
|
lesson_slugs: list[str],
|
||||||
|
lesson_headers: dict[str, str] | None = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Serialize row export → CSV UTF-8 BOM, delimiter ';', token selalu kosong.
|
"""Serialize row export → CSV UTF-8 BOM, delimiter ';', token selalu kosong.
|
||||||
|
|
||||||
- Baris pertama: legend grammar sel progress (# Format kolom lesson...).
|
- 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:...).
|
- Status ditulis dengan grammar roundtrip (kosong/completed/<earned>/<total>/done:...).
|
||||||
- `completed_count`, role, hash token, dan metadata internal tidak diekspor.
|
- `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 = io.StringIO()
|
||||||
out.write(f"{LEGEND_COMMENT_LINE}\n")
|
out.write(f"{LEGEND_COMMENT_LINE}\n")
|
||||||
writer = csv.DictWriter(
|
writer = csv.DictWriter(
|
||||||
|
|
@ -371,7 +419,7 @@ def serialize_export_csv(
|
||||||
"token": "", # selalu kosong — tidak ada export/recovery token
|
"token": "", # selalu kosong — tidak ada export/recovery token
|
||||||
"nama_siswa": row.display_name,
|
"nama_siswa": row.display_name,
|
||||||
}
|
}
|
||||||
for slug in lesson_slugs:
|
for slug, col_name in resolved_columns:
|
||||||
record[slug] = format_roundtrip_cell(row.progress.get(slug))
|
record[col_name] = format_roundtrip_cell(row.progress.get(slug))
|
||||||
writer.writerow(record)
|
writer.writerow(record)
|
||||||
return out.getvalue().encode("utf-8-sig")
|
return out.getvalue().encode("utf-8-sig")
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ def test_export_csv_content_and_no_leak():
|
||||||
assert csv_bytes.startswith("\ufeff".encode("utf-8"))
|
assert csv_bytes.startswith("\ufeff".encode("utf-8"))
|
||||||
lines = text.strip().splitlines()
|
lines = text.strip().splitlines()
|
||||||
assert lines[0].startswith("# Format kolom lesson:")
|
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)
|
assert len(lines) == 3 # hanya 1 siswa (teacher tidak ikut)
|
||||||
cols = lines[2].split(";")
|
cols = lines[2].split(";")
|
||||||
assert cols[0] == s1.id
|
assert cols[0] == s1.id
|
||||||
|
|
@ -612,7 +612,7 @@ def test_roundtrip_export_restore_delete_recreate():
|
||||||
text = csv_bytes.decode("utf-8-sig")
|
text = csv_bytes.decode("utf-8-sig")
|
||||||
lines = text.strip().splitlines()
|
lines = text.strip().splitlines()
|
||||||
assert lines[0].startswith("# Format kolom lesson:")
|
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(";")
|
cols = lines[2].split(";")
|
||||||
assert cols[0] == s1.id and cols[1] == "" # token kosong
|
assert cols[0] == s1.id and cols[1] == "" # token kosong
|
||||||
assert cols[2] == "Budi Santoso" and cols[3] == "completed" and cols[4] == "3/4"
|
assert cols[2] == "Budi Santoso" and cols[3] == "completed" and cols[4] == "3/4"
|
||||||
|
|
|
||||||
|
|
@ -691,3 +691,98 @@ def test_add_student_empty_name_rejected(client):
|
||||||
assert data["success"] is False
|
assert data["success"] is False
|
||||||
assert resp.status_code == 400
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ from services.student_roundtrip import (
|
||||||
StudentRoundTripRow,
|
StudentRoundTripRow,
|
||||||
is_canonical_uuid,
|
is_canonical_uuid,
|
||||||
mask_student_id,
|
mask_student_id,
|
||||||
|
extract_slug_from_header,
|
||||||
parse_roundtrip_csv,
|
parse_roundtrip_csv,
|
||||||
serialize_export_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
|
"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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue