elemes/routes/student_management.py

266 lines
9.1 KiB
Python

"""
Student management (teacher-only) — round-trip CSV siswa.
Endpoints:
POST /students/export-csv JSON: {"student_ids": [...]}
POST /students/import/preview multipart: file
POST /students/import multipart: file
POST /students/bulk-delete JSON: {"student_ids": [...]}
Keamanan:
- Autentikasi guru HANYA dari cookie HttpOnly `student_token` — teacher token
tidak diterima dari query maupun body.
- Validasi Origin untuk semua POST.
- Upload diproses in-memory (tidak pernah ditulis ke /tmp/filesystem).
- Response preview/import/delete TIDAK memuat raw token, token hash, maupun
exception internal. Token plaintext tidak pernah di-log.
"""
import logging
import os
from datetime import datetime
from flask import Blueprint, Response, jsonify, request
from services import repositories
from services.database import SessionLocal
from services.student_roundtrip import (
MAX_FILE_BYTES,
RoundTripImportError,
mask_student_id,
parse_roundtrip_csv,
)
from services.token_service import validate_token
student_management_bp = Blueprint("student_management", __name__)
logger = logging.getLogger(__name__)
ALLOWED_EXTENSIONS = {"csv"}
def _teacher_from_cookie():
"""Guru dari cookie HttpOnly `student_token`; None bila tidak terautentikasi."""
token = request.cookies.get("student_token", "")
if not token:
return None
info = validate_token(token)
if not info or not info.get("is_teacher"):
return None
return info
def _check_origin() -> bool:
"""Origin harus sesuai konfigurasi aplikasi (ORIGIN) bila origin dikirim."""
allowed = os.environ.get("ORIGIN", "").strip()
if not allowed or allowed == "*":
return True
origin = request.headers.get("Origin", "")
if not origin:
return True # same-origin / non-browser client
return origin in {allowed, allowed.rstrip("/")}
def _active_lesson_slugs():
if SessionLocal is None:
return []
db = SessionLocal()
try:
return [lesson.slug for lesson in repositories.list_active_lessons(db)]
finally:
db.close()
def _read_csv_upload():
"""Baca file multipart in-memory. Return (bytes, filename) atau raise ValueError."""
file = request.files.get("file")
if file is None or not file.filename:
raise ValueError("File CSV wajib disertakan (field 'file')")
filename = file.filename
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if ext not in ALLOWED_EXTENSIONS:
raise ValueError("Ekstensi file harus .csv")
# Cek ukuran SEBELUM membaca isi agar upload raksasa tidak di-buffer penuh.
content_length = getattr(file, "content_length", None)
if content_length and content_length > MAX_FILE_BYTES:
raise ValueError(f"File melebihi batas {MAX_FILE_BYTES // (1024 * 1024)} MiB")
content = file.read()
if not content:
raise ValueError("File kosong")
if len(content) > MAX_FILE_BYTES:
raise ValueError(f"File melebihi batas {MAX_FILE_BYTES // (1024 * 1024)} MiB")
return content, filename
@student_management_bp.route("/students/export-csv", methods=["POST"])
def export_students():
if not _check_origin():
return jsonify({"success": False, "message": "Origin tidak diizinkan"}), 403
teacher = _teacher_from_cookie()
if teacher is None:
return jsonify({"success": False, "message": "Unauthorized (Teacher only)"}), 401
if SessionLocal is None:
return jsonify({"success": False, "message": "PostgreSQL tidak aktif"}), 503
try:
data = request.get_json(silent=True, force=True) or {}
student_ids = data.get("student_ids", []) or []
except Exception: # noqa: BLE001
student_ids = []
db = SessionLocal()
try:
csv_bytes = repositories.export_students_csv(db, student_ids)
except ValueError as exc:
db.rollback()
return jsonify({"success": False, "message": str(exc)}), 400
except Exception: # noqa: BLE001 — jangan bocorkan detail internal
db.rollback()
logger.exception("Export CSV gagal")
return jsonify({"success": False, "message": "Gagal mengekspor siswa"}), 500
finally:
db.close()
filename = f"data_siswa_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
return Response(
csv_bytes,
mimetype="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@student_management_bp.route("/students/import/preview", methods=["POST"])
def import_preview():
if not _check_origin():
return jsonify({"success": False, "message": "Origin tidak diizinkan"}), 403
teacher = _teacher_from_cookie()
if teacher is None:
return jsonify({"success": False, "message": "Unauthorized (Teacher only)"}), 401
if SessionLocal is None:
return jsonify({"success": False, "message": "PostgreSQL tidak aktif"}), 503
try:
content, _filename = _read_csv_upload()
except ValueError as exc:
return jsonify({"success": False, "message": str(exc)}), 400
try:
rows = parse_roundtrip_csv(content, _active_lesson_slugs())
except RoundTripImportError as exc:
return jsonify({"success": False, "message": "File tidak valid", "errors": exc.errors}), 400
db = SessionLocal()
try:
summary = repositories.preview_student_import(db, rows)
finally:
db.close()
# Preview memakai nama & masked student_id — TIDAK token/hash.
preview_rows = [
{
"line": row.line,
"student_id": mask_student_id(row.student_id),
"nama_siswa": row.display_name,
"progress_lessons": len(row.progress),
}
for row in rows
]
return jsonify({"success": True, "summary": summary, "rows": preview_rows})
@student_management_bp.route("/students/import", methods=["POST"])
def import_students():
if not _check_origin():
return jsonify({"success": False, "message": "Origin tidak diizinkan"}), 403
teacher = _teacher_from_cookie()
if teacher is None:
return jsonify({"success": False, "message": "Unauthorized (Teacher only)"}), 401
if SessionLocal is None:
return jsonify({"success": False, "message": "PostgreSQL tidak aktif"}), 503
try:
content, _filename = _read_csv_upload()
except ValueError as exc:
return jsonify({"success": False, "message": str(exc)}), 400
try:
rows = parse_roundtrip_csv(content, _active_lesson_slugs())
except RoundTripImportError as exc:
return jsonify({"success": False, "message": "File tidak valid", "errors": exc.errors}), 400
db = SessionLocal()
try:
result = repositories.run_student_import(db, rows)
except RoundTripImportError as exc:
db.rollback()
return jsonify(
{
"success": False,
"message": "Import ditolak — ada data siswa/token yang bertentangan dengan database",
"errors": exc.errors,
}
), 409
except Exception: # noqa: BLE001 — jangan bocorkan exception internal
db.rollback()
logger.exception("Import CSV gagal")
return jsonify({"success": False, "message": "Gagal mengimpor siswa"}), 500
finally:
db.close()
logger.info(
"Import CSV: students_created=%s students_updated=%s "
"progress_created=%s progress_restored=%s",
result["students_created"],
result["students_updated"],
result["progress_created"],
result["progress_restored"],
)
return jsonify(
{
"success": True,
"students_created": result["students_created"],
"students_updated": result["students_updated"],
"progress_created": result["progress_created"],
"progress_restored": result["progress_restored"],
}
)
@student_management_bp.route("/students/bulk-delete", methods=["POST"])
def bulk_delete():
if not _check_origin():
return jsonify({"success": False, "message": "Origin tidak diizinkan"}), 403
teacher = _teacher_from_cookie()
if teacher is None:
return jsonify({"success": False, "message": "Unauthorized (Teacher only)"}), 401
if SessionLocal is None:
return jsonify({"success": False, "message": "PostgreSQL tidak aktif"}), 503
try:
data = request.get_json(silent=True, force=True) or {}
student_ids = data.get("student_ids", [])
except Exception: # noqa: BLE001
student_ids = []
db = SessionLocal()
try:
deleted_ids = repositories.delete_students(db, student_ids)
db.commit()
except ValueError as exc:
db.rollback()
return jsonify({"success": False, "message": str(exc)}), 400
except Exception: # noqa: BLE001
db.rollback()
logger.exception("Bulk delete gagal")
return jsonify({"success": False, "message": "Gagal menghapus siswa"}), 500
finally:
db.close()
return jsonify(
{
"success": True,
"deleted_count": len(deleted_ids),
"deleted_ids": deleted_ids,
}
)