MUHAMMAD
FARI
MADYAN
[ Press ESC or Click to Skip ]

Rebuild Python FastAPI + React E-Learning

1

Part 1: Pengenalan & Arsitektur: Dari Monolit ke Arsitektur Asinkron Terpisah

2

Part 2: Async ORM & Type Safety: SQLAlchemy 2.0 Async, Pydantic v2 & Pencegahan Masalah N+1

3

Part 3: Engine Ujian CBT Presisi Tinggi & Telemetri Pengawasan Native WebSocket

4

Part 4: Penguatan Keamanan Enterprise: Stateless JWT, RBAC & 1-Click Impersonation

5

Part 5: Pipeline Data Excel Batch Berkecepatan Tinggi & Pengujian Asinkron Otomatis

6

Part 6: Single VPS Production Deployment: Docker Compose, Nginx SSL & Monitoring Pemulihan Otomatis

Artikel ini tersedia dalam Bahasa Inggris

🇬🇧 Read in English
🇮🇩 Bahasa Indonesia📚 Rebuild Python FastAPI + React E-Learning

Python FastAPI E-Learning Part 3: Engine Ujian CBT Presisi Tinggi & Telemetri Pengawasan Native WebSocket

Rancang engine ujian Computer Based Test (CBT) tangguh anti-curang berbasis Python FastAPI: sinkronisasi countdown timer otoritatif server, autosave instan, penangkapan pelanggaran tab-switch, dan telemetri pengawasan siswa real-time dengan native ASGI WebSocket & Redis Pub/Sub.

Muhammad Fari MadyanPenulis

8 menit baca

·

6 Oktober 2026


Realitas Rekayasa Sistem Ujian Online Berskala Besar

Membangun platform Computer Based Testing (CBT) menghadirkan tantangan arsitektur unik yang jarang ditemui pada aplikasi CRUD bisnis biasa:

  1. Manipulasi Jam Klien & Latensi Jaringan: Jika sisa waktu ujian bergantung pada jam browser pengguna, siswa nakal dapat dengan mudah menghentikan eksekusi JavaScript atau memundurkan jam sistem operasi untuk memperoleh perpanjangan waktu ilegal.
  2. Risiko Kehilangan Data Saat Koneksi Terputus: Jika koneksi WiFi siswa terputus setelah 45 menit mengerjakan 50 soal, mengirim seluruh jawaban sekaligus di akhir ujian adalah resep bencana kehilangan data massal.
  3. Telemetri Pengawasan Berskala Ribuan Pengguna: Guru dan panitia pengawas ujian membutuhkan visibilitas instan atas aktivitas siswa, sisa durasi, dan indikasi kecurangan tanpa membebani database utama melalui polling HTTP agresif setiap 2 detik.

Pada proyek mfarim/fastapi-elearning-react, kami merancang arsitektur engine ujian asinkron yang menjamin nol kehilangan data (zero data-loss), perhitungan waktu otoritatif server, dan telemetri native WebSocket.

