feat: adjustment on the socket session

This commit is contained in:
akhdanre 2025-05-13 02:25:14 +07:00
parent 1db14658e1
commit 76bbbf0824
9 changed files with 336 additions and 97 deletions

View File

@ -1,6 +1,7 @@
# Existing Configurations
MONGO_URI=
FLASK_ENV=
DEBUG=
FLASK_ENV=development
DEBUG=True
SECRET_KEY=
@ -10,3 +11,9 @@ GOOGLE_CLIENT_SECRET=
GOOGLE_AUHT_URI=
GOOGLE_TOKEN_URI=
GOOGLE_AUTH_PROVIDER_X509_CERT_URL=
# Redis Configuration
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB=0
REDIS_PASSWORD=

View File

@ -1,17 +1,20 @@
from dotenv import load_dotenv
import os
# Load variabel dari file .env
# Load variables from .env
load_dotenv(override=True)
class Config:
# Flask Environment Settings
FLASK_ENV = os.getenv("FLASK_ENV", "development")
DEBUG = os.getenv("DEBUG", "False").lower() in ("true", "1", "t")
SECRET_KEY = os.getenv("SECRET_KEY", "your_secret_key")
# MongoDB Settings
MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017/yourdb")
# Google OAuth Settings
GOOGLE_PROJECT_ID = os.getenv("GOOGLE_PROJECT_ID")
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
@ -22,6 +25,17 @@ class Config:
"GOOGLE_TOKEN_URI", "https://oauth2.googleapis.com/token"
)
GOOGLE_AUTH_PROVIDER_X509_CERT_URL = os.getenv("GOOGLE_AUTH_PROVIDER_X509_CERT_URL")
GOOGLE_SCOPE = "email profile"
GOOGLE_BASE_URL = "https://www.googleapis.com/oauth2/v1/"
# Redis Configuration
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = int(os.getenv("REDIS_PORT", 6379))
REDIS_DB = int(os.getenv("REDIS_DB", 0))
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", None)
@property
def REDIS_URL(self):
if self.REDIS_PASSWORD:
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"

View File

