diff --git a/backend/experiment/actions/final.py b/backend/experiment/actions/final.py index 514d900c6..a452f1996 100644 --- a/backend/experiment/actions/final.py +++ b/backend/experiment/actions/final.py @@ -1,5 +1,8 @@ from django.utils.translation import gettext_lazy as _ +from experiment.serializers import serialize_social_media_config +from session.models import Session + from .base_action import BaseAction @@ -21,18 +24,27 @@ class Final(BaseAction): # pylint: disable=too-few-public-methods 'DIAMOND': {'text': _('diamond'), 'class': 'diamond'} } - def __init__(self, session, title=_("Final score"), final_text=None, - button=None, points=None, rank=None, social=None, - show_profile_link=False, show_participant_link=False, - show_participant_id_only=False, feedback_info=None, total_score=None, logo=None - ): + def __init__( + self, + session: Session, + title: str = _("Final score"), + final_text: str = None, + button: dict = None, + points: str = None, + rank: str = None, + show_profile_link: bool = False, + show_participant_link: bool = False, + show_participant_id_only: bool = False, + feedback_info: dict = None, + total_score: float = None, + logo: dict = None, + ): self.session = session self.title = title self.final_text = final_text self.button = button self.rank = rank - self.social = social self.show_profile_link = show_profile_link self.show_participant_link = show_participant_link self.show_participant_id_only = show_participant_id_only @@ -50,22 +62,32 @@ def __init__(self, session, title=_("Final score"), final_text=None, def action(self): """Get data for final action""" return { - 'view': self.ID, - 'score': self.total_score, - 'rank': self.rank, - 'final_text': self.final_text, - 'button': self.button, - 'points': self.points, - 'action_texts': { - 'play_again': _('Play again'), - 'profile': _('My profile'), - 'all_experiments': _('All experiments') + "view": self.ID, + "score": self.total_score, + "rank": self.rank, + "final_text": self.final_text, + "button": self.button, + "points": self.points, + "action_texts": { + "play_again": _("Play again"), + "profile": _("My profile"), + "all_experiments": _("All experiments"), }, - 'title': self.title, - 'social': self.social, - 'show_profile_link': self.show_profile_link, - 'show_participant_link': self.show_participant_link, - 'feedback_info': self.feedback_info, - 'participant_id_only': self.show_participant_id_only, - 'logo': self.logo, + "title": self.title, + "social": self.get_social_media_config(self.session), + "show_profile_link": self.show_profile_link, + "show_participant_link": self.show_participant_link, + "feedback_info": self.feedback_info, + "participant_id_only": self.show_participant_id_only, + "logo": self.logo, } + + def get_social_media_config(self, session: Session) -> dict: + experiment = session.block.phase.experiment + if ( + hasattr(experiment, "social_media_config") + and experiment.social_media_config + ): + return serialize_social_media_config( + experiment.social_media_config, session.total_score() + ) diff --git a/backend/experiment/actions/tests/test_actions_final.py b/backend/experiment/actions/tests/test_actions_final.py new file mode 100644 index 000000000..6f788391a --- /dev/null +++ b/backend/experiment/actions/tests/test_actions_final.py @@ -0,0 +1,75 @@ +from django.test import TestCase +from django.utils.translation import activate + +from experiment.models import ( + Block, + BlockTranslatedContent, + Experiment, + ExperimentTranslatedContent, + Phase, + SocialMediaConfig, +) +from participant.models import Participant +from result.models import Result +from session.models import Session + +from experiment.actions import Final + + +class FinalTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.experiment = Experiment.objects.create( + slug="final_countdown", + ) + ExperimentTranslatedContent.objects.create( + experiment=cls.experiment, name="Final Countdown", language="en", index=1 + ) + ExperimentTranslatedContent.objects.create( + experiment=cls.experiment, + name="Laatste Telaf", + social_media_message="Ik heb {points} punten gescoord op {experiment_name}. Kan jij het beter?", + language="nl", + index=0, + ) + phase = Phase.objects.create(experiment=cls.experiment) + block = Block.objects.create(phase=phase, rules="HOOKED", rounds=6) + BlockTranslatedContent.objects.create( + block=block, name="Test block", language="en" + ) + participant = Participant.objects.create() + cls.session = Session.objects.create(block=block, participant=participant) + Result.objects.create(session=cls.session, score=28) + Result.objects.create(session=cls.session, score=14) + + def test_final_action_without_social(self): + final = Final(self.session) + serialized = final.action() + self.assertEqual(serialized.get("title"), "Final score") + self.assertIsNone(serialized.get("social")) + + def test_final_action_with_social(self): + SocialMediaConfig.objects.create( + experiment=self.experiment, + channels=["Facebook"], + tags=["amazing"], + url="example.com", + ) + final = Final(self.session) + serialized = final.action() + social_info = serialized.get("social") + self.assertIsNotNone(social_info) + self.assertEqual(social_info.get("channels"), ["Facebook"]) + self.assertEqual(social_info.get("url"), "example.com") + self.assertEqual(social_info.get("tags"), ["amazing"]) + self.assertEqual( + social_info.get("content"), + "Ik heb 42.0 punten gescoord op Laatste Telaf. Kan jij het beter?", + ) + activate("en") + final = Final(self.session) + serialized = final.action() + social_info = serialized.get("social") + self.assertEqual( + social_info.get("content"), "I scored 42.0 points in Final Countdown!" + ) diff --git a/backend/experiment/migrations/0059_add_social_media_config.py b/backend/experiment/migrations/0059_add_social_media_config.py new file mode 100644 index 000000000..1736d80ea --- /dev/null +++ b/backend/experiment/migrations/0059_add_social_media_config.py @@ -0,0 +1,56 @@ +# Generated by Django 4.2.16 on 2024-10-28 16:06 + +from django.db import migrations +from django.conf import settings +from django.utils import translation + + +def add_social_media_config(apps, schema_editor): + """add information from rules files to database""" + Block = apps.get_model("experiment", "Block") + ExperimentTranslatedContent = apps.get_model( + "experiment", "ExperimentTranslatedContent" + ) + SocialMediaConfig = apps.get_model("experiment", "SocialMediaConfig") + blocks = Block.objects.all() + for block in blocks: + if block.rules in [ + "HOOKED", + "EUROVISION_2020", + "KUIPER_2020", + "HUANG_2022", + "MUSICAL_PREFERENCES", + "MATCHING_PAIRS", + ]: + channels = ["facebook", "twitter"] + if block.rules == "MATCHING_PAIRS": + channels.append("clipboard") + elif block.rules == "MUSICAL_PREFERENCES": + channels = ["weibo", "share"] + if not SocialMediaConfig.objects.filter( + experiment=block.phase.experiment + ).exists(): + SocialMediaConfig.objects.create( + experiment=block.phase.experiment, + tags=["amsterdammusiclab", "citizenscience"], + url=f"{settings.RELOAD_PARTICIPANT_TARGET}/{block.slug}", + channels=channels, + ) + if not ExperimentTranslatedContent.objects.filter( + experiment=block.phase.experiment + ).exists(): + ExperimentTranslatedContent.objects.create( + experiment=block.phase.experiment, + language="en", + name=block.phase.experiment.slug, + ) + +class Migration(migrations.Migration): + + dependencies = [ + ("experiment", "0058_remove_socialmediaconfig_content_and_more"), + ] + + operations = [ + migrations.RunPython(add_social_media_config, migrations.RunPython.noop) + ] diff --git a/backend/experiment/models.py b/backend/experiment/models.py index dcf0eb27b..a0918c3f4 100644 --- a/backend/experiment/models.py +++ b/backend/experiment/models.py @@ -25,7 +25,6 @@ class Experiment(models.Model): slug (str): Slug translated_content (Queryset[ExperimentTranslatedContent]): Translated content theme_config (theme.ThemeConfig): ThemeConfig instance - dashboard (bool): Show dashboard? active (bool): Set experiment active social_media_config (SocialMediaConfig): SocialMediaConfig instance phases (Queryset[Phase]): Queryset of Phase instances @@ -155,7 +154,7 @@ class Phase(models.Model): experiment (Experiment): Instance of an Experiment index (int): Index of the phase dashboard (bool): Should the dashbopard be displayed for this phase? - randomize (bool): Should the block of this phase be randomized? + randomize (bool): Should the blocks of this phase be randomized? """ experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE, related_name="phases") @@ -494,6 +493,7 @@ class ExperimentTranslatedContent(models.Model): description (str): Description consent (FileField): Consent text markdown or html about_content (str): About text + social_media_message (str): Message to post with on social media. Can contain {points} and {experiment_name} placeholders """ experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE, related_name="translated_content") @@ -555,7 +555,6 @@ class SocialMediaConfig(models.Model): experiment (Experiment): Experiment instance tags (list[str]): Tags url (str): Url to be shared - content (str): Shared text channels (list[str]): Social media channel """ @@ -584,12 +583,12 @@ class SocialMediaConfig(models.Model): help_text=_("Selected social media channels for sharing"), ) - def get_content(self, score: int | None = None, experiment_name: str | None = None) -> str: + def get_content(self, score: float) -> str: """Get social media share content Args: score: Score - experiment_name: Block name + experiment_name: Experiment name Returns: Social media shared text @@ -599,9 +598,12 @@ def get_content(self, score: int | None = None, experiment_name: str | None = No """ translated_content = self.experiment.get_current_content() social_message = translated_content.social_media_message + experiment_name = translated_content.name if social_message: - has_placeholders = "{points}" in social_message and "{experiment_name}" in social_message + has_placeholders = ( + "{points}" in social_message and "{experiment_name}" in social_message + ) if not has_placeholders: return social_message @@ -612,9 +614,14 @@ def get_content(self, score: int | None = None, experiment_name: str | None = No return social_message.format(points=score, experiment_name=experiment_name) if score is None or experiment_name is None: - raise ValueError("score and experiment_name are required when no social media message is provided") + raise ValueError( + "score and name are required when no social media message is provided" + ) - return _("I scored {points} points in {experiment_name}").format(score=score, experiment_name=experiment_name) + return _("I scored %(score)d points in %(experiment_name)s") % { + "score": score, + "experiment_name": experiment_name, + } def __str__(self): fallback_content = self.experiment.get_fallback_content() diff --git a/backend/experiment/rules/base.py b/backend/experiment/rules/base.py index 965dbdc7a..b56347e01 100644 --- a/backend/experiment/rules/base.py +++ b/backend/experiment/rules/base.py @@ -154,30 +154,6 @@ def get_open_questions(self, session, randomize=False, cutoff_index=None) -> Uni ) return trials - def social_media_info(self, session: Session): - """ - ⚠️ Note: You can use this method to customize the social media message for a Final action in a block, - but the content will come from the experiment's social media config model - """ - - block = session.block - - current_url = f"{settings.RELOAD_PARTICIPANT_TARGET}/{block.slug}" - score = session.final_score - experiment = block.phase.experiment - experiment_name = experiment.get_current_content().name - social_media_config = experiment.social_media_config - tags = social_media_config.tags if social_media_config.tags else ["amsterdammusiclab", "citizenscience"] - url = social_media_config.url or current_url - message = social_media_config.get_content(score, experiment_name) - - return { - "channels": social_media_config.channels or ["facebook", "twitter"], - "content": message, - "url": url, - "tags": tags, - } - def validate_playlist(self, playlist: None): errors = [] # Common validations across blocks diff --git a/backend/experiment/rules/hooked.py b/backend/experiment/rules/hooked.py index 59fa5cf1f..26398daae 100644 --- a/backend/experiment/rules/hooked.py +++ b/backend/experiment/rules/hooked.py @@ -102,7 +102,6 @@ def next_round(self, session: Session): session=session, final_text=self.final_score_message(session), rank=self.rank(session), - social=self.social_media_info(session), show_profile_link=True, button={ "text": _("Play again"), diff --git a/backend/experiment/rules/matching_pairs.py b/backend/experiment/rules/matching_pairs.py index d38ee0cd7..208fe1086 100644 --- a/backend/experiment/rules/matching_pairs.py +++ b/backend/experiment/rules/matching_pairs.py @@ -77,15 +77,12 @@ def next_round(self, session): return actions else: # final score saves the result from the cleared board into account - social_info = self.social_media_info(session) - social_info["channels"].append("clipboard") score = Final( session, title="Score", final_text="Can you score higher than your friends and family? Share and let them try!", button={"text": "Play again", "link": self.get_play_again_url(session)}, rank=self.rank(session, exclude_unfinished=False), - social=social_info, feedback_info=self.feedback_info(), ) return [score] diff --git a/backend/experiment/rules/musical_preferences.py b/backend/experiment/rules/musical_preferences.py index 8d0dbb6c3..200725be1 100644 --- a/backend/experiment/rules/musical_preferences.py +++ b/backend/experiment/rules/musical_preferences.py @@ -238,13 +238,13 @@ def calculate_score(self, result, data): def get_final_view(self, session, top_participant, known_songs, n_songs, top_all): # finalize block - social_info = self.social_media_info(session) view = Final( session, title=_("End"), - final_text=_("Thank you for your participation and contribution to science!"), + final_text=_( + "Thank you for your participation and contribution to science!" + ), feedback_info=self.feedback_info(), - social=social_info, ) return view diff --git a/backend/experiment/rules/tests/test_base.py b/backend/experiment/rules/tests/test_base.py index 5b9868438..10a4b19b0 100644 --- a/backend/experiment/rules/tests/test_base.py +++ b/backend/experiment/rules/tests/test_base.py @@ -8,43 +8,6 @@ class BaseTest(TestCase): - def test_social_media_info(self): - reload_participant_target = settings.RELOAD_PARTICIPANT_TARGET - slug = "music-lab" - experiment = Experiment.objects.create( - slug=slug, - ) - ExperimentTranslatedContent.objects.create( - experiment=experiment, language="en", name="Music Lab", description="Test music lab" - ) - SocialMediaConfig.objects.create( - experiment=experiment, - url="https://app.amsterdammusiclab.nl/music-lab", - tags=["music-lab"], - ) - phase = Phase.objects.create( - experiment=experiment, - ) - block = Block.objects.create( - slug=slug, - phase=phase, - ) - base = Base() - session = Session.objects.create( - block=block, - participant=Participant.objects.create(), - final_score=101, - ) - social_media_info = base.social_media_info(session) - - expected_url = f"{reload_participant_target}/{slug}" - - self.assertEqual(social_media_info["channels"], ["facebook", "twitter"]) - self.assertEqual(social_media_info["content"], "I scored 101 points in Music Lab!") - self.assertEqual(social_media_info["url"], expected_url) - # Check for double slashes - self.assertNotIn(social_media_info["url"], "//") - self.assertEqual(social_media_info["tags"], ["music-lab"]) def test_get_play_again_url(self): block = Block.objects.create( diff --git a/backend/experiment/rules/tests/test_beat_alignment.py b/backend/experiment/rules/tests/test_beat_alignment.py index eb4f46132..a9b43607f 100644 --- a/backend/experiment/rules/tests/test_beat_alignment.py +++ b/backend/experiment/rules/tests/test_beat_alignment.py @@ -1,5 +1,5 @@ from django.test import TestCase -from experiment.models import Block +from experiment.models import Block, Experiment, Phase from result.models import Result from participant.models import Participant from participant.utils import PARTICIPANT_KEY @@ -32,9 +32,12 @@ def setUpTestData(cls): playlist = Playlist.objects.create(name='TestBAT') playlist.csv = csv playlist._update_sections() + experiment = Experiment.objects.create(slug="bat_test") + phase = Phase.objects.create(experiment=experiment) # rules is BeatAlignment.ID in beat_alignment.py cls.block = Block.objects.create( - rules='BEAT_ALIGNMENT', slug='ba', rounds=13) + phase=phase, rules="BEAT_ALIGNMENT", slug="ba", rounds=13 + ) cls.block.playlists.add(playlist) def load_json(self, response): diff --git a/backend/experiment/rules/thats_my_song.py b/backend/experiment/rules/thats_my_song.py index 47698af04..bd913c6b5 100644 --- a/backend/experiment/rules/thats_my_song.py +++ b/backend/experiment/rules/thats_my_song.py @@ -55,7 +55,6 @@ def next_round(self, session: Session): session.save() # Return a score and final score action. - social_info = self.social_media_info(session) return [ self.get_score(session, round_number), Final( @@ -63,9 +62,11 @@ def next_round(self, session: Session): final_text=self.final_score_message(session) + " For more information about this experiment, visit the Vanderbilt University Medical Center Music Cognition Lab.", rank=self.rank(session), - social=social_info, show_profile_link=True, - button={"text": _("Play again"), "link": self.get_play_again_url(session)}, + button={ + "text": _("Play again"), + "link": self.get_play_again_url(session), + }, logo={ "image": "/images/vumc_mcl_logo.png", "link": "https://www.vumc.org/music-cognition-lab/welcome", diff --git a/backend/experiment/serializers.py b/backend/experiment/serializers.py index b06734c08..40dcf7030 100644 --- a/backend/experiment/serializers.py +++ b/backend/experiment/serializers.py @@ -1,6 +1,8 @@ from random import shuffle +from typing import Optional from django_markup.markup import formatter +from django.utils.translation import activate, get_language from experiment.actions.consent import Consent from image.serializers import serialize_image @@ -17,7 +19,20 @@ def serialize_actions(actions): return actions.action() -def serialize_experiment(experiment: Experiment, language: str = "en") -> dict: +def get_experiment_translated_content(experiment): + language_code = get_language()[0:2] + + translated_content = experiment.get_translated_content(language_code) + + if not translated_content: + raise ValueError("No translated content found for this experiment") + + # set language cookie to the first available translation for this experiment + activate(translated_content.language) + return translated_content + + +def serialize_experiment(experiment: Experiment) -> dict: """Serialize experiment Args: @@ -27,10 +42,7 @@ def serialize_experiment(experiment: Experiment, language: str = "en") -> dict: Basic info about an experiment """ - translated_content = experiment.get_translated_content(language) - - if not translated_content: - raise ValueError("No translated content found for experiment") + translated_content = get_experiment_translated_content(experiment) serialized = { "slug": experiment.slug, @@ -50,12 +62,17 @@ def serialize_experiment(experiment: Experiment, language: str = "en") -> dict: serialized["aboutContent"] = formatter(translated_content.about_content, filter_name="markdown") if hasattr(experiment, "social_media_config") and experiment.social_media_config: - serialized["socialMedia"] = serialize_social_media_config(experiment.social_media_config) + serialized["socialMedia"] = serialize_social_media_config( + experiment.social_media_config + ) return serialized -def serialize_social_media_config(social_media_config: SocialMediaConfig) -> dict: +def serialize_social_media_config( + social_media_config: SocialMediaConfig, + score: Optional[float] = 0, +) -> dict: """Serialize social media config Args: @@ -66,10 +83,10 @@ def serialize_social_media_config(social_media_config: SocialMediaConfig) -> dic """ return { - "tags": social_media_config.tags, + "tags": social_media_config.tags or ["amsterdammusiclab", "citizenscience"], "url": social_media_config.url, - "content": social_media_config.get_content(), - "channels": social_media_config.channels, + "content": social_media_config.get_content(score), + "channels": social_media_config.channels or ["facebook", "twitter"], } @@ -169,7 +186,7 @@ def get_finished_session_count(block: Block, participant: Participant) -> int: return count -def get_total_score(blocks: list, participant: dict) -> int: +def get_total_score(blocks: list, participant: Participant) -> int: """Calculate total score of all blocks on the dashboard Args: diff --git a/backend/experiment/views.py b/backend/experiment/views.py index 905b759cd..36222bf3b 100644 --- a/backend/experiment/views.py +++ b/backend/experiment/views.py @@ -2,8 +2,7 @@ import logging from django.http import Http404, HttpRequest, JsonResponse -from django.conf import settings -from django.utils.translation import activate, gettext_lazy as _, get_language +from django.utils.translation import gettext_lazy as _, get_language from django_markup.markup import formatter from .models import Block, Experiment, Phase, Feedback, Session @@ -111,15 +110,6 @@ def get_experiment( request.session[EXPERIMENT_KEY] = slug participant = get_participant(request) - language_code = get_language()[0:2] - - translated_content = experiment.get_translated_content(language_code) - - if not translated_content: - raise ValueError("No translated content found for this experiment") - - experiment_language = translated_content.language - activate(experiment_language) phases = list(Phase.objects.filter(experiment=experiment.id).order_by("index")) try: @@ -133,7 +123,7 @@ def get_experiment( except IndexError: serialized_phase = {"dashboard": [], "next_block": None} - response = JsonResponse({**serialize_experiment(experiment, language_code), **serialized_phase}) + response = JsonResponse({**serialize_experiment(experiment), **serialized_phase}) return response