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:
parent
2a5fdf8400
commit
7696a04d72
|
|
@ -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
|
||||
>
|
||||
↓
|
||||
</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
|
||||
>
|
||||
↓
|
||||
</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);
|
||||
|
|
|
|||
|
|
@ -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}"},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Reference in New Issue