2026-08-08 11:57:08 +07:00
|
|
|
"""
|
|
|
|
|
Repository layer — seluruh akses data PostgreSQL lewat SQLAlchemy session.
|
|
|
|
|
|
|
|
|
|
Dipanggil oleh service/facade; session & commit dikelola pemanggil
|
|
|
|
|
(mis. get_db() per request). Kontrak perilaku dijamin test integrasi
|
|
|
|
|
terhadap PostgreSQL nyata (test_repositories.py) dan suite kontrak
|
|
|
|
|
(test_token_service_contract.py) yang sama untuk kedua backend.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import func, select
|
feat(student-management): kelola siswa round-trip CSV + bulk delete (PG)
- Export siswa terpilih/seluruh + progress ke CSV round-trip (token tidak
pernah diekspor; teacher tidak ikut; urutan deterministik)
- Import preview all-or-nothing: siswa existing di-restore (nama+progress,
token lama dipertahankan), siswa baru dibuat dari student_id kosong +
token; conflict tidak menyebut raw token/digest
- Bulk delete multi-seleksi dengan validasi ketat (teacher terlindungi,
zero-delete bila ada ID bermasalah)
- fix(import): deteksi delimiter berbasis schema — menerima titik koma
(;) maupun koma (,), ekspor tetap ';' + BOM; header tab/campuran ditolak
dengan pesan jelas (regresi: file Excel/Sheets kini bisa diimpor)
- refactor: progress_status.py — kontrak status legacy tunggal dipakai
storage PG, export, dan import; report siswa hanya role student
- UI /progress: dialog import/export + bulk delete, selection store,
respon import tanpa raw token; APP_VERSION 22 -> 26
- ops: ORIGIN env var di podman-compose.yml
- docs: README section 9 (workflow round-trip + delimiter) dan
docs/11-database-migration (kelola siswa via round-trip CSV)
- test: parser round-trip (45), repositories, route management (24,
PostgreSQL), frontend 91, fixtures CSV valid/invalid
2026-08-09 07:29:19 +07:00
|
|
|
from sqlalchemy.exc import IntegrityError
|
2026-08-08 11:57:08 +07:00
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
|
|
from services.models import AccessToken, Lesson, StudentProgress, User
|
feat(student-management): kelola siswa round-trip CSV + bulk delete (PG)
- Export siswa terpilih/seluruh + progress ke CSV round-trip (token tidak
pernah diekspor; teacher tidak ikut; urutan deterministik)
- Import preview all-or-nothing: siswa existing di-restore (nama+progress,
token lama dipertahankan), siswa baru dibuat dari student_id kosong +
token; conflict tidak menyebut raw token/digest
- Bulk delete multi-seleksi dengan validasi ketat (teacher terlindungi,
zero-delete bila ada ID bermasalah)
- fix(import): deteksi delimiter berbasis schema — menerima titik koma
(;) maupun koma (,), ekspor tetap ';' + BOM; header tab/campuran ditolak
dengan pesan jelas (regresi: file Excel/Sheets kini bisa diimpor)
- refactor: progress_status.py — kontrak status legacy tunggal dipakai
storage PG, export, dan import; report siswa hanya role student
- UI /progress: dialog import/export + bulk delete, selection store,
respon import tanpa raw token; APP_VERSION 22 -> 26
- ops: ORIGIN env var di podman-compose.yml
- docs: README section 9 (workflow round-trip + delimiter) dan
docs/11-database-migration (kelola siswa via round-trip CSV)
- test: parser round-trip (45), repositories, route management (24,
PostgreSQL), frontend 91, fixtures CSV valid/invalid
2026-08-09 07:29:19 +07:00
|
|
|
from services.progress_status import ParsedProgress
|
|
|
|
|
from services.student_roundtrip import (
|
|
|
|
|
RoundTripImportError,
|
|
|
|
|
StudentRoundTripRow,
|
|
|
|
|
is_canonical_uuid,
|
|
|
|
|
serialize_export_csv,
|
|
|
|
|
)
|
2026-08-08 11:57:08 +07:00
|
|
|
from services.token_hashing import hash_token
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _uuid() -> str:
|
|
|
|
|
return str(uuid4())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── users & access_tokens ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_user_by_id(db: Session, user_id: str) -> User | None:
|
|
|
|
|
return db.get(User, user_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_user_by_token(db: Session, raw_token: str) -> User | None:
|
|
|
|
|
"""Lookup user dari token mentah. Update last_used_at (tanpa commit)."""
|
|
|
|
|
digest = hash_token(raw_token)
|
|
|
|
|
token = db.scalar(
|
|
|
|
|
select(AccessToken).where(
|
|
|
|
|
AccessToken.token_hash == digest,
|
|
|
|
|
AccessToken.revoked_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if token is None:
|
|
|
|
|
return None
|
|
|
|
|
user = db.get(User, token.user_id)
|
|
|
|
|
if user is None or not user.is_active:
|
|
|
|
|
return None
|
|
|
|
|
token.last_used_at = datetime.now(timezone.utc)
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def find_user_by_raw_token(db: Session, raw_token: str) -> User | None:
|
|
|
|
|
"""Lookup user dari token mentah TANPA side-effect (untuk importer/verifikasi)."""
|
|
|
|
|
digest = hash_token(raw_token)
|
|
|
|
|
token = db.scalar(select(AccessToken).where(AccessToken.token_hash == digest))
|
|
|
|
|
if token is None:
|
|
|
|
|
return None
|
|
|
|
|
return db.get(User, token.user_id)
|
|
|
|
|
|
|
|
|
|
|
feat(student-management): kelola siswa round-trip CSV + bulk delete (PG)
- Export siswa terpilih/seluruh + progress ke CSV round-trip (token tidak
pernah diekspor; teacher tidak ikut; urutan deterministik)
- Import preview all-or-nothing: siswa existing di-restore (nama+progress,
token lama dipertahankan), siswa baru dibuat dari student_id kosong +
token; conflict tidak menyebut raw token/digest
- Bulk delete multi-seleksi dengan validasi ketat (teacher terlindungi,
zero-delete bila ada ID bermasalah)
- fix(import): deteksi delimiter berbasis schema — menerima titik koma
(;) maupun koma (,), ekspor tetap ';' + BOM; header tab/campuran ditolak
dengan pesan jelas (regresi: file Excel/Sheets kini bisa diimpor)
- refactor: progress_status.py — kontrak status legacy tunggal dipakai
storage PG, export, dan import; report siswa hanya role student
- UI /progress: dialog import/export + bulk delete, selection store,
respon import tanpa raw token; APP_VERSION 22 -> 26
- ops: ORIGIN env var di podman-compose.yml
- docs: README section 9 (workflow round-trip + delimiter) dan
docs/11-database-migration (kelola siswa via round-trip CSV)
- test: parser round-trip (45), repositories, route management (24,
PostgreSQL), frontend 91, fixtures CSV valid/invalid
2026-08-09 07:29:19 +07:00
|
|
|
def create_user(
|
|
|
|
|
db: Session,
|
|
|
|
|
*,
|
|
|
|
|
display_name: str,
|
|
|
|
|
role: str,
|
|
|
|
|
is_active: bool = True,
|
|
|
|
|
user_id: str | None = None,
|
|
|
|
|
) -> User:
|
|
|
|
|
"""Buat user baru; `user_id` opsional (create-with-ID untuk import round-trip).
|
|
|
|
|
|
|
|
|
|
Caller existing tidak berubah — parameter baru bersifat opsional.
|
|
|
|
|
"""
|
|
|
|
|
kwargs: dict = {"display_name": display_name, "role": role, "is_active": is_active}
|
|
|
|
|
if user_id is not None:
|
|
|
|
|
kwargs["id"] = user_id
|
|
|
|
|
user = User(**kwargs)
|
2026-08-08 11:57:08 +07:00
|
|
|
db.add(user)
|
|
|
|
|
db.flush()
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_access_token(
|
|
|
|
|
db: Session, *, user_id: str, raw_token: str
|
|
|
|
|
) -> AccessToken:
|
|
|
|
|
token = AccessToken(id=_uuid(), user_id=user_id, token_hash=hash_token(raw_token))
|
|
|
|
|
db.add(token)
|
|
|
|
|
db.flush()
|
|
|
|
|
return token
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def revoke_token(db: Session, *, user_id: str, raw_token: str) -> bool:
|
|
|
|
|
digest = hash_token(raw_token)
|
|
|
|
|
token = db.scalar(
|
|
|
|
|
select(AccessToken).where(
|
|
|
|
|
AccessToken.token_hash == digest,
|
|
|
|
|
AccessToken.user_id == user_id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if token is None:
|
|
|
|
|
return False
|
|
|
|
|
token.revoked_at = datetime.now(timezone.utc)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def revoke_all_tokens(db: Session, *, user_id: str) -> int:
|
|
|
|
|
tokens = list(
|
|
|
|
|
db.scalars(
|
|
|
|
|
select(AccessToken).where(
|
|
|
|
|
AccessToken.user_id == user_id,
|
|
|
|
|
AccessToken.revoked_at.is_(None),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
for token in tokens:
|
|
|
|
|
token.revoked_at = datetime.now(timezone.utc)
|
|
|
|
|
return len(tokens)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── lessons ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_lessons(db: Session) -> list[Lesson]:
|
|
|
|
|
return list(db.scalars(select(Lesson).order_by(Lesson.order_index, Lesson.slug)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_active_lessons(db: Session) -> list[Lesson]:
|
|
|
|
|
return list(
|
|
|
|
|
db.scalars(select(Lesson).where(Lesson.is_active.is_(True)).order_by(Lesson.order_index, Lesson.slug))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_lesson_by_slug(db: Session, slug: str) -> Lesson | None:
|
|
|
|
|
return db.scalar(select(Lesson).where(Lesson.slug == slug))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def upsert_lesson(db: Session, *, slug: str, title: str, order_index: int) -> Lesson:
|
|
|
|
|
lesson = get_lesson_by_slug(db, slug)
|
|
|
|
|
if lesson is None:
|
|
|
|
|
lesson = Lesson(slug=slug, title=title, order_index=order_index)
|
|
|
|
|
db.add(lesson)
|
|
|
|
|
else:
|
|
|
|
|
lesson.title = title
|
|
|
|
|
lesson.order_index = order_index
|
|
|
|
|
lesson.is_active = True
|
|
|
|
|
db.flush()
|
|
|
|
|
return lesson
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def deactivate_missing_lessons(db: Session, active_slugs: set[str]) -> int:
|
|
|
|
|
"""Lesson yang tidak ada lagi di Markdown → is_active=False (bukan dihapus)."""
|
|
|
|
|
lessons = list(db.scalars(select(Lesson).where(Lesson.is_active.is_(True))))
|
|
|
|
|
changed = 0
|
|
|
|
|
for lesson in lessons:
|
|
|
|
|
if lesson.slug not in active_slugs:
|
|
|
|
|
lesson.is_active = False
|
|
|
|
|
changed += 1
|
|
|
|
|
return changed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── student_progress ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_progress(db: Session, *, user_id: str, lesson_id: str) -> StudentProgress | None:
|
|
|
|
|
return db.scalar(
|
|
|
|
|
select(StudentProgress).where(
|
|
|
|
|
StudentProgress.user_id == user_id,
|
|
|
|
|
StudentProgress.lesson_id == lesson_id,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_progress_for_user(db: Session, *, user_id: str) -> list[StudentProgress]:
|
|
|
|
|
return list(
|
|
|
|
|
db.scalars(select(StudentProgress).where(StudentProgress.user_id == user_id))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_progress(
|
|
|
|
|
db: Session,
|
|
|
|
|
*,
|
|
|
|
|
user_id: str,
|
|
|
|
|
lesson_id: str,
|
|
|
|
|
state: str,
|
|
|
|
|
score_earned: int | None = None,
|
|
|
|
|
score_total: int | None = None,
|
|
|
|
|
) -> StudentProgress | None:
|
|
|
|
|
"""Upsert progress (sparse model).
|
|
|
|
|
|
|
|
|
|
- state='not_started' → HAPUS row bila ada; tidak ada row = not_started.
|
|
|
|
|
- state='scored' wajib membawa score_earned & score_total.
|
|
|
|
|
"""
|
|
|
|
|
if state == "not_started":
|
|
|
|
|
progress = get_progress(db, user_id=user_id, lesson_id=lesson_id)
|
|
|
|
|
if progress is not None:
|
|
|
|
|
db.delete(progress)
|
|
|
|
|
db.flush()
|
|
|
|
|
return None
|
|
|
|
|
if state == "scored":
|
|
|
|
|
if score_earned is None or score_total is None:
|
|
|
|
|
raise ValueError("state='scored' memerlukan score_earned & score_total")
|
|
|
|
|
else:
|
|
|
|
|
score_earned = None
|
|
|
|
|
score_total = None
|
|
|
|
|
|
|
|
|
|
progress = get_progress(db, user_id=user_id, lesson_id=lesson_id)
|
|
|
|
|
if progress is None:
|
|
|
|
|
progress = StudentProgress(
|
|
|
|
|
id=_uuid(),
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
lesson_id=lesson_id,
|
|
|
|
|
state=state,
|
|
|
|
|
score_earned=score_earned,
|
|
|
|
|
score_total=score_total,
|
|
|
|
|
)
|
|
|
|
|
db.add(progress)
|
|
|
|
|
else:
|
|
|
|
|
progress.state = state
|
|
|
|
|
progress.score_earned = score_earned
|
|
|
|
|
progress.score_total = score_total
|
|
|
|
|
db.flush()
|
|
|
|
|
return progress
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def count_completed_lessons(db: Session, *, user_id: str) -> int:
|
|
|
|
|
return (
|
|
|
|
|
db.scalar(
|
|
|
|
|
select(func.count())
|
|
|
|
|
.select_from(StudentProgress)
|
|
|
|
|
.where(
|
|
|
|
|
StudentProgress.user_id == user_id,
|
|
|
|
|
StudentProgress.state.in_(("completed", "scored")),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
or 0
|
|
|
|
|
)
|
feat(student-management): kelola siswa round-trip CSV + bulk delete (PG)
- Export siswa terpilih/seluruh + progress ke CSV round-trip (token tidak
pernah diekspor; teacher tidak ikut; urutan deterministik)
- Import preview all-or-nothing: siswa existing di-restore (nama+progress,
token lama dipertahankan), siswa baru dibuat dari student_id kosong +
token; conflict tidak menyebut raw token/digest
- Bulk delete multi-seleksi dengan validasi ketat (teacher terlindungi,
zero-delete bila ada ID bermasalah)
- fix(import): deteksi delimiter berbasis schema — menerima titik koma
(;) maupun koma (,), ekspor tetap ';' + BOM; header tab/campuran ditolak
dengan pesan jelas (regresi: file Excel/Sheets kini bisa diimpor)
- refactor: progress_status.py — kontrak status legacy tunggal dipakai
storage PG, export, dan import; report siswa hanya role student
- UI /progress: dialog import/export + bulk delete, selection store,
respon import tanpa raw token; APP_VERSION 22 -> 26
- ops: ORIGIN env var di podman-compose.yml
- docs: README section 9 (workflow round-trip + delimiter) dan
docs/11-database-migration (kelola siswa via round-trip CSV)
- test: parser round-trip (45), repositories, route management (24,
PostgreSQL), frontend 91, fixtures CSV valid/invalid
2026-08-09 07:29:19 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── export / import round-trip ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_id_list(student_ids: list[str], *, field: str) -> set[str]:
|
|
|
|
|
"""Validasi array UUID: tipe list, 1-1000 item, canonical, tanpa duplikat.
|
|
|
|
|
|
|
|
|
|
Raise ValueError dengan pesan aman (tanpa data sensitif).
|
|
|
|
|
"""
|
|
|
|
|
if not isinstance(student_ids, list) or not student_ids:
|
|
|
|
|
raise ValueError(f"{field} wajib berisi 1-1000 UUID")
|
|
|
|
|
if len(student_ids) > 1000:
|
|
|
|
|
raise ValueError(f"Maksimum 1000 UUID pada {field}")
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
for sid in student_ids:
|
|
|
|
|
if not is_canonical_uuid(sid):
|
|
|
|
|
raise ValueError(f"UUID tidak valid pada {field}: {sid!r}")
|
|
|
|
|
if sid in seen:
|
|
|
|
|
raise ValueError(f"UUID duplikat pada {field}: {sid!r}")
|
|
|
|
|
seen.add(sid)
|
|
|
|
|
return seen
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_students_for_export(
|
|
|
|
|
db: Session,
|
|
|
|
|
student_ids: list[str] | None = None,
|
|
|
|
|
) -> list[User]:
|
|
|
|
|
"""Siswa untuk export — hanya role student, urutan deterministik.
|
|
|
|
|
|
|
|
|
|
- `student_ids=None` atau array kosong → seluruh siswa.
|
|
|
|
|
- Array non-kosong → divalidasi ketat: duplicate/malformed/unknown/teacher
|
|
|
|
|
ID membuat seluruh request gagal (bukan diam-diam dilewati).
|
|
|
|
|
"""
|
|
|
|
|
if student_ids:
|
|
|
|
|
wanted = _validate_id_list(student_ids, field="student_ids")
|
|
|
|
|
users = list(
|
|
|
|
|
db.scalars(
|
|
|
|
|
select(User)
|
|
|
|
|
.where(User.id.in_(wanted), User.role == "student")
|
|
|
|
|
.order_by(User.created_at, User.id)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
found = {u.id for u in users}
|
|
|
|
|
missing = [sid for sid in student_ids if sid not in found]
|
|
|
|
|
if missing:
|
|
|
|
|
raise ValueError("student_id tidak dikenal atau bukan siswa")
|
|
|
|
|
return users
|
|
|
|
|
return list(
|
|
|
|
|
db.scalars(
|
|
|
|
|
select(User).where(User.role == "student").order_by(User.created_at, User.id)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def export_students_csv(
|
|
|
|
|
db: Session,
|
|
|
|
|
student_ids: list[str] | None = None,
|
|
|
|
|
) -> bytes:
|
|
|
|
|
"""Export siswa (selection atau seluruh) + progress → CSV round-trip bytes.
|
|
|
|
|
|
|
|
|
|
Kolom token selalu kosong; teacher tidak pernah ikut; export tidak mengubah DB.
|
|
|
|
|
"""
|
|
|
|
|
users = list_students_for_export(db, student_ids)
|
|
|
|
|
lessons = list_active_lessons(db)
|
|
|
|
|
lesson_slugs = [lesson.slug for lesson in lessons]
|
|
|
|
|
|
|
|
|
|
rows: list[StudentRoundTripRow] = []
|
|
|
|
|
for user in users:
|
|
|
|
|
progress_rows = {
|
|
|
|
|
p.lesson_id: p for p in list_progress_for_user(db, user_id=user.id)
|
|
|
|
|
}
|
|
|
|
|
progress: dict[str, ParsedProgress] = {}
|
|
|
|
|
for lesson in lessons:
|
|
|
|
|
p = progress_rows.get(lesson.id)
|
|
|
|
|
if p is None:
|
|
|
|
|
continue
|
|
|
|
|
if p.state == "scored":
|
|
|
|
|
progress[lesson.slug] = ParsedProgress(
|
|
|
|
|
"scored", p.score_earned, p.score_total
|
|
|
|
|
)
|
|
|
|
|
elif p.state == "completed":
|
|
|
|
|
progress[lesson.slug] = ParsedProgress("completed")
|
|
|
|
|
rows.append(
|
|
|
|
|
StudentRoundTripRow(
|
|
|
|
|
line=0,
|
|
|
|
|
student_id=user.id,
|
|
|
|
|
raw_token="",
|
|
|
|
|
display_name=user.display_name,
|
|
|
|
|
progress=progress,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return serialize_export_csv(rows, lesson_slugs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def find_student_import_conflicts(
|
|
|
|
|
db: Session, rows: list[StudentRoundTripRow]
|
|
|
|
|
) -> list[str]:
|
|
|
|
|
"""Conflict terhadap database saat ini (tanpa write).
|
|
|
|
|
|
|
|
|
|
Aturan identitas baris (kontrak round-trip):
|
|
|
|
|
- student_id terisi → user WAJIB sudah ada dan ber-role `student`; siswa
|
|
|
|
|
existing TIDAK boleh menerima token baru (import hanya restore/update
|
|
|
|
|
nama & progress; token lama dipertahankan).
|
|
|
|
|
- student_id kosong (siswa baru) → token wajib non-kosong (dijamin
|
|
|
|
|
parser); HMAC digest token tidak boleh sudah ada di `access_tokens`
|
|
|
|
|
(termasuk token revoked).
|
|
|
|
|
|
|
|
|
|
Error tidak pernah menyebut token mentah maupun digest.
|
|
|
|
|
"""
|
|
|
|
|
conflicts: list[str] = []
|
|
|
|
|
|
|
|
|
|
# Query semua student_id terisi SEKALI, lalu validasi keberadaan & role.
|
|
|
|
|
existing_by_id: dict[str, User] = {}
|
|
|
|
|
ids = [r.student_id for r in rows if r.student_id]
|
|
|
|
|
if ids:
|
|
|
|
|
for user in db.scalars(select(User).where(User.id.in_(ids))):
|
|
|
|
|
existing_by_id[user.id] = user
|
|
|
|
|
|
|
|
|
|
new_rows: list[StudentRoundTripRow] = []
|
|
|
|
|
for row in rows:
|
|
|
|
|
if row.student_id:
|
|
|
|
|
user = existing_by_id.get(row.student_id)
|
|
|
|
|
if user is None:
|
|
|
|
|
conflicts.append(
|
|
|
|
|
f"Baris {row.line}: student_id tidak dikenal atau bukan siswa"
|
|
|
|
|
)
|
|
|
|
|
elif user.role != "student":
|
|
|
|
|
conflicts.append(
|
|
|
|
|
f"Baris {row.line}: student_id milik teacher — import tidak dapat mengubah teacher"
|
|
|
|
|
)
|
|
|
|
|
elif row.raw_token:
|
|
|
|
|
conflicts.append(
|
|
|
|
|
f"Baris {row.line}: siswa existing tidak boleh menerima token baru "
|
|
|
|
|
"(hapus siswa terlebih dahulu untuk membuat ulang)"
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
new_rows.append(row)
|
|
|
|
|
|
|
|
|
|
# Hanya siswa baru yang ikut pemeriksaan digest token.
|
|
|
|
|
if new_rows:
|
|
|
|
|
digests = {hash_token(r.raw_token) for r in new_rows}
|
|
|
|
|
existing_digests = set(
|
|
|
|
|
db.scalars(
|
|
|
|
|
select(AccessToken.token_hash).where(
|
|
|
|
|
AccessToken.token_hash.in_(digests)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
for row in new_rows:
|
|
|
|
|
if hash_token(row.raw_token) in existing_digests:
|
|
|
|
|
conflicts.append(f"Baris {row.line}: token sudah terdaftar di database")
|
|
|
|
|
return conflicts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def preview_student_import(
|
|
|
|
|
db: Session, rows: list[StudentRoundTripRow]
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Ringkasan import TANPA menulis apa pun.
|
|
|
|
|
|
|
|
|
|
Baris dengan student_id terisi dilaporkan sebagai update/restore
|
|
|
|
|
(bukan create); baris tanpa student_id sebagai siswa baru. Conflict
|
|
|
|
|
dihitung terhadap state DB saat ini; apply akan memvalidasi ulang.
|
|
|
|
|
"""
|
|
|
|
|
conflicts = find_student_import_conflicts(db, rows)
|
|
|
|
|
to_create = [r for r in rows if not r.student_id]
|
|
|
|
|
to_update = [r for r in rows if r.student_id]
|
|
|
|
|
return {
|
|
|
|
|
"rows": len(rows),
|
|
|
|
|
"students_to_create": len(to_create),
|
|
|
|
|
"students_to_update": len(to_update),
|
|
|
|
|
"progress_to_create": sum(len(r.progress) for r in to_create),
|
|
|
|
|
"progress_to_restore": sum(len(r.progress) for r in to_update),
|
|
|
|
|
"conflicts": conflicts,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_student_import(
|
|
|
|
|
db: Session, rows: list[StudentRoundTripRow]
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Import all-or-nothing: siswa baru dibuat, siswa existing di-restore.
|
|
|
|
|
|
|
|
|
|
- Memvalidasi ulang conflict terhadap state DB TERBARU (mengalahkan race
|
|
|
|
|
preview→apply); bila ada conflict baru, seluruh import gagal tanpa write.
|
|
|
|
|
- Siswa baru (student_id kosong): buat user role student + access token
|
|
|
|
|
(digest) + progress non-not_started, seperti sebelumnya.
|
|
|
|
|
- Siswa existing (student_id terisi, token kosong): pertahankan user &
|
|
|
|
|
token lama; update display_name sesuai CSV; apply progress
|
|
|
|
|
non-not_started. TIDAK ada token baru yang dibuat/ditimpa.
|
|
|
|
|
- Sparse progress dipertahankan: status kosong/not_started tidak pernah
|
|
|
|
|
menjadi row database, dan progress lama yang tidak ada di CSV TIDAK
|
|
|
|
|
dihapus (merge, bukan snapshot penuh).
|
|
|
|
|
|
|
|
|
|
Catatan transaksi: fungsi ini sengaja melakukan COMMIT sekali (kontrak
|
|
|
|
|
all-or-nothing round-trip) — pemanggil (route) TIDAK commit lagi. Ini
|
|
|
|
|
menyimpang dari pola umum repository yang menyerahkan commit ke pemanggil;
|
|
|
|
|
pengecualian ini diperlukan agar seluruh file masuk dalam satu transaksi.
|
|
|
|
|
"""
|
|
|
|
|
conflicts = find_student_import_conflicts(db, rows)
|
|
|
|
|
if conflicts:
|
|
|
|
|
raise RoundTripImportError(conflicts)
|
|
|
|
|
|
|
|
|
|
user_ids: list[str] = []
|
|
|
|
|
students_created = 0
|
|
|
|
|
students_updated = 0
|
|
|
|
|
progress_created = 0
|
|
|
|
|
progress_restored = 0
|
|
|
|
|
try:
|
|
|
|
|
for row in rows:
|
|
|
|
|
if row.student_id:
|
|
|
|
|
user = db.get(User, row.student_id)
|
|
|
|
|
if user is None or user.role != "student":
|
|
|
|
|
# Tidak mungkin terjadi pasca-validasi conflict; jaga safety.
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"Baris {row.line}: student_id tidak dikenal atau bukan siswa"
|
|
|
|
|
)
|
|
|
|
|
user.display_name = row.display_name
|
|
|
|
|
students_updated += 1
|
|
|
|
|
else:
|
|
|
|
|
user = create_user(
|
|
|
|
|
db,
|
|
|
|
|
display_name=row.display_name,
|
|
|
|
|
role="student",
|
|
|
|
|
is_active=True,
|
|
|
|
|
user_id=row.student_id,
|
|
|
|
|
)
|
|
|
|
|
create_access_token(db, user_id=user.id, raw_token=row.raw_token)
|
|
|
|
|
students_created += 1
|
|
|
|
|
|
|
|
|
|
for slug, parsed in row.progress.items():
|
|
|
|
|
lesson = get_lesson_by_slug(db, slug)
|
|
|
|
|
if lesson is None:
|
|
|
|
|
raise ValueError(f"Baris {row.line}: lesson tidak dikenal: {slug!r}")
|
|
|
|
|
set_progress(
|
|
|
|
|
db,
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
lesson_id=lesson.id,
|
|
|
|
|
state=parsed.state,
|
|
|
|
|
score_earned=parsed.score_earned,
|
|
|
|
|
score_total=parsed.score_total,
|
|
|
|
|
)
|
|
|
|
|
if row.student_id:
|
|
|
|
|
progress_restored += 1
|
|
|
|
|
else:
|
|
|
|
|
progress_created += 1
|
|
|
|
|
user_ids.append(user.id)
|
|
|
|
|
db.commit()
|
|
|
|
|
except IntegrityError:
|
|
|
|
|
# Race: token/UUID bentrok dengan transaksi lain antara validasi & commit
|
|
|
|
|
db.rollback()
|
|
|
|
|
raise RoundTripImportError(
|
|
|
|
|
["Terjadi konflik data saat menyimpan (perubahan bersamaan) — seluruh import dibatalkan"]
|
|
|
|
|
) from None
|
|
|
|
|
except Exception:
|
|
|
|
|
db.rollback()
|
|
|
|
|
raise
|
|
|
|
|
return {
|
|
|
|
|
"students_created": students_created,
|
|
|
|
|
"students_updated": students_updated,
|
|
|
|
|
"progress_created": progress_created,
|
|
|
|
|
"progress_restored": progress_restored,
|
|
|
|
|
"user_ids": user_ids,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── bulk delete ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def delete_students(db: Session, student_ids: list[str]) -> list[str]:
|
|
|
|
|
"""Hapus permanen siswa terpilih beserta token & progress (cascade).
|
|
|
|
|
|
|
|
|
|
- Seluruh ID divalidasi SEBELUM delete pertama: duplicate/malformed/unknown/
|
|
|
|
|
teacher ID membatalkan seluruh request (zero delete).
|
|
|
|
|
- Query wajib `User.role == 'student'`; teacher terlindungi.
|
|
|
|
|
- Tidak commit — transaksi dikelola pemanggil (service/route).
|
|
|
|
|
- Setelah commit, UUID & token target boleh dipakai ulang oleh importer.
|
|
|
|
|
"""
|
|
|
|
|
wanted = _validate_id_list(student_ids, field="student_ids")
|
|
|
|
|
users = list(
|
|
|
|
|
db.scalars(select(User).where(User.id.in_(wanted), User.role == "student"))
|
|
|
|
|
)
|
|
|
|
|
found = {u.id for u in users}
|
|
|
|
|
missing = [sid for sid in student_ids if sid not in found]
|
|
|
|
|
if missing:
|
|
|
|
|
raise ValueError("student_id tidak dikenal atau bukan siswa")
|
|
|
|
|
for user in users:
|
|
|
|
|
db.delete(user)
|
|
|
|
|
db.flush()
|
|
|
|
|
return [u.id for u in users]
|