feat(progress): export laporan per bab/materi (poin 6)

Tambah filter ?bab= dan ?materi= pada export CSV, plus ikon unduh di kartu bab dan baris materi.
This commit is contained in:
a2nr 2026-10-27 16:08:55 +07:00
parent 2a5fdf8400
commit 7696a04d72
3 changed files with 162 additions and 3 deletions

View File

@ -555,6 +555,15 @@
<span class="badge badge-accent">{group.uniqueStudentsCount} siswa selesai</span>
</div>
</button>
<a
class="scope-export-btn sub-bab-export"
href="/api/progress-report/export-csv?token={encodeURIComponent($authToken)}&bab={encodeURIComponent(group.folder)}"
title="Export CSV materi dalam {group.title}"
aria-label="Export CSV sub-bab {group.title}"
download
>
&#8595;
</a>
<!-- Level 2: Daftar Materi di dalam Sub-Bab -->
{#if isSubBabOpen}
@ -592,6 +601,15 @@
{/if}
</div>
</button>
<a
class="scope-export-btn lesson-export"
href="/api/progress-report/export-csv?token={encodeURIComponent($authToken)}&materi={encodeURIComponent(item.key)}"
title="Export CSV materi {item.lesson.title}"
aria-label="Export CSV materi {item.lesson.title}"
download
>
&#8595;
</a>
<!-- Level 3: Siswa yang Pernah Menyelesaikan Materi -->
{#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);

View File

@ -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=<folder> dan/atau ?materi=<slug>,
# 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}"},
)

View File

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