From 577d10754817b08a0b271660226eb1d2e447eaaf Mon Sep 17 00:00:00 2001 From: Drikus Roor Date: Mon, 5 Aug 2024 09:48:37 +0200 Subject: [PATCH] Added: Add translated_content model and update Experiment model (#1208) * feat: Add translated_content model with FK to experiment and add migrations * fix: Fix reverse migration * feat: Remove experiment fields and migrations The commit removes the `name`, `description`, `consent`, and `about_content` fields from the `Experiment` model and updates the admin forms accordingly. * refactor: Only show an empty form for a newly to be created experiment * feat: Add fallback option to get_translated_content method This commit adds a `fallback` parameter to the `get_translated_content` method in the `Experiment` model. When set to `True`, if no content is found for the specified language, it will return the primary content instead. If `fallback` is set to `False` and no content is found, a `ValueError` will be raised. Refactor the `get_content` method to `get_translated_content` to better reflect its purpose. * refactor: Rename primary_content to fallback_content * fix(test): Fix & update tests after adding experiment translated content * chore: Rename primary content to fallback content * refactor: Use `get_language` instead of checking `request.LANGUAGE_CODE` * refactor: Remove unnecessary code for setting language in get_block view as this has been replaced at the experiment level per #1204 * refactor: Moving setting the language cookie from `get_block` to `get_experiment` * test: Add tests for getting the right language content based on the `Accept-Language` header's value * refactor: Delete all experiment translated content This commit modifies the migration file `0050_migrate_experiment_content_to_translatedmakemigrations.py` to delete all experiment translated content using the `ExperimentTranslatedContent.objects.all().delete()` method. This change is necessary to clean up the database and ensure that no translated content exists anymore after running the reverse migration. * fix: Fix ci by changing `docker-compose` command to `docker compose` --- .github/workflows/ci-backend.yml | 10 +- .github/workflows/ci-frontend.yml | 8 +- backend/experiment/admin.py | 111 +++--- backend/experiment/forms.py | 352 ++++++++++------- .../0049_experimenttranslatedcontent.py | 236 +++++++++++ ...ent_content_to_translatedmakemigrations.py | 50 +++ ...emove_experiment_about_content_and_more.py | 29 ++ backend/experiment/models.py | 301 +++++++------- backend/experiment/serializers.py | 108 +++--- .../experiment/tests/test_admin_experiment.py | 77 ++-- backend/experiment/tests/test_model.py | 146 ++++--- .../experiment/tests/test_model_functions.py | 69 ++-- backend/experiment/tests/test_views.py | 367 ++++++++---------- backend/experiment/views.py | 147 ++++--- backend/image/validators.py | 26 +- scripts/build-front | 3 +- scripts/format-back | 2 +- scripts/lint-back | 2 +- scripts/lint-front | 2 +- scripts/manage | 2 +- scripts/test-back | 3 +- scripts/test-back-coverage | 3 +- scripts/test-front | 3 +- scripts/test-front-ci | 2 +- scripts/test-front-watch | 3 +- 25 files changed, 1195 insertions(+), 867 deletions(-) create mode 100644 backend/experiment/migrations/0049_experimenttranslatedcontent.py create mode 100644 backend/experiment/migrations/0050_migrate_experiment_content_to_translatedmakemigrations.py create mode 100644 backend/experiment/migrations/0051_remove_experiment_about_content_and_more.py diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml index 25ea5d513..5bccb4188 100644 --- a/.github/workflows/ci-backend.yml +++ b/.github/workflows/ci-backend.yml @@ -22,16 +22,16 @@ jobs: steps: - uses: actions/checkout@v3 - name: Run Backend Tests - run: sudo docker-compose --env-file .env-github-actions run server bash -c "coverage run manage.py test" + run: sudo docker compose --env-file .env-github-actions run server bash -c "coverage run manage.py test" - name: Generate Backend Coverage Report (Inline) - run: sudo docker-compose --env-file .env-github-actions run server bash -c "coverage report --show-missing" + run: sudo docker compose --env-file .env-github-actions run server bash -c "coverage report --show-missing" # Generate coverage badge (only for main and develop branches) - name: Generate Backend Coverage Report (XML) and Badge if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' run: | - sudo docker-compose --env-file .env-github-actions run server bash -c "coverage xml" - sudo docker-compose --env-file .env-github-actions run server bash -c "genbadge coverage -i coverage.xml -o coverage-backend-badge-new.svg -n \"Backend Code Coverage\"" + sudo docker compose --env-file .env-github-actions run server bash -c "coverage xml" + sudo docker compose --env-file .env-github-actions run server bash -c "genbadge coverage -i coverage.xml -o coverage-backend-badge-new.svg -n \"Backend Code Coverage\"" # Push coverage badge to separate branch (only for main and develop branches) - name: Push Backend Coverage Badge to separate branch continue-on-error: true @@ -66,4 +66,4 @@ jobs: - uses: actions/checkout@v3 - name: Lint Backend continue-on-error: false - run: sudo docker-compose --env-file .env-github-actions run server bash -c "ruff check" + run: sudo docker compose --env-file .env-github-actions run server bash -c "ruff check" diff --git a/.github/workflows/ci-frontend.yml b/.github/workflows/ci-frontend.yml index 6019cd9d8..3c0e8c123 100644 --- a/.github/workflows/ci-frontend.yml +++ b/.github/workflows/ci-frontend.yml @@ -22,7 +22,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Run Frontend Tests - run: sudo docker-compose --env-file .env-github-actions run client yarn test:ci + run: sudo docker compose --env-file .env-github-actions run client yarn test:ci frontend-coverage-badge: name: Generate Frontend Coverage Badge @@ -33,8 +33,8 @@ jobs: - uses: actions/checkout@v3 - name: Generate Frontend Coverage Report (XML) and Badge run: | - sudo docker-compose --env-file .env-github-actions run client yarn test:ci - sudo docker-compose --env-file .env-github-actions run client yarn coverage-badges -s public/coverage/coverage-summary.json -o public/coverage/coverage-frontend-badge-new.svg --label 'Frontend Code Coverage' + sudo docker compose --env-file .env-github-actions run client yarn test:ci + sudo docker compose --env-file .env-github-actions run client yarn coverage-badges -s public/coverage/coverage-summary.json -o public/coverage/coverage-frontend-badge-new.svg --label 'Frontend Code Coverage' - name: Push Frontend Coverage Badge to separate branch continue-on-error: true run: | @@ -65,4 +65,4 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - run: sudo docker-compose --env-file .env-github-actions run client yarn lint + - run: sudo docker compose --env-file .env-github-actions run client yarn lint diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index b47d0c9da..002243d12 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -22,6 +22,7 @@ Phase, Feedback, SocialMediaConfig, + ExperimentTranslatedContent, ) from question.admin import QuestionSeriesInline from experiment.forms import ( @@ -45,6 +46,16 @@ class FeedbackInline(admin.TabularInline): extra = 0 +class ExperimentTranslatedContentInline(NestedStackedInline): + model = ExperimentTranslatedContent + sortable_field_name = "index" + + def get_extra(self, request, obj=None, **kwargs): + if obj: + return 0 + return 1 + + class BlockAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): list_display = ( "image_preview", @@ -112,36 +123,22 @@ def export(self, request, obj, parent_obj=None): zip_buffer = BytesIO() with ZipFile(zip_buffer, "w") as new_zip: # serialize data to new json files within the zip file - new_zip.writestr( - "sessions.json", data=str(serializers.serialize("json", all_sessions)) - ) + new_zip.writestr("sessions.json", data=str(serializers.serialize("json", all_sessions))) new_zip.writestr( "participants.json", - data=str( - serializers.serialize("json", all_participants.order_by("pk")) - ), + data=str(serializers.serialize("json", all_participants.order_by("pk"))), ) new_zip.writestr( "profiles.json", - data=str( - serializers.serialize( - "json", all_profiles.order_by("participant", "pk") - ) - ), + data=str(serializers.serialize("json", all_profiles.order_by("participant", "pk"))), ) new_zip.writestr( "results.json", - data=str( - serializers.serialize("json", all_results.order_by("session")) - ), + data=str(serializers.serialize("json", all_results.order_by("session"))), ) new_zip.writestr( "sections.json", - data=str( - serializers.serialize( - "json", all_sections.order_by("playlist", "pk") - ) - ), + data=str(serializers.serialize("json", all_sections.order_by("playlist", "pk"))), ) new_zip.writestr( "songs.json", @@ -152,11 +149,7 @@ def export(self, request, obj, parent_obj=None): response = HttpResponse(zip_buffer.getbuffer()) response["Content-Type"] = "application/x-zip-compressed" response["Content-Disposition"] = ( - 'attachment; filename="' - + obj.slug - + "-" - + timezone.now().isoformat() - + '.zip"' + 'attachment; filename="' + obj.slug + "-" + timezone.now().isoformat() + '.zip"' ) return response @@ -170,20 +163,14 @@ def export_csv(self, request, obj, parent_obj=None): result_keys = [] export_options = [] # Get all export options - session_keys = [ - key for key in request.POST.getlist("export_session_fields") - ] + session_keys = [key for key in request.POST.getlist("export_session_fields")] result_keys = [key for key in request.POST.getlist("export_result_fields")] export_options = [key for key in request.POST.getlist("export_options")] response = HttpResponse(content_type="text/csv") - response["Content-Disposition"] = 'attachment; filename="{}.csv"'.format( - obj.slug - ) + response["Content-Disposition"] = 'attachment; filename="{}.csv"'.format(obj.slug) # Get filtered data - block_table, fieldnames = obj.export_table( - session_keys, result_keys, export_options - ) + block_table, fieldnames = obj.export_table(session_keys, result_keys, export_options) fieldnames.sort() writer = csv.DictWriter(response, fieldnames) writer.writeheader() @@ -227,11 +214,7 @@ def block_name_link(self, obj): def block_slug_link(self, obj): dev_mode = settings.DEBUG is True - url = ( - f"http://localhost:3000/block/{obj.slug}" - if dev_mode - else f"/block/{obj.slug}" - ) + url = f"http://localhost:3000/block/{obj.slug}" if dev_mode else f"/block/{obj.slug}" return format_html( f'{obj.slug} ' @@ -248,21 +231,33 @@ def block_slug_link(self, obj): class BlockInline(NestedStackedInline): model = Block - extra = 1 sortable_field_name = "index" + def get_extra(self, request, obj=None, **kwargs): + if obj: + return 0 + return 1 + class PhaseInline(NestedTabularInline): model = Phase - extra = 1 sortable_field_name = "index" inlines = [BlockInline] + def get_extra(self, request, obj=None, **kwargs): + if obj: + return 0 + return 1 + class SocialMediaConfigInline(NestedStackedInline): form = SocialMediaConfigForm model = SocialMediaConfig - extra = 0 + + def get_extra(self, request, obj=None, **kwargs): + if obj: + return 0 + return 1 class ExperimentAdmin(InlineActionsModelAdminMixin, NestedModelAdmin): @@ -276,21 +271,22 @@ class ExperimentAdmin(InlineActionsModelAdminMixin, NestedModelAdmin): ) fields = [ "slug", - "name", "active", - "description", - "consent", "theme_config", "dashboard", - "about_content", ] inline_actions = ["dashboard"] form = ExperimentForm inlines = [ + ExperimentTranslatedContentInline, PhaseInline, SocialMediaConfigInline, ] + def name(self, obj): + content = obj.get_fallback_content() + return content.name if content else "No name" + def slug_link(self, obj): dev_mode = settings.DEBUG is True url = f"http://localhost:3000/{obj.slug}" if dev_mode else f"/{obj.slug}" @@ -300,20 +296,16 @@ def slug_link(self, obj): ) def description_excerpt(self, obj): - if len(obj.description) < 50: - return obj.description + description = obj.get_fallback_content().description or "No description" + if len(description) < 50: + return description - return obj.description[:50] + "..." + return description[:50] + "..." def phases(self, obj): phases = Phase.objects.filter(series=obj) return format_html( - ", ".join( - [ - f'{phase.name}' - for phase in phases - ] - ) + ", ".join([f'{phase.name}' for phase in phases]) ) slug_link.short_description = "Slug" @@ -380,7 +372,9 @@ def name_link(self, obj): def related_experiment(self, obj): url = reverse("admin:experiment_experiment_change", args=[obj.series.pk]) - return format_html('{}', url, obj.series.name) + content = obj.series.get_fallback_content() + experiment_name = content.name if content else "No name" + return format_html('{}', url, experiment_name) def blocks(self, obj): blocks = Block.objects.filter(phase=obj) @@ -389,12 +383,7 @@ def blocks(self, obj): return "No blocks" return format_html( - ", ".join( - [ - f'{block.name}' - for block in blocks - ] - ) + ", ".join([f'{block.name}' for block in blocks]) ) diff --git a/backend/experiment/forms.py b/backend/experiment/forms.py index 667cd46e2..df5bf96f7 100644 --- a/backend/experiment/forms.py +++ b/backend/experiment/forms.py @@ -1,156 +1,220 @@ -from django.forms import CheckboxSelectMultiple, ModelForm, ChoiceField, Form, MultipleChoiceField, ModelMultipleChoiceField, Select, TypedMultipleChoiceField, CheckboxSelectMultiple, TextInput -from experiment.models import Experiment, Block, SocialMediaConfig +from django.forms import ( + CheckboxSelectMultiple, + ModelForm, + ChoiceField, + Form, + MultipleChoiceField, + ModelMultipleChoiceField, + Select, + CheckboxSelectMultiple, + TextInput, +) +from experiment.models import Experiment, Block, SocialMediaConfig, ExperimentTranslatedContent from experiment.rules import BLOCK_RULES # session_keys for Export CSV -SESSION_CHOICES = [('block_id', 'Block ID'), - ('block_name', 'Block name'), - ('participant_id', 'Participant ID'), - ('participant_country', 'Participant Country'), - ('participant_access_info', 'Participant access info'), - ('session_start', 'Session start time'), - ('session_end', 'Session end time'), - ('final_score', 'Final score'), - ] +SESSION_CHOICES = [ + ("block_id", "Block ID"), + ("block_name", "Block name"), + ("participant_id", "Participant ID"), + ("participant_country", "Participant Country"), + ("participant_access_info", "Participant access info"), + ("session_start", "Session start time"), + ("session_end", "Session end time"), + ("final_score", "Final score"), +] # result_keys for Export CSV -RESULT_CHOICES = [('section_name', 'Section name'), - ('result_created_at', 'Created time'), - ('result_score', 'Result score'), - ('result_comment', 'Result comment'), - ('expected_response', 'Expected response'), - ('given_response', 'Given response'), - ('question_key', 'Question key'), - ] +RESULT_CHOICES = [ + ("section_name", "Section name"), + ("result_created_at", "Created time"), + ("result_score", "Result score"), + ("result_comment", "Result comment"), + ("expected_response", "Expected response"), + ("given_response", "Given response"), + ("question_key", "Question key"), +] # export_options for Export CSV -EXPORT_OPTIONS = [('export_profile', "Include participants' profile Q&A"), - ('session_data', 'Include session data'), - ('convert_session_json', 'Convert session data JSON to CSV columns'), - ('decision_time', 'Include result decision time'), - ('result_config', "Include result configuration parameters"), - ('convert_result_json', 'Convert result data JSON to CSV columns'), - ('wide_format', 'CSV wide format (Long format is default)'), - ] +EXPORT_OPTIONS = [ + ("export_profile", "Include participants' profile Q&A"), + ("session_data", "Include session data"), + ("convert_session_json", "Convert session data JSON to CSV columns"), + ("decision_time", "Include result decision time"), + ("result_config", "Include result configuration parameters"), + ("convert_result_json", "Convert result data JSON to CSV columns"), + ("wide_format", "CSV wide format (Long format is default)"), +] # Export templates for Export CSV -EXPORT_TEMPLATES = {'wide': - [['block_id', 'block_name', 'participant_id', - 'participant_country', 'participant_access_info', 'session_start', 'session_end', 'final_score'], - ['section_name', 'result_created_at', 'result_score', 'result_comment', - 'expected_response', 'given_response'], - ['export_profile', 'session_data', 'convert_session_json', 'decision_time', 'result_config', - 'convert_result_json', 'wide_format']], - 'wide_json': - [['block_id', 'block_name', 'participant_id', - 'participant_country', 'participant_access_info', 'session_start', 'session_end', 'final_score'], - ['section_name', 'result_created_at', 'result_score', 'result_comment', - 'expected_response', 'given_response'], - ['export_profile', 'session_data', 'decision_time', 'result_config', 'wide_format']], - 'wide_results': - [['block_name', 'participant_id', 'session_start', 'session_end', 'final_score'], - ['section_name', 'result_created_at', 'result_score', 'result_comment', - 'expected_response', 'given_response'], - ['session_data', 'convert_session_json', 'decision_time', 'wide_format']], - - 'wide_results_json': - [['block_name', 'participant_id', 'session_start', 'session_end', 'final_score'], - ['section_name', 'result_created_at', 'result_score', 'result_comment', - 'expected_response', 'given_response'], - ['session_data', 'decision_time', 'result_config', 'wide_format']], - 'wide_profile': - [['block_name', 'participant_id', - 'participant_country', 'participant_access_info'], - [], - ['export_profile', 'wide_format']], - - 'long': - [['block_id', 'block_name', 'participant_id', - 'participant_country', 'participant_access_info', 'session_start', 'session_end', 'final_score'], - ['section_name', 'result_created_at', 'result_score', 'result_comment', - 'expected_response', 'given_response'], - ['export_profile', 'session_data', 'convert_session_json', 'decision_time', 'result_config', - 'convert_result_json']], - 'long_json': - [['block_id', 'block_name', 'participant_id', - 'participant_country', 'participant_access_info', 'session_start', 'session_end', 'final_score'], - ['section_name', 'result_created_at', 'result_score', 'result_comment', - 'expected_response', 'given_response'], - ['export_profile', 'session_data', 'decision_time', 'result_config' - ]], - 'long_results': - [['block_name', 'participant_id', 'session_start', 'session_end', 'final_score'], - ['section_name', 'result_created_at', 'result_score', 'result_comment', - 'expected_response', 'given_response'], - ['session_data', 'convert_session_json', 'decision_time']], - 'long_results_json': - [['block_name', 'participant_id', 'session_start', 'session_end', 'final_score'], - ['section_name', 'result_created_at', 'result_score', 'result_comment', - 'expected_response', 'given_response'], - ['session_data', 'decision_time', 'result_config']], - 'long_profile': - [['block_name', 'participant_id', - 'participant_country', 'participant_access_info'], - [], - ['export_profile']] - } +EXPORT_TEMPLATES = { + "wide": [ + [ + "block_id", + "block_name", + "participant_id", + "participant_country", + "participant_access_info", + "session_start", + "session_end", + "final_score", + ], + ["section_name", "result_created_at", "result_score", "result_comment", "expected_response", "given_response"], + [ + "export_profile", + "session_data", + "convert_session_json", + "decision_time", + "result_config", + "convert_result_json", + "wide_format", + ], + ], + "wide_json": [ + [ + "block_id", + "block_name", + "participant_id", + "participant_country", + "participant_access_info", + "session_start", + "session_end", + "final_score", + ], + ["section_name", "result_created_at", "result_score", "result_comment", "expected_response", "given_response"], + ["export_profile", "session_data", "decision_time", "result_config", "wide_format"], + ], + "wide_results": [ + ["block_name", "participant_id", "session_start", "session_end", "final_score"], + ["section_name", "result_created_at", "result_score", "result_comment", "expected_response", "given_response"], + ["session_data", "convert_session_json", "decision_time", "wide_format"], + ], + "wide_results_json": [ + ["block_name", "participant_id", "session_start", "session_end", "final_score"], + ["section_name", "result_created_at", "result_score", "result_comment", "expected_response", "given_response"], + ["session_data", "decision_time", "result_config", "wide_format"], + ], + "wide_profile": [ + ["block_name", "participant_id", "participant_country", "participant_access_info"], + [], + ["export_profile", "wide_format"], + ], + "long": [ + [ + "block_id", + "block_name", + "participant_id", + "participant_country", + "participant_access_info", + "session_start", + "session_end", + "final_score", + ], + ["section_name", "result_created_at", "result_score", "result_comment", "expected_response", "given_response"], + [ + "export_profile", + "session_data", + "convert_session_json", + "decision_time", + "result_config", + "convert_result_json", + ], + ], + "long_json": [ + [ + "block_id", + "block_name", + "participant_id", + "participant_country", + "participant_access_info", + "session_start", + "session_end", + "final_score", + ], + ["section_name", "result_created_at", "result_score", "result_comment", "expected_response", "given_response"], + ["export_profile", "session_data", "decision_time", "result_config"], + ], + "long_results": [ + ["block_name", "participant_id", "session_start", "session_end", "final_score"], + ["section_name", "result_created_at", "result_score", "result_comment", "expected_response", "given_response"], + ["session_data", "convert_session_json", "decision_time"], + ], + "long_results_json": [ + ["block_name", "participant_id", "session_start", "session_end", "final_score"], + ["section_name", "result_created_at", "result_score", "result_comment", "expected_response", "given_response"], + ["session_data", "decision_time", "result_config"], + ], + "long_profile": [ + ["block_name", "participant_id", "participant_country", "participant_access_info"], + [], + ["export_profile"], + ], +} # Export templates for Export CSV (ExportForm) TEMPLATE_CHOICES = [ - ('wide', 'CSV wide data format, All data'), - ('wide_json', 'CSV wide data format, All data, Keep JSON'), - ('wide_results', - 'CSV wide data format, Session data, Results'), - ('wide_results_json', - 'CSV wide data format, Session data as JSON, Results, Result data as JSON'), - ('wide_profile', - 'CSV wide data format, Profile Q&A'), - ('long', 'CSV long data format, All data'), - ('long_json', 'CSV long data format, All data, Keep JSON'), - ('long_results', - 'CSV long data format, Session data, Results'), - ('long_results_json', - 'CSV long data format, Session data as JSON, Results, Result data as JSON'), - ('long_profile', - 'CSV long data format, Profile Q&A'), + ("wide", "CSV wide data format, All data"), + ("wide_json", "CSV wide data format, All data, Keep JSON"), + ("wide_results", "CSV wide data format, Session data, Results"), + ("wide_results_json", "CSV wide data format, Session data as JSON, Results, Result data as JSON"), + ("wide_profile", "CSV wide data format, Profile Q&A"), + ("long", "CSV long data format, All data"), + ("long_json", "CSV long data format, All data, Keep JSON"), + ("long_results", "CSV long data format, Session data, Results"), + ("long_results_json", "CSV long data format, Session data as JSON, Results, Result data as JSON"), + ("long_profile", "CSV long data format, Profile Q&A"), ] class ModelFormFieldAsJSON(ModelMultipleChoiceField): - """ override clean method to prevent pk lookup to save querysets """ + """override clean method to prevent pk lookup to save querysets""" + def clean(self, value): return value class MarkdownPreviewTextInput(TextInput): - template_name = 'widgets/markdown_preview_text_input.html' + template_name = "widgets/markdown_preview_text_input.html" class ExperimentForm(ModelForm): def __init__(self, *args, **kwargs): super(ModelForm, self).__init__(*args, **kwargs) - self.fields['dashboard'].help_text = ( - 'This field will be deprecated in the nearby future. ' - 'Please use experiment phases for dashboard configuration. (see bottom of form).

' + self.fields["dashboard"].help_text = ( + "This field will be deprecated in the nearby future. " + "Please use experiment phases for dashboard configuration. (see bottom of form).

" 'Legacy behavior: If you check "dashboard", the experiment will have a ' - 'dashboard that shows all or a subgroup of related blocks along ' - 'with a description, footer, and about page. If you leave it unchecked, ' - 'the experiment will redirect to the first block.') - self.fields['about_content'].widget = MarkdownPreviewTextInput() + "dashboard that shows all or a subgroup of related blocks along " + "with a description, footer, and about page. If you leave it unchecked, " + "the experiment will redirect to the first block." + ) class Meta: model = Experiment - fields = ['slug', 'description', - 'dashboard', 'about_content'] + fields = [ + "slug", + "dashboard", + ] class Media: js = ["experiment_admin.js"] css = {"all": ["experiment_admin.css"]} -class BlockForm(ModelForm): +class ExperimentTranslatedContentForm(ModelForm): + def __init__(self, *args, **kwargs): + super(ModelForm, self).__init__(*args, **kwargs) + self.fields["about_content"].widget = MarkdownPreviewTextInput() + + class Meta: + model = ExperimentTranslatedContent + fields = [ + "about_content", + ] + +class BlockForm(ModelForm): def __init__(self, *args, **kwargs): super(ModelForm, self).__init__(*args, **kwargs) @@ -159,25 +223,22 @@ def __init__(self, *args, **kwargs): choices += ((i, BLOCK_RULES[i].__name__),) choices += (("", "---------"),) - self.fields['rules'] = ChoiceField( - choices=sorted(choices) - ) + self.fields["rules"] = ChoiceField(choices=sorted(choices)) def clean_playlists(self): - # Check if there is a rules id selected and key exists - if 'rules' not in self.cleaned_data: + if "rules" not in self.cleaned_data: return # Validat the rules' playlist - rule_id = self.cleaned_data['rules'] + rule_id = self.cleaned_data["rules"] cl = BLOCK_RULES[rule_id] rules = cl() - playlists = self.cleaned_data['playlists'] + playlists = self.cleaned_data["playlists"] if not playlists: - return self.cleaned_data['playlists'] + return self.cleaned_data["playlists"] playlist_errors = [] @@ -189,23 +250,30 @@ def clean_playlists(self): playlist_errors.append(f"Playlist [{playlist.name}]: {error}") if playlist_errors: - self.add_error('playlists', playlist_errors) + self.add_error("playlists", playlist_errors) return playlists class Meta: model = Block - fields = ['name', 'slug', 'active', 'rules', - 'rounds', 'bonus_points', 'playlists',] + fields = [ + "name", + "slug", + "active", + "rules", + "rounds", + "bonus_points", + "playlists", + ] help_texts = { - 'description': 'A short description of the block that will be displayed on the experiment page and as a meta description in search engines.', - 'image': 'An image that will be displayed on the experiment page and as a meta image in search engines.', - 'consent': 'Upload an HTML (.html) or MARKDOWN (.md) file with a text to ask a user its consent
\ + "description": "A short description of the block that will be displayed on the experiment page and as a meta description in search engines.", + "image": "An image that will be displayed on the experiment page and as a meta image in search engines.", + "consent": "Upload an HTML (.html) or MARKDOWN (.md) file with a text to ask a user its consent
\ for using the block data for this instance of the block.
\ This field will override any consent text loaded from the rules file.
\ - HTML files also allow django template tags so that the text can be translated', - 'slug': 'The slug is used to identify the block in the URL so you can access it on the web as follows: app.amsterdammusiclab.nl/{slug}
\ - It must be unique, lowercase and contain only letters, numbers, and hyphens. Nor can it start with any of the following reserved words: admin, server, block, participant, result, section, session, static.', + HTML files also allow django template tags so that the text can be translated", + "slug": "The slug is used to identify the block in the URL so you can access it on the web as follows: app.amsterdammusiclab.nl/{slug}
\ + It must be unique, lowercase and contain only letters, numbers, and hyphens. Nor can it start with any of the following reserved words: admin, server, block, participant, result, section, session, static.", } class Media: @@ -214,14 +282,8 @@ class Media: class ExportForm(Form): - export_session_fields = MultipleChoiceField( - widget=CheckboxSelectMultiple, - choices=SESSION_CHOICES - ) - export_result_fields = MultipleChoiceField( - widget=CheckboxSelectMultiple, - choices=RESULT_CHOICES - ) + export_session_fields = MultipleChoiceField(widget=CheckboxSelectMultiple, choices=SESSION_CHOICES) + export_result_fields = MultipleChoiceField(widget=CheckboxSelectMultiple, choices=RESULT_CHOICES) export_options = MultipleChoiceField( widget=CheckboxSelectMultiple, choices=EXPORT_OPTIONS, @@ -229,9 +291,7 @@ class ExportForm(Form): class TemplateForm(Form): - select_template = ChoiceField( - widget=Select, - choices=TEMPLATE_CHOICES) + select_template = ChoiceField(widget=Select, choices=TEMPLATE_CHOICES) class QuestionSeriesAdminForm(ModelForm): @@ -241,11 +301,9 @@ class Media: class SocialMediaConfigForm(ModelForm): channels = MultipleChoiceField( - widget=CheckboxSelectMultiple, - choices=SocialMediaConfig.SOCIAL_MEDIA_CHANNELS, - required=False + widget=CheckboxSelectMultiple, choices=SocialMediaConfig.SOCIAL_MEDIA_CHANNELS, required=False ) class Meta: model = SocialMediaConfig - fields = '__all__' + fields = "__all__" diff --git a/backend/experiment/migrations/0049_experimenttranslatedcontent.py b/backend/experiment/migrations/0049_experimenttranslatedcontent.py new file mode 100644 index 000000000..a07e29f5d --- /dev/null +++ b/backend/experiment/migrations/0049_experimenttranslatedcontent.py @@ -0,0 +1,236 @@ +# Generated by Django 4.2.14 on 2024-07-31 13:38 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import experiment.models + + +class Migration(migrations.Migration): + dependencies = [ + ("experiment", "0048_block_phase_delete_groupedblock"), + ] + + operations = [ + migrations.CreateModel( + name="ExperimentTranslatedContent", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("index", models.IntegerField(default=0)), + ( + "language", + models.CharField( + blank=True, + choices=[ + ("", "Unset"), + ("aa", "Afar"), + ("af", "Afrikaans"), + ("ak", "Akan"), + ("sq", "Albanian"), + ("am", "Amharic"), + ("ar", "Arabic"), + ("an", "Aragonese"), + ("hy", "Armenian"), + ("as", "Assamese"), + ("av", "Avaric"), + ("ae", "Avestan"), + ("ay", "Aymara"), + ("az", "Azerbaijani"), + ("bm", "Bambara"), + ("ba", "Bashkir"), + ("eu", "Basque"), + ("be", "Belarusian"), + ("bn", "Bengali"), + ("bh", "Bihari languages"), + ("bi", "Bislama"), + ("nb", "Bokmål, Norwegian; Norwegian Bokmål"), + ("bs", "Bosnian"), + ("br", "Breton"), + ("bg", "Bulgarian"), + ("my", "Burmese"), + ("ca", "Catalan; Valencian"), + ("km", "Central Khmer"), + ("ch", "Chamorro"), + ("ce", "Chechen"), + ("ny", "Chichewa; Chewa; Nyanja"), + ("zh", "Chinese"), + ("cu", "Church Slavic; Old Slavonic; Church Slavonic; Old Bulgarian; Old Church Slavonic"), + ("cv", "Chuvash"), + ("kw", "Cornish"), + ("co", "Corsican"), + ("cr", "Cree"), + ("hr", "Croatian"), + ("cs", "Czech"), + ("da", "Danish"), + ("dv", "Divehi; Dhivehi; Maldivian"), + ("nl", "Dutch; Flemish"), + ("dz", "Dzongkha"), + ("en", "English"), + ("eo", "Esperanto"), + ("et", "Estonian"), + ("ee", "Ewe"), + ("fo", "Faroese"), + ("fj", "Fijian"), + ("fi", "Finnish"), + ("fr", "French"), + ("ff", "Fulah"), + ("gd", "Gaelic; Scottish Gaelic"), + ("gl", "Galician"), + ("lg", "Ganda"), + ("ka", "Georgian"), + ("de", "German"), + ("el", "Greek, Modern (1453-)"), + ("gn", "Guarani"), + ("gu", "Gujarati"), + ("ht", "Haitian; Haitian Creole"), + ("ha", "Hausa"), + ("he", "Hebrew"), + ("hz", "Herero"), + ("hi", "Hindi"), + ("ho", "Hiri Motu"), + ("hu", "Hungarian"), + ("is", "Icelandic"), + ("io", "Ido"), + ("ig", "Igbo"), + ("id", "Indonesian"), + ("ia", "Interlingua (International Auxiliary Language Association)"), + ("ie", "Interlingue; Occidental"), + ("iu", "Inuktitut"), + ("ik", "Inupiaq"), + ("ga", "Irish"), + ("it", "Italian"), + ("ja", "Japanese"), + ("jv", "Javanese"), + ("kl", "Kalaallisut; Greenlandic"), + ("kn", "Kannada"), + ("kr", "Kanuri"), + ("ks", "Kashmiri"), + ("kk", "Kazakh"), + ("ki", "Kikuyu; Gikuyu"), + ("rw", "Kinyarwanda"), + ("ky", "Kirghiz; Kyrgyz"), + ("kv", "Komi"), + ("kg", "Kongo"), + ("ko", "Korean"), + ("kj", "Kuanyama; Kwanyama"), + ("ku", "Kurdish"), + ("lo", "Lao"), + ("la", "Latin"), + ("lv", "Latvian"), + ("li", "Limburgan; Limburger; Limburgish"), + ("ln", "Lingala"), + ("lt", "Lithuanian"), + ("lu", "Luba-Katanga"), + ("lb", "Luxembourgish; Letzeburgesch"), + ("mk", "Macedonian"), + ("mg", "Malagasy"), + ("ms", "Malay"), + ("ml", "Malayalam"), + ("mt", "Maltese"), + ("gv", "Manx"), + ("mi", "Maori"), + ("mr", "Marathi"), + ("mh", "Marshallese"), + ("mn", "Mongolian"), + ("na", "Nauru"), + ("nv", "Navajo; Navaho"), + ("nd", "Ndebele, North; North Ndebele"), + ("nr", "Ndebele, South; South Ndebele"), + ("ng", "Ndonga"), + ("ne", "Nepali"), + ("se", "Northern Sami"), + ("no", "Norwegian"), + ("nn", "Norwegian Nynorsk; Nynorsk, Norwegian"), + ("oc", "Occitan (post 1500)"), + ("oj", "Ojibwa"), + ("or", "Oriya"), + ("om", "Oromo"), + ("os", "Ossetian; Ossetic"), + ("pi", "Pali"), + ("pa", "Panjabi; Punjabi"), + ("fa", "Persian"), + ("pl", "Polish"), + ("pt", "Portuguese"), + ("ps", "Pushto; Pashto"), + ("qu", "Quechua"), + ("ro", "Romanian; Moldavian; Moldovan"), + ("rm", "Romansh"), + ("rn", "Rundi"), + ("ru", "Russian"), + ("sm", "Samoan"), + ("sg", "Sango"), + ("sa", "Sanskrit"), + ("sc", "Sardinian"), + ("sr", "Serbian"), + ("sn", "Shona"), + ("ii", "Sichuan Yi; Nuosu"), + ("sd", "Sindhi"), + ("si", "Sinhala; Sinhalese"), + ("sk", "Slovak"), + ("sl", "Slovenian"), + ("so", "Somali"), + ("st", "Sotho, Southern"), + ("es", "Spanish; Castilian"), + ("su", "Sundanese"), + ("sw", "Swahili"), + ("ss", "Swati"), + ("sv", "Swedish"), + ("tl", "Tagalog"), + ("ty", "Tahitian"), + ("tg", "Tajik"), + ("ta", "Tamil"), + ("tt", "Tatar"), + ("te", "Telugu"), + ("th", "Thai"), + ("bo", "Tibetan"), + ("ti", "Tigrinya"), + ("to", "Tonga (Tonga Islands)"), + ("ts", "Tsonga"), + ("tn", "Tswana"), + ("tr", "Turkish"), + ("tk", "Turkmen"), + ("tw", "Twi"), + ("ug", "Uighur; Uyghur"), + ("uk", "Ukrainian"), + ("ur", "Urdu"), + ("uz", "Uzbek"), + ("ve", "Venda"), + ("vi", "Vietnamese"), + ("vo", "Volapük"), + ("wa", "Walloon"), + ("cy", "Welsh"), + ("fy", "Western Frisian"), + ("wo", "Wolof"), + ("xh", "Xhosa"), + ("yi", "Yiddish"), + ("yo", "Yoruba"), + ("za", "Zhuang; Chuang"), + ("zu", "Zulu"), + ], + default="", + max_length=2, + ), + ), + ("name", models.CharField(default="", max_length=64)), + ("description", models.TextField(blank=True, default="")), + ( + "consent", + models.FileField( + blank=True, + default="", + upload_to=experiment.models.consent_upload_path, + validators=[django.core.validators.FileExtensionValidator(allowed_extensions=["md", "html"])], + ), + ), + ("about_content", models.TextField(blank=True, default="")), + ( + "experiment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="translated_content", + to="experiment.experiment", + ), + ), + ], + ), + ] diff --git a/backend/experiment/migrations/0050_migrate_experiment_content_to_translatedmakemigrations.py b/backend/experiment/migrations/0050_migrate_experiment_content_to_translatedmakemigrations.py new file mode 100644 index 000000000..9177a6cd0 --- /dev/null +++ b/backend/experiment/migrations/0050_migrate_experiment_content_to_translatedmakemigrations.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.14 on 2024-07-31 13:41 + +from django.db import migrations + + +def migrate_experiment_content(apps, schema_editor): + Experiment = apps.get_model("experiment", "Experiment") + ExperimentTranslatedContent = apps.get_model("experiment", "ExperimentTranslatedContent") + + for experiment in Experiment.objects.all(): + ExperimentTranslatedContent.objects.create( + experiment=experiment, + index=0, + language="en", # Set English as default language + name=experiment.name, + description=experiment.description, + consent=experiment.consent, + about_content=experiment.about_content, + ) + + +def reverse_migrate_experiment_content(apps, schema_editor): + Experiment = apps.get_model("experiment", "Experiment") + ExperimentTranslatedContent = apps.get_model("experiment", "ExperimentTranslatedContent") + + for experiment in Experiment.objects.all(): + # find the English translation or the first one (lowest index) + experiment_translations = ExperimentTranslatedContent.objects.filter(experiment=experiment) + primary_translation = experiment_translations.order_by("index").first() + + if not primary_translation: + continue + + experiment.name = primary_translation.name + experiment.description = primary_translation.description + experiment.consent = primary_translation.consent + experiment.about_content = primary_translation.about_content + experiment.save() + + ExperimentTranslatedContent.objects.all().delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("experiment", "0049_experimenttranslatedcontent"), + ] + + operations = [ + migrations.RunPython(migrate_experiment_content, reverse_migrate_experiment_content), + ] diff --git a/backend/experiment/migrations/0051_remove_experiment_about_content_and_more.py b/backend/experiment/migrations/0051_remove_experiment_about_content_and_more.py new file mode 100644 index 000000000..4d49b75a2 --- /dev/null +++ b/backend/experiment/migrations/0051_remove_experiment_about_content_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.14 on 2024-07-31 13:56 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiment', '0050_migrate_experiment_content_to_translatedmakemigrations'), + ] + + operations = [ + migrations.RemoveField( + model_name='experiment', + name='about_content', + ), + migrations.RemoveField( + model_name='experiment', + name='consent', + ), + migrations.RemoveField( + model_name='experiment', + name='description', + ), + migrations.RemoveField( + model_name='experiment', + name='name', + ), + ] diff --git a/backend/experiment/models.py b/backend/experiment/models.py index 157f0674a..3bb4f90ca 100644 --- a/backend/experiment/models.py +++ b/backend/experiment/models.py @@ -14,26 +14,21 @@ from .validators import markdown_html_validator, block_slug_validator language_choices = [(key, ISO_LANGUAGES[key]) for key in ISO_LANGUAGES.keys()] -language_choices[0] = ('', 'Unset') +language_choices[0] = ("", "Unset") def consent_upload_path(instance, filename): """Generate path to save consent file based on block.slug""" folder_name = instance.slug - return f'consent/{folder_name}/{filename}' + return f"consent/{folder_name}/{filename}" class Experiment(models.Model): - """ A model to allow nesting multiple phases with blocks into a 'parent' experiment """ - name = models.CharField(max_length=64, default='') - description = models.TextField(blank=True, default='') - slug = models.SlugField(max_length=64, default='') - consent = models.FileField(upload_to=consent_upload_path, - blank=True, - default='', - validators=[markdown_html_validator()]) - theme_config = models.ForeignKey( - "theme.ThemeConfig", blank=True, null=True, on_delete=models.SET_NULL) + """A model to allow nesting multiple phases with blocks into a 'parent' experiment""" + + slug = models.SlugField(max_length=64, default="") + translated_content = models.QuerySet["ExperimentTranslatedContent"] + theme_config = models.ForeignKey("theme.ThemeConfig", blank=True, null=True, on_delete=models.SET_NULL) # first experiments in a test series, in fixed order first_experiments = models.JSONField(blank=True, null=True, default=dict) random_experiments = models.JSONField(blank=True, null=True, default=dict) @@ -41,27 +36,26 @@ class Experiment(models.Model): last_experiments = models.JSONField(blank=True, null=True, default=dict) # present random_experiments as dashboard dashboard = models.BooleanField(default=False) - about_content = models.TextField(blank=True, default='') active = models.BooleanField(default=True) - social_media_config: Optional['SocialMediaConfig'] - phases: models.QuerySet['Phase'] + social_media_config: Optional["SocialMediaConfig"] + phases: models.QuerySet["Phase"] def __str__(self): - return self.name or self.slug + translated_content = self.get_fallback_content() + return translated_content.name if translated_content else self.slug class Meta: verbose_name_plural = "Experiments" def associated_blocks(self): phases = self.phases.all() - return [ - block for phase in phases for block in list(phase.blocks.all())] + return [block for phase in phases for block in list(phase.blocks.all())] def export_sessions(self): """export sessions for this experiment""" all_sessions = Session.objects.none() for block in self.associated_blocks(): - all_sessions |= Session.objects.filter(block=block).order_by('-started_at') + all_sessions |= Session.objects.filter(block=block).order_by("-started_at") return all_sessions def current_participants(self): @@ -71,70 +65,75 @@ def current_participants(self): participants[session.participant.id] = session.participant return participants.values() + def get_fallback_content(self): + """Get fallback content for the experiment""" + return self.translated_content.order_by("index").first() + + def get_translated_content(self, language: str, fallback: bool = True): + """Get content for a specific language""" + content = self.translated_content.filter(language=language).first() + + if not content and fallback: + fallback_content = self.get_fallback_content() + + if not fallback_content: + raise ValueError("No fallback content found for experiment") + + return fallback_content + + if not content: + raise ValueError(f"No content found for language {language}") + + return content + class Phase(models.Model): - name = models.CharField(max_length=64, blank=True, default='') - series = models.ForeignKey(Experiment, - on_delete=models.CASCADE, related_name='phases') - index = models.IntegerField(default=0, help_text='Index of the phase in the series. Lower numbers come first.') + name = models.CharField(max_length=64, blank=True, default="") + series = models.ForeignKey(Experiment, on_delete=models.CASCADE, related_name="phases") + index = models.IntegerField(default=0, help_text="Index of the phase in the series. Lower numbers come first.") dashboard = models.BooleanField(default=False) - randomize = models.BooleanField( - default=False, help_text='Randomize the order of the experiments in this phase.') + randomize = models.BooleanField(default=False, help_text="Randomize the order of the experiments in this phase.") def __str__(self): - compound_name = self.name or self.series.name or self.series.slug or 'Unnamed phase' + default_content = self.series.get_fallback_content() + experiment_name = default_content.name if default_content else None + compound_name = self.name or experiment_name or self.series.slug or "Unnamed phase" if not self.name: - return f'{compound_name} ({self.index})' + return f"{compound_name} ({self.index})" - return f'{compound_name}' + return f"{compound_name}" class Meta: - ordering = ['index'] + ordering = ["index"] class Block(models.Model): """Root entity for configuring experiment blocks""" - phase = models.ForeignKey( - Phase, - on_delete=models.CASCADE, - related_name='blocks', - blank=True, - null=True - ) - index = models.IntegerField(default=0, help_text='Index of the block in the phase. Lower numbers come first.') - playlists = models.ManyToManyField('section.Playlist', blank=True) + phase = models.ForeignKey(Phase, on_delete=models.CASCADE, related_name="blocks", blank=True, null=True) + index = models.IntegerField(default=0, help_text="Index of the block in the phase. Lower numbers come first.") + playlists = models.ManyToManyField("section.Playlist", blank=True) name = models.CharField(db_index=True, max_length=64) - description = models.TextField(blank=True, default='') - image = models.ForeignKey( - Image, - on_delete=models.SET_NULL, - blank=True, - null=True - ) + description = models.TextField(blank=True, default="") + image = models.ForeignKey(Image, on_delete=models.SET_NULL, blank=True, null=True) slug = models.SlugField(db_index=True, max_length=64, unique=True, validators=[block_slug_validator]) - url = models.CharField(verbose_name='URL with more information about the block', max_length=100, blank=True, default='') - hashtag = models.CharField(verbose_name='hashtag for social media', max_length=20, blank=True, default='') + url = models.CharField( + verbose_name="URL with more information about the block", max_length=100, blank=True, default="" + ) + hashtag = models.CharField(verbose_name="hashtag for social media", max_length=20, blank=True, default="") active = models.BooleanField(default=True) rounds = models.PositiveIntegerField(default=10) bonus_points = models.PositiveIntegerField(default=0) rules = models.CharField(default="", max_length=64) - language = models.CharField( - default="", blank=True, choices=language_choices, max_length=2) - theme_config = models.ForeignKey( - ThemeConfig, - on_delete=models.SET_NULL, - blank=True, - null=True + language = models.CharField(default="", blank=True, choices=language_choices, max_length=2) + theme_config = models.ForeignKey(ThemeConfig, on_delete=models.SET_NULL, blank=True, null=True) + consent = models.FileField( + upload_to=consent_upload_path, blank=True, default="", validators=[markdown_html_validator()] ) - consent = models.FileField(upload_to=consent_upload_path, - blank=True, - default='', - validators=[markdown_html_validator()]) class Meta: - ordering = ['name'] + ordering = ["name"] def __str__(self): return self.name @@ -161,14 +160,12 @@ def current_participants(self): def export_admin(self): """Export data for admin""" return { - 'exportedAt': timezone.now().isoformat(), - 'block': { - 'id': self.id, - 'name': self.name, - 'sessions': [session.export_admin() for session in self.session_set.all()], - 'participants': [ - participant.export_admin() for participant in self.current_participants() - ] + "exportedAt": timezone.now().isoformat(), + "block": { + "id": self.id, + "name": self.name, + "sessions": [session.export_admin() for session in self.session_set.all()], + "participants": [participant.export_admin() for participant in self.current_participants()], }, } @@ -176,44 +173,46 @@ def export_sessions(self): # export session objects return self.session_set.all() - def export_table(self, session_keys: List[str], result_keys: List[str], export_options: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[str]]: + def export_table( + self, session_keys: List[str], result_keys: List[str], export_options: Dict[str, Any] + ) -> Tuple[List[Dict[str, Any]], List[str]]: """Export filtered tabular data for admin - session_keys : session fieldnames to be included - result_keys : result fieldnames to be included - export_options : export options (see admin/forms.py) + session_keys : session fieldnames to be included + result_keys : result fieldnames to be included + export_options : export options (see admin/forms.py) """ rows = [] # a list of dictionaries fieldnames = set() # keep track of all potential fieldnames - result_prefix = '' + result_prefix = "" for session in self.session_set.all(): profile = session.participant.export_admin() session_finished = session.finished_at.isoformat() if session.finished_at else None # Get data for all potential session fields full_row = { - 'block_id': self.id, - 'block_name': self.name, - 'participant_id': profile['id'], - 'participant_country': profile['country_code'], - 'participant_access_info': profile['access_info'], - 'session_start': session.started_at.isoformat(), - 'session_end': session_finished, - 'final_score': session.final_score + "block_id": self.id, + "block_name": self.name, + "participant_id": profile["id"], + "participant_country": profile["country_code"], + "participant_access_info": profile["access_info"], + "session_start": session.started_at.isoformat(), + "session_end": session_finished, + "final_score": session.final_score, } row = {} # Add the selected sessions fields for session_key in session_keys: row[session_key] = full_row[session_key] # Add profile data if selected - if 'export_profile' in export_options: - row.update(profile['profile']) + if "export_profile" in export_options: + row.update(profile["profile"]) # Add session data - if session.json_data != '': - if 'session_data' in export_options: + if session.json_data != "": + if "session_data" in export_options: # Convert json session data to csv columns if selected - if 'convert_session_json' in export_options: + if "convert_session_json" in export_options: row.update(session.load_json_data()) else: - row['session_data'] = session.load_json_data() + row["session_data"] = session.load_json_data() fieldnames.update(row.keys()) if session.result_set.count() == 0: # some sessions may have only profile questions @@ -221,57 +220,55 @@ def export_table(self, session_keys: List[str], result_keys: List[str], export_o else: result_counter = 1 # Create new row for each result - if 'wide_format' in export_options: + if "wide_format" in export_options: this_row = copy.deepcopy(row) for result in session.result_set.all(): # Add all results to one row - if 'wide_format' not in export_options: + if "wide_format" not in export_options: this_row = copy.deepcopy(row) # Get data for al potential result fields full_result_data = { - 'section_name': result.section.song.name if result.section else None, - 'result_created_at': result.created_at.isoformat(), - 'result_score': result.score, - 'result_comment': result.comment, - 'expected_response': result.expected_response, - 'given_response': result.given_response, - 'question_key': result.question_key, + "section_name": result.section.song.name if result.section else None, + "result_created_at": result.created_at.isoformat(), + "result_score": result.score, + "result_comment": result.comment, + "expected_response": result.expected_response, + "given_response": result.given_response, + "question_key": result.question_key, } result_data = {} # Add counter for single row / wide format - if 'wide_format' in export_options: - result_prefix = str(result_counter).zfill(3) + '-' + if "wide_format" in export_options: + result_prefix = str(result_counter).zfill(3) + "-" # add the selected result fields for result_key in result_keys: - result_data[(result_prefix + result_key) - ] = full_result_data[result_key] + result_data[(result_prefix + result_key)] = full_result_data[result_key] else: # add the selected result fields for result_key in result_keys: result_data[result_key] = full_result_data[result_key] # Add result data - if result.json_data != '': + if result.json_data != "": # convert result json data to csv columns if selected - if 'convert_result_json' in export_options: - if 'decision_time' in export_options: - result_data[result_prefix + 'decision_time'] = result.load_json_data().get( - 'decision_time', '') - if 'result_config' in export_options: - result_data[result_prefix + 'result_config'] = result.load_json_data().get( - 'config', '') + if "convert_result_json" in export_options: + if "decision_time" in export_options: + result_data[result_prefix + "decision_time"] = result.load_json_data().get( + "decision_time", "" + ) + if "result_config" in export_options: + result_data[result_prefix + "result_config"] = result.load_json_data().get("config", "") else: - if 'result_config' in export_options: - result_data[result_prefix + - 'result_data'] = result.load_json_data() + if "result_config" in export_options: + result_data[result_prefix + "result_data"] = result.load_json_data() this_row.update(result_data) fieldnames.update(result_data.keys()) result_counter += 1 # Append row for long format - if 'wide_format' not in export_options: + if "wide_format" not in export_options: rows.append(this_row) # Append row for wide format - if 'wide_format' in export_options: + if "wide_format" in export_options: rows.append(this_row) return rows, list(fieldnames) @@ -287,30 +284,39 @@ def get_rules(self): def max_score(self): """Get max score from all sessions with a positive score""" - score = self.session_set.filter( - final_score__gte=0).aggregate(models.Max('final_score')) - if 'final_score__max' in score: - return score['final_score__max'] + score = self.session_set.filter(final_score__gte=0).aggregate(models.Max("final_score")) + if "final_score__max" in score: + return score["final_score__max"] return 0 def add_default_question_series(self): - """ Add default question_series to block""" + """Add default question_series to block""" from experiment.rules import BLOCK_RULES from question.models import Question, QuestionSeries, QuestionInSeries + question_series = getattr(BLOCK_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'], - block=self, - index=i+1, - randomize=question_series['randomize']) - for i, question in enumerate(question_series['keys']): + name=question_series["name"], block=self, index=i + 1, randomize=question_series["randomize"] + ) + for i, question in enumerate(question_series["keys"]): QuestionInSeries.objects.create( - question_series=qs, - question=Question.objects.get(pk=question), - index=i+1) + question_series=qs, question=Question.objects.get(pk=question), index=i + 1 + ) + + +class ExperimentTranslatedContent(models.Model): + experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE, related_name="translated_content") + index = models.IntegerField(default=0) + language = models.CharField(default="", blank=True, choices=language_choices, max_length=2) + name = models.CharField(max_length=64, default="") + description = models.TextField(blank=True, default="") + consent = models.FileField( + upload_to=consent_upload_path, blank=True, default="", validators=[markdown_html_validator()] + ) + about_content = models.TextField(blank=True, default="") class Feedback(models.Model): @@ -319,58 +325,49 @@ class Feedback(models.Model): class SocialMediaConfig(models.Model): - experiment = models.OneToOneField( - Experiment, - on_delete=models.CASCADE, - related_name='social_media_config' - ) + experiment = models.OneToOneField(Experiment, on_delete=models.CASCADE, related_name="social_media_config") tags = ArrayField( - models.CharField(max_length=100), - blank=True, - default=list, - help_text=_("List of tags for social media sharing") + models.CharField(max_length=100), blank=True, default=list, help_text=_("List of tags for social media sharing") ) url = models.URLField( - blank=True, - help_text=_("URL to be shared on social media. If empty, the experiment URL will be used.") + blank=True, help_text=_("URL to be shared on social media. If empty, the experiment URL will be used.") ) content = models.TextField( blank=True, help_text=_("Content for social media sharing. Use {points} and {block_name} as placeholders."), - default="I scored {points} points in {block_name}!" + default="I scored {points} points in {block_name}!", ) SOCIAL_MEDIA_CHANNELS = [ - ('facebook', _('Facebook')), - ('whatsapp', _('WhatsApp')), - ('twitter', _('Twitter')), - ('weibo', _('Weibo')), - ('share', _('Share')), - ('clipboard', _('Clipboard')), + ("facebook", _("Facebook")), + ("whatsapp", _("WhatsApp")), + ("twitter", _("Twitter")), + ("weibo", _("Weibo")), + ("share", _("Share")), + ("clipboard", _("Clipboard")), ] channels = ArrayField( models.CharField(max_length=20, choices=SOCIAL_MEDIA_CHANNELS), blank=True, default=list, - help_text=_("Selected social media channels for sharing") + help_text=_("Selected social media channels for sharing"), ) - def get_content( - self, score: int | None = None, block_name: str | None = None - ) -> str: + def get_content(self, score: int | None = None, block_name: str | None = None) -> str: if self.content: return self.content if not score or not block_name: raise ValueError("score and block_name are required") - return _("I scored {points} points in {block_name}").format( - score=score, - block_name=block_name - ) + return _("I scored {points} points in {block_name}").format(score=score, block_name=block_name) def __str__(self): - return f"Social Media for {self.experiment.name}" + fallback_content = self.experiment.get_fallback_content() + if fallback_content: + return f"Social Media for {fallback_content.name}" + + return f"Social Media for {self.experiment.slug}" diff --git a/backend/experiment/serializers.py b/backend/experiment/serializers.py index 4757e7992..e0df63716 100644 --- a/backend/experiment/serializers.py +++ b/backend/experiment/serializers.py @@ -11,71 +11,57 @@ def serialize_actions(actions): - ''' Serialize an array of actions ''' + """Serialize an array of actions""" if isinstance(actions, list): return [a.action() for a in actions] return actions.action() -def serialize_experiment( - experiment: Experiment -) -> dict: +def serialize_experiment(experiment: Experiment, language="en") -> dict: + translated_content = experiment.get_translated_content(language) + + if not translated_content: + raise ValueError("No translated content found for experiment") serialized = { - 'slug': experiment.slug, - 'name': experiment.name, - 'description': formatter( - experiment.description, - filter_name='markdown' - ), + "slug": experiment.slug, + "name": translated_content.name, + "description": formatter(translated_content.description, filter_name="markdown"), } - if experiment.consent: - serialized['consent'] = Consent(experiment.consent).action() + if translated_content.consent: + serialized["consent"] = Consent(translated_content.consent).action() + elif experiment.get_fallback_content() and experiment.get_fallback_content().consent: + serialized["consent"] = Consent(experiment.get_fallback_content().consent).action() if experiment.theme_config: - serialized['theme'] = serialize_theme( - experiment.theme_config - ) - - if experiment.about_content: - serialized['aboutContent'] = formatter( - experiment.about_content, - filter_name='markdown' - ) - - if (hasattr(experiment, 'social_media_config') - and experiment.social_media_config): - serialized['socialMedia'] = serialize_social_media_config( - experiment.social_media_config - ) + serialized["theme"] = serialize_theme(experiment.theme_config) + + if translated_content.about_content: + serialized["aboutContent"] = formatter(translated_content.about_content, filter_name="markdown") + + if hasattr(experiment, "social_media_config") and experiment.social_media_config: + serialized["socialMedia"] = serialize_social_media_config(experiment.social_media_config) return serialized -def serialize_social_media_config( - social_media_config: SocialMediaConfig - ) -> dict: +def serialize_social_media_config(social_media_config: SocialMediaConfig) -> dict: return { - 'tags': social_media_config.tags, - 'url': social_media_config.url, - 'content': social_media_config.content, - 'channels': social_media_config.channels, + "tags": social_media_config.tags, + "url": social_media_config.url, + "content": social_media_config.content, + "channels": social_media_config.channels, } -def serialize_phase( - phase: Phase, - participant: Participant - ) -> dict: - blocks = list(Block.objects.filter( - phase=phase.id).order_by('index')) +def serialize_phase(phase: Phase, participant: Participant) -> dict: + blocks = list(Block.objects.filter(phase=phase.id).order_by("index")) if phase.randomize: shuffle(blocks) - next_block = get_upcoming_block( - blocks, participant, phase.dashboard) + next_block = get_upcoming_block(blocks, participant, phase.dashboard) total_score = get_total_score(blocks, participant) @@ -83,26 +69,25 @@ def serialize_phase( return None return { - 'dashboard': [serialize_block(block, participant) for block in blocks] if phase.dashboard else [], - 'nextBlock': next_block, - 'totalScore': total_score + "dashboard": [serialize_block(block, participant) for block in blocks] if phase.dashboard else [], + "nextBlock": next_block, + "totalScore": total_score, } def serialize_block(block_object: Block, participant: Participant): return { - 'slug': block_object.slug, - 'name': block_object.name, - 'description': block_object.description, - 'image': serialize_image(block_object.image) if block_object.image else None, + "slug": block_object.slug, + "name": block_object.name, + "description": block_object.description, + "image": serialize_image(block_object.image) if block_object.image else None, } def get_upcoming_block(block_list, participant, repeat_allowed=True): - ''' return next block with minimum finished sessions for this participant - if repeated blocks are not allowed (dashboard=False) and there are only finished sessions, return None ''' - finished_session_counts = [get_finished_session_count( - block, participant) for block in block_list] + """return next block with minimum finished sessions for this participant + if repeated blocks are not allowed (dashboard=False) and there are only finished sessions, return None""" + finished_session_counts = [get_finished_session_count(block, participant) for block in block_list] minimum_session_count = min(finished_session_counts) if not repeat_allowed and minimum_session_count != 0: return None @@ -110,27 +95,22 @@ def get_upcoming_block(block_list, participant, repeat_allowed=True): def get_started_session_count(block, participant): - ''' Get the number of started sessions for this block and participant ''' - count = Session.objects.filter( - block=block, participant=participant).count() + """Get the number of started sessions for this block and participant""" + count = Session.objects.filter(block=block, participant=participant).count() return count def get_finished_session_count(block, participant): - ''' Get the number of finished sessions for this block and participant ''' - count = Session.objects.filter( - block=block, participant=participant, finished_at__isnull=False).count() + """Get the number of finished sessions for this block and participant""" + count = Session.objects.filter(block=block, participant=participant, finished_at__isnull=False).count() return count def get_total_score(blocks, participant): - '''Calculate total score of all blocks on the dashboard''' + """Calculate total score of all blocks on the dashboard""" total_score = 0 for block in blocks: - sessions = Session.objects.filter( - block=block, - participant=participant - ) + sessions = Session.objects.filter(block=block, participant=participant) for session in sessions: total_score += session.final_score return total_score diff --git a/backend/experiment/tests/test_admin_experiment.py b/backend/experiment/tests/test_admin_experiment.py index fdaf9fa42..7b66358e6 100644 --- a/backend/experiment/tests/test_admin_experiment.py +++ b/backend/experiment/tests/test_admin_experiment.py @@ -7,7 +7,7 @@ from django.urls import reverse from django.utils.html import format_html from experiment.admin import BlockAdmin, ExperimentAdmin, PhaseAdmin -from experiment.models import Block, Experiment, Phase +from experiment.models import Block, Experiment, Phase, ExperimentTranslatedContent from participant.models import Participant from result.models import Result from session.models import Session @@ -32,9 +32,7 @@ class TestAdminBlock(TestCase): def setUpTestData(cls): Block.objects.create(name="test", slug="TEST") Participant.objects.create() - Session.objects.create( - block=Block.objects.first(), participant=Participant.objects.first() - ) + Session.objects.create(block=Block.objects.first(), participant=Participant.objects.first()) Result.objects.create(session=Session.objects.first()) def setUp(self): @@ -127,9 +125,7 @@ def test_admin_export(self): self.assertEqual(len(test_zip.namelist()), 6) # test content of the json files in the zip - these_participants = json.loads( - test_zip.read("participants.json").decode("utf-8") - ) + these_participants = json.loads(test_zip.read("participants.json").decode("utf-8")) self.assertEqual(len(these_participants), 1) self.assertEqual(Participant.objects.first().unique_hash, "42") @@ -160,9 +156,7 @@ def test_export_table_includes_question_key(self): export_options = ["convert_result_json"] # Adjust based on your needs # Call the method under test - rows, fieldnames = self.block.export_table( - session_keys, result_keys, export_options - ) + rows, fieldnames = self.block.export_table(session_keys, result_keys, export_options) # Assert that 'question_key' is in the fieldnames and check its value in rows self.assertIn("question_key", fieldnames) @@ -175,16 +169,20 @@ def test_export_table_includes_question_key(self): class TestExperimentAdmin(TestCase): @classmethod def setUpTestData(self): - self.experiment_series = Experiment.objects.create( + self.experiment = Experiment.objects.create( + slug="TEST", + ) + ExperimentTranslatedContent.objects.create( + experiment=self.experiment, + language="en", name="test", description="test description very long like the tea of oolong and the song of the bird in the morning", - slug="TEST", ) def setUp(self): self.admin = ExperimentAdmin(model=Experiment, admin_site=AdminSite) - def test_experiment_series_admin_list_display(self): + def test_experiment_admin_list_display(self): self.assertEqual( ExperimentAdmin.list_display, ( @@ -197,19 +195,28 @@ def test_experiment_series_admin_list_display(self): ), ) - def test_experiment_series_admin_description_excerpt(self): + def test_experiment_admin_description_excerpt(self): self.assertEqual( - self.admin.description_excerpt(self.experiment_series), + self.admin.description_excerpt(self.experiment), "test description very long like the tea of oolong ...", ) + + experiment = Experiment.objects.create(slug="TEST2") + ExperimentTranslatedContent.objects.create( + experiment=experiment, + language="en", + name="test", + description="Do you like oolong tea or do you prefer the song of the bird in the morning?", + ) + self.assertEqual( - self.admin.description_excerpt(Experiment.objects.create(description="")), - "", + self.admin.description_excerpt(experiment), + "Do you like oolong tea or do you prefer the song o...", ) def test_experiment_admin_research_dashboard(self): request = RequestFactory().request() - response = self.admin.dashboard(request, self.experiment_series) + response = self.admin.dashboard(request, self.experiment) self.assertEqual(response.status_code, 200) @@ -218,40 +225,42 @@ def setUp(self): self.admin = PhaseAdmin(model=Phase, admin_site=AdminSite) def test_related_experiment_with_experiment(self): - series = Experiment.objects.create(name="Test Experiment") - phase = Phase.objects.create( - name="Test Phase", index=1, randomize=False, series=series, dashboard=True - ) + experiment = Experiment.objects.create(slug="test-experiment") + ExperimentTranslatedContent.objects.create(experiment=experiment, language="en", name="Test Experiment") + phase = Phase.objects.create(name="Test Phase", index=1, randomize=False, series=experiment, dashboard=True) related_experiment = self.admin.related_experiment(phase) - expected_url = reverse("admin:experiment_experiment_change", args=[series.pk]) + expected_url = reverse("admin:experiment_experiment_change", args=[experiment.pk]) expected_related_experiment = format_html( - '{}', expected_url, series.name + '{}', expected_url, experiment.get_fallback_content().name ) self.assertEqual(related_experiment, expected_related_experiment) def test_experiment_with_no_blocks(self): - series = Experiment.objects.create(name="Test Series") - phase = Phase.objects.create( - name="Test Group", index=1, randomize=False, dashboard=True, series=series + experiment = Experiment.objects.create(slug="no-blocks") + ExperimentTranslatedContent.objects.create( + experiment=experiment, + language="en", + name="No Blocks", ) + phase = Phase.objects.create(name="Test Group", index=1, randomize=False, dashboard=True, series=experiment) blocks = self.admin.blocks(phase) self.assertEqual(blocks, "No blocks") def test_experiment_with_blocks(self): - series = Experiment.objects.create(name="Test Series") - phase = Phase.objects.create( - name="Test Group", index=1, randomize=False, dashboard=True, series=series + experiment = Experiment.objects.create(slug="with-blocks") + ExperimentTranslatedContent.objects.create( + experiment=experiment, + language="en", + name="With Blocks", ) + phase = Phase.objects.create(name="Test Phase", index=1, randomize=False, dashboard=True, series=experiment) block1 = Block.objects.create(name="Block 1", slug="block-1", phase=phase) block2 = Block.objects.create(name="Block 2", slug="block-2", phase=phase) blocks = self.admin.blocks(phase) expected_blocks = format_html( ", ".join( - [ - f'{block.name}' - for block in [block1, block2] - ] + [f'{block.name}' for block in [block1, block2]] ) ) self.assertEqual(blocks, expected_blocks) diff --git a/backend/experiment/tests/test_model.py b/backend/experiment/tests/test_model.py index 98836a67e..f0a940895 100644 --- a/backend/experiment/tests/test_model.py +++ b/backend/experiment/tests/test_model.py @@ -2,7 +2,7 @@ from image.models import Image from theme.models import ThemeConfig -from experiment.models import Block, Experiment +from experiment.models import Block, Experiment, ExperimentTranslatedContent from participant.models import Participant from session.models import Session from result.models import Result @@ -11,110 +11,96 @@ class BlockModelTest(TestCase): @classmethod def setUpTestData(cls): - logo_image = Image.objects.create( - file='logoimage.svg' - ) - background_image = Image.objects.create( - file='backgroundimage.tif' - ) + logo_image = Image.objects.create(file="logoimage.svg") + background_image = Image.objects.create(file="backgroundimage.tif") ThemeConfig.objects.create( - name='Default', - description='Default theme configuration', - heading_font_url='https://example.com/heading_font', - body_font_url='https://example.com/body_font', + name="Default", + description="Default theme configuration", + heading_font_url="https://example.com/heading_font", + body_font_url="https://example.com/body_font", logo_image=logo_image, background_image=background_image, ) Block.objects.create( - name='Test Block', - description='Test block description', - slug='test-block', - url='https://example.com/block', - hashtag='test', + name="Test Block", + description="Test block description", + slug="test-block", + url="https://example.com/block", + hashtag="test", rounds=5, bonus_points=10, - rules='RHYTHM_BATTERY_FINAL', - language='en', - theme_config=ThemeConfig.objects.get(name='Default'), + rules="RHYTHM_BATTERY_FINAL", + language="en", + theme_config=ThemeConfig.objects.get(name="Default"), ) def test_block_str(self): - block = Block.objects.get(name='Test Block') - self.assertEqual(str(block), 'Test Block') + block = Block.objects.get(name="Test Block") + self.assertEqual(str(block), "Test Block") def test_block_session_count(self): - block = Block.objects.get(name='Test Block') + block = Block.objects.get(name="Test Block") self.assertEqual(block.session_count(), 0) def test_block_playlist_count(self): - block = Block.objects.get(name='Test Block') + block = Block.objects.get(name="Test Block") self.assertEqual(block.playlist_count(), 0) def test_block_current_participants(self): - block = Block.objects.get(name='Test Block') + block = Block.objects.get(name="Test Block") participants = block.current_participants() self.assertEqual(len(participants), 0) def test_block_export_admin(self): - block = Block.objects.get(name='Test Block') + block = Block.objects.get(name="Test Block") exported_data = block.export_admin() - self.assertEqual(exported_data['block']['name'], 'Test Block') + self.assertEqual(exported_data["block"]["name"], "Test Block") def test_block_export_sessions(self): - block = Block.objects.get(name='Test Block') + block = Block.objects.get(name="Test Block") sessions = block.export_sessions() self.assertEqual(len(sessions), 0) def test_block_export_table(self): - block = Block.objects.get(name='Test Block') + block = Block.objects.get(name="Test Block") amount_of_sessions = 3 for i in range(amount_of_sessions): - session = Session.objects.create( - block=block, - participant=Participant.objects.create() - ) + session = Session.objects.create(block=block, participant=Participant.objects.create()) Result.objects.create( session=session, expected_response=1, given_response=1, - question_key='test_question_1', + question_key="test_question_1", ) - session_keys = ['block_id', 'block_name'] - result_keys = ['section_name', 'result_created_at'] - export_options = {'wide_format': True} - rows, fieldnames = block.export_table( - session_keys, - result_keys, - export_options - ) + session_keys = ["block_id", "block_name"] + result_keys = ["section_name", "result_created_at"] + export_options = {"wide_format": True} + rows, fieldnames = block.export_table(session_keys, result_keys, export_options) self.assertEqual(len(rows), amount_of_sessions) self.assertEqual(len(fieldnames), len(session_keys) + len(result_keys)) def test_block_get_rules(self): - block = Block.objects.get(name='Test Block') + block = Block.objects.get(name="Test Block") rules = block.get_rules() self.assertIsNotNone(rules) def test_block_max_score(self): - block = Block.objects.get(name='Test Block') + block = Block.objects.get(name="Test Block") amount_of_results = 3 question_score = 1 - session = Session.objects.create( - block=block, - participant=Participant.objects.create() - ) + session = Session.objects.create(block=block, participant=Participant.objects.create()) for j in range(amount_of_results): Result.objects.create( session=session, expected_response=1, given_response=1, score=question_score, - question_key=f'test_question_{j + 1}', + question_key=f"test_question_{j + 1}", ) session.finish() session.save() @@ -127,19 +113,59 @@ def test_block_max_score(self): class ExperimentModelTest(TestCase): - @classmethod - def setUpTestData(self): - self.experiment_series = Experiment.objects.create( - slug='test-series', - name='Test Series', - description='Test series description with a very long description. From here to the moon and from the moon to the stars.', + def setUpTestData(cls): + cls.experiment = Experiment.objects.create( + slug="test-series", ) - - def test_experiment_series_str(self): - experiment_series_no_name = Experiment.objects.create( - slug='test-series-no-name', + ExperimentTranslatedContent.objects.create( + experiment=cls.experiment, + index=0, + language="en", + name="Test Experiment", + description="Test experiment description in English.", + ) + ExperimentTranslatedContent.objects.create( + experiment=cls.experiment, + index=1, + language="es", + name="Experimento de Prueba", + description="Descripción de la experimento de prueba en español.", ) - self.assertEqual(str(self.experiment_series), 'Test Series') - self.assertEqual(str(experiment_series_no_name), 'test-series-no-name') + def test_experiment_str(self): + self.assertEqual(str(self.experiment), "Test Experiment") + + experiment_no_content = Experiment.objects.create( + slug="test-series-no-content", + ) + self.assertEqual(str(experiment_no_content), "test-series-no-content") + + def test_get_fallback_content(self): + fallback_content = self.experiment.get_fallback_content() + self.assertIsNotNone(fallback_content) + self.assertEqual(fallback_content.language, "en") + self.assertEqual(fallback_content.name, "Test Experiment") + + def test_get_translated_content_existing_language(self): + spanish_content = self.experiment.get_translated_content("es") + self.assertIsNotNone(spanish_content) + self.assertEqual(spanish_content.language, "es") + self.assertEqual(spanish_content.name, "Experimento de Prueba") + + def test_get_translated_content_nonexistent_language_with_fallback(self): + french_content = self.experiment.get_translated_content("fr", fallback=True) + self.assertIsNotNone(french_content) + self.assertEqual(french_content.language, "en") # Falls back to English + self.assertEqual(french_content.name, "Test Experiment") + + def test_get_translated_content_nonexistent_language_without_fallback(self): + with self.assertRaises(ValueError): + self.experiment.get_translated_content("fr", fallback=False) + + def test_get_translated_content_no_content_no_fallback(self): + experiment_no_content = Experiment.objects.create( + slug="test-series-no-content", + ) + with self.assertRaises(ValueError): + experiment_no_content.get_translated_content("en", fallback=True) diff --git a/backend/experiment/tests/test_model_functions.py b/backend/experiment/tests/test_model_functions.py index 2ee6061d2..ab8ef2d61 100644 --- a/backend/experiment/tests/test_model_functions.py +++ b/backend/experiment/tests/test_model_functions.py @@ -1,29 +1,32 @@ from django.test import TestCase from session.models import Session from participant.models import Participant -from experiment.models import Block, Experiment, Phase +from experiment.models import Block, Experiment, Phase, ExperimentTranslatedContent class TestModelBlock(TestCase): @classmethod def setUpTestData(cls): - cls.block = Block.objects.create(rules='THATS_MY_SONG', slug='hooked', rounds=42) + cls.block = Block.objects.create(rules="THATS_MY_SONG", slug="hooked", rounds=42) def test_separate_rules_instance(self): rules1 = self.block.get_rules() rules2 = self.block.get_rules() - keys1 = rules1.question_series[0]['keys'] + rules1.question_series[1]['keys'] - keys2 = rules2.question_series[0]['keys'] + rules2.question_series[1]['keys'] + 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 TestModelExperiment(TestCase): @classmethod def setUpTestData(cls): - cls.experiment = Experiment.objects.create(name='experiment') - cls.participant1 = Participant.objects.create(participant_id_url='001') - cls.participant2 = Participant.objects.create(participant_id_url='002') - cls.participant3 = Participant.objects.create(participant_id_url='003') + cls.experiment = Experiment.objects.create() + cls.experiment_translated_content = ExperimentTranslatedContent.objects.create( + experiment=cls.experiment, language="en", name="test_experiment", description="test_description" + ) + cls.participant1 = Participant.objects.create(participant_id_url="001") + cls.participant2 = Participant.objects.create(participant_id_url="002") + cls.participant3 = Participant.objects.create(participant_id_url="003") def test_verbose_name_plural(self): # Get the Experiment Meta class @@ -33,44 +36,38 @@ def test_verbose_name_plural(self): def test_associated_blocks(self): experiment = self.experiment - phase1 = Phase.objects.create( - name='first_phase', series=experiment) - phase2 = Phase.objects.create( - name='second_phase', series=experiment) - block = Block.objects.create( - rules='THATS_MY_SONG', slug='hooked', rounds=42, phase=phase1) - block2 = Block.objects.create( - rules='THATS_MY_SONG', slug='unhinged', rounds=42, phase=phase2) - block3 = Block.objects.create( - rules='THATS_MY_SONG', slug='derailed', rounds=42, phase=phase2) + phase1 = Phase.objects.create(name="first_phase", series=experiment) + phase2 = Phase.objects.create(name="second_phase", series=experiment) + block = Block.objects.create(rules="THATS_MY_SONG", slug="hooked", rounds=42, phase=phase1) + block2 = Block.objects.create(rules="THATS_MY_SONG", slug="unhinged", rounds=42, phase=phase2) + block3 = Block.objects.create(rules="THATS_MY_SONG", slug="derailed", rounds=42, phase=phase2) - self.assertEqual(experiment.associated_blocks(), [ - block, block2, block3]) + self.assertEqual(experiment.associated_blocks(), [block, block2, block3]) def test_export_sessions(self): experiment = self.experiment - phase = Phase.objects.create( - name='test', series=experiment) - block = Block.objects.create( - rules='THATS_MY_SONG', slug='hooked', rounds=42, phase=phase) + phase = Phase.objects.create(name="test", series=experiment) + block = Block.objects.create(rules="THATS_MY_SONG", slug="hooked", rounds=42, phase=phase) Session.objects.bulk_create( - [Session(block=block, participant=self.participant1), - Session(block=block, participant=self.participant2), - Session(block=block, participant=self.participant3)] - ) + [ + Session(block=block, participant=self.participant1), + Session(block=block, participant=self.participant2), + Session(block=block, participant=self.participant3), + ] + ) sessions = experiment.export_sessions() self.assertEqual(len(sessions), 3) def test_current_participants(self): experiment = self.experiment - phase = Phase.objects.create( - name='test', series=experiment) - block = Block.objects.create( - rules='THATS_MY_SONG', slug='hooked', rounds=42, phase=phase) + phase = Phase.objects.create(name="test", series=experiment) + block = Block.objects.create(rules="THATS_MY_SONG", slug="hooked", rounds=42, phase=phase) Session.objects.bulk_create( - [Session(block=block, participant=self.participant1), - Session(block=block, participant=self.participant2), - Session(block=block, participant=self.participant3)] - ) + [ + Session(block=block, participant=self.participant1), + Session(block=block, participant=self.participant2), + Session(block=block, participant=self.participant3), + ] + ) participants = experiment.current_participants() self.assertEqual(len(participants), 3) diff --git a/backend/experiment/tests/test_views.py b/backend/experiment/tests/test_views.py index 9679bd136..fabf83f5b 100644 --- a/backend/experiment/tests/test_views.py +++ b/backend/experiment/tests/test_views.py @@ -1,17 +1,16 @@ from django.conf import settings from django.test import TestCase from django.utils import timezone +from django.utils.translation import activate, get_language from image.models import Image -from experiment.serializers import ( - serialize_block, - serialize_phase -) +from experiment.serializers import serialize_block, serialize_phase from experiment.models import ( Block, Experiment, Phase, SocialMediaConfig, + ExperimentTranslatedContent, ) from experiment.rules.hooked import Hooked from participant.models import Participant @@ -21,231 +20,225 @@ class TestExperimentViews(TestCase): - @classmethod def setUpTestData(cls): cls.participant = Participant.objects.create() theme_config = create_theme_config() experiment = Experiment.objects.create( - name='Test Series', - slug='test_series', + slug="test_series", theme_config=theme_config, ) - experiment.social_media_config = create_social_media_config(experiment) - introductory_phase = Phase.objects.create( - name='introduction', - series=experiment, - index=1 - ) - cls.block1 = Block.objects.create( - name='block1', slug='block1', phase=introductory_phase) - intermediate_phase = Phase.objects.create( - name='intermediate', - series=experiment, - index=2 + ExperimentTranslatedContent.objects.create( + experiment=experiment, language="en", name="Test Series", description="Test Description" ) + experiment.social_media_config = create_social_media_config(experiment) + introductory_phase = Phase.objects.create(name="introduction", series=experiment, index=1) + cls.block1 = Block.objects.create(name="block1", slug="block1", phase=introductory_phase) + intermediate_phase = Phase.objects.create(name="intermediate", series=experiment, index=2) cls.block2 = Block.objects.create( - name='block2', slug='block2', theme_config=theme_config, phase=intermediate_phase) - cls.block3 = Block.objects.create( - name='block3', slug='block3', phase=intermediate_phase) - final_phase = Phase.objects.create( - name='final', - series=experiment, - index=3 + name="block2", slug="block2", theme_config=theme_config, phase=intermediate_phase ) - cls.block4 = Block.objects.create( - name='block4', slug='block4', phase=final_phase) + cls.block3 = Block.objects.create(name="block3", slug="block3", phase=intermediate_phase) + final_phase = Phase.objects.create(name="final", series=experiment, index=3) + cls.block4 = Block.objects.create(name="block4", slug="block4", phase=final_phase) def test_get_experiment(self): # save participant data to request session session = self.client.session - session['participant_id'] = self.participant.id + session["participant_id"] = self.participant.id session.save() # check that first_experiments is returned correctly - response = self.client.get('/experiment/test_series/') - self.assertEqual(response.json().get( - 'nextBlock').get('slug'), 'block1') + response = self.client.get("/experiment/test_series/") + self.assertEqual(response.json().get("nextBlock").get("slug"), "block1") # create session - Session.objects.create( - block=self.block1, - participant=self.participant, - finished_at=timezone.now() - ) - response = self.client.get('/experiment/test_series/') - self.assertIn(response.json().get('nextBlock').get( - 'slug'), ('block2', 'block3')) - self.assertEqual(response.json().get('dashboard'), []) - Session.objects.create( - block=self.block2, - participant=self.participant, - finished_at=timezone.now() - ) - Session.objects.create( - block=self.block3, - participant=self.participant, - finished_at=timezone.now() - ) - response = self.client.get('/experiment/test_series/') + Session.objects.create(block=self.block1, participant=self.participant, finished_at=timezone.now()) + response = self.client.get("/experiment/test_series/") + self.assertIn(response.json().get("nextBlock").get("slug"), ("block2", "block3")) + self.assertEqual(response.json().get("dashboard"), []) + Session.objects.create(block=self.block2, participant=self.participant, finished_at=timezone.now()) + Session.objects.create(block=self.block3, participant=self.participant, finished_at=timezone.now()) + response = self.client.get("/experiment/test_series/") response_json = response.json() - self.assertEqual(response_json.get( - 'nextBlock').get('slug'), 'block4') - self.assertEqual(response_json.get('dashboard'), []) - self.assertEqual(response_json.get('theme').get('name'), 'test_theme') - self.assertEqual(len(response_json['theme']['header']['score']), 3) - self.assertEqual(response_json.get('theme').get('footer').get( - 'disclaimer'), '

Test Disclaimer

') - self.assertEqual(response_json.get('socialMedia').get('url'), 'https://www.example.com') - self.assertEqual(response_json.get('socialMedia').get('content'), 'Test Content') - self.assertEqual(response_json.get('socialMedia').get('tags'), ['aml', 'toontjehoger']) - self.assertEqual(response_json.get('socialMedia').get('channels'), ['facebook', 'twitter', 'weibo']) + self.assertEqual(response_json.get("nextBlock").get("slug"), "block4") + self.assertEqual(response_json.get("dashboard"), []) + self.assertEqual(response_json.get("theme").get("name"), "test_theme") + self.assertEqual(len(response_json["theme"]["header"]["score"]), 3) + self.assertEqual(response_json.get("theme").get("footer").get("disclaimer"), "

Test Disclaimer

") + self.assertEqual(response_json.get("socialMedia").get("url"), "https://www.example.com") + self.assertEqual(response_json.get("socialMedia").get("content"), "Test Content") + self.assertEqual(response_json.get("socialMedia").get("tags"), ["aml", "toontjehoger"]) + self.assertEqual(response_json.get("socialMedia").get("channels"), ["facebook", "twitter", "weibo"]) def test_get_experiment_not_found(self): # if Experiment does not exist, return 404 - response = self.client.get('/experiment/not_found/') + response = self.client.get("/experiment/not_found/") self.assertEqual(response.status_code, 404) def test_get_experiment_inactive(self): # if Experiment is inactive, return 404 - experiment = Experiment.objects.get(slug='test_series') + experiment = Experiment.objects.get(slug="test_series") experiment.active = False experiment.save() - response = self.client.get('/experiment/test_series/') + response = self.client.get("/experiment/test_series/") self.assertEqual(response.status_code, 404) def test_get_experiment_without_social_media(self): session = self.client.session - session['participant_id'] = self.participant.id + session["participant_id"] = self.participant.id session.save() - Session.objects.create( - block=self.block1, - participant=self.participant, - finished_at=timezone.now() - ) - intermediate_phase = Phase.objects.get( - name='intermediate' - ) + Session.objects.create(block=self.block1, participant=self.participant, finished_at=timezone.now()) + intermediate_phase = Phase.objects.get(name="intermediate") intermediate_phase.dashboard = True intermediate_phase.save() - Experiment.objects.create( - name='No Social Media', - slug='no_social_media', - theme_config=create_theme_config(name='no_social_media') + experiment = Experiment.objects.create( + slug="no_social_media", + theme_config=create_theme_config(name="no_social_media"), + ) + ExperimentTranslatedContent.objects.create( + experiment=experiment, language="en", name="Test Experiment", description="Test Description" ) - response = self.client.get('/experiment/no_social_media/') + response = self.client.get("/experiment/no_social_media/") self.assertEqual(response.status_code, 200) - self.assertNotIn('socialMedia', response.json()) + self.assertNotIn("socialMedia", response.json()) def test_experiment_with_dashboard(self): # if Experiment has dashboard set True, return list of random blocks session = self.client.session - session['participant_id'] = self.participant.id + session["participant_id"] = self.participant.id session.save() - Session.objects.create( - block=self.block1, - participant=self.participant, - finished_at=timezone.now() - ) - intermediate_phase = Phase.objects.get( - name='intermediate' - ) + Session.objects.create(block=self.block1, participant=self.participant, finished_at=timezone.now()) + intermediate_phase = Phase.objects.get(name="intermediate") intermediate_phase.dashboard = True intermediate_phase.save() # check that first_experiments is returned correctly - response = self.client.get('/experiment/test_series/') - self.assertEqual(type(response.json().get('dashboard')), list) + response = self.client.get("/experiment/test_series/") + self.assertEqual(type(response.json().get("dashboard")), list) def test_experiment_total_score(self): - """ Test calculation of total score for grouped block on dashboard """ + """Test calculation of total score for grouped block on dashboard""" session = self.client.session - session['participant_id'] = self.participant.id + session["participant_id"] = self.participant.id session.save() Session.objects.create( - block=self.block2, - participant=self.participant, - finished_at=timezone.now(), - final_score=8 - ) - intermediate_phase = Phase.objects.get( - name='intermediate' + block=self.block2, participant=self.participant, finished_at=timezone.now(), final_score=8 ) + intermediate_phase = Phase.objects.get(name="intermediate") intermediate_phase.dashboard = True intermediate_phase.save() serialized_coll_1 = serialize_phase(intermediate_phase, self.participant) - total_score_1 = serialized_coll_1['totalScore'] + total_score_1 = serialized_coll_1["totalScore"] self.assertEqual(total_score_1, 8) Session.objects.create( - block=self.block3, - participant=self.participant, - finished_at=timezone.now(), - final_score=8 + block=self.block3, participant=self.participant, finished_at=timezone.now(), final_score=8 ) serialized_coll_2 = serialize_phase(intermediate_phase, self.participant) - total_score_2 = serialized_coll_2['totalScore'] + total_score_2 = serialized_coll_2["totalScore"] self.assertEqual(total_score_2, 16) + def test_experiment_get_translated_content(self): + """Test get_translated_content method""" + experiment = Experiment.objects.get(slug="test_series") + translated_content = experiment.get_translated_content("en") + self.assertEqual(translated_content.name, "Test Series") -class ExperimentViewsTest(TestCase): + def test_experiment_get_fallback_content(self): + """Test get_fallback_content method""" + + experiment = Experiment.objects.create(slug="test_experiment_translated_content") + ExperimentTranslatedContent.objects.create( + experiment=experiment, + index=0, + language="en", + name="Test Experiment Fallback Content", + description="Test experiment description in English.", + ) + ExperimentTranslatedContent.objects.create( + experiment=experiment, + index=1, + language="es", + name="Experimento de Prueba", + description="Descripción de la experimento de prueba en español.", + ) + + session = self.client.session + session["participant_id"] = self.participant.id + session.save() + + # request experiment with language set to English (British) + response = self.client.get( + "/experiment/test_experiment_translated_content/", headers={"Accept-Language": "en-Gb"} + ) + + # since English translation is available, the English content should be returned + self.assertEqual(response.json().get("name"), "Test Experiment Fallback Content") + + # request experiment with language set to Spanish + response = self.client.get("/experiment/test_experiment_translated_content/", headers={"Accept-Language": "es"}) + + # since Spanish translation is available, the Spanish content should be returned + self.assertEqual(response.json().get("name"), "Experimento de Prueba") + + # request experiment with language set to Dutch + response = self.client.get("/experiment/test_experiment_translated_content/", headers={"Accept-Language": "nl"}) + # since no Dutch translation is available, the fallback content should be returned + self.assertEqual(response.json().get("name"), "Test Experiment Fallback Content") + + +class ExperimentViewsTest(TestCase): def test_serialize_block(self): # Create an block block = Block.objects.create( - slug='test-block', - name='Test Block', - description='This is a test block', + slug="test-block", + name="Test Block", + description="This is a test block", image=Image.objects.create( - title='Test', - description='', - file='test-image.jpg', - alt='Test', - href='https://www.example.com', - rel='', - target='_self', + title="Test", + description="", + file="test-image.jpg", + alt="Test", + href="https://www.example.com", + rel="", + target="_self", ), - theme_config=create_theme_config() + theme_config=create_theme_config(), ) participant = Participant.objects.create() - Session.objects.bulk_create([ - Session(block=block, participant=participant, finished_at=timezone.now()) for index in range(3) - ]) + Session.objects.bulk_create( + [Session(block=block, participant=participant, finished_at=timezone.now()) for index in range(3)] + ) # Call the serialize_block function serialized_block = serialize_block(block, participant) # Assert the serialized data + self.assertEqual(serialized_block["slug"], "test-block") + self.assertEqual(serialized_block["name"], "Test Block") + self.assertEqual(serialized_block["description"], "This is a test block") self.assertEqual( - serialized_block['slug'], 'test-block' - ) - self.assertEqual( - serialized_block['name'], 'Test Block' - ) - self.assertEqual( - serialized_block['description'], 'This is a test block' - ) - self.assertEqual( - serialized_block['image'], { - 'title': 'Test', - 'description': '', - 'file': f'{settings.BASE_URL}/upload/test-image.jpg', - 'href': 'https://www.example.com', - 'alt': 'Test', - 'rel': '', - 'target': '_self', - 'tags': [] - } + serialized_block["image"], + { + "title": "Test", + "description": "", + "file": f"{settings.BASE_URL}/upload/test-image.jpg", + "href": "https://www.example.com", + "alt": "Test", + "rel": "", + "target": "_self", + "tags": [], + }, ) def test_get_block(self): # Create an block block = Block.objects.create( - slug='test-block', - name='Test Block', - description='This is a test block', - image=Image.objects.create( - file='test-image.jpg' - ), + slug="test-block", + name="Test Block", + description="This is a test block", + image=Image.objects.create(file="test-image.jpg"), rules=Hooked.ID, theme_config=create_theme_config(), rounds=3, @@ -260,71 +253,47 @@ def test_get_block(self): request_session.save() # Experiment block session - Session.objects.bulk_create([ - Session(block=block, participant=participant, finished_at=timezone.now()) for index in range(3) - ]) + Session.objects.bulk_create( + [Session(block=block, participant=participant, finished_at=timezone.now()) for index in range(3)] + ) - response = self.client.get('/experiment/block/test-block/') + response = self.client.get("/experiment/block/test-block/") - self.assertEqual( - response.json()['slug'], 'test-block' - ) - self.assertEqual( - response.json()['name'], 'Test Block' - ) - self.assertEqual( - response.json()['theme']['name'], 'test_theme' - ) - self.assertEqual( - len(response.json()['theme']['header']['score']), 3 - ) - self.assertEqual( - response.json()['theme']['footer']['disclaimer'], '

Test Disclaimer

' - ) - self.assertEqual( - response.json()['rounds'], 3 - ) - self.assertEqual( - response.json()['bonus_points'], 42 - ) + self.assertEqual(response.json()["slug"], "test-block") + self.assertEqual(response.json()["name"], "Test Block") + self.assertEqual(response.json()["theme"]["name"], "test_theme") + self.assertEqual(len(response.json()["theme"]["header"]["score"]), 3) + self.assertEqual(response.json()["theme"]["footer"]["disclaimer"], "

Test Disclaimer

") + self.assertEqual(response.json()["rounds"], 3) + self.assertEqual(response.json()["bonus_points"], 42) -def create_theme_config(name='test_theme') -> ThemeConfig: +def create_theme_config(name="test_theme") -> ThemeConfig: theme_config = ThemeConfig.objects.create( - name=name, - description='Test Theme', - heading_font_url='https://fonts.googleapis.com/css2?family=Architects+Daughter&family=Micro+5&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap', - body_font_url='https://fonts.googleapis.com/css2?family=Architects+Daughter&family=Micro+5&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap', - logo_image=Image.objects.create(file='test-logo.jpg'), - background_image=Image.objects.create(file='test-background.jpg'), - ) - HeaderConfig.objects.create( - theme=theme_config, - show_score=True + name=name, + description="Test Theme", + heading_font_url="https://fonts.googleapis.com/css2?family=Architects+Daughter&family=Micro+5&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap", + body_font_url="https://fonts.googleapis.com/css2?family=Architects+Daughter&family=Micro+5&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap", + logo_image=Image.objects.create(file="test-logo.jpg"), + background_image=Image.objects.create(file="test-background.jpg"), ) + HeaderConfig.objects.create(theme=theme_config, show_score=True) footer_config = FooterConfig.objects.create( theme=theme_config, - disclaimer='Test Disclaimer', - privacy='Test Privacy', + disclaimer="Test Disclaimer", + privacy="Test Privacy", ) - footer_config.logos.add( - Image.objects.create(file='test-logo-b.jpg'), - through_defaults={'index': 1} - ) - footer_config.logos.add( - Image.objects.create(file='test-logo-a.jpg'), - through_defaults={'index': 0} - ) + footer_config.logos.add(Image.objects.create(file="test-logo-b.jpg"), through_defaults={"index": 1}) + footer_config.logos.add(Image.objects.create(file="test-logo-a.jpg"), through_defaults={"index": 0}) return theme_config -def create_social_media_config( - experiment: Experiment) -> SocialMediaConfig: +def create_social_media_config(experiment: Experiment) -> SocialMediaConfig: return SocialMediaConfig.objects.create( experiment=experiment, - url='https://www.example.com', - content='Test Content', - channels=['facebook', 'twitter', 'weibo'], - tags=['aml', 'toontjehoger'] + url="https://www.example.com", + content="Test Content", + channels=["facebook", "twitter", "weibo"], + tags=["aml", "toontjehoger"], ) diff --git a/backend/experiment/views.py b/backend/experiment/views.py index f1ec50677..381fc8d8c 100644 --- a/backend/experiment/views.py +++ b/backend/experiment/views.py @@ -3,7 +3,7 @@ from django.http import Http404, HttpRequest, JsonResponse from django.conf import settings -from django.utils.translation import activate, gettext_lazy as _ +from django.utils.translation import activate, gettext_lazy as _, get_language from django_markup.markup import formatter from .models import Block, Experiment, Phase, Feedback, Session @@ -27,12 +27,11 @@ def get_block(request: HttpRequest, slug: str) -> JsonResponse: DO NOT modify session data here, it will break participant_id system (/participant and /block/ are called at the same time by the frontend)""" block = block_or_404(slug) - class_name = '' - if request.LANGUAGE_CODE.startswith('zh'): - class_name = 'chinese' + class_name = "" + active_language = get_language() - if block.language: - activate(block.language) + if active_language.startswith("zh"): + class_name = "chinese" participant = get_participant(request) session = Session(block=block, participant=participant) @@ -45,48 +44,39 @@ def get_block(request: HttpRequest, slug: str) -> JsonResponse: # create data block_data = { - 'id': block.id, - 'slug': block.slug, - 'name': block.name, - 'theme': serialize_theme(block.theme_config) if block.theme_config else None, - 'description': block.description, - 'image': serialize_image(block.image) if block.image else None, - 'class_name': class_name, # can be used to override style - 'rounds': block.rounds, - 'bonus_points': block.bonus_points, - 'playlists': [ - {'id': playlist.id, 'name': playlist.name} - for playlist in block.playlists.all() - ], - 'feedback_info': block.get_rules().feedback_info(), - + "id": block.id, + "slug": block.slug, + "name": block.name, + "theme": serialize_theme(block.theme_config) if block.theme_config else None, + "description": block.description, + "image": serialize_image(block.image) if block.image else None, + "class_name": class_name, # can be used to override style + "rounds": block.rounds, + "bonus_points": block.bonus_points, + "playlists": [{"id": playlist.id, "name": playlist.name} for playlist in block.playlists.all()], + "feedback_info": block.get_rules().feedback_info(), # only call first round if the (deprecated) first_round method exists # otherwise, call next_round - 'next_round': ( + "next_round": ( serialize_actions(block.get_rules().first_round(block)) - if hasattr(block.get_rules(), "first_round") - and block.get_rules().first_round + if hasattr(block.get_rules(), "first_round") and block.get_rules().first_round else serialize_actions(block.get_rules().next_round(session)) ), - 'loading_text': _('Loading'), - 'session_id': session.id, + "loading_text": _("Loading"), + "session_id": session.id, } - response = JsonResponse(block_data, json_dumps_params={'indent': 4}) - if block.language: - response.set_cookie(settings.LANGUAGE_COOKIE_NAME, block.language) - else: - # avoid carrying over language cookie from other blocks - response.set_cookie(settings.LANGUAGE_COOKIE_NAME, None) + response = JsonResponse(block_data, json_dumps_params={"indent": 4}) + return response def post_feedback(request, slug): - text = request.POST.get('feedback') + text = request.POST.get("feedback") block = block_or_404(slug) feedback = Feedback(text=text, block=block) feedback.save() - return JsonResponse({'status': 'ok'}) + return JsonResponse({"status": "ok"}) def block_or_404(slug): @@ -108,13 +98,14 @@ def get_experiment( slug: str, phase_index: int = 0, ) -> JsonResponse: - ''' + """ check which `Phase` objects are related to the `Experiment` with the given slug retrieve the phase with the lowest order (= current_phase) return the next block from the current_phase without a finished session except if Phase.dashboard = True, then all blocks of the current_phase will be returned as an array (also those with finished session) - ''' + """ + try: experiment = Experiment.objects.get(slug=slug, active=True) except Experiment.DoesNotExist: @@ -122,101 +113,99 @@ def get_experiment( except Exception as e: logger.error(e) return JsonResponse( - { - "error": "Something went wrong while fetching the experiment. Please try again later." - }, + {"error": "Something went wrong while fetching the experiment. Please try again later."}, status=500, ) request.session[EXPERIMENT_KEY] = slug participant = get_participant(request) - phases = list(Phase.objects.filter( - series=experiment.id).order_by('index')) + language_code = get_language()[0:2] + + translated_content = experiment.get_translated_content(language_code) + + if not translated_content: + raise ValueError("No translated content found for this experiment") + + experiment_language = translated_content.language + activate(experiment_language) + + phases = list(Phase.objects.filter(series=experiment.id).order_by("index")) try: current_phase = phases[phase_index] - serialized_phase = serialize_phase( - current_phase, participant) + serialized_phase = serialize_phase(current_phase, participant) if not serialized_phase: # if the current phase is not a dashboard and has no unfinished blocks, it will return None # set it to finished and continue to next phase phase_index += 1 return get_experiment(request, slug, phase_index=phase_index) except IndexError: - serialized_phase = { - 'dashboard': [], - 'next_block': None - } - return JsonResponse({ - **serialize_experiment(experiment), - **serialized_phase - }) + serialized_phase = {"dashboard": [], "next_block": None} + + response = JsonResponse({**serialize_experiment(experiment, language_code), **serialized_phase}) + + return response def get_associated_blocks(pk_list): - ''' get all the experiment objects registered in an Experiment field''' + """get all the experiment objects registered in an Experiment field""" return [Block.objects.get(pk=pk) for pk in pk_list] def render_markdown(request): - - if request.method != 'POST': - return JsonResponse({'status': 'error', 'message': 'Only POST requests are allowed'}) + if request.method != "POST": + return JsonResponse({"status": "error", "message": "Only POST requests are allowed"}) if not request.body: - return JsonResponse({'status': 'error', 'message': 'No body found in request'}) + return JsonResponse({"status": "error", "message": "No body found in request"}) - if request.content_type != 'application/json': - return JsonResponse({'status': 'error', 'message': 'Only application/json content type is allowed'}) + if request.content_type != "application/json": + return JsonResponse({"status": "error", "message": "Only application/json content type is allowed"}) data = json.loads(request.body) - markdown = data['markdown'] + markdown = data["markdown"] if markdown: - return JsonResponse({'html': formatter(markdown, filter_name='markdown')}) + return JsonResponse({"html": formatter(markdown, filter_name="markdown")}) - return JsonResponse({'html': ''}) + return JsonResponse({"html": ""}) -def validate_block_playlist( - request: HttpRequest, - rules_id: str - ) -> JsonResponse: +def validate_block_playlist(request: HttpRequest, rules_id: str) -> JsonResponse: """ Validate the playlist of an experiment based on the used rules """ - if request.method != 'POST': - return JsonResponse({'status': 'error', 'message': 'Only POST requests are allowed'}) + if request.method != "POST": + return JsonResponse({"status": "error", "message": "Only POST requests are allowed"}) if not request.body: - return JsonResponse({'status': 'error', 'message': 'No body found in request'}) + return JsonResponse({"status": "error", "message": "No body found in request"}) - if request.content_type != 'application/json': - return JsonResponse({'status': 'error', 'message': 'Only application/json content type is allowed'}) + if request.content_type != "application/json": + return JsonResponse({"status": "error", "message": "Only application/json content type is allowed"}) json_body = json.loads(request.body) - playlist_ids = json_body.get('playlists', []) + playlist_ids = json_body.get("playlists", []) playlists = Playlist.objects.filter(id__in=playlist_ids) if not playlists: - return JsonResponse({'status': 'error', 'message': 'The block must have a playlist.'}) + return JsonResponse({"status": "error", "message": "The block must have a playlist."}) rules = BLOCK_RULES[rules_id]() if not rules.validate_playlist: - return JsonResponse({'status': 'warn', 'message': 'This rulesset does not have a playlist validation.'}) + return JsonResponse({"status": "warn", "message": "This rulesset does not have a playlist validation."}) playlist_errors = [] for playlist in playlists: errors = rules.validate_playlist(playlist) if errors: - playlist_errors.append({ - 'playlist': playlist.name, - 'errors': errors - }) + playlist_errors.append({"playlist": playlist.name, "errors": errors}) if playlist_errors: - return JsonResponse({'status': 'error', 'message': 'There are errors in the playlist.', 'errors': playlist_errors}) + return JsonResponse( + {"status": "error", "message": "There are errors in the playlist.", "errors": playlist_errors} + ) - return JsonResponse({'status': 'ok', 'message': 'The playlist is valid.'}) + return JsonResponse({"status": "ok", "message": "The playlist is valid."}) diff --git a/backend/image/validators.py b/backend/image/validators.py index da879fca3..3f38ba576 100644 --- a/backend/image/validators.py +++ b/backend/image/validators.py @@ -5,7 +5,6 @@ @deconstructible class FileValidator(object): - def __init__(self, allowed_extensions=None, allowed_mimetypes=None): self.allowed_extensions = allowed_extensions self.allowed_mimetypes = allowed_mimetypes @@ -13,19 +12,24 @@ def __init__(self, allowed_extensions=None, allowed_mimetypes=None): def __call__(self, value): ext = os.path.splitext(value.name)[1].lower() - print('Antoinette!', value, ext) - if self.allowed_extensions and ext not in self.allowed_extensions: - raise ValidationError(f'Unsupported file extension: {ext}.') + raise ValidationError(f"Unsupported file extension: {ext}.") validate_image_file = FileValidator( - allowed_extensions=['.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp',], + allowed_extensions=[ + ".jpg", + ".jpeg", + ".png", + ".gif", + ".svg", + ".webp", + ], allowed_mimetypes=[ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/svg+xml', - 'image/webp', - ] + "image/jpeg", + "image/png", + "image/gif", + "image/svg+xml", + "image/webp", + ], ) diff --git a/scripts/build-front b/scripts/build-front index 8b70ab8fb..55c79114c 100755 --- a/scripts/build-front +++ b/scripts/build-front @@ -1,3 +1,2 @@ #!/bin/bash -docker-compose run --rm client yarn build - +docker compose run --rm client yarn build diff --git a/scripts/format-back b/scripts/format-back index d30738d9a..d9a2f561e 100755 --- a/scripts/format-back +++ b/scripts/format-back @@ -1,2 +1,2 @@ #!/bin/bash -docker-compose run --rm server bash -c "ruff format" +docker compose run --rm server bash -c "ruff format" diff --git a/scripts/lint-back b/scripts/lint-back index 26d09d042..4ccb70639 100755 --- a/scripts/lint-back +++ b/scripts/lint-back @@ -5,4 +5,4 @@ # It uses the `ruff check` command to lint the codebase. # The `--watch` flag tells Ruff to watch the codebase for changes and re-lint the codebase when changes are detected. # The `--fix` flag tells Ruff to automatically fix any linting errors that it can. -docker-compose run --rm server bash -c "ruff check $@" +docker compose run --rm server bash -c "ruff check $@" diff --git a/scripts/lint-front b/scripts/lint-front index ad826e4f8..2e313c2f5 100755 --- a/scripts/lint-front +++ b/scripts/lint-front @@ -1,2 +1,2 @@ #!/bin/bash -docker-compose run --rm client yarn lint $@ +docker compose run --rm client yarn lint $@ diff --git a/scripts/manage b/scripts/manage index 8c7cccdc5..9e3ca3918 100755 --- a/scripts/manage +++ b/scripts/manage @@ -1,4 +1,4 @@ #!/bin/bash # This script executes Django management commands through Docker. -docker-compose run --rm server python manage.py $@ \ No newline at end of file +docker compose run --rm server python manage.py $@ diff --git a/scripts/test-back b/scripts/test-back index 8d367adff..9600c7a95 100755 --- a/scripts/test-back +++ b/scripts/test-back @@ -1,3 +1,2 @@ #!/bin/bash -docker-compose run --rm server bash -c "python manage.py test $@" - +docker compose run --rm server bash -c "python manage.py test $@" diff --git a/scripts/test-back-coverage b/scripts/test-back-coverage index 67a0101e0..0c9161475 100755 --- a/scripts/test-back-coverage +++ b/scripts/test-back-coverage @@ -1,3 +1,2 @@ #!/bin/bash -docker-compose run --rm server bash -c "coverage run manage.py test && coverage report --show-missing" - +docker compose run --rm server bash -c "coverage run manage.py test && coverage report --show-missing" diff --git a/scripts/test-front b/scripts/test-front index a2fd3536d..b85d425df 100755 --- a/scripts/test-front +++ b/scripts/test-front @@ -1,3 +1,2 @@ #!/bin/bash -docker-compose run --rm client yarn test --watch=false - +docker compose run --rm client yarn test --watch=false diff --git a/scripts/test-front-ci b/scripts/test-front-ci index d630b82c9..14a83b113 100755 --- a/scripts/test-front-ci +++ b/scripts/test-front-ci @@ -1,2 +1,2 @@ #!/bin/bash -docker-compose run --rm client yarn test:ci +docker compose run --rm client yarn test:ci diff --git a/scripts/test-front-watch b/scripts/test-front-watch index bb76f6de1..ce5ac315b 100755 --- a/scripts/test-front-watch +++ b/scripts/test-front-watch @@ -1,3 +1,2 @@ #!/bin/bash -docker-compose run --rm client yarn test --watch=true - +docker compose run --rm client yarn test --watch=true