266 lines
9.1 KiB
Python
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,
|
|
}
|
|
)
|