From 1240cc96c3c9cfea1c4dfc8fc4c92d3b41052a35 Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Thu, 11 Apr 2024 19:36:25 +0200 Subject: [PATCH 001/180] Add Question model --- backend/experiment/admin.py | 43 ++++- backend/experiment/forms.py | 13 +- .../management/commands/createquestions.py | 11 ++ .../commands/templates/experiment.py | 19 +- .../migrations/0027_add_question_model.py | 77 ++++++++ .../0028_add_question_model_data.py | 23 +++ backend/experiment/models.py | 77 +++++++- backend/experiment/questions/__init__.py | 91 +++++---- backend/experiment/questions/demographics.py | 25 +++ backend/experiment/questions/languages.py | 8 + backend/experiment/rules/base.py | 12 +- backend/experiment/rules/categorization.py | 11 +- backend/experiment/rules/gold_msi.py | 28 ++- backend/experiment/rules/hooked.py | 17 +- backend/experiment/rules/huang_2022.py | 35 ++-- backend/experiment/rules/matching_pairs.py | 18 +- .../experiment/rules/matching_pairs_icmpc.py | 10 +- .../experiment/rules/musical_preferences.py | 20 +- backend/experiment/rules/speech2song.py | 28 +-- backend/experiment/rules/tele_tunes.py | 8 +- backend/experiment/rules/thats_my_song.py | 8 +- .../experiment/rules/visual_matching_pairs.py | 18 +- .../experiment/static/experiment_admin.css | 25 +-- backend/experiment/static/experiment_admin.js | 173 ++++-------------- .../experiment/static/questionseries_admin.js | 50 +++++ backend/experiment/urls.py | 5 +- backend/experiment/views.py | 13 +- docker-compose-deploy.yml | 2 +- docker-compose.yaml | 2 +- 29 files changed, 560 insertions(+), 310 deletions(-) create mode 100644 backend/experiment/management/commands/createquestions.py create mode 100644 backend/experiment/migrations/0027_add_question_model.py create mode 100644 backend/experiment/migrations/0028_add_question_model_data.py create mode 100644 backend/experiment/static/questionseries_admin.js diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index 158e2ccae..cdda737d1 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -14,8 +14,8 @@ from inline_actions.admin import InlineActionsModelAdminMixin from django.urls import reverse from django.utils.html import format_html -from experiment.models import Experiment, ExperimentSeries, Feedback -from experiment.forms import ExperimentForm, ExportForm, TemplateForm, EXPORT_TEMPLATES +from experiment.models import Experiment, ExperimentSeries, Feedback, Question, QuestionGroup, QuestionSeries, QuestionInSeries +from experiment.forms import ExperimentForm, ExportForm, TemplateForm, EXPORT_TEMPLATES, QuestionSeriesAdminForm from section.models import Section, Song from result.models import Result from participant.models import Participant @@ -30,6 +30,41 @@ class FeedbackInline(admin.TabularInline): extra = 0 +class QuestionInSeriesInline(admin.TabularInline): + model = QuestionInSeries + extra = 0 + +class QuestionSeriesInline(admin.TabularInline): + model = QuestionSeries + extra = 0 + show_change_link = True + +class QuestionAdmin(admin.ModelAdmin): + def has_change_permission(self, request, obj=None): + return obj.editable if obj else False + +class QuestionGroupAdmin(admin.ModelAdmin): + formfield_overrides = { + models.ManyToManyField: {'widget': CheckboxSelectMultiple}, + } + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + + if obj and not obj.editable: + for field_name in form.base_fields: + form.base_fields[field_name].disabled = True + + return form + +class QuestionSeriesAdmin(admin.ModelAdmin): + inlines = [QuestionInSeriesInline] + form = QuestionSeriesAdminForm + +admin.site.register(Question, QuestionAdmin) +admin.site.register(QuestionGroup, QuestionGroupAdmin) +admin.site.register(QuestionSeries, QuestionSeriesAdmin) + class ExperimentAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): list_display = ('image_preview', 'experiment_link', 'rules', 'rounds', 'playlist_count', 'session_count', 'active') @@ -37,8 +72,8 @@ class ExperimentAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): search_fields = ['name'] inline_actions = ['export', 'export_csv'] fields = ['name', 'description', 'image', 'slug', 'url', 'hashtag', 'theme_config', 'language', 'active', 'rules', - 'rounds', 'bonus_points', 'playlists', 'consent', 'questions'] - inlines = [FeedbackInline] + 'rounds', 'bonus_points', 'playlists', 'consent'] + inlines = [QuestionSeriesInline, FeedbackInline] form = ExperimentForm # make playlists fields a list of checkboxes diff --git a/backend/experiment/forms.py b/backend/experiment/forms.py index 6b179d0a7..2673a6975 100644 --- a/backend/experiment/forms.py +++ b/backend/experiment/forms.py @@ -2,8 +2,6 @@ from experiment.models import Experiment from experiment.rules import EXPERIMENT_RULES -from django.forms import TypedMultipleChoiceField, CheckboxSelectMultiple -from .questions import QUESTIONS_CHOICES # session_keys for Export CSV SESSION_CHOICES = [('experiment_id', 'Experiment ID'), @@ -133,12 +131,6 @@ def __init__(self, *args, **kwargs): choices=sorted(choices) ) - self.fields['questions'] = TypedMultipleChoiceField( - choices=QUESTIONS_CHOICES, - widget=CheckboxSelectMultiple, - required=False - ) - class Meta: model = Experiment fields = ['name', 'slug', 'active', 'rules', @@ -178,3 +170,8 @@ class TemplateForm(Form): select_template = ChoiceField( widget=Select, choices=TEMPLATE_CHOICES) + + +class QuestionSeriesAdminForm(ModelForm): + class Media: + js = ["questionseries_admin.js"] diff --git a/backend/experiment/management/commands/createquestions.py b/backend/experiment/management/commands/createquestions.py new file mode 100644 index 000000000..a9288838d --- /dev/null +++ b/backend/experiment/management/commands/createquestions.py @@ -0,0 +1,11 @@ + +from django.core.management.base import BaseCommand + +from experiment.questions import create_default_questions + + +class Command(BaseCommand): + help = "Creates default questions and question groups in the database" + + def handle(self, *args, **options): + create_default_questions() diff --git a/backend/experiment/management/commands/templates/experiment.py b/backend/experiment/management/commands/templates/experiment.py index 5a9dd0ba7..20250753d 100644 --- a/backend/experiment/management/commands/templates/experiment.py +++ b/backend/experiment/management/commands/templates/experiment.py @@ -18,13 +18,18 @@ class NewExperimentRuleset(Base): def __init__(self): # Add your questions here - self.questions = [ - question_by_key('dgf_gender_identity'), - question_by_key('dgf_generation'), - question_by_key('dgf_musical_experience', EXTRA_DEMOGRAPHICS), - question_by_key('dgf_country_of_origin'), - question_by_key('dgf_education', drop_choices=[ - 'isced-2', 'isced-5']) + self.question_series = [ + { + "name": "Demographics", + "keys": [ + 'dgf_gender_identity', + 'dgf_generation', + 'dgf_musical_experience', + 'dgf_country_of_origin', + 'dgf_education_matching_pairs' + ], + "randomize": False + }, ] def first_round(self, experiment): diff --git a/backend/experiment/migrations/0027_add_question_model.py b/backend/experiment/migrations/0027_add_question_model.py new file mode 100644 index 000000000..cb6ef8573 --- /dev/null +++ b/backend/experiment/migrations/0027_add_question_model.py @@ -0,0 +1,77 @@ +# Generated by Django 3.2.25 on 2024-04-11 16:31 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiment', '0026_auto_20240319_1114'), + ] + + operations = [ + migrations.CreateModel( + name='Question', + fields=[ + ('key', models.CharField(max_length=128, primary_key=True, serialize=False)), + ('question', models.CharField(max_length=1024)), + ('editable', models.BooleanField(default=True, editable=False)), + ], + options={ + 'ordering': ['key'], + }, + ), + migrations.CreateModel( + name='QuestionInSeries', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('index', models.PositiveIntegerField()), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='experiment.question')), + ], + options={ + 'verbose_name_plural': 'Question In Series objects', + 'ordering': ['index'], + }, + ), + migrations.RemoveField( + model_name='experiment', + name='questions', + ), + migrations.CreateModel( + name='QuestionSeries', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='', max_length=128)), + ('index', models.PositiveIntegerField()), + ('randomize', models.BooleanField(default=False)), + ('experiment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='experiment.experiment')), + ('questions', models.ManyToManyField(through='experiment.QuestionInSeries', to='experiment.Question')), + ], + options={ + 'verbose_name_plural': 'Question Series', + 'ordering': ['index'], + }, + ), + migrations.AddField( + model_name='questioninseries', + name='question_series', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='experiment.questionseries'), + ), + migrations.CreateModel( + name='QuestionGroup', + fields=[ + ('key', models.CharField(max_length=128, primary_key=True, serialize=False)), + ('editable', models.BooleanField(default=True, editable=False)), + ('questions', models.ManyToManyField(to='experiment.Question')), + ], + options={ + 'verbose_name_plural': 'Question Groups', + 'ordering': ['key'], + }, + ), + migrations.AlterUniqueTogether( + name='questioninseries', + unique_together={('question_series', 'question')}, + ), + ] diff --git a/backend/experiment/migrations/0028_add_question_model_data.py b/backend/experiment/migrations/0028_add_question_model_data.py new file mode 100644 index 000000000..4f563f4bc --- /dev/null +++ b/backend/experiment/migrations/0028_add_question_model_data.py @@ -0,0 +1,23 @@ + +from django.db import migrations +from experiment.models import Experiment +from experiment.questions import create_default_questions + + +def add_default_question_series(apps, schema_editor): + + create_default_questions() + + for experiment in Experiment.objects.all(): + experiment.add_default_question_series() + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiment', '0027_add_question_model'), + ] + + operations = [ + migrations.RunPython(add_default_question_series, reverse_code=migrations.RunPython.noop), + ] diff --git a/backend/experiment/models.py b/backend/experiment/models.py index b21ac6baa..c6ee71fc7 100644 --- a/backend/experiment/models.py +++ b/backend/experiment/models.py @@ -5,7 +5,6 @@ from django.contrib.postgres.fields import ArrayField from typing import List, Dict, Tuple, Any from experiment.standards.iso_languages import ISO_LANGUAGES -from .questions import QUESTIONS_CHOICES, get_default_question_keys from theme.models import ThemeConfig from image.models import Image @@ -71,11 +70,6 @@ class Experiment(models.Model): blank=True, null=True ) - questions = ArrayField( - models.TextField(choices=QUESTIONS_CHOICES), - blank=True, - default=get_default_question_keys - ) consent = models.FileField(upload_to=consent_upload_path, blank=True, default='', @@ -239,6 +233,77 @@ def max_score(self): return 0 + def add_default_question_series(self): + """ Add default question_series to experiment""" + from experiment.rules import EXPERIMENT_RULES + for i,question_series in enumerate(EXPERIMENT_RULES[self.rules]().question_series): + qs = QuestionSeries.objects.create( + name = question_series['name'], + experiment = self, + index = i+1, + randomize = question_series['randomize']) + for i,question in enumerate(question_series['keys']): + qis = QuestionInSeries.objects.create( + question_series = qs, + question = Question.objects.get(pk=question), + index=i+1) + + class Feedback(models.Model): text = models.TextField() experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE) + + +class Question(models.Model): + + key = models.CharField(primary_key=True, max_length=128) + question = models.CharField(max_length=1024) + editable = models.BooleanField(default=True, editable=False) + + def __str__(self): + return "("+self.key+") "+ self.question + + class Meta: + ordering = ["key"] + + +class QuestionGroup(models.Model): + + key = models.CharField(primary_key=True, max_length=128) + questions = models.ManyToManyField(Question) + editable = models.BooleanField(default=True, editable=False) + + class Meta: + ordering = ["key"] + verbose_name_plural = "Question Groups" + + def __str__(self): + return self.key + + +class QuestionSeries(models.Model): + + name = models.CharField(default='', max_length=128) + experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE) + index = models.PositiveIntegerField() # index of QuestionSeries within Experiment + questions = models.ManyToManyField(Question, through='QuestionInSeries') + randomize = models.BooleanField(default=False) # randomize questions within QuestionSeries + + class Meta: + ordering = ["index"] + verbose_name_plural = "Question Series" + + def __str__(self): + return "QuestionSeries object ({}): {} questions".format(self.id, self.questioninseries_set.count()) + + +class QuestionInSeries(models.Model): + + question_series = models.ForeignKey(QuestionSeries, on_delete=models.CASCADE) + question = models.ForeignKey(Question, on_delete=models.CASCADE) + index = models.PositiveIntegerField() + + class Meta: + unique_together = ('question_series', 'question') + ordering = ["index"] + verbose_name_plural = "Question In Series objects" diff --git a/backend/experiment/questions/__init__.py b/backend/experiment/questions/__init__.py index 3bef215ed..4d8406881 100644 --- a/backend/experiment/questions/__init__.py +++ b/backend/experiment/questions/__init__.py @@ -1,45 +1,72 @@ -from .demographics import DEMOGRAPHICS, EXTRA_DEMOGRAPHICS -from .goldsmiths import MSI_F1_ACTIVE_ENGAGEMENT, MSI_F2_PERCEPTUAL_ABILITIES, MSI_F3_MUSICAL_TRAINING, MSI_F4_SINGING_ABILITIES, MSI_F5_EMOTIONS, MSI_OTHER -from .languages import LANGUAGE +from .demographics import DEMOGRAPHICS, EXTRA_DEMOGRAPHICS, DEMOGRAPHICS_OTHER +from .goldsmiths import MSI_F1_ACTIVE_ENGAGEMENT, MSI_F2_PERCEPTUAL_ABILITIES, MSI_F3_MUSICAL_TRAINING, MSI_F4_SINGING_ABILITIES, MSI_F5_EMOTIONS, MSI_OTHER, MSI_FG_GENERAL, MSI_ALL +from .languages import LANGUAGE, LANGUAGE_OTHER from .musicgens import MUSICGENS_17_W_VARIANTS from .stomp import STOMP from .tipi import TIPI from .other import OTHER +import random +from experiment.models import QuestionGroup, Question -# Label of the group as it will apear in the admin -QUESTION_GROUPS = [ ("DEMOGRAPHICS",DEMOGRAPHICS), - ("EXTRA_DEMOGRAPHICS",EXTRA_DEMOGRAPHICS), - ("MSI_F1_ACTIVE_ENGAGEMENT",MSI_F1_ACTIVE_ENGAGEMENT), - ("MSI_F2_PERCEPTUAL_ABILITIES",MSI_F2_PERCEPTUAL_ABILITIES), - ("MSI_F3_MUSICAL_TRAINING",MSI_F3_MUSICAL_TRAINING), - ("MSI_F4_SINGING_ABILITIES",MSI_F4_SINGING_ABILITIES), - ("MSI_F5_EMOTIONS",MSI_F5_EMOTIONS), - ("MSI_OTHER",MSI_OTHER), - ("LANGUAGE",LANGUAGE), - ("MUSICGENS_17_W_VARIANTS",MUSICGENS_17_W_VARIANTS), - ("STOMP",STOMP), - ("TIPI",TIPI), - ("OTHER",OTHER) -] - -QUESTIONS_ALL = [] -KEYS_ALL = [] -QUESTIONS_CHOICES = [] - -for question_group in QUESTION_GROUPS: - QUESTIONS_ALL.extend(question_group[1]) - KEYS_ALL.extend([question.key for question in question_group[1]]) - QUESTIONS_CHOICES.append( (question_group[0], [(q.key,"("+q.key+") "+q.question) for q in question_group[1]]) ) +# Default QuestionGroups used by command createquestions +QUESTION_GROUPS_DEFAULT = { "DEMOGRAPHICS" : DEMOGRAPHICS, + "EXTRA_DEMOGRAPHICS" : EXTRA_DEMOGRAPHICS, + "MSI_F1_ACTIVE_ENGAGEMENT" : MSI_F1_ACTIVE_ENGAGEMENT, + "MSI_F2_PERCEPTUAL_ABILITIES" : MSI_F2_PERCEPTUAL_ABILITIES, + "MSI_F3_MUSICAL_TRAINING" : MSI_F3_MUSICAL_TRAINING, + "MSI_F4_SINGING_ABILITIES" : MSI_F4_SINGING_ABILITIES, + "MSI_F5_EMOTIONS" : MSI_F5_EMOTIONS, + "MSI_OTHER" : MSI_OTHER, + "MSI_FG_GENERAL" : MSI_FG_GENERAL, + "MSI_ALL" : MSI_ALL, + "LANGUAGE" : LANGUAGE, + "MUSICGENS_17_W_VARIANTS" : MUSICGENS_17_W_VARIANTS, + "STOMP" : STOMP, + "STOMP20" : STOMP, + "TIPI" : TIPI, + "OTHER" : OTHER, + "DEMOGRAPHICS_OTHER" : DEMOGRAPHICS_OTHER, + "LANGUAGE_OTHER" : LANGUAGE_OTHER +} + +QUESTIONS = {} +QUESTION_GROUPS = {} + +for group, questions in QUESTION_GROUPS_DEFAULT.items(): + for question in questions: QUESTIONS[question.key] = question + QUESTION_GROUPS[group] = [ q.key for q in questions ] + + +def get_questions_from_series(questionseries_set): + + keys_all = [] + + for questionseries in questionseries_set: + keys = [qis.question.key for qis in questionseries.questioninseries_set.all()] + if questionseries.randomize: random.shuffle(keys) + keys_all.extend(keys) + + return [QUESTIONS[key] for key in keys_all] def get_default_question_keys(): + """ For backward compatibility. One of the migrations calls it""" return [] +def create_default_questions(): + """Creates default questions and question groups in the database""" + + for group_key, questions in QUESTION_GROUPS_DEFAULT.items(): -def get_questions_from_keys(keys): - """ Returns questions in the order of keys""" - return [QUESTIONS_ALL[KEYS_ALL.index(key)] for key in keys] + if not QuestionGroup.objects.filter(key = group_key).exists(): + group = QuestionGroup.objects.create(key = group_key, editable = False) + else: + group = QuestionGroup.objects.get(key = group_key) + for question in questions: + if not Question.objects.filter(key = question.key).exists(): + q = Question.objects.create(key = question.key, question = question.question, editable = False) + else: + q = Question.objects.get(key = question.key) + group.questions.add(q) -if len(KEYS_ALL) != len(set(KEYS_ALL)): - raise Exception("Duplicate question keys") diff --git a/backend/experiment/questions/demographics.py b/backend/experiment/questions/demographics.py index ad8fd11f5..aa6284cb0 100644 --- a/backend/experiment/questions/demographics.py +++ b/backend/experiment/questions/demographics.py @@ -145,3 +145,28 @@ } ) ] + + +# Temporary until full Question model is implemented +from .utils import question_by_key + +question_dgf_education_matching_pairs = question_by_key('dgf_education', drop_choices=['isced-2', 'isced-5']) +question_dgf_education_matching_pairs.key = 'dgf_education_matching_pairs' + +question_dgf_education_gold_msi = question_by_key('dgf_education', drop_choices=['isced-1']) +question_dgf_education_gold_msi.key = 'dgf_education_gold_msi' + +question_dgf_education_huang_2022 = question_by_key('dgf_education', drop_choices=['isced-5']) +question_dgf_education_huang_2022.key = 'dgf_education_huang_2022' + +DEMOGRAPHICS_OTHER = [ + question_dgf_education_matching_pairs, + question_dgf_education_gold_msi, + question_dgf_education_huang_2022, + + TextQuestion( + key='fame_name', + question=_("Enter a name to enter the ICMPC hall of fame"), + is_skippable=True + ) +] diff --git a/backend/experiment/questions/languages.py b/backend/experiment/questions/languages.py index c7cbb4ece..d34b66192 100644 --- a/backend/experiment/questions/languages.py +++ b/backend/experiment/questions/languages.py @@ -51,3 +51,11 @@ def exposure_question(self): question=question, choices=choices ) + +# Temporary until full Question model is implemented +LANGUAGE_OTHER = [ + # Copied from speech2song.py + LanguageQuestion(_('English')).exposure_question(), + LanguageQuestion(_('Brazilian Portuguese')).exposure_question(), + LanguageQuestion(_('Mandarin Chinese')).exposure_question() +] diff --git a/backend/experiment/rules/base.py b/backend/experiment/rules/base.py index 2413c4b63..de421c464 100644 --- a/backend/experiment/rules/base.py +++ b/backend/experiment/rules/base.py @@ -10,7 +10,7 @@ from experiment.questions.utils import question_by_key, unanswered_questions from result.score import SCORING_RULES -from experiment.questions import get_questions_from_keys +from experiment.questions import get_questions_from_series, QUESTION_GROUPS logger = logging.getLogger(__name__) @@ -21,7 +21,11 @@ class Base(object): contact_email = settings.CONTACT_MAIL def __init__(self): - self.questions = DEMOGRAPHICS + [question_by_key('msi_39_best_instrument', MSI_OTHER)] + self.question_series = [ + {"name": "DEMOGRAPHICS", "keys": QUESTION_GROUPS["DEMOGRAPHICS"], "randomize": False}, + {"name": "MSI_OTHER", "keys": ['msi_39_best_instrument'], "randomize": False}, + ] + def feedback_info(self): feedback_body = render_to_string('feedback/user_feedback.html', {'email': self.contact_email}) @@ -121,7 +125,7 @@ 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, self.questions, randomize) + questionnaire = unanswered_questions(session.participant, get_questions_from_series(session.experiment.questionseries_set.all()), randomize) try: question = next(questionnaire) return Trial( @@ -134,7 +138,7 @@ def get_questionnaire(self, session, randomize=False, cutoff_index=None): ''' Get a list of questions to be asked in succession ''' trials = [] - questions = list(unanswered_questions(session.participant, get_questions_from_keys(session.experiment.questions), randomize, cutoff_index)) + questions = list(unanswered_questions(session.participant, get_questions_from_series(session.experiment.questionseries_set.all()), randomize, cutoff_index)) open_questions = len(questions) if not open_questions: return None diff --git a/backend/experiment/rules/categorization.py b/backend/experiment/rules/categorization.py index f9e4b9b21..04402b830 100644 --- a/backend/experiment/rules/categorization.py +++ b/backend/experiment/rules/categorization.py @@ -21,11 +21,12 @@ class Categorization(Base): ID = 'CATEGORIZATION' def __init__(self): - self.questions = [ - question_by_key('dgf_age', EXTRA_DEMOGRAPHICS), - question_by_key('dgf_gender_reduced', EXTRA_DEMOGRAPHICS), - question_by_key('dgf_native_language', EXTRA_DEMOGRAPHICS), - question_by_key('dgf_musical_experience', EXTRA_DEMOGRAPHICS) + self.question_series = [ + { + "name": "Categorization", + "keys": ['dgf_age','dgf_gender_reduced','dgf_native_language','dgf_musical_experience'], + "randomize": False + }, ] def first_round(self, experiment): diff --git a/backend/experiment/rules/gold_msi.py b/backend/experiment/rules/gold_msi.py index 8d36dbbe6..bb43265ec 100644 --- a/backend/experiment/rules/gold_msi.py +++ b/backend/experiment/rules/gold_msi.py @@ -4,6 +4,7 @@ from experiment.questions.goldsmiths import MSI_F3_MUSICAL_TRAINING from experiment.questions.demographics import EXTRA_DEMOGRAPHICS from experiment.questions.utils import question_by_key +from experiment.questions import QUESTION_GROUPS from experiment.actions.utils import final_action_with_optional_button from .base import Base @@ -14,16 +15,25 @@ class GoldMSI(Base): ID = 'GOLD_MSI' def __init__(self): - demographics = [ - question_by_key('dgf_gender_identity'), - question_by_key('dgf_age', EXTRA_DEMOGRAPHICS), - question_by_key('dgf_education', drop_choices=['isced-1']), - question_by_key('dgf_highest_qualification_expectation', - EXTRA_DEMOGRAPHICS), - question_by_key('dgf_country_of_residence'), - question_by_key('dgf_country_of_origin'), + self.question_series = [ + { + "name": "MSI_F3_MUSICAL_TRAINING", + "keys": QUESTION_GROUPS["MSI_F3_MUSICAL_TRAINING"], + "randomize": False + }, + { + "name": "Demographics", + "keys": [ + 'dgf_gender_identity', + 'dgf_age', + 'dgf_education_gold_msi', + 'dgf_highest_qualification_expectation', + 'dgf_country_of_residence', + 'dgf_country_of_origin' + ], + "randomize": False + }, ] - self.questions = MSI_F3_MUSICAL_TRAINING + demographics def first_round(self, experiment): # Consent with admin text or default text diff --git a/backend/experiment/rules/hooked.py b/backend/experiment/rules/hooked.py index f47426ba0..da75d476c 100644 --- a/backend/experiment/rules/hooked.py +++ b/backend/experiment/rules/hooked.py @@ -8,6 +8,7 @@ from experiment.actions import Consent, Explainer, Final, Playlist, Score, Step, Trial from experiment.actions.form import BooleanQuestion, Form from experiment.actions.playback import Autoplay +from experiment.questions import QUESTION_GROUPS from experiment.questions.demographics import DEMOGRAPHICS from experiment.questions.goldsmiths import MSI_OTHER from experiment.questions.utils import question_by_key @@ -41,15 +42,13 @@ class Hooked(Base): play_method = 'BUFFER' def __init__(self): - self.questions = [ - # 1. Demographic questions (7 questions) - *copy_shuffle(DEMOGRAPHICS), - question_by_key('msi_39_best_instrument', MSI_OTHER), - *copy_shuffle(MSI_FG_GENERAL), # 2. General music sophistication - # 3. Complete music sophistication (20 questions) - *copy_shuffle(MSI_ALL), - *copy_shuffle(STOMP20), # 4. STOMP (20 questions) - *copy_shuffle(TIPI) # 5. TIPI (10 questions) + 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) ] def first_round(self, experiment): diff --git a/backend/experiment/rules/huang_2022.py b/backend/experiment/rules/huang_2022.py index cadfb72cc..f33eed2f6 100644 --- a/backend/experiment/rules/huang_2022.py +++ b/backend/experiment/rules/huang_2022.py @@ -11,6 +11,7 @@ from experiment.questions.goldsmiths import MSI_ALL, MSI_OTHER from experiment.questions.other import OTHER from experiment.questions.utils import question_by_key +from experiment.questions import QUESTION_GROUPS from experiment.actions.styles import STYLE_BOOLEAN_NEGATIVE_FIRST from result.utils import prepare_result from .hooked import Hooked @@ -27,18 +28,28 @@ class Huang2022(Hooked): play_method = 'EXTERNAL' def __init__(self): - self.questions = MSI_ALL + [ - question_by_key('msi_39_best_instrument', MSI_OTHER), - question_by_key('dgf_genre_preference_zh', OTHER), - question_by_key('dgf_generation'), - question_by_key('dgf_education', drop_choices=['isced-5']), - question_by_key( - 'dgf_highest_qualification_expectation', EXTRA_DEMOGRAPHICS), - question_by_key('dgf_occupational_status', EXTRA_DEMOGRAPHICS), - question_by_key('dgf_region_of_origin', OTHER), - question_by_key('dgf_region_of_residence', OTHER), - question_by_key('dgf_gender_identity_zh', OTHER), - question_by_key('contact', OTHER), + self.question_series = [ + { + "name": "MSI_ALL", + "keys": QUESTION_GROUPS["MSI_ALL"], + "randomize": False + }, + { + "name": "Demographics and other", + "keys": [ + 'msi_39_best_instrument', + 'dgf_genre_preference_zh', + 'dgf_generation', + 'dgf_education_huang_2022', + 'dgf_highest_qualification_expectation', + 'dgf_occupational_status', + 'dgf_region_of_origin', + 'dgf_region_of_residence', + 'dgf_gender_identity_zh', + 'contact' + ], + "randomize": False + }, ] def first_round(self, experiment): diff --git a/backend/experiment/rules/matching_pairs.py b/backend/experiment/rules/matching_pairs.py index e669dd20c..e555982ce 100644 --- a/backend/experiment/rules/matching_pairs.py +++ b/backend/experiment/rules/matching_pairs.py @@ -22,12 +22,18 @@ class MatchingPairsGame(Base): randomize = True def __init__(self): - self.questions = [ - question_by_key('dgf_gender_identity'), - question_by_key('dgf_generation'), - question_by_key('dgf_musical_experience', EXTRA_DEMOGRAPHICS), - question_by_key('dgf_country_of_origin'), - question_by_key('dgf_education', drop_choices=['isced-2', 'isced-5']) + self.question_series = [ + { + "name": "Demographics", + "keys": [ + 'dgf_gender_identity', + 'dgf_generation', + 'dgf_musical_experience', + 'dgf_country_of_origin', + 'dgf_education_matching_pairs', + ], + "randomize": False + }, ] def first_round(self, experiment): diff --git a/backend/experiment/rules/matching_pairs_icmpc.py b/backend/experiment/rules/matching_pairs_icmpc.py index 389b88484..d61dd4a7a 100644 --- a/backend/experiment/rules/matching_pairs_icmpc.py +++ b/backend/experiment/rules/matching_pairs_icmpc.py @@ -1,16 +1,12 @@ from django.utils.translation import gettext_lazy as _ -from .matching_pairs import MatchingPairs +from .matching_pairs import MatchingPairsGame from experiment.actions.form import TextQuestion -class MatchingPairsICMPC(MatchingPairs): +class MatchingPairsICMPC(MatchingPairsGame): ID = 'MATCHING_PAIRS_ICMPC' def __init__(self): super().__init__() - self.questions.append(TextQuestion( - key='fame_name', - question=_("Enter a name to enter the ICMPC hall of fame"), - is_skippable=True - )) + self.question_series[0]['keys'].append('fame_name') diff --git a/backend/experiment/rules/musical_preferences.py b/backend/experiment/rules/musical_preferences.py index 0b19a9686..7e16a9da2 100644 --- a/backend/experiment/rules/musical_preferences.py +++ b/backend/experiment/rules/musical_preferences.py @@ -37,13 +37,19 @@ class MusicalPreferences(Base): } def __init__(self): - self.questions = [ - question_by_key('msi_38_listen_music', MSI_F1_ACTIVE_ENGAGEMENT), - question_by_key('dgf_genre_preference_zh', OTHER), - question_by_key('dgf_gender_identity_zh', OTHER), - question_by_key('dgf_age', EXTRA_DEMOGRAPHICS), - question_by_key('dgf_region_of_origin', OTHER), - question_by_key('dgf_region_of_residence', OTHER) + 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', + ], + "randomize": False + }, ] def first_round(self, experiment): diff --git a/backend/experiment/rules/speech2song.py b/backend/experiment/rules/speech2song.py index 05e170315..54fa7ff4f 100644 --- a/backend/experiment/rules/speech2song.py +++ b/backend/experiment/rules/speech2song.py @@ -24,17 +24,23 @@ class Speech2Song(Base): ID = 'SPEECH_TO_SONG' def __init__(self): - self.questions = [ - question_by_key('dgf_age', EXTRA_DEMOGRAPHICS), - question_by_key('dgf_gender_identity'), - question_by_key('dgf_country_of_origin_open', EXTRA_DEMOGRAPHICS), - question_by_key('dgf_country_of_residence_open', EXTRA_DEMOGRAPHICS), - question_by_key('lang_mother', LANGUAGE), - question_by_key('lang_second', LANGUAGE), - question_by_key('lang_third', LANGUAGE), - LanguageQuestion(_('English')).exposure_question(), - LanguageQuestion(_('Brazilian Portuguese')).exposure_question(), - LanguageQuestion(_('Mandarin Chinese')).exposure_question() + self.question_series = [ + { + "name": "Question series Speech2Song", + "keys": [ + 'dgf_age', + 'dgf_gender_identity', + 'dgf_country_of_origin_open', + 'dgf_country_of_residence_open', + 'lang_mother', + 'lang_second', + 'lang_third', + LanguageQuestion(_('English')).exposure_question().key, + LanguageQuestion(_('Brazilian Portuguese')).exposure_question().key, + LanguageQuestion(_('Mandarin Chinese')).exposure_question().key + ], + "randomize": False + }, ] def first_round(self, experiment): diff --git a/backend/experiment/rules/tele_tunes.py b/backend/experiment/rules/tele_tunes.py index 34801058b..a2f98c493 100644 --- a/backend/experiment/rules/tele_tunes.py +++ b/backend/experiment/rules/tele_tunes.py @@ -16,10 +16,8 @@ class HookedTeleTunes(Hooked): consent_file = 'consent/consent_teletunes.html' def __init__(self): - self.questions = [ - # 1. Demographic questions (7 questions) - *copy_shuffle(DEMOGRAPHICS), - # 2. Musicgens questions with variants - *copy_shuffle(MUSICGENS_17_W_VARIANTS) + self.question_series = [ + {"name": "DEMOGRAPHICS", "keys": QUESTION_GROUPS["DEMOGRAPHICS"], "randomize": True}, # 1. Demographic questions (7 questions) + {"name": "MUSICGENS_17_W_VARIANTS", "keys": QUESTION_GROUPS["MUSICGENS_17_W_VARIANTS"], "randomize": True}, # 2. Musicgens questions with variants ] diff --git a/backend/experiment/rules/thats_my_song.py b/backend/experiment/rules/thats_my_song.py index 63117dfcc..3c3ad70fe 100644 --- a/backend/experiment/rules/thats_my_song.py +++ b/backend/experiment/rules/thats_my_song.py @@ -5,6 +5,7 @@ from experiment.actions.form import Form, ChoiceQuestion from experiment.questions.utils import copy_shuffle, question_by_key from experiment.questions.musicgens import MUSICGENS_17_W_VARIANTS +from experiment.questions import QUESTION_GROUPS from .hooked import Hooked from result.utils import prepare_result @@ -17,10 +18,9 @@ class ThatsMySong(Hooked): round_modifier = 1 def __init__(self): - self.questions = [ - question_by_key('dgf_generation'), - question_by_key('dgf_gender_identity'), - *copy_shuffle(MUSICGENS_17_W_VARIANTS) + self.question_series = [ + {"name": "DEMOGRAPHICS", "keys": ['dgf_generation','dgf_gender_identity'], "randomize": False}, + {"name": "MUSICGENS_17_W_VARIANTS", "keys": QUESTION_GROUPS["MUSICGENS_17_W_VARIANTS"], "randomize": True}, ] def feedback_info(self): diff --git a/backend/experiment/rules/visual_matching_pairs.py b/backend/experiment/rules/visual_matching_pairs.py index 23c355429..a664af81d 100644 --- a/backend/experiment/rules/visual_matching_pairs.py +++ b/backend/experiment/rules/visual_matching_pairs.py @@ -19,12 +19,18 @@ class VisualMatchingPairsGame(Base): contact_email = 'aml.tunetwins@gmail.com' def __init__(self): - self.questions = [ - question_by_key('dgf_gender_identity'), - question_by_key('dgf_generation'), - question_by_key('dgf_musical_experience', EXTRA_DEMOGRAPHICS), - question_by_key('dgf_country_of_origin'), - question_by_key('dgf_education', drop_choices=['isced-2', 'isced-5']) + self.question_series = [ + { + "name": "Demographics", + "keys": [ + 'dgf_gender_identity', + 'dgf_generation', + 'dgf_musical_experience', + 'dgf_country_of_origin', + 'dgf_education_matching_pairs', + ], + "randomize": False + }, ] def first_round(self, experiment): diff --git a/backend/experiment/static/experiment_admin.css b/backend/experiment/static/experiment_admin.css index 4ef1f7109..442071db1 100644 --- a/backend/experiment/static/experiment_admin.css +++ b/backend/experiment/static/experiment_admin.css @@ -1,24 +1,3 @@ -.buttons-row { - margin: 0px 0px 0px 10px; -} - -.buttons-row .button { - padding: 2px 5px; - margin: 0px 0px 0px 5px; -} - -.buttons-column { - margin: 10px 0px 0px 0px; -} - -.buttons-column button, .loader { - display: block; - padding: 2px 5px; - margin: 5px 0px 0px 0px; -} - -form .button-show-hide { - width: 45px; - padding: 2px 5px; - margin: 0px 10px 0px 0px; +#id_message { + color: var(--error-fg); } diff --git a/backend/experiment/static/experiment_admin.js b/backend/experiment/static/experiment_admin.js index ae03575fd..7c9bf5b61 100644 --- a/backend/experiment/static/experiment_admin.js +++ b/backend/experiment/static/experiment_admin.js @@ -1,150 +1,45 @@ document.addEventListener("DOMContentLoaded", (event) => { - let fieldLabel = document.querySelector(".field-questions > div > label") - let groups = document.querySelectorAll(".field-questions > div > ul > li") - let questions = document.querySelectorAll("#id_questions label") - let formInputs = document.querySelectorAll("button, fieldset, optgroup, option, select, textarea, input") - - let buttons = [ - {"text": "Show all", "eventListener": showAll(true)}, - {"text": "Hide all", "eventListener": showAll(false)}, - {"text": "Select all", "eventListener": selectAll(true)}, - {"text": "Unselect all", "eventListener": selectAll(false)}, - {"text": "Rules' defaults", "eventListener": setDefaultQuestions} - ] - - let buttonsColumn = document.createElement("div") - buttonsColumn.className = "buttons-column" - fieldLabel.append(buttonsColumn) - - buttons.forEach( (button) => { - let btn = createButton() - btn.innerText = button.text - btn.addEventListener("click", button.eventListener) - buttonsColumn.append(btn) - }) - - let loader = document.createElement("div") - loader.className = "loader" - loader.style.display = "none" - loader.innerText = "Loading..." - buttonsColumn.append(loader) - - groups.forEach( (group) => { - - group.style.fontWeight = "bold" - - let buttonsRow = document.createElement("span") - buttonsRow.className = "buttons-row" - - let btn = createButton() - btn.innerText = "Select group" - btn.addEventListener("click", selectGroup(group, true)) - buttonsRow.append(btn) - - btn = createButton() - btn.innerText = "Unselect group" - btn.addEventListener("click", selectGroup(group, false)) - buttonsRow.append(btn) - - buttonsRow.style.display = "none" - group.querySelector("ul").style.display = "none" - group.insertBefore(buttonsRow, group.childNodes[1]) - - btn = createButton() - btn.innerText = "Show" - btn.className += " button-show-hide" - btn.addEventListener("click", toggleShowHide) - group.insertBefore(btn, group.childNodes[0]) - - }) - - function createButton(){ - let btn = document.createElement("button") - btn.className = "button" - btn.type = "button" - return btn - } - - function showAll(show) { - return () => groups.forEach(group => showGroup(group, show)) - } - - function showGroup(group, show) { - let questionList = group.querySelector("ul") - questionList.style.display = show ? "" : "none" - - let showHideButton = group.querySelector(".button-show-hide") - showHideButton.innerText = show ? "Hide" : "Show" - - let selectButtonRow = group.querySelector(".buttons-row") - selectButtonRow.style.display = show ? "" : "none" - } - - function selectGroup(group, checked) { - let checkbxs = group.querySelectorAll("input") - return () => checkbxs.forEach(c => c.checked = checked) - } - - function selectAll(checked){ - return () => groups.forEach(group => { - selectGroup(group, checked)() - showGroup(group, true) - }) - } - - function toggleShowHide() { - - let group = this.parentElement - let questionList = group.querySelector("ul") - - if (questionList.style.display == "" || questionList.style.display == "block") { - showGroup(group, false) - } else if (questionList.style.display == "none") { - showGroup(group, true) + // Get experiment id from URL + match = window.location.href.match(/\/experiment\/experiment\/(.+)\/change/) + experiment_id = match && match[1] + + let buttonAddDefaultQuestions = document.createElement("input") + buttonAddDefaultQuestions.type = "button" + buttonAddDefaultQuestions.value = "Add rules' defaults" + buttonAddDefaultQuestions.addEventListener("click", addDefaultQuestions) + + let message = document.createElement("span") + message.id = "id_message" + message.className = "form-row" + + document.querySelector('#questionseries_set-group').append(buttonAddDefaultQuestions, message) + + 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 = "" + } else { + buttonAddDefaultQuestions.disabled = true + message.innerText = "Save Experiment first" } } - // Question text presented in experiment admin must include question key in parenthesis, e.g. (dgf_country_of_origin) - async function setDefaultQuestions() { - - // Selected Rules - let rules = document.getElementById("id_rules").value + async function addDefaultQuestions() { - let defaultQuestions = [] + const csrftoken = document.querySelector('[name=csrfmiddlewaretoken]').value; + let response = await fetch(`/experiment/add_default_question_series/${experiment_id}/`, + {method:"POST", mode: 'same-origin',headers: {'X-CSRFToken': csrftoken}}) - if (rules) { - - formInputs.forEach( c => c.setAttribute("disabled","")) - loader.style.display = "block" - - //Get default question list - let url=`/experiment/default_questions/${rules}/` - let response = await fetch(url) - - if (response.ok) { - let json_data = await response.json() - defaultQuestions = json_data['default_questions'] - } - - formInputs.forEach( c => c.removeAttribute("disabled")) - loader.style.display = "none" + if (response.ok) { + location.reload() } - - // Uncheck all questions - selectAll(false)() - - // Check questions present in the default questions list - for (const question of questions) { - for (const defaultQuestion of defaultQuestions) { - if (question.textContent.includes(`(${defaultQuestion})`) > 0) { - question.querySelector("input").checked = true - } - } - } } - }) - - diff --git a/backend/experiment/static/questionseries_admin.js b/backend/experiment/static/questionseries_admin.js new file mode 100644 index 000000000..a871e8bb6 --- /dev/null +++ b/backend/experiment/static/questionseries_admin.js @@ -0,0 +1,50 @@ + +document.addEventListener("DOMContentLoaded", (event) => { + + async function getQuestionGroups(){ + + let response = await fetch(`/experiment/question_groups/`) + + if (response.ok) { + return await response.json() + } + } + + getQuestionGroups().then( (questionGroups) => { + + let buttonAddQuestionGroup = document.createElement("input") + buttonAddQuestionGroup.type = "button" + buttonAddQuestionGroup.value = "Add all questions in group" + buttonAddQuestionGroup.addEventListener("click", addQuestionGroup) + + let selectQuestionGroup = document.createElement("select") + + Object.keys(questionGroups).sort().forEach( (group) => { + option = document.createElement("option") + option.innerText = group + selectQuestionGroup.append(option) + }) + + document.querySelector('#questioninseries_set-group').append(buttonAddQuestionGroup, selectQuestionGroup) + + function addQuestionGroup() { + + // "Add another Question in series" is already created by Django + let addQuestionAnchor = document.querySelector(".add-row a") + + questionGroups[selectQuestionGroup.value].forEach ( (questionKey) => { + + totalFormsInput = document.querySelector("#id_questioninseries_set-TOTAL_FORMS") + totalFormsBefore = Number(totalFormsInput.value) + addQuestionAnchor.click() + totalForms = Number(totalFormsInput.value) + + if (totalForms == totalFormsBefore + 1) { + questionSelect = document.querySelector(`#id_questioninseries_set-${totalForms-1}-question`) + questionSelect.querySelector(`option[value=${questionKey}]`).selected = true + document.querySelector(`#id_questioninseries_set-${totalForms-1}-index`).value = totalForms + } + }) + } + }) +}) diff --git a/backend/experiment/urls.py b/backend/experiment/urls.py index 3b4b49d11..ab8467d28 100644 --- a/backend/experiment/urls.py +++ b/backend/experiment/urls.py @@ -1,16 +1,17 @@ from django.urls import path from django.views.generic.base import TemplateView -from .views import get_experiment, get_experiment_collection, post_feedback, default_questions +from .views import get_experiment, get_experiment_collection, post_feedback, question_groups, add_default_question_series app_name = 'experiment' urlpatterns = [ + path('question_groups/', question_groups, name='question_groups'), + path('add_default_question_series//', add_default_question_series, name='add_default_question_series'), # Experiment path('/', get_experiment, name='experiment'), path('/feedback/', post_feedback, name='feedback'), path('collection//', get_experiment_collection, name='experiment_collection'), - path('default_questions//', default_questions, name='default_questions'), # Robots.txt path( diff --git a/backend/experiment/views.py b/backend/experiment/views.py index 90bc27442..209344ae5 100644 --- a/backend/experiment/views.py +++ b/backend/experiment/views.py @@ -13,6 +13,7 @@ from session.models import Session from experiment.rules import EXPERIMENT_RULES from experiment.actions.utils import COLLECTION_KEY +from experiment.models import QuestionSeries, QuestionInSeries, Question, QuestionGroup logger = logging.getLogger(__name__) @@ -70,9 +71,17 @@ def experiment_or_404(slug): except Experiment.DoesNotExist: raise Http404("Experiment does not exist") +def question_groups(request): + question_groups = {} + for question_group in QuestionGroup.objects.all(): + question_groups[question_group.key] = [q.key for q in QuestionGroup.objects.get(pk=question_group.key).questions.all()] + return JsonResponse(question_groups) -def default_questions(request, rules): - return JsonResponse({'default_questions': [q.key for q in EXPERIMENT_RULES[rules]().questions]}) + +def add_default_question_series(request, id): + if request.method == "POST": + Experiment.objects.get(pk=id).add_default_question_series() + return JsonResponse({}) def get_experiment_collection(request, slug): diff --git a/docker-compose-deploy.yml b/docker-compose-deploy.yml index a4ee205b6..9aee45926 100644 --- a/docker-compose-deploy.yml +++ b/docker-compose-deploy.yml @@ -59,7 +59,7 @@ services: - SQL_HOST=${SQL_HOST} ports: - 8000:8000 - command: bash -c "python manage.py migrate && python manage.py bootstrap && python manage.py collectstatic --noinput && gunicorn aml.wsgi:application --bind 0.0.0.0:8000" + command: bash -c "python manage.py migrate && python manage.py bootstrap && python manage.py createquestions && python manage.py collectstatic --noinput && gunicorn aml.wsgi:application --bind 0.0.0.0:8000" restart: always client-builder: diff --git a/docker-compose.yaml b/docker-compose.yaml index a0779496e..208c83089 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -54,7 +54,7 @@ services: - SQL_HOST=${SQL_HOST} ports: - 8000:8000 - command: bash -c "python manage.py migrate && python manage.py bootstrap && python manage.py runserver 0.0.0.0:8000" + command: bash -c "python manage.py migrate && python manage.py bootstrap && python manage.py createquestions && python manage.py runserver 0.0.0.0:8000" client: build: context: ./frontend From 3363c21a113ea814762bb96a6b3058bb331358f5 Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Fri, 12 Apr 2024 02:01:15 +0200 Subject: [PATCH 002/180] Update tests for Question model --- backend/experiment/fixtures/experiment.json | 6 -- backend/experiment/fixtures/question.json | 66 +++++++++++++++++ .../experiment/fixtures/questioninseries.json | 74 +++++++++++++++++++ .../experiment/fixtures/questionseries.json | 20 +++++ backend/experiment/rules/tests/test_hooked.py | 4 +- .../experiment/tests/test_admin_experiment.py | 2 +- backend/experiment/tests/test_forms.py | 1 - .../experiment/tests/test_model_functions.py | 6 +- 8 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 backend/experiment/fixtures/question.json create mode 100644 backend/experiment/fixtures/questioninseries.json create mode 100644 backend/experiment/fixtures/questionseries.json diff --git a/backend/experiment/fixtures/experiment.json b/backend/experiment/fixtures/experiment.json index 3c684464b..e288a35c3 100644 --- a/backend/experiment/fixtures/experiment.json +++ b/backend/experiment/fixtures/experiment.json @@ -229,9 +229,6 @@ 13, 2, 1 - ], - "questions": [ - "msi_01_music_activities", "msi_03_writing", "msi_08_intrigued_styles", "msi_15_internet_search_music", "msi_21_spend_income", "msi_24_music_addiction", "msi_28_track_new", "msi_34_attended_events", "msi_38_listen_music", "msi_05_good_singer", "msi_06_song_first_time", "msi_11_spot_mistakes", "msi_12_performance_diff", "msi_13_trouble_recognising", "msi_18_out_of_beat", "msi_22_out_of_tune", "msi_23_no_idea_in_tune", "msi_26_genre", "msi_14_never_complimented", "msi_27_consider_musician", "msi_32_practice_years", "msi_33_practice_daily", "msi_35_theory_training", "msi_36_instrumental_training", "msi_37_play_instruments", "msi_04_sing_along", "msi_07_from_memory", "msi_10_sing_with_recording", "msi_17_not_sing_harmony", "msi_25_sing_public", "msi_29_sing_after_hearing", "msi_30_sing_back", "msi_02_shivers", "msi_09_rarely_emotions", "msi_16_motivate", "msi_19_identify_special", "msi_20_talk_emotions", "msi_31_memories", "msi_39_best_instrument", "dgf_genre_preference_zh", "dgf_generation", "dgf_education", "dgf_highest_qualification_expectation", "dgf_occupational_status", "dgf_region_of_origin", "dgf_region_of_residence", "dgf_gender_identity_zh", "contact" ] } }, @@ -328,9 +325,6 @@ "language": "", "playlists": [ 18 - ], - "questions": [ - "dgf_generation", "dgf_gender_identity", "P01_1", "P01_2", "P01_3", "P02_1", "P02_2", "P02_3", "P03_1", "P03_2", "P03_3", "P04_1", "P04_2", "P04_3", "P04_4", "P05_1", "P05_2", "P05_3", "P05_4", "P05_5", "P06_1", "P06_2", "P06_3", "P06_4", "P07_1", "P07_2", "P07_3", "P08_1", "P08_2", "P08_3", "P09_1", "P09_2", "P09_3", "P10_1", "P10_2", "P10_3", "P11_1", "P11_2", "P11_3", "P12_1", "P12_2", "P12_3", "P13_1", "P13_2", "P13_3", "P14_1", "P14_2", "P14_3", "P14_4", "P15_1", "P15_2", "P15_3", "P15_4", "P16_1", "P16_2", "P16_3", "P17_1", "P17_2", "P17_3" ] } } diff --git a/backend/experiment/fixtures/question.json b/backend/experiment/fixtures/question.json new file mode 100644 index 000000000..58c0e7331 --- /dev/null +++ b/backend/experiment/fixtures/question.json @@ -0,0 +1,66 @@ +[ + { + "model": "experiment.question", + "pk": "dgf_generation", + "fields": { + "question": "When were you born?", + "editable": false + } + }, + { + "model": "experiment.question", + "pk": "dgf_gender_identity", + "fields": { + "question": "With which gender do you currently most identify?", + "editable": false + } + }, + { + "model": "experiment.question", + "pk": "P01_1", + "fields": { + "question": "Can you clap in time with a musical beat?", + "editable": false + } + }, + { + "model": "experiment.question", + "pk": "P01_2", + "fields": { + "question": "I can tap my foot in time with the beat of the music I hear.", + "editable": false + } + }, + { + "model": "experiment.question", + "pk": "P01_3", + "fields": { + "question": "When listening to music, can you move in time with the beat?", + "editable": false + } + }, + { + "model": "experiment.question", + "pk": "msi_01_music_activities", + "fields": { + "question": "I spend a lot of my free time doing music-related activities.", + "editable": false + } + }, + { + "model": "experiment.question", + "pk": "msi_03_writing", + "fields": { + "question": "I enjoy writing about music, for example on blogs and forums.", + "editable": false + } + }, + { + "model": "experiment.question", + "pk": "msi_08_intrigued_styles", + "fields": { + "question": "I’m intrigued by musical styles I’m not familiar with and want to find out more.", + "editable": false + } + } +] diff --git a/backend/experiment/fixtures/questioninseries.json b/backend/experiment/fixtures/questioninseries.json new file mode 100644 index 000000000..28c776b1d --- /dev/null +++ b/backend/experiment/fixtures/questioninseries.json @@ -0,0 +1,74 @@ +[ + { + "model": "experiment.questioninseries", + "pk": 1, + "fields": { + "question_series": 1, + "question": "msi_01_music_activities", + "index": 1 + } + }, + { + "model": "experiment.questioninseries", + "pk": 2, + "fields": { + "question_series": 1, + "question": "msi_03_writing", + "index": 2 + } + }, + { + "model": "experiment.questioninseries", + "pk": 3, + "fields": { + "question_series": 1, + "question": "msi_08_intrigued_styles", + "index": 3 + } + }, + { + "model": "experiment.questioninseries", + "pk": 4, + "fields": { + "question_series": 2, + "question": "dgf_generation", + "index": 1 + } + }, + { + "model": "experiment.questioninseries", + "pk": 5, + "fields": { + "question_series": 2, + "question": "dgf_gender_identity", + "index": 2 + } + }, + { + "model": "experiment.questioninseries", + "pk": 6, + "fields": { + "question_series": 2, + "question": "P01_1", + "index": 3 + } + }, + { + "model": "experiment.questioninseries", + "pk": 7, + "fields": { + "question_series": 2, + "question": "P01_2", + "index": 4 + } + }, + { + "model": "experiment.questioninseries", + "pk": 8, + "fields": { + "question_series": 2, + "question": "P01_3", + "index": 5 + } + } +] diff --git a/backend/experiment/fixtures/questionseries.json b/backend/experiment/fixtures/questionseries.json new file mode 100644 index 000000000..da9e03ab1 --- /dev/null +++ b/backend/experiment/fixtures/questionseries.json @@ -0,0 +1,20 @@ +[ + { + "model": "experiment.questionseries", + "pk": 1, + "fields": { + "experiment": 14, + "index": 1, + "randomize": false + } + }, + { + "model": "experiment.questionseries", + "pk": 2, + "fields": { + "experiment": 20, + "index": 1, + "randomize": false + } + } +] diff --git a/backend/experiment/rules/tests/test_hooked.py b/backend/experiment/rules/tests/test_hooked.py index 273dc4538..8e79a87b9 100644 --- a/backend/experiment/rules/tests/test_hooked.py +++ b/backend/experiment/rules/tests/test_hooked.py @@ -9,7 +9,7 @@ class HookedTest(TestCase): - fixtures = ['playlist', 'experiment'] + fixtures = ['playlist', 'experiment','question','questionseries','questioninseries'] @classmethod def setUpTestData(cls): @@ -130,7 +130,7 @@ def test_hooked_china(self): question_trials = rules.get_questionnaire(session) # assert len(question_trials) == len(rules.questions) keys = [q.feedback_form.form[0].key for q in question_trials] - questions = [q.key for q in rules.questions] + questions = rules.question_series[0]['keys'][0:3] assert set(keys).difference(set(questions)) == set() diff --git a/backend/experiment/tests/test_admin_experiment.py b/backend/experiment/tests/test_admin_experiment.py index 6e7bbc2bb..50c256125 100644 --- a/backend/experiment/tests/test_admin_experiment.py +++ b/backend/experiment/tests/test_admin_experiment.py @@ -11,7 +11,7 @@ from session.models import Session # Expected field count per model -EXPECTED_EXPERIMENT_FIELDS = 16 +EXPECTED_EXPERIMENT_FIELDS = 15 EXPECTED_SESSION_FIELDS = 9 EXPECTED_RESULT_FIELDS = 12 EXPECTED_PARTICIPANT_FIELDS = 5 diff --git a/backend/experiment/tests/test_forms.py b/backend/experiment/tests/test_forms.py index 1b3d266e2..e76b5998e 100644 --- a/backend/experiment/tests/test_forms.py +++ b/backend/experiment/tests/test_forms.py @@ -10,7 +10,6 @@ def test_form_fields(self): self.assertIn('slug', form.fields) self.assertIn('active', form.fields) self.assertIn('rules', form.fields) - self.assertIn('questions', form.fields) self.assertIn('rounds', form.fields) self.assertIn('bonus_points', form.fields) self.assertIn('playlists', form.fields) diff --git a/backend/experiment/tests/test_model_functions.py b/backend/experiment/tests/test_model_functions.py index 2fb18c92f..bf02703eb 100644 --- a/backend/experiment/tests/test_model_functions.py +++ b/backend/experiment/tests/test_model_functions.py @@ -10,9 +10,9 @@ def setUpTestData(cls): def test_separate_rules_instance(self): rules1 = self.experiment.get_rules() rules2 = self.experiment.get_rules() - keys1 = [q.key for q in rules1.questions] - keys2 = [q.key for q in rules2.questions] - assert keys1 != keys2 + keys1 = rules1.question_series[0]['keys'] + rules1.question_series[1]['keys'] + keys2 = rules2.question_series[0]['keys'] + rules2.question_series[1]['keys'] + assert keys1 == keys2 class TestModelExperimentSeries(TestCase): From 28bcfade0bf4bb38b63fda6c072e6299377dc258 Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Fri, 12 Apr 2024 02:32:11 +0200 Subject: [PATCH 003/180] Fix linter errors --- backend/experiment/admin.py | 6 +++++ backend/experiment/models.py | 1 - backend/experiment/questions/__init__.py | 27 +++++++++++++---------- backend/experiment/questions/languages.py | 1 + backend/experiment/rules/base.py | 1 - backend/experiment/views.py | 1 + 6 files changed, 23 insertions(+), 14 deletions(-) diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index cdda737d1..265f3daeb 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -34,15 +34,18 @@ class QuestionInSeriesInline(admin.TabularInline): model = QuestionInSeries extra = 0 + class QuestionSeriesInline(admin.TabularInline): model = QuestionSeries extra = 0 show_change_link = True + class QuestionAdmin(admin.ModelAdmin): def has_change_permission(self, request, obj=None): return obj.editable if obj else False + class QuestionGroupAdmin(admin.ModelAdmin): formfield_overrides = { models.ManyToManyField: {'widget': CheckboxSelectMultiple}, @@ -57,14 +60,17 @@ def get_form(self, request, obj=None, **kwargs): return form + class QuestionSeriesAdmin(admin.ModelAdmin): inlines = [QuestionInSeriesInline] form = QuestionSeriesAdminForm + admin.site.register(Question, QuestionAdmin) admin.site.register(QuestionGroup, QuestionGroupAdmin) admin.site.register(QuestionSeries, QuestionSeriesAdmin) + class ExperimentAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): list_display = ('image_preview', 'experiment_link', 'rules', 'rounds', 'playlist_count', 'session_count', 'active') diff --git a/backend/experiment/models.py b/backend/experiment/models.py index c6ee71fc7..3c2d232c1 100644 --- a/backend/experiment/models.py +++ b/backend/experiment/models.py @@ -232,7 +232,6 @@ def max_score(self): return 0 - def add_default_question_series(self): """ Add default question_series to experiment""" from experiment.rules import EXPERIMENT_RULES diff --git a/backend/experiment/questions/__init__.py b/backend/experiment/questions/__init__.py index 4d8406881..cca778020 100644 --- a/backend/experiment/questions/__init__.py +++ b/backend/experiment/questions/__init__.py @@ -33,7 +33,8 @@ QUESTION_GROUPS = {} for group, questions in QUESTION_GROUPS_DEFAULT.items(): - for question in questions: QUESTIONS[question.key] = question + for question in questions: + QUESTIONS[question.key] = question QUESTION_GROUPS[group] = [ q.key for q in questions ] @@ -43,7 +44,8 @@ def get_questions_from_series(questionseries_set): for questionseries in questionseries_set: keys = [qis.question.key for qis in questionseries.questioninseries_set.all()] - if questionseries.randomize: random.shuffle(keys) + if questionseries.randomize: + random.shuffle(keys) keys_all.extend(keys) return [QUESTIONS[key] for key in keys_all] @@ -53,20 +55,21 @@ def get_default_question_keys(): """ For backward compatibility. One of the migrations calls it""" return [] + def create_default_questions(): """Creates default questions and question groups in the database""" for group_key, questions in QUESTION_GROUPS_DEFAULT.items(): - if not QuestionGroup.objects.filter(key = group_key).exists(): - group = QuestionGroup.objects.create(key = group_key, editable = False) + if not QuestionGroup.objects.filter(key = group_key).exists(): + group = QuestionGroup.objects.create(key = group_key, editable = False) + else: + group = QuestionGroup.objects.get(key = group_key) + + for question in questions: + if not Question.objects.filter(key = question.key).exists(): + q = Question.objects.create(key = question.key, question = question.question, editable = False) else: - group = QuestionGroup.objects.get(key = group_key) - - for question in questions: - if not Question.objects.filter(key = question.key).exists(): - q = Question.objects.create(key = question.key, question = question.question, editable = False) - else: - q = Question.objects.get(key = question.key) - group.questions.add(q) + q = Question.objects.get(key = question.key) + group.questions.add(q) diff --git a/backend/experiment/questions/languages.py b/backend/experiment/questions/languages.py index d34b66192..9c4aa1202 100644 --- a/backend/experiment/questions/languages.py +++ b/backend/experiment/questions/languages.py @@ -52,6 +52,7 @@ def exposure_question(self): choices=choices ) + # Temporary until full Question model is implemented LANGUAGE_OTHER = [ # Copied from speech2song.py diff --git a/backend/experiment/rules/base.py b/backend/experiment/rules/base.py index de421c464..73ba7e2be 100644 --- a/backend/experiment/rules/base.py +++ b/backend/experiment/rules/base.py @@ -26,7 +26,6 @@ def __init__(self): {"name": "MSI_OTHER", "keys": ['msi_39_best_instrument'], "randomize": False}, ] - def feedback_info(self): feedback_body = render_to_string('feedback/user_feedback.html', {'email': self.contact_email}) return { diff --git a/backend/experiment/views.py b/backend/experiment/views.py index 209344ae5..fec9bbb46 100644 --- a/backend/experiment/views.py +++ b/backend/experiment/views.py @@ -71,6 +71,7 @@ def experiment_or_404(slug): except Experiment.DoesNotExist: raise Http404("Experiment does not exist") + def question_groups(request): question_groups = {} for question_group in QuestionGroup.objects.all(): From fd8a6c94c219b2f83ac3fbc39529683e5fa67ae1 Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Fri, 12 Apr 2024 08:10:12 +0200 Subject: [PATCH 004/180] Fix linter errors 2 --- backend/experiment/questions/demographics.py | 30 ++++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/backend/experiment/questions/demographics.py b/backend/experiment/questions/demographics.py index aa6284cb0..2da492dba 100644 --- a/backend/experiment/questions/demographics.py +++ b/backend/experiment/questions/demographics.py @@ -147,23 +147,29 @@ ] -# Temporary until full Question model is implemented -from .utils import question_by_key -question_dgf_education_matching_pairs = question_by_key('dgf_education', drop_choices=['isced-2', 'isced-5']) -question_dgf_education_matching_pairs.key = 'dgf_education_matching_pairs' +def demographics_other(): + from .utils import question_by_key + + questions = [] + + question = question_by_key('dgf_education', drop_choices=['isced-2', 'isced-5']) + question.key = 'dgf_education_matching_pairs' + questions.append(question) -question_dgf_education_gold_msi = question_by_key('dgf_education', drop_choices=['isced-1']) -question_dgf_education_gold_msi.key = 'dgf_education_gold_msi' + question = question_by_key('dgf_education', drop_choices=['isced-1']) + question.key = 'dgf_education_gold_msi' + questions.append(question) -question_dgf_education_huang_2022 = question_by_key('dgf_education', drop_choices=['isced-5']) -question_dgf_education_huang_2022.key = 'dgf_education_huang_2022' + question = question_by_key('dgf_education', drop_choices=['isced-5']) + question.key = 'dgf_education_huang_2022' + questions.append(question) -DEMOGRAPHICS_OTHER = [ - question_dgf_education_matching_pairs, - question_dgf_education_gold_msi, - question_dgf_education_huang_2022, + return questions + +# Temporary until full Question model is implemented +DEMOGRAPHICS_OTHER = demographics_other() + [ TextQuestion( key='fame_name', question=_("Enter a name to enter the ICMPC hall of fame"), From 07f01998958471db2e29648bb9a0a5cd212fc0d4 Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Fri, 12 Apr 2024 08:14:06 +0200 Subject: [PATCH 005/180] Fix linter errors 3 --- backend/experiment/questions/demographics.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/experiment/questions/demographics.py b/backend/experiment/questions/demographics.py index 2da492dba..0419cd18d 100644 --- a/backend/experiment/questions/demographics.py +++ b/backend/experiment/questions/demographics.py @@ -147,7 +147,6 @@ ] - def demographics_other(): from .utils import question_by_key From 9805400883adf2633e4ffba9b839454061898501 Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Tue, 16 Apr 2024 13:15:28 +0200 Subject: [PATCH 006/180] Fix migration conflicts --- ...{0027_add_question_model.py => 0034_add_question_model.py} | 4 ++-- ...question_model_data.py => 0035_add_question_model_data.py} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename backend/experiment/migrations/{0027_add_question_model.py => 0034_add_question_model.py} (96%) rename backend/experiment/migrations/{0028_add_question_model_data.py => 0035_add_question_model_data.py} (90%) diff --git a/backend/experiment/migrations/0027_add_question_model.py b/backend/experiment/migrations/0034_add_question_model.py similarity index 96% rename from backend/experiment/migrations/0027_add_question_model.py rename to backend/experiment/migrations/0034_add_question_model.py index cb6ef8573..a28145781 100644 --- a/backend/experiment/migrations/0027_add_question_model.py +++ b/backend/experiment/migrations/0034_add_question_model.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.25 on 2024-04-11 16:31 +# Generated by Django 3.2.25 on 2024-04-16 11:06 from django.db import migrations, models import django.db.models.deletion @@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ - ('experiment', '0026_auto_20240319_1114'), + ('experiment', '0033_rename_related'), ] operations = [ diff --git a/backend/experiment/migrations/0028_add_question_model_data.py b/backend/experiment/migrations/0035_add_question_model_data.py similarity index 90% rename from backend/experiment/migrations/0028_add_question_model_data.py rename to backend/experiment/migrations/0035_add_question_model_data.py index 4f563f4bc..2c46f296a 100644 --- a/backend/experiment/migrations/0028_add_question_model_data.py +++ b/backend/experiment/migrations/0035_add_question_model_data.py @@ -15,7 +15,7 @@ def add_default_question_series(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('experiment', '0027_add_question_model'), + ('experiment', '0034_add_question_model'), ] operations = [ From 0455b1efa0941fd304a7dbde6fb713c389ff7075 Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Wed, 17 Apr 2024 09:26:25 +0200 Subject: [PATCH 007/180] Modify button text, Add rules' default and save --- backend/experiment/static/experiment_admin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/experiment/static/experiment_admin.js b/backend/experiment/static/experiment_admin.js index 7c9bf5b61..194163206 100644 --- a/backend/experiment/static/experiment_admin.js +++ b/backend/experiment/static/experiment_admin.js @@ -7,7 +7,7 @@ document.addEventListener("DOMContentLoaded", (event) => { let buttonAddDefaultQuestions = document.createElement("input") buttonAddDefaultQuestions.type = "button" - buttonAddDefaultQuestions.value = "Add rules' defaults" + buttonAddDefaultQuestions.value = "Add rules' defaults and save" buttonAddDefaultQuestions.addEventListener("click", addDefaultQuestions) let message = document.createElement("span") From 807453681c04a0cb17725b1b70228c034ed59490 Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Mon, 22 Apr 2024 10:25:25 +0200 Subject: [PATCH 008/180] Do not add default questions to experiment if question_series does not exist in rules --- backend/experiment/models.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/backend/experiment/models.py b/backend/experiment/models.py index e2824ec45..42a563148 100644 --- a/backend/experiment/models.py +++ b/backend/experiment/models.py @@ -276,17 +276,19 @@ def max_score(self): def add_default_question_series(self): """ Add default question_series to experiment""" from experiment.rules import EXPERIMENT_RULES - for i,question_series in enumerate(EXPERIMENT_RULES[self.rules]().question_series): - qs = QuestionSeries.objects.create( - name = question_series['name'], - experiment = self, - index = i+1, - randomize = question_series['randomize']) - for i,question in enumerate(question_series['keys']): - qis = QuestionInSeries.objects.create( - question_series = qs, - question = Question.objects.get(pk=question), - index=i+1) + question_series = getattr(EXPERIMENT_RULES[self.rules](), "question_series", None) + if question_series: + for i,question_series in enumerate(question_series): + qs = QuestionSeries.objects.create( + name = question_series['name'], + experiment = self, + index = i+1, + randomize = question_series['randomize']) + for i,question in enumerate(question_series['keys']): + qis = QuestionInSeries.objects.create( + question_series = qs, + question = Question.objects.get(pk=question), + index=i+1) class Feedback(models.Model): From 0b9686260f64b6281b8185177bc8b77bbd9e09c1 Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Fri, 10 May 2024 10:21:18 +0200 Subject: [PATCH 009/180] Move question to own app --- backend/aml/base_settings.py | 1 + backend/aml/urls.py | 1 + backend/experiment/admin.py | 44 +---------- .../management/commands/createquestions.py | 2 +- .../commands/templates/experiment.py | 4 +- .../0017_experiment_add_questions_field.py | 3 +- .../migrations/0034_add_question_model.py | 64 +--------------- .../0035_add_question_model_data.py | 4 +- backend/experiment/models.py | 55 +------------- backend/experiment/rules/base.py | 8 +- backend/experiment/rules/categorization.py | 4 +- backend/experiment/rules/gold_msi.py | 8 +- backend/experiment/rules/hooked.py | 16 ++-- backend/experiment/rules/huang_2022.py | 10 +-- backend/experiment/rules/matching_pairs.py | 4 +- .../experiment/rules/musical_preferences.py | 8 +- backend/experiment/rules/speech2song.py | 6 +- backend/experiment/rules/tele_tunes.py | 6 +- backend/experiment/rules/tests/test_hooked.py | 2 +- backend/experiment/rules/thats_my_song.py | 6 +- .../experiment/rules/visual_matching_pairs.py | 4 +- .../experiment/static/questionseries_admin.js | 2 +- backend/experiment/urls.py | 3 +- backend/experiment/views.py | 8 -- backend/question/__init__.py | 0 backend/question/admin.py | 46 ++++++++++++ backend/question/apps.py | 6 ++ .../questions => question}/demographics.py | 0 .../fixtures/question.json | 16 ++-- .../fixtures/questioninseries.json | 16 ++-- .../fixtures/questionseries.json | 4 +- .../questions => question}/goldsmiths.py | 0 .../questions => question}/languages.py | 0 .../migrations/0001_add_question_model.py | 75 +++++++++++++++++++ .../0002_add_question_model_data.py | 19 +++++ backend/question/migrations/__init__.py | 0 backend/question/models.py | 58 ++++++++++++++ .../questions => question}/musicgens.py | 0 .../questions => question}/other.py | 0 .../profile_scoring_rules.py | 0 .../__init__.py => question/questions.py} | 2 +- .../questions => question}/stomp.py | 0 .../questions => question}/tests.py | 0 .../questions => question}/tipi.py | 0 backend/question/urls.py | 8 ++ .../questions => question}/utils.py | 0 backend/question/views.py | 9 +++ backend/result/utils.py | 2 +- 48 files changed, 296 insertions(+), 238 deletions(-) create mode 100644 backend/question/__init__.py create mode 100644 backend/question/admin.py create mode 100644 backend/question/apps.py rename backend/{experiment/questions => question}/demographics.py (100%) rename backend/{experiment => question}/fixtures/question.json (81%) rename backend/{experiment => question}/fixtures/questioninseries.json (73%) rename backend/{experiment => question}/fixtures/questionseries.json (72%) rename backend/{experiment/questions => question}/goldsmiths.py (100%) rename backend/{experiment/questions => question}/languages.py (100%) create mode 100644 backend/question/migrations/0001_add_question_model.py create mode 100644 backend/question/migrations/0002_add_question_model_data.py create mode 100644 backend/question/migrations/__init__.py create mode 100644 backend/question/models.py rename backend/{experiment/questions => question}/musicgens.py (100%) rename backend/{experiment/questions => question}/other.py (100%) rename backend/{experiment/questions => question}/profile_scoring_rules.py (100%) rename backend/{experiment/questions/__init__.py => question/questions.py} (98%) rename backend/{experiment/questions => question}/stomp.py (100%) rename backend/{experiment/questions => question}/tests.py (100%) rename backend/{experiment/questions => question}/tipi.py (100%) create mode 100644 backend/question/urls.py rename backend/{experiment/questions => question}/utils.py (100%) create mode 100644 backend/question/views.py diff --git a/backend/aml/base_settings.py b/backend/aml/base_settings.py index b5d44b06f..36bb26d8e 100644 --- a/backend/aml/base_settings.py +++ b/backend/aml/base_settings.py @@ -52,6 +52,7 @@ 'session', 'section', 'theme', + 'question' ] MIDDLEWARE = [ diff --git a/backend/aml/urls.py b/backend/aml/urls.py index 9444450fc..46d9bbc7d 100644 --- a/backend/aml/urls.py +++ b/backend/aml/urls.py @@ -27,6 +27,7 @@ # Urls patterns urlpatterns = [ path('experiment/', include('experiment.urls')), + path('question/', include('question.urls')), path('participant/', include('participant.urls')), path('result/', include('result.urls')), path('section/', include('section.urls')), diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index 3773e1880..34696b5d9 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -15,7 +15,8 @@ from inline_actions.admin import InlineActionsModelAdminMixin from django.urls import reverse from django.utils.html import format_html -from experiment.models import Experiment, ExperimentCollection, ExperimentCollectionGroup, Feedback, GroupedExperiment, Question, QuestionGroup, QuestionSeries, QuestionInSeries +from experiment.models import Experiment, ExperimentCollection, ExperimentCollectionGroup, Feedback, GroupedExperiment +from question.admin import QuestionSeriesInline from experiment.forms import ExperimentCollectionForm, ExperimentForm, ExportForm, TemplateForm, EXPORT_TEMPLATES, QuestionSeriesAdminForm from section.models import Section, Song from result.models import Result @@ -31,47 +32,6 @@ class FeedbackInline(admin.TabularInline): extra = 0 -class QuestionInSeriesInline(admin.TabularInline): - model = QuestionInSeries - extra = 0 - - -class QuestionSeriesInline(admin.TabularInline): - model = QuestionSeries - extra = 0 - show_change_link = True - - -class QuestionAdmin(admin.ModelAdmin): - def has_change_permission(self, request, obj=None): - return obj.editable if obj else False - - -class QuestionGroupAdmin(admin.ModelAdmin): - formfield_overrides = { - models.ManyToManyField: {'widget': CheckboxSelectMultiple}, - } - - def get_form(self, request, obj=None, **kwargs): - form = super().get_form(request, obj, **kwargs) - - if obj and not obj.editable: - for field_name in form.base_fields: - form.base_fields[field_name].disabled = True - - return form - - -class QuestionSeriesAdmin(admin.ModelAdmin): - inlines = [QuestionInSeriesInline] - form = QuestionSeriesAdminForm - - -admin.site.register(Question, QuestionAdmin) -admin.site.register(QuestionGroup, QuestionGroupAdmin) -admin.site.register(QuestionSeries, QuestionSeriesAdmin) - - class ExperimentAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): list_display = ('image_preview', 'experiment_name_link', 'experiment_slug_link', 'rules', diff --git a/backend/experiment/management/commands/createquestions.py b/backend/experiment/management/commands/createquestions.py index a9288838d..79cbb076e 100644 --- a/backend/experiment/management/commands/createquestions.py +++ b/backend/experiment/management/commands/createquestions.py @@ -1,7 +1,7 @@ from django.core.management.base import BaseCommand -from experiment.questions import create_default_questions +from question.questions import create_default_questions class Command(BaseCommand): diff --git a/backend/experiment/management/commands/templates/experiment.py b/backend/experiment/management/commands/templates/experiment.py index 20250753d..b1e7f8366 100644 --- a/backend/experiment/management/commands/templates/experiment.py +++ b/backend/experiment/management/commands/templates/experiment.py @@ -4,8 +4,8 @@ from experiment.actions import Consent, BooleanQuestion, Explainer, Final, Form, Playlist, Step, Trial from experiment.actions.playback import Autoplay -from experiment.questions.demographics import EXTRA_DEMOGRAPHICS -from experiment.questions.utils import question_by_key +from question.demographics import EXTRA_DEMOGRAPHICS +from question.utils import question_by_key from experiment.rules.base import Base from result.utils import prepare_result diff --git a/backend/experiment/migrations/0017_experiment_add_questions_field.py b/backend/experiment/migrations/0017_experiment_add_questions_field.py index 4cd0f1abb..efad68be0 100644 --- a/backend/experiment/migrations/0017_experiment_add_questions_field.py +++ b/backend/experiment/migrations/0017_experiment_add_questions_field.py @@ -2,7 +2,6 @@ import django.contrib.postgres.fields from django.db import migrations, models -import experiment.questions class Migration(migrations.Migration): @@ -16,7 +15,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='experiment', name='questions', - field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('DEMOGRAPHICS', [('dgf_gender_identity', '(dgf_gender_identity) With which gender do you currently most identify?'), ('dgf_generation', '(dgf_generation) When were you born?'), ('dgf_country_of_origin', '(dgf_country_of_origin) In which country did you spend the most formative years of your childhood and youth?'), ('dgf_education', '(dgf_education) What is the highest educational qualification that you have attained?'), ('dgf_country_of_residence', '(dgf_country_of_residence) In which country do you currently reside?'), ('dgf_genre_preference', '(dgf_genre_preference) To which group of musical genres do you currently listen most?')]), ('EXTRA_DEMOGRAPHICS', [('dgf_age', '(dgf_age) What is your age?'), ('dgf_country_of_origin_open', '(dgf_country_of_origin_open) In which country did you spend the most formative years of your childhood and youth?'), ('dgf_country_of_residence_open', '(dgf_country_of_residence_open) In which country do you currently reside?'), ('dgf_native_language', '(dgf_native_language) What is your native language?'), ('dgf_highest_qualification_expectation', '(dgf_highest_qualification_expectation) If you are still in education, what is the highest qualification you expect to obtain?'), ('dgf_occupational_status', '(dgf_occupational_status) Occupational status'), ('dgf_gender_reduced', '(dgf_gender_reduced) What is your gender?'), ('dgf_musical_experience', '(dgf_musical_experience) Please select your level of musical experience:')]), ('MSI_F1_ACTIVE_ENGAGEMENT', [('msi_01_music_activities', '(msi_01_music_activities) I spend a lot of my free time doing music-related activities.'), ('msi_03_writing', '(msi_03_writing) I enjoy writing about music, for example on blogs and forums.'), ('msi_08_intrigued_styles', '(msi_08_intrigued_styles) I’m intrigued by musical styles I’m not familiar with and want to find out more.'), ('msi_15_internet_search_music', '(msi_15_internet_search_music) I often read or search the internet for things related to music.'), ('msi_21_spend_income', '(msi_21_spend_income) I don’t spend much of my disposable income on music.'), ('msi_24_music_addiction', '(msi_24_music_addiction) Music is kind of an addiction for me: I couldn’t live without it.'), ('msi_28_track_new', '(msi_28_track_new) I keep track of new music that I come across (e.g. new artists or recordings).'), ('msi_34_attended_events', '(msi_34_attended_events) I have attended _ live music events as an audience member in the past twelve months.'), ('msi_38_listen_music', '(msi_38_listen_music) I listen attentively to music for _ per day.')]), ('MSI_F2_PERCEPTUAL_ABILITIES', [('msi_05_good_singer', '(msi_05_good_singer) I am able to judge whether someone is a good singer or not.'), ('msi_06_song_first_time', '(msi_06_song_first_time) I usually know when I’m hearing a song for the first time.'), ('msi_11_spot_mistakes', '(msi_11_spot_mistakes) I find it difficult to spot mistakes in a performance of a song even if I know the tune.'), ('msi_12_performance_diff', '(msi_12_performance_diff) I can compare and discuss differences between two performances or versions of the same piece of music.'), ('msi_13_trouble_recognising', '(msi_13_trouble_recognising) I have trouble recognising a familiar song when played in a different way or by a different performer.'), ('msi_18_out_of_beat', '(msi_18_out_of_beat) I can tell when people sing or play out of time with the beat.'), ('msi_22_out_of_tune', '(msi_22_out_of_tune) I can tell when people sing or play out of tune.'), ('msi_23_no_idea_in_tune', '(msi_23_no_idea_in_tune) When I sing, I have no idea whether I’m in tune or not.'), ('msi_26_genre', '(msi_26_genre) When I hear a piece of music I can usually identify its genre.')]), ('MSI_F3_MUSICAL_TRAINING', [('msi_14_never_complimented', '(msi_14_never_complimented) I have never been complimented for my talents as a musical performer.'), ('msi_27_consider_musician', '(msi_27_consider_musician) I would not consider myself a musician.'), ('msi_32_practice_years', '(msi_32_practice_years) I engaged in regular, daily practice of a musical instrument (including voice) for _ years.'), ('msi_33_practice_daily', '(msi_33_practice_daily) At the peak of my interest, I practised my primary instrument for _ hours per day.'), ('msi_35_theory_training', '(msi_35_theory_training) I have had formal training in music theory for _ years.'), ('msi_36_instrumental_training', '(msi_36_instrumental_training) I have had _ years of formal training on a musical instrument (including voice) during my lifetime.'), ('msi_37_play_instruments', '(msi_37_play_instruments) How many musical instruments can you play?')]), ('MSI_F4_SINGING_ABILITIES', [('msi_04_sing_along', '(msi_04_sing_along) If somebody starts singing a song I don’t know, I can usually join in.'), ('msi_07_from_memory', '(msi_07_from_memory) I can sing or play music from memory.'), ('msi_10_sing_with_recording', '(msi_10_sing_with_recording) I am able to hit the right notes when I sing along with a recording.'), ('msi_17_not_sing_harmony', '(msi_17_not_sing_harmony) I am not able to sing in harmony when somebody is singing a familiar tune.'), ('msi_25_sing_public', '(msi_25_sing_public) I don’t like singing in public because I’m afraid that I would sing wrong notes.'), ('msi_29_sing_after_hearing', '(msi_29_sing_after_hearing) After hearing a new song two or three times, I can usually sing it by myself.'), ('msi_30_sing_back', '(msi_30_sing_back) I only need to hear a new tune once and I can sing it back hours later.')]), ('MSI_F5_EMOTIONS', [('msi_02_shivers', '(msi_02_shivers) I sometimes choose music that can trigger shivers down my spine.'), ('msi_09_rarely_emotions', '(msi_09_rarely_emotions) Pieces of music rarely evoke emotions for me.'), ('msi_16_motivate', '(msi_16_motivate) I often pick certain music to motivate or excite me.'), ('msi_19_identify_special', '(msi_19_identify_special) I am able to identify what is special about a given musical piece.'), ('msi_20_talk_emotions', '(msi_20_talk_emotions) I am able to talk about the emotions that a piece of music evokes for me.'), ('msi_31_memories', '(msi_31_memories) Music can evoke my memories of past people and places.')]), ('MSI_OTHER', [('msi_39_best_instrument', '(msi_39_best_instrument) The instrument I play best, including voice (or none), is:'), ('ST_01_age_instrument', '(ST_01_age_instrument) What age did you start to play an instrument?'), ('AP_01_absolute_pitch', "(AP_01_absolute_pitch) Do you have absolute pitch? Absolute or perfect pitch is the ability to recognise and name an isolated musical tone without a reference tone, e.g. being able to say 'F#' if someone plays that note on the piano.")]), ('LANGUAGE', [('lang_experience', '(lang_experience) Please rate your previous experience:'), ('lang_mother', '(lang_mother) What is your mother tongue?'), ('lang_second', '(lang_second) What is your second language, if applicable?'), ('lang_third', '(lang_third) What is your third language, if applicable?')]), ('MUSICGENS_17_W_VARIANTS', [('P01_1', '(P01_1) Can you clap in time with a musical beat?'), ('P01_2', '(P01_2) I can tap my foot in time with the beat of the music I hear.'), ('P01_3', '(P01_3) When listening to music, can you move in time with the beat?'), ('P02_1', '(P02_1) I can recognise a piece of music after hearing just a few notes.'), ('P02_2', '(P02_2) I can easily recognise a familiar song.'), ('P02_3', "(P02_3) When I hear the beginning of a song I know immediately whether I've heard it before or not."), ('P03_1', '(P03_1) I can tell when people sing out of tune.'), ('P03_2', '(P03_2) I am able to judge whether someone is a good singer or not.'), ('P03_3', '(P03_3) I find it difficult to spot mistakes in a performance of a song even if I know the tune.'), ('P04_1', '(P04_1) I feel chills when I hear music that I like.'), ('P04_2', '(P04_2) I get emotional listening to certain pieces of music.'), ('P04_3', '(P04_3) I become tearful or cry when I listen to a melody that I like very much.'), ('P04_4', '(P04_4) Music gives me shivers or goosebumps.'), ('P05_1', "(P05_1) When I listen to music I'm absorbed by it."), ('P05_2', '(P05_2) While listening to music, I become so involved that I forget about myself and my surroundings.'), ('P05_3', "(P05_3) When I listen to music I get so caught up in it that I don't notice anything."), ('P05_4', "(P05_4) I feel like I am 'one' with the music."), ('P05_5', '(P05_5) I lose myself in music.'), ('P06_1', '(P06_1) I like listening to music.'), ('P06_2', '(P06_2) I enjoy music.'), ('P06_3', '(P06_3) I listen to music for pleasure.'), ('P06_4', "(P06_4) Music is kind of an addiction for me - I couldn't live without it."), ('P07_1', '(P07_1) I can tell when people sing or play out of time with the beat of the music.'), ('P07_2', '(P07_2) I can hear when people are not in sync when they play a song.'), ('P07_3', '(P07_3) I can tell when music is sung or played in time with the beat.'), ('P08_1', '(P08_1) I can sing or play a song from memory.'), ('P08_2', '(P08_2) Singing or playing music from memory is easy for me.'), ('P08_3', '(P08_3) I find it hard to sing or play a song from memory.'), ('P09_1', "(P09_1) When I sing, I have no idea whether I'm in tune or not."), ('P09_2', '(P09_2) I am able to hit the right notes when I sing along with a recording.'), ('P09_3', '(P09_3) I can sing along with other people.'), ('P10_1', '(P10_1) I have no sense for rhythm (when I listen, play or dance to music).'), ('P10_2', '(P10_2) Understanding the rhythm of a piece is easy for me (when I listen, play or dance to music).'), ('P10_3', '(P10_3) I have a good sense of rhythm (when I listen, play, or dance to music).'), ('P11_1', "(P11_1) Do you have absolute pitch? Absolute pitch is the ability to recognise and name an isolated musical tone without a reference tone, e.g. being able to say 'F#' if someone plays that note on the piano."), ('P11_2', '(P11_2) Do you have perfect pitch?'), ('P11_3', "(P11_3) If someone plays a note on an instrument and you can't see what note it is, can you still name it (e.g. say that is a 'C' or an 'F')?"), ('P12_1', '(P12_1) Can you hear the difference between two melodies?'), ('P12_2', '(P12_2) I can recognise differences between melodies even if they are similar.'), ('P12_3', '(P12_3) I can tell when two melodies are the same or different.'), ('P13_1', '(P13_1) I make up new melodies in my mind.'), ('P13_2', "(P13_2) I make up songs, even when I'm just singing to myself."), ('P13_3', '(P13_3) I like to play around with new melodies that come to my mind.'), ('P14_1', '(P14_1) I have a melody stuck in my mind.'), ('P14_2', '(P14_2) I experience earworms.'), ('P14_3', '(P14_3) I get music stuck in my head.'), ('P14_4', '(P14_4) I have a piece of music stuck on repeat in my head.'), ('P15_1', '(P15_1) Music makes me dance.'), ('P15_2', "(P15_2) I don't like to dance, not even with music I like."), ('P15_3', '(P15_3) I can dance to a beat.'), ('P15_4', '(P15_4) I easily get into a groove when listening to music.'), ('P16_1', '(P16_1) Can you hear the difference between two rhythms?'), ('P16_2', '(P16_2) I can tell when two rhythms are the same or different.'), ('P16_3', '(P16_3) I can recognise differences between rhythms even if they are similar.'), ('P17_1', "(P17_1) I can't help humming or singing along to music that I like."), ('P17_2', "(P17_2) When I hear a tune I like a lot I can't help tapping or moving to its beat."), ('P17_3', '(P17_3) Hearing good music makes me want to sing along.')]), ('STOMP', [('stomp_alternative', '(stomp_alternative) How much do you like alternative music?'), ('stomp_blues', '(stomp_blues) How much do you like blues music?'), ('stomp_classical', '(stomp_classical) How much do you like classical music?'), ('stomp_country', '(stomp_country) How much do you like country music?'), ('stomp_dance', '(stomp_dance) How much do you like dance and electronic music?'), ('stomp_folk', '(stomp_folk) How much do you like folk music?'), ('stomp_funk', '(stomp_funk) How much do you like funk music?'), ('stomp_gospel', '(stomp_gospel) How much do you like gospel music?'), ('stomp_metal', '(stomp_metal) How much do you like heavy metal music?'), ('stomp_world', '(stomp_world) How much do you like world music?'), ('stomp_jazz', '(stomp_jazz) How much do you like jazz music?'), ('stomp_new_age', '(stomp_new_age) How much do you like new-age music?'), ('stomp_opera', '(stomp_opera) How much do you like opera music?'), ('stomp_pop', '(stomp_pop) How much do you like pop music?'), ('stomp_punk', '(stomp_punk) How much do you like punk music?'), ('stomp_rap', '(stomp_rap) How much do you like rap and hip-hop music?'), ('stomp_reggae', '(stomp_reggae) How much do you like reggae music?'), ('stomp_religious', '(stomp_religious) How much do you like religious music?'), ('stomp_rock', '(stomp_rock) How much do you like rock music?'), ('stomp_rnb', '(stomp_rnb) How much do you like soul and R&B music?'), ('stomp_bluegrass', '(stomp_bluegrass) How much do you like bluegrass music?'), ('stomp_oldies', '(stomp_oldies) How much do you like oldies music?'), ('stomp_soundtracks', '(stomp_soundtracks) How much do you like soundtracks and theme-song music?')]), ('TIPI', [('tipi_op', '(tipi_op) I see myself as open to new experiences and complex.'), ('tipi_on', '(tipi_on) I see myself as conventional and uncreative.'), ('tipi_cp', '(tipi_cp) I see myself as dependable and self-disciplined.'), ('tipi_cn', '(tipi_cn) I see myself as disorganised and careless.'), ('tipi_ep', '(tipi_ep) I see myself as extraverted and enthusiastic.'), ('tipi_en', '(tipi_en) I see myself as reserved and quiet.'), ('tipi_ap', '(tipi_ap) I see myself as sympathetic and warm.'), ('tipi_an', '(tipi_an) I see myself as critical and quarrelsome.'), ('tipi_np', '(tipi_np) I see myself as anxious and easily upset.'), ('tipi_nn', '(tipi_nn) I see myself as calm and emotionally stable.')]), ('OTHER', [('dgf_region_of_origin', '(dgf_region_of_origin) In which region did you spend the most formative years of your childhood and youth?'), ('dgf_region_of_residence', '(dgf_region_of_residence) In which region do you currently reside?'), ('dgf_gender_identity_zh', '(dgf_gender_identity_zh) 您目前对自己的性别认识?'), ('dgf_genre_preference_zh', '(dgf_genre_preference_zh) To which group of musical genres do you currently listen most?'), ('contact', '(contact) Contact (optional):')])]), blank=True, default=experiment.questions.get_default_question_keys, size=None), + field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(choices=[('DEMOGRAPHICS', [('dgf_gender_identity', '(dgf_gender_identity) With which gender do you currently most identify?'), ('dgf_generation', '(dgf_generation) When were you born?'), ('dgf_country_of_origin', '(dgf_country_of_origin) In which country did you spend the most formative years of your childhood and youth?'), ('dgf_education', '(dgf_education) What is the highest educational qualification that you have attained?'), ('dgf_country_of_residence', '(dgf_country_of_residence) In which country do you currently reside?'), ('dgf_genre_preference', '(dgf_genre_preference) To which group of musical genres do you currently listen most?')]), ('EXTRA_DEMOGRAPHICS', [('dgf_age', '(dgf_age) What is your age?'), ('dgf_country_of_origin_open', '(dgf_country_of_origin_open) In which country did you spend the most formative years of your childhood and youth?'), ('dgf_country_of_residence_open', '(dgf_country_of_residence_open) In which country do you currently reside?'), ('dgf_native_language', '(dgf_native_language) What is your native language?'), ('dgf_highest_qualification_expectation', '(dgf_highest_qualification_expectation) If you are still in education, what is the highest qualification you expect to obtain?'), ('dgf_occupational_status', '(dgf_occupational_status) Occupational status'), ('dgf_gender_reduced', '(dgf_gender_reduced) What is your gender?'), ('dgf_musical_experience', '(dgf_musical_experience) Please select your level of musical experience:')]), ('MSI_F1_ACTIVE_ENGAGEMENT', [('msi_01_music_activities', '(msi_01_music_activities) I spend a lot of my free time doing music-related activities.'), ('msi_03_writing', '(msi_03_writing) I enjoy writing about music, for example on blogs and forums.'), ('msi_08_intrigued_styles', '(msi_08_intrigued_styles) I’m intrigued by musical styles I’m not familiar with and want to find out more.'), ('msi_15_internet_search_music', '(msi_15_internet_search_music) I often read or search the internet for things related to music.'), ('msi_21_spend_income', '(msi_21_spend_income) I don’t spend much of my disposable income on music.'), ('msi_24_music_addiction', '(msi_24_music_addiction) Music is kind of an addiction for me: I couldn’t live without it.'), ('msi_28_track_new', '(msi_28_track_new) I keep track of new music that I come across (e.g. new artists or recordings).'), ('msi_34_attended_events', '(msi_34_attended_events) I have attended _ live music events as an audience member in the past twelve months.'), ('msi_38_listen_music', '(msi_38_listen_music) I listen attentively to music for _ per day.')]), ('MSI_F2_PERCEPTUAL_ABILITIES', [('msi_05_good_singer', '(msi_05_good_singer) I am able to judge whether someone is a good singer or not.'), ('msi_06_song_first_time', '(msi_06_song_first_time) I usually know when I’m hearing a song for the first time.'), ('msi_11_spot_mistakes', '(msi_11_spot_mistakes) I find it difficult to spot mistakes in a performance of a song even if I know the tune.'), ('msi_12_performance_diff', '(msi_12_performance_diff) I can compare and discuss differences between two performances or versions of the same piece of music.'), ('msi_13_trouble_recognising', '(msi_13_trouble_recognising) I have trouble recognising a familiar song when played in a different way or by a different performer.'), ('msi_18_out_of_beat', '(msi_18_out_of_beat) I can tell when people sing or play out of time with the beat.'), ('msi_22_out_of_tune', '(msi_22_out_of_tune) I can tell when people sing or play out of tune.'), ('msi_23_no_idea_in_tune', '(msi_23_no_idea_in_tune) When I sing, I have no idea whether I’m in tune or not.'), ('msi_26_genre', '(msi_26_genre) When I hear a piece of music I can usually identify its genre.')]), ('MSI_F3_MUSICAL_TRAINING', [('msi_14_never_complimented', '(msi_14_never_complimented) I have never been complimented for my talents as a musical performer.'), ('msi_27_consider_musician', '(msi_27_consider_musician) I would not consider myself a musician.'), ('msi_32_practice_years', '(msi_32_practice_years) I engaged in regular, daily practice of a musical instrument (including voice) for _ years.'), ('msi_33_practice_daily', '(msi_33_practice_daily) At the peak of my interest, I practised my primary instrument for _ hours per day.'), ('msi_35_theory_training', '(msi_35_theory_training) I have had formal training in music theory for _ years.'), ('msi_36_instrumental_training', '(msi_36_instrumental_training) I have had _ years of formal training on a musical instrument (including voice) during my lifetime.'), ('msi_37_play_instruments', '(msi_37_play_instruments) How many musical instruments can you play?')]), ('MSI_F4_SINGING_ABILITIES', [('msi_04_sing_along', '(msi_04_sing_along) If somebody starts singing a song I don’t know, I can usually join in.'), ('msi_07_from_memory', '(msi_07_from_memory) I can sing or play music from memory.'), ('msi_10_sing_with_recording', '(msi_10_sing_with_recording) I am able to hit the right notes when I sing along with a recording.'), ('msi_17_not_sing_harmony', '(msi_17_not_sing_harmony) I am not able to sing in harmony when somebody is singing a familiar tune.'), ('msi_25_sing_public', '(msi_25_sing_public) I don’t like singing in public because I’m afraid that I would sing wrong notes.'), ('msi_29_sing_after_hearing', '(msi_29_sing_after_hearing) After hearing a new song two or three times, I can usually sing it by myself.'), ('msi_30_sing_back', '(msi_30_sing_back) I only need to hear a new tune once and I can sing it back hours later.')]), ('MSI_F5_EMOTIONS', [('msi_02_shivers', '(msi_02_shivers) I sometimes choose music that can trigger shivers down my spine.'), ('msi_09_rarely_emotions', '(msi_09_rarely_emotions) Pieces of music rarely evoke emotions for me.'), ('msi_16_motivate', '(msi_16_motivate) I often pick certain music to motivate or excite me.'), ('msi_19_identify_special', '(msi_19_identify_special) I am able to identify what is special about a given musical piece.'), ('msi_20_talk_emotions', '(msi_20_talk_emotions) I am able to talk about the emotions that a piece of music evokes for me.'), ('msi_31_memories', '(msi_31_memories) Music can evoke my memories of past people and places.')]), ('MSI_OTHER', [('msi_39_best_instrument', '(msi_39_best_instrument) The instrument I play best, including voice (or none), is:'), ('ST_01_age_instrument', '(ST_01_age_instrument) What age did you start to play an instrument?'), ('AP_01_absolute_pitch', "(AP_01_absolute_pitch) Do you have absolute pitch? Absolute or perfect pitch is the ability to recognise and name an isolated musical tone without a reference tone, e.g. being able to say 'F#' if someone plays that note on the piano.")]), ('LANGUAGE', [('lang_experience', '(lang_experience) Please rate your previous experience:'), ('lang_mother', '(lang_mother) What is your mother tongue?'), ('lang_second', '(lang_second) What is your second language, if applicable?'), ('lang_third', '(lang_third) What is your third language, if applicable?')]), ('MUSICGENS_17_W_VARIANTS', [('P01_1', '(P01_1) Can you clap in time with a musical beat?'), ('P01_2', '(P01_2) I can tap my foot in time with the beat of the music I hear.'), ('P01_3', '(P01_3) When listening to music, can you move in time with the beat?'), ('P02_1', '(P02_1) I can recognise a piece of music after hearing just a few notes.'), ('P02_2', '(P02_2) I can easily recognise a familiar song.'), ('P02_3', "(P02_3) When I hear the beginning of a song I know immediately whether I've heard it before or not."), ('P03_1', '(P03_1) I can tell when people sing out of tune.'), ('P03_2', '(P03_2) I am able to judge whether someone is a good singer or not.'), ('P03_3', '(P03_3) I find it difficult to spot mistakes in a performance of a song even if I know the tune.'), ('P04_1', '(P04_1) I feel chills when I hear music that I like.'), ('P04_2', '(P04_2) I get emotional listening to certain pieces of music.'), ('P04_3', '(P04_3) I become tearful or cry when I listen to a melody that I like very much.'), ('P04_4', '(P04_4) Music gives me shivers or goosebumps.'), ('P05_1', "(P05_1) When I listen to music I'm absorbed by it."), ('P05_2', '(P05_2) While listening to music, I become so involved that I forget about myself and my surroundings.'), ('P05_3', "(P05_3) When I listen to music I get so caught up in it that I don't notice anything."), ('P05_4', "(P05_4) I feel like I am 'one' with the music."), ('P05_5', '(P05_5) I lose myself in music.'), ('P06_1', '(P06_1) I like listening to music.'), ('P06_2', '(P06_2) I enjoy music.'), ('P06_3', '(P06_3) I listen to music for pleasure.'), ('P06_4', "(P06_4) Music is kind of an addiction for me - I couldn't live without it."), ('P07_1', '(P07_1) I can tell when people sing or play out of time with the beat of the music.'), ('P07_2', '(P07_2) I can hear when people are not in sync when they play a song.'), ('P07_3', '(P07_3) I can tell when music is sung or played in time with the beat.'), ('P08_1', '(P08_1) I can sing or play a song from memory.'), ('P08_2', '(P08_2) Singing or playing music from memory is easy for me.'), ('P08_3', '(P08_3) I find it hard to sing or play a song from memory.'), ('P09_1', "(P09_1) When I sing, I have no idea whether I'm in tune or not."), ('P09_2', '(P09_2) I am able to hit the right notes when I sing along with a recording.'), ('P09_3', '(P09_3) I can sing along with other people.'), ('P10_1', '(P10_1) I have no sense for rhythm (when I listen, play or dance to music).'), ('P10_2', '(P10_2) Understanding the rhythm of a piece is easy for me (when I listen, play or dance to music).'), ('P10_3', '(P10_3) I have a good sense of rhythm (when I listen, play, or dance to music).'), ('P11_1', "(P11_1) Do you have absolute pitch? Absolute pitch is the ability to recognise and name an isolated musical tone without a reference tone, e.g. being able to say 'F#' if someone plays that note on the piano."), ('P11_2', '(P11_2) Do you have perfect pitch?'), ('P11_3', "(P11_3) If someone plays a note on an instrument and you can't see what note it is, can you still name it (e.g. say that is a 'C' or an 'F')?"), ('P12_1', '(P12_1) Can you hear the difference between two melodies?'), ('P12_2', '(P12_2) I can recognise differences between melodies even if they are similar.'), ('P12_3', '(P12_3) I can tell when two melodies are the same or different.'), ('P13_1', '(P13_1) I make up new melodies in my mind.'), ('P13_2', "(P13_2) I make up songs, even when I'm just singing to myself."), ('P13_3', '(P13_3) I like to play around with new melodies that come to my mind.'), ('P14_1', '(P14_1) I have a melody stuck in my mind.'), ('P14_2', '(P14_2) I experience earworms.'), ('P14_3', '(P14_3) I get music stuck in my head.'), ('P14_4', '(P14_4) I have a piece of music stuck on repeat in my head.'), ('P15_1', '(P15_1) Music makes me dance.'), ('P15_2', "(P15_2) I don't like to dance, not even with music I like."), ('P15_3', '(P15_3) I can dance to a beat.'), ('P15_4', '(P15_4) I easily get into a groove when listening to music.'), ('P16_1', '(P16_1) Can you hear the difference between two rhythms?'), ('P16_2', '(P16_2) I can tell when two rhythms are the same or different.'), ('P16_3', '(P16_3) I can recognise differences between rhythms even if they are similar.'), ('P17_1', "(P17_1) I can't help humming or singing along to music that I like."), ('P17_2', "(P17_2) When I hear a tune I like a lot I can't help tapping or moving to its beat."), ('P17_3', '(P17_3) Hearing good music makes me want to sing along.')]), ('STOMP', [('stomp_alternative', '(stomp_alternative) How much do you like alternative music?'), ('stomp_blues', '(stomp_blues) How much do you like blues music?'), ('stomp_classical', '(stomp_classical) How much do you like classical music?'), ('stomp_country', '(stomp_country) How much do you like country music?'), ('stomp_dance', '(stomp_dance) How much do you like dance and electronic music?'), ('stomp_folk', '(stomp_folk) How much do you like folk music?'), ('stomp_funk', '(stomp_funk) How much do you like funk music?'), ('stomp_gospel', '(stomp_gospel) How much do you like gospel music?'), ('stomp_metal', '(stomp_metal) How much do you like heavy metal music?'), ('stomp_world', '(stomp_world) How much do you like world music?'), ('stomp_jazz', '(stomp_jazz) How much do you like jazz music?'), ('stomp_new_age', '(stomp_new_age) How much do you like new-age music?'), ('stomp_opera', '(stomp_opera) How much do you like opera music?'), ('stomp_pop', '(stomp_pop) How much do you like pop music?'), ('stomp_punk', '(stomp_punk) How much do you like punk music?'), ('stomp_rap', '(stomp_rap) How much do you like rap and hip-hop music?'), ('stomp_reggae', '(stomp_reggae) How much do you like reggae music?'), ('stomp_religious', '(stomp_religious) How much do you like religious music?'), ('stomp_rock', '(stomp_rock) How much do you like rock music?'), ('stomp_rnb', '(stomp_rnb) How much do you like soul and R&B music?'), ('stomp_bluegrass', '(stomp_bluegrass) How much do you like bluegrass music?'), ('stomp_oldies', '(stomp_oldies) How much do you like oldies music?'), ('stomp_soundtracks', '(stomp_soundtracks) How much do you like soundtracks and theme-song music?')]), ('TIPI', [('tipi_op', '(tipi_op) I see myself as open to new experiences and complex.'), ('tipi_on', '(tipi_on) I see myself as conventional and uncreative.'), ('tipi_cp', '(tipi_cp) I see myself as dependable and self-disciplined.'), ('tipi_cn', '(tipi_cn) I see myself as disorganised and careless.'), ('tipi_ep', '(tipi_ep) I see myself as extraverted and enthusiastic.'), ('tipi_en', '(tipi_en) I see myself as reserved and quiet.'), ('tipi_ap', '(tipi_ap) I see myself as sympathetic and warm.'), ('tipi_an', '(tipi_an) I see myself as critical and quarrelsome.'), ('tipi_np', '(tipi_np) I see myself as anxious and easily upset.'), ('tipi_nn', '(tipi_nn) I see myself as calm and emotionally stable.')]), ('OTHER', [('dgf_region_of_origin', '(dgf_region_of_origin) In which region did you spend the most formative years of your childhood and youth?'), ('dgf_region_of_residence', '(dgf_region_of_residence) In which region do you currently reside?'), ('dgf_gender_identity_zh', '(dgf_gender_identity_zh) 您目前对自己的性别认识?'), ('dgf_genre_preference_zh', '(dgf_genre_preference_zh) To which group of musical genres do you currently listen most?'), ('contact', '(contact) Contact (optional):')])]), blank=True, default=[], size=None), ), migrations.AlterField( model_name='experiment', diff --git a/backend/experiment/migrations/0034_add_question_model.py b/backend/experiment/migrations/0034_add_question_model.py index a28145781..a064254b6 100644 --- a/backend/experiment/migrations/0034_add_question_model.py +++ b/backend/experiment/migrations/0034_add_question_model.py @@ -1,7 +1,6 @@ -# Generated by Django 3.2.25 on 2024-04-16 11:06 +# Generated by Django 3.2.25 on 2024-05-09 11:52 -from django.db import migrations, models -import django.db.models.deletion +from django.db import migrations class Migration(migrations.Migration): @@ -11,67 +10,8 @@ class Migration(migrations.Migration): ] operations = [ - migrations.CreateModel( - name='Question', - fields=[ - ('key', models.CharField(max_length=128, primary_key=True, serialize=False)), - ('question', models.CharField(max_length=1024)), - ('editable', models.BooleanField(default=True, editable=False)), - ], - options={ - 'ordering': ['key'], - }, - ), - migrations.CreateModel( - name='QuestionInSeries', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('index', models.PositiveIntegerField()), - ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='experiment.question')), - ], - options={ - 'verbose_name_plural': 'Question In Series objects', - 'ordering': ['index'], - }, - ), migrations.RemoveField( model_name='experiment', name='questions', ), - migrations.CreateModel( - name='QuestionSeries', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(default='', max_length=128)), - ('index', models.PositiveIntegerField()), - ('randomize', models.BooleanField(default=False)), - ('experiment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='experiment.experiment')), - ('questions', models.ManyToManyField(through='experiment.QuestionInSeries', to='experiment.Question')), - ], - options={ - 'verbose_name_plural': 'Question Series', - 'ordering': ['index'], - }, - ), - migrations.AddField( - model_name='questioninseries', - name='question_series', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='experiment.questionseries'), - ), - migrations.CreateModel( - name='QuestionGroup', - fields=[ - ('key', models.CharField(max_length=128, primary_key=True, serialize=False)), - ('editable', models.BooleanField(default=True, editable=False)), - ('questions', models.ManyToManyField(to='experiment.Question')), - ], - options={ - 'verbose_name_plural': 'Question Groups', - 'ordering': ['key'], - }, - ), - migrations.AlterUniqueTogether( - name='questioninseries', - unique_together={('question_series', 'question')}, - ), ] diff --git a/backend/experiment/migrations/0035_add_question_model_data.py b/backend/experiment/migrations/0035_add_question_model_data.py index 2c46f296a..ef876714d 100644 --- a/backend/experiment/migrations/0035_add_question_model_data.py +++ b/backend/experiment/migrations/0035_add_question_model_data.py @@ -1,13 +1,10 @@ from django.db import migrations from experiment.models import Experiment -from experiment.questions import create_default_questions def add_default_question_series(apps, schema_editor): - create_default_questions() - for experiment in Experiment.objects.all(): experiment.add_default_question_series() @@ -16,6 +13,7 @@ class Migration(migrations.Migration): dependencies = [ ('experiment', '0034_add_question_model'), + ('question', '0002_add_question_model_data'), ] operations = [ diff --git a/backend/experiment/models.py b/backend/experiment/models.py index 42a563148..6ebb19328 100644 --- a/backend/experiment/models.py +++ b/backend/experiment/models.py @@ -276,6 +276,7 @@ def max_score(self): def add_default_question_series(self): """ Add default question_series to experiment""" from experiment.rules import EXPERIMENT_RULES + from question.models import Question, QuestionSeries, QuestionInSeries question_series = getattr(EXPERIMENT_RULES[self.rules](), "question_series", None) if question_series: for i,question_series in enumerate(question_series): @@ -295,57 +296,3 @@ class Feedback(models.Model): text = models.TextField() experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE) - -class Question(models.Model): - - key = models.CharField(primary_key=True, max_length=128) - question = models.CharField(max_length=1024) - editable = models.BooleanField(default=True, editable=False) - - def __str__(self): - return "("+self.key+") "+ self.question - - class Meta: - ordering = ["key"] - - -class QuestionGroup(models.Model): - - key = models.CharField(primary_key=True, max_length=128) - questions = models.ManyToManyField(Question) - editable = models.BooleanField(default=True, editable=False) - - class Meta: - ordering = ["key"] - verbose_name_plural = "Question Groups" - - def __str__(self): - return self.key - - -class QuestionSeries(models.Model): - - name = models.CharField(default='', max_length=128) - experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE) - index = models.PositiveIntegerField() # index of QuestionSeries within Experiment - questions = models.ManyToManyField(Question, through='QuestionInSeries') - randomize = models.BooleanField(default=False) # randomize questions within QuestionSeries - - class Meta: - ordering = ["index"] - verbose_name_plural = "Question Series" - - def __str__(self): - return "QuestionSeries object ({}): {} questions".format(self.id, self.questioninseries_set.count()) - - -class QuestionInSeries(models.Model): - - question_series = models.ForeignKey(QuestionSeries, on_delete=models.CASCADE) - question = models.ForeignKey(Question, on_delete=models.CASCADE) - index = models.PositiveIntegerField() - - class Meta: - unique_together = ('question_series', 'question') - ordering = ["index"] - verbose_name_plural = "Question In Series objects" diff --git a/backend/experiment/rules/base.py b/backend/experiment/rules/base.py index a91185839..fc6670f4d 100644 --- a/backend/experiment/rules/base.py +++ b/backend/experiment/rules/base.py @@ -5,12 +5,12 @@ from django.conf import settings from experiment.actions import Final, Form, Trial -from experiment.questions.demographics import DEMOGRAPHICS -from experiment.questions.goldsmiths import MSI_OTHER -from experiment.questions.utils import question_by_key, unanswered_questions +from question.demographics import DEMOGRAPHICS +from question.goldsmiths import MSI_OTHER +from question.utils import question_by_key, unanswered_questions from result.score import SCORING_RULES -from experiment.questions import get_questions_from_series, QUESTION_GROUPS +from question.questions import get_questions_from_series, QUESTION_GROUPS logger = logging.getLogger(__name__) diff --git a/backend/experiment/rules/categorization.py b/backend/experiment/rules/categorization.py index 04402b830..437a599ff 100644 --- a/backend/experiment/rules/categorization.py +++ b/backend/experiment/rules/categorization.py @@ -6,8 +6,8 @@ from experiment.actions import Consent, Explainer, Score, Trial, Final from experiment.actions.wrappers import two_alternative_forced -from experiment.questions.demographics import EXTRA_DEMOGRAPHICS -from experiment.questions.utils import question_by_key +from question.demographics import EXTRA_DEMOGRAPHICS +from question.utils import question_by_key from .base import Base import random diff --git a/backend/experiment/rules/gold_msi.py b/backend/experiment/rules/gold_msi.py index bb43265ec..c89f02ce2 100644 --- a/backend/experiment/rules/gold_msi.py +++ b/backend/experiment/rules/gold_msi.py @@ -1,10 +1,10 @@ from django.utils.translation import gettext_lazy as _ from experiment.actions import Consent, FrontendStyle, EFrontendStyle -from experiment.questions.goldsmiths import MSI_F3_MUSICAL_TRAINING -from experiment.questions.demographics import EXTRA_DEMOGRAPHICS -from experiment.questions.utils import question_by_key -from experiment.questions import QUESTION_GROUPS +from question.goldsmiths import MSI_F3_MUSICAL_TRAINING +from question.demographics import EXTRA_DEMOGRAPHICS +from question.utils import question_by_key +from question.questions import QUESTION_GROUPS from experiment.actions.utils import final_action_with_optional_button from .base import Base diff --git a/backend/experiment/rules/hooked.py b/backend/experiment/rules/hooked.py index da75d476c..d3eda641c 100644 --- a/backend/experiment/rules/hooked.py +++ b/backend/experiment/rules/hooked.py @@ -8,14 +8,14 @@ from experiment.actions import Consent, Explainer, Final, Playlist, Score, Step, Trial from experiment.actions.form import BooleanQuestion, Form from experiment.actions.playback import Autoplay -from experiment.questions import QUESTION_GROUPS -from experiment.questions.demographics import DEMOGRAPHICS -from experiment.questions.goldsmiths import MSI_OTHER -from experiment.questions.utils import question_by_key -from experiment.questions.utils import copy_shuffle -from experiment.questions.goldsmiths import MSI_FG_GENERAL, MSI_ALL -from experiment.questions.stomp import STOMP20 -from experiment.questions.tipi import TIPI +from question.questions import QUESTION_GROUPS +from question.demographics import DEMOGRAPHICS +from question.goldsmiths import MSI_OTHER +from question.utils import question_by_key +from question.utils import copy_shuffle +from question.goldsmiths import MSI_FG_GENERAL, MSI_ALL +from question.stomp import STOMP20 +from question.tipi import TIPI from experiment.actions.styles import STYLE_BOOLEAN_NEGATIVE_FIRST from experiment.actions.wrappers import song_sync from result.utils import prepare_result diff --git a/backend/experiment/rules/huang_2022.py b/backend/experiment/rules/huang_2022.py index f33eed2f6..71ab3643d 100644 --- a/backend/experiment/rules/huang_2022.py +++ b/backend/experiment/rules/huang_2022.py @@ -7,11 +7,11 @@ from experiment.actions import HTML, Final, Explainer, Step, Consent, Redirect, Playlist, Trial from experiment.actions.form import BooleanQuestion, Form from experiment.actions.playback import Autoplay -from experiment.questions.demographics import EXTRA_DEMOGRAPHICS -from experiment.questions.goldsmiths import MSI_ALL, MSI_OTHER -from experiment.questions.other import OTHER -from experiment.questions.utils import question_by_key -from experiment.questions import QUESTION_GROUPS +from question.demographics import EXTRA_DEMOGRAPHICS +from question.goldsmiths import MSI_ALL, MSI_OTHER +from question.other import OTHER +from question.utils import question_by_key +from question.questions import QUESTION_GROUPS from experiment.actions.styles import STYLE_BOOLEAN_NEGATIVE_FIRST from result.utils import prepare_result from .hooked import Hooked diff --git a/backend/experiment/rules/matching_pairs.py b/backend/experiment/rules/matching_pairs.py index dc176f096..aabf67602 100644 --- a/backend/experiment/rules/matching_pairs.py +++ b/backend/experiment/rules/matching_pairs.py @@ -6,8 +6,8 @@ from .base import Base from experiment.actions import Consent, Explainer, Final, Playlist, Step, Trial from experiment.actions.playback import MatchingPairs -from experiment.questions.demographics import EXTRA_DEMOGRAPHICS -from experiment.questions.utils import question_by_key +from question.demographics import EXTRA_DEMOGRAPHICS +from question.utils import question_by_key from result.utils import prepare_result from section.models import Section diff --git a/backend/experiment/rules/musical_preferences.py b/backend/experiment/rules/musical_preferences.py index 7e16a9da2..6b39050ea 100644 --- a/backend/experiment/rules/musical_preferences.py +++ b/backend/experiment/rules/musical_preferences.py @@ -3,10 +3,10 @@ from django.utils.translation import gettext_lazy as _ from django.template.loader import render_to_string -from experiment.questions.utils import question_by_key -from experiment.questions.demographics import EXTRA_DEMOGRAPHICS -from experiment.questions.goldsmiths import MSI_F1_ACTIVE_ENGAGEMENT -from experiment.questions.other import OTHER +from question.utils import question_by_key +from question.demographics import EXTRA_DEMOGRAPHICS +from question.goldsmiths import MSI_F1_ACTIVE_ENGAGEMENT +from question.other import OTHER from experiment.actions import Consent, Explainer, Final, HTML, Playlist, Redirect, Step, Trial from experiment.actions.form import BooleanQuestion, ChoiceQuestion, Form, LikertQuestionIcon diff --git a/backend/experiment/rules/speech2song.py b/backend/experiment/rules/speech2song.py index 54fa7ff4f..8de5bc0b6 100644 --- a/backend/experiment/rules/speech2song.py +++ b/backend/experiment/rules/speech2song.py @@ -8,9 +8,9 @@ from experiment.actions import Consent, Explainer, Step, Final, Playlist, Trial from experiment.actions.form import Form, RadiosQuestion from experiment.actions.playback import Autoplay -from experiment.questions.demographics import EXTRA_DEMOGRAPHICS -from experiment.questions.languages import LANGUAGE, LanguageQuestion -from experiment.questions.utils import question_by_key +from question.demographics import EXTRA_DEMOGRAPHICS +from question.languages import LANGUAGE, LanguageQuestion +from question.utils import question_by_key from result.utils import prepare_result diff --git a/backend/experiment/rules/tele_tunes.py b/backend/experiment/rules/tele_tunes.py index a2f98c493..f738e59f4 100644 --- a/backend/experiment/rules/tele_tunes.py +++ b/backend/experiment/rules/tele_tunes.py @@ -1,6 +1,6 @@ -from experiment.questions.musicgens import MUSICGENS_17_W_VARIANTS -from experiment.questions.demographics import DEMOGRAPHICS -from experiment.questions.utils import copy_shuffle +from question.musicgens import MUSICGENS_17_W_VARIANTS +from question.demographics import DEMOGRAPHICS +from question.utils import copy_shuffle from .hooked import Hooked diff --git a/backend/experiment/rules/tests/test_hooked.py b/backend/experiment/rules/tests/test_hooked.py index 8e79a87b9..c398991d1 100644 --- a/backend/experiment/rules/tests/test_hooked.py +++ b/backend/experiment/rules/tests/test_hooked.py @@ -1,7 +1,7 @@ from django.test import TestCase from experiment.models import Experiment -from experiment.questions.musicgens import MUSICGENS_17_W_VARIANTS +from question.musicgens import MUSICGENS_17_W_VARIANTS from participant.models import Participant from result.models import Result from section.models import Playlist diff --git a/backend/experiment/rules/thats_my_song.py b/backend/experiment/rules/thats_my_song.py index 3c3ad70fe..0d2a741ea 100644 --- a/backend/experiment/rules/thats_my_song.py +++ b/backend/experiment/rules/thats_my_song.py @@ -3,9 +3,9 @@ from experiment.actions import Final, Trial from experiment.actions.form import Form, ChoiceQuestion -from experiment.questions.utils import copy_shuffle, question_by_key -from experiment.questions.musicgens import MUSICGENS_17_W_VARIANTS -from experiment.questions import QUESTION_GROUPS +from question.utils import copy_shuffle, question_by_key +from question.musicgens import MUSICGENS_17_W_VARIANTS +from question.questions import QUESTION_GROUPS from .hooked import Hooked from result.utils import prepare_result diff --git a/backend/experiment/rules/visual_matching_pairs.py b/backend/experiment/rules/visual_matching_pairs.py index a664af81d..8bed38df2 100644 --- a/backend/experiment/rules/visual_matching_pairs.py +++ b/backend/experiment/rules/visual_matching_pairs.py @@ -6,8 +6,8 @@ from .base import Base from experiment.actions import Consent, Explainer, Final, Playlist, Step, Trial from experiment.actions.playback import VisualMatchingPairs -from experiment.questions.demographics import EXTRA_DEMOGRAPHICS -from experiment.questions.utils import question_by_key +from question.demographics import EXTRA_DEMOGRAPHICS +from question.utils import question_by_key from result.utils import prepare_result from section.models import Section diff --git a/backend/experiment/static/questionseries_admin.js b/backend/experiment/static/questionseries_admin.js index a871e8bb6..1de2092f5 100644 --- a/backend/experiment/static/questionseries_admin.js +++ b/backend/experiment/static/questionseries_admin.js @@ -3,7 +3,7 @@ document.addEventListener("DOMContentLoaded", (event) => { async function getQuestionGroups(){ - let response = await fetch(`/experiment/question_groups/`) + let response = await fetch(`/question/question_groups/`) if (response.ok) { return await response.json() diff --git a/backend/experiment/urls.py b/backend/experiment/urls.py index 0ff4dabba..4345b2381 100644 --- a/backend/experiment/urls.py +++ b/backend/experiment/urls.py @@ -1,11 +1,10 @@ from django.urls import path from django.views.generic.base import TemplateView -from .views import get_experiment, get_experiment_collection, post_feedback, render_markdown, question_groups, add_default_question_series +from .views import get_experiment, get_experiment_collection, post_feedback, render_markdown, add_default_question_series app_name = 'experiment' urlpatterns = [ - path('question_groups/', question_groups, name='question_groups'), path('add_default_question_series//', add_default_question_series, name='add_default_question_series'), # Experiment path('render_markdown/', render_markdown, name='render_markdown'), diff --git a/backend/experiment/views.py b/backend/experiment/views.py index bc287c035..deb6036dc 100644 --- a/backend/experiment/views.py +++ b/backend/experiment/views.py @@ -10,7 +10,6 @@ from experiment.serializers import serialize_actions, serialize_experiment_collection, serialize_experiment_collection_group from experiment.rules import EXPERIMENT_RULES from experiment.actions.utils import COLLECTION_KEY -from experiment.models import QuestionSeries, QuestionInSeries, Question, QuestionGroup from participant.utils import get_participant logger = logging.getLogger(__name__) @@ -70,13 +69,6 @@ def experiment_or_404(slug): raise Http404("Experiment does not exist") -def question_groups(request): - question_groups = {} - for question_group in QuestionGroup.objects.all(): - question_groups[question_group.key] = [q.key for q in QuestionGroup.objects.get(pk=question_group.key).questions.all()] - return JsonResponse(question_groups) - - def add_default_question_series(request, id): if request.method == "POST": Experiment.objects.get(pk=id).add_default_question_series() diff --git a/backend/question/__init__.py b/backend/question/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/question/admin.py b/backend/question/admin.py new file mode 100644 index 000000000..de18930ba --- /dev/null +++ b/backend/question/admin.py @@ -0,0 +1,46 @@ +from django.contrib import admin +from django.db import models +from question.models import Question, QuestionGroup, QuestionSeries, QuestionInSeries +from django.forms import CheckboxSelectMultiple +from experiment.forms import QuestionSeriesAdminForm + + +class QuestionInSeriesInline(admin.TabularInline): + model = QuestionInSeries + extra = 0 + + +class QuestionSeriesInline(admin.TabularInline): + model = QuestionSeries + extra = 0 + show_change_link = True + + +class QuestionAdmin(admin.ModelAdmin): + def has_change_permission(self, request, obj=None): + return obj.editable if obj else False + + +class QuestionGroupAdmin(admin.ModelAdmin): + formfield_overrides = { + models.ManyToManyField: {'widget': CheckboxSelectMultiple}, + } + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + + if obj and not obj.editable: + for field_name in form.base_fields: + form.base_fields[field_name].disabled = True + + return form + + +class QuestionSeriesAdmin(admin.ModelAdmin): + inlines = [QuestionInSeriesInline] + form = QuestionSeriesAdminForm + + +admin.site.register(Question, QuestionAdmin) +admin.site.register(QuestionGroup, QuestionGroupAdmin) +admin.site.register(QuestionSeries, QuestionSeriesAdmin) diff --git a/backend/question/apps.py b/backend/question/apps.py new file mode 100644 index 000000000..046c7d1ba --- /dev/null +++ b/backend/question/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class QuestionConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'question' diff --git a/backend/experiment/questions/demographics.py b/backend/question/demographics.py similarity index 100% rename from backend/experiment/questions/demographics.py rename to backend/question/demographics.py diff --git a/backend/experiment/fixtures/question.json b/backend/question/fixtures/question.json similarity index 81% rename from backend/experiment/fixtures/question.json rename to backend/question/fixtures/question.json index 58c0e7331..5618e2f57 100644 --- a/backend/experiment/fixtures/question.json +++ b/backend/question/fixtures/question.json @@ -1,6 +1,6 @@ [ { - "model": "experiment.question", + "model": "question.question", "pk": "dgf_generation", "fields": { "question": "When were you born?", @@ -8,7 +8,7 @@ } }, { - "model": "experiment.question", + "model": "question.question", "pk": "dgf_gender_identity", "fields": { "question": "With which gender do you currently most identify?", @@ -16,7 +16,7 @@ } }, { - "model": "experiment.question", + "model": "question.question", "pk": "P01_1", "fields": { "question": "Can you clap in time with a musical beat?", @@ -24,7 +24,7 @@ } }, { - "model": "experiment.question", + "model": "question.question", "pk": "P01_2", "fields": { "question": "I can tap my foot in time with the beat of the music I hear.", @@ -32,7 +32,7 @@ } }, { - "model": "experiment.question", + "model": "question.question", "pk": "P01_3", "fields": { "question": "When listening to music, can you move in time with the beat?", @@ -40,7 +40,7 @@ } }, { - "model": "experiment.question", + "model": "question.question", "pk": "msi_01_music_activities", "fields": { "question": "I spend a lot of my free time doing music-related activities.", @@ -48,7 +48,7 @@ } }, { - "model": "experiment.question", + "model": "question.question", "pk": "msi_03_writing", "fields": { "question": "I enjoy writing about music, for example on blogs and forums.", @@ -56,7 +56,7 @@ } }, { - "model": "experiment.question", + "model": "question.question", "pk": "msi_08_intrigued_styles", "fields": { "question": "I’m intrigued by musical styles I’m not familiar with and want to find out more.", diff --git a/backend/experiment/fixtures/questioninseries.json b/backend/question/fixtures/questioninseries.json similarity index 73% rename from backend/experiment/fixtures/questioninseries.json rename to backend/question/fixtures/questioninseries.json index 28c776b1d..ed392f763 100644 --- a/backend/experiment/fixtures/questioninseries.json +++ b/backend/question/fixtures/questioninseries.json @@ -1,6 +1,6 @@ [ { - "model": "experiment.questioninseries", + "model": "question.questioninseries", "pk": 1, "fields": { "question_series": 1, @@ -9,7 +9,7 @@ } }, { - "model": "experiment.questioninseries", + "model": "question.questioninseries", "pk": 2, "fields": { "question_series": 1, @@ -18,7 +18,7 @@ } }, { - "model": "experiment.questioninseries", + "model": "question.questioninseries", "pk": 3, "fields": { "question_series": 1, @@ -27,7 +27,7 @@ } }, { - "model": "experiment.questioninseries", + "model": "question.questioninseries", "pk": 4, "fields": { "question_series": 2, @@ -36,7 +36,7 @@ } }, { - "model": "experiment.questioninseries", + "model": "question.questioninseries", "pk": 5, "fields": { "question_series": 2, @@ -45,7 +45,7 @@ } }, { - "model": "experiment.questioninseries", + "model": "question.questioninseries", "pk": 6, "fields": { "question_series": 2, @@ -54,7 +54,7 @@ } }, { - "model": "experiment.questioninseries", + "model": "question.questioninseries", "pk": 7, "fields": { "question_series": 2, @@ -63,7 +63,7 @@ } }, { - "model": "experiment.questioninseries", + "model": "question.questioninseries", "pk": 8, "fields": { "question_series": 2, diff --git a/backend/experiment/fixtures/questionseries.json b/backend/question/fixtures/questionseries.json similarity index 72% rename from backend/experiment/fixtures/questionseries.json rename to backend/question/fixtures/questionseries.json index da9e03ab1..32dc4c7c2 100644 --- a/backend/experiment/fixtures/questionseries.json +++ b/backend/question/fixtures/questionseries.json @@ -1,6 +1,6 @@ [ { - "model": "experiment.questionseries", + "model": "question.questionseries", "pk": 1, "fields": { "experiment": 14, @@ -9,7 +9,7 @@ } }, { - "model": "experiment.questionseries", + "model": "question.questionseries", "pk": 2, "fields": { "experiment": 20, diff --git a/backend/experiment/questions/goldsmiths.py b/backend/question/goldsmiths.py similarity index 100% rename from backend/experiment/questions/goldsmiths.py rename to backend/question/goldsmiths.py diff --git a/backend/experiment/questions/languages.py b/backend/question/languages.py similarity index 100% rename from backend/experiment/questions/languages.py rename to backend/question/languages.py diff --git a/backend/question/migrations/0001_add_question_model.py b/backend/question/migrations/0001_add_question_model.py new file mode 100644 index 000000000..80cf94db1 --- /dev/null +++ b/backend/question/migrations/0001_add_question_model.py @@ -0,0 +1,75 @@ +# Generated by Django 3.2.25 on 2024-05-09 11:52 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('experiment', '0034_add_question_model'), + ] + + operations = [ + migrations.CreateModel( + name='Question', + fields=[ + ('key', models.CharField(max_length=128, primary_key=True, serialize=False)), + ('question', models.CharField(max_length=1024)), + ('editable', models.BooleanField(default=True, editable=False)), + ], + options={ + 'ordering': ['key'], + }, + ), + migrations.CreateModel( + name='QuestionInSeries', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('index', models.PositiveIntegerField()), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='question.question')), + ], + options={ + 'verbose_name_plural': 'Question In Series objects', + 'ordering': ['index'], + }, + ), + migrations.CreateModel( + name='QuestionSeries', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='', max_length=128)), + ('index', models.PositiveIntegerField()), + ('randomize', models.BooleanField(default=False)), + ('experiment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='experiment.experiment')), + ('questions', models.ManyToManyField(through='question.QuestionInSeries', to='question.Question')), + ], + options={ + 'verbose_name_plural': 'Question Series', + 'ordering': ['index'], + }, + ), + migrations.AddField( + model_name='questioninseries', + name='question_series', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='question.questionseries'), + ), + migrations.CreateModel( + name='QuestionGroup', + fields=[ + ('key', models.CharField(max_length=128, primary_key=True, serialize=False)), + ('editable', models.BooleanField(default=True, editable=False)), + ('questions', models.ManyToManyField(to='question.Question')), + ], + options={ + 'verbose_name_plural': 'Question Groups', + 'ordering': ['key'], + }, + ), + migrations.AlterUniqueTogether( + name='questioninseries', + unique_together={('question_series', 'question')}, + ), + ] diff --git a/backend/question/migrations/0002_add_question_model_data.py b/backend/question/migrations/0002_add_question_model_data.py new file mode 100644 index 000000000..df584d389 --- /dev/null +++ b/backend/question/migrations/0002_add_question_model_data.py @@ -0,0 +1,19 @@ + +from django.db import migrations +from question.questions import create_default_questions + + +def default_questions(apps, schema_editor): + + create_default_questions() + + +class Migration(migrations.Migration): + + dependencies = [ + ('question', '0001_add_question_model'), + ] + + operations = [ + migrations.RunPython(default_questions, reverse_code=migrations.RunPython.noop), + ] diff --git a/backend/question/migrations/__init__.py b/backend/question/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/question/models.py b/backend/question/models.py new file mode 100644 index 000000000..330c5a266 --- /dev/null +++ b/backend/question/models.py @@ -0,0 +1,58 @@ +from django.db import models +from experiment.models import Experiment + + +class Question(models.Model): + + key = models.CharField(primary_key=True, max_length=128) + question = models.CharField(max_length=1024) + editable = models.BooleanField(default=True, editable=False) + + def __str__(self): + return "("+self.key+") "+ self.question + + class Meta: + ordering = ["key"] + + +class QuestionGroup(models.Model): + + key = models.CharField(primary_key=True, max_length=128) + questions = models.ManyToManyField(Question) + editable = models.BooleanField(default=True, editable=False) + + class Meta: + ordering = ["key"] + verbose_name_plural = "Question Groups" + + def __str__(self): + return self.key + + +class QuestionSeries(models.Model): + + name = models.CharField(default='', max_length=128) + experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE) + index = models.PositiveIntegerField() # index of QuestionSeries within Experiment + questions = models.ManyToManyField(Question, through='QuestionInSeries') + randomize = models.BooleanField(default=False) # randomize questions within QuestionSeries + + class Meta: + ordering = ["index"] + verbose_name_plural = "Question Series" + + def __str__(self): + return "QuestionSeries object ({}): {} questions".format(self.id, self.questioninseries_set.count()) + + +class QuestionInSeries(models.Model): + + question_series = models.ForeignKey(QuestionSeries, on_delete=models.CASCADE) + question = models.ForeignKey(Question, on_delete=models.CASCADE) + index = models.PositiveIntegerField() + + class Meta: + unique_together = ('question_series', 'question') + ordering = ["index"] + verbose_name_plural = "Question In Series objects" + diff --git a/backend/experiment/questions/musicgens.py b/backend/question/musicgens.py similarity index 100% rename from backend/experiment/questions/musicgens.py rename to backend/question/musicgens.py diff --git a/backend/experiment/questions/other.py b/backend/question/other.py similarity index 100% rename from backend/experiment/questions/other.py rename to backend/question/other.py diff --git a/backend/experiment/questions/profile_scoring_rules.py b/backend/question/profile_scoring_rules.py similarity index 100% rename from backend/experiment/questions/profile_scoring_rules.py rename to backend/question/profile_scoring_rules.py diff --git a/backend/experiment/questions/__init__.py b/backend/question/questions.py similarity index 98% rename from backend/experiment/questions/__init__.py rename to backend/question/questions.py index cca778020..93af5f62b 100644 --- a/backend/experiment/questions/__init__.py +++ b/backend/question/questions.py @@ -6,7 +6,7 @@ from .tipi import TIPI from .other import OTHER import random -from experiment.models import QuestionGroup, Question +from .models import QuestionGroup, Question # Default QuestionGroups used by command createquestions QUESTION_GROUPS_DEFAULT = { "DEMOGRAPHICS" : DEMOGRAPHICS, diff --git a/backend/experiment/questions/stomp.py b/backend/question/stomp.py similarity index 100% rename from backend/experiment/questions/stomp.py rename to backend/question/stomp.py diff --git a/backend/experiment/questions/tests.py b/backend/question/tests.py similarity index 100% rename from backend/experiment/questions/tests.py rename to backend/question/tests.py diff --git a/backend/experiment/questions/tipi.py b/backend/question/tipi.py similarity index 100% rename from backend/experiment/questions/tipi.py rename to backend/question/tipi.py diff --git a/backend/question/urls.py b/backend/question/urls.py new file mode 100644 index 000000000..607e7493d --- /dev/null +++ b/backend/question/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from .views import question_groups + +app_name = 'question' + +urlpatterns = [ + path('question_groups/', question_groups, name='question_groups'), +] diff --git a/backend/experiment/questions/utils.py b/backend/question/utils.py similarity index 100% rename from backend/experiment/questions/utils.py rename to backend/question/utils.py diff --git a/backend/question/views.py b/backend/question/views.py new file mode 100644 index 000000000..dbcd5c540 --- /dev/null +++ b/backend/question/views.py @@ -0,0 +1,9 @@ +from django.http import JsonResponse +from question.models import QuestionGroup + + +def question_groups(request): + question_groups = {} + for question_group in QuestionGroup.objects.all(): + question_groups[question_group.key] = [q.key for q in QuestionGroup.objects.get(pk=question_group.key).questions.all()] + return JsonResponse(question_groups) diff --git a/backend/result/utils.py b/backend/result/utils.py index 37eac21c5..2906024e8 100644 --- a/backend/result/utils.py +++ b/backend/result/utils.py @@ -1,7 +1,7 @@ from session.models import Session from .models import Result -from experiment.questions.profile_scoring_rules import PROFILE_SCORING_RULES +from question.profile_scoring_rules import PROFILE_SCORING_RULES from result.score import SCORING_RULES From dc264c8821335a3993009af64faa592ceba4eca0 Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Fri, 10 May 2024 11:07:50 +0200 Subject: [PATCH 010/180] First createquestions then boostrap, add default question series to bootstrapped experiment --- backend/experiment/management/commands/bootstrap.py | 1 + docker-compose-deploy.yml | 2 +- docker-compose.yaml | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/experiment/management/commands/bootstrap.py b/backend/experiment/management/commands/bootstrap.py index 1e2e77c97..bf28f6caa 100644 --- a/backend/experiment/management/commands/bootstrap.py +++ b/backend/experiment/management/commands/bootstrap.py @@ -23,5 +23,6 @@ def handle(self, *args, **options): slug='gold-msi', ) experiment.playlists.add(playlist) + experiment.add_default_question_series() print('Created default experiment') diff --git a/docker-compose-deploy.yml b/docker-compose-deploy.yml index 9aee45926..350fbc7c6 100644 --- a/docker-compose-deploy.yml +++ b/docker-compose-deploy.yml @@ -59,7 +59,7 @@ services: - SQL_HOST=${SQL_HOST} ports: - 8000:8000 - command: bash -c "python manage.py migrate && python manage.py bootstrap && python manage.py createquestions && python manage.py collectstatic --noinput && gunicorn aml.wsgi:application --bind 0.0.0.0:8000" + command: bash -c "python manage.py migrate && python manage.py createquestions && python manage.py bootstrap && python manage.py collectstatic --noinput && gunicorn aml.wsgi:application --bind 0.0.0.0:8000" restart: always client-builder: diff --git a/docker-compose.yaml b/docker-compose.yaml index 15aa9f000..9dbb46100 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -54,7 +54,7 @@ services: - SQL_HOST=${SQL_HOST} ports: - 8000:8000 - command: bash -c "python manage.py migrate && python manage.py bootstrap && python manage.py createquestions && python manage.py runserver 0.0.0.0:8000" + command: bash -c "python manage.py migrate && python manage.py createquestions && python manage.py bootstrap && python manage.py runserver 0.0.0.0:8000" client: build: context: ./frontend From 8ff247a64eaa526ad27a1969f5fa745fe88f3285 Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Fri, 10 May 2024 11:22:17 +0200 Subject: [PATCH 011/180] Convert indentation to spaces in experiment_admin.js and questionseries_admin.js --- backend/experiment/static/experiment_admin.js | 82 +++++++++---------- .../experiment/static/questionseries_admin.js | 68 +++++++-------- 2 files changed, 75 insertions(+), 75 deletions(-) diff --git a/backend/experiment/static/experiment_admin.js b/backend/experiment/static/experiment_admin.js index 194163206..23cbca6ae 100644 --- a/backend/experiment/static/experiment_admin.js +++ b/backend/experiment/static/experiment_admin.js @@ -1,45 +1,45 @@ document.addEventListener("DOMContentLoaded", (event) => { - // Get experiment id from URL - match = window.location.href.match(/\/experiment\/experiment\/(.+)\/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 message = document.createElement("span") - message.id = "id_message" - message.className = "form-row" - - document.querySelector('#questionseries_set-group').append(buttonAddDefaultQuestions, message) - - 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 = "" - } else { - buttonAddDefaultQuestions.disabled = true - message.innerText = "Save Experiment first" - } - } - - async function addDefaultQuestions() { - - const csrftoken = document.querySelector('[name=csrfmiddlewaretoken]').value; - let response = await fetch(`/experiment/add_default_question_series/${experiment_id}/`, - {method:"POST", mode: 'same-origin',headers: {'X-CSRFToken': csrftoken}}) - - if (response.ok) { - location.reload() - } - } + // Get experiment id from URL + match = window.location.href.match(/\/experiment\/experiment\/(.+)\/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 message = document.createElement("span") + message.id = "id_message" + message.className = "form-row" + + document.querySelector('#questionseries_set-group').append(buttonAddDefaultQuestions, message) + + 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 = "" + } else { + buttonAddDefaultQuestions.disabled = true + message.innerText = "Save Experiment first" + } + } + + async function addDefaultQuestions() { + + const csrftoken = document.querySelector('[name=csrfmiddlewaretoken]').value; + let response = await fetch(`/experiment/add_default_question_series/${experiment_id}/`, + {method:"POST", mode: 'same-origin',headers: {'X-CSRFToken': csrftoken}}) + + if (response.ok) { + location.reload() + } + } }) diff --git a/backend/experiment/static/questionseries_admin.js b/backend/experiment/static/questionseries_admin.js index 1de2092f5..9cab6b8b0 100644 --- a/backend/experiment/static/questionseries_admin.js +++ b/backend/experiment/static/questionseries_admin.js @@ -1,50 +1,50 @@ document.addEventListener("DOMContentLoaded", (event) => { - async function getQuestionGroups(){ + async function getQuestionGroups(){ - let response = await fetch(`/question/question_groups/`) + let response = await fetch(`/question/question_groups/`) - if (response.ok) { - return await response.json() - } - } + if (response.ok) { + return await response.json() + } + } - getQuestionGroups().then( (questionGroups) => { + getQuestionGroups().then( (questionGroups) => { - let buttonAddQuestionGroup = document.createElement("input") - buttonAddQuestionGroup.type = "button" - buttonAddQuestionGroup.value = "Add all questions in group" - buttonAddQuestionGroup.addEventListener("click", addQuestionGroup) + let buttonAddQuestionGroup = document.createElement("input") + buttonAddQuestionGroup.type = "button" + buttonAddQuestionGroup.value = "Add all questions in group" + buttonAddQuestionGroup.addEventListener("click", addQuestionGroup) - let selectQuestionGroup = document.createElement("select") + let selectQuestionGroup = document.createElement("select") - Object.keys(questionGroups).sort().forEach( (group) => { - option = document.createElement("option") - option.innerText = group - selectQuestionGroup.append(option) - }) + Object.keys(questionGroups).sort().forEach( (group) => { + option = document.createElement("option") + option.innerText = group + selectQuestionGroup.append(option) + }) - document.querySelector('#questioninseries_set-group').append(buttonAddQuestionGroup, selectQuestionGroup) + document.querySelector('#questioninseries_set-group').append(buttonAddQuestionGroup, selectQuestionGroup) - function addQuestionGroup() { + function addQuestionGroup() { - // "Add another Question in series" is already created by Django - let addQuestionAnchor = document.querySelector(".add-row a") + // "Add another Question in series" is already created by Django + let addQuestionAnchor = document.querySelector(".add-row a") - questionGroups[selectQuestionGroup.value].forEach ( (questionKey) => { + questionGroups[selectQuestionGroup.value].forEach ( (questionKey) => { - totalFormsInput = document.querySelector("#id_questioninseries_set-TOTAL_FORMS") - totalFormsBefore = Number(totalFormsInput.value) - addQuestionAnchor.click() - totalForms = Number(totalFormsInput.value) + totalFormsInput = document.querySelector("#id_questioninseries_set-TOTAL_FORMS") + totalFormsBefore = Number(totalFormsInput.value) + addQuestionAnchor.click() + totalForms = Number(totalFormsInput.value) - if (totalForms == totalFormsBefore + 1) { - questionSelect = document.querySelector(`#id_questioninseries_set-${totalForms-1}-question`) - questionSelect.querySelector(`option[value=${questionKey}]`).selected = true - document.querySelector(`#id_questioninseries_set-${totalForms-1}-index`).value = totalForms - } - }) - } - }) + if (totalForms == totalFormsBefore + 1) { + questionSelect = document.querySelector(`#id_questioninseries_set-${totalForms-1}-question`) + questionSelect.querySelector(`option[value=${questionKey}]`).selected = true + document.querySelector(`#id_questioninseries_set-${totalForms-1}-index`).value = totalForms + } + }) + } + }) }) From 65375bf452ef9b911415e2d1e84adf563fd21577 Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Fri, 10 May 2024 11:34:52 +0200 Subject: [PATCH 012/180] Randomize MSI_F3 questions in rhythm_battery_final.py (former gold_msi.py) --- backend/experiment/rules/rhythm_battery_final.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/experiment/rules/rhythm_battery_final.py b/backend/experiment/rules/rhythm_battery_final.py index fd8c5f3ed..1b9ce090b 100644 --- a/backend/experiment/rules/rhythm_battery_final.py +++ b/backend/experiment/rules/rhythm_battery_final.py @@ -18,7 +18,7 @@ def __init__(self): { "name": "MSI_F3_MUSICAL_TRAINING", "keys": QUESTION_GROUPS["MSI_F3_MUSICAL_TRAINING"], - "randomize": False + "randomize": True }, { "name": "Demographics", From a52f9f23c692947817f87c18c28a64068e142d61 Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Fri, 10 May 2024 13:11:17 +0200 Subject: [PATCH 013/180] Add tests for createquestions command --- backend/experiment/management/__init__.py | 0 backend/experiment/management/tests.py | 9 +++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 backend/experiment/management/__init__.py diff --git a/backend/experiment/management/__init__.py b/backend/experiment/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/experiment/management/tests.py b/backend/experiment/management/tests.py index 4abdd16f3..01006157e 100644 --- a/backend/experiment/management/tests.py +++ b/backend/experiment/management/tests.py @@ -26,7 +26,12 @@ def test_output_csv(self): finally: remove(filename) # Make sure csv file is deleted even if tests fail - - + def test_createquestions(self): + from question.models import Question, QuestionGroup + call_command('createquestions') + self.assertEqual(len(Question.objects.all()), 161) # Only built-in questions in test database + self.assertEqual(len(QuestionGroup.objects.all()), 18) # Only built-in question groups in test database + self.assertEqual(len(Question.objects.filter(key='dgf_country_of_origin')), 1) + self.assertEqual(len(QuestionGroup.objects.filter(key='DEMOGRAPHICS')), 1) From 0fc847467c32d219dedf58ab0a247b8a6d09c351 Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Fri, 10 May 2024 13:34:56 +0200 Subject: [PATCH 014/180] Cleanup: remove get_default_question_keys() --- backend/question/questions.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/backend/question/questions.py b/backend/question/questions.py index 93af5f62b..5347e3ec9 100644 --- a/backend/question/questions.py +++ b/backend/question/questions.py @@ -51,11 +51,6 @@ def get_questions_from_series(questionseries_set): return [QUESTIONS[key] for key in keys_all] -def get_default_question_keys(): - """ For backward compatibility. One of the migrations calls it""" - return [] - - def create_default_questions(): """Creates default questions and question groups in the database""" From 64d16925e010e8153d841847ce76dc6239a82ecd Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Fri, 10 May 2024 15:08:59 +0200 Subject: [PATCH 015/180] Add docstrings to question app --- backend/question/admin.py | 3 +++ backend/question/models.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/backend/question/admin.py b/backend/question/admin.py index de18930ba..bf347088f 100644 --- a/backend/question/admin.py +++ b/backend/question/admin.py @@ -27,6 +27,9 @@ class QuestionGroupAdmin(admin.ModelAdmin): } def get_form(self, request, obj=None, **kwargs): + """This method is needed because setting the QuestionGroup.questions field as readonly + for built-in (i.e., not editable) groups shows the questions as one-line of concatenated strings, which is ugly. + Instead, this method allows to keep the checkboxes, but disabled""" form = super().get_form(request, obj, **kwargs) if obj and not obj.editable: diff --git a/backend/question/models.py b/backend/question/models.py index 330c5a266..37e1a1d7e 100644 --- a/backend/question/models.py +++ b/backend/question/models.py @@ -3,6 +3,7 @@ class Question(models.Model): + """A model that (currently) refers to a built-in question""" key = models.CharField(primary_key=True, max_length=128) question = models.CharField(max_length=1024) @@ -16,6 +17,7 @@ class Meta: class QuestionGroup(models.Model): + """Convenience model for groups of questions to add at once to Experiment QuestionSeries from admin""" key = models.CharField(primary_key=True, max_length=128) questions = models.ManyToManyField(Question) @@ -30,6 +32,7 @@ def __str__(self): class QuestionSeries(models.Model): + """Series of Questions asked in an Experiment""" name = models.CharField(default='', max_length=128) experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE) @@ -46,6 +49,7 @@ def __str__(self): class QuestionInSeries(models.Model): + """Question with its index in QuestionSeries""" question_series = models.ForeignKey(QuestionSeries, on_delete=models.CASCADE) question = models.ForeignKey(Question, on_delete=models.CASCADE) From a323cfeb3e5c404d01e34a07b077ac54d0bee383 Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Sun, 12 May 2024 13:12:51 +0200 Subject: [PATCH 016/180] Move createquestions command tests to question app --- backend/experiment/management/tests.py | 7 ------- backend/question/management/__init__.py | 0 backend/question/management/tests.py | 15 +++++++++++++++ 3 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 backend/question/management/__init__.py create mode 100644 backend/question/management/tests.py diff --git a/backend/experiment/management/tests.py b/backend/experiment/management/tests.py index 01006157e..a030666e8 100644 --- a/backend/experiment/management/tests.py +++ b/backend/experiment/management/tests.py @@ -26,12 +26,5 @@ def test_output_csv(self): finally: remove(filename) # Make sure csv file is deleted even if tests fail - def test_createquestions(self): - from question.models import Question, QuestionGroup - call_command('createquestions') - self.assertEqual(len(Question.objects.all()), 161) # Only built-in questions in test database - self.assertEqual(len(QuestionGroup.objects.all()), 18) # Only built-in question groups in test database - self.assertEqual(len(Question.objects.filter(key='dgf_country_of_origin')), 1) - self.assertEqual(len(QuestionGroup.objects.filter(key='DEMOGRAPHICS')), 1) diff --git a/backend/question/management/__init__.py b/backend/question/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/question/management/tests.py b/backend/question/management/tests.py new file mode 100644 index 000000000..4a3748ed0 --- /dev/null +++ b/backend/question/management/tests.py @@ -0,0 +1,15 @@ +from django.core.management import call_command +from django.test import TestCase + + +class CreateQuestionsTest(TestCase): + + def test_createquestions(self): + from question.models import Question, QuestionGroup + call_command('createquestions') + self.assertEqual(len(Question.objects.all()), 161) # Only built-in questions in test database + self.assertEqual(len(QuestionGroup.objects.all()), 18) # Only built-in question groups in test database + self.assertEqual(len(Question.objects.filter(key='dgf_country_of_origin')), 1) + self.assertEqual(len(QuestionGroup.objects.filter(key='DEMOGRAPHICS')), 1) + + From 8f32b4a5ad6fe332bd5e08ecd82f19d7d9d0bcfa Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Mon, 27 May 2024 10:51:50 +0200 Subject: [PATCH 017/180] chore: Update package version to 2.1.0 (#1026) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a37256e0e..191a531f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "muscle", - "version": "2.0.0", + "version": "2.1.0", "private": false, "description": "The MUSCLE platform is an application that provides an easy way to implement and run online listening experiments for music research.", "license": "MIT", From b5d7330a27fc60bff2d401f19dd774d18f6e9c76 Mon Sep 17 00:00:00 2001 From: BeritJanssen Date: Mon, 27 May 2024 11:31:25 +0200 Subject: [PATCH 018/180] fix: return correct content for first_round --- backend/experiment/rules/thats_my_song.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/experiment/rules/thats_my_song.py b/backend/experiment/rules/thats_my_song.py index 2393328d8..82ad44fb6 100644 --- a/backend/experiment/rules/thats_my_song.py +++ b/backend/experiment/rules/thats_my_song.py @@ -55,7 +55,7 @@ def get_info_playlist(self, filename): def first_round(self, experiment): actions = super().first_round(experiment) # skip Consent and Playlist action - return actions[:1] + return [actions[0]] def next_round(self, session): """Get action data for the next round""" From dc7449ec942d1ef49357ff3131020b86385971d4 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Mon, 27 May 2024 11:32:46 +0200 Subject: [PATCH 019/180] CI: Configure deployment to Production server (#1015) * CI: Deploy to production environment with updated environment variables and build Podman images * chore: Temporarily run podman workflow for prod environment on ci/deploy-prod branch * chore: Update podman.yml workflow conditions for prod environment on ci/deploy-prod branch --- .github/workflows/podman.yml | 84 ++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/.github/workflows/podman.yml b/.github/workflows/podman.yml index 0608f061e..e65bcd0b2 100644 --- a/.github/workflows/podman.yml +++ b/.github/workflows/podman.yml @@ -188,3 +188,87 @@ jobs: - name: Check logs run: podman-compose -f docker-compose-deploy.yml logs + deploy-production: + name: Deploy to production environment + environment: Production + runs-on: PRD + + # this job runs only on tags + if: github.ref == 'refs/tags/*' + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + env: + # Variables + AML_ALLOWED_HOSTS: ${{ vars.AML_ALLOWED_HOSTS }} + AML_CORS_ORIGIN_WHITELIST: ${{ vars.AML_CORS_ORIGIN_WHITELIST }} + AML_DEBUG: ${{ vars.AML_DEBUG }} + AML_LOCATION_PROVIDER: ${{ vars.AML_LOCATION_PROVIDER }} + AML_SUBPATH: ${{ vars.AML_SUBPATH }} + DJANGO_SETTINGS_MODULE: ${{ vars.DJANGO_SETTINGS_MODULE }} + SENTRY_ENVIRONMENT: "acceptance" + SQL_DATABASE: ${{ vars.SQL_DATABASE }} + SQL_HOST: ${{ vars.SQL_HOST }} + SQL_PORT: ${{ vars.SQL_PORT }} + FRONTEND_API_ROOT: ${{ vars.FRONTEND_API_ROOT }} + FRONTEND_EXPERIMENT_SLUG: ${{ vars.FRONTEND_EXPERIMENT_SLUG }} + FRONTEND_AML_HOME: ${{ vars.FRONTEND_AML_HOME }} + FRONTEND_HTML_PAGE_TITLE: ${{ vars.FRONTEND_HTML_PAGE_TITLE }} + FRONTEND_HTML_FAVICON: ${{ vars.FRONTEND_HTML_FAVICON || '' }} + FRONTEND_LOGO_URL: ${{ vars.FRONTEND_LOGO_URL || '' }} + FRONTEND_HTML_OG_DESCRIPTION: ${{ vars.FRONTEND_HTML_OG_DESCRIPTION || '' }} + FRONTEND_HTML_OG_IMAGE: ${{ vars.FRONTEND_HTML_OG_IMAGE || '' }} + FRONTEND_HTML_OG_TITLE: ${{ vars.FRONTEND_HTML_OG_TITLE || '' }} + FRONTEND_HTML_OG_URL: ${{ vars.FRONTEND_HTML_OG_URL || '' }} + FRONTEND_HTML_BODY_CLASS: ${{ vars.FRONTEND_HTML_BODY_CLASS || '' }} + + # Secrets + AML_SECRET_KEY: ${{ secrets.AML_SECRET_KEY }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SQL_USER: ${{ secrets.SQL_USER }} + SQL_PASSWORD: ${{ secrets.SQL_PASSWORD }} + FRONTEND_SENTRY_DSN: ${{ secrets.FRONTEND_SENTRY_DSN }} + DJANGO_SUPERUSER_USERNAME: ${{ secrets.DJANGO_SUPERUSER_USERNAME }} + DJANGO_SUPERUSER_PASSWORD: ${{ secrets.DJANGO_SUPERUSER_PASSWORD }} + DJANGO_SUPERUSER_EMAIL: ${{ secrets.DJANGO_SUPERUSER_EMAIL }} + # Prevent podman services from exiting after startup + RUNNER_TRACKING_ID: "" + steps: + - uses: actions/checkout@v4 + - name: Create .env file + run: | + touch .env + echo "VITE_API_ROOT=$FRONTEND_API_ROOT" >> .env + echo "VITE_EXPERIMENT_SLUG=$FRONTEND_EXPERIMENT_SLUG" >> .env + echo "VITE_AML_HOME=$FRONTEND_AML_HOME" >> .env + echo "VITE_LOGO_URL=$FRONTEND_LOGO_URL" >> .env + echo "VITE_HTML_FAVICON=$FRONTEND_HTML_FAVICON" >> .env + echo "VITE_HTML_PAGE_TITLE=$FRONTEND_HTML_PAGE_TITLE" >> .env + echo "VITE_HTML_OG_DESCRIPTION=$FRONTEND_HTML_OG_DESCRIPTION" >> .env + echo "VITE_HTML_OG_IMAGE=$FRONTEND_HTML_OG_IMAGE" >> .env + echo "VITE_HTML_OG_TITLE=$FRONTEND_HTML_OG_TITLE" >> .env + echo "VITE_HTML_OG_URL=$FRONTEND_HTML_OG_URL" >> .env + echo "VITE_HTML_BODY_CLASS=$FRONTEND_HTML_BODY_CLASS" >> .env + echo "VITE_SENTRY_DSN=$FRONTEND_SENTRY_DSN" >> .env + cp .env frontend/.env + - name: Build Podman images + run: podman-compose -f docker-compose-deploy.yml build + - name: Deploy Podman images + run: podman-compose -f docker-compose-deploy.yml up -d --force-recreate + - name: Notify Sentry of new release + run: | + curl -X POST "https://sentry.io/api/0/organizations/uva-aml/releases/" \ + -H "Authorization: Bearer ${{ secrets.SENTRY_AUTH_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d '{ + "version": "${{ github.sha }}", + "refs": [{ + "repository": "Amsterdam-Music-Lab/MUSCLE", + "commit": "${{ github.sha }}" + }], + "projects": ["muscle-frontend", "muscle-backend"], + "environment": "production" + }' + - name: Prune old images + run: podman image prune -a -f + - name: Check Podman images + run: podman-compose -f docker-compose-deploy.yml ps \ No newline at end of file From 85862a62c241aa3aff7a3fa9cc9eaf06afaca92a Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Mon, 27 May 2024 12:00:05 +0200 Subject: [PATCH 020/180] chore: Update package version to 2.1.1 (#1031) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 191a531f9..a78e6c7f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "muscle", - "version": "2.1.0", + "version": "2.1.1", "private": false, "description": "The MUSCLE platform is an application that provides an easy way to implement and run online listening experiments for music research.", "license": "MIT", From ab1ed01cfff5d60354bc2505ee917b5d7eb84960 Mon Sep 17 00:00:00 2001 From: BeritJanssen Date: Mon, 27 May 2024 12:15:57 +0200 Subject: [PATCH 021/180] fix: pick correct first_round action in That's My Song --- backend/experiment/rules/thats_my_song.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/experiment/rules/thats_my_song.py b/backend/experiment/rules/thats_my_song.py index 82ad44fb6..3adee411e 100644 --- a/backend/experiment/rules/thats_my_song.py +++ b/backend/experiment/rules/thats_my_song.py @@ -55,7 +55,7 @@ def get_info_playlist(self, filename): def first_round(self, experiment): actions = super().first_round(experiment) # skip Consent and Playlist action - return [actions[0]] + return [actions[2]] def next_round(self, session): """Get action data for the next round""" From a551e3d9e4ee79e5126a3fbd3b09392340dfd181 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Mon, 27 May 2024 12:24:05 +0200 Subject: [PATCH 022/180] Fixed (CI): Trigger workflow (not just job) on release creation as well (#1034) * Fixed: Fix reversed section URL prefix (#1024) * fix: Prefix reversed section url with BASE_URL if present (cherry picked from commit b42f64968acffa9baf54e6c4e4f930bb356cdb28) * chore: Add BASE_URL environment variable to production settings too (cherry picked from commit 0b4f6d8124187c7b89009f09b09d1cb6369d3673) * refactor: Fallback to "http://localhost:8000" even when Docker sets BASE_URL as an empty string (cherry picked from commit 0af40259898855017e3bdf64eaae8f3334fca1cc) * refactor: Strip trailing slash from base url Co-authored-by: Berit (cherry picked from commit 3ef339747d23564cec0827553b9d8451e33c2689) * chore: Update package version to 2.1.0 * chore: Add release workflow for created releases --- .github/workflows/podman.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/podman.yml b/.github/workflows/podman.yml index e65bcd0b2..14ce08306 100644 --- a/.github/workflows/podman.yml +++ b/.github/workflows/podman.yml @@ -6,6 +6,8 @@ on: - develop - main workflow_dispatch: + release: + types: [created] jobs: deploy-test: From 84154954ae5feeda2b55323298c642f4a79bf907 Mon Sep 17 00:00:00 2001 From: BeritJanssen Date: Tue, 28 May 2024 07:08:05 +0200 Subject: [PATCH 023/180] feat: altered plink version --- .../experiment/rules/toontjehoger_3_plink.py | 4 - .../rules/toontjehogerkids_3_plink.py | 151 ++++++++++++++++++ 2 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 backend/experiment/rules/toontjehogerkids_3_plink.py diff --git a/backend/experiment/rules/toontjehoger_3_plink.py b/backend/experiment/rules/toontjehoger_3_plink.py index 2d930b64a..0d52f8f57 100644 --- a/backend/experiment/rules/toontjehoger_3_plink.py +++ b/backend/experiment/rules/toontjehoger_3_plink.py @@ -41,12 +41,8 @@ def first_round(self, experiment): button_label="Start" ) - # 2. Choose playlist. - playlist = Playlist(experiment.playlists.all()) - return [ explainer, - playlist, ] def next_round(self, session): diff --git a/backend/experiment/rules/toontjehogerkids_3_plink.py b/backend/experiment/rules/toontjehogerkids_3_plink.py new file mode 100644 index 000000000..f6968a609 --- /dev/null +++ b/backend/experiment/rules/toontjehogerkids_3_plink.py @@ -0,0 +1,151 @@ +import logging +from os.path import join +from django.template.loader import render_to_string + +from .toontjehoger_1_mozart import toontjehoger_ranks +from experiment.actions import Explainer, Step, Score, Final, Playlist, Info, Trial +from experiment.actions.playback import PlayButton +from experiment.actions.form import AutoCompleteQuestion, RadiosQuestion, Form +from .toontjehoger_3_plink import ToontjeHoger3Plink + +from experiment.utils import non_breaking_spaces + +from result.utils import prepare_result + +logger = logging.getLogger(__name__) + + +class ToontjeHogerKids3Plink(ToontjeHoger3Plink): + ID = 'TOONTJE_HOGER_KIDS_3_PLINK' + TITLE = "" + SCORE_MAIN_CORRECT = 10 + SCORE_MAIN_WRONG = 0 + SCORE_EXTRA_1_CORRECT = 4 + SCORE_EXTRA_2_CORRECT = 4 + SCORE_EXTRA_WRONG = 0 + + def first_round(self, experiment): + """Create data for the first experiment rounds.""" + + explainer = Explainer( + instruction="Muziekherkenning", + steps=[ + Step("Je krijgt {} zeer korte muziekfragmenten te horen.".format( + experiment.rounds)), + Step("Ken je het nummer? Noem de juiste artiest en titel!"), + Step( + "Weet je het niet? Neem dan een gokje.") + ], + step_numbers=True, + button_label="Start" + ) + + return [ + explainer + ] + + def get_last_result(self, session): + ''' get the last score, based on question (plink) + ''' + last_result = session.result_set.last() + + if not last_result: + logger.error("No last result") + return "" + + return last_result + + def get_score_view(self, session): + last_result = self.get_last_result(session) + section = last_result.section + score = last_result.score + + if last_result.expected_response == last_result.given_response: + feedback = "Goedzo! Je hoorde inderdaad {} van {}.".format( + non_breaking_spaces(section.song.name), non_breaking_spaces(section.song.artist)) + else: + feedback = "Helaas! Je hoorde {} van {}.".format(non_breaking_spaces( + section.song.name), non_breaking_spaces(section.song.artist)) + + config = {'show_total_score': True} + round_number = session.get_relevant_results(['plink']).count() - 1 + score_title = "Ronde %(number)d / %(total)d" %\ + {'number': round_number+1, 'total': session.experiment.rounds} + return Score(session, config=config, feedback=feedback, score=score, title=score_title) + + def get_plink_round(self, session, present_score=False): + next_round = [] + if present_score: + next_round.append(self.get_score_view(session)) + # Get all song sections + all_sections = session.all_sections() + choices = {} + for section in all_sections: + label = section.song_label() + choices[section.pk] = label + + # Get section to recognize + section = session.section_from_unused_song() + if section is None: + raise Exception("Error: could not find section") + + expected_response = section.pk + + question1 = AutoCompleteQuestion( + key='plink', + choices=choices, + question='Noem de artiest en de titel van het nummer', + result_id=prepare_result( + 'plink', + session, + section=section, + expected_response=expected_response + ) + ) + next_round.append(Trial( + playback=PlayButton( + sections=[section] + ), + feedback_form=Form( + [question1], + submit_label='Volgende' + ) + )) + return [next_round] + + def calculate_score(self, result, data): + """ + Calculate score, based on the data field + """ + return self.SCORE_MAIN_CORRECT if result.expected_response == result.given_response else self.SCORE_MAIN_WRONG + + def get_final_round(self, session): + + # Finish session. + session.finish() + session.save() + + # Score + score = self.get_score_view(session) + + # Final + final_text = "Goed gedaan, jouw muziekherkenning is uitstekend!" if session.final_score >= 4 * \ + self.SCORE_MAIN_CORRECT else "Dat bleek toch even lastig!" + final = Final( + session=session, + final_text=final_text, + rank=toontjehoger_ranks(session), + button={'text': 'Wat hebben we getest?'} + ) + + # Info page + body = render_to_string( + join('info', 'toontjehoger', 'experiment3.html')) + info = Info( + body=body, + heading="Muziekherkenning", + button_label="Terug naar ToontjeHoger", + button_link="/toontjehoger" + ) + + return [score, final, info] From 0e636750481bb70074ae0b72dccb867cc89de8fd Mon Sep 17 00:00:00 2001 From: BeritJanssen Date: Tue, 28 May 2024 07:57:02 +0200 Subject: [PATCH 024/180] feat: make copies of toontjehoger rules --- backend/experiment/rules/__init__.py | 12 + .../rules/toontjehogerkids_1_mozart.py | 236 ++++++++++++++++ .../rules/toontjehogerkids_2_preverbal.py | 251 ++++++++++++++++++ .../rules/toontjehogerkids_3_plink.py | 6 +- .../rules/toontjehogerkids_4_absolute.py | 183 +++++++++++++ .../rules/toontjehogerkids_5_tempo.py | 251 ++++++++++++++++++ .../rules/toontjehogerkids_6_relative.py | 171 ++++++++++++ 7 files changed, 1107 insertions(+), 3 deletions(-) create mode 100644 backend/experiment/rules/toontjehogerkids_1_mozart.py create mode 100644 backend/experiment/rules/toontjehogerkids_2_preverbal.py create mode 100644 backend/experiment/rules/toontjehogerkids_4_absolute.py create mode 100644 backend/experiment/rules/toontjehogerkids_5_tempo.py create mode 100644 backend/experiment/rules/toontjehogerkids_6_relative.py diff --git a/backend/experiment/rules/__init__.py b/backend/experiment/rules/__init__.py index f978dc31c..5ee714e7f 100644 --- a/backend/experiment/rules/__init__.py +++ b/backend/experiment/rules/__init__.py @@ -28,6 +28,12 @@ from .toontjehoger_4_absolute import ToontjeHoger4Absolute from .toontjehoger_5_tempo import ToontjeHoger5Tempo from .toontjehoger_6_relative import ToontjeHoger6Relative +from .toontjehogerkids_1_mozart import ToontjeHogerKids1Mozart +from .toontjehogerkids_2_preverbal import ToontjeHogerKids2Preverbal +from .toontjehogerkids_3_plink import ToontjeHogerKids3Plink +from .toontjehogerkids_4_absolute import ToontjeHogerKids4Absolute +from .toontjehogerkids_5_tempo import ToontjeHogerKids5Tempo +from .toontjehogerkids_6_relative import ToontjeHogerKids6Relative from .toontjehoger_home import ToontjeHogerHome from .visual_matching_pairs import VisualMatchingPairsGame @@ -67,5 +73,11 @@ ToontjeHoger5Tempo.ID: ToontjeHoger5Tempo, ToontjeHoger6Relative.ID: ToontjeHoger6Relative, ToontjeHogerHome.ID: ToontjeHogerHome, + ToontjeHogerKids1Mozart.ID: ToontjeHogerKids1Mozart, + ToontjeHogerKids2Preverbal.ID: ToontjeHogerKids2Preverbal, + ToontjeHogerKids3Plink.ID: ToontjeHogerKids3Plink, + ToontjeHogerKids4Absolute.ID: ToontjeHogerKids4Absolute, + ToontjeHogerKids5Tempo.ID: ToontjeHogerKids5Tempo, + ToontjeHogerKids6Relative.ID: ToontjeHogerKids6Relative, VisualMatchingPairsGame.ID: VisualMatchingPairsGame } diff --git a/backend/experiment/rules/toontjehogerkids_1_mozart.py b/backend/experiment/rules/toontjehogerkids_1_mozart.py new file mode 100644 index 000000000..8e6b9182d --- /dev/null +++ b/backend/experiment/rules/toontjehogerkids_1_mozart.py @@ -0,0 +1,236 @@ +import logging +from django.template.loader import render_to_string +from os.path import join +from experiment.actions import Trial, Explainer, Step, Score, Final, Playlist, Info, HTML +from experiment.actions.form import ButtonArrayQuestion, Form +from experiment.actions.playback import Autoplay +from .base import Base +from experiment.utils import non_breaking_spaces + +from result.utils import prepare_result + +logger = logging.getLogger(__name__) + +QUESTION_URL1 = "/images/experiments/toontjehoger/mozart-effect1.webp" +QUESTION_URL2 = "/images/experiments/toontjehoger/mozart-effect2.webp" +ANSWER_URL1 = "/images/experiments/toontjehoger/mozart-effect1-answer.webp" +ANSWER_URL2 = "/images/experiments/toontjehoger/mozart-effect2-answer.webp" + + +def toontjehoger_ranks(session): + score = session.final_score + if score < 25: + return 'PLASTIC' + elif score < 50: + return 'BRONZE' + elif score < 75: + return 'SILVER' + else: + return 'GOLD' + + +class ToontjeHogerKids1Mozart(Base): + ID = 'TOONTJE_HOGER_KIDS_1_MOZART' + TITLE = "" + SCORE_CORRECT = 50 + SCORE_WRONG = 0 + + def first_round(self, experiment): + """Create data for the first experiment rounds.""" + + # 1. Explain game. + explainer = Explainer( + instruction="Het Mozart effect", + steps=[ + Step("Je hoort een muziekfragment van ongeveer 25 seconden."), + Step("Hierna verschijnt een korte puzzel."), + Step("Lukt het om het juiste antwoord te vinden?"), + ], + step_numbers=True, + button_label="Start" + ) + + # 2. Choose playlist. + playlist = Playlist(experiment.playlists.all()) + + return [ + explainer, + playlist + ] + + def next_round(self, session): + """Get action data for the next round""" + rounds_passed = session.rounds_passed() + + # Round 1 + if rounds_passed == 0: + round = self.get_image_trial(session, + section_group='1', + image_url=QUESTION_URL1, + question="Welke vorm ontstaat er na het afknippen van de hoekjes?", + expected_response='B' + ) + # No combine_actions because of inconsistent next_round array wrapping in first round + return round + + # Round 2 + if rounds_passed == 1: + answer_explainer = self.get_answer_explainer(session, round=1) + score = self.get_score(session) + round = self.get_image_trial(session, + section_group='2', + image_url=QUESTION_URL2, + question="Welke vorm ontstaat er na het afknippen van het hoekje?", + expected_response='B' + ) + return [*answer_explainer, *score, *round] + + # Final + return self.get_final_round(session) + + def get_answer_explainer(self, session, round): + last_result = session.last_result() + + correct_answer_given = last_result.score > 0 + + heading = "Goed gedaan!" if correct_answer_given else "Helaas!" + + feedback_correct = "Het juiste antwoord was inderdaad {}.".format( + last_result.expected_response) + feedback_incorrect = "Antwoord {} is niet goed! Het juiste antwoord was {}.".format( + last_result.given_response, last_result.expected_response) + feedback = feedback_correct if correct_answer_given else feedback_incorrect + + image_url = ANSWER_URL1 if round == 1 else ANSWER_URL2 + body = '

