diff --git a/frontend/src/routes/teacher/progress/+page.svelte b/frontend/src/routes/teacher/progress/+page.svelte index 5d7a6dd..7994a63 100644 --- a/frontend/src/routes/teacher/progress/+page.svelte +++ b/frontend/src/routes/teacher/progress/+page.svelte @@ -555,6 +555,15 @@ {group.uniqueStudentsCount} siswa selesai + + ↓ + {#if isSubBabOpen} @@ -592,6 +601,15 @@ {/if} + + ↓ + {#if isLessonOpen} @@ -876,6 +894,7 @@ } .sub-bab-card { + position: relative; border: 1px solid var(--color-border); border-radius: var(--radius); background: var(--color-bg); @@ -892,7 +911,7 @@ display: flex; align-items: center; gap: 0.6rem; - padding: 0.75rem 1rem; + padding: 0.75rem 2.6rem 0.75rem 1rem; background: var(--color-bg-secondary); border: none; border-bottom: 1px solid transparent; @@ -943,6 +962,38 @@ gap: 0.4rem; } + /* Ikon export per scope (satu klik, tanpa dialog) */ + .scope-export-btn { + position: absolute; + top: 0.55rem; + right: 0.6rem; + width: 1.6rem; + height: 1.6rem; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg); + color: var(--color-text-muted); + text-decoration: none; + font-size: 0.95rem; + line-height: 1; + z-index: 2; + transition: all 0.15s; + } + .scope-export-btn:hover { + color: var(--color-primary); + border-color: var(--color-primary); + } + .lesson-export { + top: 0.32rem; + right: 0.45rem; + width: 1.45rem; + height: 1.45rem; + font-size: 0.85rem; + } + /* Level 2: Lessons Container */ .lessons-container { padding: 0.4rem 0.5rem 0.4rem 1.25rem; @@ -962,6 +1013,7 @@ } .lesson-item { + position: relative; border-radius: var(--radius); overflow: hidden; transition: background-color 0.15s; @@ -972,7 +1024,7 @@ display: flex; align-items: center; gap: 0.5rem; - padding: 0.5rem 0.75rem 0.5rem 1.5rem; + padding: 0.5rem 2.3rem 0.5rem 1.5rem; background: transparent; border: 1px solid transparent; border-radius: var(--radius); diff --git a/routes/progress.py b/routes/progress.py index 39ed341..e5b49ab 100644 --- a/routes/progress.py +++ b/routes/progress.py @@ -6,6 +6,7 @@ import csv import io import json import logging +import re from flask import Blueprint, request, jsonify, Response @@ -56,6 +57,12 @@ def _weighted_points_compact(raw) -> str: return "|".join(parts) +def _safe_filename_part(value: str) -> str: + """Bersihkan nama scope utk filename CSV (folder bisa mengandung '/').""" + cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()) + return cleaned.strip("-._") or "scope" + + @progress_bp.route('/reset-progress', methods=['POST']) def reset_progress(): """Reset progress siswa via student_id (Teacher only). @@ -206,6 +213,36 @@ def export_progress_csv(): get_lessons_with_learning_objectives, ) + # Scope export (opsional): ?bab= dan/atau ?materi=, + # keduanya repeatable. Tanpa param = semua materi (perilaku lama). + bab_filters = {v for v in request.args.getlist('bab') if v} + materi_filters = {v for v in request.args.getlist('materi') if v} + if bab_filters or materi_filters: + # Pemetaan sub-bab SAMA dengan /progress-report.json agar scope konsisten. + sub_bab_map = {} + for folder in find_all_sub_home_folders(): + data = get_sub_home_data(folder) + if data and data.get('title'): + sub_bab_map[folder] = data['title'] + + filtered_lessons = [] + for lesson in ordered_lessons: + slug = lesson['filename'].replace('.md', '') + if materi_filters and slug not in materi_filters: + continue + if bab_filters: + file_path = lesson.get('path') or find_lesson_file(lesson['filename']) + folder_name = None + if file_path: + _sub_home_path, folder_name = find_sub_home_for_lesson(file_path) + lesson_bab = folder_name if folder_name in sub_bab_map else None + if lesson_bab not in bab_filters: + # Dukung kartu "Lainnya (Materi Umum)". + if not (lesson_bab is None and '_uncategorized' in bab_filters): + continue + filtered_lessons.append(lesson) + ordered_lessons = filtered_lessons + lesson_slugs = [l['filename'].replace('.md', '') for l in ordered_lessons] # Dua kolom breakdown JENIS per lesson (rapor guru): rasio benar MCQ dan # poin weighted. Kolom kosong bila jenis itu tidak ada di kuis lesson. @@ -244,9 +281,14 @@ def export_progress_csv(): row['completed_count'] = student.get('completed_count', 0) writer.writerow(row) + filename = 'progress_report.csv' + if bab_filters or materi_filters: + scope_parts = sorted(bab_filters) + sorted(materi_filters) + filename = f"progress_report_{'_'.join(_safe_filename_part(p) for p in scope_parts)}.csv" + return Response( output.getvalue(), mimetype='text/csv; charset=utf-8', - headers={"Content-Disposition": "attachment; filename=progress_report.csv"}, + headers={"Content-Disposition": f"attachment; filename={filename}"}, ) diff --git a/services/tests/test_progress_routes.py b/services/tests/test_progress_routes.py index b33e7f5..2a43c27 100644 --- a/services/tests/test_progress_routes.py +++ b/services/tests/test_progress_routes.py @@ -388,6 +388,71 @@ def test_export_csv_weighted_points_column(client): assert budi_row["hello_world_weighted_points"] == "10:1|7:1|0:1" +def test_export_csv_scope_bab_and_materi(client, monkeypatch): + """Export per bab/materi memfilter kolom; tanpa param tetap semua (kompatibel).""" + import csv + import io + + students = [{ + "nama_siswa": "Budi Santoso", + "id": "u1", + "role": "student", + "completed_count": 2, + "hello_world": "done", + "hello_world_composite": 90, + "variabel_test": "done", + "variabel_test_composite": 70, + }] + lessons = [ + {"filename": "hello_world.md", "title": "Hello World", + "path": "/content/bab_a/hello_world.md"}, + {"filename": "variabel_test.md", "title": "Variabel", + "path": "/content/bab_b/variabel_test.md"}, + ] + monkeypatch.setattr( + "routes.progress.get_all_students_progress", + lambda _fn: (students, lessons), + ) + monkeypatch.setattr("routes.progress.find_all_sub_home_folders", lambda: ["bab_a", "bab_b"]) + monkeypatch.setattr( + "routes.progress.get_sub_home_data", + lambda folder: {"title": f"Title {folder}"}, + ) + monkeypatch.setattr("routes.progress.find_lesson_file", lambda _name: None) + monkeypatch.setattr( + "routes.progress.find_sub_home_for_lesson", + lambda path: (None, "bab_a" if "bab_a" in path else "bab_b"), + ) + + def _fields(query): + resp = client.get(f"/progress-report/export-csv?token={TEACHER_TOKEN}{query}") + assert resp.status_code == 200 + reader = csv.DictReader(io.StringIO(resp.data.decode("utf-8-sig")), delimiter=";") + return reader.fieldnames, resp.headers.get("Content-Disposition", "") + + fields, _ = _fields("") + assert "hello_world" in fields and "variabel_test" in fields + + fields, disposition = _fields("&bab=bab_a") + assert "hello_world" in fields + assert "variabel_test" not in fields + assert "progress_report_bab_a.csv" in disposition + + fields, disposition = _fields("&materi=variabel_test") + assert "variabel_test" in fields + assert "hello_world" not in fields + assert "progress_report_variabel_test.csv" in disposition + + # Gabungan keduanya = irisan scope. + fields, _ = _fields("&bab=bab_b&materi=variabel_test") + assert "variabel_test" in fields + assert "hello_world" not in fields + + # Kartu "Lainnya (Materi Umum)" bisa diexport. + fields, _ = _fields("&bab=_uncategorized") + assert "hello_world" not in fields and "variabel_test" not in fields + + def test_report_legacy_answers_without_quiz_type(client): """Attempt lama tanpa quiz_type tetap terbaca sebagai MCQ (tanpa weighted).""" answers = [