@ -3,14 +3,82 @@ from flask import request
from app.services import SessionService
import threading
import time
import json
from redis import Redis
class RedisRepository:
"""Small helper wrapper to (de)serialize python objects to Redis."""
def __init__(self, redis: Redis):
self.redis = redis
def set_data(self, key: str, value):
self.redis.set(key, json.dumps(value))
def get_data(self, key: str):
data = self.redis.get(key)
return json.loads(data) if data else None
def delete_key(self, key: str):
self.redis.delete(key)
class SocketController:
def __init__(self, socketio: SocketIO, session_service: SessionService):
def __init__(
self,
socketio: SocketIO,
redis: Redis,
session_service: SessionService,
):
self.socketio = socketio
self.session_service = session_service
self.redis_repo = RedisRepository(redis)
# Menyimpan SID admin untuk setiap session \u2192 {session_code: sid}
self.admin_sids: dict[str, str] = {}
self._register_events()
# ---------------------------------------------------------------------
# Helper utilities
# ---------------------------------------------------------------------
@staticmethod
def _is_correct(user_answer, question: dict) -> bool:
"""Bandingkan jawaban user dengan kunci jawaban."""
print("user answer", user_answer, "question_target", question["target_answer"])
if question["type"] == "fill_the_blank":
return (
str(user_answer).strip().lower()
== str(question["target_answer"]).strip().lower()
)
elif question["type"] == "true_false":
return bool(user_answer) == question["target_answer"]
elif question["type"] == "option":
# user_answer bisa dikirim sebagai index atau value teks option.
# Pastikan dicast ke int terlebih dahulu jika memungkinkan.
try:
return int(user_answer) == question["target_answer"]
except ValueError:
return (
str(user_answer).strip().lower()
== str(question["options"][question["target_answer"]])
.strip()
.lower()
)
return False
def _questions_key(self, session_code: str) -> str:
return f"session:{session_code}:questions"
def _answers_key(self, session_code: str) -> str:
return f"session:{session_code}:answers"
def _scores_key(self, session_code: str) -> str:
return f"session:{session_code}:scores"
# ---------------------------------------------------------------------
# Socket.IO event bindings
# ---------------------------------------------------------------------
def _register_events(self):
@self.socketio.on("connect")
def on_connect():
@ -23,8 +91,8 @@ class SocketController:
@self.socketio.on("join_room")
def handle_join_room(data):
session_code = data["session_code"]
user_id = data["user_id"]
session_code = data.get("session_code")
user_id = data.get("user_id")
if not session_code or not user_id:
emit("error", {"message": "session_code and user_id are required"})
@ -33,43 +101,47 @@ class SocketController:
session = self.session_service.join_session(
session_code=session_code, user_id=user_id
)
if session is None:
emit("error", {"message": "Failed to join session or session inactive"})
return
join_room(session_code)
if session["is_admin"] == True:
emit(
"room_message",
{
"message": f"admin has joined the room.",
"room": session_code,
"argument": "adm_update",
},
room=session_code,
)
return
# Kalau user ini admin, simpan SIDnya.
if session["is_admin"]:
self.admin_sids[session_code] = request.sid
message = "Admin has joined the room."
else:
message = f"User {session['username']} has joined the room."
print(message)
emit(
"room_message",
{
"message": f"user {session['username']} has joined the room.",
"type": "join",
"message": message,
"room": session_code,
"argument": "adm_update",
"data": session,
"data": session if not session["is_admin"] else None,
},
room=session_code,
)
@self.socketio.on("submit_answer")
def handle_submit_answer(data):
session_id = data.get("session_id")
session_code = data.get("session_id") # frontend masih mengirim session_id
user_id = data.get("user_id")
question_index = data.get("question_index")
answer = data.get("answer")
user_answer = data.get("answer")
if not all([session_id, user_id, question_index is not None, answer]):
if not all(
[
session_code,
user_id,
question_index is not None,
user_answer is not None,
]
):
emit(
"error",
{
@ -78,62 +150,130 @@ class SocketController:
)
return
print(f"User {user_id} answered question {question_index} with {answer}")
# ----- 1. Ambil list pertanyaan untuk mendapatkan kunci jawaban ----------
questions = (
self.redis_repo.get_data(self._questions_key(session_code)) or []
)
question = next(
(q for q in questions if q["index"] == question_index), None
)
if question is None:
emit("error", {"message": "Question not found"})
return
# TODO: kamu bisa menyimpan jawaban ke database di sini
# self.answer_service.save_answer(session_id, user_id, question_index, answer)
is_correct = self._is_correct(user_answer, question)
# Kirim notifikasi ke admin (host) atau semua peserta kalau perlu
print(
f"User {user_id} answered Q{question_index} with '{user_answer}' -> {'' if is_correct else ''}"
)
# ----- 2. Simpan jawaban ke Redis --------------------------------------
answers = self.redis_repo.get_data(self._answers_key(session_code)) or []
answers.append(
{
"user_id": user_id,
"question_index": question_index,
"answer": user_answer,
"correct": is_correct,
}
)
self.redis_repo.set_data(self._answers_key(session_code), answers)
# ----- 3. Update skor peruser -----------------------------------------
scores = self.redis_repo.get_data(self._scores_key(session_code)) or {}
user_score = scores.get(str(user_id), {"correct": 0, "incorrect": 0})
if is_correct:
user_score["correct"] += 1
else:
user_score["incorrect"] += 1
scores[str(user_id)] = user_score
self.redis_repo.set_data(self._scores_key(session_code), scores)
# ----- 4. Beri tahu user (ack) -----------------------------------------
emit(
"answer_submitted",
{
"user_id": user_id,
"question_index": question_index,
"answer": answer,
"answer": user_answer,
"correct": is_correct,
},
room=session_id,
room=request.sid,
)
# ----- 5. Kirim update skor hanya ke admin -----------------------------
admin_sid = self.admin_sids.get(session_code)
if admin_sid:
emit("score_update", scores, room=admin_sid)
@self.socketio.on("leave_room")
def handle_leave_room(data):
session_id = data.get("session_id")
session_code = data.get("session_id")
user_id = data.get("user_id")
username = data.get("username", "anonymous")
leave_room(session_id)
leave_room(session_code)
emit(
"room_message",
{"message": f"{username} has left the room.", "room": session_id},
room=session_id,
{
"type": "leave",
"message": f"{username} has left the room.",
"room": session_code,
"data": user_id,
},
room=session_code,
)
@self.socketio.on("send_message")
def on_send_message(data):
session_id = data.get("session_id")
session_code = data.get("session_id")
message = data.get("message")
username = data.get("username", "anonymous")
emit(
"receive_message",
{"message": message, "from": username},
room=session_id,
room=session_code,
)
@self.socketio.on("end_session")
def handle_end_session(data):
session_id = data.get("session_id")
session_code = data.get("session_id")
user_id = data.get("user_id")
if not session_id or not user_id:
if not session_code or not user_id:
emit("error", {"message": "session_id and user_id required"})
return
self.session_service.end_session(session_id=session_id, user_id=user_id)
# Validasi user berhak mengakhiri session
self.session_service.end_session(session_id=session_code, user_id=user_id)
answers = self.redis_repo.get_data(self._answers_key(session_code)) or []
scores = self.redis_repo.get_data(self._scores_key(session_code)) or {}
print("\n📦 Final Quiz Data for Session", session_code)
print("------------------------------------------------------------")
for entry in answers:
status = "" if entry["correct"] else ""
print(
f"User {entry['user_id']} - Q{entry['question_index']}: {entry['answer']} {status}"
)
print("\n🏁 Rekap Skor:")
for uid, sc in scores.items():
print(f"User {uid}: Benar {sc['correct']} | Salah {sc['incorrect']}")
# Bersihkan semua data session di Redis
for key in [
self._answers_key(session_code),
self._scores_key(session_code),
self._questions_key(session_code),
]:
self.redis_repo.delete_key(key)
emit(
"room_closed",
{"message": "Session has ended.", "room": session_id},
room=session_id,
{"message": "Session has ended.", "room": session_code},
room=session_code,
)
@self.socketio.on("start_quiz")
@ -144,54 +284,120 @@ class SocketController:
return
emit("quiz_started", {"message": "Quiz has started!"}, room=session_code)
# Jalankan thread untuk mengirim soal simulasi setiap 5 detik
threading.Thread(
target=self._simulate_quiz_flow, args=(session_code,)
target=self._simulate_quiz_flow, args=(session_code,), daemon=True
).start()
def _simulate_quiz_flow(self, session_code):
# ---------------------------------------------------------------------
# Quiz flow simulation -------------------------------------------------
# ---------------------------------------------------------------------
def _simulate_quiz_flow(self, session_code: str):
"""Mengirim list pertanyaan satu per satu secara otomatis (demo)."""
questions = [
{
"question_index": 0,
"question": "Apa ibu kota Indonesia?",
"type": "option",
"options": ["Jakarta", "Bandung", "Surabaya"],
"index": 1,
"question": "Kerajaan Hindu tertua di Indonesia adalah?",
"target_answer": "Kutai",
"duration": 30,
"type": "fill_the_blank",
"options": None,
},
{
"question_index": 1,
"question": "2 + 2 = ?",
"type": "option",
"options": ["3", "4", "5"],
},
{
"question_index": 2,
"question": "Siapa presiden pertama Indonesia?",
"type": "option",
"options": ["Sukarno", "Soeharto", "Jokowi"],
},
{
"question_index": 3,
"question": "Tuliskan nama lengkap presiden pertama Indonesia.",
"type": "fill_in_the_blank",
"options": [],
},
{
"question_index": 4,
"question": "Indonesia merdeka pada tahun 1945.",
"index": 2,
"question": "Apakah benar Majapahit mencapai puncak kejayaan pada masa Hayam Wuruk?",
"target_answer": True,
"duration": 30,
"type": "true_false",
"options": [],
"options": None,
},
{
"index": 3,
"question": "Kerajaan maritim terbesar di Asia Tenggara pada abad ke7 adalah?",
"target_answer": 2,
"duration": 30,
"type": "option",
"options": ["Majapahit", "Tarumanegara", "Sriwijaya", "Mataram Kuno"],
},
{
"index": 4,
"question": "Prasasti Yupa merupakan peninggalan dari kerajaan?",
"target_answer": "Kutai",
"duration": 30,
"type": "fill_the_blank",
"options": None,
},
{
"index": 5,
"question": "Apakah Tarumanegara terletak di wilayah Kalimantan Timur?",
"target_answer": False,
"duration": 30,
"type": "true_false",
"options": None,
},
{
"index": 6,
"question": "Kitab Negarakertagama ditulis oleh?",
"target_answer": 0,
"duration": 30,
"type": "option",
"options": [
"Empu Tantular",
"Empu Prapanca",
"Hayam Wuruk",
"Gajah Mada",
],
},
{
"index": 7,
"question": "Tokoh yang terkenal dengan Sumpah Palapa adalah?",
"target_answer": "Gajah Mada",
"duration": 30,
"type": "fill_the_blank",
"options": None,
},
{
"index": 8,
"question": "Candi Borobudur dibangun oleh kerajaan Hindu?",
"target_answer": False,
"duration": 30,
"type": "true_false",
"options": None,
},
{
"index": 9,
"question": "Raja terkenal dari Kerajaan Sriwijaya adalah?",
"target_answer": 1,
"duration": 30,
"type": "option",
"options": [
"Dapunta Hyang",
"Balaputradewa",
"Airlangga",
"Hayam Wuruk",
],
},
{
"index": 10,
"question": "Candi Prambanan merupakan peninggalan agama?",
"target_answer": "Hindu",
"duration": 30,
"type": "fill_the_blank",
"options": None,
},
]
for q in questions:
print(f"Sending question {q['question_index']} to {session_code}")
self.socketio.emit("quiz_question", q, room=session_code)
time.sleep(20)
# send true ansewr
time.sleep(5)
# Simpan ke Redis agar bisa dipakai saat evaluasi jawaban
self.redis_repo.set_data(self._questions_key(session_code), questions)
# Beri sedikit jeda sebelum pertanyaan pertama
time.sleep(2)
for q in questions:
print(f"\n📢 Mengirim pertanyaan {q['index']} ke room {session_code}")
q.pop("target_answer")
self.socketio.emit("quiz_question", q, room=session_code)
time.sleep(q["duration"])
# Setelah selesai semua soal, kirim command bahwa quiz selesai
self.socketio.emit(
"quiz_done", {"message": "Quiz has ended!"}, room=session_code
)

View File

@ -32,7 +32,7 @@ class Container(containers.DeclarativeContainer):
"""Dependency Injection Container"""
mongo = providers.Dependency()
redis = providers.Dependency()
socketio = providers.Dependency()
# repository
@ -89,5 +89,10 @@ class Container(containers.DeclarativeContainer):
quiz_controller = providers.Factory(QuizController, quiz_service, answer_service)
history_controller = providers.Factory(HistoryController, history_service)
subject_controller = providers.Factory(SubjectController, subject_service)
socket_controller = providers.Factory(SocketController, socketio, session_service)
socket_controller = providers.Factory(
SocketController,
socketio,
redis,
session_service,
)
session_controller = providers.Factory(SessionController, session_service)

View File

@ -1,17 +1,11 @@
# main.py
import eventlet
eventlet.monkey_patch()
import sys
import os
import logging
from flask import Flask
from flask_socketio import SocketIO
# sys.path.append(os.path.dirname(__file__))
from app.di_container import Container
from app.configs import Config, LoggerConfig
from app.blueprints import (
@ -24,12 +18,10 @@ from app.blueprints import (
session_bp,
)
from app.database import init_db
from redis import Redis
socketio = SocketIO(cors_allowed_origins="*")
def createApp() -> Flask:
def createApp() -> tuple[Flask, SocketIO]:
app = Flask(__name__)
app.config.from_object(Config)
LoggerConfig.init_logger(app)
@ -44,8 +36,19 @@ def createApp() -> Flask:
mongo = init_db(app)
if mongo is not None:
container.mongo.override(mongo)
container.socketio.override(socketio)
redis_url = Config().REDIS_URL
redis_client = Redis.from_url(redis_url)
redis_client.ping()
container.redis.override(redis_client)
socketio = SocketIO(
cors_allowed_origins="*",
# message_queue=redis_url,
async_mode="eventlet",
)
container.socketio.override(socketio)
container.socket_controller()
socketio.init_app(app)
@ -69,4 +72,4 @@ def createApp() -> Flask:
app.register_blueprint(subject_blueprint, url_prefix="/api/subject")
app.register_blueprint(session_bp, url_prefix="/api/session")
return app
return app, socketio

View File

@ -42,8 +42,9 @@ class UserMapper:
@staticmethod
def user_entity_to_response(user: UserEntity) -> UserResponseModel:
print(str(user.id))
return UserResponseModel(
id=str(user.id) if user.id else None,
_id=str(user.id) if user.id else None,
google_id=user.google_id,
email=user.email,
name=user.name,

View File

@ -71,7 +71,7 @@ class SessionService:
return self.repository.update(session_id, {"started_at": now})
def end_session(self, session_id: str, user_id: str):
session = self.repository.find_by_id(session_id)
session = self.repository.find_by_session_id(session_id)
if session and session.host_id == user_id:
session.is_active = False
self.repository.update(session_id, {"is_active": False})

View File

@ -12,6 +12,7 @@ cryptography==44.0.2
dependency-injector==4.46.0
dnspython==2.7.0
email_validator==2.2.0
eventlet==0.39.1
exceptiongroup==1.2.2
Flask==3.0.3
Flask-Bcrypt==1.0.1
@ -23,6 +24,8 @@ flask-swagger-ui==4.11.1
google-auth==2.38.0
google-auth-httplib2==0.2.0
google-auth-oauthlib==1.2.1
greenlet==3.2.1
gunicorn==23.0.0
h11==0.14.0
httplib2==0.22.0
idna==3.10

4
run.py
View File

@ -1,7 +1,7 @@
# run.py
from app.main import createApp, socketio
from app.main import createApp
app = createApp()
app, socketio = createApp()
if __name__ == "__main__":
socketio.run(app, host="0.0.0.0", port=5000, debug=True)