┌────────────────────────────────────────────────────────────────────────────────────────┐ │ TOPOLOGI ENGINE CBT & TELEMETRI PENGAWAS │ └────────────────────────────────────────────────────────────────────────────────────────┘ 👨‍🎓 Klien Peserta Ujian (React 19) 👨‍🏫 Layar Pengawas Guru ┌────────────────────────────┐ ┌────────────────────────────┐ │ • Lockdown Mode Fullscreen │ │ • Grid Status Peserta Live │ │ • Listener Tab-Switching │ │ • Progress Bar Real-Time │ │ • Autosave Jawaban Instan │ │ • Peringatan Indikasi Curang│ └─────────────┬──────────────┘ └─────────────▲──────────────┘ │ │ HTTPS │ Jawaban / Pelanggaran WSS │ Siaran Telemetri ▼ │ ┌───────────────────────────────────────────────────────────────┴──────────────┐ │ FASTAPI ASGI BACKEND (Uvicorn) │ │ │ │ ┌───────────────────────────────┐ ┌──────────────────────────────────┐ │ │ │ ExamRunnerService │ │ ConnectionManager (WSS) │ │ │ │ • Kalkulasi Sisa Waktu Server│────▶│ • Kelola Room WebSocket Aktif │ │ │ │ • Auto-Submit Saat Deadline │ │ • Garbage Collector Koneksi Mati│ │ │ │ • Ambang Batas Curang (>=5) │ └──────────────────┬───────────────┘ │ │ └──────────────┬────────────────┘ │ │ └─────────────────┼─────────────────────────────────────────┼──────────────────┘ │ │ ▼ ▼ ┌──────────────────────┐ ┌──────────────────────┐ │ PostgreSQL 16 │ │ Redis 7 Pub/Sub │ │ (ExamAttempt/Answers)│ │ (Siaran Multi-Node) │ └──────────────────────┘ └──────────────────────┘

1. Timer Otoritatif Server & State Machine Ujian

Prinsip fundamental dari arsitektur ujian aman adalah: klien browser tidak boleh pernah menentukan sisa waktu ujian.

Pada berkas backend/app/api/v1/exam_runner.py, ketika siswa memulai ujian melalui /api/v1/exams/{id}/start, backend mencatat timestamp UTC mutlak started_at di dalam tabel database:

PYTHON

# backend/app/api/v1/exam_runner.py
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload

from app.core.database import get_db
from app.models.exam import Examination, ExamAttempt
from app.schemas.exam import ExamStartResponse
from app.api.deps import require_roles

@router.post("/{id}/start", response_model=ApiResponse[ExamStartResponse])
async def start_exam(
    id: int,
    db: AsyncSession = Depends(get_db),
    current_user = Depends(require_roles(["ROLE_STUDENT", "ROLE_ADMIN"]))
):
    student_id = current_user.student_profile.id
    now = datetime.utcnow()

    # 1. Ambil data ujian & pastikan sudah terbit
    stmt = (
        select(Examination)
        .options(selectinload(Examination.questions))
        .where(Examination.id == id, Examination.deleted_at.is_(None))
    )
    exam = (await db.execute(stmt)).scalars().first()
    if not exam or exam.status == "draft":
        raise HTTPException(status_code=400, detail="Ujian belum dapat diakses")

    total_duration_sec = exam.duration_minutes * 60

    # 2. Cek apakah siswa sudah memiliki sesi attempt yang sedang berjalan
    att_stmt = (
        select(ExamAttempt)
        .options(selectinload(ExamAttempt.answers))
        .where(ExamAttempt.examination_id == id, ExamAttempt.student_id == student_id)
        .order_by(ExamAttempt.id.desc())
    )
    attempt = (await db.execute(att_stmt)).scalars().first()

    if attempt and attempt.status in ["in_progress"]:
        # Resume sesi: Hitung selisih waktu mutlak dari server
        elapsed = (now - attempt.started_at).total_seconds()
        remaining = max(0, int(total_duration_sec - elapsed))
        
        # Eksekusi hard cut-off batas waktu
        if remaining <= 0:
            attempt.status = "submitted"
            attempt.finished_at = now
            await db.commit()
            raise HTTPException(status_code=400, detail="Batas waktu ujian telah habis")
    else:
        # Buat sesi attempt baru
        attempt = ExamAttempt(
            examination_id=id,
            student_id=student_id,
            status="in_progress",
            started_at=now,
        )
        db.add(attempt)
        await db.commit()
        await db.refresh(attempt)
        remaining = total_duration_sec

    return ApiResponse.ok(
        data=build_exam_start_dto(attempt, exam, remaining),
        message="Sesi ujian berhasil dimulai"
    )

