From 0204a9c732566de0053fa06504cf89d0b850dc77 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Wed, 4 Sep 2024 13:22:55 +0200 Subject: [PATCH] Added: Add multi-language`BlockTranslatedContent` in relation to `Block` (#1227) * feat: Add BlockTranslatedContent model and relate it to Block with a ManyToMany relationship * refactor: Migrate block content and associated models * refactor: Add `translated_content` as `ManyToMany` field of `Block` instead of on `BlockTranslatedContent` * feat: Warn user about missing translated contents * refactor: Remove hashtag, url, consent & language from block * refactor: Move warnings to remarks column * fix: Fix many tests after removing consent/url/hashtags from block model * doc: Update consent's doc string * fix: Fix minor test * fix: Fix remainder of tests * fix: Validating the model before saving the model and its relations results into errors as the validator looks for translated content using the FKs * refactor: Handle missing language in content * fix: Re-add Hooked ID * refactor(`BlockTranslatedContent`) Refactor `Block`-`BlockTranslatedContent` relationship to `ForeignKey` - Update Block model to use ForeignKey for translated_contents - Modify migrations to reflect new relationship - Adjust admin interface to handle inline editing of translated contents - Update related queries and methods to use new relationship structure * refactor: Use tabular inline for block translated content * fix: Fix border radius top left in markdown input * feat: Add all necessary fields for experiment translated content to its form * chore: Remove unnecessary print statement * fix: Add `BlockTranslatedContentInline` inline to `BlockAdmin` * fix: Create temporary fix for tabular inline headings appearing out of its h2 element See also: - https://github.com/theatlantic/django-nested-admin/issues/261 - https://github.com/theatlantic/django-nested-admin/pull/259 * chore: Incorporate latest version of `django-nested-admin` that includes fix for the tabular inline form's heading * refactor: Rename test to be more descriptive * refactor: Incorporate the migration of #1240 --- backend/experiment/actions/consent.py | 31 +- backend/experiment/admin.py | 97 ++++- backend/experiment/fixtures/experiment.json | 16 - backend/experiment/forms.py | 11 +- .../management/commands/bootstrap.py | 19 +- .../commands/templates/experiment.py | 2 +- ...ename_series_phase_experiments_and_more.py | 234 ++++++++++++ .../migrations/0054_migrate_block_content.py | 107 ++++++ ...k_consent_remove_block_hashtag_and_more.py | 29 ++ backend/experiment/models.py | 86 ++++- backend/experiment/rules/base.py | 103 ++--- backend/experiment/rules/categorization.py | 358 +++++++++--------- backend/experiment/rules/hooked.py | 217 ++++++----- .../experiment/rules/musical_preferences.py | 301 +++++++-------- backend/experiment/rules/tests/test_base.py | 49 ++- backend/experiment/rules/tests/test_hooked.py | 198 +++++----- .../rules/tests/test_musical_preferences.py | 84 ++-- .../rules/tests/test_rhythm_battery_final.py | 10 +- backend/experiment/static/block_admin.js | 37 +- .../widgets/markdown_preview_text_input.html | 2 +- .../experiment/tests/test_admin_experiment.py | 10 +- backend/experiment/tests/test_model.py | 3 - .../experiment/tests/test_model_functions.py | 8 +- backend/experiment/tests/test_views.py | 26 +- backend/experiment/utils.py | 112 +++++- backend/experiment/views.py | 2 +- backend/question/models.py | 14 +- backend/requirements.in/base.txt | 2 +- backend/requirements/dev.txt | 2 +- backend/requirements/prod.txt | 2 +- backend/session/tests/test_views.py | 54 +-- backend/session/views.py | 22 +- .../src/components/Explainer/Explainer.tsx | 2 +- frontend/src/components/Final/Final.tsx | 2 +- frontend/src/components/Info/Info.tsx | 2 +- frontend/src/components/Loading/Loading.tsx | 2 +- frontend/src/components/Playlist/Playlist.tsx | 2 +- frontend/src/components/Question/Question.tsx | 2 +- frontend/src/components/Trial/Trial.tsx | 2 +- 39 files changed, 1409 insertions(+), 853 deletions(-) create mode 100644 backend/experiment/migrations/0053_alter_block_options_rename_series_phase_experiments_and_more.py create mode 100644 backend/experiment/migrations/0054_migrate_block_content.py create mode 100644 backend/experiment/migrations/0055_remove_block_consent_remove_block_hashtag_and_more.py diff --git a/backend/experiment/actions/consent.py b/backend/experiment/actions/consent.py index 732913c45..e6cd77b69 100644 --- a/backend/experiment/actions/consent.py +++ b/backend/experiment/actions/consent.py @@ -3,6 +3,7 @@ from django.template.loader import render_to_string from django.template import Template, Context from django_markup.markup import formatter +from django.core.files import File from .base_action import BaseAction @@ -11,13 +12,13 @@ def get_render_format(url: str) -> str: """ Detect markdown file based on file extension """ - if splitext(url)[1] == '.md': - return 'MARKDOWN' - return 'HTML' + if splitext(url)[1] == ".md": + return "MARKDOWN" + return "HTML" def render_html_or_markdown(dry_text: str, render_format: str) -> str: - ''' + """ render html or markdown Parameters: @@ -26,19 +27,19 @@ def render_html_or_markdown(dry_text: str, render_format: str) -> str: Returns: a string of content rendered to html - ''' - if render_format == 'HTML': + """ + if render_format == "HTML": template = Template(dry_text) context = Context() return template.render(context) - if render_format == 'MARKDOWN': - return formatter(dry_text, filter_name='markdown') + if render_format == "MARKDOWN": + return formatter(dry_text, filter_name="markdown") class Consent(BaseAction): # pylint: disable=too-few-public-methods """ Provide data for a view that ask consent for using the experiment data - - text: Uploaded file via block.consent (fileField) + - text: Uploaded file via an experiment's translated content's consent (fileField) - title: The title to be displayed - confirm: The text on the confirm button - deny: The text on the deny button @@ -50,7 +51,7 @@ class Consent(BaseAction): # pylint: disable=too-few-public-methods """ # default consent text, that can be used for multiple blocks - ID = 'CONSENT' + ID = "CONSENT" default_text = "Lorem ipsum dolor sit amet, nec te atqui scribentur. Diam \ molestie posidonium te sit, ea sea expetenda suscipiantur \ @@ -63,21 +64,21 @@ class Consent(BaseAction): # pylint: disable=too-few-public-methods amet, nec te atqui scribentur. Diam molestie posidonium te sit, \ ea sea expetenda suscipiantur contentiones." - def __init__(self, text, title='Informed consent', confirm='I agree', deny='Stop', url=''): + def __init__(self, text: File, title="Informed consent", confirm="I agree", deny="Stop", url=""): # Determine which text to use - if text!='': + if text != "": # Uploaded consent via file field: block.consent (High priority) - with text.open('r') as f: + with text.open("r") as f: dry_text = f.read() render_format = get_render_format(text.url) - elif url!='': + elif url != "": # Template file via url (Low priority) dry_text = render_to_string(url) render_format = get_render_format(url) else: # use default text dry_text = self.default_text - render_format = 'HTML' + render_format = "HTML" # render text fot the consent component self.text = render_html_or_markdown(dry_text, render_format) self.title = title diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index 34f4f451a..116c983b9 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -3,19 +3,22 @@ from io import BytesIO from django.conf import settings -from django.contrib import admin +from django.contrib import admin, messages from django.db import models from django.utils import timezone from django.core import serializers from django.shortcuts import render, redirect from django.forms import CheckboxSelectMultiple from django.http import HttpResponse + from inline_actions.admin import InlineActionsModelAdminMixin from django.urls import reverse from django.utils.html import format_html from nested_admin import NestedModelAdmin, NestedStackedInline, NestedTabularInline +from experiment.utils import check_missing_translations, get_flag_emoji, get_missing_content_blocks + from experiment.models import ( Block, Experiment, @@ -23,10 +26,12 @@ Feedback, SocialMediaConfig, ExperimentTranslatedContent, + BlockTranslatedContent, ) from question.admin import QuestionSeriesInline from experiment.forms import ( ExperimentForm, + ExperimentTranslatedContentForm, BlockForm, ExportForm, TemplateForm, @@ -46,9 +51,19 @@ class FeedbackInline(admin.TabularInline): extra = 0 +class BlockTranslatedContentInline(NestedTabularInline): + model = BlockTranslatedContent + + def get_extra(self, request, obj=None, **kwargs): + if obj: + return 0 + return 1 + + class ExperimentTranslatedContentInline(NestedStackedInline): model = ExperimentTranslatedContent sortable_field_name = "index" + form = ExperimentTranslatedContentForm def get_extra(self, request, obj=None, **kwargs): if obj: @@ -75,18 +90,14 @@ class BlockAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): "description", "image", "slug", - "url", - "hashtag", "theme_config", - "language", "active", "rules", "rounds", "bonus_points", "playlists", - "consent", ] - inlines = [QuestionSeriesInline, FeedbackInline] + inlines = [QuestionSeriesInline, FeedbackInline, BlockTranslatedContentInline] form = BlockForm # make playlists fields a list of checkboxes @@ -232,6 +243,8 @@ def block_slug_link(self, obj): class BlockInline(NestedStackedInline): model = Block sortable_field_name = "index" + inlines = [BlockTranslatedContentInline] + form = BlockForm def get_extra(self, request, obj=None, **kwargs): if obj: @@ -289,6 +302,7 @@ class Media: def name(self, obj): content = obj.get_fallback_content() + return content.name if content else "No name" def slug_link(self, obj): @@ -300,17 +314,15 @@ def slug_link(self, obj): ) def description_excerpt(self, obj): - fallback_content = obj.get_fallback_content() - description = ( - fallback_content.description if fallback_content and fallback_content.description else "No description" - ) + experiment_fallback_content = obj.get_fallback_content() + description = experiment_fallback_content.description if experiment_fallback_content else "No description" if len(description) < 50: return description return description[:50] + "..." def phases(self, obj): - phases = Phase.objects.filter(series=obj) + phases = Phase.objects.filter(experiment=obj) return format_html( ", ".join([f'{phase.name}' for phase in phases]) ) @@ -382,13 +394,21 @@ def remarks(self, obj): } ) - if not remarks_array: - remarks_array.append({"level": "success", "message": "✅ All good", "title": "No issues found."}) - supported_languages = obj.translated_content.values_list("language", flat=True).distinct() - # TODO: Check if all blocks support the same languages as the experiment - # Implement this when the blocks have been updated to support multiple languages + missing_content_block_translations = check_missing_translations(obj) + + if missing_content_block_translations: + remarks_array.append( + { + "level": "warning", + "message": "🌍 Missing block content", + "title": missing_content_block_translations, + } + ) + + if not remarks_array: + remarks_array.append({"level": "success", "message": "✅ All good", "title": "No issues found."}) # TODO: Check if all theme configs support the same languages as the experiment # Implement this when the theme configs have been updated to support multiple languages @@ -399,12 +419,28 @@ def remarks(self, obj): return format_html( "\n".join( [ - f'{remark["message"]}' + f'{remark["message"]}
' for remark in remarks_array ] ) ) + def save_model(self, request, obj, form, change): + # Save the model + super().save_model(request, obj, form, change) + + # Check for missing translations after saving + missing_content_blocks = get_missing_content_blocks(obj) + + if missing_content_blocks: + for block, missing_languages in missing_content_blocks: + missing_language_flags = [get_flag_emoji(language) for language in missing_languages] + self.message_user( + request, + f"Block {block.name} does not have content in {', '.join(missing_language_flags)}", + level=messages.WARNING, + ) + admin.site.register(Experiment, ExperimentAdmin) @@ -418,7 +454,7 @@ class PhaseAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): "randomize", "blocks", ) - fields = ["name", "series", "index", "dashboard", "randomize"] + fields = ["name", "experiment", "index", "dashboard", "randomize"] inlines = [BlockInline] def name_link(self, obj): @@ -427,8 +463,8 @@ def name_link(self, obj): return format_html('{}', url, obj_name) def related_experiment(self, obj): - url = reverse("admin:experiment_experiment_change", args=[obj.series.pk]) - content = obj.series.get_fallback_content() + url = reverse("admin:experiment_experiment_change", args=[obj.experiment.pk]) + content = obj.experiment.get_fallback_content() experiment_name = content.name if content else "No name" return format_html('{}', url, experiment_name) @@ -444,3 +480,24 @@ def blocks(self, obj): admin.site.register(Phase, PhaseAdmin) + + +@admin.register(BlockTranslatedContent) +class BlockTranslatedContentAdmin(admin.ModelAdmin): + list_display = ["name", "block", "language"] + list_filter = ["language"] + search_fields = [ + "name", + "block__name", + ] + + def blocks(self, obj): + # Block is manytomany, so we need to find it through the related name + blocks = Block.objects.filter(translated_contents=obj) + + if not blocks: + return "No block" + + return format_html( + ", ".join([f'{block.name}' for block in blocks]) + ) diff --git a/backend/experiment/fixtures/experiment.json b/backend/experiment/fixtures/experiment.json index cfa3173fb..0cfc0a17f 100644 --- a/backend/experiment/fixtures/experiment.json +++ b/backend/experiment/fixtures/experiment.json @@ -16,7 +16,6 @@ "rounds": 100, "bonus_points": 0, "rules": "DURATION_DISCRIMINATION", - "language": "", "playlists": [ 5 ] @@ -32,7 +31,6 @@ "rounds": 100, "bonus_points": 0, "rules": "DURATION_DISCRIMINATION_TONE", - "language": "", "playlists": [ 4 ] @@ -48,7 +46,6 @@ "rounds": 17, "bonus_points": 0, "rules": "BEAT_ALIGNMENT", - "language": "", "playlists": [ 7 ] @@ -64,7 +61,6 @@ "rounds": 100, "bonus_points": 0, "rules": "H_BAT", - "language": "", "playlists": [ 9 ] @@ -80,7 +76,6 @@ "rounds": 100, "bonus_points": 0, "rules": "H_BAT_BFIT", - "language": "", "playlists": [ 8 ] @@ -96,7 +91,6 @@ "rounds": 100, "bonus_points": 0, "rules": "BST", - "language": "", "playlists": [ 10 ] @@ -112,7 +106,6 @@ "rounds": 100, "bonus_points": 0, "rules": "ANISOCHRONY", - "language": "", "playlists": [ 3 ] @@ -128,7 +121,6 @@ "rounds": 40, "bonus_points": 0, "rules": "RHYTHM_DISCRIMINATION", - "language": "", "playlists": [ 6 ] @@ -144,7 +136,6 @@ "rounds": 10, "bonus_points": 0, "rules": "RHYTHM_BATTERY_FINAL", - "language": "", "playlists": [ 3 ] @@ -160,7 +151,6 @@ "rounds": 10, "bonus_points": 0, "rules": "RHYTHM_BATTERY_INTRO", - "language": "", "playlists": [ 11 ] @@ -176,7 +166,6 @@ "rounds": 30, "bonus_points": 0, "rules": "HUANG_2022", - "language": "zh", "playlists": [ 13, 2, @@ -194,7 +183,6 @@ "rounds": 10, "bonus_points": 0, "rules": "CATEGORIZATION", - "language": "en", "playlists": [ 12 ] @@ -210,7 +198,6 @@ "rounds": 10, "bonus_points": 0, "rules": "MATCHING_PAIRS", - "language": "en", "playlists": [ 14 ] @@ -226,7 +213,6 @@ "rounds": 30, "bonus_points": 0, "rules": "EUROVISION_2020", - "language": "en", "playlists": [ 16 ] @@ -242,7 +228,6 @@ "rounds": 30, "bonus_points": 0, "rules": "KUIPER_2020", - "language": "en", "playlists": [ 17 ] @@ -258,7 +243,6 @@ "rounds": 10, "bonus_points": 0, "rules": "THATS_MY_SONG", - "language": "", "playlists": [ 18 ] diff --git a/backend/experiment/forms.py b/backend/experiment/forms.py index fce76fdf2..7895e73d5 100644 --- a/backend/experiment/forms.py +++ b/backend/experiment/forms.py @@ -9,8 +9,11 @@ CheckboxSelectMultiple, TextInput, ) -from experiment.models import Experiment, Block, SocialMediaConfig, ExperimentTranslatedContent +from django.contrib.admin.widgets import RelatedFieldWidgetWrapper + +from experiment.models import BlockTranslatedContent, Experiment, Block, SocialMediaConfig, ExperimentTranslatedContent from experiment.rules import BLOCK_RULES +from django.db.models.fields.related import ManyToManyRel # session_keys for Export CSV @@ -210,6 +213,11 @@ def __init__(self, *args, **kwargs): class Meta: model = ExperimentTranslatedContent fields = [ + "index", + "language", + "name", + "description", + "consent", "about_content", ] @@ -257,6 +265,7 @@ def clean_playlists(self): class Meta: model = Block fields = [ + "index", "name", "slug", "active", diff --git a/backend/experiment/management/commands/bootstrap.py b/backend/experiment/management/commands/bootstrap.py index 03ce018b8..12847d136 100644 --- a/backend/experiment/management/commands/bootstrap.py +++ b/backend/experiment/management/commands/bootstrap.py @@ -8,24 +8,21 @@ class Command(BaseCommand): - """ Command for creating a superuser and an block if they do not yet exist """ + """Command for creating a superuser and a block if they do not yet exist""" def handle(self, *args, **options): - create_default_questions() if User.objects.count() == 0: - management.call_command('createsuperuser', '--no-input') - print('Created superuser') + management.call_command("createsuperuser", "--no-input") + print("Created superuser") if Block.objects.count() == 0: - playlist = Playlist.objects.create( - name='Empty Playlist' - ) + playlist = Playlist.objects.create(name="Empty Playlist") block = Block.objects.create( - name='Goldsmiths Musical Sophistication Index', - rules='RHYTHM_BATTERY_FINAL', - slug='gold-msi', + name="Goldsmiths Musical Sophistication Index", + rules="RHYTHM_BATTERY_FINAL", + slug="gold-msi", ) block.playlists.add(playlist) block.add_default_question_series() - print('Created default block') + print("Created default block") diff --git a/backend/experiment/management/commands/templates/experiment.py b/backend/experiment/management/commands/templates/experiment.py index 9c866974d..34a9f562b 100644 --- a/backend/experiment/management/commands/templates/experiment.py +++ b/backend/experiment/management/commands/templates/experiment.py @@ -9,7 +9,7 @@ class NewBlockRuleset(Base): - ''' An block type that could be used to test musical preferences ''' + ''' A block type that could be used to test musical preferences ''' ID = 'NEW_BLOCK_RULESET' contact_email = 'info@example.com' diff --git a/backend/experiment/migrations/0053_alter_block_options_rename_series_phase_experiments_and_more.py b/backend/experiment/migrations/0053_alter_block_options_rename_series_phase_experiments_and_more.py new file mode 100644 index 000000000..69d87d4ac --- /dev/null +++ b/backend/experiment/migrations/0053_alter_block_options_rename_series_phase_experiments_and_more.py @@ -0,0 +1,234 @@ +# Generated by Django 4.2.14 on 2024-08-07 13:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("experiment", "0052_remove_experiment_first_experiments_and_more"), + ] + + operations = [ + migrations.AlterModelOptions( + name="block", + options={}, + ), + migrations.RenameField( + model_name="phase", + old_name="series", + new_name="experiment", + ), + migrations.CreateModel( + name="BlockTranslatedContent", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "language", + models.CharField( + blank=True, + choices=[ + ("", "Unset"), + ("aa", "Afar"), + ("af", "Afrikaans"), + ("ak", "Akan"), + ("sq", "Albanian"), + ("am", "Amharic"), + ("ar", "Arabic"), + ("an", "Aragonese"), + ("hy", "Armenian"), + ("as", "Assamese"), + ("av", "Avaric"), + ("ae", "Avestan"), + ("ay", "Aymara"), + ("az", "Azerbaijani"), + ("bm", "Bambara"), + ("ba", "Bashkir"), + ("eu", "Basque"), + ("be", "Belarusian"), + ("bn", "Bengali"), + ("bh", "Bihari languages"), + ("bi", "Bislama"), + ("nb", "Bokmål, Norwegian; Norwegian Bokmål"), + ("bs", "Bosnian"), + ("br", "Breton"), + ("bg", "Bulgarian"), + ("my", "Burmese"), + ("ca", "Catalan; Valencian"), + ("km", "Central Khmer"), + ("ch", "Chamorro"), + ("ce", "Chechen"), + ("ny", "Chichewa; Chewa; Nyanja"), + ("zh", "Chinese"), + ("cu", "Church Slavic; Old Slavonic; Church Slavonic; Old Bulgarian; Old Church Slavonic"), + ("cv", "Chuvash"), + ("kw", "Cornish"), + ("co", "Corsican"), + ("cr", "Cree"), + ("hr", "Croatian"), + ("cs", "Czech"), + ("da", "Danish"), + ("dv", "Divehi; Dhivehi; Maldivian"), + ("nl", "Dutch; Flemish"), + ("dz", "Dzongkha"), + ("en", "English"), + ("eo", "Esperanto"), + ("et", "Estonian"), + ("ee", "Ewe"), + ("fo", "Faroese"), + ("fj", "Fijian"), + ("fi", "Finnish"), + ("fr", "French"), + ("ff", "Fulah"), + ("gd", "Gaelic; Scottish Gaelic"), + ("gl", "Galician"), + ("lg", "Ganda"), + ("ka", "Georgian"), + ("de", "German"), + ("el", "Greek, Modern (1453-)"), + ("gn", "Guarani"), + ("gu", "Gujarati"), + ("ht", "Haitian; Haitian Creole"), + ("ha", "Hausa"), + ("he", "Hebrew"), + ("hz", "Herero"), + ("hi", "Hindi"), + ("ho", "Hiri Motu"), + ("hu", "Hungarian"), + ("is", "Icelandic"), + ("io", "Ido"), + ("ig", "Igbo"), + ("id", "Indonesian"), + ("ia", "Interlingua (International Auxiliary Language Association)"), + ("ie", "Interlingue; Occidental"), + ("iu", "Inuktitut"), + ("ik", "Inupiaq"), + ("ga", "Irish"), + ("it", "Italian"), + ("ja", "Japanese"), + ("jv", "Javanese"), + ("kl", "Kalaallisut; Greenlandic"), + ("kn", "Kannada"), + ("kr", "Kanuri"), + ("ks", "Kashmiri"), + ("kk", "Kazakh"), + ("ki", "Kikuyu; Gikuyu"), + ("rw", "Kinyarwanda"), + ("ky", "Kirghiz; Kyrgyz"), + ("kv", "Komi"), + ("kg", "Kongo"), + ("ko", "Korean"), + ("kj", "Kuanyama; Kwanyama"), + ("ku", "Kurdish"), + ("lo", "Lao"), + ("la", "Latin"), + ("lv", "Latvian"), + ("li", "Limburgan; Limburger; Limburgish"), + ("ln", "Lingala"), + ("lt", "Lithuanian"), + ("lu", "Luba-Katanga"), + ("lb", "Luxembourgish; Letzeburgesch"), + ("mk", "Macedonian"), + ("mg", "Malagasy"), + ("ms", "Malay"), + ("ml", "Malayalam"), + ("mt", "Maltese"), + ("gv", "Manx"), + ("mi", "Maori"), + ("mr", "Marathi"), + ("mh", "Marshallese"), + ("mn", "Mongolian"), + ("na", "Nauru"), + ("nv", "Navajo; Navaho"), + ("nd", "Ndebele, North; North Ndebele"), + ("nr", "Ndebele, South; South Ndebele"), + ("ng", "Ndonga"), + ("ne", "Nepali"), + ("se", "Northern Sami"), + ("no", "Norwegian"), + ("nn", "Norwegian Nynorsk; Nynorsk, Norwegian"), + ("oc", "Occitan (post 1500)"), + ("oj", "Ojibwa"), + ("or", "Oriya"), + ("om", "Oromo"), + ("os", "Ossetian; Ossetic"), + ("pi", "Pali"), + ("pa", "Panjabi; Punjabi"), + ("fa", "Persian"), + ("pl", "Polish"), + ("pt", "Portuguese"), + ("ps", "Pushto; Pashto"), + ("qu", "Quechua"), + ("ro", "Romanian; Moldavian; Moldovan"), + ("rm", "Romansh"), + ("rn", "Rundi"), + ("ru", "Russian"), + ("sm", "Samoan"), + ("sg", "Sango"), + ("sa", "Sanskrit"), + ("sc", "Sardinian"), + ("sr", "Serbian"), + ("sn", "Shona"), + ("ii", "Sichuan Yi; Nuosu"), + ("sd", "Sindhi"), + ("si", "Sinhala; Sinhalese"), + ("sk", "Slovak"), + ("sl", "Slovenian"), + ("so", "Somali"), + ("st", "Sotho, Southern"), + ("es", "Spanish; Castilian"), + ("su", "Sundanese"), + ("sw", "Swahili"), + ("ss", "Swati"), + ("sv", "Swedish"), + ("tl", "Tagalog"), + ("ty", "Tahitian"), + ("tg", "Tajik"), + ("ta", "Tamil"), + ("tt", "Tatar"), + ("te", "Telugu"), + ("th", "Thai"), + ("bo", "Tibetan"), + ("ti", "Tigrinya"), + ("to", "Tonga (Tonga Islands)"), + ("ts", "Tsonga"), + ("tn", "Tswana"), + ("tr", "Turkish"), + ("tk", "Turkmen"), + ("tw", "Twi"), + ("ug", "Uighur; Uyghur"), + ("uk", "Ukrainian"), + ("ur", "Urdu"), + ("uz", "Uzbek"), + ("ve", "Venda"), + ("vi", "Vietnamese"), + ("vo", "Volapük"), + ("wa", "Walloon"), + ("cy", "Welsh"), + ("fy", "Western Frisian"), + ("wo", "Wolof"), + ("xh", "Xhosa"), + ("yi", "Yiddish"), + ("yo", "Yoruba"), + ("za", "Zhuang; Chuang"), + ("zu", "Zulu"), + ], + default="", + max_length=2, + ), + ), + ("name", models.CharField(default="", max_length=64)), + ("description", models.TextField(blank=True, default="")), + ( + "block", + models.ForeignKey( + on_delete=models.deletion.CASCADE, + related_name="translated_contents", + to="experiment.block", + ), + ), + ], + options={ + "unique_together": {("block", "language")}, + }, + ), + ] diff --git a/backend/experiment/migrations/0054_migrate_block_content.py b/backend/experiment/migrations/0054_migrate_block_content.py new file mode 100644 index 000000000..825903fd8 --- /dev/null +++ b/backend/experiment/migrations/0054_migrate_block_content.py @@ -0,0 +1,107 @@ +# Generated by Django 4.2.14 on 2024-08-07 14:30 + +from django.db import migrations +from django.core.files.base import File +from pathlib import Path + + +def migrate_block_content(apps, schema_editor): + Block = apps.get_model("experiment", "Block") + BlockTranslatedContent = apps.get_model("experiment", "BlockTranslatedContent") + Phase = apps.get_model("experiment", "Phase") + Experiment = apps.get_model("experiment", "Experiment") + ExperimentTranslatedContent = apps.get_model("experiment", "ExperimentTranslatedContent") + + for block in Block.objects.all(): + language = block.language if hasattr(block, "language") and block.language else "en" + + BlockTranslatedContent.objects.create( + block=block, + language=language, + name=block.name, + description=block.description, + ) + + if block.phase: + continue + + # Create a new experiment and phase for orphan blocks + experiment = Experiment.objects.create(slug=block.slug) + content = ExperimentTranslatedContent.objects.create( + experiment=experiment, + index=0, + language=language, + name=block.name, + description=block.description, + ) + + # Attempt to add consent file + rules = block.get_rules() + try: + consent_path = Path("experiment", "templates", rules.default_consent_file) + with consent_path.open(mode="rb") as f: + content.consent = File(f, name=consent_path.name) + content.save() + except Exception: + # If there's an error, we'll just skip adding the consent file + pass + + phase = Phase.objects.create(experiment=experiment, index=0, name=f"{block.name}_phase") + block.phase = phase + block.save() + + +def reverse_migrate_block_content(apps, schema_editor): + Block = apps.get_model("experiment", "Block") + BlockTranslatedContent = apps.get_model("experiment", "BlockTranslatedContent") + Experiment = apps.get_model("experiment", "Experiment") + ExperimentTranslatedContent = apps.get_model("experiment", "ExperimentTranslatedContent") + Phase = apps.get_model("experiment", "Phase") + + for block in Block.objects.all(): + block_fallback_content = block.translated_contents.first() + + phase = block.phase + experiment = phase.experiment if phase else None + experiment_fallback_content = ( + ExperimentTranslatedContent.objects.filter(experiment=experiment).order_by("index").first() + if experiment + else None + ) + + if experiment_fallback_content: + language = experiment_fallback_content.language + possible_block_fallback_content = block.translated_contents.filter(language=language).first() + if possible_block_fallback_content: + block_fallback_content = possible_block_fallback_content + + if not block_fallback_content: + continue + + block.name = block_fallback_content.name if block_fallback_content.name else block.slug + block.description = block_fallback_content.description if block_fallback_content.description else "" + if experiment_fallback_content and experiment_fallback_content.consent: + block.consent = experiment_fallback_content.consent + + # Remove the created phase and experiment if they match the criteria + if block.phase and block.phase.name == f"{block.name}_phase": + phase = Phase.objects.get(pk=block.phase.id) + experiment = Experiment.objects.get(pk=phase.experiment.id) + block.phase = None + block.save() + phase.delete() + experiment.delete() + + block.save() + + BlockTranslatedContent.objects.all().delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("experiment", "0053_alter_block_options_rename_series_phase_experiments_and_more"), + ] + + operations = [ + migrations.RunPython(migrate_block_content, reverse_migrate_block_content), + ] diff --git a/backend/experiment/migrations/0055_remove_block_consent_remove_block_hashtag_and_more.py b/backend/experiment/migrations/0055_remove_block_consent_remove_block_hashtag_and_more.py new file mode 100644 index 000000000..0bbca34ad --- /dev/null +++ b/backend/experiment/migrations/0055_remove_block_consent_remove_block_hashtag_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.14 on 2024-08-21 14:04 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiment', '0054_migrate_block_content'), + ] + + operations = [ + migrations.RemoveField( + model_name='block', + name='consent', + ), + migrations.RemoveField( + model_name='block', + name='hashtag', + ), + migrations.RemoveField( + model_name='block', + name='language', + ), + migrations.RemoveField( + model_name='block', + name='url', + ), + ] diff --git a/backend/experiment/models.py b/backend/experiment/models.py index 94cafe49f..72fcd92a5 100644 --- a/backend/experiment/models.py +++ b/backend/experiment/models.py @@ -2,7 +2,7 @@ from django.db import models from django.utils import timezone -from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext_lazy as _, get_language from django.contrib.postgres.fields import ArrayField from typing import List, Dict, Tuple, Any from experiment.standards.iso_languages import ISO_LANGUAGES @@ -84,18 +84,23 @@ def get_translated_content(self, language: str, fallback: bool = True): return content + def get_current_content(self, fallback: bool = True): + """Get content for the 'current' language""" + language = get_language() + return self.get_translated_content(language, fallback) + class Phase(models.Model): name = models.CharField(max_length=64, blank=True, default="") - series = models.ForeignKey(Experiment, on_delete=models.CASCADE, related_name="phases") + experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE, related_name="phases") index = models.IntegerField(default=0, help_text="Index of the phase in the series. Lower numbers come first.") dashboard = models.BooleanField(default=False) randomize = models.BooleanField(default=False, help_text="Randomize the order of the experiments in this phase.") def __str__(self): - default_content = self.series.get_fallback_content() + default_content = self.experiment.get_fallback_content() experiment_name = default_content.name if default_content else None - compound_name = self.name or experiment_name or self.series.slug or "Unnamed phase" + compound_name = self.name or experiment_name or self.experiment.slug or "Unnamed phase" if not self.name: return f"{compound_name} ({self.index})" @@ -112,29 +117,31 @@ class Block(models.Model): phase = models.ForeignKey(Phase, on_delete=models.CASCADE, related_name="blocks", blank=True, null=True) index = models.IntegerField(default=0, help_text="Index of the block in the phase. Lower numbers come first.") playlists = models.ManyToManyField("section.Playlist", blank=True) + + # TODO: to be deleted? name = models.CharField(db_index=True, max_length=64) + # TODO: to be deleted? description = models.TextField(blank=True, default="") + image = models.ForeignKey(Image, on_delete=models.SET_NULL, blank=True, null=True) slug = models.SlugField(db_index=True, max_length=64, unique=True, validators=[block_slug_validator]) - url = models.CharField( - verbose_name="URL with more information about the block", max_length=100, blank=True, default="" - ) - hashtag = models.CharField(verbose_name="hashtag for social media", max_length=20, blank=True, default="") + active = models.BooleanField(default=True) rounds = models.PositiveIntegerField(default=10) bonus_points = models.PositiveIntegerField(default=0) rules = models.CharField(default="", max_length=64) - language = models.CharField(default="", blank=True, choices=language_choices, max_length=2) - theme_config = models.ForeignKey(ThemeConfig, on_delete=models.SET_NULL, blank=True, null=True) - consent = models.FileField( - upload_to=consent_upload_path, blank=True, default="", validators=[markdown_html_validator()] - ) - class Meta: - ordering = ["name"] + translated_contents = models.QuerySet["BlockTranslatedContent"] + + theme_config = models.ForeignKey(ThemeConfig, on_delete=models.SET_NULL, blank=True, null=True) def __str__(self): - return self.name + if self.name: + return self.name + + content = self.get_fallback_content() + + return content.name if content and content.name else self.slug def session_count(self): """Number of sessions""" @@ -304,6 +311,39 @@ def add_default_question_series(self): question_series=qs, question=Question.objects.get(pk=question), index=i + 1 ) + def get_fallback_content(self): + """Get fallback content for the block""" + if not self.phase or self.phase.experiment: + return self.translated_contents.first() + + experiment = self.phase.experiment + fallback_language = experiment.get_fallback_content().language + fallback_content = self.translated_contents.filter(language=fallback_language).first() + + return fallback_content + + def get_translated_content(self, language: str, fallback: bool = True): + """Get content for a specific language""" + content = self.translated_contents.filter(language=language).first() + + if not content and fallback: + fallback_content = self.get_fallback_content() + + if not fallback_content: + raise ValueError("No fallback content found for block") + + return fallback_content + + if not content: + raise ValueError(f"No content found for language {language}") + + return content + + def get_current_content(self, fallback: bool = True): + """Get content for the 'current' language""" + language = get_language() + return self.get_translated_content(language, fallback) + class ExperimentTranslatedContent(models.Model): experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE, related_name="translated_content") @@ -317,6 +357,20 @@ class ExperimentTranslatedContent(models.Model): about_content = models.TextField(blank=True, default="") +class BlockTranslatedContent(models.Model): + block = models.ForeignKey(Block, on_delete=models.CASCADE, related_name="translated_contents") + language = models.CharField(default="", blank=True, choices=language_choices, max_length=2) + name = models.CharField(max_length=64, default="") + description = models.TextField(blank=True, default="") + + def __str__(self): + return f"{self.name} ({self.language})" + + class Meta: + # Assures that there is only one translation per language + unique_together = ["block", "language"] + + class Feedback(models.Model): text = models.TextField() block = models.ForeignKey(Block, on_delete=models.CASCADE) diff --git a/backend/experiment/rules/base.py b/backend/experiment/rules/base.py index 05773b01d..9bf9408d3 100644 --- a/backend/experiment/rules/base.py +++ b/backend/experiment/rules/base.py @@ -25,22 +25,18 @@ def __init__(self): self.question_series = [] def feedback_info(self): - feedback_body = render_to_string('feedback/user_feedback.html', {'email': self.contact_email}) + feedback_body = render_to_string("feedback/user_feedback.html", {"email": self.contact_email}) return { # Header above the feedback form - 'header': _("Do you have any remarks or questions?"), - + "header": _("Do you have any remarks or questions?"), # Button text - 'button': _("Submit"), - + "button": _("Submit"), # Body of the feedback form, can be HTML. Shown under the button - 'contact_body': feedback_body, - + "contact_body": feedback_body, # Thank you message after submitting feedback - 'thank_you': _("We appreciate your feedback!"), - + "thank_you": _("We appreciate your feedback!"), # Show a floating button on the right side of the screen to open the feedback form - 'show_float_button': False, + "show_float_button": False, } def calculate_score(self, result, data): @@ -53,11 +49,15 @@ def calculate_score(self, result, data): return None def get_play_again_url(self, session: Session): - participant_id_url_param = f'?participant_id={session.participant.participant_id_url}' if session.participant.participant_id_url else "" - return f'/{session.block.slug}{participant_id_url_param}' + participant_id_url_param = ( + f"?participant_id={session.participant.participant_id_url}" + if session.participant.participant_id_url + else "" + ) + return f"/{session.block.slug}{participant_id_url_param}" def calculate_intermediate_score(self, session, result): - """ process result data during a trial (i.e., between next_round calls) + """process result data during a trial (i.e., between next_round calls) return score """ return 0 @@ -78,10 +78,7 @@ def final_score_message(self, session): correct += 1 score_message = "Well done!" if session.final_score > 0 else "Too bad!" - message = "You correctly identified {} out of {} recognized songs!".format( - correct, - total - ) + message = "You correctly identified {} out of {} recognized songs!".format(correct, total) return score_message + " " + message def rank(self, session, exclude_unfinished=True): @@ -91,21 +88,19 @@ def rank(self, session, exclude_unfinished=True): # Few or negative points or no score, always return lowest plastic score if score <= 0 or not score: - return ranks['PLASTIC'] + return ranks["PLASTIC"] # Buckets for positive scores: # rank: starts percentage buckets = [ # ~ stanines 1-3 - {'rank': ranks['BRONZE'], 'min_percentile': 0.0}, + {"rank": ranks["BRONZE"], "min_percentile": 0.0}, # ~ stanines 4-6 - {'rank': ranks['SILVER'], 'min_percentile': 25.0}, + {"rank": ranks["SILVER"], "min_percentile": 25.0}, # ~ stanine 7 - {'rank': ranks['GOLD'], 'min_percentile': 75.0}, - {'rank': ranks['PLATINUM'], - 'min_percentile': 90.0}, # ~ stanine 8 - {'rank': ranks['DIAMOND'], - 'min_percentile': 95.0}, # ~ stanine 9 + {"rank": ranks["GOLD"], "min_percentile": 75.0}, + {"rank": ranks["PLATINUM"], "min_percentile": 90.0}, # ~ stanine 8 + {"rank": ranks["DIAMOND"], "min_percentile": 95.0}, # ~ stanine 9 ] percentile = session.percentile_rank(exclude_unfinished) @@ -114,11 +109,11 @@ def rank(self, session, exclude_unfinished=True): # If the percentile rank is higher than the min_percentile # return the rank for bucket in reversed(buckets): - if percentile >= bucket['min_percentile']: - return bucket['rank'] + if percentile >= bucket["min_percentile"]: + return bucket["rank"] # Default return, in case score isn't in the buckets - return ranks['PLASTIC'] + return ranks["PLASTIC"] def get_single_question(self, session, randomize=False): """Get a random question from each question list, in priority completion order. @@ -126,54 +121,66 @@ def get_single_question(self, session, randomize=False): Participants will not continue to the next question set until they have completed their current one. """ - questionnaire = unanswered_questions(session.participant, get_questions_from_series(session.block.questionseries_set.all()), randomize) + questionnaire = unanswered_questions( + session.participant, get_questions_from_series(session.block.questionseries_set.all()), randomize + ) try: question = next(questionnaire) - return Trial( - title=_("Questionnaire"), - feedback_form=Form([question], is_skippable=question.is_skippable)) + return Trial(title=_("Questionnaire"), feedback_form=Form([question], is_skippable=question.is_skippable)) except StopIteration: return None def get_open_questions(self, session, randomize=False, cutoff_index=None) -> Union[list, None]: - ''' Get a list of trials for questions not yet answered by the user ''' + """Get a list of trials for questions not yet answered by the user""" trials = [] - questions = list(unanswered_questions(session.participant, get_questions_from_series(session.block.questionseries_set.all()), randomize, cutoff_index)) + questions = list( + unanswered_questions( + session.participant, + get_questions_from_series(session.block.questionseries_set.all()), + randomize, + cutoff_index, + ) + ) open_questions = len(questions) if not open_questions: return None for index, question in enumerate(questions): - trials.append(Trial( - title=_("Questionnaire %(index)i / %(total)i") % {'index': index+1, 'total': open_questions}, - feedback_form=Form([question], is_skippable=question.is_skippable) - )) + trials.append( + Trial( + title=_("Questionnaire %(index)i / %(total)i") % {"index": index + 1, "total": open_questions}, + feedback_form=Form([question], is_skippable=question.is_skippable), + ) + ) return trials def social_media_info(self, block, score): - ''' ⚠️ Deprecated. The social media info will eventually be present on the Experiment level, not the Block level. ''' + """⚠️ Deprecated. The social media info will eventually be present on the Experiment level, not the Block level.""" current_url = f"{settings.RELOAD_PARTICIPANT_TARGET}/{block.slug}" + + experiment = block.phase.experiment + social_media_config = experiment.social_media_config + tags = social_media_config.tags if social_media_config.tags else [] + url = social_media_config.url or current_url + return { - 'apps': ['facebook', 'twitter'], - 'message': _("I scored %(score)i points on %(url)s") % { - 'score': score, - 'url': current_url - }, - 'url': block.url or current_url, - 'hashtags': [block.hashtag or block.slug, "amsterdammusiclab", "citizenscience"] + "apps": ["facebook", "twitter"], + "message": _("I scored %(score)i points on %(url)s") % {"score": score, "url": current_url}, + "url": url, + "hashtags": [*tags, "amsterdammusiclab", "citizenscience"], } def validate_playlist(self, playlist: None): errors = [] # Common validations across blocks if not playlist: - errors.append('The block must have a playlist.') + errors.append("The block must have a playlist.") return errors sections = playlist.section_set.all() if not sections: - errors.append('The block must have at least one section.') + errors.append("The block must have at least one section.") try: playlist.clean_csv() diff --git a/backend/experiment/rules/categorization.py b/backend/experiment/rules/categorization.py index 63c648bc4..43f61b22b 100644 --- a/backend/experiment/rules/categorization.py +++ b/backend/experiment/rules/categorization.py @@ -16,15 +16,15 @@ class Categorization(Base): - ID = 'CATEGORIZATION' - default_consent_file = 'consent/consent_categorization.html' + ID = "CATEGORIZATION" + default_consent_file = "consent/consent_categorization.html" def __init__(self): self.question_series = [ { "name": "Categorization", - "keys": ['dgf_age','dgf_gender_reduced','dgf_native_language','dgf_musical_experience'], - "randomize": False + "keys": ["dgf_age", "dgf_gender_reduced", "dgf_native_language", "dgf_musical_experience"], + "randomize": False, }, ] @@ -32,7 +32,7 @@ def get_intro_explainer(self): return Explainer( instruction="This is a listening experiment in which you have to respond to short sound sequences.", steps=[], - button_label='Ok' + button_label="Ok", ) def next_round(self, session: Session): @@ -46,12 +46,12 @@ def next_round(self, session: Session): json_data = session.load_json_data() # Plan experiment on the first call to next_round - if not json_data.get('phase'): + if not json_data.get("phase"): json_data = self.plan_experiment(session) # Check if this participant already has a session - if json_data == 'REPEAT': - json_data = {'phase': 'REPEAT'} + if json_data == "REPEAT": + json_data = {"phase": "REPEAT"} session.save_json_data(json_data) session.save() final = Final( @@ -61,8 +61,8 @@ def next_round(self, session: Session): return final # Total participants reached - Abort with message - if json_data == 'FULL': - json_data = {'phase': 'FULL'} + if json_data == "FULL": + json_data = {"phase": "FULL"} session.save_json_data(json_data) session.save() final = Final( @@ -72,11 +72,10 @@ def next_round(self, session: Session): return final # Calculate round number from passed training rounds - rounds_passed = (session.get_rounds_passed() - - int(json_data['training_rounds'])) + rounds_passed = session.get_rounds_passed() - int(json_data["training_rounds"]) # Change phase to enable collecting results of second half of training-1 if session.get_rounds_passed() == 10: - json_data['phase'] = 'training-1B' + json_data["phase"] = "training-1B" session.save_json_data(json_data) session.save() @@ -85,15 +84,13 @@ def next_round(self, session: Session): profiles = session.participant.profile() for profile in profiles: # Delete results and json from session and exit - if profile.given_response == 'aborted': + if profile.given_response == "aborted": session.result_set.all().delete() - json_data = {'phase': 'ABORTED', - 'training_rounds': json_data['training_rounds']} + json_data = {"phase": "ABORTED", "training_rounds": json_data["training_rounds"]} session.save_json_data(json_data) session.save() profile.delete() - final_message = render_to_string( - 'final/categorization_final.html') + final_message = render_to_string("final/categorization_final.html") final = Final( session=session, final_text="Thanks for your participation!" + final_message, @@ -102,58 +99,58 @@ def next_round(self, session: Session): # Prepare sections for next phase json_data = self.plan_phase(session) - if 'training' in json_data['phase']: + if "training" in json_data["phase"]: if rounds_passed == 0: explainer2 = Explainer( instruction="The experiment will now begin. Please don't close the browser during the experiment. You can only run it once. Click to start a sound sequence.", steps=[], - button_label='Ok' + button_label="Ok", ) trial = self.next_trial_action(session) return [explainer2, trial] # Get next training action - elif rounds_passed < len(json_data['sequence']): + elif rounds_passed < len(json_data["sequence"]): return self.get_trial_with_feedback(session) # Training phase completed, get the results - training_rounds = int(json_data['training_rounds']) + training_rounds = int(json_data["training_rounds"]) if training_rounds == 0: - this_results = session.result_set.filter(comment='training-1B') + this_results = session.result_set.filter(comment="training-1B") elif training_rounds == 20: - this_results = session.result_set.filter(comment='training-2') + this_results = session.result_set.filter(comment="training-2") elif training_rounds == 30: - this_results = session.result_set.filter(comment='training-3') + this_results = session.result_set.filter(comment="training-3") # calculate the score for this sequence - score_avg = this_results.aggregate(Avg('score'))['score__avg'] + score_avg = this_results.aggregate(Avg("score"))["score__avg"] # End of training? if score_avg >= SCORE_AVG_MIN_TRAINING: - json_data['phase'] = "testing" - json_data['training_rounds'] = session.get_rounds_passed() + json_data["phase"] = "testing" + json_data["training_rounds"] = session.get_rounds_passed() session.save_json_data(json_data) session.save() explainer = Explainer( instruction="You are entering the main phase of the experiment. From now on you will only occasionally get feedback on your responses. Simply try to keep responding to the sound sequences as you did before.", steps=[], - button_label='Ok' + button_label="Ok", ) else: # Update passed training rounds for calc round_number - json_data['training_rounds'] = session.get_rounds_passed() + json_data["training_rounds"] = session.get_rounds_passed() session.save_json_data(json_data) session.save() # Failed the training? exit experiment - if json_data['training_rounds'] == 40: + if json_data["training_rounds"] == 40: # Clear group from session for reuse end_data = { - 'phase': 'FAILED_TRAINING', - 'training_rounds': json_data['training_rounds'], - 'assigned_group': json_data['assigned_group'], - 'button_colors': json_data['button_colors'], - 'pair_colors': json_data['pair_colors'], + "phase": "FAILED_TRAINING", + "training_rounds": json_data["training_rounds"], + "assigned_group": json_data["assigned_group"], + "button_colors": json_data["button_colors"], + "pair_colors": json_data["pair_colors"], } session.save_json_data(end_data) session.final_score = 0 @@ -161,10 +158,9 @@ def next_round(self, session: Session): profiles = session.participant.profile() for profile in profiles: # Delete failed_training tag from profile - if profile.question_key == 'failed_training': + if profile.question_key == "failed_training": profile.delete() - final_message = render_to_string( - 'final/categorization_final.html') + final_message = render_to_string("final/categorization_final.html") final = Final( session=session, final_text="Thanks for your participation!" + final_message, @@ -172,26 +168,25 @@ def next_round(self, session: Session): return final else: # Show continue to next training phase or exit option - explainer = Trial(title="Training failed", feedback_form=Form( - [repeat_training_or_quit])) + explainer = Trial(title="Training failed", feedback_form=Form([repeat_training_or_quit])) feedback = self.get_feedback(session) return [feedback, explainer] - elif json_data['phase'] == 'testing': - if rounds_passed < len(json_data['sequence']): + elif json_data["phase"] == "testing": + if rounds_passed < len(json_data["sequence"]): # Determine wether this round has feedback - if rounds_passed in json_data['feedback_sequence']: + if rounds_passed in json_data["feedback_sequence"]: return self.get_trial_with_feedback(session) return self.next_trial_action(session) # Testing phase completed get results - this_results = session.result_set.filter(comment='testing') + this_results = session.result_set.filter(comment="testing") # Calculate percentage of correct response to training stimuli final_score = 0 for result in this_results: - if 'T' in result.section.song.name and result.score == 1: + if "T" in result.section.song.name and result.score == 1: final_score += 1 score_percent = 100 * (final_score / 30) @@ -199,27 +194,27 @@ def next_round(self, session: Session): # assign rank based on percentage of correct response to training stimuli if score_percent == 100: - rank = ranks['PLATINUM'] + rank = ranks["PLATINUM"] final_text = "Congratulations! You did great and won a platinum medal!" elif score_percent >= 80: - rank = ranks['GOLD'] + rank = ranks["GOLD"] final_text = "Congratulations! You did great and won a gold medal!" elif score_percent >= 60: - rank = ranks['SILVER'] + rank = ranks["SILVER"] final_text = "Congratulations! You did very well and won a silver medal!" else: - rank = ranks['BRONZE'] + rank = ranks["BRONZE"] final_text = "Congratulations! You did well and won a bronze medal!" # calculate the final score for the entire test sequence # final_score = sum([result.score for result in training_results]) end_data = { - 'phase': 'FINISHED', - 'training_rounds': json_data['training_rounds'], - 'assigned_group': json_data['assigned_group'], - 'button_colors': json_data['button_colors'], - 'pair_colors': json_data['pair_colors'], - 'group': json_data['group'] + "phase": "FINISHED", + "training_rounds": json_data["training_rounds"], + "assigned_group": json_data["assigned_group"], + "button_colors": json_data["button_colors"], + "pair_colors": json_data["pair_colors"], + "group": json_data["group"], } session.save_json_data(end_data) session.finish() @@ -228,15 +223,14 @@ def next_round(self, session: Session): profiles = session.participant.profile() for profile in profiles: # Delete failed_training tag from profile - if profile.question_key == 'failed_training': + if profile.question_key == "failed_training": profile.delete() - final_message = render_to_string( - 'final/categorization_final.html') + final_message = render_to_string("final/categorization_final.html") final = Final( session=session, final_text=final_text + final_message, total_score=round(score_percent), - points='% correct' + points="% correct", ) return final @@ -251,17 +245,18 @@ def plan_experiment(self, session): """ # Check for unfinished sessions older then 24 hours caused by closed browser - all_sessions = session.block.session_set.filter( - finished_at=None).filter( - started_at__lte=timezone.now()-timezone.timedelta(hours=24)).exclude( - json_data__contains='ABORTED').exclude( - json_data__contains='FAILED_TRAINING').exclude( - json_data__contains='REPEAT').exclude( - json_data__contains='FULL').exclude( - json_data__contains='CLOSED_BROWSER') + all_sessions = ( + session.block.session_set.filter(finished_at=None) + .filter(started_at__lte=timezone.now() - timezone.timedelta(hours=24)) + .exclude(json_data__contains="ABORTED") + .exclude(json_data__contains="FAILED_TRAINING") + .exclude(json_data__contains="REPEAT") + .exclude(json_data__contains="FULL") + .exclude(json_data__contains="CLOSED_BROWSER") + ) for closed_session in all_sessions: # Release the group for assignment to a new participant - closed_json_data = {'phase': 'CLOSED_BROWSER'} + closed_json_data = {"phase": "CLOSED_BROWSER"} # Delete results closed_session.save_json_data(closed_json_data) closed_session.result_set.all().delete() @@ -269,77 +264,72 @@ def plan_experiment(self, session): # Count sessions per assigned group used_groups = [ - session.block.session_set.filter( - json_data__contains='S1').count(), - session.block.session_set.filter( - json_data__contains='S2').count(), - session.block.session_set.filter( - json_data__contains='C1').count(), - session.block.session_set.filter( - json_data__contains='C2').count() + session.block.session_set.filter(json_data__contains="S1").count(), + session.block.session_set.filter(json_data__contains="S2").count(), + session.block.session_set.filter(json_data__contains="C1").count(), + session.block.session_set.filter(json_data__contains="C2").count(), ] # Get sessions for current participant - current_sessions = session.block.session_set.filter( - participant=session.participant) + current_sessions = session.block.session_set.filter(participant=session.participant) # Check if this participant already has a previous session if current_sessions.count() > 1 and not settings.TESTING: - json_data = 'REPEAT' + json_data = "REPEAT" else: # Check wether a group falls behind in the count if max(used_groups) - min(used_groups) > 1: # assign the group that falls behind group_index = used_groups.index(min(used_groups)) if group_index == 0: - group = 'S1' + group = "S1" elif group_index == 1: - group = 'S2' + group = "S2" elif group_index == 2: - group = 'C1' + group = "C1" elif group_index == 3: - group = 'C2' + group = "C2" else: # Assign a random group - group = random.choice(['S1', 'S2', 'C1', 'C2']) + group = random.choice(["S1", "S2", "C1", "C2"]) # Assign a random correct response color for 1A, 2A - stimuli_a = random.choice(['BLUE', 'ORANGE']) + stimuli_a = random.choice(["BLUE", "ORANGE"]) # Determine which button is orange and which is blue - button_order = random.choice(['neutral', 'neutral-inverted']) + button_order = random.choice(["neutral", "neutral-inverted"]) # Set expected resonse accordingly - ph = '___' # placeholder - if button_order == 'neutral' and stimuli_a == 'BLUE': - choices = {'A': ph, 'B': ph} - elif button_order == 'neutral-inverted' and stimuli_a == 'ORANGE': - choices = {'A': ph, 'B': ph} + ph = "___" # placeholder + if button_order == "neutral" and stimuli_a == "BLUE": + choices = {"A": ph, "B": ph} + elif button_order == "neutral-inverted" and stimuli_a == "ORANGE": + choices = {"A": ph, "B": ph} else: - choices = {'B': ph, 'A': ph} - if group == 'S1': - assigned_group = 'Same direction, Pair 1' - elif group == 'S2': - assigned_group = 'Same direction, Pair 2' - elif group == 'C1': - assigned_group = 'Crossed direction, Pair 1' + choices = {"B": ph, "A": ph} + if group == "S1": + assigned_group = "Same direction, Pair 1" + elif group == "S2": + assigned_group = "Same direction, Pair 2" + elif group == "C1": + assigned_group = "Crossed direction, Pair 1" else: - assigned_group = 'Crossed direction, Pair 2' - if button_order == 'neutral': - button_colors = 'Blue left, Orange right' + assigned_group = "Crossed direction, Pair 2" + if button_order == "neutral": + button_colors = "Blue left, Orange right" else: - button_colors = 'Orange left, Blue right' - if stimuli_a == 'BLUE': - pair_colors = 'A = Blue, B = Orange' + button_colors = "Orange left, Blue right" + if stimuli_a == "BLUE": + pair_colors = "A = Blue, B = Orange" else: - pair_colors = 'A = Orange, B = Blue' + pair_colors = "A = Orange, B = Blue" json_data = { - 'phase': "training", - 'training_rounds': "0", - 'assigned_group': assigned_group, - 'button_colors': button_colors, - 'pair_colors': pair_colors, - 'group': group, - 'stimuli_a': stimuli_a, - 'button_order': button_order, - 'choices': choices + "phase": "training", + "training_rounds": "0", + "assigned_group": assigned_group, + "button_colors": button_colors, + "pair_colors": pair_colors, + "group": group, + "stimuli_a": stimuli_a, + "button_order": button_order, + "choices": choices, } session.save_json_data(json_data) session.save() @@ -348,60 +338,72 @@ def plan_experiment(self, session): def plan_phase(self, session): json_data = session.load_json_data() - if 'training' in json_data['phase']: + if "training" in json_data["phase"]: # Retrieve training stimuli for the assigned group - if json_data["group"] == 'S1': + if json_data["group"] == "S1": sections = session.playlist.section_set.filter( - group='SAME', tag__contains='1', song__artist__contains='Training') - elif json_data["group"] == 'S2': + group="SAME", tag__contains="1", song__artist__contains="Training" + ) + elif json_data["group"] == "S2": sections = session.playlist.section_set.filter( - group='SAME', tag__contains='2', song__artist__contains='Training') - elif json_data["group"] == 'C1': + group="SAME", tag__contains="2", song__artist__contains="Training" + ) + elif json_data["group"] == "C1": sections = session.playlist.section_set.filter( - group='CROSSED', tag__contains='1', song__artist__contains='Training') - elif json_data["group"] == 'C2': + group="CROSSED", tag__contains="1", song__artist__contains="Training" + ) + elif json_data["group"] == "C2": sections = session.playlist.section_set.filter( - group='CROSSED', tag__contains='2', song__artist__contains='Training') + group="CROSSED", tag__contains="2", song__artist__contains="Training" + ) # Generate randomized sequence for the testing phase section_sequence = [] # Add 10 x 2 training stimuli - if int(json_data['training_rounds']) == 0: + if int(json_data["training_rounds"]) == 0: new_rounds = 10 - json_data['phase'] = 'training-1A' - elif int(json_data['training_rounds']) == 20: - json_data['phase'] = 'training-2' + json_data["phase"] = "training-1A" + elif int(json_data["training_rounds"]) == 20: + json_data["phase"] = "training-2" new_rounds = 5 else: - json_data['phase'] = 'training-3' + json_data["phase"] = "training-3" new_rounds = 5 for _ in range(0, new_rounds): section_sequence.append(sections[0].song_id) section_sequence.append(sections[1].song_id) random.shuffle(section_sequence) - json_data['sequence'] = section_sequence + json_data["sequence"] = section_sequence else: # Retrieve test & training stimuli for the assigned group - if json_data["group"] == 'S1': + if json_data["group"] == "S1": training_sections = session.playlist.section_set.filter( - group='SAME', tag__contains='1', song__artist__contains='Training') - test_sections = session.playlist.section_set.filter( - group='SAME', tag__contains='1').exclude(song__artist__contains='Training') - elif json_data["group"] == 'S2': + group="SAME", tag__contains="1", song__artist__contains="Training" + ) + test_sections = session.playlist.section_set.filter(group="SAME", tag__contains="1").exclude( + song__artist__contains="Training" + ) + elif json_data["group"] == "S2": training_sections = session.playlist.section_set.filter( - group='SAME', tag__contains='2', song__artist__contains='Training') - test_sections = session.playlist.section_set.filter( - group='SAME', tag__contains='2').exclude(song__artist__contains='Training') - elif json_data["group"] == 'C1': + group="SAME", tag__contains="2", song__artist__contains="Training" + ) + test_sections = session.playlist.section_set.filter(group="SAME", tag__contains="2").exclude( + song__artist__contains="Training" + ) + elif json_data["group"] == "C1": training_sections = session.playlist.section_set.filter( - group='CROSSED', tag__contains='1', song__artist__contains='Training') - test_sections = session.playlist.section_set.filter( - group='CROSSED', tag__contains='1').exclude(song__artist__contains='Training') - elif json_data["group"] == 'C2': + group="CROSSED", tag__contains="1", song__artist__contains="Training" + ) + test_sections = session.playlist.section_set.filter(group="CROSSED", tag__contains="1").exclude( + song__artist__contains="Training" + ) + elif json_data["group"] == "C2": training_sections = session.playlist.section_set.filter( - group='CROSSED', tag__contains='2', song__artist__contains='Training') - test_sections = session.playlist.section_set.filter( - group='CROSSED', tag__contains='2').exclude(song__artist__contains='Training') + group="CROSSED", tag__contains="2", song__artist__contains="Training" + ) + test_sections = session.playlist.section_set.filter(group="CROSSED", tag__contains="2").exclude( + song__artist__contains="Training" + ) # Generate randomized sequence for the testing phase section_sequence = [] # Add 15 x 2 training stimuli @@ -418,16 +420,16 @@ def plan_phase(self, session): sequence_length = len(section_sequence) sequence_a = [] sequence_b = [] - for stimulus in range(sequence_length-1): + for stimulus in range(sequence_length - 1): if section_sequence[stimulus] == training_sections[0].song_id: - sequence_a.append((stimulus+1)) + sequence_a.append((stimulus + 1)) elif section_sequence[stimulus] == training_sections[1].song_id: - sequence_b.append((stimulus+1)) + sequence_b.append((stimulus + 1)) random.shuffle(sequence_a) random.shuffle(sequence_b) feedback_sequence = sequence_a[0:10] + sequence_b[0:10] - json_data['feedback_sequence'] = feedback_sequence - json_data['sequence'] = section_sequence + json_data["feedback_sequence"] = feedback_sequence + json_data["sequence"] = section_sequence session.save_json_data(json_data) session.save() @@ -435,22 +437,20 @@ def plan_phase(self, session): return json_data def get_feedback(self, session): - last_score = session.last_score() if session.last_result().given_response == "TIMEOUT": icon = "fa-question" elif last_score == 1: - icon = 'fa-face-smile' + icon = "fa-face-smile" elif last_score == 0: - icon = 'fa-face-frown' + icon = "fa-face-frown" else: pass # throw error return Score(session, icon=icon, timer=1, title=self.get_title(session)) def get_trial_with_feedback(self, session): - score = self.get_feedback(session) trial = self.next_trial_action(session) @@ -463,44 +463,44 @@ def next_trial_action(self, session): json_data = session.load_json_data() # Retrieve next section in the sequence - rounds_passed = (session.get_rounds_passed() - - int(json_data['training_rounds'])) - sequence = json_data['sequence'] + rounds_passed = session.get_rounds_passed() - int(json_data["training_rounds"]) + sequence = json_data["sequence"] this_section = sequence[get_rounds_passed] section = session.playlist.get_section(song_ids=[this_section]) # Determine expected response - if section.tag == '1A' or section.tag == '2A': - expected_response = 'A' + if section.tag == "1A" or section.tag == "2A": + expected_response = "A" else: - expected_response = 'B' + expected_response = "B" choices = json_data["choices"] - config = {'listen_first': True, - 'auto_advance': True, - 'auto_advance_timer': 2500, - 'time_pass_break': False - } - style = {json_data['button_order']: True} - trial = two_alternative_forced(session, section, choices, expected_response, - style=style, comment=json_data['phase'], scoring_rule='CORRECTNESS', title=self.get_title(session), config=config) + config = {"listen_first": True, "auto_advance": True, "auto_advance_timer": 2500, "time_pass_break": False} + style = {json_data["button_order"]: True} + trial = two_alternative_forced( + session, + section, + choices, + expected_response, + style=style, + comment=json_data["phase"], + scoring_rule="CORRECTNESS", + title=self.get_title(session), + config=config, + ) return trial def get_title(self, session): json_data = session.load_json_data() - rounds_passed = (session.get_rounds_passed() - - int(json_data['training_rounds'])+1) + rounds_passed = session.get_rounds_passed() - int(json_data["training_rounds"]) + 1 return f"Round {get_rounds_passed} / {len(json_data['sequence'])}" repeat_training_or_quit = ChoiceQuestion( - key='failed_training', - view='BUTTON_ARRAY', - question='You seem to have difficulties reacting correctly to the sound sequences. Is your audio on? If you want to give it another try, click on Ok.', - choices={ - 'continued': "OK", - 'aborted': "Exit" - }, + key="failed_training", + view="BUTTON_ARRAY", + question="You seem to have difficulties reacting correctly to the sound sequences. Is your audio on? If you want to give it another try, click on Ok.", + choices={"continued": "OK", "aborted": "Exit"}, submits=True, is_skippable=False, - style={'buttons-large-gap': True, 'buttons-large-text': True, 'boolean': True} + style={"buttons-large-gap": True, "buttons-large-text": True, "boolean": True}, ) diff --git a/backend/experiment/rules/hooked.py b/backend/experiment/rules/hooked.py index 7ebcd09d0..bf85f3530 100644 --- a/backend/experiment/rules/hooked.py +++ b/backend/experiment/rules/hooked.py @@ -21,9 +21,9 @@ class Hooked(Base): """Superclass for Hooked experiment rules""" - ID = 'HOOKED' - default_consent_file = 'consent/consent_hooked.html' + ID = "HOOKED" + default_consent_file = "consent/consent_hooked.html" recognition_time = 15 # response time for "Do you know this song?" sync_time = 15 # response time for "Did the track come back in the right place?" # if the track continues in the wrong place: minimal shift forward (in seconds) @@ -31,35 +31,57 @@ class Hooked(Base): # if the track continutes in the wrong place: maximal shift forward (in seconds) max_jitter = 15 heard_before_time = 15 # response time for "Have you heard this song in previous rounds?" - question_offset = 5 # how many rounds will be presented without questions + question_offset = 5 # how many rounds will be presented without questions questions = True - counted_result_keys = ['recognize', 'heard_before'] - play_method = 'BUFFER' + counted_result_keys = ["recognize", "heard_before"] + play_method = "BUFFER" def __init__(self): self.question_series = [ - {"name": "DEMOGRAPHICS", "keys": QUESTION_GROUPS["DEMOGRAPHICS"], "randomize": True}, # 1. Demographic questions (7 questions) - {"name": "MSI_OTHER", "keys": ['msi_39_best_instrument'], "randomize": False}, - {"name": "MSI_FG_GENERAL", "keys": QUESTION_GROUPS["MSI_FG_GENERAL"], "randomize": True}, # 2. General music sophistication - {"name": "MSI_ALL", "keys": QUESTION_GROUPS["MSI_ALL"], "randomize": True}, # 3. Complete music sophistication (20 questions) - {"name": "STOMP20", "keys": QUESTION_GROUPS["STOMP20"], "randomize": True}, # 4. STOMP (20 questions) - {"name": "TIPI", "keys": QUESTION_GROUPS["TIPI"], "randomize": True}, # 5. TIPI (10 questions) + { + "name": "DEMOGRAPHICS", + "keys": QUESTION_GROUPS["DEMOGRAPHICS"], + "randomize": True, + }, # 1. Demographic questions (7 questions) + {"name": "MSI_OTHER", "keys": ["msi_39_best_instrument"], "randomize": False}, + { + "name": "MSI_FG_GENERAL", + "keys": QUESTION_GROUPS["MSI_FG_GENERAL"], + "randomize": True, + }, # 2. General music sophistication + { + "name": "MSI_ALL", + "keys": QUESTION_GROUPS["MSI_ALL"], + "randomize": True, + }, # 3. Complete music sophistication (20 questions) + {"name": "STOMP20", "keys": QUESTION_GROUPS["STOMP20"], "randomize": True}, # 4. STOMP (20 questions) + {"name": "TIPI", "keys": QUESTION_GROUPS["TIPI"], "randomize": True}, # 5. TIPI (10 questions) ] def get_intro_explainer(self): - """ Explain the game """ + """Explain the game""" return Explainer( instruction="How to Play", steps=[ - Step(_( - "Do you recognise the song? Try to sing along. The faster you recognise songs, the more points you can earn.")), - Step(_( - "Do you really know the song? Keep singing or imagining the music while the sound is muted. The music is still playing: you just can’t hear it!")), - Step(_( - "Was the music in the right place when the sound came back? Or did we jump to a different spot during the silence?")) + Step( + _( + "Do you recognise the song? Try to sing along. The faster you recognise songs, the more points you can earn." + ) + ), + Step( + _( + "Do you really know the song? Keep singing or imagining the music while the sound is muted. The music is still playing: you just can’t hear it!" + ) + ), + Step( + _( + "Was the music in the right place when the sound came back? Or did we jump to a different spot during the silence?" + ) + ), ], step_numbers=True, - button_label=_("Let's go!")) + button_label=_("Let's go!"), + ) def next_round(self, session: Session): """Get action data for the next round""" @@ -68,7 +90,6 @@ def next_round(self, session: Session): # If the number of results equals the number of block.rounds, # close the session and return data for the final_score view. if round_number == session.block.rounds: - # Finish session. session.finish() session.save() @@ -81,14 +102,13 @@ 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.block, total_score), + social=self.social_media_info(session.block, total_score), show_profile_link=True, button={ - 'text': _('Play again'), - 'link': self.get_play_again_url(session), - } - ) + "text": _("Play again"), + "link": self.get_play_again_url(session), + }, + ), ] # Get next round number and initialise actions list. Two thirds of @@ -110,31 +130,27 @@ def next_round(self, session: Session): else: # Create a score action. actions.append(self.get_score(session, round_number)) - heard_before_offset = session.load_json_data().get('heard_before_offset') + heard_before_offset = session.load_json_data().get("heard_before_offset") # SongSync rounds. Skip questions until round indicated by question_offset. if round_number in range(1, self.question_offset): - actions.extend(self.next_song_sync_action( - session, round_number)) + actions.extend(self.next_song_sync_action(session, round_number)) elif round_number in range(self.question_offset, heard_before_offset): question_trial = self.get_single_question(session) if question_trial: actions.append(question_trial) - actions.extend(self.next_song_sync_action( - session, round_number)) + actions.extend(self.next_song_sync_action(session, round_number)) # HeardBefore rounds elif round_number == heard_before_offset: # Introduce new round type with Explainer. actions.append(self.heard_before_explainer()) - actions.append( - self.next_heard_before_action(session, round_number)) + actions.append(self.next_heard_before_action(session, round_number)) elif round_number > heard_before_offset: question_trial = self.get_single_question(session) if question_trial: actions.append(question_trial) - actions.append( - self.next_heard_before_action(session, round_number)) + actions.append(self.next_heard_before_action(session, round_number)) return actions @@ -147,7 +163,8 @@ def heard_before_explainer(self): Step(_("Did you hear the same song during previous rounds?")), ], step_numbers=True, - button_label=_("Continue")) + button_label=_("Continue"), + ) def final_score_message(self, session: Session): """Create final score message for given session""" @@ -159,15 +176,15 @@ def final_score_message(self, session: Session): n_old_new_correct = 0 for result in session.result_set.all(): - if result.question_key == 'recognize': - if result.given_response == 'yes': + if result.question_key == "recognize": + if result.given_response == "yes": n_sync_guessed += 1 json_data = result.load_json_data() - sync_time += json_data.get('decision_time') + sync_time += json_data.get("decision_time") if result.score > 0: n_sync_correct += 1 else: - if result.expected_response == 'old': + if result.expected_response == "old": n_old_new_expected += 1 if result.score > 0: n_old_new_correct += 1 @@ -177,18 +194,18 @@ def final_score_message(self, session: Session): song_sync_message = "You did not recognise any songs at first." else: song_sync_message = "It took you {} s to recognise a song on average, and you correctly identified {} out of the {} songs you thought you knew.".format( - round(sync_time / n_sync_guessed, 1), n_sync_correct, n_sync_guessed) + round(sync_time / n_sync_guessed, 1), n_sync_correct, n_sync_guessed + ) heard_before_message = "During the bonus rounds, you remembered {} of the {} songs that came back.".format( - n_old_new_correct, n_old_new_expected) + n_old_new_correct, n_old_new_expected + ) return score_message + " " + song_sync_message + " " + heard_before_message def get_trial_title(self, session: Session, round_number): - return _("Round %(number)d / %(total)d") %\ - {'number': round_number, 'total': session.block.rounds} + return _("Round %(number)d / %(total)d") % {"number": round_number, "total": session.block.rounds} def plan_sections(self, session: Session): - """Set the plan of tracks for a session. - """ + """Set the plan of tracks for a session.""" # Get available songs and pick a section for each n_rounds = session.block.rounds @@ -196,55 +213,61 @@ def plan_sections(self, session: Session): # 2/3 of the rounds are SongSync, of which 1/4 songs will return, 3/4 songs are "free", i.e., won't return # 1/3 of the rounds are "heard before", of which 1/2 old songs # e.g. 30 rounds -> 20 SongSync with 5 songs to be repeated later - n_song_sync_rounds = round(2/3 * n_rounds) - n_returning_rounds = round(1/4 * n_song_sync_rounds) - song_sync_condtions = ['returning'] * n_returning_rounds + ['free'] * (n_song_sync_rounds - n_returning_rounds) + n_song_sync_rounds = round(2 / 3 * n_rounds) + n_returning_rounds = round(1 / 4 * n_song_sync_rounds) + song_sync_condtions = ["returning"] * n_returning_rounds + ["free"] * (n_song_sync_rounds - n_returning_rounds) random.shuffle(song_sync_condtions) n_heard_before_rounds = n_rounds - n_song_sync_rounds n_heard_before_rounds_old = round(0.5 * n_heard_before_rounds) n_heard_before_rounds_new = n_heard_before_rounds - n_heard_before_rounds_old - heard_before_conditions = ['old'] * n_heard_before_rounds_old + ['new'] * n_heard_before_rounds_new + heard_before_conditions = ["old"] * n_heard_before_rounds_old + ["new"] * n_heard_before_rounds_new random.shuffle(heard_before_conditions) plan = song_sync_condtions + heard_before_conditions # Save, overwriting existing plan if one exists. - session.save_json_data({'plan': plan, 'heard_before_offset': n_song_sync_rounds}) + session.save_json_data({"plan": plan, "heard_before_offset": n_song_sync_rounds}) def select_song_sync_section(self, session: Session, condition, filter_by={}) -> Section: - ''' Return a section for the song_sync round + """Return a section for the song_sync round parameters: - session - condition: can be "new" or "returning" - filter_by: may be used to filter sections - ''' + """ return session.playlist.get_section(filter_by, song_ids=session.get_unused_song_ids()) def next_song_sync_action(self, session: Session, round_number: int) -> Trial: """Get next song_sync section for this session.""" try: - plan = session.load_json_data()['plan'] + plan = session.load_json_data()["plan"] except KeyError as error: - logger.error('Missing plan key: %s' % str(error)) + logger.error("Missing plan key: %s" % str(error)) return None condition = plan[round_number] section = self.select_song_sync_section(session, condition) - if condition == 'returning': - played_sections = session.load_json_data().get('played_sections', []) + if condition == "returning": + played_sections = session.load_json_data().get("played_sections", []) played_sections.append(section.id) - session.save_json_data({'played_sections': played_sections}) - return song_sync(session, section, title=self.get_trial_title(session, round_number + 1), - recognition_time=self.recognition_time, sync_time=self.sync_time, - min_jitter=self.min_jitter, max_jitter=self.max_jitter) + session.save_json_data({"played_sections": played_sections}) + return song_sync( + session, + section, + title=self.get_trial_title(session, round_number + 1), + recognition_time=self.recognition_time, + sync_time=self.sync_time, + min_jitter=self.min_jitter, + max_jitter=self.max_jitter, + ) - def select_heard_before_section(self, session: Session, condition: str, filter_by = {}) -> Section: - """ select a section for the `heard_before` rounds + def select_heard_before_section(self, session: Session, condition: str, filter_by={}) -> Section: + """select a section for the `heard_before` rounds parameters: - session - condition: 'old' or 'new' - filter_by: dictionary to restrict the types of sections returned, e.g., to play a section with a different tag """ - if condition == 'old': + if condition == "old": current_section_id = self.get_returning_section_id(session) return Section.objects.get(pk=current_section_id) else: @@ -252,49 +275,51 @@ def select_heard_before_section(self, session: Session, condition: str, filter_b return session.playlist.get_section(filter_by, song_ids=song_ids) def get_returning_section_id(self, session: Session) -> int: - ''' read the list of `played_sections`, select and return a random item, + """read the list of `played_sections`, select and return a random item, save `played_sections` without this item - ''' - played_sections = session.load_json_data().get('played_sections') + """ + played_sections = session.load_json_data().get("played_sections") random.shuffle(played_sections) current_section_id = played_sections.pop() - session.save_json_data({'played_sections': played_sections}) + session.save_json_data({"played_sections": played_sections}) return current_section_id def next_heard_before_action(self, session: Session, round_number: int) -> Trial: """Get next heard_before action for this session.""" # Load plan. try: - plan = session.load_json_data()['plan'] + plan = session.load_json_data()["plan"] except KeyError as error: - logger.error('Missing plan key: %s' % str(error)) + logger.error("Missing plan key: %s" % str(error)) return None # Get section. condition = plan[round_number] section = self.select_heard_before_section(session, condition) - playback = Autoplay( - [section], - show_animation=True, - preload_message=_('Get ready!') - ) + playback = Autoplay([section], show_animation=True, preload_message=_("Get ready!")) # create Result object and save expected result to database - key = 'heard_before' - form = Form([BooleanQuestion( - key=key, - choices={ - 'new': _("No"), - 'old': _("Yes"), - }, - question=_("Did you hear this song in previous rounds?"), - result_id=prepare_result( - key, session, section=section, expected_response=condition, scoring_rule='REACTION_TIME',), - submits=True, - style={STYLE_BOOLEAN_NEGATIVE_FIRST: True, 'buttons-large-gap': True}) - ]) - config = { - 'auto_advance': True, - 'response_time': self.heard_before_time - } + key = "heard_before" + form = Form( + [ + BooleanQuestion( + key=key, + choices={ + "new": _("No"), + "old": _("Yes"), + }, + question=_("Did you hear this song in previous rounds?"), + result_id=prepare_result( + key, + session, + section=section, + expected_response=condition, + scoring_rule="REACTION_TIME", + ), + submits=True, + style={STYLE_BOOLEAN_NEGATIVE_FIRST: True, "buttons-large-gap": True}, + ) + ] + ) + config = {"auto_advance": True, "response_time": self.heard_before_time} trial = Trial( title=self.get_trial_title(session, round_number + 1), playback=playback, @@ -304,11 +329,7 @@ def next_heard_before_action(self, session: Session, round_number: int) -> Trial return trial def get_score(self, session, round_number): - config = {'show_section': True, 'show_total_score': True} + config = {"show_section": True, "show_total_score": True} title = self.get_trial_title(session, round_number) previous_score = session.get_previous_result(self.counted_result_keys).score - return Score(session, - config=config, - title=title, - score=previous_score - ) + return Score(session, config=config, title=title, score=previous_score) diff --git a/backend/experiment/rules/musical_preferences.py b/backend/experiment/rules/musical_preferences.py index afba3f8a8..4bc6fb167 100644 --- a/backend/experiment/rules/musical_preferences.py +++ b/backend/experiment/rules/musical_preferences.py @@ -19,42 +19,35 @@ class MusicalPreferences(Base): - ID = 'MUSICAL_PREFERENCES' - default_consent_file = 'consent/consent_musical_preferences.html' + ID = "MUSICAL_PREFERENCES" + default_consent_file = "consent/consent_musical_preferences.html" preference_offset = 20 knowledge_offset = 42 - contact_email = 'musicexp_china@163.com' - counted_result_keys = ['like_song'] - - know_score = { - 'yes': 2, - 'unsure': 1, - 'no': 0 - } + contact_email = "musicexp_china@163.com" + counted_result_keys = ["like_song"] + know_score = {"yes": 2, "unsure": 1, "no": 0} def __init__(self): self.question_series = [ { "name": "Question series Musical Preferences", "keys": [ - 'msi_38_listen_music', - 'dgf_genre_preference_zh', - 'dgf_gender_identity_zh', - 'dgf_age', - 'dgf_region_of_origin', - 'dgf_region_of_residence', + "msi_38_listen_music", + "dgf_genre_preference_zh", + "dgf_gender_identity_zh", + "dgf_age", + "dgf_region_of_origin", + "dgf_region_of_residence", ], - "randomize": False + "randomize": False, }, ] def get_intro_explainer(self): return Explainer( - instruction=_('Welcome to the Musical Preferences experiment!'), - steps=[ - Step(_('Please start by checking your connection quality.')) - ], - button_label=_('OK') + instruction=_("Welcome to the Musical Preferences experiment!"), + steps=[Step(_("Please start by checking your connection quality."))], + button_label=_("OK"), ) def next_round(self, session: Session): @@ -70,13 +63,16 @@ def next_round(self, session: Session): explainer = Explainer( instruction=_("Questionnaire"), steps=[ - Step(_( - "To understand your musical preferences, we have {} questions for you before the experiment \ + Step( + _( + "To understand your musical preferences, we have {} questions for you before the experiment \ begins. The first two questions are about your music listening experience, while the other \ - four questions are demographic questions. It will take 2-3 minutes.").format(n_questions)), - Step(_("Have fun!")) + four questions are demographic questions. It will take 2-3 minutes." + ).format(n_questions) + ), + Step(_("Have fun!")), ], - button_label=_("Let's go!") + button_label=_("Let's go!"), ) return [explainer, *question_trials] else: @@ -84,34 +80,37 @@ def next_round(self, session: Session): explainer = Explainer( instruction=_("How to play"), steps=[ - Step( - _("You will hear 64 music clips and have to answer two questions for each clip.")), - Step( - _("It will take 20-30 minutes to complete the whole experiment.")), - Step( - _("Either wear headphones or use your device's speakers.")), - Step( - _("Your final results will be displayed at the end.")), - Step(_("Have fun!")) + Step(_("You will hear 64 music clips and have to answer two questions for each clip.")), + Step(_("It will take 20-30 minutes to complete the whole experiment.")), + Step(_("Either wear headphones or use your device's speakers.")), + Step(_("Your final results will be displayed at the end.")), + Step(_("Have fun!")), ], - button_label=_("Start") + button_label=_("Start"), ) actions = [playlist, explainer] else: - if last_result.question_key == 'audio_check1': + if last_result.question_key == "audio_check1": playback = get_test_playback() - html = HTML(body=render_to_string('html/huang_2022/audio_check.html')) - form = Form(form=[BooleanQuestion( - key='audio_check2', - choices={'no': _('Quit'), 'yes': _('Next')}, - result_id=prepare_result( - 'audio_check2', session, scoring_rule='BOOLEAN'), - submits=True, - style=STYLE_BOOLEAN_NEGATIVE_FIRST - )]) - return Trial(playback=playback, html=html, feedback_form=form, - config={'response_time': 15}, - title=_("Tech check")) + html = HTML(body=render_to_string("html/huang_2022/audio_check.html")) + form = Form( + form=[ + BooleanQuestion( + key="audio_check2", + choices={"no": _("Quit"), "yes": _("Next")}, + result_id=prepare_result("audio_check2", session, scoring_rule="BOOLEAN"), + submits=True, + style=STYLE_BOOLEAN_NEGATIVE_FIRST, + ) + ] + ) + return Trial( + playback=playback, + html=html, + feedback_form=form, + config={"response_time": 15}, + title=_("Tech check"), + ) else: # participant had persistent audio problems, finish session and redirect session.finish() @@ -119,162 +118,168 @@ def next_round(self, session: Session): return Redirect(settings.HOMEPAGE) else: playback = get_test_playback() - html = HTML( - body='

{}

'.format(_('Do you hear the music?'))) - form = Form(form=[BooleanQuestion( - key='audio_check1', - choices={'no': _('No'), 'yes': _('Yes')}, - result_id=prepare_result('audio_check1', session, - scoring_rule='BOOLEAN'), - submits=True, - style=STYLE_BOOLEAN_NEGATIVE_FIRST)]) - return [self.get_intro_explainer(), Trial(playback=playback, feedback_form=form, html=html, - config={'response_time': 15}, - title=_("Audio check"))] + html = HTML(body="

{}

".format(_("Do you hear the music?"))) + form = Form( + form=[ + BooleanQuestion( + key="audio_check1", + choices={"no": _("No"), "yes": _("Yes")}, + result_id=prepare_result("audio_check1", session, scoring_rule="BOOLEAN"), + submits=True, + style=STYLE_BOOLEAN_NEGATIVE_FIRST, + ) + ] + ) + return [ + self.get_intro_explainer(), + Trial( + playback=playback, + feedback_form=form, + html=html, + config={"response_time": 15}, + title=_("Audio check"), + ), + ] if round_number == self.preference_offset + 1: - like_results = session.result_set.filter(question_key='like_song') + like_results = session.result_set.filter(question_key="like_song") feedback = Trial( - html=HTML(body=render_to_string('html/musical_preferences/feedback.html', { - 'unlocked': _("Love unlocked"), - 'n_songs': round_number, - 'top_participant': self.get_preferred_songs(like_results, 3) - })) + html=HTML( + body=render_to_string( + "html/musical_preferences/feedback.html", + { + "unlocked": _("Love unlocked"), + "n_songs": round_number, + "top_participant": self.get_preferred_songs(like_results, 3), + }, + ) + ) ) actions = [feedback] elif round_number == self.knowledge_offset: - like_results = session.result_set.filter(question_key='like_song') - known_songs = session.result_set.filter( - question_key='know_song', score=2).count() + like_results = session.result_set.filter(question_key="like_song") + known_songs = session.result_set.filter(question_key="know_song", score=2).count() feedback = Trial( - html=HTML(body=render_to_string('html/musical_preferences/feedback.html', { - 'unlocked': _("Knowledge unlocked"), - 'n_songs': round_number - 1, - 'top_participant': self.get_preferred_songs(like_results, 3), - 'n_known_songs': known_songs - })) + html=HTML( + body=render_to_string( + "html/musical_preferences/feedback.html", + { + "unlocked": _("Knowledge unlocked"), + "n_songs": round_number - 1, + "top_participant": self.get_preferred_songs(like_results, 3), + "n_known_songs": known_songs, + }, + ) + ) ) actions = [feedback] elif round_number == session.block.rounds: - like_results = session.result_set.filter(question_key='like_song') - known_songs = session.result_set.filter( - question_key='know_song', score=2).count() - all_results = Result.objects.filter( - question_key='like_song', - section_id__isnull=False - ) + like_results = session.result_set.filter(question_key="like_song") + known_songs = session.result_set.filter(question_key="know_song", score=2).count() + all_results = Result.objects.filter(question_key="like_song", section_id__isnull=False) top_participant = self.get_preferred_songs(like_results, 3) top_all = self.get_preferred_songs(all_results, 3) feedback = Trial( - html=HTML(body=render_to_string('html/musical_preferences/feedback.html', { - 'unlocked': _("Connection unlocked"), - 'n_songs': round_number, - 'top_participant': top_participant, - 'n_known_songs': known_songs, - 'top_all': top_all - })) + html=HTML( + body=render_to_string( + "html/musical_preferences/feedback.html", + { + "unlocked": _("Connection unlocked"), + "n_songs": round_number, + "top_participant": top_participant, + "n_known_songs": known_songs, + "top_all": top_all, + }, + ) + ) ) session.finish() session.save() - return [feedback, self.get_final_view( - session, - top_participant, - known_songs, - round_number, - top_all - )] + return [feedback, self.get_final_view(session, top_participant, known_songs, round_number, top_all)] section = session.playlist.get_section(song_ids=session.get_unused_song_ids()) - like_key = 'like_song' + like_key = "like_song" likert = LikertQuestionIcon( - question=_('2. How much do you like this song?'), + question=_("2. How much do you like this song?"), key=like_key, - result_id=prepare_result( - like_key, session, section=section, scoring_rule='LIKERT') + result_id=prepare_result(like_key, session, section=section, scoring_rule="LIKERT"), ) - know_key = 'know_song' + know_key = "know_song" know = ChoiceQuestion( - question=_('1. Do you know this song?'), + question=_("1. Do you know this song?"), key=know_key, - view='BUTTON_ARRAY', - choices={ - 'yes': 'fa-check', - 'unsure': 'fa-question', - 'no': 'fa-xmark' - }, + view="BUTTON_ARRAY", + choices={"yes": "fa-check", "unsure": "fa-question", "no": "fa-xmark"}, result_id=prepare_result(know_key, session, section=section), - style=STYLE_BOOLEAN + style=STYLE_BOOLEAN, ) playback = Autoplay([section], show_animation=True) form = Form([know, likert]) view = Trial( playback=playback, feedback_form=form, - title=_('Song %(round)s/%(total)s') % { - 'round': round_number, 'total': session.block.rounds}, + title=_("Song %(round)s/%(total)s") % {"round": round_number, "total": session.block.rounds}, config={ - 'response_time': section.duration + .1, - } + "response_time": section.duration + 0.1, + }, ) actions.append(view) return actions def calculate_score(self, result, data): - if data.get('key') == 'know_song': - return self.know_score.get(data.get('value')) + if data.get("key") == "know_song": + return self.know_score.get(data.get("value")) else: return super().calculate_score(result, data) def social_media_info(self, block, top_participant, known_songs, n_songs, top_all): - ''' ⚠️ Deprecated. The social media info will eventually be present on the Experiment level, not the Block level. ''' - current_url = "{}/{}".format(settings.RELOAD_PARTICIPANT_TARGET, - block.slug - ) + """⚠️ Deprecated. The social media info will eventually be present on the Experiment level, not the Block level.""" + current_url = "{}/{}".format(settings.RELOAD_PARTICIPANT_TARGET, block.slug) + + experiment = block.phase.experiment + social_media_config = experiment.social_media_config + tags = social_media_config.tags if social_media_config.tags else [] + url = social_media_config.url or current_url + + def format_songs(songs): + return ", ".join([song["name"] for song in songs]) - def format_songs(songs): return ', '.join( - [song['name'] for song in songs]) return { - 'apps': ['weibo', 'share'], - 'message': _("I explored musical preferences on %(url)s! My top 3 favorite songs: %(top_participant)s. I know %(known_songs)i out of %(n_songs)i songs. All players' top 3 songs: %(top_all)s") % { - 'url': current_url, - 'top_participant': format_songs(top_participant), - 'known_songs': known_songs, - 'n_songs': n_songs, - 'top_all': format_songs(top_all) + "apps": ["weibo", "share"], + "message": _( + "I explored musical preferences on %(url)s! My top 3 favorite songs: %(top_participant)s. I know %(known_songs)i out of %(n_songs)i songs. All players' top 3 songs: %(top_all)s" + ) + % { + "url": current_url, + "top_participant": format_songs(top_participant), + "known_songs": known_songs, + "n_songs": n_songs, + "top_all": format_songs(top_all), }, - 'url': block.url or current_url, - 'hashtags': [block.hashtag or block.slug, "amsterdammusiclab", "citizenscience"] + "url": url, + "hashtags": [*tags, "amsterdammusiclab", "citizenscience"], } def get_final_view(self, session, top_participant, known_songs, n_songs, top_all): # finalize block - social_info = self.social_media_info( - session.block, - top_participant, - known_songs, - n_songs, - top_all - ) - social_info['apps'] = ['weibo', 'share'] + social_info = self.social_media_info(session.block, top_participant, known_songs, n_songs, top_all) + social_info["apps"] = ["weibo", "share"] 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 + social=social_info, ) return view def feedback_info(self): info = super().feedback_info() - info['header'] = _("Any remarks or questions (optional):") + info["header"] = _("Any remarks or questions (optional):") return info def get_preferred_songs(self, result_set, n=5): - top_results = result_set.annotate( - avg_score=Avg('score')).order_by('score')[:n] + top_results = result_set.annotate(avg_score=Avg("score")).order_by("score")[:n] out_list = [] for result in top_results.all(): section = Section.objects.get(pk=result.section.id) - out_list.append({'artist': section.song.artist, - 'name': section.song.name}) + out_list.append({"artist": section.song.artist, "name": section.song.name}) return out_list diff --git a/backend/experiment/rules/tests/test_base.py b/backend/experiment/rules/tests/test_base.py index 0bf1eb559..ad0528691 100644 --- a/backend/experiment/rules/tests/test_base.py +++ b/backend/experiment/rules/tests/test_base.py @@ -1,6 +1,6 @@ from django.test import TestCase from django.conf import settings -from experiment.models import Block +from experiment.models import Experiment, Phase, Block, SocialMediaConfig from session.models import Session from participant.models import Participant from section.models import Playlist @@ -8,13 +8,24 @@ class BaseTest(TestCase): - def test_social_media_info(self): reload_participant_target = settings.RELOAD_PARTICIPANT_TARGET - slug = 'music-lab' + slug = "music-lab" + experiment = Experiment.objects.create( + slug=slug, + ) + 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( - name='Music Lab', + name="Music Lab", slug=slug, + phase=phase, ) base = Base() social_media_info = base.social_media_info( @@ -24,17 +35,19 @@ def test_social_media_info(self): expected_url = f"{reload_participant_target}/{slug}" - self.assertEqual(social_media_info['apps'], ['facebook', 'twitter']) - self.assertEqual(social_media_info['message'], 'I scored 100 points on https://app.amsterdammusiclab.nl/music-lab') - self.assertEqual(social_media_info['url'], expected_url) + self.assertEqual(social_media_info["apps"], ["facebook", "twitter"]) + self.assertEqual( + social_media_info["message"], "I scored 100 points on https://app.amsterdammusiclab.nl/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['hashtags'], ['music-lab', 'amsterdammusiclab', 'citizenscience']) + self.assertNotIn(social_media_info["url"], "//") + self.assertEqual(social_media_info["hashtags"], ["music-lab", "amsterdammusiclab", "citizenscience"]) def test_get_play_again_url(self): block = Block.objects.create( - name='Music Lab', - slug='music-lab', + name="Music Lab", + slug="music-lab", ) session = Session.objects.create( block=block, @@ -42,15 +55,15 @@ def test_get_play_again_url(self): ) base = Base() play_again_url = base.get_play_again_url(session) - self.assertEqual(play_again_url, '/music-lab') + self.assertEqual(play_again_url, "/music-lab") def test_get_play_again_url_with_participant_id(self): block = Block.objects.create( - name='Music Lab', - slug='music-lab', + name="Music Lab", + slug="music-lab", ) participant = Participant.objects.create( - participant_id_url='42', + participant_id_url="42", ) session = Session.objects.create( block=block, @@ -58,14 +71,14 @@ def test_get_play_again_url_with_participant_id(self): ) base = Base() play_again_url = base.get_play_again_url(session) - self.assertEqual(play_again_url, '/music-lab?participant_id=42') + self.assertEqual(play_again_url, "/music-lab?participant_id=42") def test_validate_playlist(self): base = Base() playlist = None errors = base.validate_playlist(playlist) - self.assertEqual(errors, ['The block must have a playlist.']) + self.assertEqual(errors, ["The block must have a playlist."]) playlist = Playlist.objects.create() errors = base.validate_playlist(playlist) - self.assertEqual(errors, ['The block must have at least one section.']) + self.assertEqual(errors, ["The block must have at least one section."]) diff --git a/backend/experiment/rules/tests/test_hooked.py b/backend/experiment/rules/tests/test_hooked.py index 359c1b21a..39e32f791 100644 --- a/backend/experiment/rules/tests/test_hooked.py +++ b/backend/experiment/rules/tests/test_hooked.py @@ -3,7 +3,7 @@ from unittest.mock import Mock from experiment.actions import Explainer, Final, Score, Trial -from experiment.models import Block +from experiment.models import Experiment, Phase, Block, SocialMediaConfig from question.musicgens import MUSICGENS_17_W_VARIANTS from participant.models import Participant from question.questions import get_questions_from_series @@ -13,13 +13,13 @@ class HookedTest(TestCase): - fixtures = ['playlist', 'experiment'] + fixtures = ["playlist", "experiment"] @classmethod def setUpTestData(cls): - ''' set up data for Hooked base class ''' + """set up data for Hooked base class""" cls.participant = Participant.objects.create() - cls.playlist = Playlist.objects.create(name='Test Eurovision') + cls.playlist = Playlist.objects.create(name="Test Eurovision") cls.playlist.csv = ( "Albania 2018 - Eugent Bushpepa,Mall,7.046,45.0,euro2/Karaoke/2018-11-00-07-046-k.mp3,3,201811007\n" "Albania 2018 - Eugent Bushpepa,Mall,7.046,45.0,euro2/V1/2018-11-00-07-046-v1.mp3,1,201811007\n" @@ -72,36 +72,35 @@ def score_results(self, actions): def test_hooked(self): n_rounds = 18 - block = Block.objects.create(name='Hooked', rules='HOOKED', rounds=n_rounds) + experiment = Experiment.objects.create(slug="HOOKED") + SocialMediaConfig.objects.create(experiment=experiment, url="https://app.amsterdammusiclab.nl/hooked") + phase = Phase.objects.create(experiment=experiment) + block = Block.objects.create(name="Hooked", rules="HOOKED", rounds=n_rounds, phase=phase) block.add_default_question_series() - session = Session.objects.create( - block=block, - participant=self.participant, - playlist=self.playlist - ) + session = Session.objects.create(block=block, participant=self.participant, playlist=self.playlist) rules = session.block_rules() for i in range(n_rounds + 1): actions = rules.next_round(session) self.assertNotEqual(actions, None) self.score_results(actions) - heard_before_offset = session.load_json_data().get('heard_before_offset') + heard_before_offset = session.load_json_data().get("heard_before_offset") self.assertEqual(heard_before_offset, 12) if i == 0: - plan = session.load_json_data().get('plan') + plan = session.load_json_data().get("plan") self.assertIsNotNone(plan) self.assertEqual(len(plan), n_rounds) - self.assertEqual(len([p for p in plan if p == 'free']), 9) - self.assertEqual(len([p for p in plan if p == 'returning']), 3) - self.assertEqual(len([p for p in plan if p == 'new']), 3) - self.assertEqual(len([p for p in plan if p == 'old']), 3) + self.assertEqual(len([p for p in plan if p == "free"]), 9) + self.assertEqual(len([p for p in plan if p == "returning"]), 3) + self.assertEqual(len([p for p in plan if p == "new"]), 3) + self.assertEqual(len([p for p in plan if p == "old"]), 3) self.assertEqual(len(actions), 5) - self.assertEqual(session.result_set.filter(question_key='recognize').count(), 1) - self.assertEqual(session.result_set.filter(question_key='correct_place').count(), 1) + self.assertEqual(session.result_set.filter(question_key="recognize").count(), 1) + self.assertEqual(session.result_set.filter(question_key="correct_place").count(), 1) elif i == 1: self.assertEqual(len(actions), 4) self.assertEqual(type(actions[0]), Score) - self.assertEqual(session.result_set.filter(question_key='recognize').count(), 2) - self.assertEqual(session.result_set.filter(question_key='correct_place').count(), 2) + self.assertEqual(session.result_set.filter(question_key="recognize").count(), 2) + self.assertEqual(session.result_set.filter(question_key="correct_place").count(), 2) elif i == rules.question_offset: self.assertEqual(len(actions), 5) self.assertEqual(self.participant.result_set.count(), 1) @@ -113,29 +112,25 @@ def test_hooked(self): # we have a score, heard_before trial, and a question trial self.assertEqual(len(actions), 3) # at least one heard_before result should have been created - self.assertGreater(session.result_set.filter(question_key='heard_before').count(), 0) + self.assertGreater(session.result_set.filter(question_key="heard_before").count(), 0) elif i == n_rounds: # final round self.assertEqual(type(actions[0]), Score) self.assertEqual(type(actions[1]), Final) def test_eurovision_same(self): - self._run_eurovision('same') + self._run_eurovision("same") def test_eurovision_different(self): - self._run_eurovision('different') + self._run_eurovision("different") def test_eurovision_karaoke(self): - self._run_eurovision('karaoke') + self._run_eurovision("karaoke") def _run_eurovision(self, session_type): n_rounds = 6 - block = Block.objects.create(name='Test-Eurovision', rules='EUROVISION_2020', rounds=n_rounds) - session = Session.objects.create( - block=block, - participant=self.participant, - playlist=self.playlist - ) + block = Block.objects.create(name="Test-Eurovision", rules="EUROVISION_2020", rounds=n_rounds) + session = Session.objects.create(block=block, participant=self.participant, playlist=self.playlist) rules = session.block_rules() rules.question_offset = 3 mock_session_type = Mock(return_value=session_type) @@ -143,41 +138,43 @@ def _run_eurovision(self, session_type): for i in range(block.rounds): actions = rules.next_round(session) self.score_results(actions) - heard_before_offset = session.load_json_data().get('heard_before_offset') - plan = session.load_json_data().get('plan') + heard_before_offset = session.load_json_data().get("heard_before_offset") + plan = session.load_json_data().get("plan") self.assertIsNotNone(actions) if i == heard_before_offset - 1: - played_sections = session.load_json_data().get('played_sections') + played_sections = session.load_json_data().get("played_sections") self.assertIsNotNone(played_sections) elif i >= heard_before_offset: - plan = session.load_json_data().get('plan') - song_sync_sections = list(session.result_set.filter(question_key='recognize').values_list('section', flat=True)) - heard_before_section = session.result_set.filter(question_key='heard_before').last().section + plan = session.load_json_data().get("plan") + song_sync_sections = list( + session.result_set.filter(question_key="recognize").values_list("section", flat=True) + ) + heard_before_section = session.result_set.filter(question_key="heard_before").last().section song_sync_songs = [Section.objects.get(pk=section).song for section in song_sync_sections] - if plan[i] == 'old': - if session_type == 'same': + if plan[i] == "old": + if session_type == "same": self.assertIn(heard_before_section.id, song_sync_sections) - elif session_type == 'different': + elif session_type == "different": self.assertIn(heard_before_section.song, song_sync_songs) self.assertNotIn(heard_before_section, song_sync_sections) - self.assertNotEqual(heard_before_section.tag, '3') - elif session_type == 'karaoke': + self.assertNotEqual(heard_before_section.tag, "3") + elif session_type == "karaoke": self.assertIn(heard_before_section.song, song_sync_songs) self.assertNotIn(heard_before_section, song_sync_sections) - self.assertEqual(heard_before_section.tag, '3') + self.assertEqual(heard_before_section.tag, "3") def test_kuiper_same(self): - self._run_kuiper('same') + self._run_kuiper("same") def test_kuiper_different(self): - self._run_kuiper('different') + self._run_kuiper("different") def _run_kuiper(self, session_type): self.assertEqual(Result.objects.count(), 0) n_rounds = 6 - block = Block.objects.create(name='Test-Christmas', rules='KUIPER_2020', rounds=n_rounds) - playlist = Playlist.objects.create(name='Test-Christmas') + block = Block.objects.create(name="Test-Christmas", rules="KUIPER_2020", rounds=n_rounds) + playlist = Playlist.objects.create(name="Test-Christmas") playlist.csv = ( "Band Aid,1984 - Do They Know It’s Christmas,1.017,45.0,Kerstmuziek/Do They Know It_s Christmas00.01.017.i.s.mp3,0,100000707\n" "Band Aid,1984 - Do They Know It’s Christmas,8.393,45.0,Kerstmuziek/Do They Know It_s Christmas00.08.393.v1.s.mp3,0,100000713\n" @@ -211,11 +208,7 @@ def _run_kuiper(self, session_type): "Chuck Berry,1959 - Run Rudolph Run,113.301,45.0,Kerstmuziek/Run Rudolph Run01.53.301.v2.s.mp3,0,100002714\n" ) playlist.update_sections() - session = Session.objects.create( - block=block, - participant=self.participant, - playlist=playlist - ) + session = Session.objects.create(block=block, participant=self.participant, playlist=playlist) rules = session.block_rules() rules.question_offset = 3 mock_session_type = Mock(return_value=session_type) @@ -223,23 +216,29 @@ def _run_kuiper(self, session_type): for i in range(n_rounds): actions = rules.next_round(session) self.score_results(actions) - heard_before_offset = session.load_json_data().get('heard_before_offset') + heard_before_offset = session.load_json_data().get("heard_before_offset") if i == heard_before_offset - 1: - played_sections = session.load_json_data().get('played_sections') - song_sync_sections = list(session.result_set.filter(question_key='recognize').values_list('section', flat=True)) + played_sections = session.load_json_data().get("played_sections") + song_sync_sections = list( + session.result_set.filter(question_key="recognize").values_list("section", flat=True) + ) self.assertEqual(len(song_sync_sections), 4) self.assertEqual(len(played_sections), 1) self.assertIn(played_sections[0], song_sync_sections) elif i in range(heard_before_offset, n_rounds): - plan = session.load_json_data().get('plan') - song_sync_sections = list(session.result_set.filter(question_key='recognize').values_list('section', flat=True)) - heard_before_section = session.result_set.filter(question_key='heard_before').last().section - if plan[i] == 'old': - if session_type == 'same': + plan = session.load_json_data().get("plan") + song_sync_sections = list( + session.result_set.filter(question_key="recognize").values_list("section", flat=True) + ) + heard_before_section = session.result_set.filter(question_key="heard_before").last().section + if plan[i] == "old": + if session_type == "same": self.assertIn(heard_before_section.id, song_sync_sections) - if session_type == 'different': + if session_type == "different": song_sync_songs = [Section.objects.get(pk=section).song for section in song_sync_sections] - repeated_song = next((song for song in song_sync_songs if song == heard_before_section.song), None) + repeated_song = next( + (song for song in song_sync_songs if song == heard_before_section.song), None + ) self.assertIsNotNone(repeated_song) self.assertNotIn(heard_before_section, song_sync_sections) else: @@ -247,98 +246,79 @@ def _run_kuiper(self, session_type): def test_thats_my_song(self): musicgen_keys = [q.key for q in MUSICGENS_17_W_VARIANTS] - block = Block.objects.get(name='ThatsMySong') + block = Block.objects.get(name="ThatsMySong") block.add_default_question_series() - playlist = Playlist.objects.get(name='ThatsMySong') + playlist = Playlist.objects.get(name="ThatsMySong") playlist.update_sections() - session = Session.objects.create( - block=block, - participant=self.participant, - playlist=playlist - ) + session = Session.objects.create(block=block, participant=self.participant, playlist=playlist) rules = session.block_rules() assert rules.feedback_info() is None # need to add 1 to the index, as there is double round counted as 0 in the rules files for i in range(0, block.rounds + 1): actions = rules.next_round(session) - heard_before_offset = session.load_json_data().get('heard_before_offset') + heard_before_offset = session.load_json_data().get("heard_before_offset") if i == block.rounds + 1: assert len(actions) == 2 - assert actions[1].ID == 'FINAL' + assert actions[1].ID == "FINAL" elif i == 0: self.assertEqual(len(actions), 4) - self.assertEqual(actions[1].feedback_form.form[0].key, 'dgf_generation') - self.assertEqual(actions[2].feedback_form.form[0].key, 'dgf_gender_identity') - self.assertEqual(actions[3].feedback_form.form[0].key, 'playlist_decades') - result = Result.objects.get( - session=session, - question_key='playlist_decades' - ) - result.given_response = '1960s,1970s,1980s' + self.assertEqual(actions[1].feedback_form.form[0].key, "dgf_generation") + self.assertEqual(actions[2].feedback_form.form[0].key, "dgf_gender_identity") + self.assertEqual(actions[3].feedback_form.form[0].key, "playlist_decades") + result = Result.objects.get(session=session, question_key="playlist_decades") + result.given_response = "1960s,1970s,1980s" result.save() - generation = Result.objects.get( - participant=self.participant, - question_key='dgf_generation' - ) - generation.given_response = 'something' + generation = Result.objects.get(participant=self.participant, question_key="dgf_generation") + generation.given_response = "something" generation.save() - gender = Result.objects.get( - participant=self.participant, - question_key='dgf_gender_identity' - ) - gender.given_response = 'and another thing' + gender = Result.objects.get(participant=self.participant, question_key="dgf_gender_identity") + gender.given_response = "and another thing" gender.save() elif i == 1: assert session.result_set.count() == 3 - assert session.load_json_data().get('plan') is not None + assert session.load_json_data().get("plan") is not None assert len(actions) == 3 - assert actions[0].feedback_form.form[0].key == 'recognize' - assert actions[2].feedback_form.form[0].key == 'correct_place' + assert actions[0].feedback_form.form[0].key == "recognize" + assert actions[2].feedback_form.form[0].key == "correct_place" else: - assert actions[0].ID == 'SCORE' + assert actions[0].ID == "SCORE" if i < rules.question_offset + 1: assert len(actions) == 4 - assert actions[1].feedback_form.form[0].key == 'recognize' + assert actions[1].feedback_form.form[0].key == "recognize" elif i < heard_before_offset + 1: assert len(actions) == 5 assert actions[1].feedback_form.form[0].key in musicgen_keys elif i == heard_before_offset + 1: assert len(actions) == 3 - assert actions[1].ID == 'EXPLAINER' - assert actions[2].feedback_form.form[0].key == 'heard_before' + assert actions[1].ID == "EXPLAINER" + assert actions[2].feedback_form.form[0].key == "heard_before" else: assert len(actions) == 3 assert actions[1].feedback_form.form[0].key in musicgen_keys - assert actions[2].feedback_form.form[0].key == 'heard_before' + assert actions[2].feedback_form.form[0].key == "heard_before" def test_hooked_china(self): - block = Block.objects.get(name='Hooked-China') + block = Block.objects.get(name="Hooked-China") block.add_default_question_series() - playlist = Playlist.objects.get(name='普通话') + playlist = Playlist.objects.get(name="普通话") playlist.update_sections() - session = Session.objects.create( - block=block, - participant=self.participant, - playlist=playlist - ) + session = Session.objects.create(block=block, participant=self.participant, playlist=playlist) rules = session.block_rules() self.assertIsNotNone(rules.feedback_info()) # check that first round is an audio check - song = Song.objects.create(name='audiocheck') - Section.objects.create(playlist=playlist, song=song, filename=SimpleUploadedFile( - 'some_audio.wav', b'' - )) + song = Song.objects.create(name="audiocheck") + Section.objects.create(playlist=playlist, song=song, filename=SimpleUploadedFile("some_audio.wav", b"")) actions = rules.next_round(session) self.assertIsInstance(actions[0], Trial) - self.assertEqual(actions[0].feedback_form.form[0].key, 'audio_check1') + self.assertEqual(actions[0].feedback_form.form[0].key, "audio_check1") # check that question trials are as expected question_trials = rules.get_open_questions(session) total_questions = get_questions_from_series(block.questionseries_set.all()) self.assertEqual(len(question_trials), len(total_questions)) keys = [q.feedback_form.form[0].key for q in question_trials] - questions = rules.question_series[0]['keys'][0:3] + questions = rules.question_series[0]["keys"][0:3] for question in questions: self.assertIn(question, keys) diff --git a/backend/experiment/rules/tests/test_musical_preferences.py b/backend/experiment/rules/tests/test_musical_preferences.py index 0268bc186..3ae7e6b13 100644 --- a/backend/experiment/rules/tests/test_musical_preferences.py +++ b/backend/experiment/rules/tests/test_musical_preferences.py @@ -2,9 +2,8 @@ from django.core.files.uploadedfile import SimpleUploadedFile from experiment.rules.musical_preferences import MusicalPreferences - from experiment.actions import Trial -from experiment.models import Block +from experiment.models import Experiment, Phase, Block, SocialMediaConfig from participant.models import Participant from result.models import Result from section.models import Playlist, Section, Song @@ -12,33 +11,35 @@ class MusicalPreferencesTest(TestCase): - fixtures = ['playlist', 'experiment'] + fixtures = ["playlist", "experiment"] @classmethod def setUpTestData(cls): cls.participant = Participant.objects.create() - cls.playlist = Playlist.objects.create(name='MusicalPrefences') - csv = ("SuperArtist,SuperSong,0.0,10.0,bat/artist1.mp3,0,0,0\n" - "SuperArtist,MehSong,0.0,10.0,bat/artist2.mp3,0,0,0\n" - "MehArtist,MehSong,0.0,10.0,bat/artist3.mp3,0,0,0\n" - "AwfulArtist,MehSong,0.0,10.0,bat/artist4.mp3,0,0,0\n" - "AwfulArtist,AwfulSong,0.0,10.0,bat/artist5.mp3,0,0,0\n") + cls.playlist = Playlist.objects.create(name="MusicalPrefences") + csv = ( + "SuperArtist,SuperSong,0.0,10.0,bat/artist1.mp3,0,0,0\n" + "SuperArtist,MehSong,0.0,10.0,bat/artist2.mp3,0,0,0\n" + "MehArtist,MehSong,0.0,10.0,bat/artist3.mp3,0,0,0\n" + "AwfulArtist,MehSong,0.0,10.0,bat/artist4.mp3,0,0,0\n" + "AwfulArtist,AwfulSong,0.0,10.0,bat/artist5.mp3,0,0,0\n" + ) cls.playlist.csv = csv cls.playlist.update_sections() - cls.block = Block.objects.create(name='MusicalPreferences', - rules="MUSICAL_PREFERENCES", - rounds=5) - cls.session = Session.objects.create( - block=cls.block, - participant=cls.participant, - playlist=cls.playlist + + cls.experiment = Experiment.objects.create(slug="music-lab") + cls.social_media_config = SocialMediaConfig.objects.create( + experiment=cls.experiment, url="https://app.amsterdammusiclab.nl/mpref" ) - audiocheck_playlist = Playlist.objects.create(name='test_audiocheck') - song = Song.objects.create(name='audiocheck') + cls.phase = Phase.objects.create(experiment=cls.experiment) + cls.block = Block.objects.create( + name="MusicalPreferences", phase=cls.phase, rules="MUSICAL_PREFERENCES", rounds=5 + ) + cls.session = Session.objects.create(block=cls.block, participant=cls.participant, playlist=cls.playlist) + audiocheck_playlist = Playlist.objects.create(name="test_audiocheck") + song = Song.objects.create(name="audiocheck") Section.objects.create( - playlist=audiocheck_playlist, song=song, filename=SimpleUploadedFile( - 'some_audio.wav', b'' - ) + playlist=audiocheck_playlist, song=song, filename=SimpleUploadedFile("some_audio.wav", b"") ) def test_first_rounds(self): @@ -46,48 +47,28 @@ def test_first_rounds(self): actions = rules.next_round(self.session) self.assertEqual(len(actions), 2) self.assertIsInstance(actions[1], Trial) - self.assertEqual(actions[1].feedback_form.form[0].key, 'audio_check1') + self.assertEqual(actions[1].feedback_form.form[0].key, "audio_check1") def test_preferred_songs(self): for index, section in enumerate(list(self.playlist.section_set.all())): - Result.objects.create( - question_key='like_song', - score=5-index, - section=section, - session=self.session - ) + Result.objects.create(question_key="like_song", score=5 - index, section=section, session=self.session) mp = MusicalPreferences() - preferred_sections = mp.get_preferred_songs( - self.session.result_set.order_by('?'), 3) - assert preferred_sections[0]['artist'] == 'SuperArtist' - assert preferred_sections[1]['name'] == 'MehSong' - assert preferred_sections[2]['artist'] == 'MehArtist' - assert 'AwfulArtist' not in [p['artist'] for p in preferred_sections] + preferred_sections = mp.get_preferred_songs(self.session.result_set.order_by("?"), 3) + assert preferred_sections[0]["artist"] == "SuperArtist" + assert preferred_sections[1]["name"] == "MehSong" + assert preferred_sections[2]["artist"] == "MehArtist" + assert "AwfulArtist" not in [p["artist"] for p in preferred_sections] def test_preferred_songs_results_without_section(self): # Create 3 results with a section for index, section in enumerate(list(self.playlist.section_set.all())): if index < 3: - Result.objects.create( - question_key='like_song', - score=5-index, - section=section, - session=self.session - ) + Result.objects.create(question_key="like_song", score=5 - index, section=section, session=self.session) - other_session = Session.objects.create( - block=self.block, - participant=self.participant, - playlist=self.playlist - ) + other_session = Session.objects.create(block=self.block, participant=self.participant, playlist=self.playlist) for i in range(10): - Result.objects.create( - question_key='like_song', - score=5-i, - section=None, - session=other_session - ) + Result.objects.create(question_key="like_song", score=5 - i, section=None, session=other_session) mp = MusicalPreferences() # Go to the last round (top_all = ... caused the error) @@ -95,4 +76,3 @@ def test_preferred_songs_results_without_section(self): actions = mp.next_round(self.session) if i == self.session.block.rounds + 1: self.assertIsNotNone(actions) - diff --git a/backend/experiment/rules/tests/test_rhythm_battery_final.py b/backend/experiment/rules/tests/test_rhythm_battery_final.py index e3a2b181f..e23563a80 100644 --- a/backend/experiment/rules/tests/test_rhythm_battery_final.py +++ b/backend/experiment/rules/tests/test_rhythm_battery_final.py @@ -19,16 +19,10 @@ def setUpTestData(cls): language="en", consent=SimpleUploadedFile("consent.md", b"# test", content_type="text/html"), ) - block = Block.objects.create( - name="test_md", - slug="MARKDOWN_BLOCK", - rules=RhythmBatteryFinal.ID - ) + block = Block.objects.create(name="test_md", slug="MARKDOWN_BLOCK", rules=RhythmBatteryFinal.ID) block.add_default_question_series() Session.objects.create( - block=block, - playlist=Playlist.objects.create(name='test'), - participant=Participant.objects.create() + block=block, playlist=Playlist.objects.create(name="test"), participant=Participant.objects.create() ) def test_init(self): diff --git a/backend/experiment/static/block_admin.js b/backend/experiment/static/block_admin.js index c4c397f36..77a5c1d50 100644 --- a/backend/experiment/static/block_admin.js +++ b/backend/experiment/static/block_admin.js @@ -2,33 +2,34 @@ document.addEventListener("DOMContentLoaded", (event) => { // Get experiment id from URL - match = window.location.href.match(/\/experiment\/block\/(.+)\/change/) - experiment_id = match && match[1] + match = window.location.href.match(/\/experiment\/block\/(.+)\/change/); + experiment_id = match && match[1]; - let buttonAddDefaultQuestions = document.createElement("input") - buttonAddDefaultQuestions.type = "button" - buttonAddDefaultQuestions.value = "Add rules' defaults and save" - buttonAddDefaultQuestions.addEventListener("click", addDefaultQuestions) + let buttonAddDefaultQuestions = document.createElement("input"); + buttonAddDefaultQuestions.type = "button"; + buttonAddDefaultQuestions.value = "Add rules' defaults and save"; + buttonAddDefaultQuestions.addEventListener("click", addDefaultQuestions); - let message = document.createElement("span") - message.id = "id_message" - message.className = "form-row" + let message = document.createElement("span"); + message.id = "id_message"; + message.className = "form-row"; - document.querySelector('#questionseries_set-group').append(buttonAddDefaultQuestions, message) + const questionSeriesSetGroup = document.querySelector('#questionseries_set-group'); + questionSeriesSetGroup.append(buttonAddDefaultQuestions, message); - let selectRules = document.querySelector("#id_rules") - selectRules.onchange = toggleButton - toggleButton() + let selectRules = document.querySelector("#id_rules"); + selectRules.onchange = toggleButton; + toggleButton(); function toggleButton(e) { // Check if we are on a Change Experiment (not Add Experiment) and if selection for Experiment rules has not changed if (experiment_id && (selectRules[selectRules.selectedIndex] === selectRules.querySelector("option[selected]"))) { - buttonAddDefaultQuestions.disabled = false - message.innerText = "" + buttonAddDefaultQuestions.disabled = false; + message.innerText = ""; } else { - buttonAddDefaultQuestions.disabled = true - message.innerText = "Save Block first" + buttonAddDefaultQuestions.disabled = true; + message.innerText = "Save Block first"; } } @@ -39,7 +40,7 @@ document.addEventListener("DOMContentLoaded", (event) => { { method: "POST", mode: 'same-origin', headers: { 'X-CSRFToken': csrftoken } }) if (response.ok) { - location.reload() + location.reload(); } } }) diff --git a/backend/experiment/templates/widgets/markdown_preview_text_input.html b/backend/experiment/templates/widgets/markdown_preview_text_input.html index 8e96f864b..1e16718dd 100644 --- a/backend/experiment/templates/widgets/markdown_preview_text_input.html +++ b/backend/experiment/templates/widgets/markdown_preview_text_input.html @@ -58,7 +58,7 @@ background-color: var(--button-bg); margin-top: -1px; margin-bottom: .5rem; - border-radius: 5px 0 5px 5px; + border-radius: 0px 0 5px 5px; } .tab-content.active { diff --git a/backend/experiment/tests/test_admin_experiment.py b/backend/experiment/tests/test_admin_experiment.py index 7a17ff26f..0f11de4e9 100644 --- a/backend/experiment/tests/test_admin_experiment.py +++ b/backend/experiment/tests/test_admin_experiment.py @@ -14,7 +14,7 @@ # Expected field count per model -EXPECTED_BLOCK_FIELDS = 17 +EXPECTED_BLOCK_FIELDS = 13 EXPECTED_SESSION_FIELDS = 9 EXPECTED_RESULT_FIELDS = 12 EXPECTED_PARTICIPANT_FIELDS = 5 @@ -225,10 +225,10 @@ class PhaseAdminTest(TestCase): def setUp(self): self.admin = PhaseAdmin(model=Phase, admin_site=AdminSite) - def test_related_experiment_with_experiment(self): + def test_phase_admin_related_experiment_method(self): experiment = Experiment.objects.create(slug="test-experiment") ExperimentTranslatedContent.objects.create(experiment=experiment, language="en", name="Test Experiment") - phase = Phase.objects.create(name="Test Phase", index=1, randomize=False, series=experiment, dashboard=True) + phase = Phase.objects.create(name="Test Phase", index=1, randomize=False, experiment=experiment, dashboard=True) related_experiment = self.admin.related_experiment(phase) expected_url = reverse("admin:experiment_experiment_change", args=[experiment.pk]) expected_related_experiment = format_html( @@ -243,7 +243,7 @@ def test_experiment_with_no_blocks(self): language="en", name="No Blocks", ) - phase = Phase.objects.create(name="Test Group", index=1, randomize=False, dashboard=True, series=experiment) + phase = Phase.objects.create(name="Test Group", index=1, randomize=False, dashboard=True, experiment=experiment) blocks = self.admin.blocks(phase) self.assertEqual(blocks, "No blocks") @@ -254,7 +254,7 @@ def test_experiment_with_blocks(self): language="en", name="With Blocks", ) - phase = Phase.objects.create(name="Test Phase", index=1, randomize=False, dashboard=True, series=experiment) + phase = Phase.objects.create(name="Test Phase", index=1, randomize=False, dashboard=True, experiment=experiment) block1 = Block.objects.create(name="Block 1", slug="block-1", phase=phase) block2 = Block.objects.create(name="Block 2", slug="block-2", phase=phase) diff --git a/backend/experiment/tests/test_model.py b/backend/experiment/tests/test_model.py index f0a940895..341f09f5c 100644 --- a/backend/experiment/tests/test_model.py +++ b/backend/experiment/tests/test_model.py @@ -25,12 +25,9 @@ def setUpTestData(cls): name="Test Block", description="Test block description", slug="test-block", - url="https://example.com/block", - hashtag="test", rounds=5, bonus_points=10, rules="RHYTHM_BATTERY_FINAL", - language="en", theme_config=ThemeConfig.objects.get(name="Default"), ) diff --git a/backend/experiment/tests/test_model_functions.py b/backend/experiment/tests/test_model_functions.py index ab8ef2d61..814904399 100644 --- a/backend/experiment/tests/test_model_functions.py +++ b/backend/experiment/tests/test_model_functions.py @@ -36,8 +36,8 @@ def test_verbose_name_plural(self): def test_associated_blocks(self): experiment = self.experiment - phase1 = Phase.objects.create(name="first_phase", series=experiment) - phase2 = Phase.objects.create(name="second_phase", series=experiment) + phase1 = Phase.objects.create(name="first_phase", experiment=experiment) + phase2 = Phase.objects.create(name="second_phase", experiment=experiment) block = Block.objects.create(rules="THATS_MY_SONG", slug="hooked", rounds=42, phase=phase1) block2 = Block.objects.create(rules="THATS_MY_SONG", slug="unhinged", rounds=42, phase=phase2) block3 = Block.objects.create(rules="THATS_MY_SONG", slug="derailed", rounds=42, phase=phase2) @@ -46,7 +46,7 @@ def test_associated_blocks(self): def test_export_sessions(self): experiment = self.experiment - phase = Phase.objects.create(name="test", series=experiment) + phase = Phase.objects.create(name="test", experiment=experiment) block = Block.objects.create(rules="THATS_MY_SONG", slug="hooked", rounds=42, phase=phase) Session.objects.bulk_create( [ @@ -60,7 +60,7 @@ def test_export_sessions(self): def test_current_participants(self): experiment = self.experiment - phase = Phase.objects.create(name="test", series=experiment) + phase = Phase.objects.create(name="test", experiment=experiment) block = Block.objects.create(rules="THATS_MY_SONG", slug="hooked", rounds=42, phase=phase) Session.objects.bulk_create( [ diff --git a/backend/experiment/tests/test_views.py b/backend/experiment/tests/test_views.py index 213f563fd..6082135c0 100644 --- a/backend/experiment/tests/test_views.py +++ b/backend/experiment/tests/test_views.py @@ -1,6 +1,7 @@ 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 @@ -31,14 +32,14 @@ def setUpTestData(cls): experiment=experiment, language="en", name="Test Series", description="Test Description" ) experiment.social_media_config = create_social_media_config(experiment) - introductory_phase = Phase.objects.create(name="introduction", series=experiment, index=1) + introductory_phase = Phase.objects.create(name="introduction", experiment=experiment, index=1) cls.block1 = Block.objects.create(name="block1", slug="block1", phase=introductory_phase) - intermediate_phase = Phase.objects.create(name="intermediate", series=experiment, index=2) + intermediate_phase = Phase.objects.create(name="intermediate", experiment=experiment, index=2) cls.block2 = Block.objects.create( name="block2", slug="block2", theme_config=theme_config, phase=intermediate_phase ) cls.block3 = Block.objects.create(name="block3", slug="block3", phase=intermediate_phase) - final_phase = Phase.objects.create(name="final", series=experiment, index=3) + final_phase = Phase.objects.create(name="final", experiment=experiment, index=3) cls.block4 = Block.objects.create(name="block4", slug="block4", phase=final_phase) def test_get_experiment(self): @@ -189,7 +190,11 @@ def test_experiment_get_fallback_content(self): class ExperimentViewsTest(TestCase): def test_serialize_block(self): - # Create an block + # 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", name="Test Block", @@ -204,6 +209,7 @@ def test_serialize_block(self): target="_self", ), theme_config=create_theme_config(), + phase=phase, ) participant = Participant.objects.create() Session.objects.bulk_create( @@ -232,7 +238,16 @@ def test_serialize_block(self): ) def test_get_block(self): - # Create an block + # Create a block + experiment = Experiment.objects.create(slug="test-experiment") + ExperimentTranslatedContent.objects.create( + experiment=experiment, + language="en", + name="Test Experiment", + description="Test Description", + consent=SimpleUploadedFile("test-consent.md", b"test consent"), + ) + phase = Phase.objects.create(experiment=experiment) block = Block.objects.create( slug="test-block", name="Test Block", @@ -242,6 +257,7 @@ def test_get_block(self): theme_config=create_theme_config(), rounds=3, bonus_points=42, + phase=phase, ) participant = Participant.objects.create() participant.save() diff --git a/backend/experiment/utils.py b/backend/experiment/utils.py index 805115007..f76f273b5 100644 --- a/backend/experiment/utils.py +++ b/backend/experiment/utils.py @@ -1,15 +1,40 @@ +from typing import List, Tuple import roman +from django.utils.html import format_html +from django.db.models.query import QuerySet +from experiment.models import Experiment, Phase, Block, BlockTranslatedContent def slugify(text): """Create a slug from given string""" - non_url_safe = ['"', '#', '$', '%', '&', '+', - ',', '/', ':', ';', '=', '?', - '@', '[', '\\', ']', '^', '`', - '{', '|', '}', '~', "'"] - translate_table = {ord(char): u'' for char in non_url_safe} + non_url_safe = [ + '"', + "#", + "$", + "%", + "&", + "+", + ",", + "/", + ":", + ";", + "=", + "?", + "@", + "[", + "\\", + "]", + "^", + "`", + "{", + "|", + "}", + "~", + "'", + ] + translate_table = {ord(char): "" for char in non_url_safe} text = text.translate(translate_table) - text = u'_'.join(text.split()) + text = "_".join(text.split()) return text.lower() @@ -25,14 +50,79 @@ def external_url(text, url): return '{}'.format(url, text) -def create_player_labels(num_labels, label_style='number'): +def create_player_labels(num_labels, label_style="number"): return [format_label(i, label_style) for i in range(num_labels)] def format_label(number, label_style): - if label_style == 'alphabetic': + if label_style == "alphabetic": return chr(number + 65) - elif label_style == 'roman': - return roman.toRoman(number+1) + elif label_style == "roman": + return roman.toRoman(number + 1) else: - return str(number+1) + return str(number + 1) + + +def get_flag_emoji(country_code): + # If the country code is not provided or is empty, return "Unknown" + if not country_code: + return "🏳️" + + # Convert the country code to uppercase + country_code = country_code.upper() + + # Calculate the Unicode code points for the flag emoji + flag_emoji = "".join([chr(127397 + ord(char)) for char in country_code]) + + return flag_emoji + + +def get_missing_content_block(block: Block) -> List[str]: + block_experiment = block.phase.experiment + + languages = block_experiment.translated_content.values_list("language", flat=True) + + missing_languages = [] + + for language in languages: + block_content = BlockTranslatedContent.objects.filter(block=block, language=language) + if not block_content: + missing_languages.append(language) + + return missing_languages + + +# Returns a list of a tuple containing the Block and a list of missing languages +def get_missing_content_blocks(experiment: Experiment) -> List[Tuple[Block, List[str]]]: + languages = experiment.translated_content.values_list("language", flat=True) + + associated_phases = Phase.objects.filter(experiment=experiment) + associated_blocks = Block.objects.filter(phase__in=associated_phases) + + missing_content_blocks = [] + + for block in associated_blocks: + missing_languages = [] + for language in languages: + block_content = BlockTranslatedContent.objects.filter(block=block, language=language) + if not block_content: + missing_languages.append(language) + + if len(missing_languages) > 0: + missing_content_blocks.append((block, missing_languages)) + + return missing_content_blocks + + +def check_missing_translations(experiment: Experiment) -> str: + warnings = [] + + missing_content_blocks = get_missing_content_blocks(experiment) + + for block, missing_languages in missing_content_blocks: + missing_language_flags = [get_flag_emoji(language) for language in missing_languages] + warnings.append(f"Block {block.name} does not have content in {', '.join(missing_language_flags)}") + + warnings_text = "\n".join(warnings) + + return warnings_text diff --git a/backend/experiment/views.py b/backend/experiment/views.py index ba5f0010c..5f24a2296 100644 --- a/backend/experiment/views.py +++ b/backend/experiment/views.py @@ -122,7 +122,7 @@ def get_experiment( experiment_language = translated_content.language activate(experiment_language) - phases = list(Phase.objects.filter(series=experiment.id).order_by("index")) + phases = list(Phase.objects.filter(experiment=experiment.id).order_by("index")) try: current_phase = phases[phase_index] serialized_phase = serialize_phase(current_phase, participant) diff --git a/backend/question/models.py b/backend/question/models.py index e04d782b8..accb60787 100644 --- a/backend/question/models.py +++ b/backend/question/models.py @@ -10,7 +10,7 @@ class Question(models.Model): editable = models.BooleanField(default=True, editable=False) def __str__(self): - return "("+self.key+") "+ self.question + return "(" + self.key + ") " + self.question class Meta: ordering = ["key"] @@ -32,13 +32,13 @@ def __str__(self): class QuestionSeries(models.Model): - """Series of Questions asked in an Block""" + """Series of Questions asked in a Block""" - name = models.CharField(default='', max_length=128) + name = models.CharField(default="", max_length=128) block = models.ForeignKey(Block, on_delete=models.CASCADE) - index = models.PositiveIntegerField() # index of QuestionSeries within Block - questions = models.ManyToManyField(Question, through='QuestionInSeries') - randomize = models.BooleanField(default=False) # randomize questions within QuestionSeries + index = models.PositiveIntegerField() # index of QuestionSeries within Block + questions = models.ManyToManyField(Question, through="QuestionInSeries") + randomize = models.BooleanField(default=False) # randomize questions within QuestionSeries class Meta: ordering = ["index"] @@ -56,6 +56,6 @@ class QuestionInSeries(models.Model): index = models.PositiveIntegerField() class Meta: - unique_together = ('question_series', 'question') + unique_together = ("question_series", "question") ordering = ["index"] verbose_name_plural = "Question In Series objects" diff --git a/backend/requirements.in/base.txt b/backend/requirements.in/base.txt index 7a9ed9617..43df93a22 100644 --- a/backend/requirements.in/base.txt +++ b/backend/requirements.in/base.txt @@ -35,4 +35,4 @@ genbadge[coverage] django-markup[all_filter_dependencies] # Nested inline forms -django-nested-admin +django-nested-admin>=4.1.1 diff --git a/backend/requirements/dev.txt b/backend/requirements/dev.txt index d3c5fb2e0..59be0ab8f 100644 --- a/backend/requirements/dev.txt +++ b/backend/requirements/dev.txt @@ -45,7 +45,7 @@ django-inline-actions==2.4.0 # via -r requirements.in/base.txt django-markup[all-filter-dependencies,all_filter_dependencies]==1.8.1 # via -r requirements.in/base.txt -django-nested-admin==4.1.0 +django-nested-admin==4.1.1 # via -r requirements.in/base.txt docutils==0.20.1 # via diff --git a/backend/requirements/prod.txt b/backend/requirements/prod.txt index a9c1eae56..6ad40ddba 100644 --- a/backend/requirements/prod.txt +++ b/backend/requirements/prod.txt @@ -34,7 +34,7 @@ django-inline-actions==2.4.0 # via -r requirements.in/base.txt django-markup[all-filter-dependencies,all_filter_dependencies]==1.8.1 # via -r requirements.in/base.txt -django-nested-admin==4.1.0 +django-nested-admin==4.1.1 # via -r requirements.in/base.txt docutils==0.20.1 # via diff --git a/backend/session/tests/test_views.py b/backend/session/tests/test_views.py index 692b3864a..47cbca9d5 100644 --- a/backend/session/tests/test_views.py +++ b/backend/session/tests/test_views.py @@ -11,67 +11,49 @@ class SessionViewsTest(TestCase): @classmethod def setUpTestData(cls): cls.participant = Participant.objects.create(unique_hash=42) - cls.playlist1 = Playlist.objects.create(name='First Playlist') - cls.playlist2 = Playlist.objects.create(name='Second Playlist') - cls.block = Block.objects.create( - name='TestViews', - slug='testviews', - rules='RHYTHM_BATTERY_INTRO' - ) - cls.block.playlists.add( - cls.playlist1, cls.playlist2 - ) + cls.playlist1 = Playlist.objects.create(name="First Playlist") + cls.playlist2 = Playlist.objects.create(name="Second Playlist") + cls.block = Block.objects.create(name="TestViews", slug="testviews", rules="RHYTHM_BATTERY_INTRO") + cls.block.playlists.add(cls.playlist1, cls.playlist2) def setUp(self): session = self.client.session - session['participant_id'] = self.participant.id + session["participant_id"] = self.participant.id session.save() def test_create_with_playlist(self): - request = { - "block_id": self.block.id, - "playlist_id": self.playlist2.id - } - self.client.post('/session/create/', request) - new_session = Session.objects.get( - block=self.block, participant=self.participant) + request = {"block_id": self.block.id, "playlist_id": self.playlist2.id} + self.client.post("/session/create/", request) + new_session = Session.objects.get(block=self.block, participant=self.participant) assert new_session assert new_session.playlist == self.playlist2 def test_create_without_playlist(self): - request = { - "block_id": self.block.id - } - self.client.post('/session/create/', request) - new_session = Session.objects.get( - block=self.block, participant=self.participant) + request = {"block_id": self.block.id} + self.client.post("/session/create/", request) + new_session = Session.objects.get(block=self.block, participant=self.participant) assert new_session assert new_session.playlist == self.playlist1 def test_next_round(self): - session = Session.objects.create( - block=self.block, participant=self.participant) - response = self.client.get( - f'/session/{session.id}/next_round/') + session = Session.objects.create(block=self.block, participant=self.participant) + response = self.client.get(f"/session/{session.id}/next_round/") assert response def test_next_round_with_experiment(self): - slug = 'myexperiment' + slug = "myexperiment" experiment = Experiment.objects.create(slug=slug) request_session = self.client.session request_session[EXPERIMENT_KEY] = slug request_session.save() - session = Session.objects.create( - block=self.block, participant=self.participant) - response = self.client.get( - f'/session/{session.id}/next_round/') + session = Session.objects.create(block=self.block, participant=self.participant) + response = self.client.get(f"/session/{session.id}/next_round/") assert response changed_session = Session.objects.get(pk=session.pk) assert changed_session.load_json_data().get(EXPERIMENT_KEY) is None - phase = Phase.objects.create(series=experiment) + phase = Phase.objects.create(experiment=experiment) self.block.phase = phase self.block.save() - response = self.client.get( - f'/session/{session.id}/next_round/') + response = self.client.get(f"/session/{session.id}/next_round/") changed_session = Session.objects.get(pk=session.pk) assert changed_session.load_json_data().get(EXPERIMENT_KEY) == slug diff --git a/backend/session/views.py b/backend/session/views.py index 5c2805016..124d0f634 100644 --- a/backend/session/views.py +++ b/backend/session/views.py @@ -31,8 +31,7 @@ def create_session(request): if request.POST.get("playlist_id"): try: - playlist = Playlist.objects.get( - pk=request.POST.get("playlist_id"), block__id=session.block.id) + playlist = Playlist.objects.get(pk=request.POST.get("playlist_id"), block__id=session.block.id) session.playlist = playlist except: raise Http404("Playlist does not exist") @@ -43,29 +42,28 @@ def create_session(request): # Save session session.save() - return JsonResponse({'session': {'id': session.id}}) + return JsonResponse({"session": {"id": session.id}}) def continue_session(request, session_id): - """ given a session_id, continue where we left off """ + """given a session_id, continue where we left off""" session = get_object_or_404(Session, pk=session_id) # Get next round for given session action = serialize_actions(session.block_rules().next_round(session)) - return JsonResponse(action, json_dumps_params={'indent': 4}) + return JsonResponse(action, json_dumps_params={"indent": 4}) def next_round(request, session_id): """ - Fall back to continue an block is case next_round data is missing + Fall back to continue a block is case next_round data is missing This data is normally provided in: result() """ # Current participant participant = get_participant(request) - session = get_object_or_404(Session, - pk=session_id, participant__id=participant.id) + session = get_object_or_404(Session, pk=session_id, participant__id=participant.id) # check if this block is part of an Experiment experiment_slug = request.session.get(EXPERIMENT_KEY) @@ -81,11 +79,11 @@ def next_round(request, session_id): actions = serialize_actions(session.block_rules().next_round(session)) if not isinstance(actions, list): - if actions.get('redirect'): - return redirect(actions.get('redirect')) + if actions.get("redirect"): + return redirect(actions.get("redirect")) actions = [actions] - return JsonResponse({'next_round': actions}, json_dumps_params={'indent': 4}) + return JsonResponse({"next_round": actions}, json_dumps_params={"indent": 4}) def finalize_session(request, session_id): @@ -94,4 +92,4 @@ def finalize_session(request, session_id): session = get_object_or_404(Session, pk=session_id, participant__id=participant.id) session.finish() session.save() - return JsonResponse({'status': 'ok'}) + return JsonResponse({"status": "ok"}) diff --git a/frontend/src/components/Explainer/Explainer.tsx b/frontend/src/components/Explainer/Explainer.tsx index d4a0ff7c1..d5fc42245 100644 --- a/frontend/src/components/Explainer/Explainer.tsx +++ b/frontend/src/components/Explainer/Explainer.tsx @@ -15,7 +15,7 @@ export interface ExplainerProps { } /** - * Explainer is an block view that shows a list of steps + * Explainer is a block view that shows a list of steps * If the button has not been clicked, onNext will be called automatically after the timer expires (in milliseconds). * If timer == null, onNext will only be called after the button is clicked. */ diff --git a/frontend/src/components/Final/Final.tsx b/frontend/src/components/Final/Final.tsx index c6104a1c2..25d94d738 100644 --- a/frontend/src/components/Final/Final.tsx +++ b/frontend/src/components/Final/Final.tsx @@ -46,7 +46,7 @@ export interface FinalProps { } /** - * Final is an block view that shows the final scores of the block + * Final is a block view that shows the final scores of the block * It can only be the last view of a block */ const Final = ({ diff --git a/frontend/src/components/Info/Info.tsx b/frontend/src/components/Info/Info.tsx index b25affc2b..f9f682f54 100644 --- a/frontend/src/components/Info/Info.tsx +++ b/frontend/src/components/Info/Info.tsx @@ -10,7 +10,7 @@ export interface InfoProps { onNext?: () => void; } -/** Info is an block view that shows the Info text, and handles agreement/stop actions */ +/** Info is a block view that shows the Info text, and handles agreement/stop actions */ const Info = ({ heading, body, button_label, button_link, onNext }: InfoProps) => { const [maxHeight, setMaxHeight] = useState(getMaxHeight()); diff --git a/frontend/src/components/Loading/Loading.tsx b/frontend/src/components/Loading/Loading.tsx index cdfbabbd6..55368b346 100644 --- a/frontend/src/components/Loading/Loading.tsx +++ b/frontend/src/components/Loading/Loading.tsx @@ -6,7 +6,7 @@ export interface LoadingProps { } /** - * Loading is an block view that shows a loading screen + * Loading is a block view that shows a loading screen * It is normally set by code during loading of data */ const Loading = ({ duration = 2, loadingText = '' }: LoadingProps) => { diff --git a/frontend/src/components/Playlist/Playlist.tsx b/frontend/src/components/Playlist/Playlist.tsx index 9a1bac56d..f6161bf2d 100644 --- a/frontend/src/components/Playlist/Playlist.tsx +++ b/frontend/src/components/Playlist/Playlist.tsx @@ -9,7 +9,7 @@ export interface PlaylistProps { } /** - * Playlist is an block view, that handles (auto)selection of a playlist + * Playlist is a block view, that handles (auto)selection of a playlist */ const Playlist = ({ block, instruction, onNext, playlist }: PlaylistProps) => { const playlists = block.playlists; diff --git a/frontend/src/components/Question/Question.tsx b/frontend/src/components/Question/Question.tsx index 16b901ed7..6a27b491e 100644 --- a/frontend/src/components/Question/Question.tsx +++ b/frontend/src/components/Question/Question.tsx @@ -22,7 +22,7 @@ interface QuestionProps { emphasizeTitle?: boolean; } -/** Question is an block view that shows a question and handles storing the answer */ +/** Question is a block view that shows a question and handles storing the answer */ const Question = ({ question, onChange, diff --git a/frontend/src/components/Trial/Trial.tsx b/frontend/src/components/Trial/Trial.tsx index 317af8074..37c041add 100644 --- a/frontend/src/components/Trial/Trial.tsx +++ b/frontend/src/components/Trial/Trial.tsx @@ -29,7 +29,7 @@ export interface TrialProps { } /** - * Trial is an block view to present information to the user and/or collect user feedback + * Trial is a block view to present information to the user and/or collect user feedback * If "playback" is provided, it will play audio through the Playback component * If "html" is provided, it will show html content * If "feedback_form" is provided, it will present a form of questions to the user