From d744901ebb67ca4e7f7de5e1707f5c413f9492ae Mon Sep 17 00:00:00 2001 From: BeritJanssen Date: Mon, 11 Nov 2024 16:15:15 +0100 Subject: [PATCH] fix: log phase on experiment level --- backend/experiment/serializers.py | 32 +++--- backend/experiment/tests/test_serializers.py | 108 ++++++++++++++++--- backend/experiment/tests/test_views.py | 99 ++++------------- backend/experiment/views.py | 32 +++--- 4 files changed, 145 insertions(+), 126 deletions(-) diff --git a/backend/experiment/serializers.py b/backend/experiment/serializers.py index f7cbcef35..1b5030233 100644 --- a/backend/experiment/serializers.py +++ b/backend/experiment/serializers.py @@ -99,18 +99,17 @@ def serialize_phase(phase: Phase, participant: Participant) -> dict: participant: Participant instance Returns: - Dashboard info for a participant + A dictionary of the dashboard (if applicable), the next block, and the total score of the phase """ - blocks = list(Block.objects.filter(phase=phase.id).order_by("index")) + blocks = list(phase.blocks.order_by("index").all()) - if phase.randomize: - shuffle(blocks) - - next_block = get_upcoming_block(phase, participant, blocks) + next_block = get_upcoming_block(phase, participant) if not next_block: return None total_score = get_total_score(blocks, participant) + if phase.randomize: + shuffle(blocks) return { "dashboard": [serialize_block(block, participant) for block in blocks] if phase.dashboard else [], @@ -138,17 +137,19 @@ def serialize_block(block_object: Block, language: str = "en") -> dict: } -def get_upcoming_block(phase: Phase, participant: Participant, block_list: list[Block]): +def get_upcoming_block(phase: Phase, participant: Participant): """return next block with minimum finished sessions for this participant - if repeated blocks are not allowed (dashboard=False) and there are only finished sessions, return None + if all blocks have been played an equal number of times, return None Args: - block_list: List of Block instances - participant: Participant instance - repeat_allowed: Allow repeating a block + phase: Phase for which next block needs to be picked + participant: Participant for which next block needs to be picked """ + blocks = list(phase.blocks.all()) + + shuffle(blocks) finished_session_counts = [ - get_finished_session_count(block, participant) for block in block_list + get_finished_session_count(block, participant) for block in blocks ] min_session_count = min(finished_session_counts) @@ -163,7 +164,7 @@ def get_upcoming_block(phase: Phase, participant: Participant, block_list: list[ phase_profile.save() return None next_block_index = finished_session_counts.index(min_session_count) - return serialize_block(block_list[next_block_index]) + return serialize_block(blocks[next_block_index]) def get_started_session_count(block: Block, participant: Participant) -> int: @@ -192,8 +193,9 @@ def get_finished_session_count(block: Block, participant: Participant) -> int: Number of finished sessions for this block and participant """ - count = Session.objects.filter(block=block, participant=participant, finished_at__isnull=False).count() - return count + return Session.objects.filter( + block=block, participant=participant, finished_at__isnull=False + ).count() def get_total_score(blocks: list, participant: Participant) -> int: diff --git a/backend/experiment/tests/test_serializers.py b/backend/experiment/tests/test_serializers.py index 10a0dd4bf..efc72f78b 100644 --- a/backend/experiment/tests/test_serializers.py +++ b/backend/experiment/tests/test_serializers.py @@ -1,8 +1,11 @@ +from django.conf import settings from django.test import TestCase from django.utils import timezone -from experiment.models import Block, Experiment, Phase -from experiment.serializers import serialize_phase +from experiment.models import Block, BlockTranslatedContent, Experiment, Phase +from experiment.serializers import get_upcoming_block, serialize_block, serialize_phase +from experiment.tests.test_views import create_theme_config +from image.models import Image from participant.models import Participant from session.models import Session @@ -36,22 +39,95 @@ def setUpTestData(cls): block.save() def test_serialize_phase(self): + phase = serialize_phase(self.phase1, self.participant) + self.assertIsNotNone(phase) + next_block_slug = phase.get("nextBlock").get("slug") + self.assertEqual(phase.get("dashboard"), []) + self.assertEqual(next_block_slug, "rhythm_intro") + self.assertEqual(phase.get("totalScore"), 0) + Session.objects.create( + participant=self.participant, + block=Block.objects.get(slug=next_block_slug), + finished_at=timezone.now(), + ) + phase = serialize_phase(self.phase1, self.participant) + self.assertIsNone(phase) + + def test_upcoming_block(self): + block = get_upcoming_block(self.phase1, self.participant) + self.assertEqual(block.get("slug"), "rhythm_intro") + Session.objects.create( + block=Block.objects.get(slug=block.get("slug")), + participant=self.participant, + finished_at=timezone.now(), + ) + block = get_upcoming_block(self.phase1, self.participant) + self.assertIsNone(block) for i in range(3): - phase = serialize_phase(self.phase2, self.participant) - next_block = phase.get("nextBlock") - self.assertIsNotNone(next_block) - self.assertIn(next_block.get("slug"), ["ddi", "hbat_bit", "rhdis"]) - block_obj = Block.objects.get(slug=next_block.get("slug")) + block = get_upcoming_block(self.phase2, self.participant) + self.assertIsNotNone(block) + self.assertIn(block.get("slug"), ["ddi", "hbat_bit", "rhdis"]) Session.objects.create( - block=block_obj, + block=Block.objects.get(slug=block.get("slug")), participant=self.participant, finished_at=timezone.now(), ) - phase = serialize_phase(self.phase2, self.participant) - self.assertIsNone(phase) - # if we enter the phase once more, we're going to get next_block again - phase = serialize_phase(self.phase2, self.participant) - self.assertIsNotNone(phase) - next_block = phase.get("nextBlock") - self.assertIsNotNone(next_block) - self.assertIn(next_block.get("slug"), ["ddi", "hbat_bit", "rhdis"]) + block = get_upcoming_block(self.phase2, self.participant) + self.assertIsNone(block) + + def test_serialize_block(self): + # Create the experiment & phase for the block + experiment = Experiment.objects.create(slug="test-experiment") + phase = Phase.objects.create(experiment=experiment) + + # Create a block + block = Block.objects.create( + slug="test-block", + image=Image.objects.create( + title="Test", + description="", + file="test-image.jpg", + alt="Test", + href="https://www.example.com", + rel="", + target="_self", + ), + theme_config=create_theme_config(), + phase=phase, + ) + BlockTranslatedContent.objects.create( + block=block, + language="en", + name="Test Block", + description="This is a test block", + ) + participant = Participant.objects.create() + Session.objects.bulk_create( + [ + Session( + block=block, participant=participant, finished_at=timezone.now() + ) + for index in range(3) + ] + ) + + # Call the serialize_block function + serialized_block = serialize_block(block, participant) + + # Assert the serialized data + self.assertEqual(serialized_block["slug"], "test-block") + self.assertEqual(serialized_block["name"], "Test Block") + self.assertEqual(serialized_block["description"], "This is a test block") + self.assertEqual( + serialized_block["image"], + { + "title": "Test", + "description": "", + "file": f"{settings.BASE_URL}/upload/test-image.jpg", + "href": "https://www.example.com", + "alt": "Test", + "rel": "", + "target": "_self", + "tags": [], + }, + ) diff --git a/backend/experiment/tests/test_views.py b/backend/experiment/tests/test_views.py index dcf9d4b95..605fefa75 100644 --- a/backend/experiment/tests/test_views.py +++ b/backend/experiment/tests/test_views.py @@ -1,10 +1,9 @@ -from django.conf import settings from django.test import TestCase from django.utils import timezone from django.core.files.uploadedfile import SimpleUploadedFile from image.models import Image -from experiment.serializers import serialize_block, serialize_phase +from experiment.serializers import serialize_phase from experiment.models import ( Block, BlockTranslatedContent, @@ -24,6 +23,9 @@ class TestExperimentViews(TestCase): @classmethod def setUpTestData(cls): cls.participant = Participant.objects.create() + session = cls.client.session + session["participant_id"] = cls.participant.id + session.save() theme_config = create_theme_config() experiment = Experiment.objects.create( slug="test_series", @@ -46,10 +48,6 @@ def setUpTestData(cls): cls.block4 = Block.objects.create(slug="block4", phase=cls.final_phase) def test_get_experiment(self): - # save participant data to request session - session = self.client.session - session["participant_id"] = self.participant.id - session.save() # check that the correct block is returned correctly response = self.client.get("/experiment/test_series/") self.assertEqual(response.json().get("nextBlock").get("slug"), "block1") @@ -72,12 +70,12 @@ def test_get_experiment(self): self.assertEqual(response_json.get("socialMedia").get("tags"), ["aml", "toontjehoger"]) self.assertEqual(response_json.get("socialMedia").get("channels"), ["facebook", "twitter", "weibo"]) - def test_get_experiment_not_found(self): + def test_experiment_not_found(self): # if Experiment does not exist, return 404 response = self.client.get("/experiment/not_found/") self.assertEqual(response.status_code, 404) - def test_get_experiment_inactive(self): + def test_experiment_inactive(self): # if Experiment is inactive, return 404 experiment = Experiment.objects.get(slug="test_series") experiment.active = False @@ -85,32 +83,30 @@ def test_get_experiment_inactive(self): response = self.client.get("/experiment/test_series/") self.assertEqual(response.status_code, 404) - def test_get_experiment_without_social_media(self): - session = self.client.session - session["participant_id"] = self.participant.id - session.save() - Session.objects.create(block=self.block1, participant=self.participant, finished_at=timezone.now()) - self.intermediate_phase.dashboard = True - self.intermediate_phase.save() + def test_experiment_has_no_phases(self): + Experiment.objects.create(slug="invalid_experiment") + response = self.client.get("/experiment/invalid_experiment/") + self.assertEqual(response.status_code, 500) + def test_experiment_without_social_media(self): experiment = Experiment.objects.create( slug="no_social_media", theme_config=create_theme_config(name="no_social_media"), ) + self.intermediate_phase.experiment = experiment + self.intermediate_phase.save() ExperimentTranslatedContent.objects.create( - experiment=experiment, language="en", name="Test Experiment", description="Test Description" + experiment=experiment, + language="en", + name="Test Experiment", + description="Test Description", ) - response = self.client.get("/experiment/no_social_media/") - self.assertEqual(response.status_code, 200) self.assertNotIn("socialMedia", response.json()) def test_experiment_with_dashboard(self): # if Experiment has dashboard set True, return list of random blocks - session = self.client.session - session["participant_id"] = self.participant.id - session.save() Session.objects.create(block=self.block1, participant=self.participant, finished_at=timezone.now()) self.intermediate_phase.dashboard = True self.intermediate_phase.save() @@ -120,9 +116,6 @@ def test_experiment_with_dashboard(self): def test_experiment_total_score(self): """Test calculation of total score for grouped block on dashboard""" - session = self.client.session - session["participant_id"] = self.participant.id - session.save() Session.objects.create( block=self.block2, participant=self.participant, finished_at=timezone.now(), final_score=8 ) @@ -148,6 +141,8 @@ def test_experiment_get_fallback_content(self): """Test get_fallback_content method""" experiment = Experiment.objects.create(slug="test_experiment_translated_content") + self.intermediate_phase.experiment = experiment + self.intermediate_phase.save() ExperimentTranslatedContent.objects.create( experiment=experiment, index=0, @@ -185,61 +180,7 @@ def test_experiment_get_fallback_content(self): response = self.client.get("/experiment/test_experiment_translated_content/", headers={"Accept-Language": "nl"}) # since no Dutch translation is available, the fallback content should be returned - self.assertEqual(response.json().get("name"), "Test Experiment Fallback Content") - - -class ExperimentViewsTest(TestCase): - def test_serialize_block(self): - # Create the experiment & phase for the block - experiment = Experiment.objects.create(slug="test-experiment") - phase = Phase.objects.create(experiment=experiment) - - # Create a block - block = Block.objects.create( - slug="test-block", - image=Image.objects.create( - title="Test", - description="", - file="test-image.jpg", - alt="Test", - href="https://www.example.com", - rel="", - target="_self", - ), - theme_config=create_theme_config(), - phase=phase, - ) - BlockTranslatedContent.objects.create( - block=block, - language="en", - name="Test Block", - description="This is a test block", - ) - participant = Participant.objects.create() - Session.objects.bulk_create( - [Session(block=block, participant=participant, finished_at=timezone.now()) for index in range(3)] - ) - - # Call the serialize_block function - serialized_block = serialize_block(block, participant) - - # Assert the serialized data - self.assertEqual(serialized_block["slug"], "test-block") - self.assertEqual(serialized_block["name"], "Test Block") - self.assertEqual(serialized_block["description"], "This is a test block") - self.assertEqual( - serialized_block["image"], - { - "title": "Test", - "description": "", - "file": f"{settings.BASE_URL}/upload/test-image.jpg", - "href": "https://www.example.com", - "alt": "Test", - "rel": "", - "target": "_self", - "tags": [], - }, - ) + self.assertEqual(response.json().get("name"), "Test Experiment Fallback Content")=å def test_get_block(self): # Create a block diff --git a/backend/experiment/views.py b/backend/experiment/views.py index 36222bf3b..6ebaa832c 100644 --- a/backend/experiment/views.py +++ b/backend/experiment/views.py @@ -87,7 +87,6 @@ def add_default_question_series(request, id): def get_experiment( request: HttpRequest, slug: str, - phase_index: int = 0, ) -> JsonResponse: """ check which `Phase` objects are related to the `Experiment` with the given slug @@ -111,21 +110,22 @@ def get_experiment( request.session[EXPERIMENT_KEY] = slug participant = get_participant(request) - phases = list(Phase.objects.filter(experiment=experiment.id).order_by("index")) - try: - current_phase = phases[phase_index] - serialized_phase = serialize_phase(current_phase, participant) - if not serialized_phase: - # if the current phase is not a dashboard and has no unfinished blocks, it will return None - # set it to finished and continue to next phase - phase_index += 1 - return get_experiment(request, slug, phase_index=phase_index) - except IndexError: - serialized_phase = {"dashboard": [], "next_block": None} - - response = JsonResponse({**serialize_experiment(experiment), **serialized_phase}) - - return response + phases = list(experiment.phases.order_by("index").all()) + if not len(phases): + return JsonResponse( + {"error": "This experiment does not have phases and blocks configured"}, + status=500, + ) + phase_key = 'f"{slug}-phase"' + phase_index = request.session.get(phase_key, 0) + if phase_index == len(phases): + phase_index = 0 + serialized_phase = serialize_phase(phases[phase_index], participant) + if not serialized_phase: + phase_index += 1 + request.session[phase_key] = phase_index + serialized_phase = serialize_phase(phases[phase_index], participant) + return JsonResponse({**serialize_experiment(experiment), **serialized_phase}) def get_associated_blocks(pk_list):