diff --git a/app/blueprints/session.py b/app/blueprints/session.py index 65a39c7..97ce9ab 100644 --- a/app/blueprints/session.py +++ b/app/blueprints/session.py @@ -10,3 +10,9 @@ session_bp = Blueprint("session", __name__) @inject def sessionGet(controller: SessionController = Provide[Container.session_controller]): return controller.createRoom(request.get_json()) + + +@session_bp.route("/summary", methods=["POST"]) +@inject +def summary(controller: SessionController = Provide[Container.session_controller]): + return controller.summaryall(request.get_json()) diff --git a/app/controllers/session_controller.py b/app/controllers/session_controller.py index 55de170..c1fa58c 100644 --- a/app/controllers/session_controller.py +++ b/app/controllers/session_controller.py @@ -30,3 +30,13 @@ class SessionController(MethodView): data=session, status_code=201, ) + + def summaryall(self, body): + self.session_service.summaryAllSessionData( + session_id=body.get("session_id"), start_time="" + ) + return make_response( + message="succes create room", + data="", + status_code=201, + ) diff --git a/app/helpers/datetime_util.py b/app/helpers/datetime_util.py index b55178b..e2febc7 100644 --- a/app/helpers/datetime_util.py +++ b/app/helpers/datetime_util.py @@ -33,3 +33,12 @@ class DatetimeUtil: """Convert string ke datetime dengan timezone""" dt = datetime.strptime(date_str, fmt) return dt.replace(tzinfo=ZoneInfo(tz)) + + @staticmethod + def from_iso(date_str: str, tz: str = "UTC") -> datetime: + """Convert ISO 8601 string to datetime with timezone awareness""" + dt = datetime.fromisoformat(date_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=ZoneInfo(tz)) + return dt + diff --git a/app/repositories/answer_memory_repository.py b/app/repositories/answer_memory_repository.py index 8ed3a0c..7329c8a 100644 --- a/app/repositories/answer_memory_repository.py +++ b/app/repositories/answer_memory_repository.py @@ -16,6 +16,28 @@ class AnswerMemoryRepository: def _build_pattern_key(self, session_id: str) -> str: return self.KEY_PATTERN_TEMPLATE.format(session_id=session_id) + def initialize_empty_answers( + self, + session_id: str, + user_ids: List[str], + total_questions: int, + ): + """ + Initialize empty answers for all users at the start of the quiz. + """ + for user_id in user_ids: + key = self._build_key(session_id, user_id) + answers = [ + { + "question_index": idx + 1, + "answer": "", + "is_true": False, + "time_spent": 0.0, + } + for idx in range(total_questions) + ] + self.set_data(key, answers) + def save_user_answer( self, session_id: str, @@ -25,11 +47,15 @@ class AnswerMemoryRepository: correct: bool, time_spent: float, ): + """ + Update user's answer for a specific question. + Assumes answers have been initialized. + """ key = self._build_key(session_id, user_id) answers = self.get_data(key) or [] for ans in answers: - if ans["question_index"] == question_index: + if ans.get("question_index") == question_index: ans.update( { "answer": answer, @@ -38,15 +64,6 @@ class AnswerMemoryRepository: } ) break - else: - answers.append( - { - "question_index": question_index, - "answer": answer, - "is_true": correct, - "time_spent": time_spent, - } - ) self.set_data(key, answers) @@ -68,8 +85,8 @@ class AnswerMemoryRepository: def delete_all_answers(self, session_id: str): pattern = self._build_pattern_key(session_id) keys = self.redis.keys(pattern) - for key in keys: - self.redis.delete(key) + if keys: + self.redis.delete(*keys) def set_data(self, key: str, value: Any): self.redis.set(key, json.dumps(value)) @@ -83,24 +100,39 @@ class AnswerMemoryRepository: session_id: str, question_index: int, default_time_spent: float = 0.0, - ): - + ) -> List[str]: + """ + Auto-fill unanswered specific question (by index) as incorrect for all users. + :return: List of user IDs who had not answered the specific question. + """ pattern = self._build_pattern_key(session_id) keys = self.redis.keys(pattern) + users_with_unanswered = [] + for key in keys: answers = self.get_data(key) or [] - has_answered = any( - ans["question_index"] == question_index for ans in answers - ) + has_unanswered = False - if not has_answered: - answers.append( - { - "question_index": question_index, - "answer": "", - "is_true": False, - "time_spent": default_time_spent, - } - ) - self.set_data(key, answers) + for ans in answers: + if ( + ans.get("question_index") == question_index + and ans.get("answer") == "" + ): + has_unanswered = True + ans.update( + { + "answer": "", + "is_true": False, + "time_spent": default_time_spent, + } + ) + break # No need to check other answers for this user + + if has_unanswered: + user_id = key.decode().split(":")[-1] + users_with_unanswered.append(user_id) + + self.set_data(key, answers) + + return users_with_unanswered diff --git a/app/repositories/session_repostory.py b/app/repositories/session_repostory.py index 998b85b..6c63720 100644 --- a/app/repositories/session_repostory.py +++ b/app/repositories/session_repostory.py @@ -2,6 +2,7 @@ from pymongo.collection import Collection from pymongo.database import Database from typing import Optional from app.models.entities import SessionEntity +from bson import ObjectId class SessionRepository: @@ -25,11 +26,12 @@ class SessionRepository: doc = self.collection.find_one({"session_code": session_code}) return SessionEntity(**doc) if doc else None + from bson import ObjectId + def update(self, session_id: str, update_fields: SessionEntity) -> bool: - """Update specific fields using $set""" result = self.collection.update_one( - {"_id": session_id}, - {"$set": update_fields}, + {"_id": ObjectId(session_id)}, + {"$set": update_fields.model_dump(by_alias=True, exclude_none=True)}, ) return result.modified_count > 0 diff --git a/app/services/session_service.py b/app/services/session_service.py index 26376d4..49a4b12 100644 --- a/app/services/session_service.py +++ b/app/services/session_service.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional +from typing import Any, Dict from uuid import uuid4 from app.repositories import ( SessionRepository, @@ -10,10 +10,11 @@ from app.repositories import ( AnswerMemoryRepository, ScoreMemoryRepository, ) -from app.models.entities import SessionEntity +from app.models.entities import SessionEntity, UserAnswerEntity, AnswerItemEntity from app.helpers import DatetimeUtil from flask_socketio import SocketIO import time +from bson import ObjectId class SessionService: @@ -136,8 +137,15 @@ class SessionService: return {"is_success": False} def run_quiz_flow(self, session_id: str, socketio: SocketIO): + users = self.session_redis_repository.get_user_in_session(session_id) quiz = self.quiz_redis_repository.get_quiz_for_session(session_id) + self.answer_redis_repository.initialize_empty_answers( + session_id=session_id, + user_ids=[u["id"] for u in users if "id" in u], + total_questions=quiz.total_quiz, + ) questions = quiz.question_listings + start_quiz = DatetimeUtil.now_iso() time.sleep(2) for q in questions: @@ -148,12 +156,28 @@ class SessionService: socketio.emit("quiz_question", question_to_send, room=session_id) time.sleep(q.duration) - self.answer_redis_repository.auto_fill_incorrect_answers( + usersNotAnswer = self.answer_redis_repository.auto_fill_incorrect_answers( session_id=session_id, question_index=q.index, default_time_spent=q.duration, ) + for userId in usersNotAnswer: + self.score_redis_repository.update_user_score( + session_id=session_id, + user_id=userId, + correct=False, + ) + socketio.emit( + "score_update", + { + "scores": self.get_ranked_scores(session_id), + }, + room=session_id, + ) + + socketio.emit("clean_up", room=session_id) + self.summaryAllSessionData(session_id=session_id, start_time=start_quiz) socketio.emit("quiz_done", room=session_id) def submit_answer( @@ -219,5 +243,68 @@ class SessionService: return str(ans).strip().lower() == str(q.target_answer).strip().lower() if q.type == "essay": return str(q.target_answer).lower() in str(ans).lower() - # fallback + return False + + def summaryAllSessionData(self, session_id: str, start_time): + session = self.session_redis_repository.get_session(session_id=session_id) + now = DatetimeUtil.now_iso() + session["id"] = ObjectId(session["id"]) + session["participants"] = [ + {"id": user["id"], "joined_at": user["joined_at"]} + for user in session["participants"] + ] + session["created_at"] = DatetimeUtil.from_iso(session["created_at"]) + session["started_at"] = DatetimeUtil.from_iso(start_time) + session["ended_at"] = DatetimeUtil.from_iso(now) + + newData = SessionEntity(**session) + newData.is_active = False + + answers = self.answer_redis_repository.get_all_user_answers( + session_id=session_id + ) + + quiz = self.quiz_repository.get_by_id(newData.quiz_id) + self.quiz_repository.update_user_playing( + quiz_id=quiz.id, total_user=quiz.total_user_playing + len(answers) + ) + + self.session_mongo_repository.update( + session_id=session_id, + update_fields=newData, + ) + + for key, value_list in answers.items(): + answer_items = [] + total_correct = 0 + + for item in sorted(value_list, key=lambda x: x["question_index"]): + is_correct = item["is_true"] + if is_correct: + total_correct += 1 + + answer_item = AnswerItemEntity( + question_index=item["question_index"], + answer=item["answer"], + is_correct=is_correct, + time_spent=item["time_spent"], + ) + answer_items.append(answer_item) + + total_questions = len(value_list) + total_score = ( + (total_correct / total_questions) * 100 if total_questions > 0 else 0.0 + ) + + userAnswer = UserAnswerEntity( + user_id=key, + quiz_id=str(quiz.id), + session_id=session_id, + total_correct=total_correct, + total_score=round(total_score, 2), + answers=answer_items, + answered_at=newData.started_at, + ) + + self.answer_repository.create(userAnswer)