{}

'.format( + image_url, feedback) + + # Return answer info view + info = Info( + body=body, + heading=heading, + button_label="Volgende", + ) + return [info] + + def get_score(self, session): + # Feedback message + last_result = session.last_result() + section = last_result.section + feedback = "Je hoorde {} van {}.".format( + section.song.name, non_breaking_spaces(section.song.artist)) if section else "" + + # Return score view + config = {'show_total_score': True} + score = Score(session, config=config, feedback=feedback) + return [score] + + def get_image_trial(self, session, section_group, image_url, question, expected_response): + # Config + # ----------------- + section = session.section_from_any_song( + filter_by={'group': section_group}) + if section is None: + raise Exception("Error: could not find section") + + # Step 1 + # -------------------- + + # Listen + playback = Autoplay([section], show_animation=True) + + listen_config = { + 'auto_advance': True, + 'show_continue_button': False, + 'response_time': section.duration + } + + listen = Trial( + config=listen_config, + playback=playback, + title=self.TITLE, + ) + + # Step 2 + # -------------------- + + # Question + key = 'expected_shape' + question = ButtonArrayQuestion( + question=question, + key=key, + choices={ + 'A': 'A', + 'B': 'B', + 'C': 'C', + 'D': 'D', + 'E': 'E', + }, + view='BUTTON_ARRAY', + result_id=prepare_result( + key, session, section=section, expected_response=expected_response), + submits=True + ) + form = Form([question]) + + image_trial = Trial( + html=HTML( + body=''.format( + image_url)), + feedback_form=form, + title=self.TITLE, + ) + + return [listen, image_trial] + + def get_explainer_round2(): + explainer = Explainer( + instruction="Het Mozart effect", + steps=[ + Step("Je krijgt nu een ander muziekfragment van 20 seconden te horen."), + Step("Hierna verschijnt weer een korte puzzel."), + Step("Lukt het nu om de juiste te kiezen?"), + ], + step_numbers=True, + button_label="Start" + ) + + return [explainer] + + def calculate_score(self, result, data): + score = self.SCORE_CORRECT if result.expected_response == result.given_response else self.SCORE_WRONG + return score + + def get_final_round(self, session): + + # Finish session. + session.finish() + session.save() + + # Answer explainer + answer_explainer = self.get_answer_explainer(session, round=2) + + # Score + score = self.get_score(session) + + # Final + final_text = "Je hebt het uitstekend gedaan!" if session.final_score >= 2 * \ + self.SCORE_CORRECT else "Dat bleek toch even lastig!" + final = Final( + session=session, + final_text=final_text, + rank=toontjehoger_ranks(session), + button={'text': 'Wat hebben we getest?'} + ) + + # Info page + body = render_to_string( + join('info', 'toontjehoger', 'experiment1.html')) + info = Info( + body=body, + heading="Het Mozart effect", + button_label="Terug naar ToontjeHoger", + button_link="/toontjehoger" + ) + + return [*answer_explainer, *score, final, info] diff --git a/backend/experiment/rules/toontjehogerkids_2_preverbal.py b/backend/experiment/rules/toontjehogerkids_2_preverbal.py new file mode 100644 index 000000000..6586c4bd3 --- /dev/null +++ b/backend/experiment/rules/toontjehogerkids_2_preverbal.py @@ -0,0 +1,251 @@ +import logging +from django.template.loader import render_to_string + +from .toontjehoger_1_mozart import toontjehoger_ranks +from experiment.actions import Trial, Explainer, Step, Score, Final, Playlist, Info, HTML +from experiment.actions.form import ButtonArrayQuestion, ChoiceQuestion, Form +from experiment.actions.playback import ImagePlayer +from experiment.actions.styles import STYLE_NEUTRAL +from .base import Base +from os.path import join +from result.utils import prepare_result + +logger = logging.getLogger(__name__) + + +class ToontjeHogerKids2Preverbal(Base): + ID = 'TOONTJE_HOGER_KIDS_2_PREVERBAL' + TITLE = "" + SCORE_CORRECT = 50 + SCORE_WRONG = 0 + + def first_round(self, experiment): + """Create data for the first experiment rounds.""" + + # 1. Explain game. + explainer = Explainer( + instruction="Het eerste luisteren", + steps=[ + Step( + "Je krijgt drie spectrogrammen te zien met de vraag: welk geluid is van een mens?"), + Step( + "Daarvoor eerst nog wat uitleg van wat een spectrogram is, natuurlijk."), + Step( + "Tenslotte krijg je twee geluiden te horen met de vraag: welke baby is in Frankrijk geboren?"), + ], + step_numbers=True, + button_label="Start" + ) + + # 2 Spectrogram information + spectrogram_info = self.get_spectrogram_info() + + # 3. Choose playlist. + playlist = Playlist(experiment.playlists.all()) + + return [ + explainer, + spectrogram_info, + playlist, + ] + + def get_spectrogram_info(self): + image_url = "/images/experiments/toontjehoger/spectrogram_info_nl.webp" + description = "Een spectrogram is een visuele weergave van geluid, waarin je kan zien hoe een geluid verandert over de tijd. Hoe witter, hoe meer energie op die frequentie." + body = '