Keunggulan Arsitektur Ini:

  1. Kebal Masalah Jaringan: Jika baterai laptop siswa habis atau WiFi mati selama 5 menit, saat halaman dibuka kembali, backend menghitung: $$\text{Sisa Waktu} = (\text{Durasi} \times 60) - (\text{Jam Server Sekarang} - \text{Jam Mulai Ujian})$$ Siswa tidak akan bisa memanipulasi waktu tambahan sedikit pun.
  2. Deadline Keras Tanpa Kompromi: Saat remaining <= 0, kiriman jawaban berikutnya akan langsung ditolak oleh backend dan status attempt diubah otomatis menjadi "submitted".

2. Autosave Instan dengan Sinkronisasi UI Optimistik

Mengharuskan siswa menekan tombol "Submit" untuk seluruh soal di akhir ujian adalah pola desain buruk. Jika koneksi bermasalah pada detik terakhir, kerja keras siswa akan lenyap.

Di frontend React (frontend/src/pages/exams/ExamRunner.tsx), setiap kali siswa mengklik salah satu opsi jawaban, warna nomor soal pada palet navigasi langsung berubah hijau secara optimistik di UI lokal, sementara background request dikirimkan ke server secara debounced:

PYTHON

# backend/app/api/v1/exam_runner.py
@router.post("/attempts/{attempt_id}/answers", response_model=ApiResponse[Dict[str, Any]])
async def submit_answer(
    attempt_id: int,
    payload: SubmitAnswerRequest,
    db: AsyncSession = Depends(get_db),
    current_user = Depends(require_roles(["ROLE_STUDENT", "ROLE_ADMIN"]))
):
    attempt = await get_valid_in_progress_attempt(db, attempt_id, current_user.student_profile.id)
    
    # 1. Operasi Upsert: Perbarui jawaban jika sudah ada, atau buat baru
    stmt = select(ExamAnswer).where(
        ExamAnswer.exam_attempt_id == attempt_id,
        ExamAnswer.question_id == payload.question_id
    )
    answer = (await db.execute(stmt)).scalars().first()

    if not answer:
        answer = ExamAnswer(
            exam_attempt_id=attempt_id,
            question_id=payload.question_id,
            student_answer=payload.selected_option,
        )
        db.add(answer)
    else:
        answer.student_answer = payload.selected_option
        answer.updated_at = datetime.utcnow()

    await db.commit()

    # 2. Siarkan progres terkini ke dashboard guru pengawas via WebSocket
    await ws_manager.broadcast_to_exam(
        exam_id=attempt.examination_id,
        event_type="ANSWER_SUBMITTED",
        data={
            "studentId": current_user.student_profile.id,
            "attemptId": attempt_id,
            "questionId": payload.question_id,
            "totalAnswered": await count_answered_questions(db, attempt_id)
        }
    )

    return ApiResponse.ok(message="Jawaban berhasil tersimpan")

3. Deteksi Kecurangan & Ambang Batas Pelanggaran (Anti-Cheat)

Untuk menjamin integritas ujian, aplikasi mengintegrasikan deteksi perilaku di frontend yang dipadukan dengan kebijakan ambang batas ketat di sisi server.

Event Hook di Frontend (ExamRunner.tsx)

TYPESCRIPT

// Deteksi perpindahan tab browser atau keluar dari mode fullscreen
useEffect(() => {
  const handleVisibilityChange = () => {
    if (document.hidden) {
      reportViolation("TAB_SWITCH_DETECTED");
    }
  };

  const handleFullscreenChange = () => {
    if (!document.fullscreenElement) {
      reportViolation("FULLSCREEN_EXITED");
    }
  };

  document.addEventListener("visibilitychange", handleVisibilityChange);
  document.addEventListener("fullscreenchange", handleFullscreenChange);

  return () => {
    document.removeEventListener("visibilitychange", handleVisibilityChange);
    document.removeEventListener("fullscreenchange", handleFullscreenChange);
  };
}, []);

Eksekusi Diskualifikasi Server (Threshold 5 Pelanggaran)

