diff --git a/backend/aml/base_settings.py b/backend/aml/base_settings.py index 3355933f3..6da138937 100644 --- a/backend/aml/base_settings.py +++ b/backend/aml/base_settings.py @@ -53,6 +53,7 @@ 'session', 'section', 'theme', + 'question' ] MIDDLEWARE = [ diff --git a/backend/aml/urls.py b/backend/aml/urls.py index 7dfee1221..3026a4b22 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 12c86ceea..8280d272b 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -16,7 +16,8 @@ from django.urls import reverse from django.utils.html import format_html from experiment.models import Experiment, ExperimentCollection, ExperimentCollectionGroup, Feedback, GroupedExperiment -from experiment.forms import ExperimentCollectionForm, ExperimentForm, ExportForm, TemplateForm, EXPORT_TEMPLATES +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 from participant.models import Participant @@ -43,8 +44,8 @@ class ExperimentAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): 'slug', 'url', 'hashtag', 'theme_config', 'language', 'active', 'rules', 'rounds', 'bonus_points', 'playlists', - 'consent', 'questions'] - inlines = [FeedbackInline] + 'consent'] + inlines = [QuestionSeriesInline, FeedbackInline] form = ExperimentForm # make playlists fields a list of checkboxes diff --git a/backend/experiment/fixtures/experiment.json b/backend/experiment/fixtures/experiment.json index 45c1b7fe6..aa1bff627 100644 --- a/backend/experiment/fixtures/experiment.json +++ b/backend/experiment/fixtures/experiment.json @@ -197,9 +197,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" ] } }, @@ -280,9 +277,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/forms.py b/backend/experiment/forms.py index bd6963f06..0ab473282 100644 --- a/backend/experiment/forms.py +++ b/backend/experiment/forms.py @@ -2,7 +2,6 @@ from experiment.models import ExperimentCollection, Experiment from experiment.rules import EXPERIMENT_RULES -from .questions import QUESTIONS_CHOICES # session_keys for Export CSV SESSION_CHOICES = [('experiment_id', 'Experiment ID'), @@ -164,12 +163,6 @@ def __init__(self, *args, **kwargs): choices=sorted(choices) ) - self.fields['questions'] = TypedMultipleChoiceField( - choices=QUESTIONS_CHOICES, - widget=CheckboxSelectMultiple, - required=False - ) - def clean_playlists(self): # Check if there is a rules id selected and key exists @@ -239,3 +232,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/__init__.py b/backend/experiment/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/experiment/management/commands/bootstrap.py b/backend/experiment/management/commands/bootstrap.py index 1e2e77c97..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') @@ -23,5 +27,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/backend/experiment/management/commands/templates/experiment.py b/backend/experiment/management/commands/templates/experiment.py index 5a9dd0ba7..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 @@ -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/management/tests.py b/backend/experiment/management/tests.py index 4abdd16f3..a030666e8 100644 --- a/backend/experiment/management/tests.py +++ b/backend/experiment/management/tests.py @@ -28,5 +28,3 @@ def test_output_csv(self): - - 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/0035_add_question_model.py b/backend/experiment/migrations/0035_add_question_model.py new file mode 100644 index 000000000..c1e6ac15f --- /dev/null +++ b/backend/experiment/migrations/0035_add_question_model.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.25 on 2024-05-09 11:52 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiment', '0034_collection_corrections'), + ] + + operations = [ + migrations.RemoveField( + model_name='experiment', + name='questions', + ), + ] diff --git a/backend/experiment/migrations/0036_add_question_model_data.py b/backend/experiment/migrations/0036_add_question_model_data.py new file mode 100644 index 000000000..cf4ef86a8 --- /dev/null +++ b/backend/experiment/migrations/0036_add_question_model_data.py @@ -0,0 +1,23 @@ + +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(): + if EXPERIMENT_RULES.get(experiment.rules) and not experiment.questionseries_set.all(): + experiment.add_default_question_series() + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiment', '0035_add_question_model'), + ('question', '0002_add_question_model_data'), + ] + + 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 107b9aa56..9a5277874 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 @@ -116,11 +115,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='', @@ -287,7 +281,26 @@ def max_score(self): return 0 + 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): + 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) + diff --git a/backend/experiment/questions/__init__.py b/backend/experiment/questions/__init__.py deleted file mode 100644 index 3bef215ed..000000000 --- a/backend/experiment/questions/__init__.py +++ /dev/null @@ -1,45 +0,0 @@ -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 .musicgens import MUSICGENS_17_W_VARIANTS -from .stomp import STOMP -from .tipi import TIPI -from .other import OTHER - -# 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]]) ) - - -def get_default_question_keys(): - return [] - - -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 len(KEYS_ALL) != len(set(KEYS_ALL)): - raise Exception("Duplicate question keys") diff --git a/backend/experiment/rules/base.py b/backend/experiment/rules/base.py index 6b1225681..dceade890 100644 --- a/backend/experiment/rules/base.py +++ b/backend/experiment/rules/base.py @@ -6,15 +6,14 @@ from django.core.exceptions import ValidationError from experiment.actions import Final, Form, Trial +from question.demographics import DEMOGRAPHICS +from question.goldsmiths import MSI_OTHER +from question.utils import question_by_key, unanswered_questions from section.models import Playlist -import section.validators -from experiment.questions.demographics import DEMOGRAPHICS -from experiment.questions.goldsmiths import MSI_OTHER -from experiment.questions.utils import question_by_key, unanswered_questions from result.score import SCORING_RULES from session.models import Session -from experiment.questions import get_questions_from_keys +from question.questions import get_questions_from_series, QUESTION_GROUPS logger = logging.getLogger(__name__) @@ -25,7 +24,10 @@ 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}) @@ -129,7 +131,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( @@ -142,7 +144,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 f358cde3c..dd1971fac 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 @@ -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/hooked.py b/backend/experiment/rules/hooked.py index aa0c9ace4..826268064 100644 --- a/backend/experiment/rules/hooked.py +++ b/backend/experiment/rules/hooked.py @@ -8,13 +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.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 @@ -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..71ab3643d 100644 --- a/backend/experiment/rules/huang_2022.py +++ b/backend/experiment/rules/huang_2022.py @@ -7,10 +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 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 @@ -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 739601d2e..0ef6e6426 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 @@ -22,12 +22,18 @@ class MatchingPairsGame(Base): random_seed = None 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..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 @@ -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/rhythm_battery_final.py b/backend/experiment/rules/rhythm_battery_final.py index 2cc45ea14..1b9ce090b 100644 --- a/backend/experiment/rules/rhythm_battery_final.py +++ b/backend/experiment/rules/rhythm_battery_final.py @@ -1,10 +1,8 @@ from django.utils.translation import gettext_lazy as _ from django.template.loader import render_to_string +from question.questions import QUESTION_GROUPS from experiment.actions import Explainer, Final, Step -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 .base import Base @@ -16,16 +14,25 @@ class RhythmBatteryFinal(Base): show_participant_final = False 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": True + }, + { + "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): explainer = Explainer( diff --git a/backend/experiment/rules/speech2song.py b/backend/experiment/rules/speech2song.py index a8c4323dc..2de84bbd5 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 session.models import Session @@ -26,17 +26,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..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 @@ -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/tests/test_hooked.py b/backend/experiment/rules/tests/test_hooked.py index 273dc4538..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 @@ -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/rules/thats_my_song.py b/backend/experiment/rules/thats_my_song.py index 59a4285a9..bf0765391 100644 --- a/backend/experiment/rules/thats_my_song.py +++ b/backend/experiment/rules/thats_my_song.py @@ -3,8 +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 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 @@ -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 da9c4d69d..493f5c596 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 @@ -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..23cbca6ae 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) - } - } - - // 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 - - let defaultQuestions = [] - - 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" - } - - // 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 - } - } - } - } - + // 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 new file mode 100644 index 000000000..9cab6b8b0 --- /dev/null +++ b/backend/experiment/static/questionseries_admin.js @@ -0,0 +1,50 @@ + +document.addEventListener("DOMContentLoaded", (event) => { + + async function getQuestionGroups(){ + + let response = await fetch(`/question/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/tests/test_admin_experiment.py b/backend/experiment/tests/test_admin_experiment.py index be9351801..b89cf3ca9 100644 --- a/backend/experiment/tests/test_admin_experiment.py +++ b/backend/experiment/tests/test_admin_experiment.py @@ -14,7 +14,7 @@ # Expected field count per model -EXPECTED_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 498909e36..6eb39f2ce 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 TestModelExperimentCollection(TestCase): diff --git a/backend/experiment/urls.py b/backend/experiment/urls.py index 09692262d..d6153b18e 100644 --- a/backend/experiment/urls.py +++ b/backend/experiment/urls.py @@ -1,10 +1,11 @@ from django.urls import path from django.views.generic.base import TemplateView -from .views import get_experiment, get_experiment_collection, post_feedback, default_questions, render_markdown, validate_experiment_playlist +from .views import get_experiment, get_experiment_collection, post_feedback, render_markdown, add_default_question_series, validate_experiment_playlist app_name = 'experiment' urlpatterns = [ + path('add_default_question_series//', add_default_question_series, name='add_default_question_series'), # Experiment path('render_markdown/', render_markdown, name='render_markdown'), path('validate_playlist/', validate_experiment_playlist, name='validate_experiment_playlist'), @@ -12,7 +13,6 @@ 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 b13e9f5ad..f5b9aea33 100644 --- a/backend/experiment/views.py +++ b/backend/experiment/views.py @@ -73,8 +73,10 @@ def experiment_or_404(slug): raise Http404("Experiment does not exist") -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: HttpRequest, slug: str, group_index: int = 0) -> JsonResponse: 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..bf347088f --- /dev/null +++ b/backend/question/admin.py @@ -0,0 +1,49 @@ +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): + """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: + 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 85% rename from backend/experiment/questions/demographics.py rename to backend/question/demographics.py index ad8fd11f5..0419cd18d 100644 --- a/backend/experiment/questions/demographics.py +++ b/backend/question/demographics.py @@ -145,3 +145,33 @@ } ) ] + + +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 = question_by_key('dgf_education', drop_choices=['isced-1']) + question.key = 'dgf_education_gold_msi' + questions.append(question) + + question = question_by_key('dgf_education', drop_choices=['isced-5']) + question.key = 'dgf_education_huang_2022' + questions.append(question) + + 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"), + is_skippable=True + ) +] diff --git a/backend/question/fixtures/question.json b/backend/question/fixtures/question.json new file mode 100644 index 000000000..5618e2f57 --- /dev/null +++ b/backend/question/fixtures/question.json @@ -0,0 +1,66 @@ +[ + { + "model": "question.question", + "pk": "dgf_generation", + "fields": { + "question": "When were you born?", + "editable": false + } + }, + { + "model": "question.question", + "pk": "dgf_gender_identity", + "fields": { + "question": "With which gender do you currently most identify?", + "editable": false + } + }, + { + "model": "question.question", + "pk": "P01_1", + "fields": { + "question": "Can you clap in time with a musical beat?", + "editable": false + } + }, + { + "model": "question.question", + "pk": "P01_2", + "fields": { + "question": "I can tap my foot in time with the beat of the music I hear.", + "editable": false + } + }, + { + "model": "question.question", + "pk": "P01_3", + "fields": { + "question": "When listening to music, can you move in time with the beat?", + "editable": false + } + }, + { + "model": "question.question", + "pk": "msi_01_music_activities", + "fields": { + "question": "I spend a lot of my free time doing music-related activities.", + "editable": false + } + }, + { + "model": "question.question", + "pk": "msi_03_writing", + "fields": { + "question": "I enjoy writing about music, for example on blogs and forums.", + "editable": false + } + }, + { + "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.", + "editable": false + } + } +] diff --git a/backend/question/fixtures/questioninseries.json b/backend/question/fixtures/questioninseries.json new file mode 100644 index 000000000..ed392f763 --- /dev/null +++ b/backend/question/fixtures/questioninseries.json @@ -0,0 +1,74 @@ +[ + { + "model": "question.questioninseries", + "pk": 1, + "fields": { + "question_series": 1, + "question": "msi_01_music_activities", + "index": 1 + } + }, + { + "model": "question.questioninseries", + "pk": 2, + "fields": { + "question_series": 1, + "question": "msi_03_writing", + "index": 2 + } + }, + { + "model": "question.questioninseries", + "pk": 3, + "fields": { + "question_series": 1, + "question": "msi_08_intrigued_styles", + "index": 3 + } + }, + { + "model": "question.questioninseries", + "pk": 4, + "fields": { + "question_series": 2, + "question": "dgf_generation", + "index": 1 + } + }, + { + "model": "question.questioninseries", + "pk": 5, + "fields": { + "question_series": 2, + "question": "dgf_gender_identity", + "index": 2 + } + }, + { + "model": "question.questioninseries", + "pk": 6, + "fields": { + "question_series": 2, + "question": "P01_1", + "index": 3 + } + }, + { + "model": "question.questioninseries", + "pk": 7, + "fields": { + "question_series": 2, + "question": "P01_2", + "index": 4 + } + }, + { + "model": "question.questioninseries", + "pk": 8, + "fields": { + "question_series": 2, + "question": "P01_3", + "index": 5 + } + } +] diff --git a/backend/question/fixtures/questionseries.json b/backend/question/fixtures/questionseries.json new file mode 100644 index 000000000..32dc4c7c2 --- /dev/null +++ b/backend/question/fixtures/questionseries.json @@ -0,0 +1,20 @@ +[ + { + "model": "question.questionseries", + "pk": 1, + "fields": { + "experiment": 14, + "index": 1, + "randomize": false + } + }, + { + "model": "question.questionseries", + "pk": 2, + "fields": { + "experiment": 20, + "index": 1, + "randomize": false + } + } +] 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 84% rename from backend/experiment/questions/languages.py rename to backend/question/languages.py index c7cbb4ece..9c4aa1202 100644 --- a/backend/experiment/questions/languages.py +++ b/backend/question/languages.py @@ -51,3 +51,12 @@ 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/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..2b7fd4cf1 --- /dev/null +++ b/backend/question/management/tests.py @@ -0,0 +1,14 @@ +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 + 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/migrations/0001_add_question_model.py b/backend/question/migrations/0001_add_question_model.py new file mode 100644 index 000000000..62f748be3 --- /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', '0035_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..37e1a1d7e --- /dev/null +++ b/backend/question/models.py @@ -0,0 +1,62 @@ +from django.db import models +from experiment.models import Experiment + + +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) + editable = models.BooleanField(default=True, editable=False) + + def __str__(self): + return "("+self.key+") "+ self.question + + class Meta: + ordering = ["key"] + + +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) + 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): + """Series of Questions asked in an Experiment""" + + 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 with its index in QuestionSeries""" + + 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/question/questions.py b/backend/question/questions.py new file mode 100644 index 000000000..5347e3ec9 --- /dev/null +++ b/backend/question/questions.py @@ -0,0 +1,70 @@ +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 .models import QuestionGroup, Question + +# 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 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) + 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) + 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