diff --git a/.env.example b/.env.example index f8ad228..67920c6 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/app/configs/config.py b/app/configs/config.py index 499742d..a3a3220 100644 --- a/app/configs/config.py +++ b/app/configs/config.py @@ -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}" diff --git a/app/controllers/socket_conroller.py b/app/controllers/socket_conroller.py index f204245..ef93729 100644 --- a/app/controllers/socket_conroller.py +++ b/app/controllers/socket_conroller.py @@ -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 di‑cast 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 SID‑nya. + 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") # front‑end 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 per‑user ----------------------------------------- + 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 ke‑7 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 ) diff --git a/app/di_container.py b/app/di_container.py index cd771f4..e32ddb0 100644 --- a/app/di_container.py +++ b/app/di_container.py @@ -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) diff --git a/app/main.py b/app/main.py index 9962377..6a97a6e 100644 --- a/app/main.py +++ b/app/main.py @@ -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 diff --git a/app/mapper/user_mapper.py b/app/mapper/user_mapper.py index 9bb2354..378bc34 100644 --- a/app/mapper/user_mapper.py +++ b/app/mapper/user_mapper.py @@ -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, diff --git a/app/services/session_service.py b/app/services/session_service.py index 1887ec5..34a104f 100644 --- a/app/services/session_service.py +++ b/app/services/session_service.py @@ -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}) diff --git a/requirement.txt b/requirement.txt index 0494ec4..100539b 100644 --- a/requirement.txt +++ b/requirement.txt @@ -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 diff --git a/run.py b/run.py index a4bb1cf..14466a3 100644 --- a/run.py +++ b/run.py @@ -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)