Ketika siswa berpindah tab atau keluar dari jendela ujian, counter pelanggaran akan bertambah. Saat pelanggaran mencapai 5 kali, server secara sepihak mengunci dan mengakhiri sesi ujian:

PYTHON

# backend/app/api/v1/exam_runner.py
@router.post("/attempts/{attempt_id}/violation", response_model=ApiResponse[Dict[str, Any]])
async def report_violation(
    attempt_id: int,
    payload: ViolationReportRequest,
    db: AsyncSession = Depends(get_db),
    current_user = Depends(require_roles(["ROLE_STUDENT", "ROLE_ADMIN"]))
):
    attempt = await get_valid_in_progress_attempt(db, attempt_id, current_user.student_profile.id)

    attempt.violations += 1
    force_submitted = False

    # Ambang batas tegas: Otomatis submit saat pelanggaran ke-5
    if attempt.violations >= 5:
        attempt.status = "force_finished"
        attempt.finished_at = datetime.utcnow()
        force_submitted = True

    await db.commit()

    # Kirim peringatan seketika ke layar pengawas guru
    await ws_manager.broadcast_to_exam(
        exam_id=attempt.examination_id,
        event_type="VIOLATION_LOGGED",
        data={
            "studentId": current_user.student_profile.id,
            "studentName": current_user.name,
            "violationsCount": attempt.violations,
            "violationType": payload.violation_type,
            "forceSubmitted": force_submitted,
        }
    )

    return ApiResponse.ok(
        data={"violations": attempt.violations, "forceSubmitted": force_submitted},
        message="Pelanggaran tercatat"
    )

4. Arsitektur Native ASGI WebSocket dengan ConnectionManager

Berbeda dengan framework sinkron yang membutuhkan gateway tambahan seperti Socket.IO atau Centrifugo, FastAPI mendukung WebSocket secara native langsung di atas event loop ASGI.

Di backend/app/services/websocket_manager.py, kami membangun manajer koneksi berbasis kamar (room-based):

PYTHON

# backend/app/services/websocket_manager.py
import json
import logging
from typing import Dict, Set
from fastapi import WebSocket

logger = logging.getLogger(__name__)

class ConnectionManager:
    def __init__(self):
        # Memetakan examination_id -> Kumpulan WebSocket pengawas yang aktif
        self.active_rooms: Dict[int, Set[WebSocket]] = {}

    async def connect(self, exam_id: int, websocket: WebSocket):
        await websocket.accept()
        if exam_id not in self.active_rooms:
            self.active_rooms[exam_id] = set()
        self.active_rooms[exam_id].add(websocket)
        logger.info(f"Pengawas terhubung ke ujian {exam_id}. Total pengawas: {len(self.active_rooms[exam_id])}")

    def disconnect(self, exam_id: int, websocket: WebSocket):
        if exam_id in self.active_rooms:
            self.active_rooms[exam_id].discard(websocket)
            if not self.active_rooms[exam_id]:
                del self.active_rooms[exam_id]

    async def broadcast_to_exam(self, exam_id: int, event_type: str, data: dict):
        """Menyiarkan event telemetri ke seluruh pengawas yang tergabung di ruangan"""
        if exam_id not in self.active_rooms:
            return

        payload = {"eventType": event_type, "data": data}
        message_str = json.dumps(payload, default=str)
        dead_connections = set()

        for connection in self.active_rooms[exam_id]:
            try:
                await connection.send_text(message_str)
            except Exception as e:
                logger.warning(f"Gagal mengirim pesan telemetri: {e}")
                dead_connections.add(connection)

        # Bersihkan socket yang koneksinya sudah putus
        for dead in dead_connections:
            self.disconnect(exam_id, dead)

ws_manager = ConnectionManager()

Skalabilitas Horizontal dengan Redis Pub/Sub

Pada deployment satu server, ConnectionManager berjalan efisien di dalam memori dengan latensi sub-milidetik.