{}

'.format( + image_url, description) + + # Return answer info view + info = Info( + body=body, + heading="Wat is een spectrogram?", + button_label="Volgende", + ) + return info + + def next_round(self, session): + """Get action data for the next round""" + + rounds_passed = session.rounds_passed() + + # Round 1 + if rounds_passed == 0: + # No combine_actions because of inconsistent next_round array wrapping in first round + return self.get_round1(session) + + # Round 2 + if rounds_passed == 1: + return [*self.get_score(session, rounds_passed), *self.get_round1_playback(session), *self.get_round2(round, session)] + + # Final + return self.get_final_round(session) + + def get_score(self, session, rounds_passed): + # Feedback + last_result = session.last_result() + feedback = "" + if not last_result: + logger.error("No last result") + feedback = "Er is een fout opgetreden" + else: + if rounds_passed == 1: + appendix = "Op het volgende scherm kun je de geluiden beluisteren." + if last_result.score == self.SCORE_CORRECT: + feedback = "Dat is correct! Spectrogram C is inderdaad van een mens. " + appendix + else: + feedback = "Helaas! Je antwoord was onjuist. Het geluid van spectrogram C is van een mens. " + appendix + elif rounds_passed == 2: + if last_result.score == self.SCORE_CORRECT: + feedback = "Dat is correct! Geluid A is inderdaad de Franse baby." + else: + feedback = "Helaas! Geluid A is de Franse baby." + + # Return score view + config = {'show_total_score': True} + score = Score(session, config=config, feedback=feedback) + return [score] + + def get_round1(self, session): + # Question + key = 'expected_spectrogram' + question = ButtonArrayQuestion( + question="Welk spectrogram toont het geluid van een mens?", + key=key, + choices={ + 'A': 'A', + 'B': 'B', + 'C': 'C', + }, + view='BUTTON_ARRAY', + submits=True, + result_id=prepare_result( + key, session, expected_response="C" + ) + ) + form = Form([question]) + + image_trial = Trial( + html=HTML( + body=''.format( + "/images/experiments/toontjehoger/preverbal_1.webp")), + feedback_form=form, + title=self.TITLE, + ) + + return [image_trial] + + def get_round1_playback(self, session): + # Get sections + sectionA = session.section_from_any_song( + filter_by={'tag': 'a', 'group': '1'}) + if not sectionA: + raise Exception( + "Error: could not find section A for round 1") + + sectionB = session.section_from_any_song( + filter_by={'tag': 'b', 'group': '1'}) + if not sectionB: + raise Exception( + "Error: could not find section B for round 1") + + sectionC = session.section_from_any_song( + filter_by={'tag': 'c', 'group': '1'}) + if not sectionB: + raise Exception( + "Error: could not find section C for round 1") + + # Player + playback = ImagePlayer( + [sectionA, sectionB, sectionC], + label_style='ALPHABETIC', + images=["/images/experiments/toontjehoger/spectrogram-trumpet.webp", "/images/experiments/toontjehoger/spectrogram-whale.webp", "/images/experiments/toontjehoger/spectrogram-human.webp"], + image_labels = ['Trompet', 'Walvis', 'Mens'] + ) + + trial = Trial( + playback=playback, + feedback_form=None, + title=self.TITLE, + style='primary-form' + ) + return [trial] + + def get_round2(self, round, session): + + # Get sections + # French + sectionA = session.section_from_any_song( + filter_by={'tag': 'a', 'group': '2'}) + if not sectionA: + raise Exception( + "Error: could not find section A for round 2") + # German + sectionB = session.section_from_any_song( + filter_by={'tag': 'b', 'group': '2'}) + if not sectionB: + raise Exception( + "Error: could not find section B for round 2") + + # Player + playback = ImagePlayer( + [sectionA, sectionB], + label_style='ALPHABETIC', + images=["/images/experiments/toontjehoger/spectrogram-baby-french.webp", "/images/experiments/toontjehoger/spectrogram-baby-german.webp"], + ) + + # Question + key = 'baby' + question = ChoiceQuestion( + question="Welke baby is in Frankrijk geboren?", + key=key, + choices={ + "A": "A", + "B": "B", + }, + view='BUTTON_ARRAY', + submits=True, + result_id=prepare_result(key, session, expected_response="A"), + style=STYLE_NEUTRAL + ) + form = Form([question]) + + trial = Trial( + playback=playback, + feedback_form=form, + title=self.TITLE, + ) + return [trial] + + def calculate_score(self, result, data): + return self.SCORE_CORRECT if result.expected_response == result.given_response else self.SCORE_WRONG + + def get_final_round(self, session): + + # Finish session. + session.finish() + session.save() + + # Score + score = self.get_score(session, session.rounds_passed()) + + # Final + final_text = "Goed gedaan! Je hebt beide vragen correct beantwoord!" if session.final_score >= 2 * \ + self.SCORE_CORRECT else "Dat bleek toch even lastig!" + final = Final( + session=session, + final_text=final_text, + rank=toontjehoger_ranks(session), + button={'text': 'Wat hebben we getest?'} + ) + + # Info page + body = render_to_string( + join('info', 'toontjehoger', 'experiment2.html')) + info = Info( + body=body, + heading="Het eerste luisteren", + button_label="Terug naar ToontjeHoger", + button_link="/toontjehoger" + ) + + return [*score, final, info] diff --git a/backend/experiment/rules/toontjehogerkids_3_plink.py b/backend/experiment/rules/toontjehogerkids_3_plink.py index f6968a609..4ca043e61 100644 --- a/backend/experiment/rules/toontjehogerkids_3_plink.py +++ b/backend/experiment/rules/toontjehogerkids_3_plink.py @@ -3,9 +3,9 @@ from django.template.loader import render_to_string from .toontjehoger_1_mozart import toontjehoger_ranks -from experiment.actions import Explainer, Step, Score, Final, Playlist, Info, Trial +from experiment.actions import Explainer, Step, Score, Final, Info, Trial from experiment.actions.playback import PlayButton -from experiment.actions.form import AutoCompleteQuestion, RadiosQuestion, Form +from experiment.actions.form import AutoCompleteQuestion, Form from .toontjehoger_3_plink import ToontjeHoger3Plink from experiment.utils import non_breaking_spaces @@ -111,7 +111,7 @@ def get_plink_round(self, session, present_score=False): submit_label='Volgende' ) )) - return [next_round] + return next_round def calculate_score(self, result, data): """ diff --git a/backend/experiment/rules/toontjehogerkids_4_absolute.py b/backend/experiment/rules/toontjehogerkids_4_absolute.py new file mode 100644 index 000000000..5ca7f4c9a --- /dev/null +++ b/backend/experiment/rules/toontjehogerkids_4_absolute.py @@ -0,0 +1,183 @@ +import logging +import random +from os.path import join +from django.template.loader import render_to_string +from experiment.utils import non_breaking_spaces +from .toontjehoger_1_mozart import toontjehoger_ranks +from experiment.actions import Trial, Explainer, Step, Score, Final, Playlist, Info +from experiment.actions.form import ButtonArrayQuestion, Form +from experiment.actions.playback import Multiplayer +from experiment.actions.styles import STYLE_NEUTRAL +from experiment.utils import create_player_labels +from .base import Base +from result.utils import prepare_result + +logger = logging.getLogger(__name__) + + +class ToontjeHogerKids4Absolute(Base): + ID = 'TOONTJE_HOGER_KIDS_4_ABSOLUTE' + TITLE = "" + SCORE_CORRECT = 20 + SCORE_WRONG = 0 + # number of songs (each with a,b,c version) in the playlist + PLAYLIST_ITEMS = 13 + + def first_round(self, experiment): + """Create data for the first experiment rounds.""" + + # 1. Explain game. + explainer = Explainer( + instruction="Absoluut gehoor", + steps=[ + Step( + "Je gaat zo luisteren naar fragmenten muziek die je misschien herkent als de intro van een tv-programma of serie."), + Step( + "Van ieder fragment kan je twee versies luisteren. Eén hiervan is het origineel. De andere hebben we een beetje hoger of lager gemaakt."), + Step("Kan jij horen welke van de twee versies precies zo hoog of laag is als je 'm kent? Welke is het origineel?"), + ], + step_numbers=True, + button_label="Start" + ) + + # 2. Choose playlist. + playlist = Playlist(experiment.playlists.all()) + + return [ + explainer, + playlist, + ] + + def next_round(self, session): + """Get action data for the next round""" + + rounds_passed = session.rounds_passed() + + # Round 1 + if rounds_passed == 0: + # No combine_actions because of inconsistent next_round array wrapping in first round + return self.get_round(session) + + # Round 2 + if rounds_passed < session.experiment.rounds: + return [*self.get_score(session), *self.get_round(session)] + + # Final + return self.get_final_round(session) + + def get_round(self, session): + # Get available section groups + results = session.result_set.all() + available_groups = list(map(str, range(1, self.PLAYLIST_ITEMS))) + for result in results: + available_groups.remove(result.section.group) + + # Get sections + + # Original (A) + section1 = session.section_from_any_song( + filter_by={'tag': 'a', 'group__in': available_groups}) + if not section1: + raise Exception( + "Error: could not find section 1") + + # Changed (B/C) + variant = random.choice(["b", "c"]) + section2 = session.section_from_any_song( + filter_by={'tag': variant, 'group': section1.group}) + if not section2: + raise Exception( + "Error: could not find section 2") + + # Random section order + sections = [section1, section2] + random.shuffle(sections) + + # Player + playback = Multiplayer(sections, labels=create_player_labels(len(sections), 'alphabetic')) + + # Question + key = 'pitch' + question = ButtonArrayQuestion( + question="Welk fragment heeft de juiste toonhoogte?", + key=key, + choices={ + "A": "A", + "B": "B", + }, + submits=True, + result_id=prepare_result( + key, session, section=section1, + expected_response="A" if sections[0].id == section1.id else "B" + ), + style=STYLE_NEUTRAL + ) + form = Form([question]) + + trial = Trial( + playback=playback, + feedback_form=form, + title=self.TITLE, + ) + return [trial] + + def calculate_score(self, result, data): + return self.SCORE_CORRECT if result.expected_response == result.given_response else self.SCORE_WRONG + + def get_score(self, session): + # Feedback + last_result = session.last_result() + feedback = "" + if not last_result: + logger.error("No last result") + feedback = "Er is een fout opgetreden" + else: + if last_result.score == self.SCORE_CORRECT: + feedback = "Goedzo! Het was inderdaad antwoord {}!".format( + last_result.expected_response.upper()) + else: + feedback = "Helaas! Het juiste antwoord was {}.".format( + last_result.expected_response.upper()) + + feedback += " Je luisterde naar de intro van {}.".format( + non_breaking_spaces(last_result.section.song.name)) + + # Return score view + config = {'show_total_score': True} + score = Score(session, config=config, feedback=feedback) + return [score] + + def get_final_round(self, session): + + # Finish session. + session.finish() + session.save() + + # Score + score = self.get_score(session) + + # Final + final_text = "Dat bleek toch even lastig!" + if session.final_score >= session.experiment.rounds * 0.8 * self.SCORE_CORRECT: + final_text = "Goed gedaan! Jouw absolute gehoor is uitstekend!" + elif session.final_score >= session.experiment.rounds * 0.5 * self.SCORE_CORRECT: + final_text = "Goed gedaan! Jouw absolute gehoor is best OK!" + + final = Final( + session=session, + final_text=final_text, + rank=toontjehoger_ranks(session), + button={'text': 'Wat hebben we getest?'} + ) + + # Info page + body = render_to_string( + join('info', 'toontjehoger', 'experiment4.html')) + info = Info( + body=body, + heading="Absoluut gehoor", + button_label="Terug naar ToontjeHoger", + button_link="/toontjehoger" + ) + + return [*score, final, info] diff --git a/backend/experiment/rules/toontjehogerkids_5_tempo.py b/backend/experiment/rules/toontjehogerkids_5_tempo.py new file mode 100644 index 000000000..cacf09c10 --- /dev/null +++ b/backend/experiment/rules/toontjehogerkids_5_tempo.py @@ -0,0 +1,251 @@ +import logging +import random +from os.path import join +from django.template.loader import render_to_string +from .toontjehoger_1_mozart import toontjehoger_ranks +from experiment.actions import Trial, Explainer, Step, Score, Final, Playlist, Info +from experiment.actions.form import ButtonArrayQuestion, Form +from experiment.actions.playback import Multiplayer +from experiment.actions.styles import STYLE_NEUTRAL +from .base import Base +from experiment.utils import create_player_labels, non_breaking_spaces + +from result.utils import prepare_result + +logger = logging.getLogger(__name__) + + +class ToontjeHogerKids5Tempo(Base): + ID = 'TOONTJE_HOGER_KIDS_5_TEMPO' + TITLE = "" + SCORE_CORRECT = 20 + SCORE_WRONG = 0 + + def first_round(self, experiment): + """Create data for the first experiment rounds.""" + + # 1. Explain game. + explainer = Explainer( + instruction="Timing en tempo", + steps=[ + Step( + "Je krijgt dadelijk twee verschillende uitvoeringen van hetzelfde stuk te horen."), + Step("Eén wordt op de originele snelheid (tempo) afgespeeld, terwijl de ander iets is versneld of vertraagd."), + Step( + "Kan jij horen welke het origineel is?"), + Step("Let hierbij vooral op de timing van de muzikanten.") + ], + step_numbers=True, + button_label="Start" + ) + + # 2. Choose playlist. + playlist = Playlist(experiment.playlists.all()) + + return [ + explainer, + playlist, + ] + + def next_round(self, session): + """Get action data for the next round""" + + rounds_passed = session.rounds_passed() + + # Round 1 + if rounds_passed == 0: + # No combine_actions because of inconsistent next_round array wrapping in first round + return self.get_round(session, rounds_passed) + + # Round 2 + if rounds_passed < session.experiment.rounds: + return [*self.get_score(session), *self.get_round(session, rounds_passed)] + + # Final + return self.get_final_round(session) + + def get_random_section_pair(self, session, genre): + """ + - session: current Session + - genre: (C)lassic (J)azz (R)ock + + Voor de track: genereer drie random integers van 1-5 (bijv. [4 2 4]) + Plak deze aan de letters C, J en R (bijv. [C4, J2, R4]) + Voor het paar: genereer drie random integers van 1-2 (bijv. [1 2 2]) + Plak deze aan de letter P (bijv. P1, P2, P2) + We willen zowel de originele als de veranderde versie van het paar. Dus combineer + bovenstaande met OR en CH (bijv. “C4_P1_OR”, “C4_P1_CH”, etc.) + """ + # Previous tags + previous_tags = [ + result.section.tag for result in session.result_set.all()] + + # Get a random, unused track + # Loop until there is a valid tag + iterations = 0 + valid_tag = False + tag_base = "" + tag_original = "" + while(not valid_tag): + track = random.choice([1, 2, 3, 4, 5]) + pair = random.choice([1, 2]) + tag_base = "{}{}_P{}_".format(genre.upper(), track, pair, ) + tag_original = tag_base + "OR" + if not (tag_original in previous_tags): + valid_tag = True + + # Failsafe: prevent infinite loop + # If this happens, just reuse a track + iterations += 1 + if iterations > 10: + valid_tag = True + + tag_changed = tag_base + "CH" + + section_original = session.section_from_any_song( + filter_by={'tag': tag_original, 'group': "or"}) + + if not section_original: + raise Exception( + "Error: could not find original section: {}".format(tag_original)) + + section_changed = self.get_section_changed( + session=session, tag=tag_changed) + + sections = [section_original, section_changed] + random.shuffle(sections) + return sections + + def get_section_changed(self, session, tag): + section_changed = session.section_from_any_song( + filter_by={'tag': tag, 'group': "ch"}) + if not section_changed: + raise Exception( + "Error: could not find changed section: {}".format(tag)) + return section_changed + + def get_round(self, session, round): + # Get sections + genre = ["C", "J", "R"][round % 3] + + sections = self.get_random_section_pair(session, genre) + section_original = sections[0] if sections[0].group == "or" else sections[1] + + # Player + playback = Multiplayer(sections, labels=create_player_labels(len(sections), 'alphabetic')) + + # Question + key = 'pitch' + question = ButtonArrayQuestion( + question="Welk fragment wordt in het originele tempo afgespeeld?", + key=key, + choices={ + "A": "A", + "B": "B", + }, + submits=True, + result_id=prepare_result( + key, session, section=section_original, + expected_response="A" if sections[0].id == section_original.id else "B" + ), + style=STYLE_NEUTRAL + ) + form = Form([question]) + + trial = Trial( + playback=playback, + feedback_form=form, + title=self.TITLE, + ) + return [trial] + + def calculate_score(self, result, data): + return self.SCORE_CORRECT if result.expected_response == result.given_response else self.SCORE_WRONG + + def get_section_pair_from_result(self, result): + section_original = result.section + + if section_original is None: + raise Exception( + "Error: could not get section from result") + + tag_changed = section_original.tag.replace("OR", "CH") + section_changed = self.get_section_changed( + session=result.session, tag=tag_changed) + + if section_changed is None: + raise Exception( + "Error: could not get changed section for tag: {}".format( + tag_changed)) + + return (section_original, section_changed) + + def get_score(self, session): + # Feedback + last_result = session.last_result() + feedback = "" + if not last_result: + logger.error("No last result") + feedback = "Er is een fout opgetreden" + else: + if last_result.score == self.SCORE_CORRECT: + feedback = "Goedzo! Het was inderdaad antwoord {}!".format( + last_result.expected_response.upper()) + else: + feedback = "Helaas! Het juiste antwoord was {}.".format( + last_result.expected_response.upper()) + + section_original, section_changed = self.get_section_pair_from_result( + last_result) + + # Create feedback message + # - Track names are always the same + # - Artist could be different + if section_original.song.artist == section_changed.song.artist: + feedback += " Je hoorde {}, in beide fragmenten uitgevoerd door {}.".format( + last_result.section.song.name, last_result.section.song.artist) + else: + section_a = section_original if last_result.expected_response == "A" else section_changed + section_b = section_changed if section_a.id == section_original.id else section_original + feedback += " Je hoorde {} uitgevoerd door A) {} en B) {}.".format( + section_a.song.name, non_breaking_spaces(section_a.song.artist), non_breaking_spaces(section_b.song.artist)) + + # Return score view + config = {'show_total_score': True} + score = Score(session, config=config, feedback=feedback) + return [score] + + def get_final_round(self, session): + + # Finish session. + session.finish() + session.save() + + # Score + score = self.get_score(session) + + # Final + final_text = "Dat bleek toch even lastig!" + if session.final_score >= session.experiment.rounds * 0.8 * self.SCORE_CORRECT: + final_text = "Goed gedaan! Jouw timing is uitstekend!" + elif session.final_score >= session.experiment.rounds * 0.5 * self.SCORE_CORRECT: + final_text = "Goed gedaan! Jouw timing is best OK!" + + final = Final( + session=session, + final_text=final_text, + rank=toontjehoger_ranks(session), + button={'text': 'Wat hebben we getest?'} + ) + + # Info page + body = render_to_string( + join('info', 'toontjehoger', 'experiment5.html')) + info = Info( + body=body, + heading="Timing en tempo", + button_label="Terug naar ToontjeHoger", + button_link="/toontjehoger" + ) + + return [*score, final, info] diff --git a/backend/experiment/rules/toontjehogerkids_6_relative.py b/backend/experiment/rules/toontjehogerkids_6_relative.py new file mode 100644 index 000000000..13deba3d4 --- /dev/null +++ b/backend/experiment/rules/toontjehogerkids_6_relative.py @@ -0,0 +1,171 @@ +import logging +from django.template.loader import render_to_string +from os.path import join +from .toontjehoger_1_mozart import toontjehoger_ranks +from experiment.actions import Trial, Explainer, Step, Score, Final, Playlist, Info +from experiment.actions.form import ChoiceQuestion, Form +from experiment.actions.playback import Multiplayer +from experiment.actions.styles import STYLE_BOOLEAN +from .base import Base + +from result.utils import prepare_result + +logger = logging.getLogger(__name__) + + +class ToontjeHogerKids6Relative(Base): + ID = 'TOONTJE_HOGER_KIDS_6_RELATIVE' + TITLE = "" + SCORE_CORRECT = 50 + SCORE_WRONG = 0 + + def first_round(self, experiment): + """Create data for the first experiment rounds.""" + + # 1. Explain game. + explainer = Explainer( + instruction="Relatief Gehoor", + steps=[ + Step("In dit experiment kun je testen hoe goed jouw relatieve gehoor is! Relatief gehoor is het vermogen om een melodie te herkennen, ongeacht of deze nu wat hoger of lager in toonhoogte wordt afgespeeld."), + # Empty step adds some spacing between steps to improve readability + Step(""), + Step( + "Je krijgt twee melodieën te horen, verschillend in toonhoogte.", number=1), + Step("Luister goed, want je kunt ze maar één keer afspelen!", number=2), + Step("Aan jou de taak om te ontrafelen of deze melodieën hetzelfde zijn, ongeacht de toonhoogte! ", number=3), + ], + button_label="Start" + ) + + # 2. Choose playlist. + playlist = Playlist(experiment.playlists.all()) + + return [ + explainer, + playlist, + ] + + def next_round(self, session): + """Get action data for the next round""" + + rounds_passed = session.rounds_passed() + + # Round 1 + if rounds_passed == 0: + # No combine_actions because of inconsistent next_round array wrapping in first round + return self.get_round(rounds_passed, session) + + # Round 2 + if rounds_passed == 1: + return [*self.get_score(session), *self.get_round(round, session)] + + # Final + return self.get_final_round(session) + + def get_score(self, session): + # Feedback + last_result = session.last_result() + + if not last_result: + logger.error("No last result") + feedback = "Er is een fout opgetreden" + else: + if last_result.score == self.SCORE_CORRECT: + feedback = "Dat is correct! De melodieën in de muziekfragmenten zijn inderdaad verschillend." + else: + feedback = "Helaas! De melodieën in de muziekfragmenten zijn toch echt verschillend." + + # Return score view + config = {'show_total_score': True} + score = Score(session, config=config, feedback=feedback) + return [score] + + def get_round(self, round, session): + + # Config + # ----------------- + # section 1 is always section 'a' + section1 = session.section_from_any_song( + filter_by={'tag': 'a'}) + if section1 is None: + raise Exception( + "Error: could not find section1 for round {}".format(round)) + + # Get correct tag for round 0 or 1 + tag = 'b' if round == 0 else 'c' + section2 = session.section_from_any_song( + filter_by={'tag': tag}) + if section2 is None: + raise Exception( + "Error: could not find section2 for round {}".format(round)) + + # Fragments A,B,C are all different, so answer is always NO + expected_response = "NO" + + # Question + key = 'same_melody' + question = ChoiceQuestion( + question="Zijn deze twee melodieën hetzelfde?", + key=key, + choices={ + "YES": "Ja", + "NO": "Nee", + }, + view='BUTTON_ARRAY', + submits=True, + style=STYLE_BOOLEAN, + result_id=prepare_result( + key, session, section=section1, + expected_response=expected_response + ) + ) + form = Form([question]) + + # Player + playback = Multiplayer( + [section1, section2], + play_once=True, + labels=['A', 'B' if round == 0 else 'C'] + ) + + trial = Trial( + playback=playback, + feedback_form=form, + title=self.TITLE, + style='blue-players' + ) + return [trial] + + def calculate_score(self, result, data): + return self.SCORE_CORRECT if result.expected_response == result.given_response else self.SCORE_WRONG + + def get_final_round(self, session): + + # Finish session. + session.finish() + session.save() + + # Score + score = self.get_score(session) + + # Final + final_text = "Goed gedaan, jouw relatief gehoor is uitstekend!" if session.final_score >= 2 * \ + self.SCORE_CORRECT else "Dat bleek toch even lastig!" + final = Final( + session=session, + final_text=final_text, + rank=toontjehoger_ranks(session), + button={'text': 'Wat hebben we getest?'} + ) + + # Info page + body = render_to_string( + join('info', 'toontjehoger', 'experiment6.html')) + info = Info( + body=body, + heading="Relatief gehoor", + button_label="Terug naar ToontjeHoger", + button_link="/toontjehoger" + ) + + return [*score, final, info] From 1488446ef48b44a1ec793e2f8a98734543106fa1 Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Tue, 28 May 2024 13:30:07 +0200 Subject: [PATCH 025/180] Added: Add server-side playlist validation to rules files (#995) * chore: Remove unused import in congosamediff.py * refactor: Generalize playlist validation * feat: add API endpoint * feat: Add experiment playlist validation JavaScript and CSS files * test: Add playlist validation test cases * chore: Add comment about deleting stylesheet after merging Add Playlist validation to rules files #978 * feat: Add playlist validation to ExperimentForm clean method * refactor: Do not use generic clean method but clean_playlists method (which automatically gets called by Django as it's based on the model's properties) * revert: Remove dynamic client-side (server rules based) validation --- backend/experiment/forms.py | 24 +++++++++++ backend/experiment/rules/base.py | 16 +++++++ backend/experiment/rules/congosamediff.py | 16 +++---- backend/experiment/rules/tests/test_base.py | 11 +++++ backend/experiment/urls.py | 3 +- backend/experiment/views.py | 46 +++++++++++++++++++++ 6 files changed, 108 insertions(+), 8 deletions(-) diff --git a/backend/experiment/forms.py b/backend/experiment/forms.py index 3b320fdad..8de489aee 100644 --- a/backend/experiment/forms.py +++ b/backend/experiment/forms.py @@ -170,6 +170,30 @@ def __init__(self, *args, **kwargs): required=False ) + def clean_playlists(self): + + # Check if there is a rules id selected and key exists + if 'rules' not in self.cleaned_data: + return + + # Validat the rules' playlist + rule_id = self.cleaned_data['rules'] + cl = EXPERIMENT_RULES[rule_id] + rules = cl() + + playlists = self.cleaned_data['playlists'] + playlist_errors = [] + + # Validate playlists + for playlist in playlists: + errors = rules.validate_playlist(playlist) + + for error in errors: + playlist_errors.append(f"Playlist [{playlist.name}]: {error}") + + if playlist_errors: + self.add_error('playlists', playlist_errors) + class Meta: model = Experiment fields = ['name', 'slug', 'active', 'rules', diff --git a/backend/experiment/rules/base.py b/backend/experiment/rules/base.py index f9ebd457d..a11e4fc22 100644 --- a/backend/experiment/rules/base.py +++ b/backend/experiment/rules/base.py @@ -5,6 +5,8 @@ from django.conf import settings from experiment.actions import Final, Form, Trial +from experiment.models import Experiment +from section.models import Playlist from experiment.questions.demographics import DEMOGRAPHICS from experiment.questions.goldsmiths import MSI_OTHER from experiment.questions.utils import question_by_key, unanswered_questions @@ -156,3 +158,17 @@ def social_media_info(self, experiment, score): 'url': experiment.url or current_url, 'hashtags': [experiment.hashtag or experiment.slug, "amsterdammusiclab", "citizenscience"] } + + def validate_playlist(self, playlist: Playlist): + errors = [] + # Common validations across experiments + if not playlist: + errors.append('The experiment must have a playlist.') + return errors + + sections = playlist.section_set.all() + + if not sections: + errors.append('The experiment must have at least one section.') + + return errors diff --git a/backend/experiment/rules/congosamediff.py b/backend/experiment/rules/congosamediff.py index df83f91ed..bd8655739 100644 --- a/backend/experiment/rules/congosamediff.py +++ b/backend/experiment/rules/congosamediff.py @@ -1,7 +1,6 @@ import random import re -import math import string from django.utils.translation import gettext_lazy as _ from experiment.actions.utils import final_action_with_optional_button @@ -30,7 +29,9 @@ def first_round(self, experiment: Experiment): """ # Do a validity check on the experiment - self.validate(experiment) + errors = self.validate_playlist(experiment.playlists.first()) + if errors: + raise ValueError('The experiment playlist is not valid: \n- ' + '\n- '.join(errors)) # 1. Playlist playlist = Playlist(experiment.playlists.all()) @@ -241,12 +242,14 @@ def get_total_trials_count(self, session: Session): total_trials_count = practice_trials_count + total_unique_exp_trials_count + 1 return total_trials_count - def validate(self, experiment: Experiment): + def validate_playlist(self, playlist: PlaylistModel): errors = [] + super().validate_playlist(playlist) # Call the base class validate_playlist to perform common checks + # All sections need to have a group value - sections = experiment.playlists.first().section_set.all() + sections = playlist.section_set.all() for section in sections: file_name = section.song.name if section.song else 'No name' # every section.group should consist of a number @@ -265,7 +268,7 @@ def validate(self, experiment: Experiment): if not sections.exclude(tag__contains='practice').exists(): errors.append('At least one section should not have the tag "practice"') - # Every non-practice group should have the same number of variants + # Every non-practice group should have the same number of variants # that should be labeled with a single uppercase letter groups = sections.values('group').distinct() variants = sections.exclude(tag__contains='practice').values('tag') @@ -283,8 +286,7 @@ def validate(self, experiment: Experiment): total_variants_stringified = ', '.join(unique_variants) errors.append(f'Group {group["group"]} should have the same number of variants as the total amount of variants ({variants_count}; {total_variants_stringified}) but has {group_variants.count()} ({group_variants_stringified})') - if errors: - raise ValueError('The experiment playlist is not valid: \n- ' + '\n- '.join(errors)) + return errors def get_participant_group_variant(self, participant_id: int, group_number: int, groups_amount: int, variants_amount: int) -> str: diff --git a/backend/experiment/rules/tests/test_base.py b/backend/experiment/rules/tests/test_base.py index ef63aba92..6fbcdf164 100644 --- a/backend/experiment/rules/tests/test_base.py +++ b/backend/experiment/rules/tests/test_base.py @@ -1,6 +1,7 @@ from django.test import TestCase from django.conf import settings from experiment.models import Experiment +from section.models import Playlist from ..base import Base @@ -27,3 +28,13 @@ def test_social_media_info(self): # Check for double slashes self.assertNotIn(social_media_info['url'], '//') self.assertEqual(social_media_info['hashtags'], ['music-lab', 'amsterdammusiclab', 'citizenscience']) + + def test_validate_playlist(self): + base = Base() + playlist = None + errors = base.validate_playlist(playlist) + self.assertEqual(errors, ['The experiment must have a playlist.']) + + playlist = Playlist() + errors = base.validate_playlist(playlist) + self.assertEqual(errors, ['The experiment must have at least one section.']) \ No newline at end of file diff --git a/backend/experiment/urls.py b/backend/experiment/urls.py index 2ea63a8f8..09692262d 100644 --- a/backend/experiment/urls.py +++ b/backend/experiment/urls.py @@ -1,12 +1,13 @@ from django.urls import path from django.views.generic.base import TemplateView -from .views import get_experiment, get_experiment_collection, post_feedback, default_questions, render_markdown +from .views import get_experiment, get_experiment_collection, post_feedback, default_questions, render_markdown, validate_experiment_playlist app_name = 'experiment' urlpatterns = [ # Experiment path('render_markdown/', render_markdown, name='render_markdown'), + path('validate_playlist/', validate_experiment_playlist, name='validate_experiment_playlist'), path('/', get_experiment, name='experiment'), path('/feedback/', post_feedback, name='feedback'), path('collection//', get_experiment_collection, diff --git a/backend/experiment/views.py b/backend/experiment/views.py index a3231ffc0..27295dd2e 100644 --- a/backend/experiment/views.py +++ b/backend/experiment/views.py @@ -7,6 +7,7 @@ from django_markup.markup import formatter from .models import Experiment, ExperimentCollection, ExperimentCollectionGroup, Feedback +from section.models import Playlist from experiment.serializers import serialize_actions, serialize_experiment_collection, serialize_experiment_collection_group from experiment.rules import EXPERIMENT_RULES from experiment.actions.utils import COLLECTION_KEY @@ -132,3 +133,48 @@ def render_markdown(request): return JsonResponse({'html': formatter(markdown, filter_name='markdown')}) return JsonResponse({'html': ''}) + + +def validate_experiment_playlist( + request: HttpRequest, + rules_id: str + ) -> JsonResponse: + """ + Validate the playlist of an experiment based on the used rules + """ + + if request.method != 'POST': + return JsonResponse({'status': 'error', 'message': 'Only POST requests are allowed'}) + + if not request.body: + return JsonResponse({'status': 'error', 'message': 'No body found in request'}) + + if request.content_type != 'application/json': + return JsonResponse({'status': 'error', 'message': 'Only application/json content type is allowed'}) + + json_body = json.loads(request.body) + playlist_ids = json_body.get('playlists', []) + playlists = Playlist.objects.filter(id__in=playlist_ids) + + if not playlists: + return JsonResponse({'status': 'error', 'message': 'The experiment must have a playlist.'}) + + rules = EXPERIMENT_RULES[rules_id]() + + if not rules.validate_playlist: + return JsonResponse({'status': 'warn', 'message': 'This rulesset does not have a playlist validation.'}) + + playlist_errors = [] + + for playlist in playlists: + errors = rules.validate_playlist(playlist) + if errors: + playlist_errors.append({ + 'playlist': playlist.name, + 'errors': errors + }) + + if playlist_errors: + return JsonResponse({'status': 'error', 'message': 'There are errors in the playlist.', 'errors': playlist_errors}) + + return JsonResponse({'status': 'ok', 'message': 'The playlist is valid.'}) From c61a977e5e2284ec6dcca4861070615ab04b68f6 Mon Sep 17 00:00:00 2001 From: Evert-R Date: Tue, 28 May 2024 14:54:45 +0200 Subject: [PATCH 026/180] Add score and rank to header --- backend/experiment/serializers.py | 15 +++- backend/theme/serializers.py | 7 +- .../ExperimentCollection.tsx | 5 +- .../ExperimentCollectionDashboard.tsx | 15 ++-- frontend/src/components/Header/Header.tsx | 88 ++++++++++++++++++- frontend/src/types/Theme.ts | 3 +- 6 files changed, 119 insertions(+), 14 deletions(-) diff --git a/backend/experiment/serializers.py b/backend/experiment/serializers.py index 4bbe2d529..4ec2a57d4 100644 --- a/backend/experiment/serializers.py +++ b/backend/experiment/serializers.py @@ -50,12 +50,15 @@ def serialize_experiment_collection_group(group: ExperimentCollectionGroup, part next_experiment = get_upcoming_experiment( grouped_experiments, participant, group.dashboard) + total_score = get_total_score(grouped_experiments, participant) + if not next_experiment: return None return { 'dashboard': [serialize_experiment(experiment.experiment, participant) for experiment in grouped_experiments] if group.dashboard else [], - 'next_experiment': next_experiment + 'next_experiment': next_experiment, + 'total_score': total_score } @@ -93,3 +96,13 @@ def get_finished_session_count(experiment, participant): count = Session.objects.filter( experiment=experiment, participant=participant, finished_at__isnull=False).count() return count + + +def get_total_score(grouped_experiments, participant): + '''Calculate total score of all experiments on the dashboard''' + total_score = 0 + for grouped_experiment in grouped_experiments: + sessions = Session.objects.filter(experiment=grouped_experiment.experiment, participant=participant) + for session in sessions: + total_score += session.final_score + return total_score diff --git a/backend/theme/serializers.py b/backend/theme/serializers.py index 980b2bda3..00a3a9309 100644 --- a/backend/theme/serializers.py +++ b/backend/theme/serializers.py @@ -20,8 +20,11 @@ def serialize_header(header: HeaderConfig) -> dict: return { 'nextExperimentButtonText': _('Next experiment'), 'aboutButtonText': _('About us'), - 'showScore': header.show_score - } + 'showScore': header.show_score, + 'scoreClass': 'gold', + 'scoreLabel': _('Points'), + 'noScoreLabel': _('No points yet!') + } def serialize_theme(theme: ThemeConfig) -> dict: diff --git a/frontend/src/components/ExperimentCollection/ExperimentCollection.tsx b/frontend/src/components/ExperimentCollection/ExperimentCollection.tsx index 32f27472c..75412834b 100644 --- a/frontend/src/components/ExperimentCollection/ExperimentCollection.tsx +++ b/frontend/src/components/ExperimentCollection/ExperimentCollection.tsx @@ -34,7 +34,8 @@ const ExperimentCollection = ({ match }: ExperimentCollectionProps) => { const nextExperiment = experimentCollection?.next_experiment; const displayDashboard = experimentCollection?.dashboard.length; const showConsent = experimentCollection?.consent; - + const totalScore = experimentCollection?.total_score + const scoreClass = experimentCollection?.score_class const onNext = () => { setHasShownConsent(true); } @@ -72,7 +73,7 @@ const ExperimentCollection = ({ match }: ExperimentCollectionProps) => {
} /> - } /> + } />
) diff --git a/frontend/src/components/ExperimentCollection/ExperimentCollectionDashboard/ExperimentCollectionDashboard.tsx b/frontend/src/components/ExperimentCollection/ExperimentCollectionDashboard/ExperimentCollectionDashboard.tsx index e9ff1d349..71c8185a7 100644 --- a/frontend/src/components/ExperimentCollection/ExperimentCollectionDashboard/ExperimentCollectionDashboard.tsx +++ b/frontend/src/components/ExperimentCollection/ExperimentCollectionDashboard/ExperimentCollectionDashboard.tsx @@ -12,16 +12,19 @@ interface ExperimentCollectionDashboardProps { participantIdUrl: string | null; } -export const ExperimentCollectionDashboard: React.FC = ({ experimentCollection, participantIdUrl }) => { - +export const ExperimentCollectionDashboard: React.FC = ({ experimentCollection, participantIdUrl, totalScore }) => { + const dashboard = experimentCollection.dashboard; - const nextExperimentSlug = experimentCollection.nextExperiment?.slug; + const nextExperimentSlug = experimentCollection.nextExperiment?.slug; + const headerProps = experimentCollection.theme?.header? { - nextExperimentSlug, + nextExperimentSlug, collectionSlug: experimentCollection.slug, - ... experimentCollection.theme.header + ...experimentCollection.theme.header, + totalScore: totalScore + } : undefined; - + const getExperimentHref = (slug: string) => `/${slug}${participantIdUrl ? `?participant_id=${participantIdUrl}` : ""}`; return ( diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index f3018f02f..6be6346ac 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,15 +1,84 @@ -import React from "react"; +import React, { useEffect, useState, useRef } from "react"; import { Link } from "react-router-dom"; +import Rank from "../Rank/Rank"; +import Social from "../Social/Social" + interface HeaderProps { nextExperimentSlug: string | undefined; nextExperimentButtonText: string; collectionSlug: string; aboutButtonText: string; showScore: boolean; + totalScore: BigInteger; + scoreClass: string; + scoreLabel: string; + noScoreLabel: string; } -export const Header: React.FC = ({ nextExperimentSlug, nextExperimentButtonText, collectionSlug, aboutButtonText, showScore }) => { +export const Header: React.FC = ({ nextExperimentSlug, nextExperimentButtonText, collectionSlug, aboutButtonText, showScore, totalScore, scoreClass, scoreLabel, noScoreLabel }) => { + + const social = { + 'apps': ['facebook', 'twitter'], + 'message': `I scored ${totalScore} points`, + 'url': 'wwww.amsterdammusiclab.nl', + 'hashtags': ["amsterdammusiclab", "citizenscience"] + } + + const useAnimatedScore = (targetScore) => { + const [score, setScore] = useState(0); + + const scoreValue = useRef(0); + + useEffect(() => { + if (targetScore === 0) { + return; + } + + let id = -1; + + const nextStep = () => { + // Score step + const scoreStep = Math.max( + 1, + Math.min(10, Math.ceil(Math.abs(scoreValue.current - targetScore) / 10)) + ); + + // Scores are equal, stop + if (targetScore === scoreValue.current) { + return; + } + + // Add / subtract score + scoreValue.current += Math.sign(targetScore - scoreValue.current) * scoreStep; + setScore(scoreValue.current); + + id = setTimeout(nextStep, 50); + }; + id = setTimeout(nextStep, 50); + + return () => { + window.clearTimeout(id); + }; + }, [targetScore]); + + return score; + }; + + const Score = ({ score, label, scoreClass }) => { + const currentScore = useAnimatedScore(score); + + return ( +
+ +

+ {currentScore ? currentScore + " " : ""} + {label} +

+
+ ); + }; + return (
@@ -18,6 +87,21 @@ export const Header: React.FC = ({ nextExperimentSlug, nextExperime {aboutButtonText && {aboutButtonText}}
+ {showScore, totalScore !== 0 && ( +
+ + +
+ )} + {showScore, totalScore === 0 && ( +

{noScoreLabel}

+ )}
); } diff --git a/frontend/src/types/Theme.ts b/frontend/src/types/Theme.ts index b9aeae5df..9b674bd63 100644 --- a/frontend/src/types/Theme.ts +++ b/frontend/src/types/Theme.ts @@ -2,6 +2,7 @@ export interface Header { nextExperimentButtonText: string; aboutButtonText: string; showScore: boolean; + totalScore: BigInteger; }; export default interface Theme { @@ -13,4 +14,4 @@ export default interface Theme { name: string; footer: null; header: Header | null; -} \ No newline at end of file +} From 851ce1205679b3171b4dd8a1a4c71f9d43eb8345 Mon Sep 17 00:00:00 2001 From: Evert-R Date: Tue, 28 May 2024 15:12:51 +0200 Subject: [PATCH 027/180] fix test_header_serializer --- backend/theme/tests/test_serializers.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/theme/tests/test_serializers.py b/backend/theme/tests/test_serializers.py index a827fdeac..5c7df5895 100644 --- a/backend/theme/tests/test_serializers.py +++ b/backend/theme/tests/test_serializers.py @@ -46,7 +46,10 @@ def test_header_serializer(self): expected_json = { 'showScore': True, 'nextExperimentButtonText': 'Next experiment', - 'aboutButtonText': 'About us' + 'aboutButtonText': 'About us', + 'scoreClass': 'gold', + 'scoreLabel': 'Points', + 'noScoreLabel': 'No points yet!' } self.assertEqual(serialize_header(self.header), expected_json) From c45af914807caa8318619144f043fbbf3fb0d48a Mon Sep 17 00:00:00 2001 From: BeritJanssen Date: Tue, 28 May 2024 16:14:07 +0200 Subject: [PATCH 028/180] remove duplicate MarkdownPreviewTextInput --- backend/experiment/admin.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index 607fbd60a..12c86ceea 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -188,10 +188,6 @@ class ExperimentCollectionGroupInline(admin.StackedInline): inlines = [GroupedExperimentInline] -class MarkdownPreviewTextInput(TextInput): - template_name = 'widgets/markdown_preview_text_input.html' - - class ExperimentCollectionAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): list_display = ('name', 'slug_link', 'description_excerpt', 'dashboard', 'groups') fields = ['slug', 'name', 'description', 'consent', 'theme_config', 'dashboard', From eef4fbee53882de931ec65ee8b2575ccda2d71ac Mon Sep 17 00:00:00 2001 From: BeritJanssen Date: Tue, 28 May 2024 17:11:04 +0200 Subject: [PATCH 029/180] fix logo display --- frontend/src/components/AppBar/AppBar.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/AppBar/AppBar.jsx b/frontend/src/components/AppBar/AppBar.jsx index 7d13df5c3..e9828474a 100644 --- a/frontend/src/components/AppBar/AppBar.jsx +++ b/frontend/src/components/AppBar/AppBar.jsx @@ -8,7 +8,7 @@ const AppBar = ({ title, logoClickConfirm = null }) => { const theme = useBoundStore((state) => state.theme); - const logoUrl = theme? (API_BASE_URL + theme.logo_url) : LOGO_URL; + const logoUrl = theme?.logoUrl? (API_BASE_URL + theme.logoUrl) : LOGO_URL; // Handle click on logo, to optionally confirm navigating const onLogoClick = (e) => { From ff5dd2f62abd01e9d0999aabf13d49d3c2c47791 Mon Sep 17 00:00:00 2001 From: BeritJanssen Date: Tue, 28 May 2024 17:11:28 +0200 Subject: [PATCH 030/180] chore: os.path.join sections --- backend/section/admin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/section/admin.py b/backend/section/admin.py index 757361c15..18a4815ce 100644 --- a/backend/section/admin.py +++ b/backend/section/admin.py @@ -1,4 +1,5 @@ import csv +from os.path import join from inline_actions.admin import InlineActionsModelAdminMixin from django.contrib import admin, messages @@ -110,7 +111,8 @@ def add_sections(self, request, obj, parent_obj=None): song = get_or_create_song(this_artist, this_name) new_section.song = song - file_path = settings.MEDIA_ROOT + '/' + str(new_section.filename) + file_path = join(settings.MEDIA_ROOT, + str(new_section.filename)) with audioread.audio_open(file_path) as f: new_section.duration = f.duration new_section.save() From cb0ddb870169d16d7895edd1dd535ae3904ab4ca Mon Sep 17 00:00:00 2001 From: BeritJanssen Date: Tue, 28 May 2024 17:11:49 +0200 Subject: [PATCH 031/180] adjust footer serialization --- backend/theme/serializers.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/backend/theme/serializers.py b/backend/theme/serializers.py index 980b2bda3..8e8bf5fc1 100644 --- a/backend/theme/serializers.py +++ b/backend/theme/serializers.py @@ -3,16 +3,29 @@ from django.conf import settings from django.utils.translation import activate, gettext_lazy as _ +from django_markup.markup import formatter + +from image.models import Image from theme.models import FooterConfig, HeaderConfig, ThemeConfig def serialize_footer(footer: FooterConfig) -> dict: return { - 'disclaimer': footer.disclaimer, + 'disclaimer': formatter( + footer.disclaimer, filter_name='markdown'), 'logos': [ - join(settings.MEDIA_URL, str(logo.file)) for logo in footer.logos.all() + serialize_logo(logo) for logo in footer.logos.all() ], - 'privacy': footer.privacy + 'privacy': formatter( + footer.privacy, filter_name='markdown'), + } + + +def serialize_logo(logo: Image) -> dict: + return { + 'file': join(settings.MEDIA_URL, str(logo.file)), + 'href': logo.href, + 'alt': logo.alt, } From 2d407d44265432093608bffb6be3f344e011a67e Mon Sep 17 00:00:00 2001 From: BeritJanssen Date: Tue, 28 May 2024 17:11:57 +0200 Subject: [PATCH 032/180] add Footer component --- .../ExperimentCollection.tsx | 12 ++++ frontend/src/components/Footer/Footer.scss | 56 +++++++++++++++++++ frontend/src/components/Footer/Footer.tsx | 34 +++++++++++ frontend/src/components/components.scss | 3 + frontend/src/types/Theme.ts | 21 ++++++- 5 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/Footer/Footer.scss create mode 100644 frontend/src/components/Footer/Footer.tsx diff --git a/frontend/src/components/ExperimentCollection/ExperimentCollection.tsx b/frontend/src/components/ExperimentCollection/ExperimentCollection.tsx index 32f27472c..b1f4deeb8 100644 --- a/frontend/src/components/ExperimentCollection/ExperimentCollection.tsx +++ b/frontend/src/components/ExperimentCollection/ExperimentCollection.tsx @@ -9,6 +9,7 @@ import { import useBoundStore from "../../util/stores"; import { useExperimentCollection } from "@/API"; import Consent from "../Consent/Consent"; +import Footer from "../Footer/Footer"; import DefaultPage from "../Page/DefaultPage"; import Loading from "../Loading/Loading"; import ExperimentCollectionAbout from "./ExperimentCollectionAbout/ExperimentCollectionAbout"; @@ -35,6 +36,10 @@ const ExperimentCollection = ({ match }: ExperimentCollectionProps) => { const displayDashboard = experimentCollection?.dashboard.length; const showConsent = experimentCollection?.consent; + if (experimentCollection?.theme) { + setTheme(experimentCollection.theme); + } + const onNext = () => { setHasShownConsent(true); } @@ -74,6 +79,13 @@ const ExperimentCollection = ({ match }: ExperimentCollectionProps) => { } /> } /> + {experimentCollection.theme?.footer && ( +