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 = [