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 01/19] 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 02/19] 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 03/19] 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 04/19] 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 05/19] 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 06/19] 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 07/19] 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 08/19] 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 09/19] 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 10/19] 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 11/19] 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 12/19] 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 13/19] 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 14/19] 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 15/19] 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 16/19] 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 64f08fc099fed3a9d12d9a6908db2dc4897a609c Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Sat, 8 Jun 2024 09:54:32 +0200 Subject: [PATCH 17/19] Move create_default_questions() to bootstrap.py --- backend/experiment/management/commands/bootstrap.py | 4 ++++ .../experiment/management/commands/createquestions.py | 11 ----------- backend/question/management/tests.py | 2 +- docker-compose-deploy.yml | 2 +- docker-compose.yaml | 2 +- 5 files changed, 7 insertions(+), 14 deletions(-) delete mode 100644 backend/experiment/management/commands/createquestions.py diff --git a/backend/experiment/management/commands/bootstrap.py b/backend/experiment/management/commands/bootstrap.py index bf28f6caa..e99839991 100644 --- a/backend/experiment/management/commands/bootstrap.py +++ b/backend/experiment/management/commands/bootstrap.py @@ -4,12 +4,16 @@ from experiment.models import Experiment from section.models import Playlist +from question.questions import create_default_questions class Command(BaseCommand): """ Command for creating a superuser and an experiment if they do not yet exist """ def handle(self, *args, **options): + + create_default_questions() + if User.objects.count() == 0: management.call_command('createsuperuser', '--no-input') print('Created superuser') diff --git a/backend/experiment/management/commands/createquestions.py b/backend/experiment/management/commands/createquestions.py deleted file mode 100644 index 79cbb076e..000000000 --- a/backend/experiment/management/commands/createquestions.py +++ /dev/null @@ -1,11 +0,0 @@ - -from django.core.management.base import BaseCommand - -from question.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/question/management/tests.py b/backend/question/management/tests.py index 4a3748ed0..454cf4005 100644 --- a/backend/question/management/tests.py +++ b/backend/question/management/tests.py @@ -6,7 +6,7 @@ class CreateQuestionsTest(TestCase): def test_createquestions(self): from question.models import Question, QuestionGroup - call_command('createquestions') + call_command('bootstrap') 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) diff --git a/docker-compose-deploy.yml b/docker-compose-deploy.yml index ff0ec214c..fc465865d 100644 --- a/docker-compose-deploy.yml +++ b/docker-compose-deploy.yml @@ -60,7 +60,7 @@ services: - SQL_HOST=${SQL_HOST} ports: - 8000: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" + 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" restart: always client-builder: diff --git a/docker-compose.yaml b/docker-compose.yaml index 53ba8fa57..6ef61d3ae 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -55,7 +55,7 @@ services: - SQL_HOST=${SQL_HOST} ports: - 8000: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" + command: bash -c "python manage.py migrate && python manage.py bootstrap && python manage.py runserver 0.0.0.0:8000" client: build: context: ./frontend From 529810b09668e37db33a7daa30db27ff2e6d3d00 Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Sat, 8 Jun 2024 10:55:59 +0200 Subject: [PATCH 18/19] Data migration: add default questions to experiment only if rules exist --- backend/experiment/migrations/0036_add_question_model_data.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/experiment/migrations/0036_add_question_model_data.py b/backend/experiment/migrations/0036_add_question_model_data.py index 64159838e..cf4ef86a8 100644 --- a/backend/experiment/migrations/0036_add_question_model_data.py +++ b/backend/experiment/migrations/0036_add_question_model_data.py @@ -1,12 +1,14 @@ from django.db import migrations from experiment.models import Experiment +from experiment.rules import EXPERIMENT_RULES def add_default_question_series(apps, schema_editor): for experiment in Experiment.objects.all(): - experiment.add_default_question_series() + if EXPERIMENT_RULES.get(experiment.rules) and not experiment.questionseries_set.all(): + experiment.add_default_question_series() class Migration(migrations.Migration): From 89b0b33377762aed5d07594dc012a9e48cbba198 Mon Sep 17 00:00:00 2001 From: albertas-jn <24507839+albertas-jn@users.noreply.github.com> Date: Sat, 8 Jun 2024 12:32:37 +0200 Subject: [PATCH 19/19] Remove call_command() from test_createquestions() --- backend/question/management/tests.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/question/management/tests.py b/backend/question/management/tests.py index 454cf4005..2b7fd4cf1 100644 --- a/backend/question/management/tests.py +++ b/backend/question/management/tests.py @@ -6,7 +6,6 @@ class CreateQuestionsTest(TestCase): def test_createquestions(self): from question.models import Question, QuestionGroup - call_command('bootstrap') 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)