Saat aplikasi diskalakan secara horizontal menjadi beberapa kontainer Docker atau worker Uvicorn di balik load balancer Nginx, antar-node disinkronkan melalui saluran Redis Pub/Sub:

PYTHON

# Publikasi event ke saluran Redis:
async def publish_telemetry(exam_id: int, event_type: str, data: dict):
    channel = f"exam:{exam_id}:telemetry"
    await redis_client.publish(channel, json.dumps({"eventType": event_type, "data": data}))

# Worker latar belakang yang mendengarkan di setiap node FastAPI:
async def redis_listener(exam_id: int):
    pubsub = redis_client.pubsub()
    await pubsub.subscribe(f"exam:{exam_id}:telemetry")
    async for msg in pubsub.listen():
        if msg["type"] == "message":
            # Teruskan ke WebSocket klien lokal di node server ini
            await ws_manager.broadcast_local(exam_id, msg["data"])

5. Integrasi Layar Pengawas Guru (Exam Monitor)

Di layar pengawas guru (frontend/src/pages/exams/ExamMonitor.tsx), pesan WebSocket yang masuk diterjemahkan menjadi pembaruan visual instan:

TYPESCRIPT

// frontend/src/pages/exams/ExamMonitor.tsx
useEffect(() => {
  const ws = new WebSocket(`wss://${window.location.host}/ws/exams/${examId}/monitor`);

  ws.onmessage = (event) => {
    const payload = JSON.parse(event.data);
    
    switch (payload.eventType) {
      case "VIOLATION_LOGGED":
        // Tampilkan badge merah dan peringatan audio/visual
        updateCandidateViolation(payload.data.studentId, payload.data.violationsCount);
        break;

      case "ANSWER_SUBMITTED":
        // Naikkan persentase progress bar penyelesaian siswa
        updateCandidateProgress(payload.data.studentId, payload.data.totalAnswered);
        break;

      case "EXAM_FINISHED":
        markCandidateCompleted(payload.data.studentId);
        break;
    }
  };

  return () => ws.close();
}, [examId]);

Arsitektur ini mengubah proses pengawasan ujian dari pengecekan manual berkala menjadi pusat kendali telemetri real-time yang sanggup memantau ratusan peserta ujian secara simultan tanpa membebani database utama.


Ringkasan Rekomendasi Arsitektural

  1. Jangan percayakan jam ujian pada perangkat siswa: Selalu gunakan selisih waktu terverifikasi dari timestamp UTC server pada setiap interaksi.
  2. Pola autosave ter-debounce jauh lebih unggul dibanding submit massal: Mengamankan pekerjaan siswa dari pemadaman listrik/WiFi serta meratakan beban penulisan database.
  3. Terapkan ambang batas kecurangan otomatis: Kombinasi pendeteksi visibilitychange di browser dan pembatasan 5 kali pelanggaran di server menghadirkan sistem ujian yang adil dan transparan.
  4. Optimalkan native ASGI WebSocket dengan manajemen koneksi mati: FastAPI mampu mengelola ribuan koneksi WebSocket persisten hanya dengan konsumsi memori ~15MB–25MB per 1.000 koneksi idle.

Pada Part 4, kita akan mengulas arsitektur keamanan tingkat lanjut: Stateless JWT, Role-Based Access Control (RBAC) via Dependency Injection, dan Fitur 1-Click Administrative Impersonation dengan Zero-Reauth Session Recovery.

Lanjut Membaca

Previous article thumbnail

← Artikel Sebelumnya

Python FastAPI E-Learning Part 2: Async ORM & Type Safety – SQLAlchemy 2.0 Async, Pydantic v2 & N+1 Prevention

Artikel Selanjutnya →

Python FastAPI E-Learning Part 4: Enterprise Security Hardening – Stateless JWT, RBAC & 1-Click Impersonation

Next article thumbnail