From 95a74266f9904b917c8e9d775d650c943bfa1f8d Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Wed, 7 Aug 2024 16:25:05 +0200 Subject: [PATCH 01/23] feat: Add BlockTranslatedContent model and relate it to Block with a ManyToMany relationship --- backend/experiment/admin.py | 14 +- backend/experiment/forms.py | 1 - ...ename_series_phase_experiments_and_more.py | 224 ++++++++++++++++++ backend/experiment/models.py | 74 +++++- 4 files changed, 297 insertions(+), 16 deletions(-) create mode 100644 backend/experiment/migrations/0053_alter_block_options_rename_series_phase_experiments_and_more.py diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index 34f4f451a..7a9912f2f 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -300,17 +300,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]) ) @@ -418,7 +416,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 +425,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) diff --git a/backend/experiment/forms.py b/backend/experiment/forms.py index fce76fdf2..bf1afbfc1 100644 --- a/backend/experiment/forms.py +++ b/backend/experiment/forms.py @@ -257,7 +257,6 @@ def clean_playlists(self): class Meta: model = Block fields = [ - "name", "slug", "active", "rules", 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..e01ad9196 --- /dev/null +++ b/backend/experiment/migrations/0053_alter_block_options_rename_series_phase_experiments_and_more.py @@ -0,0 +1,224 @@ +# 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.ManyToManyField(related_name="translated_content", to="experiment.block")), + ], + ), + ] diff --git a/backend/experiment/models.py b/backend/experiment/models.py index 94cafe49f..b5e7a39b3 100644 --- a/backend/experiment/models.py +++ b/backend/experiment/models.py @@ -87,15 +87,15 @@ def get_translated_content(self, language: str, fallback: bool = True): 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})" @@ -111,30 +111,43 @@ 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.") + translated_content = models.QuerySet["BlockTranslatedContent"] playlists = models.ManyToManyField("section.Playlist", blank=True) + + # to be deleted name = models.CharField(db_index=True, max_length=64) + # # 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]) + + # to be deleted url = models.CharField( verbose_name="URL with more information about the block", max_length=100, blank=True, default="" ) + # to be deleted 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) + + # to be deleted 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) + + # to be deleted consent = models.FileField( upload_to=consent_upload_path, blank=True, default="", validators=[markdown_html_validator()] ) - class Meta: - ordering = ["name"] - def __str__(self): - 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 +317,46 @@ def add_default_question_series(self): question_series=qs, question=Question.objects.get(pk=question), index=i + 1 ) + # @property + # def name(self): + # """Get name of the block""" + # content = self.get_fallback_content() + # return content.name if content and content.name else self.slug + + # @property + # def description(self): + # """Get description of the block""" + # content = self.get_fallback_content() + # return content.description if content and content.description else "" + + def get_fallback_content(self): + """Get fallback content for the block""" + if not self.phase or self.phase.experiment: + return self.translated_content.first() + + experiment = self.phase.experiment + fallback_language = experiment.get_fallback_content().language + fallback_content = self.translated_content.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_content.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 + class ExperimentTranslatedContent(models.Model): experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE, related_name="translated_content") @@ -317,6 +370,13 @@ class ExperimentTranslatedContent(models.Model): about_content = models.TextField(blank=True, default="") +class BlockTranslatedContent(models.Model): + block = models.ManyToManyField(Block, related_name="translated_content") + 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="") + + class Feedback(models.Model): text = models.TextField() block = models.ForeignKey(Block, on_delete=models.CASCADE) From adcea3ec57d491815c209eb2011e4e4b26c8ca98 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Thu, 8 Aug 2024 11:27:14 +0200 Subject: [PATCH 02/23] refactor: Migrate block content and associated models --- .../migrations/0054_migrate_block_content.py | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 backend/experiment/migrations/0054_migrate_block_content.py 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..36be2532e --- /dev/null +++ b/backend/experiment/migrations/0054_migrate_block_content.py @@ -0,0 +1,90 @@ +# Generated by Django 4.2.14 on 2024-08-07 14:30 + +from django.db import migrations + + +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 block.language else "en" + + block_translated_content = BlockTranslatedContent.objects.create( + language=language, + name=block.name, + description=block.description, + ) + block_translated_content.block.add(block) + + if block.phase: + continue + + # if block is not associated with a phase and experiment, + # create a new experiment and phase + experiment = Experiment.objects.create( + slug=block.slug, + ) + ExperimentTranslatedContent.objects.create( + experiment=experiment, + index=0, + language=language, + name=block.name, + description=block.description, + consent=block.consent if block.consent else "", + ) + Phase.objects.create( + experiment=experiment, + index=0, + ) + + +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") + + for block in Block.objects.all(): + block_fallback_content = BlockTranslatedContent.objects.filter(block=block).first() + + phase = block.phase + experiment = Experiment.objects.filter(phases=phase).first() + experiment_fallback_content = ( + ExperimentTranslatedContent.objects.filter(experiment=experiment).order_by("index").first() + ) + + if experiment_fallback_content: + language = experiment_fallback_content.language + possible_block_fallback_content = BlockTranslatedContent.objects.filter( + language=language, block=block + ).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 "" + block.consent = ( + experiment_fallback_content.consent + if experiment_fallback_content and experiment_fallback_content.consent + else "" + ) + 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), + ] From 1a6b588f6fceb96de9361e959d987c2787cdcb4d Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Fri, 9 Aug 2024 09:11:22 +0200 Subject: [PATCH 03/23] refactor: Add `translated_content` as `ManyToMany` field of `Block` instead of on `BlockTranslatedContent` --- backend/experiment/admin.py | 23 +++++++++++++++++++ backend/experiment/forms.py | 5 +++- ...ename_series_phase_experiments_and_more.py | 6 ++++- .../migrations/0054_migrate_block_content.py | 2 +- backend/experiment/models.py | 6 +++-- 5 files changed, 37 insertions(+), 5 deletions(-) diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index 7a9912f2f..7ea224a89 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -23,6 +23,7 @@ Feedback, SocialMediaConfig, ExperimentTranslatedContent, + BlockTranslatedContent, ) from question.admin import QuestionSeriesInline from experiment.forms import ( @@ -84,6 +85,7 @@ class BlockAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): "rounds", "bonus_points", "playlists", + "translated_content", "consent", ] inlines = [QuestionSeriesInline, FeedbackInline] @@ -442,3 +444,24 @@ def blocks(self, obj): admin.site.register(Phase, PhaseAdmin) + + +@admin.register(BlockTranslatedContent) +class BlockTranslatedContentAdmin(admin.ModelAdmin): + list_display = ["name", "blocks", "language"] + list_filter = ["language"] + search_fields = [ + "name", + "blocks__name", + ] + + def blocks(self, obj): + # Block is manytomany, so we need to find it through the related name + blocks = Block.objects.filter(translated_content=obj) + + if not blocks: + return "No block" + + return format_html( + ", ".join([f'{block.name}' for block in blocks]) + ) diff --git a/backend/experiment/forms.py b/backend/experiment/forms.py index bf1afbfc1..971b0a878 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 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 index e01ad9196..849ed0d0f 100644 --- 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 @@ -218,7 +218,11 @@ class Migration(migrations.Migration): ), ("name", models.CharField(default="", max_length=64)), ("description", models.TextField(blank=True, default="")), - ("block", models.ManyToManyField(related_name="translated_content", to="experiment.block")), ], ), + migrations.AddField( + model_name="block", + name="translated_content", + field=models.ManyToManyField(blank=True, to="experiment.blocktranslatedcontent"), + ), ] diff --git a/backend/experiment/migrations/0054_migrate_block_content.py b/backend/experiment/migrations/0054_migrate_block_content.py index 36be2532e..2ec78de42 100644 --- a/backend/experiment/migrations/0054_migrate_block_content.py +++ b/backend/experiment/migrations/0054_migrate_block_content.py @@ -18,7 +18,7 @@ def migrate_block_content(apps, schema_editor): name=block.name, description=block.description, ) - block_translated_content.block.add(block) + block.translated_content.add(block_translated_content) if block.phase: continue diff --git a/backend/experiment/models.py b/backend/experiment/models.py index b5e7a39b3..14d3a798c 100644 --- a/backend/experiment/models.py +++ b/backend/experiment/models.py @@ -111,7 +111,7 @@ 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.") - translated_content = models.QuerySet["BlockTranslatedContent"] + translated_content = models.ManyToManyField("BlockTranslatedContent", blank=True) playlists = models.ManyToManyField("section.Playlist", blank=True) # to be deleted @@ -371,11 +371,13 @@ class ExperimentTranslatedContent(models.Model): class BlockTranslatedContent(models.Model): - block = models.ManyToManyField(Block, related_name="translated_content") 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 Feedback(models.Model): text = models.TextField() From 11cb5233ac600c85e32f644fd75d9987d0d3ec61 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Wed, 21 Aug 2024 14:08:17 +0200 Subject: [PATCH 04/23] feat: Warn user about missing translated contents --- backend/experiment/admin.py | 25 +++++++- backend/experiment/models.py | 12 ++-- backend/experiment/utils.py | 116 +++++++++++++++++++++++++++++++---- 3 files changed, 135 insertions(+), 18 deletions(-) diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index 7ea224a89..c22407a6d 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, @@ -271,6 +274,7 @@ class ExperimentAdmin(InlineActionsModelAdminMixin, NestedModelAdmin): "dashboard", "phases", "active", + "status", ) fields = [ "slug", @@ -291,6 +295,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): @@ -405,6 +410,24 @@ def remarks(self, obj): ) ) + def status(self, obj): + return check_missing_translations(obj) + + def save_model(self, request, obj, form, change): + # Check for missing translations + 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, + ) + + super().save_model(request, obj, form, change) + admin.site.register(Experiment, ExperimentAdmin) diff --git a/backend/experiment/models.py b/backend/experiment/models.py index 14d3a798c..4eaf9f06f 100644 --- a/backend/experiment/models.py +++ b/backend/experiment/models.py @@ -114,19 +114,19 @@ class Block(models.Model): translated_content = models.ManyToManyField("BlockTranslatedContent", blank=True) playlists = models.ManyToManyField("section.Playlist", blank=True) - # to be deleted + # TODO: to be deleted name = models.CharField(db_index=True, max_length=64) - # # to be deleted + # 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]) - # to be deleted + # TODO: to be deleted url = models.CharField( verbose_name="URL with more information about the block", max_length=100, blank=True, default="" ) - # to be deleted + # TODO: to be deleted hashtag = models.CharField(verbose_name="hashtag for social media", max_length=20, blank=True, default="") active = models.BooleanField(default=True) @@ -134,12 +134,12 @@ class Block(models.Model): bonus_points = models.PositiveIntegerField(default=0) rules = models.CharField(default="", max_length=64) - # to be deleted + # TODO: to be deleted 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) - # to be deleted + # TODO: to be deleted consent = models.FileField( upload_to=consent_upload_path, blank=True, default="", validators=[markdown_html_validator()] ) diff --git a/backend/experiment/utils.py b/backend/experiment/utils.py index 805115007..57beaca2c 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,83 @@ 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): + # 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 missing_languages: + missing_content_blocks.append((block, missing_languages)) + + return missing_content_blocks + + +def check_missing_translations(experiment: Experiment): + 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)}") + + if not warnings: + return format_html( + 'No problems' + ) + + warnings_html = format_html( + ' Warning '.format( + "\n".join(warnings) + ) + ) + + return warnings_html From c50f5b03978f8f0ac421090de8469173d2b93db7 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Wed, 21 Aug 2024 16:14:08 +0200 Subject: [PATCH 05/23] refactor: Remove hashtag, url, consent & language from block --- backend/experiment/admin.py | 4 --- ...k_consent_remove_block_hashtag_and_more.py | 29 +++++++++++++++++++ backend/experiment/models.py | 15 ---------- 3 files changed, 29 insertions(+), 19 deletions(-) create mode 100644 backend/experiment/migrations/0055_remove_block_consent_remove_block_hashtag_and_more.py diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index c22407a6d..42d4b2527 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -79,17 +79,13 @@ class BlockAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): "description", "image", "slug", - "url", - "hashtag", "theme_config", - "language", "active", "rules", "rounds", "bonus_points", "playlists", "translated_content", - "consent", ] inlines = [QuestionSeriesInline, FeedbackInline] form = BlockForm 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 4eaf9f06f..68718cfe1 100644 --- a/backend/experiment/models.py +++ b/backend/experiment/models.py @@ -122,28 +122,13 @@ class Block(models.Model): 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]) - # TODO: to be deleted - url = models.CharField( - verbose_name="URL with more information about the block", max_length=100, blank=True, default="" - ) - # TODO: to be deleted - 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) - # TODO: to be deleted - 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) - # TODO: to be deleted - consent = models.FileField( - upload_to=consent_upload_path, blank=True, default="", validators=[markdown_html_validator()] - ) - def __str__(self): content = self.get_fallback_content() From e64223c4d5af2e36c337722d54300d7f065ade13 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Wed, 21 Aug 2024 20:09:18 +0200 Subject: [PATCH 06/23] refactor: Move warnings to remarks column --- backend/experiment/admin.py | 26 ++++++++++++++++---------- backend/experiment/utils.py | 15 ++++----------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index 42d4b2527..8aed000e6 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -270,7 +270,6 @@ class ExperimentAdmin(InlineActionsModelAdminMixin, NestedModelAdmin): "dashboard", "phases", "active", - "status", ) fields = [ "slug", @@ -383,13 +382,23 @@ 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) + + print(missing_content_block_translations) + + 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 @@ -400,15 +409,12 @@ def remarks(self, obj): return format_html( "\n".join( [ - f'{remark["message"]}' + f'{remark["message"]}
' for remark in remarks_array ] ) ) - def status(self, obj): - return check_missing_translations(obj) - def save_model(self, request, obj, form, change): # Check for missing translations missing_content_blocks = get_missing_content_blocks(obj) diff --git a/backend/experiment/utils.py b/backend/experiment/utils.py index 57beaca2c..5efb03565 100644 --- a/backend/experiment/utils.py +++ b/backend/experiment/utils.py @@ -110,7 +110,7 @@ def get_missing_content_blocks(experiment: Experiment) -> List[Tuple[Block, List return missing_content_blocks -def check_missing_translations(experiment: Experiment): +def check_missing_translations(experiment: Experiment) -> str: warnings = [] missing_content_blocks = get_missing_content_blocks(experiment) @@ -118,15 +118,8 @@ def check_missing_translations(experiment: Experiment): 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)}") - if not warnings: - return format_html( - 'No problems' - ) + warnings_text = "\n".join(warnings) - warnings_html = format_html( - ' Warning '.format( - "\n".join(warnings) - ) - ) + print(warnings_text) - return warnings_html + return warnings_text From 34dc7af9abba79037d48b049d05c5128abdaf46a Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Thu, 22 Aug 2024 16:32:01 +0200 Subject: [PATCH 07/23] fix: Fix many tests after removing consent/url/hashtags from block model --- backend/experiment/fixtures/experiment.json | 16 - backend/experiment/forms.py | 1 + .../management/commands/bootstrap.py | 19 +- .../commands/templates/experiment.py | 2 +- backend/experiment/models.py | 14 +- backend/experiment/rules/base.py | 103 +++--- backend/experiment/rules/hooked.py | 224 +++++++------ .../experiment/rules/musical_preferences.py | 306 +++++++++--------- backend/experiment/rules/tests/test_base.py | 49 +-- backend/experiment/rules/tests/test_hooked.py | 190 +++++------ .../rules/tests/test_musical_preferences.py | 72 ++--- .../rules/tests/test_rhythm_battery_final.py | 4 +- .../experiment/tests/test_admin_experiment.py | 9 +- backend/experiment/tests/test_model.py | 3 - .../experiment/tests/test_model_functions.py | 8 +- backend/experiment/tests/test_views.py | 18 +- backend/experiment/views.py | 2 +- backend/question/models.py | 14 +- backend/session/tests/test_views.py | 54 ++-- backend/session/views.py | 22 +- frontend/src/components/Consent/Consent.tsx | 2 +- .../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.jsx | 2 +- frontend/src/components/Playlist/Playlist.jsx | 2 +- frontend/src/components/Question/Question.tsx | 2 +- frontend/src/components/Score/Score.jsx | 2 +- frontend/src/components/Trial/Trial.tsx | 2 +- 29 files changed, 553 insertions(+), 595 deletions(-) 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 971b0a878..1ed51bce8 100644 --- a/backend/experiment/forms.py +++ b/backend/experiment/forms.py @@ -260,6 +260,7 @@ def clean_playlists(self): class Meta: model = Block fields = [ + "name", "slug", "active", "rules", 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 b26be140b..88cecff6f 100644 --- a/backend/experiment/management/commands/templates/experiment.py +++ b/backend/experiment/management/commands/templates/experiment.py @@ -11,7 +11,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/models.py b/backend/experiment/models.py index 68718cfe1..d9d7f120d 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 @@ -302,18 +302,6 @@ def add_default_question_series(self): question_series=qs, question=Question.objects.get(pk=question), index=i + 1 ) - # @property - # def name(self): - # """Get name of the block""" - # content = self.get_fallback_content() - # return content.name if content and content.name else self.slug - - # @property - # def description(self): - # """Get description of the block""" - # content = self.get_fallback_content() - # return content.description if content and content.description else "" - def get_fallback_content(self): """Get fallback content for the block""" if not self.phase or self.phase.experiment: diff --git a/backend/experiment/rules/base.py b/backend/experiment/rules/base.py index f222bcfed..752b17df8 100644 --- a/backend/experiment/rules/base.py +++ b/backend/experiment/rules/base.py @@ -24,22 +24,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): @@ -52,11 +48,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 @@ -77,10 +77,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): @@ -90,21 +87,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) @@ -113,11 +108,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. @@ -125,54 +120,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_questionnaire(self, session, randomize=False, cutoff_index=None): - ''' Get a list of questions to be asked in succession ''' + """Get a list of questions to be asked in succession""" 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/hooked.py b/backend/experiment/rules/hooked.py index 5ee0295e5..188748f94 100644 --- a/backend/experiment/rules/hooked.py +++ b/backend/experiment/rules/hooked.py @@ -21,9 +21,10 @@ class Hooked(Base): """Superclass for Hooked experiment rules""" - ID = 'HOOKED' - consent_file = 'consent/consent_hooked.html' + ID = "HOOKED" + + 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,19 +32,31 @@ 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 first_round(self, block): @@ -53,24 +66,30 @@ def first_round(self, block): explainer = 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!"), + ) # 2. Add consent from file or admin (admin has priority) consent = Consent( - block.consent, - title=_('Informed consent'), - confirm=_('I agree'), - deny=_('Stop'), - url=self.consent_file - ) + block.consent, title=_("Informed consent"), confirm=_("I agree"), deny=_("Stop"), url=self.consent_file + ) # 3. Choose playlist. playlist = Playlist(block.playlists.all()) @@ -88,7 +107,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() @@ -101,14 +119,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 @@ -125,31 +142,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 @@ -162,7 +175,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""" @@ -174,15 +188,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 @@ -192,18 +206,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 @@ -211,55 +225,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: @@ -267,49 +287,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, @@ -319,11 +341,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 ade950f7e..d8da86952 100644 --- a/backend/experiment/rules/musical_preferences.py +++ b/backend/experiment/rules/musical_preferences.py @@ -19,50 +19,43 @@ class MusicalPreferences(Base): - ID = 'MUSICAL_PREFERENCES' - consent_file = 'consent/consent_musical_preferences.html' + ID = "MUSICAL_PREFERENCES" + 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 first_round(self, block): consent = Consent( block.consent, - title=_('Informed consent'), - confirm=_('I consent and continue.'), - deny=_('I do not consent.'), - url=self.consent_file + title=_("Informed consent"), + confirm=_("I consent and continue."), + deny=_("I do not consent."), + url=self.consent_file, ) playlist = Playlist(block.playlists.all()) explainer = 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"), ) return [ consent, @@ -83,47 +76,53 @@ 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: 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"), ) return [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, delete session and redirect session.finish() @@ -131,162 +130,165 @@ 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 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 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 33f47d5a0..5a28f1047 100644 --- a/backend/experiment/rules/tests/test_hooked.py +++ b/backend/experiment/rules/tests/test_hooked.py @@ -3,7 +3,7 @@ from django.test import TestCase 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), 3) - 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,87 +246,70 @@ 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: assert len(actions) == 3 - assert actions[0].feedback_form.form[0].key == 'dgf_generation' - assert actions[1].feedback_form.form[0].key == 'dgf_gender_identity' - assert actions[2].feedback_form.form[0].key == 'playlist_decades' - result = Result.objects.get( - session=session, - question_key='playlist_decades' - ) - result.given_response = '1960s,1970s,1980s' + assert actions[0].feedback_form.form[0].key == "dgf_generation" + assert actions[1].feedback_form.form[0].key == "dgf_gender_identity" + assert actions[2].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()) question_trials = rules.get_questionnaire(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 582545846..5b906fe9c 100644 --- a/backend/experiment/rules/tests/test_musical_preferences.py +++ b/backend/experiment/rules/tests/test_musical_preferences.py @@ -1,9 +1,8 @@ from django.test import TestCase -from django.db.models import Avg from experiment.rules.musical_preferences import MusicalPreferences -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 @@ -11,68 +10,50 @@ 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" ) + cls.phase = Phase.objects.create(experiment=cls.experiment) + 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) 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) @@ -80,4 +61,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 710956ec2..7a05e6fa3 100644 --- a/backend/experiment/rules/tests/test_rhythm_battery_final.py +++ b/backend/experiment/rules/tests/test_rhythm_battery_final.py @@ -1,8 +1,8 @@ from django.test import TestCase -from django.core.files.uploadedfile import SimpleUploadedFile from experiment.actions import Explainer from experiment.models import Experiment, ExperimentTranslatedContent, Block from experiment.rules.rhythm_battery_final import RhythmBatteryFinal +from django.core.files.uploadedfile import SimpleUploadedFile class TestRhythmBatteryFinal(TestCase): @@ -18,7 +18,7 @@ def setUpTestData(cls): ) Block.objects.create( name="test_md", - slug="MARKDOWN_BLOCK", + slug="MARKDOWN", ) def test_init(self): diff --git a/backend/experiment/tests/test_admin_experiment.py b/backend/experiment/tests/test_admin_experiment.py index 7a17ff26f..0686c949c 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 = 14 EXPECTED_SESSION_FIELDS = 9 EXPECTED_RESULT_FIELDS = 12 EXPECTED_PARTICIPANT_FIELDS = 5 @@ -193,6 +193,7 @@ def test_experiment_admin_list_display(self): "dashboard", "phases", "active", + "status", ), ) @@ -228,7 +229,7 @@ def setUp(self): def test_related_experiment_with_experiment(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 +244,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 +255,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 fb269a767..83264adfd 100644 --- a/backend/experiment/tests/test_views.py +++ b/backend/experiment/tests/test_views.py @@ -32,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): @@ -190,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", @@ -205,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( @@ -233,7 +238,9 @@ def test_serialize_block(self): ) def test_get_block(self): - # Create an block + # Create a block + experiment = Experiment.objects.create(slug="test-experiment") + phase = Phase.objects.create(experiment=experiment) block = Block.objects.create( slug="test-block", name="Test Block", @@ -243,6 +250,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/views.py b/backend/experiment/views.py index 381fc8d8c..3e8e150df 100644 --- a/backend/experiment/views.py +++ b/backend/experiment/views.py @@ -129,7 +129,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/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/Consent/Consent.tsx b/frontend/src/components/Consent/Consent.tsx index 8f0a77bb3..656d80f4a 100644 --- a/frontend/src/components/Consent/Consent.tsx +++ b/frontend/src/components/Consent/Consent.tsx @@ -18,7 +18,7 @@ export interface ConsentProps { deny: string; } -/** Consent is an block view that shows the consent text, and handles agreement/stop actions */ +/** Consent is a block view that shows the consent text, and handles agreement/stop actions */ const Consent = ({ title, text, block, participant, onNext, confirm, deny }: ConsentProps) => { const [consent, loadingConsent] = useConsent(block.slug); const urlQueryString = window.location.search; diff --git a/frontend/src/components/Explainer/Explainer.tsx b/frontend/src/components/Explainer/Explainer.tsx index 8137f6f79..046bb734f 100644 --- a/frontend/src/components/Explainer/Explainer.tsx +++ b/frontend/src/components/Explainer/Explainer.tsx @@ -15,7 +15,7 @@ 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 39f24a316..88477e105 100644 --- a/frontend/src/components/Final/Final.tsx +++ b/frontend/src/components/Final/Final.tsx @@ -46,7 +46,7 @@ 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 b8de15166..426bbcde3 100644 --- a/frontend/src/components/Info/Info.tsx +++ b/frontend/src/components/Info/Info.tsx @@ -10,7 +10,7 @@ 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.jsx b/frontend/src/components/Loading/Loading.jsx index 88b566462..c16d284c7 100644 --- a/frontend/src/components/Loading/Loading.jsx +++ b/frontend/src/components/Loading/Loading.jsx @@ -3,7 +3,7 @@ import React from "react"; import Circle from "../Circle/Circle"; /** - * 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 }) => { diff --git a/frontend/src/components/Playlist/Playlist.jsx b/frontend/src/components/Playlist/Playlist.jsx index 0c4746439..67dbc82bd 100644 --- a/frontend/src/components/Playlist/Playlist.jsx +++ b/frontend/src/components/Playlist/Playlist.jsx @@ -1,7 +1,7 @@ import React, { useEffect } from "react"; /** - * 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 }) => { 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/Score/Score.jsx b/frontend/src/components/Score/Score.jsx index 29223ddf4..5c7fc28c4 100644 --- a/frontend/src/components/Score/Score.jsx +++ b/frontend/src/components/Score/Score.jsx @@ -3,7 +3,7 @@ import classNames from "classnames"; import Circle from "../Circle/Circle"; import Button from "../Button/Button"; -/** Score is an block view that shows an intermediate and total score */ +/** Score is a block view that shows an intermediate and total score */ const Score = ({ last_song, score, diff --git a/frontend/src/components/Trial/Trial.tsx b/frontend/src/components/Trial/Trial.tsx index 9d07551cd..c5cc05b92 100644 --- a/frontend/src/components/Trial/Trial.tsx +++ b/frontend/src/components/Trial/Trial.tsx @@ -28,7 +28,7 @@ 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 From 409cb2462268e022addae24223f4a3ad9c3598b2 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Thu, 22 Aug 2024 16:33:05 +0200 Subject: [PATCH 08/23] doc: Update consent's doc string --- backend/experiment/actions/consent.py | 30 +++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/backend/experiment/actions/consent.py b/backend/experiment/actions/consent.py index 732913c45..f3d2a6d2d 100644 --- a/backend/experiment/actions/consent.py +++ b/backend/experiment/actions/consent.py @@ -11,13 +11,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 +26,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 +50,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 +63,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, 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 From 1ddce0c5e31d4c577e95af40a91e4b06f2c63342 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Thu, 22 Aug 2024 16:43:14 +0200 Subject: [PATCH 09/23] fix: Fix minor test --- backend/experiment/tests/test_admin_experiment.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/experiment/tests/test_admin_experiment.py b/backend/experiment/tests/test_admin_experiment.py index 0686c949c..988765158 100644 --- a/backend/experiment/tests/test_admin_experiment.py +++ b/backend/experiment/tests/test_admin_experiment.py @@ -193,7 +193,6 @@ def test_experiment_admin_list_display(self): "dashboard", "phases", "active", - "status", ), ) From 6de84756db986483d1adf69e5496a9f4e5c7e937 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Thu, 22 Aug 2024 16:59:52 +0200 Subject: [PATCH 10/23] fix: Fix remainder of tests --- backend/experiment/actions/consent.py | 3 +- .../commands/templates/experiment.py | 2 + backend/experiment/models.py | 17 +- backend/experiment/rules/categorization.py | 372 +++++++++--------- backend/experiment/rules/hooked.py | 4 +- .../rules/tests/test_musical_preferences.py | 4 +- backend/experiment/tests/test_views.py | 8 + 7 files changed, 221 insertions(+), 189 deletions(-) diff --git a/backend/experiment/actions/consent.py b/backend/experiment/actions/consent.py index f3d2a6d2d..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 @@ -63,7 +64,7 @@ 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 != "": # Uploaded consent via file field: block.consent (High priority) diff --git a/backend/experiment/management/commands/templates/experiment.py b/backend/experiment/management/commands/templates/experiment.py index 88cecff6f..a3facc5b2 100644 --- a/backend/experiment/management/commands/templates/experiment.py +++ b/backend/experiment/management/commands/templates/experiment.py @@ -32,12 +32,14 @@ def __init__(self): }, ] + # FIXME: We don't use first_round anymore so we can remove it def first_round(self, block): ''' Provide the first rounds of the block, before session creation The first_round must return at least one Info or Explainer action Consent and Playlist are often desired, but optional ''' + # 1. Informed consent (optional) consent = Consent(block.consent, title=_('Informed consent'), diff --git a/backend/experiment/models.py b/backend/experiment/models.py index d9d7f120d..557b2909a 100644 --- a/backend/experiment/models.py +++ b/backend/experiment/models.py @@ -84,6 +84,11 @@ 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="") @@ -114,9 +119,9 @@ class Block(models.Model): translated_content = models.ManyToManyField("BlockTranslatedContent", blank=True) playlists = models.ManyToManyField("section.Playlist", blank=True) - # TODO: to be deleted + # TODO: to be deleted? name = models.CharField(db_index=True, max_length=64) - # TODO: to be deleted + # TODO: to be deleted? description = models.TextField(blank=True, default="") image = models.ForeignKey(Image, on_delete=models.SET_NULL, blank=True, null=True) @@ -130,6 +135,9 @@ class Block(models.Model): theme_config = models.ForeignKey(ThemeConfig, on_delete=models.SET_NULL, blank=True, null=True) def __str__(self): + if self.name: + return self.name + content = self.get_fallback_content() return content.name if content and content.name else self.slug @@ -330,6 +338,11 @@ 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 ExperimentTranslatedContent(models.Model): experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE, related_name="translated_content") diff --git a/backend/experiment/rules/categorization.py b/backend/experiment/rules/categorization.py index a8fd9294e..d9c4464c6 100644 --- a/backend/experiment/rules/categorization.py +++ b/backend/experiment/rules/categorization.py @@ -18,14 +18,14 @@ class Categorization(Base): - ID = 'CATEGORIZATION' + ID = "CATEGORIZATION" 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, }, ] @@ -33,16 +33,20 @@ def first_round(self, block): explainer = Explainer( instruction="This is a listening experiment in which you have to respond to short sound sequences.", steps=[], - button_label='Ok' + button_label="Ok", ) + + experiment_translated_content = block.phase.experiment.get_current_content() + consent_text = experiment_translated_content.consent + # Add consent from file or admin (admin has priority) consent = Consent( - block.consent, - title='Informed consent', - confirm='I agree', - deny='Stop', - url='consent/consent_categorization.html' - ) + consent_text, + title="Informed consent", + confirm="I agree", + deny="Stop", + url="consent/consent_categorization.html", + ) return [consent, explainer] def next_round(self, session): @@ -53,12 +57,12 @@ def next_round(self, 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( @@ -68,8 +72,8 @@ def next_round(self, 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( @@ -79,11 +83,10 @@ def next_round(self, session): return final # Calculate round number from passed training rounds - get_rounds_passed = (session.get_rounds_passed() - - int(json_data['training_rounds'])) + get_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() @@ -92,15 +95,13 @@ def next_round(self, 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, @@ -109,58 +110,58 @@ def next_round(self, 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 get_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 get_rounds_passed < len(json_data['sequence']): + elif get_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 @@ -168,10 +169,9 @@ def next_round(self, 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, @@ -179,26 +179,25 @@ def next_round(self, 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 get_rounds_passed < len(json_data['sequence']): + elif json_data["phase"] == "testing": + if get_rounds_passed < len(json_data["sequence"]): # Determine wether this round has feedback - if get_rounds_passed in json_data['feedback_sequence']: + if get_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) @@ -206,27 +205,27 @@ def next_round(self, 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() @@ -235,15 +234,14 @@ def next_round(self, 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 @@ -258,17 +256,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() @@ -276,77 +275,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() @@ -355,60 +349,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 @@ -425,16 +431,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() @@ -442,22 +448,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) @@ -470,44 +474,44 @@ def next_trial_action(self, session): json_data = session.load_json_data() # Retrieve next section in the sequence - get_rounds_passed = (session.get_rounds_passed() - - int(json_data['training_rounds'])) - sequence = json_data['sequence'] + get_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() - get_rounds_passed = (session.get_rounds_passed() - - int(json_data['training_rounds'])+1) + get_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 188748f94..e35371e65 100644 --- a/backend/experiment/rules/hooked.py +++ b/backend/experiment/rules/hooked.py @@ -87,8 +87,10 @@ def first_round(self, block): ) # 2. Add consent from file or admin (admin has priority) + experiment_content = block.phase.experiment.get_current_content() + consent_file = experiment_content.consent consent = Consent( - block.consent, title=_("Informed consent"), confirm=_("I agree"), deny=_("Stop"), url=self.consent_file + consent_file, title=_("Informed consent"), confirm=_("I agree"), deny=_("Stop"), url=self.consent_file ) # 3. Choose playlist. diff --git a/backend/experiment/rules/tests/test_musical_preferences.py b/backend/experiment/rules/tests/test_musical_preferences.py index 5b906fe9c..4c49a05a8 100644 --- a/backend/experiment/rules/tests/test_musical_preferences.py +++ b/backend/experiment/rules/tests/test_musical_preferences.py @@ -31,7 +31,9 @@ def setUpTestData(cls): experiment=cls.experiment, url="https://app.amsterdammusiclab.nl/mpref" ) cls.phase = Phase.objects.create(experiment=cls.experiment) - cls.block = Block.objects.create(name="MusicalPreferences", rules="MUSICAL_PREFERENCES", rounds=5) + 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) def test_preferred_songs(self): diff --git a/backend/experiment/tests/test_views.py b/backend/experiment/tests/test_views.py index 83264adfd..f0e5a2499 100644 --- a/backend/experiment/tests/test_views.py +++ b/backend/experiment/tests/test_views.py @@ -2,6 +2,7 @@ from django.test import TestCase from django.utils import timezone from django.utils.translation import activate, get_language +from django.core.files.uploadedfile import SimpleUploadedFile from image.models import Image from experiment.serializers import serialize_block, serialize_phase @@ -240,6 +241,13 @@ def test_serialize_block(self): def test_get_block(self): # 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", From 6ee07b6a3fa8e4058bae74574794dff172632804 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Fri, 23 Aug 2024 11:24:59 +0200 Subject: [PATCH 11/23] fix: Validating the model before saving the model and its relations results into errors as the validator looks for translated content using the FKs --- backend/experiment/admin.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index 8aed000e6..1f25becc2 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -416,7 +416,10 @@ def remarks(self, obj): ) def save_model(self, request, obj, form, change): - # Check for missing translations + # 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: @@ -428,8 +431,6 @@ def save_model(self, request, obj, form, change): level=messages.WARNING, ) - super().save_model(request, obj, form, change) - admin.site.register(Experiment, ExperimentAdmin) From 80d930a32752a7bc1db2e7089427bfeee23fdec6 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Fri, 23 Aug 2024 11:40:13 +0200 Subject: [PATCH 12/23] refactor: Handle missing language in content --- backend/experiment/admin.py | 2 -- backend/experiment/utils.py | 9 ++++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index 1f25becc2..60031f6fc 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -386,8 +386,6 @@ def remarks(self, obj): missing_content_block_translations = check_missing_translations(obj) - print(missing_content_block_translations) - if missing_content_block_translations: remarks_array.append( { diff --git a/backend/experiment/utils.py b/backend/experiment/utils.py index 5efb03565..f76f273b5 100644 --- a/backend/experiment/utils.py +++ b/backend/experiment/utils.py @@ -64,6 +64,10 @@ def format_label(number, label_style): 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() @@ -104,7 +108,7 @@ def get_missing_content_blocks(experiment: Experiment) -> List[Tuple[Block, List if not block_content: missing_languages.append(language) - if missing_languages: + if len(missing_languages) > 0: missing_content_blocks.append((block, missing_languages)) return missing_content_blocks @@ -114,12 +118,11 @@ 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) - print(warnings_text) - return warnings_text From b069e3ca1bba72565f38df1c9e9360302750f31e Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Wed, 28 Aug 2024 11:11:00 +0200 Subject: [PATCH 13/23] fix: Re-add Hooked ID --- backend/experiment/rules/hooked.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/experiment/rules/hooked.py b/backend/experiment/rules/hooked.py index 042515b9f..bf85f3530 100644 --- a/backend/experiment/rules/hooked.py +++ b/backend/experiment/rules/hooked.py @@ -22,6 +22,7 @@ class Hooked(Base): """Superclass for Hooked experiment rules""" + 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?" From 6cda4e1ea2ff557241f79014dc532e543bbbcb28 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Wed, 28 Aug 2024 12:36:30 +0200 Subject: [PATCH 14/23] 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 --- backend/experiment/admin.py | 18 +++++++++--- ...ename_series_phase_experiments_and_more.py | 16 ++++++---- .../migrations/0054_migrate_block_content.py | 29 +++++++++---------- backend/experiment/models.py | 14 ++++++--- .../experiment/tests/test_admin_experiment.py | 3 +- 5 files changed, 51 insertions(+), 29 deletions(-) diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index 60031f6fc..414cbc17a 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -85,7 +85,7 @@ class BlockAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): "rounds", "bonus_points", "playlists", - "translated_content", + "translated_contents", ] inlines = [QuestionSeriesInline, FeedbackInline] form = BlockForm @@ -230,9 +230,19 @@ def block_slug_link(self, obj): admin.site.register(Block, BlockAdmin) +class BlockTranslatedContentInline(NestedStackedInline): + model = BlockTranslatedContent + + def get_extra(self, request, obj=None, **kwargs): + if obj: + return 0 + return 1 + + class BlockInline(NestedStackedInline): model = Block sortable_field_name = "index" + inlines = [BlockTranslatedContentInline] def get_extra(self, request, obj=None, **kwargs): if obj: @@ -472,16 +482,16 @@ def blocks(self, obj): @admin.register(BlockTranslatedContent) class BlockTranslatedContentAdmin(admin.ModelAdmin): - list_display = ["name", "blocks", "language"] + list_display = ["name", "block", "language"] list_filter = ["language"] search_fields = [ "name", - "blocks__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_content=obj) + blocks = Block.objects.filter(translated_contents=obj) if not blocks: return "No block" 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 index 849ed0d0f..69d87d4ac 100644 --- 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 @@ -218,11 +218,17 @@ class Migration(migrations.Migration): ), ("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", + ), + ), ], - ), - migrations.AddField( - model_name="block", - name="translated_content", - field=models.ManyToManyField(blank=True, to="experiment.blocktranslatedcontent"), + 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 index 2ec78de42..32ccf2b39 100644 --- a/backend/experiment/migrations/0054_migrate_block_content.py +++ b/backend/experiment/migrations/0054_migrate_block_content.py @@ -11,14 +11,14 @@ def migrate_block_content(apps, schema_editor): ExperimentTranslatedContent = apps.get_model("experiment", "ExperimentTranslatedContent") for block in Block.objects.all(): - language = block.language if block.language else "en" + language = block.language if hasattr(block, "language") and block.language else "en" - block_translated_content = BlockTranslatedContent.objects.create( + BlockTranslatedContent.objects.create( + block=block, language=language, name=block.name, description=block.description, ) - block.translated_content.add(block_translated_content) if block.phase: continue @@ -34,12 +34,14 @@ def migrate_block_content(apps, schema_editor): language=language, name=block.name, description=block.description, - consent=block.consent if block.consent else "", + consent=block.consent if hasattr(block, "consent") and block.consent else "", ) - Phase.objects.create( + phase = Phase.objects.create( experiment=experiment, index=0, ) + block.phase = phase + block.save() def reverse_migrate_block_content(apps, schema_editor): @@ -49,19 +51,19 @@ def reverse_migrate_block_content(apps, schema_editor): ExperimentTranslatedContent = apps.get_model("experiment", "ExperimentTranslatedContent") for block in Block.objects.all(): - block_fallback_content = BlockTranslatedContent.objects.filter(block=block).first() + block_fallback_content = block.translated_contents.first() phase = block.phase - experiment = Experiment.objects.filter(phases=phase).first() + 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 = BlockTranslatedContent.objects.filter( - language=language, block=block - ).first() + possible_block_fallback_content = block.translated_contents.filter(language=language).first() if possible_block_fallback_content: block_fallback_content = possible_block_fallback_content @@ -70,11 +72,8 @@ def reverse_migrate_block_content(apps, schema_editor): 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 "" - block.consent = ( - experiment_fallback_content.consent - if experiment_fallback_content and experiment_fallback_content.consent - else "" - ) + if experiment_fallback_content and experiment_fallback_content.consent: + block.consent = experiment_fallback_content.consent block.save() BlockTranslatedContent.objects.all().delete() diff --git a/backend/experiment/models.py b/backend/experiment/models.py index 557b2909a..72fcd92a5 100644 --- a/backend/experiment/models.py +++ b/backend/experiment/models.py @@ -116,7 +116,6 @@ 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.") - translated_content = models.ManyToManyField("BlockTranslatedContent", blank=True) playlists = models.ManyToManyField("section.Playlist", blank=True) # TODO: to be deleted? @@ -132,6 +131,8 @@ class Block(models.Model): bonus_points = models.PositiveIntegerField(default=0) rules = models.CharField(default="", max_length=64) + translated_contents = models.QuerySet["BlockTranslatedContent"] + theme_config = models.ForeignKey(ThemeConfig, on_delete=models.SET_NULL, blank=True, null=True) def __str__(self): @@ -313,17 +314,17 @@ def add_default_question_series(self): def get_fallback_content(self): """Get fallback content for the block""" if not self.phase or self.phase.experiment: - return self.translated_content.first() + return self.translated_contents.first() experiment = self.phase.experiment fallback_language = experiment.get_fallback_content().language - fallback_content = self.translated_content.filter(language=fallback_language).first() + 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_content.filter(language=language).first() + content = self.translated_contents.filter(language=language).first() if not content and fallback: fallback_content = self.get_fallback_content() @@ -357,6 +358,7 @@ class ExperimentTranslatedContent(models.Model): 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="") @@ -364,6 +366,10 @@ class BlockTranslatedContent(models.Model): 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() diff --git a/backend/experiment/tests/test_admin_experiment.py b/backend/experiment/tests/test_admin_experiment.py index 988765158..a3338a24c 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 = 14 +EXPECTED_BLOCK_FIELDS = 13 EXPECTED_SESSION_FIELDS = 9 EXPECTED_RESULT_FIELDS = 12 EXPECTED_PARTICIPANT_FIELDS = 5 @@ -44,6 +44,7 @@ def setUp(self): def test_block_model_fields(self): block = model_to_dict(Block.objects.first()) block_fields = [key for key in block] + print(block_fields) self.assertEqual(len(block_fields), EXPECTED_BLOCK_FIELDS) def test_session_model_fields(self): From 4731ea708ea305d29bbf8f5823553d61ec3f03b8 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Wed, 28 Aug 2024 16:02:21 +0200 Subject: [PATCH 15/23] refactor: Use tabular inline for block translated content --- backend/experiment/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index 414cbc17a..376735e5a 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -230,7 +230,7 @@ def block_slug_link(self, obj): admin.site.register(Block, BlockAdmin) -class BlockTranslatedContentInline(NestedStackedInline): +class BlockTranslatedContentInline(NestedTabularInline): model = BlockTranslatedContent def get_extra(self, request, obj=None, **kwargs): From d7865c8a288f5afc4626a34d60e1ec47bfb4ff9d Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Wed, 28 Aug 2024 16:30:06 +0200 Subject: [PATCH 16/23] fix: Fix border radius top left in markdown input --- .../templates/widgets/markdown_preview_text_input.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 { From d0284d635e3d24f53138136b0db397368258d0c3 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Wed, 28 Aug 2024 16:30:40 +0200 Subject: [PATCH 17/23] feat: Add all necessary fields for experiment translated content to its form --- backend/experiment/admin.py | 3 +++ backend/experiment/forms.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index 376735e5a..aac96c0a7 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -31,6 +31,7 @@ from question.admin import QuestionSeriesInline from experiment.forms import ( ExperimentForm, + ExperimentTranslatedContentForm, BlockForm, ExportForm, TemplateForm, @@ -53,6 +54,7 @@ class FeedbackInline(admin.TabularInline): class ExperimentTranslatedContentInline(NestedStackedInline): model = ExperimentTranslatedContent sortable_field_name = "index" + form = ExperimentTranslatedContentForm def get_extra(self, request, obj=None, **kwargs): if obj: @@ -243,6 +245,7 @@ class BlockInline(NestedStackedInline): model = Block sortable_field_name = "index" inlines = [BlockTranslatedContentInline] + form = BlockForm def get_extra(self, request, obj=None, **kwargs): if obj: diff --git a/backend/experiment/forms.py b/backend/experiment/forms.py index 1ed51bce8..7895e73d5 100644 --- a/backend/experiment/forms.py +++ b/backend/experiment/forms.py @@ -213,6 +213,11 @@ def __init__(self, *args, **kwargs): class Meta: model = ExperimentTranslatedContent fields = [ + "index", + "language", + "name", + "description", + "consent", "about_content", ] @@ -260,6 +265,7 @@ def clean_playlists(self): class Meta: model = Block fields = [ + "index", "name", "slug", "active", From a3adcf0623428f5f20c7e86471e55bfc935827ca Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Wed, 28 Aug 2024 16:31:12 +0200 Subject: [PATCH 18/23] chore: Remove unnecessary print statement --- backend/experiment/tests/test_admin_experiment.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/experiment/tests/test_admin_experiment.py b/backend/experiment/tests/test_admin_experiment.py index a3338a24c..e4208c46b 100644 --- a/backend/experiment/tests/test_admin_experiment.py +++ b/backend/experiment/tests/test_admin_experiment.py @@ -44,7 +44,6 @@ def setUp(self): def test_block_model_fields(self): block = model_to_dict(Block.objects.first()) block_fields = [key for key in block] - print(block_fields) self.assertEqual(len(block_fields), EXPECTED_BLOCK_FIELDS) def test_session_model_fields(self): From 9c5bae0be84d7414410bc8fb1363eebbe7b1796d Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Fri, 30 Aug 2024 12:09:58 +0200 Subject: [PATCH 19/23] fix: Add `BlockTranslatedContentInline` inline to `BlockAdmin` --- backend/experiment/admin.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index aac96c0a7..116c983b9 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -51,6 +51,15 @@ 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" @@ -87,9 +96,8 @@ class BlockAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): "rounds", "bonus_points", "playlists", - "translated_contents", ] - inlines = [QuestionSeriesInline, FeedbackInline] + inlines = [QuestionSeriesInline, FeedbackInline, BlockTranslatedContentInline] form = BlockForm # make playlists fields a list of checkboxes @@ -232,15 +240,6 @@ def block_slug_link(self, obj): admin.site.register(Block, BlockAdmin) -class BlockTranslatedContentInline(NestedTabularInline): - model = BlockTranslatedContent - - def get_extra(self, request, obj=None, **kwargs): - if obj: - return 0 - return 1 - - class BlockInline(NestedStackedInline): model = Block sortable_field_name = "index" From fd57274f4316303611979b2b9b406187ecce25aa Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Fri, 30 Aug 2024 12:25:51 +0200 Subject: [PATCH 20/23] 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 --- backend/experiment/static/block_admin.js | 66 +++++++++++++++++------- 1 file changed, 48 insertions(+), 18 deletions(-) diff --git a/backend/experiment/static/block_admin.js b/backend/experiment/static/block_admin.js index c4c397f36..9fa00f9df 100644 --- a/backend/experiment/static/block_admin.js +++ b/backend/experiment/static/block_admin.js @@ -1,34 +1,37 @@ document.addEventListener("DOMContentLoaded", (event) => { + fixHeadings(); + // 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 +42,34 @@ document.addEventListener("DOMContentLoaded", (event) => { { method: "POST", mode: 'same-origin', headers: { 'X-CSRFToken': csrftoken } }) if (response.ok) { - location.reload() + location.reload(); } } }) + +/** Function to fix the headings for tabular inline forms + * @todo TODO: Remove this `fixHeadings` function once the issue with headings is fixed in `django-nested-admin`. + * - https://github.com/theatlantic/django-nested-admin/issues/261 + * - https://github.com/theatlantic/django-nested-admin/pull/259 + */ +function fixHeadings() { + // Find the h2 element + const h2Elements = document.querySelectorAll('.tabular h2.inline-heading'); + + for (const h2Element of h2Elements) { + + // Get the next sibling node (which should be the text node) + const textNode = h2Element.nextSibling; + + console.log(h2Element, textNode); + + // Check if the next sibling is a text node and contains non-whitespace content + if (textNode && textNode.nodeType === Node.TEXT_NODE && textNode.textContent.trim()) { + // Move the text content into the h2 element + h2Element.textContent = textNode.textContent.trim() + h2Element.textContent; + + // Remove the original text node + textNode.remove(); + } + } +} From 5947160938d783bc0b05184e8cc18575a9b2befc Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Mon, 2 Sep 2024 15:59:52 +0200 Subject: [PATCH 21/23] chore: Incorporate latest version of `django-nested-admin` that includes fix for the tabular inline form's heading --- backend/experiment/static/block_admin.js | 29 ------------------------ backend/requirements.in/base.txt | 2 +- backend/requirements/dev.txt | 2 +- backend/requirements/prod.txt | 2 +- 4 files changed, 3 insertions(+), 32 deletions(-) diff --git a/backend/experiment/static/block_admin.js b/backend/experiment/static/block_admin.js index 9fa00f9df..77a5c1d50 100644 --- a/backend/experiment/static/block_admin.js +++ b/backend/experiment/static/block_admin.js @@ -1,8 +1,6 @@ document.addEventListener("DOMContentLoaded", (event) => { - fixHeadings(); - // Get experiment id from URL match = window.location.href.match(/\/experiment\/block\/(.+)\/change/); experiment_id = match && match[1]; @@ -46,30 +44,3 @@ document.addEventListener("DOMContentLoaded", (event) => { } } }) - -/** Function to fix the headings for tabular inline forms - * @todo TODO: Remove this `fixHeadings` function once the issue with headings is fixed in `django-nested-admin`. - * - https://github.com/theatlantic/django-nested-admin/issues/261 - * - https://github.com/theatlantic/django-nested-admin/pull/259 - */ -function fixHeadings() { - // Find the h2 element - const h2Elements = document.querySelectorAll('.tabular h2.inline-heading'); - - for (const h2Element of h2Elements) { - - // Get the next sibling node (which should be the text node) - const textNode = h2Element.nextSibling; - - console.log(h2Element, textNode); - - // Check if the next sibling is a text node and contains non-whitespace content - if (textNode && textNode.nodeType === Node.TEXT_NODE && textNode.textContent.trim()) { - // Move the text content into the h2 element - h2Element.textContent = textNode.textContent.trim() + h2Element.textContent; - - // Remove the original text node - textNode.remove(); - } - } -} 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 From bbd686c9f52eae4788837aa824a1bcb53a9f0b0c Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Mon, 2 Sep 2024 16:12:44 +0200 Subject: [PATCH 22/23] refactor: Rename test to be more descriptive --- backend/experiment/tests/test_admin_experiment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/experiment/tests/test_admin_experiment.py b/backend/experiment/tests/test_admin_experiment.py index e4208c46b..0f11de4e9 100644 --- a/backend/experiment/tests/test_admin_experiment.py +++ b/backend/experiment/tests/test_admin_experiment.py @@ -225,7 +225,7 @@ 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, experiment=experiment, dashboard=True) From 811821b0ddced37930a8ed604ac04d3da4c0dadd Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Wed, 4 Sep 2024 13:11:39 +0200 Subject: [PATCH 23/23] refactor: Incorporate the migration of #1240 --- .../migrations/0054_migrate_block_content.py | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/backend/experiment/migrations/0054_migrate_block_content.py b/backend/experiment/migrations/0054_migrate_block_content.py index 32ccf2b39..825903fd8 100644 --- a/backend/experiment/migrations/0054_migrate_block_content.py +++ b/backend/experiment/migrations/0054_migrate_block_content.py @@ -1,6 +1,8 @@ # 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): @@ -23,23 +25,28 @@ def migrate_block_content(apps, schema_editor): if block.phase: continue - # if block is not associated with a phase and experiment, - # create a new experiment and phase - experiment = Experiment.objects.create( - slug=block.slug, - ) - ExperimentTranslatedContent.objects.create( + # 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, - consent=block.consent if hasattr(block, "consent") and block.consent else "", - ) - phase = Phase.objects.create( - experiment=experiment, - index=0, ) + + # 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() @@ -49,6 +56,7 @@ def reverse_migrate_block_content(apps, schema_editor): 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() @@ -74,6 +82,16 @@ def reverse_migrate_block_content(apps, schema_editor): 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()