From 21d90cb87e56779b8ec5268cf55ea440f8de75fd Mon Sep 17 00:00:00 2001 From: Asp-Codes Date: Sun, 26 Jan 2025 22:20:50 +0530 Subject: [PATCH 1/3] added logic for the shuffling question order --- app/models.py | 5 ++++ app/routers/quizzes.py | 1 - app/routers/sessions.py | 54 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/app/models.py b/app/models.py index 6d33ea42..cb528093 100644 --- a/app/models.py +++ b/app/models.py @@ -448,10 +448,12 @@ class Session(BaseModel): id: PyObjectId = Field(default_factory=PyObjectId, alias="_id") user_id: str quiz_id: str + omr_mode: bool = False created_at: datetime = Field(default_factory=datetime.utcnow) events: List[Event] = [] has_quiz_ended: bool = False omr_mode: bool = False + question_order: List[int] = [] metrics: Optional[SessionMetrics] = None # gets updated when quiz ends class Config: @@ -471,6 +473,7 @@ class UpdateSession(BaseModel): event: EventType metrics: Optional[SessionMetrics] + # questionOrder: Optional[List[int]] = None class Config: schema_extra = {"example": {"event": "start-quiz"}} @@ -481,6 +484,7 @@ class SessionResponse(Session): is_first: bool session_answers: List[SessionAnswer] + question_order: List[int] # order of question_ids in the session time_remaining: Optional[int] = None # time in seconds class Config: @@ -506,6 +510,7 @@ class Config: "time_spent": 30, }, ], + "questionOrder": [0, 1, 2], } } diff --git a/app/routers/quizzes.py b/app/routers/quizzes.py index a7983b90..0b16f325 100644 --- a/app/routers/quizzes.py +++ b/app/routers/quizzes.py @@ -195,7 +195,6 @@ async def get_quiz(quiz_id: str, omr_mode: bool = Query(False)): ] ) ) - for question_set_index, question_set in enumerate(quiz["question_sets"]): updated_subset_without_details = [] options_count_per_set = options_count_across_sets[question_set_index][ diff --git a/app/routers/sessions.py b/app/routers/sessions.py index b4d7887c..c669fab5 100644 --- a/app/routers/sessions.py +++ b/app/routers/sessions.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, status, HTTPException +import random from fastapi.responses import JSONResponse from fastapi.encoders import jsonable_encoder import pymongo @@ -15,6 +16,7 @@ from datetime import datetime from logger_config import get_logger from typing import Dict +from settings import Settings def str_to_datetime(datetime_str: str) -> datetime: @@ -22,6 +24,46 @@ def str_to_datetime(datetime_str: str) -> datetime: return datetime.fromisoformat(datetime_str) +def shuffle_question_order(question_sets): + # Assuming `state['question_sets']` is a list of question arrays (list of lists) and `state['bucket_size']` is an integer + # state = { + # 'question_sets': question_sets, # Replace with your actual question sets + # 'bucket_size': bucket_size, # Replace with the actual bucket size + # 'question_order': [] # This will hold the shuffled question order + # } + question_order = [] + bucket_size = Settings().subset_size + + global_index = 0 # Track global index across all questions + logger.info(question_sets) + # Iterate over each question set + for question_set in question_sets: + total_questions = len( + question_set["questions"] + ) # Get total number of questions in the current set + num_blocks = ( + total_questions + bucket_size - 1 + ) // bucket_size # Equivalent to Math.ceil(total_questions / subset_size) + + # For each block (subset of questions) + for block in range(num_blocks): + # Get the start and end index for the current block + start = block * bucket_size + end = min(start + bucket_size, total_questions) + block_indices = list(range(global_index, global_index + (end - start))) + + # Shuffle the current block using Fisher-Yates algorithm + random.shuffle(block_indices) + + # Append the shuffled indices to question_order + question_order.extend(block_indices) + + # Update global index for the next set of questions + global_index += len(block_indices) + + return question_order + + router = APIRouter(prefix="/sessions", tags=["Sessions"]) logger = get_logger() @@ -29,7 +71,7 @@ def str_to_datetime(datetime_str: str) -> datetime: @router.post("/", response_model=SessionResponse) async def create_session(session: Session): logger.info( - f"Creating new session for user: {session.user_id} and quiz: {session.quiz_id}" + f"Creating new session for user: {session.user_id} and quiz: {session.quiz_id} and {session.omr_mode}" ) current_session = jsonable_encoder(session) @@ -56,6 +98,8 @@ async def create_session(session: Session): limit=2, ) ) + logger.info(f"Found {(previous_two_sessions)} previous sessions") + last_session, second_last_session = None, None # only one session exists if len(previous_two_sessions) == 1: @@ -70,6 +114,9 @@ async def create_session(session: Session): if last_session is None: logger.info("No previous session exists for this user-quiz combo") current_session["is_first"] = True + if not session.omr_mode: + question_order = shuffle_question_order(quiz["question_sets"]) + current_session["question_order"] = question_order if quiz["time_limit"] is not None: current_session["time_remaining"] = quiz["time_limit"][ "max" @@ -84,6 +131,7 @@ async def create_session(session: Session): ) ) else: + # due to this condition we are not returning the order as expected! condition_to_return_last_session = ( # checking "events" key for backward compatibility "events" in last_session @@ -102,7 +150,7 @@ async def create_session(session: Session): if condition_to_return_last_session is True: logger.info( - f"No meaningful event has occurred in last_session. Returning this session which has id {last_session['_id']}" + f"No meaningful event has occurred in last_session. Returning this session which has id {last_session}" ) # copy the omr mode value if changed (changes when toggled in UI) if ( @@ -136,6 +184,8 @@ async def create_session(session: Session): current_session["time_remaining"] = last_session.get("time_remaining", None) current_session["has_quiz_ended"] = last_session.get("has_quiz_ended", False) current_session["metrics"] = last_session.get("metrics", None) + logger.info(f"last_session['question_order']: {last_session['question_order']}") + current_session["question_order"] = last_session["question_order"] # restore the answers from the last (previous) sessions session_answers_of_the_last_session = last_session["session_answers"] From e74c9c19112f5a782e776c712fb376f4847642fc Mon Sep 17 00:00:00 2001 From: Asp-Codes Date: Sun, 26 Jan 2025 22:35:08 +0530 Subject: [PATCH 2/3] added logic for the shuffling question order --- app/models.py | 5 +++-- app/routers/sessions.py | 14 ++------------ 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/app/models.py b/app/models.py index cb528093..50742b9d 100644 --- a/app/models.py +++ b/app/models.py @@ -473,7 +473,6 @@ class UpdateSession(BaseModel): event: EventType metrics: Optional[SessionMetrics] - # questionOrder: Optional[List[int]] = None class Config: schema_extra = {"example": {"event": "start-quiz"}} @@ -484,7 +483,9 @@ class SessionResponse(Session): is_first: bool session_answers: List[SessionAnswer] - question_order: List[int] # order of question_ids in the session + question_order: List[ + int + ] # random order of questions for each quiz assesment/homework time_remaining: Optional[int] = None # time in seconds class Config: diff --git a/app/routers/sessions.py b/app/routers/sessions.py index c669fab5..c7fe5fbf 100644 --- a/app/routers/sessions.py +++ b/app/routers/sessions.py @@ -25,12 +25,6 @@ def str_to_datetime(datetime_str: str) -> datetime: def shuffle_question_order(question_sets): - # Assuming `state['question_sets']` is a list of question arrays (list of lists) and `state['bucket_size']` is an integer - # state = { - # 'question_sets': question_sets, # Replace with your actual question sets - # 'bucket_size': bucket_size, # Replace with the actual bucket size - # 'question_order': [] # This will hold the shuffled question order - # } question_order = [] bucket_size = Settings().subset_size @@ -71,7 +65,7 @@ def shuffle_question_order(question_sets): @router.post("/", response_model=SessionResponse) async def create_session(session: Session): logger.info( - f"Creating new session for user: {session.user_id} and quiz: {session.quiz_id} and {session.omr_mode}" + f"Creating new session for user: {session.user_id} and quiz: {session.quiz_id}" ) current_session = jsonable_encoder(session) @@ -98,8 +92,6 @@ async def create_session(session: Session): limit=2, ) ) - logger.info(f"Found {(previous_two_sessions)} previous sessions") - last_session, second_last_session = None, None # only one session exists if len(previous_two_sessions) == 1: @@ -131,7 +123,6 @@ async def create_session(session: Session): ) ) else: - # due to this condition we are not returning the order as expected! condition_to_return_last_session = ( # checking "events" key for backward compatibility "events" in last_session @@ -150,7 +141,7 @@ async def create_session(session: Session): if condition_to_return_last_session is True: logger.info( - f"No meaningful event has occurred in last_session. Returning this session which has id {last_session}" + f"No meaningful event has occurred in last_session. Returning this session which has id {last_session['_id']}" ) # copy the omr mode value if changed (changes when toggled in UI) if ( @@ -184,7 +175,6 @@ async def create_session(session: Session): current_session["time_remaining"] = last_session.get("time_remaining", None) current_session["has_quiz_ended"] = last_session.get("has_quiz_ended", False) current_session["metrics"] = last_session.get("metrics", None) - logger.info(f"last_session['question_order']: {last_session['question_order']}") current_session["question_order"] = last_session["question_order"] # restore the answers from the last (previous) sessions From 1ce3b1e2dd70ae9c921900a8c047e0db9df684c3 Mon Sep 17 00:00:00 2001 From: Asp-Codes Date: Sat, 1 Mar 2025 00:09:17 +0530 Subject: [PATCH 3/3] updated the code --- app/models.py | 3 +- app/routers/sessions.py | 7 +- ..._previous_sessions_with_random_ordering.py | 62 ++++++++++++++++ app/tests/base.py | 6 +- app/tests/test_sessions.py | 71 +++++++++++++++++++ 5 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 app/scripts/update_previous_sessions_with_random_ordering.py diff --git a/app/models.py b/app/models.py index 50742b9d..104a5164 100644 --- a/app/models.py +++ b/app/models.py @@ -452,7 +452,6 @@ class Session(BaseModel): created_at: datetime = Field(default_factory=datetime.utcnow) events: List[Event] = [] has_quiz_ended: bool = False - omr_mode: bool = False question_order: List[int] = [] metrics: Optional[SessionMetrics] = None # gets updated when quiz ends @@ -511,7 +510,7 @@ class Config: "time_spent": 30, }, ], - "questionOrder": [0, 1, 2], + "question_order": [0, 1, 2, 3], } } diff --git a/app/routers/sessions.py b/app/routers/sessions.py index c7fe5fbf..b99250cb 100644 --- a/app/routers/sessions.py +++ b/app/routers/sessions.py @@ -29,7 +29,6 @@ def shuffle_question_order(question_sets): bucket_size = Settings().subset_size global_index = 0 # Track global index across all questions - logger.info(question_sets) # Iterate over each question set for question_set in question_sets: total_questions = len( @@ -54,7 +53,6 @@ def shuffle_question_order(question_sets): # Update global index for the next set of questions global_index += len(block_indices) - return question_order @@ -107,8 +105,9 @@ async def create_session(session: Session): logger.info("No previous session exists for this user-quiz combo") current_session["is_first"] = True if not session.omr_mode: - question_order = shuffle_question_order(quiz["question_sets"]) - current_session["question_order"] = question_order + current_session["question_order"] = shuffle_question_order( + quiz["question_sets"] + ) if quiz["time_limit"] is not None: current_session["time_remaining"] = quiz["time_limit"][ "max" diff --git a/app/scripts/update_previous_sessions_with_random_ordering.py b/app/scripts/update_previous_sessions_with_random_ordering.py new file mode 100644 index 00000000..59d594c0 --- /dev/null +++ b/app/scripts/update_previous_sessions_with_random_ordering.py @@ -0,0 +1,62 @@ +from pymongo import MongoClient +import os +from dotenv import load_dotenv + + +def get_db_client(): + """Connect to MongoDB and return the client""" + if "MONGO_AUTH_CREDENTIALS" not in os.environ: + load_dotenv("../.env") # Adjust path if needed + + client = MongoClient(os.getenv("MONGO_AUTH_CREDENTIALS")) + return client + + +# Function to update the question_order in sessions +def update_question_order_in_sessions(): + """Update question_order in all sessions where needed""" + client = get_db_client() + + db_name = "quiz" + db = client[db_name] + + # Get all sessions that don't have question_order field + sessions_to_update = db.sessions.find({"question_order": {"$exists": False}}) + + update_count = 0 + for session in sessions_to_update: + # Get quiz ID for the respective session + quiz_id = session.get("quiz_id") + if not quiz_id: + continue + + # Find the quiz document + quiz = db.quizzes.find_one({"_id": quiz_id}) + if not quiz: + continue + + # Get question sets from the quiz to calculate the totalt questions count + question_sets = quiz.get("question_sets", []) + + # Calculate total number of questions across all sets + total_questions = sum( + len(question_set.get("questions", [])) for question_set in question_sets + ) + + # Create question_order array [0,1, 2, ..., total_questions-1] + question_order = list(range(0, total_questions)) + + # Update the session with the new question_order + result = db.sessions.update_one( + {"_id": session["_id"]}, {"$set": {"question_order": question_order}} + ) + + if result.modified_count: + update_count += 1 + + print(f"Updated question_order for {update_count} sessions") + client.close() + + +if __name__ == "__main__": + update_question_order_in_sessions() diff --git a/app/tests/base.py b/app/tests/base.py index 2b90cdd1..8c47be30 100644 --- a/app/tests/base.py +++ b/app/tests/base.py @@ -126,7 +126,11 @@ def setUp(self): # omr assessment with multiple question sets response = self.client.post( sessions.router.prefix + "/", - json={"quiz_id": self.multi_qset_omr["_id"], "user_id": 1}, + json={ + "quiz_id": self.multi_qset_omr["_id"], + "user_id": 1, + "omr_mode": True, + }, ) self.multi_qset_omr_session = json.loads(response.content) diff --git a/app/tests/test_sessions.py b/app/tests/test_sessions.py index cf04afe3..aa553d03 100644 --- a/app/tests/test_sessions.py +++ b/app/tests/test_sessions.py @@ -4,6 +4,10 @@ from ..schemas import EventType from datetime import datetime import time +from settings import Settings + + +settings = Settings() class SessionsTestCase(SessionsBaseTestCase): @@ -288,3 +292,70 @@ def test_check_quiz_status_for_user(self): # Assert that response is a dict assert isinstance(response.json(), dict) + + def test_check_question_order_first_session_and_omr_mode(self): + data = open("app/tests/dummy_data/multiple_question_set_omr_quiz.json") + quiz_data = json.load(data) + response = self.client.post(quizzes.router.prefix + "/", json=quiz_data) + quiz_id = json.loads(response.content)["id"] + quiz = self.client.get(quizzes.router.prefix + f"/{quiz_id}").json() + response = self.client.post( + sessions.router.prefix + "/", + json={"quiz_id": quiz["_id"], "user_id": 1, "omr_mode": True}, + ) + assert response.status_code == 201 + session = json.loads(response.content) + assert session["is_first"] is True + assert len(session["question_order"]) == 0 + + def test_check_question_order_first_session_and_not_omr_mode(self): + data = open("app/tests/dummy_data/multiple_question_set_quiz.json") + quiz_data = json.load(data) + response = self.client.post(quizzes.router.prefix + "/", json=quiz_data) + quiz_id = json.loads(response.content)["id"] + quiz = self.client.get(quizzes.router.prefix + f"/{quiz_id}").json() + response = self.client.post( + sessions.router.prefix + "/", json={"quiz_id": quiz["_id"], "user_id": 1} + ) + assert response.status_code == 201 + session = json.loads(response.content) + assert session["is_first"] is True + + question_order = session["question_order"] + check_limit = min(settings.subset_size, len(question_order)) + + for i in range(check_limit): + assert ( + question_order[i] < check_limit + ), f"Value {question_order[i]} exceeds {check_limit}" + + def test_check_question_order_wit_previous_session_and_not_omr_mode(self): + self.session_id = self.multi_qset_quiz_session["_id"] + self.session_question_order = self.multi_qset_quiz_session["question_order"] + # better remove the homework_session_to_large session + response = self.client.post( + sessions.router.prefix + "/", + json={ + "quiz_id": self.multi_qset_quiz_session["quiz_id"], + "user_id": self.multi_qset_quiz_session["user_id"], + }, + ) + assert response.status_code == 201 + session = json.loads(response.content) + assert session["question_order"] != [] + assert session["question_order"] == self.session_question_order + + def test_check_question_order_with_previous_session_and_omr_mode(self): + self.session_id = self.multi_qset_omr_session["_id"] + self.session_question_order = self.multi_qset_omr_session["question_order"] + response = self.client.post( + sessions.router.prefix + "/", + json={ + "quiz_id": self.multi_qset_omr_session["quiz_id"], + "user_id": self.multi_qset_omr_session["user_id"], + "omr_mode": True, + }, + ) + assert response.status_code == 201 + session = json.loads(response.content) + assert session["question_order"] == self.session_question_order == []