diff --git a/.env-github-actions b/.env-github-actions index 1e5f04b8c..7cdad8e5b 100644 --- a/.env-github-actions +++ b/.env-github-actions @@ -8,7 +8,7 @@ AML_LOCATION_PROVIDER=http://ip2country:5000/{} AML_DEBUG=True DJANGO_SETTINGS_MODULE=aml.development_settings REACT_APP_API_ROOT=http://localhost:8000 -REACT_APP_EXPERIMENT_SLUG=fe +REACT_APP_EXPERIMENT_SLUG=gold-msi REACT_APP_AML_HOME=https://www.amsterdammusiclab.nl REACT_APP_HTML_PAGE_TITLE=Amsterdam Music Lab Experiment REACT_APP_HTML_OG_DESCRIPTION=Listening experiments from the Amsterdam Music Lab. Test your musical knowledge and skills in engaging citizen-science experiments. diff --git a/.env.dist b/.env.dist index 92187bc60..cea75fe88 100644 --- a/.env.dist +++ b/.env.dist @@ -13,10 +13,10 @@ DJANGO_SUPERUSER_USERNAME=admin # do not use in production! DJANGO_SUPERUSER_PASSWORD=admin # do not use in production! DJANGO_SUPERUSER_EMAIL=mail@example.com # do not use in production! AML_LOCATION_PROVIDER=http://ip2country:5000/{} # address of the ip2country container, don't change -ALLOWED_HOSTS=localhost # needs to be changed when running in production +AML_ALLOWED_HOSTS="backend.muscle.local" # needs to be changed when running in production -REACT_APP_API_ROOT=http://localhost:8000 # address of the server, don't change -REACT_APP_EXPERIMENT_SLUG=gmsi # experiment slug that the frontend redirects to +REACT_APP_API_ROOT=http://backend.muscle.local # address of the server, don't change +REACT_APP_EXPERIMENT_SLUG=gold-msi # experiment slug that the frontend redirects to REACT_APP_AML_HOME=https://www.amsterdammusiclab.nl # website you will be redirected to if you do not agree with an informed consent form REACT_APP_LOGO_URL= # optional: link to logo REACT_APP_HTML_PAGE_TITLE=Amsterdam Music Lab Experiment # optional: information for web crawlers @@ -27,3 +27,4 @@ REACT_APP_HTML_OG_TITLE=Amsterdam Music Lab Experiment # optional: information f REACT_APP_HTML_OG_URL= # optional: information for web crawlers REACT_APP_HTML_BODY_CLASS= # optional: assign a css class to the
tag in index.html, for custom styling REACT_APP_SENTRY_DSN= # optional: link to sentry instance, e.g. https://xxx@xxx.ingest.sentry.io/xxx +REACT_APP_STRICT= # optional: use StrictMode for development diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 205b17f66..571007b63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: - name: Run Backend Tests 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" + 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 @@ -24,7 +24,7 @@ jobs: # Push coverage badge to separate branch (only for main and develop branches) - name: Push Backend Coverage Badge to separate branch continue-on-error: true - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' + if: github.ref == 'refs/heads/develop' run: | if git ls-remote --heads origin code-coverage-badges; then git fetch origin code-coverage-badges @@ -54,7 +54,7 @@ jobs: steps: - uses: actions/checkout@v3 - name: Lint Backend - continue-on-error: true + continue-on-error: false run: sudo docker-compose --env-file .env-github-actions run server bash -c "flake8" frontend-test: @@ -63,7 +63,43 @@ jobs: steps: - uses: actions/checkout@v3 - name: Run Frontend Tests - run: sudo docker-compose --env-file .env-github-actions run client yarn test:ci --watchAll=false + run: sudo docker-compose --env-file .env-github-actions run client yarn test:ci + + frontend-coverage-badge: + name: Generate Frontend Coverage Badge + needs: frontend-test + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/develop' + steps: + - 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 --coverageDirectory=public/coverage + 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: | + if git ls-remote --heads origin code-coverage-badges; then + git fetch origin code-coverage-badges + git checkout code-coverage-badges + else + git checkout -b code-coverage-badges + git push origin code-coverage-badges + fi + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + rm frontend/coverage-frontend-badge.svg -f + sudo mv frontend/public/coverage/coverage-frontend-badge-new.svg frontend/coverage-frontend-badge.svg + git add frontend/coverage-frontend-badge.svg + git commit -m "Add frontend coverage badge for commit $GITHUB_SHA" + git push origin code-coverage-badges + # Check if there are any changes + if git diff --staged --quiet; then + echo "No changes in coverage badge. Skipping commit and push." + else + git commit -m "Add frontend coverage badge for commit $GITHUB_SHA" + git push origin code-coverage-badges + fi frontend-lint: name: Lint Frontend diff --git a/.github/workflows/podman.yml b/.github/workflows/podman.yml new file mode 100644 index 000000000..d45978f6d --- /dev/null +++ b/.github/workflows/podman.yml @@ -0,0 +1,132 @@ +name: Podman build & deploy + +on: + push: + branches: + - develop + workflow_dispatch: + + # temporarily also for PRs + pull_request: + branches: + - develop + +jobs: + deploy-test: + name: Deploy to test environment + environment: Test + runs-on: tst + if: github.ref == 'refs/heads/develop' + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + env: + + # Variables + AML_ALLOWED_HOSTS: ${{ vars.AML_ALLOWED_HOSTS }} + AML_CORS_ORIGIN_WHITELIST: ${{ vars.AML_CORS_ORIGIN_WHITELIST }} + AML_DEBUG: ${{ vars.AML_DEBUG }} + AML_LOCATION_PROVIDER: ${{ vars.AML_LOCATION_PROVIDER }} + AML_SUBPATH: ${{ vars.AML_SUBPATH }} + DJANGO_SETTINGS_MODULE: ${{ vars.DJANGO_SETTINGS_MODULE }} + SQL_DATABASE: ${{ vars.SQL_DATABASE }} + SQL_HOST: ${{ vars.SQL_HOST }} + SQL_PORT: ${{ vars.SQL_PORT }} + REACT_APP_API_ROOT: ${{ vars.REACT_APP_API_ROOT }} + REACT_APP_EXPERIMENT_SLUG: ${{ vars.REACT_APP_EXPERIMENT_SLUG }} + REACT_APP_AML_HOME: ${{ vars.REACT_APP_AML_HOME }} + REACT_APP_HTML_PAGE_TITLE: ${{ vars.REACT_APP_HTML_PAGE_TITLE }} + + # Secrets + AML_SECRET_KEY: ${{ secrets.AML_SECRET_KEY }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SQL_USER: ${{ secrets.SQL_USER }} + SQL_PASSWORD: ${{ secrets.SQL_PASSWORD }} + REACT_APP_SENTRY_DSN: ${{ secrets.REACT_APP_SENTRY_DSN }} + DJANGO_SUPERUSER_USERNAME: ${{ secrets.DJANGO_SUPERUSER_USERNAME }} + DJANGO_SUPERUSER_PASSWORD: ${{ secrets.DJANGO_SUPERUSER_PASSWORD }} + DJANGO_SUPERUSER_EMAIL: ${{ secrets.DJANGO_SUPERUSER_EMAIL }} + + # Prevent podman services from exiting after startup + RUNNER_TRACKING_ID: "" + + steps: + - uses: actions/checkout@v4 + - name: Create .env file + run: | + touch .env + echo "REACT_APP_API_ROOT=$REACT_APP_API_ROOT" >> .env + echo "REACT_APP_EXPERIMENT_SLUG=$REACT_APP_EXPERIMENT_SLUG" >> .env + echo "REACT_APP_AML_HOME=$REACT_APP_AML_HOME" >> .env + echo "REACT_APP_HTML_PAGE_TITLE=$REACT_APP_HTML_PAGE_TITLE" >> .env + echo "REACT_APP_SENTRY_DSN=$REACT_APP_SENTRY_DSN" >> .env + cp .env frontend/.env + - name: Build Podman images + run: podman-compose -f docker-compose-deploy.yml build + - name: Deploy Podman images + run: podman-compose -f docker-compose-deploy.yml up -d --force-recreate + - name: Prune old images + run: podman image prune -a -f + - name: Check Podman images + run: podman-compose -f docker-compose-deploy.yml ps + - name: Check logs + run: podman-compose -f docker-compose-deploy.yml logs + + deploy-acceptance: + name: Deploy to acceptance environment + environment: Acceptance + runs-on: ACC + if: github.ref == 'refs/heads/main' || github.ref == 'refs/tags/*' || 1 == 1 # Temporarily always run during testing + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + env: + + # Variables + AML_ALLOWED_HOSTS: ${{ vars.AML_ALLOWED_HOSTS }} + AML_CORS_ORIGIN_WHITELIST: ${{ vars.AML_CORS_ORIGIN_WHITELIST }} + AML_DEBUG: ${{ vars.AML_DEBUG }} + AML_LOCATION_PROVIDER: ${{ vars.AML_LOCATION_PROVIDER }} + AML_SUBPATH: ${{ vars.AML_SUBPATH }} + DJANGO_SETTINGS_MODULE: ${{ vars.DJANGO_SETTINGS_MODULE }} + SQL_DATABASE: ${{ vars.SQL_DATABASE }} + SQL_HOST: ${{ vars.SQL_HOST }} + SQL_PORT: ${{ vars.SQL_PORT }} + REACT_APP_API_ROOT: ${{ vars.REACT_APP_API_ROOT }} + REACT_APP_EXPERIMENT_SLUG: ${{ vars.REACT_APP_EXPERIMENT_SLUG }} + REACT_APP_AML_HOME: ${{ vars.REACT_APP_AML_HOME }} + REACT_APP_HTML_PAGE_TITLE: ${{ vars.REACT_APP_HTML_PAGE_TITLE }} + + # Secrets + AML_SECRET_KEY: ${{ secrets.AML_SECRET_KEY }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SQL_USER: ${{ secrets.SQL_USER }} + SQL_PASSWORD: ${{ secrets.SQL_PASSWORD }} + REACT_APP_SENTRY_DSN: ${{ secrets.REACT_APP_SENTRY_DSN }} + DJANGO_SUPERUSER_USERNAME: ${{ secrets.DJANGO_SUPERUSER_USERNAME }} + DJANGO_SUPERUSER_PASSWORD: ${{ secrets.DJANGO_SUPERUSER_PASSWORD }} + DJANGO_SUPERUSER_EMAIL: ${{ secrets.DJANGO_SUPERUSER_EMAIL }} + + # Prevent podman services from exiting after startup + RUNNER_TRACKING_ID: "" + + steps: + - uses: actions/checkout@v4 + - name: Create .env file + run: | + touch .env + echo "REACT_APP_API_ROOT=$REACT_APP_API_ROOT" >> .env + echo "REACT_APP_EXPERIMENT_SLUG=$REACT_APP_EXPERIMENT_SLUG" >> .env + echo "REACT_APP_AML_HOME=$REACT_APP_AML_HOME" >> .env + echo "REACT_APP_HTML_PAGE_TITLE=$REACT_APP_HTML_PAGE_TITLE" >> .env + echo "REACT_APP_SENTRY_DSN=$REACT_APP_SENTRY_DSN" >> .env + cp .env frontend/.env + - name: Build Podman images + run: podman-compose -f docker-compose-deploy.yml build + - name: Deploy Podman images + run: podman-compose -f docker-compose-deploy.yml up -d --force-recreate + - name: Prune old images + run: podman image prune -a -f + - name: Check Podman images + run: podman-compose -f docker-compose-deploy.yml ps + - name: Check logs + run: podman-compose -f docker-compose-deploy.yml logs + diff --git a/README.md b/README.md index da2a0bfef..71989f196 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ [![DOI](https://zenodo.org/badge/418963353.svg)](https://zenodo.org/badge/latestdoi/418963353) [![Test](https://github.com/Amsterdam-Music-Lab/MUSCLE/actions/workflows/ci.yml/badge.svg)](https://github.com/Amsterdam-Music-Lab/MUSCLE/actions/workflows/test.yml/badge.svg) ![Backend Code Coverage Percentage](https://raw.githubusercontent.com/Amsterdam-Music-Lab/MUSCLE/code-coverage-badges/backend/coverage-backend-badge.svg) +![Frontend Code Coverage Percentage](https://raw.githubusercontent.com/Amsterdam-Music-Lab/MUSCLE/code-coverage-badges/frontend/coverage-frontend-badge.svg) # MUSic-related Citizen Science Listening Experiments (MUSCLE) This application provides an easy way to implement and run online listening experiments for music research. It presents questions, and typically audio stimuli, to participants, and collects their feedback. @@ -23,11 +24,10 @@ Install [Docker Desktop](https://docs.docker.com/desktop/). ### Linux * Install [Docker Engine](https://docs.docker.com/engine/install/) * Install [Docker Compose](https://docs.docker.com/compose/install/) - -As of April 2022, [Docker Desktop for Linux](https://docs.docker.com/desktop/linux/) is still in Beta and have not been tested by us. +* Install [Docker Desktop](https://docs.docker.com/desktop/install/linux-install/) ## Development build -Make a copy of the file .env.dist (in the same directory as this README) and rename it to .env. This file contains variables used by Docker to start up a container network serving MUSCLE. +Make a copy of [the file](https://github.com/Amsterdam-Music-Lab/MUSCLE/blob/develop/.env.dist) `.env.dist` (in the same directory as this README) and rename it to `.env.` This file contains variables used by Docker to start up a container network serving MUSCLE. Start Docker (the app icon is a whale carrying containers). Then, open a terminal and run `docker-compose up` (add `sudo` on Linux). @@ -46,3 +46,7 @@ To stop the containers, press `ctrl-c` or (in another terminal) run ## Production build A production build should define its own `docker-compose.yaml`, making use of the `Dockerfile` of the `backend` and `frontend` environments. It should also define a custom .env file, with safe passwords for the SQL database and the Python backend. Instead of mounting the entire backend and frontend directory and using the development servers, the backend should serve with gunicorn, and the frontend should use a build script to compile static html, css and JavaScript. + +## Troubleshooting + +Please refer to the [wiki](https://github.com/Amsterdam-Music-Lab/MUSCLE/wiki/X.-Troubleshooting) a checklist of common issues and their solutions. diff --git a/backend/.flake8 b/backend/.flake8 new file mode 100644 index 000000000..0605143d0 --- /dev/null +++ b/backend/.flake8 @@ -0,0 +1,46 @@ +[flake8] +# Flake8 Configuration File + +# High Priority: Security and Correctness +# These issues are critical and should be addressed first. +extend-ignore = + E722, # Do not use bare 'except' + F722, # Syntax error identified by pyflakes + F821, # Undefined name + +# Medium Priority: Code Maintainability and Readability +# Improving these can greatly enhance code readability and maintainability. + E501, # Line too long + F401, # Unused import + F403, # 'from module import *' used; unable to detect undefined names + F405, # Name may be undefined, or defined from star imports + F811, # Redefinition of unused name from line N + F841, # Local variable name is assigned to but never used + +# Low Priority: Style Guide Adherence +# These are mostly about whitespace and indentation, which can be adjusted later. + E201, # Whitespace after '(' + E202, # Whitespace before ')' + E203, # Whitespace before ':' + E222, # Multiple spaces after operator + E225, # Missing whitespace around operator + E231, # Missing whitespace after ',' + E251, # Unexpected spaces around keyword / parameter equals + E262, # Inline comment should start with '# ' + E122, # Continuation line missing indentation or outdented + E124, # Closing bracket does not match visual indentation + E125, # Continuation line with same indent as next logical line + E127, # Continuation line over-indented for visual indent + E128, # Continuation line under-indented for visual indent + E131, # Continuation line unaligned for hanging indent + E261, # At least two spaces before inline comment + W191, # Indentation contains tabs + W291, # Trailing whitespace + W292, # No newline at end of file + W293, # Blank line contains whitespace + W391, # Blank line at end of file + W503, # Line break occurred before a binary operator + +# General Configuration +max-line-length = 120 +exclude = .venv, .git, __pycache__, build, dist diff --git a/backend/Dockerfile b/backend/Dockerfile index 4eda53084..3203cdf09 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.8 +FROM docker.io/python:3.8 ENV PYTHONUNBUFFERED 1 RUN apt-get -y update RUN apt-get install -y ffmpeg @@ -7,6 +7,6 @@ RUN apt-get install -y gettext WORKDIR /server COPY requirements/prod.txt /server/ RUN pip install -r prod.txt -# We add remainig code later, so pip install won't need to rerun if source code changes -COPY . /server/ +# We add remainig code later, so pip install won't need to rerun if source code changes +COPY . /server/ \ No newline at end of file diff --git a/backend/DockerfileDevelop b/backend/DockerfileDevelop index bcecba141..139171da6 100644 --- a/backend/DockerfileDevelop +++ b/backend/DockerfileDevelop @@ -1,9 +1,8 @@ -FROM python:3.8 +FROM docker.io/python:3.8 as base ENV PYTHONUNBUFFERED 1 RUN apt-get -y update RUN apt-get install -y ffmpeg WORKDIR /server COPY requirements/dev.txt /server/ -RUN pip install -r dev.txt - +RUN pip install -r dev.txt \ No newline at end of file diff --git a/backend/aml/base_settings.py b/backend/aml/base_settings.py index 911894c8c..691862cda 100644 --- a/backend/aml/base_settings.py +++ b/backend/aml/base_settings.py @@ -11,9 +11,12 @@ """ import os +import logging from corsheaders.defaults import default_headers import sentry_sdk +logger = logging.getLogger(__name__) + # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -40,6 +43,7 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'inline_actions', + 'django_markup', 'corsheaders', 'experiment', 'participant', @@ -165,14 +169,24 @@ DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' -# Sentry -sentry_sdk.init( - dsn=os.getenv("SENTRY_DSN", ""), - # Set traces_sample_rate to 1.0 to capture 100% - # of transactions for performance monitoring. - traces_sample_rate=0.2, - # Set profiles_sample_rate to 1.0 to profile 100% - # of sampled transactions. - # We recommend adjusting this value in production. - profiles_sample_rate=0.2, -) +if os.getenv("SENTRY_DSN"): + sentry_sdk.init( + dsn=os.getenv("SENTRY_DSN", ""), + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for performance monitoring. + traces_sample_rate=0.2, + # Set profiles_sample_rate to 1.0 to profile 100% + # of sampled transactions. + # We recommend adjusting this value in production. + profiles_sample_rate=0.2, + ) +else: + logger.info("SENTRY_DSN is not defined. Skipping Sentry initialization.") + +MARKUP_SETTINGS = { + 'markdown': { + 'safe_mode': True + } +} + +SUBPATH = os.getenv('AML_SUBPATH', None) diff --git a/backend/aml/production_settings.py b/backend/aml/production_settings.py index 24bb4dfa4..f9ac05ee8 100644 --- a/backend/aml/production_settings.py +++ b/backend/aml/production_settings.py @@ -6,6 +6,8 @@ # Database # https://docs.djangoproject.com/en/3.0/ref/settings/#databases +# Static url is set to /django_static/ in the nginx configuration +# to avoid conflicts with the frontend's static files in /static/ STATIC_URL = '/django_static/' DATABASES = { diff --git a/backend/aml/urls.py b/backend/aml/urls.py index 3188931e7..9444450fc 100644 --- a/backend/aml/urls.py +++ b/backend/aml/urls.py @@ -40,6 +40,11 @@ # ^ The static helper function only works in debug mode # (https://docs.djangoproject.com/en/3.0/howto/static-files/) + +# Prefix all URLS with /server if AML_SUBPATH is set +if settings.SUBPATH: + urlpatterns = [path('server/', include(urlpatterns))] + # Debug toolbar if settings.DEBUG: import debug_toolbar diff --git a/backend/experiment/actions/__init__.py b/backend/experiment/actions/__init__.py index 74879c6d5..907198157 100644 --- a/backend/experiment/actions/__init__.py +++ b/backend/experiment/actions/__init__.py @@ -2,12 +2,12 @@ from .explainer import Explainer, Step from .form import * from .final import Final +from .frontend_style import EFrontendStyle, FrontendStyle from .html import HTML from .info import Info from .playback import Playback from .playlist import Playlist from .redirect import Redirect from .score import Score -from .start_session import StartSession from .toontjehoger import ToontjeHoger from .trial import Trial diff --git a/backend/experiment/actions/base_action.py b/backend/experiment/actions/base_action.py index c1c91ae77..152bc0f52 100644 --- a/backend/experiment/actions/base_action.py +++ b/backend/experiment/actions/base_action.py @@ -1,9 +1,19 @@ +from .frontend_style import FrontendStyle + + class BaseAction(object): ID = 'BASE' + style = None - def __init__(self): + def __init__(self, style: FrontendStyle = None): + self.style = style pass def action(self): - self.view = self.ID - return self.__dict__ + action_dict = self.__dict__ + action_dict['view'] = self.ID + + if self.style is not None: + action_dict['style'] = self.style.to_dict() + + return action_dict diff --git a/backend/experiment/actions/consent.py b/backend/experiment/actions/consent.py index 97bd629e8..ce0ec228f 100644 --- a/backend/experiment/actions/consent.py +++ b/backend/experiment/actions/consent.py @@ -1,10 +1,32 @@ from .base_action import BaseAction +from django.template.loader import render_to_string +from django.template import Template, Context +from django_markup.markup import formatter + + +def get_render_format(url): + """ + Detect markdown file by file extention + """ + url_length = len(url) + if url[(url_length-2):url_length].lower() == 'md': + return 'MARKDOWN' + return 'HTML' + class Consent(BaseAction): # pylint: disable=too-few-public-methods """ Provide data for a view that ask consent for using the experiment data + - text: Uploaded file via experiment.consent (fileField) + - title: The title to be displayed + - confirm: The text on the confirm button + - deny: The text on the deny button + - url: If no text is provided the url will be used to load a template (HTML or MARKDOWN) + - render_format: (autodetected from the file extention) + 'HTML': (default) Allowed tags: html, django template language + 'MARKDOWN': Allowed tags: Markdown language - Relates to client component: Consent.js + Relates to client component: Consent.js """ # default consent text, that can be used for multiple experiments @@ -21,8 +43,28 @@ class Consent(BaseAction): # pylint: disable=too-few-public-methods amet, nec te atqui scribentur. Diam molestie posidonium te sit, \ ea sea expetenda suscipiantur contentiones." - def __init__(self, text=default_text, title='Informed consent', confirm='I agree', deny='Stop'): - self.text = text + def __init__(self, text, title='Informed consent', confirm='I agree', deny='Stop', url='', render_format='HTML'): + # Determine which text to use + if text!='': + # Uploaded consent via file field: experiment.consent (High priority) + with text.open('r') as f: + dry_text = f.read() + render_format = get_render_format(text.url) + elif url!='': + # Template file via url (Low priority) + dry_text = render_to_string(url) + render_format = get_render_format(url) + else: + # use default text + dry_text = self.default_text + # render text fot the consent component + if render_format == 'HTML': + template = Template(dry_text) + context = Context() + self.text = template.render(context) + if render_format == 'MARKDOWN': + self.text = formatter(dry_text, filter_name='markdown') self.title = title self.confirm = confirm self.deny = deny + self.render_format = render_format diff --git a/backend/experiment/actions/explainer.py b/backend/experiment/actions/explainer.py index adb5a4c2e..d605dc4c0 100644 --- a/backend/experiment/actions/explainer.py +++ b/backend/experiment/actions/explainer.py @@ -1,5 +1,6 @@ from .base_action import BaseAction + class Explainer(BaseAction): """ Provide data for a explainer that explains the experiment steps diff --git a/backend/experiment/actions/final.py b/backend/experiment/actions/final.py index 7f5c0502a..514d900c6 100644 --- a/backend/experiment/actions/final.py +++ b/backend/experiment/actions/final.py @@ -2,6 +2,7 @@ from .base_action import BaseAction + class Final(BaseAction): # pylint: disable=too-few-public-methods """ Provide data for a final view diff --git a/backend/experiment/actions/form.py b/backend/experiment/actions/form.py index 6dd7257de..a1826d08a 100644 --- a/backend/experiment/actions/form.py +++ b/backend/experiment/actions/form.py @@ -4,6 +4,7 @@ from .styles import STYLE_NEUTRAL, STYLE_BOOLEAN_NEGATIVE_FIRST, STYLE_GRADIENT_7 from .base_action import BaseAction + class Question(BaseAction): ''' Question is part of a form. - key: description of question in results table @@ -45,6 +46,7 @@ def action(self): self.expected_response = result.expected_response return self.__dict__ + class NumberQuestion(Question): def __init__(self, input_type='number', min_value=0, max_value=120, **kwargs): super().__init__(**kwargs) @@ -53,6 +55,7 @@ def __init__(self, input_type='number', min_value=0, max_value=120, **kwargs): self.input_type = input_type self.view = 'STRING' + class TextQuestion(Question): def __init__(self, input_type='text', max_length=None, **kwargs): super().__init__(**kwargs) @@ -60,6 +63,7 @@ def __init__(self, input_type='text', max_length=None, **kwargs): self.input_type = input_type self.view = 'STRING' + class BooleanQuestion(Question): def __init__(self, choices=None, **kwargs): super().__init__(**kwargs) @@ -70,6 +74,7 @@ def __init__(self, choices=None, **kwargs): self.view = 'BUTTON_ARRAY' self.style = {STYLE_BOOLEAN_NEGATIVE_FIRST: True, 'buttons-large-gap': True} + class ChoiceQuestion(Question): def __init__(self, choices, min_values=1, **kwargs): super().__init__(**kwargs) @@ -143,6 +148,7 @@ def __init__(self, scale_steps=7, explainer=_("How much do you agree or disagree 5: _("Strongly Agree"), } + class LikertQuestionIcon(Question): def __init__(self, scale_steps=7, likert_view='ICON_RANGE', **kwargs): super().__init__(**kwargs) @@ -159,6 +165,7 @@ def __init__(self, scale_steps=7, likert_view='ICON_RANGE', **kwargs): } self.style = STYLE_GRADIENT_7 + class Form(BaseAction): ''' Form is a view which brings together an array of questions with submit and optional skip button - form: array of questions diff --git a/backend/experiment/actions/frontend_style.py b/backend/experiment/actions/frontend_style.py new file mode 100644 index 000000000..7e9130893 --- /dev/null +++ b/backend/experiment/actions/frontend_style.py @@ -0,0 +1,66 @@ +from enum import Enum + + +class EFrontendStyle(Enum): + EMPTY = '' + BOOLEAN = 'boolean' + BOOLEAN_NEGATIVE_FIRST = 'boolean-negative-first' + NEUTRAL = 'neutral' + NEUTRAL_INVERTED = 'neutral-inverted' + PRIMARY = 'primary' + SECONDARY = 'secondary' + SUCCESS = 'success' + NEGATIVE = 'negative' + INFO = 'info' + WARNING = 'warning' + + @staticmethod + def is_valid(value): + return value in EFrontendStyle.__members__.values() + + +class FrontendStyle: + + VALID_STYLES = EFrontendStyle.__members__.values() + + """ + Initialize the FrontendStyle with a root style. + :param root_style: The style name for the root element. + """ + def __init__(self, root_style: EFrontendStyle = EFrontendStyle.EMPTY): + + if not EFrontendStyle.is_valid(root_style): + raise ValueError(f"Invalid root style: {root_style}") + + self.styles = {'root': root_style} + + def get_style(self, element: str) -> str: + """ + Get the style for a specific element. + :param element: The element identifier for which to get the style. + :return: The style name for the given element. + """ + return self.styles.get(element, None) + + def apply_style(self, element: str, style: str) -> None: + """ + Apply a specific style to an element after validating the style. + :param element: The element identifier to apply the style to. + :param style: The style name to apply. + """ + if EFrontendStyle.is_valid(style): + self.styles[element] = style + else: + valid_styles = ', '.join([str(s) for s in self.VALID_STYLES]) + raise ValueError(f"Invalid style: {style}. Valid styles are {valid_styles}.") + + def to_dict(self) -> dict: + serialized_styles = { 'root': self.styles['root'].value } + + return serialized_styles + + def __str__(self): + return str(self.to_dict()) + + def __json__(self): + return self.to_dict() diff --git a/backend/experiment/actions/info.py b/backend/experiment/actions/info.py index 1084d7034..c0aecef6b 100644 --- a/backend/experiment/actions/info.py +++ b/backend/experiment/actions/info.py @@ -1,5 +1,6 @@ from .base_action import BaseAction + class Info(BaseAction): # pylint: disable=too-few-public-methods """ Provide data for a view that shows information (HTML) diff --git a/backend/experiment/actions/playback.py b/backend/experiment/actions/playback.py index af2f3c56d..7d3453473 100644 --- a/backend/experiment/actions/playback.py +++ b/backend/experiment/actions/playback.py @@ -1,52 +1,154 @@ +from typing import List, Dict + +from .frontend_style import FrontendStyle from .base_action import BaseAction +# player types +TYPE_AUTOPLAY = 'AUTOPLAY' +TYPE_BUTTON = 'BUTTON' +TYPE_IMAGE = 'IMAGE' +TYPE_MULTIPLAYER = 'MULTIPLAYER' +TYPE_MATCHINGPAIRS = 'MATCHINGPAIRS' +TYPE_VISUALMATCHINGPAIRS = 'VISUALMATCHINGPAIRS' + +# playback methods +PLAY_EXTERNAL = 'EXTERNAL' +PLAY_HTML = 'HTML' +PLAY_BUFFER = 'BUFFER' +PLAY_NOAUDIO = 'NOAUDIO' + + class Playback(BaseAction): - ''' A playback wrapper for different kinds of players - - player_type: can be one of the following: - - 'AUTOPLAY' - player starts automatically - - 'BUTTON' - display one play button - - 'MULTIPLAYER' - display multiple small play buttons, one per section - - 'SPECTROGRAM' - extends multiplayer with a list of spectrograms + ''' A playback base class for different kinds of players - sections: a list of sections (in many cases, will only contain *one* section) - preload_message: text to display during preload - instruction: text to display during presentation of the sound - - play_config: define to override the following values: - - play_method: - - 'BUFFER': Use webaudio buffers. (recommended for stimuli up to 45s) - - 'HTML': Use the HTML tag. (recommended for stimuli longer than 45s) - - 'EXTERNAL': Use for externally hosted audio files. Web-audio api will be disabled - - ready_time: time before presentation of sound - - timeout_after_playback: pause in ms after playback has finished - - playhead: from where the audio file should play (offset in seconds from start) - - mute: whether audio should be muted - - auto_play: whether sound will start automatically - - stop_audio_after: after how many seconds playback audio should be stopped - - show_animation: whether to show an animation during playback - - (multiplayer) label_style: player index number style: NUMERIC, ALPHABETIC, ROMAN or empty (no label) - - play_once: the sound can only be played once - - resume_play: if the playback should resume from where a previous view left off - ''' - - TYPE_AUTOPLAY = 'AUTOPLAY' - TYPE_BUTTON = 'BUTTON' - TYPE_MULTIPLAYER = 'MULTIPLAYER' - TYPE_SPECTROGRAM = 'SPECTROGRAM' - - def __init__(self, sections, player_type='AUTOPLAY', preload_message='', instruction='', play_config=None): + - play_from: where in the audio file to start playing/ + - ready_time: how long to show the "Preload" view (loading spinner) + - show_animation: whether to show animations with this player + - mute: whether to mute the audio + - timeout_after_playback: once playback has finished, add optional timeout (in seconds) before proceeding + - stop_audio_after: stop playback after so many seconds + - resume_play: if the playback should resume from where a previous view left off + ''' + + def __init__(self, + sections, + preload_message='', + instruction='', + play_from=0, + ready_time=0, + show_animation=False, + mute=False, + timeout_after_playback=None, + stop_audio_after=None, + resume_play=False, + style=FrontendStyle() + ): self.sections = [{'id': s.id, 'url': s.absolute_url(), 'group': s.group} for s in sections] - self.ID = player_type + if str(sections[0].filename).startswith('http'): + self.play_method = PLAY_EXTERNAL + elif sections[0].duration > 45: + self.play_method = PLAY_HTML + else: + self.play_method = PLAY_BUFFER + self.show_animation = show_animation self.preload_message = preload_message self.instruction = instruction - self.play_config = { - 'play_method': 'BUFFER', - 'external_audio': False, - 'ready_time': 0, - 'playhead': 0, - 'show_animation': False, - 'mute': False, - 'play_once': False, - 'resume_play': False - } - if play_config: - self.play_config.update(play_config) + self.play_from = play_from + self.mute = mute + self.ready_time = ready_time + self.timeout_after_playback = timeout_after_playback + self.stop_audio_after = stop_audio_after + self.resume_play = resume_play + self.style = style + + +class Autoplay(Playback): + ''' + This player starts playing automatically + - show_animation: if True, show a countdown and moving histogram + ''' + + def __init__(self, sections, **kwargs): + super().__init__(sections, **kwargs) + self.ID = TYPE_AUTOPLAY + + +class PlayButton(Playback): + ''' + This player shows a button, which triggers playback + - play_once: if True, button will be disabled after one play + ''' + + def __init__(self, sections, play_once=False, **kwargs): + super().__init__(sections, **kwargs) + self.ID = TYPE_BUTTON + self.play_once = play_once + + +class Multiplayer(PlayButton): + ''' + This is a player with multiple play buttons + - stop_audio_after: after how many seconds to stop audio + - label_style: set if players should be labeled in alphabetic / numeric / roman style (based on player index) + - labels: pass list of strings if players should have custom labels + ''' + + def __init__(self, sections, stop_audio_after=5, labels=[], **kwargs): + super().__init__(sections, **kwargs) + self.ID = TYPE_MULTIPLAYER + self.stop_audio_after = stop_audio_after + if labels: + if len(labels) != len(self.sections): + raise UserWarning( + 'Number of labels and sections for the play buttons do not match') + self.labels = labels + + +class ImagePlayer(PlayButton): + ''' + This is a special case of the Multiplayer: + it shows an image next to each play button + ''' + + def __init__(self, sections, images, image_labels=[], **kwargs): + super().__init__(sections, **kwargs) + self.ID = TYPE_IMAGE + if len(images) != len(self.sections): + raise UserWarning( + 'Number of images and sections for the ImagePlayer do not match') + self.images = images + if image_labels: + if len(image_labels) != len(self.sections): + raise UserWarning( + 'Number of image labels and sections do not match') + self.image_labels = image_labels + + +class MatchingPairs(Multiplayer): + ''' + This is a special case of multiplayer: + play buttons are represented as cards + - sections: a list of sections (in many cases, will only contain *one* section) + - score_feedback_display: how to display the score feedback (large-top, small-bottom-right, hidden) + ''' + + def __init__(self, sections: List[Dict], score_feedback_display: str = 'large-top', **kwargs): + super().__init__(sections, **kwargs) + self.ID = TYPE_MATCHINGPAIRS + self.score_feedback_display = score_feedback_display + + +class VisualMatchingPairs(MatchingPairs): + ''' + This is a special case of multiplayer: + play buttons are represented as cards + this player does not play audio, but displays images instead + ''' + + def __init__(self, sections, **kwargs): + super().__init__(sections, **kwargs) + self.ID = TYPE_VISUALMATCHINGPAIRS + self.play_method = PLAY_NOAUDIO diff --git a/backend/experiment/actions/playlist.py b/backend/experiment/actions/playlist.py index a4fabfdae..32ae61e23 100644 --- a/backend/experiment/actions/playlist.py +++ b/backend/experiment/actions/playlist.py @@ -2,6 +2,7 @@ from .base_action import BaseAction + class Playlist(BaseAction): # pylint: disable=too-few-public-methods """ Provide data for playlist selection view diff --git a/backend/experiment/actions/redirect.py b/backend/experiment/actions/redirect.py index 2860093ca..ec0d050a6 100644 --- a/backend/experiment/actions/redirect.py +++ b/backend/experiment/actions/redirect.py @@ -1,5 +1,6 @@ from .base_action import BaseAction + class Redirect(BaseAction): ID = 'REDIRECT' diff --git a/backend/experiment/actions/score.py b/backend/experiment/actions/score.py index 7888d5fca..943b0abf9 100644 --- a/backend/experiment/actions/score.py +++ b/backend/experiment/actions/score.py @@ -4,11 +4,12 @@ from .base_action import BaseAction + class Score(BaseAction): # pylint: disable=too-few-public-methods """ Provide data for an intermediate score view - Relates to client component: Score.js + Relates to client component: Score.js """ ID = 'SCORE' @@ -57,7 +58,7 @@ def action(self): 'feedback': self.feedback, 'icon': self.icon, 'timer': self.timer - } + } if self.config['show_section']: action['last_song'] = self.session.last_song() if self.config['show_total_score']: @@ -66,9 +67,9 @@ def action(self): def default_score_message(self, score): """Fallback to generate a message for the given score""" - + # None - if score == None: + if score is None: score = 0 # Zero if score == 0: diff --git a/backend/experiment/actions/start_session.py b/backend/experiment/actions/start_session.py deleted file mode 100644 index 7e681983e..000000000 --- a/backend/experiment/actions/start_session.py +++ /dev/null @@ -1,13 +0,0 @@ -from .base_action import BaseAction - -class StartSession(BaseAction): # pylint: disable=too-few-public-methods - """ - Provide data for a StartSession view - - This is a required view in each experiment that handles the creation of a new session - - It should only be called after consent has been given by the participant - - As this view is more a technical step/necessity it doesn't have any additional data beside the view id - - Relates to client component: StartSession.js - """ - - ID = "START_SESSION" \ No newline at end of file diff --git a/backend/experiment/actions/toontjehoger.py b/backend/experiment/actions/toontjehoger.py index b9c2b9129..f19f14327 100644 --- a/backend/experiment/actions/toontjehoger.py +++ b/backend/experiment/actions/toontjehoger.py @@ -1,5 +1,6 @@ from .base_action import BaseAction + class ToontjeHoger(BaseAction): # pylint: disable=too-few-public-methods """ Provide data for a view that shows the ToontjeHoger homepage diff --git a/backend/experiment/actions/trial.py b/backend/experiment/actions/trial.py index fbd379931..6b2d526e4 100644 --- a/backend/experiment/actions/trial.py +++ b/backend/experiment/actions/trial.py @@ -2,6 +2,8 @@ from .base_action import BaseAction from experiment.actions.form import Form +from .frontend_style import FrontendStyle + class Trial(BaseAction): # pylint: disable=too-few-public-methods """ @@ -25,7 +27,7 @@ def __init__( title='', config = None, result_id = None, - style = None + style = FrontendStyle() ): ''' - playback: Playback object (may be None) @@ -61,7 +63,6 @@ def __init__( self.config.update(config) self.style = style - def action(self): """ Serialize data for experiment action @@ -73,8 +74,9 @@ def action(self): 'title': self.title, 'config': self.config, 'result_id': self.result_id, - 'style': self.style } + if self.style: + action['style'] = self.style.to_dict() if self.playback: action['playback'] = self.playback.action() if self.html: diff --git a/backend/experiment/actions/utils.py b/backend/experiment/actions/utils.py index bf5af61a7..ab80d6eff 100644 --- a/backend/experiment/actions/utils.py +++ b/backend/experiment/actions/utils.py @@ -7,6 +7,7 @@ from experiment.actions import Final + def final_action_with_optional_button(session, final_text, request_session): """ given a session, a score message and an optional session dictionary from an experiment series, return a Final.action, which has a button to continue to the next experiment if series is defined @@ -34,6 +35,7 @@ def final_action_with_optional_button(session, final_text, request_session): final_text=final_text, ) + def render_feedback_trivia(feedback, trivia): ''' Given two texts of feedback and trivia, render them in the final/feedback_trivia.html template.''' @@ -41,6 +43,7 @@ def render_feedback_trivia(feedback, trivia): return render_to_string(join('final', 'feedback_trivia.html'), context) + def get_average_difference(session, num_turnpoints, initial_value): """ return the average difference in milliseconds participants could hear @@ -56,6 +59,7 @@ def get_average_difference(session, num_turnpoints, initial_value): return initial_value return (sum([int(result.section.song.name) for result in last_turnpoints]) / last_turnpoints.count()) + def get_average_difference_level_based(session, num_turnpoints, initial_value): """ calculate the difference based on exponential decay, starting from an initial_value """ @@ -72,6 +76,7 @@ def get_average_difference_level_based(session, num_turnpoints, initial_value): # Difference by level starts at initial value (which is level 1, so 20/(2^0)) and then halves for every next level return sum([initial_value / (2 ** (int(result.section.song.name.split('_')[-1]) - 1)) for result in last_turnpoints]) / last_turnpoints.count() + def get_fallback_result(session): """ if there were no turnpoints (outliers): return the last result, or if there are no results, return None @@ -81,6 +86,7 @@ def get_fallback_result(session): return None return session.result_set.order_by('-created_at')[0] + def get_last_n_turnpoints(session, num_turnpoints): """ select all results associated with turnpoints in the result set @@ -90,5 +96,6 @@ def get_last_n_turnpoints(session, num_turnpoints): cutoff = min(all_results.count(), num_turnpoints) return all_results[:cutoff] + def randomize_playhead(min_jitter, max_jitter, silence_time, continuation_correctness): return silence_time + (random.uniform(min_jitter, max_jitter) if not continuation_correctness else 0) diff --git a/backend/experiment/actions/wrappers.py b/backend/experiment/actions/wrappers.py index 934ad5cdb..4c03d81c4 100644 --- a/backend/experiment/actions/wrappers.py +++ b/backend/experiment/actions/wrappers.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext as _ from .form import BooleanQuestion, ChoiceQuestion, Form -from .playback import Playback +from .playback import Autoplay, PlayButton from .trial import Trial from result.utils import prepare_result @@ -17,9 +17,8 @@ def two_alternative_forced(session, section, choices, expected_response=None, st Provide data for a Two Alternative Forced view that (auto)plays a section, shows a question and has two customizable buttons """ - playback = Playback( - [section], - 'BUTTON' + playback = PlayButton( + [section] ) key = 'choice' button_style = {'invisible-text': True, @@ -46,8 +45,9 @@ def two_alternative_forced(session, section, choices, expected_response=None, st return trial -def song_sync(session, section, title, play_method='BUFFER', - recognition_time=15, sync_time=15, min_jitter=10, max_jitter=15): +def song_sync(session, section, title, + recognition_time=15, sync_time=15, + min_jitter=10, max_jitter=15): trial_config = { 'response_time': recognition_time, 'auto_advance': True @@ -59,31 +59,25 @@ def song_sync(session, section, title, play_method='BUFFER', 'recognize', session, section=section, scoring_rule='SONG_SYNC_RECOGNITION'), submits=True )]), - playback=Playback([section], 'AUTOPLAY', play_config={ - 'ready_time': 3, - 'show_animation': True, - 'play_method': play_method - }, - preload_message=_('Get ready!'), - instruction=_('Do you recognize the song?'), - ), + playback=Autoplay([section], show_animation=True, + ready_time=3, + preload_message=_('Get ready!'), + instruction=_('Do you recognize the song?'), + ), config={**trial_config, 'break_round_on': {'EQUALS': ['TIMEOUT', 'no']}}, title=title ) silence_time = 4 silence = Trial( - playback=Playback([section], 'AUTOPLAY', + playback=Autoplay([section], + show_animation=True, instruction=_('Keep imagining the music'), - play_config={ - 'mute': True, - 'ready_time': 0, - 'show_animation': True, - }), + mute=True), config={ 'response_time': silence_time, 'auto_advance': True, - 'show_continue_button': False + 'show_continue_button': False, }, title=title ) @@ -99,15 +93,13 @@ def song_sync(session, section, title, play_method='BUFFER', scoring_rule='SONG_SYNC_CONTINUATION', expected_response='yes' if continuation_correctness else 'no') )]), - playback=Playback([section], 'AUTOPLAY', + playback=Autoplay([section], instruction=_( 'Did the track come back in the right place?'), - play_config={ - 'ready_time': 0, - 'playhead': randomize_playhead(min_jitter, max_jitter, silence_time, continuation_correctness), - 'show_animation': True, - 'resume_play': True - }), + show_animation=True, + play_from=randomize_playhead( + min_jitter, max_jitter, silence_time, continuation_correctness), + resume_play=True), config=trial_config, title=title ) diff --git a/backend/experiment/admin.py b/backend/experiment/admin.py index fca9d6f32..493f4924a 100644 --- a/backend/experiment/admin.py +++ b/backend/experiment/admin.py @@ -17,6 +17,7 @@ from result.models import Result from participant.models import Participant + class FeedbackInline(admin.TabularInline): """Inline to show results linked to given participant """ @@ -25,6 +26,7 @@ class FeedbackInline(admin.TabularInline): fields = ['text'] extra = 0 + class ExperimentAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): list_display = ('name', 'rules', 'rounds', 'playlist_count', 'session_count', 'active') @@ -32,7 +34,7 @@ class ExperimentAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): search_fields = ['name'] inline_actions = ['export', 'export_csv'] fields = ['name', 'slug', 'url', 'hashtag', 'language', 'active', 'rules', - 'rounds', 'bonus_points', 'playlists', 'experiment_series','questions'] + 'rounds', 'bonus_points', 'playlists', 'experiment_series','consent', 'questions'] inlines = [FeedbackInline] form = ExperimentForm @@ -138,13 +140,16 @@ def export_csv(self, request, obj, parent_obj=None): export_csv.short_description = "Export CSV" + admin.site.register(Experiment, ExperimentAdmin) + class ModelFormFieldAsJSON(ModelMultipleChoiceField): """ override clean method to prevent pk lookup to save querysets """ def clean(self, value): return value + class ExperimentSeriesForm(ModelForm): def __init__(self, *args, **kwargs): super(ModelForm, self).__init__(*args, **kwargs) @@ -158,8 +163,10 @@ class Meta: model = ExperimentSeries fields = ['name', 'first_experiments', 'random_experiments', 'last_experiments'] + class ExperimentSeriesAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin): fields = ['name', 'first_experiments', 'random_experiments', 'last_experiments'] form = ExperimentSeriesForm + admin.site.register(ExperimentSeries, ExperimentSeriesAdmin) diff --git a/backend/experiment/forms.py b/backend/experiment/forms.py index 8e9c6fbd0..38ea4813a 100644 --- a/backend/experiment/forms.py +++ b/backend/experiment/forms.py @@ -13,7 +13,7 @@ ('participant_access_info', 'Participant access info'), ('session_start', 'Session start time'), ('session_end', 'Session end time'), - ('final_score', 'Final score') + ('final_score', 'Final score'), ] # result_keys for Export CSV @@ -22,7 +22,8 @@ ('result_score', 'Result score'), ('result_comment', 'Result comment'), ('expected_response', 'Expected response'), - ('given_response', 'Given response') + ('given_response', 'Given response'), + ('question_key', 'Question key'), ] # export_options for Export CSV @@ -144,6 +145,10 @@ class Meta: model = Experiment fields = ['name', 'slug', 'active', 'rules', 'rounds', 'bonus_points', 'playlists', 'experiment_series'] + help_texts = {'consent': 'Upload an HTML (.html) or MARKDOWN (.md) file with a text to ask a user its consenttest
{% endblocktranslate %}', content_type='text/html') + ) + + def test_markdown_rendering(self): + consent = Consent('', url='dev/consent_mock.md') + self.assertEqual(consent.render_format, 'MARKDOWN') + self.assertEqual(consent.text, 'test
') diff --git a/backend/experiment/tests/test_actions_form.py b/backend/experiment/tests/test_actions_form.py new file mode 100644 index 000000000..c10a07676 --- /dev/null +++ b/backend/experiment/tests/test_actions_form.py @@ -0,0 +1,342 @@ +from django.test import TestCase + +from experiment.actions.form import BooleanQuestion, ChoiceQuestion, Form, NumberQuestion, TextQuestion, DropdownQuestion, AutoCompleteQuestion, RadiosQuestion, ButtonArrayQuestion, RangeQuestion, LikertQuestion, LikertQuestionIcon + + +class FormTest(TestCase): + def setUp(self): + self.questions = [NumberQuestion(key='test_key', min_value=1, max_value=10)] + self.form = Form(form=self.questions, submit_label='Submit', skip_label='Skip', is_skippable=True) + + def test_initialization(self): + self.assertEqual(len(self.form.form), 1) + self.assertEqual(self.form.submit_label, 'Submit') + self.assertEqual(self.form.skip_label, 'Skip') + self.assertTrue(self.form.is_skippable) + + def test_action_method(self): + action_result = self.form.action() + self.assertIn('form', action_result) + self.assertEqual(len(action_result['form']), 1) + self.assertIn('submit_label', action_result) + self.assertIn('skip_label', action_result) + self.assertIn('is_skippable', action_result) + + +class NumberQuestionTest(TestCase): + def setUp(self): + self.number_question = NumberQuestion( + key='test_key', + min_value=1, + max_value=10, + input_type='number' + ) + + def test_initialization(self): + self.assertEqual(self.number_question.key, 'test_key') + self.assertEqual(self.number_question.min_value, 1) + self.assertEqual(self.number_question.max_value, 10) + self.assertEqual(self.number_question.input_type, 'number') + + def test_action_method(self): + action_result = self.number_question.action() + self.assertIn('key', action_result) + self.assertIn('min_value', action_result) + self.assertIn('max_value', action_result) + self.assertEqual(action_result['min_value'], 1) + self.assertEqual(action_result['max_value'], 10) + + +class TextQuestionTest(TestCase): + def setUp(self): + self.text_question = TextQuestion( + key='test_key', + max_length=100, + input_type='text' + ) + + def test_initialization(self): + self.assertEqual(self.text_question.key, 'test_key') + self.assertEqual(self.text_question.max_length, 100) + self.assertEqual(self.text_question.input_type, 'text') + + def test_action_method(self): + action_result = self.text_question.action() + self.assertIn('key', action_result) + self.assertIn('max_length', action_result) + self.assertEqual(action_result['max_length'], 100) + + +class BooleanQuestionTest(TestCase): + def setUp(self): + self.boolean_question = BooleanQuestion( + key='test_key', + choices={ + 'no': 'No', + 'yes': 'Yes' + } + ) + + def test_initialization(self): + self.assertEqual(self.boolean_question.key, 'test_key') + self.assertEqual(self.boolean_question.choices, {'no': 'No', 'yes': 'Yes'}) + + def test_action_method(self): + action_result = self.boolean_question.action() + self.assertIn('key', action_result) + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], {'no': 'No', 'yes': 'Yes'}) + + +class ChoiceQuestionTest(TestCase): + def setUp(self): + self.choice_question = ChoiceQuestion( + key='test_key', + choices={ + 'no': 'No', + 'yes': 'Yes' + }, + min_values=1 + ) + + def test_initialization(self): + self.assertEqual(self.choice_question.key, 'test_key') + self.assertEqual(self.choice_question.choices, {'no': 'No', 'yes': 'Yes'}) + self.assertEqual(self.choice_question.min_values, 1) + + def test_action_method(self): + action_result = self.choice_question.action() + self.assertIn('key', action_result) + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], {'no': 'No', 'yes': 'Yes'}) + self.assertIn('min_values', action_result) + self.assertEqual(action_result['min_values'], 1) + + +class DropdownQuestionTest(TestCase): + def setUp(self): + self.dropdown_question = DropdownQuestion( + key='test_key', + choices={ + 'no': 'No', + 'yes': 'Yes' + }, + ) + + def test_initialization(self): + self.assertEqual(self.dropdown_question.key, 'test_key') + self.assertEqual(self.dropdown_question.choices, {'no': 'No', 'yes': 'Yes'}) + + def test_action_method(self): + action_result = self.dropdown_question.action() + self.assertIn('key', action_result) + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], {'no': 'No', 'yes': 'Yes'}) + + +class AutoCompleteQuestionTest(TestCase): + def setUp(self): + self.autocomplete_question = AutoCompleteQuestion( + key='test_key', + choices={ + 'no': 'No', + 'yes': 'Yes' + }, + ) + + def test_initialization(self): + self.assertEqual(self.autocomplete_question.key, 'test_key') + self.assertEqual(self.autocomplete_question.choices, {'no': 'No', 'yes': 'Yes'}) + + def test_action_method(self): + action_result = self.autocomplete_question.action() + self.assertIn('key', action_result) + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], {'no': 'No', 'yes': 'Yes'}) + + +class RadiosQuestionTest(TestCase): + def setUp(self): + self.radios_question = RadiosQuestion( + key='test_key', + choices={ + 'no': 'No', + 'yes': 'Yes' + }, + ) + + def test_initialization(self): + self.assertEqual(self.radios_question.key, 'test_key') + self.assertEqual(self.radios_question.choices, {'no': 'No', 'yes': 'Yes'}) + + def test_action_method(self): + action_result = self.radios_question.action() + self.assertIn('key', action_result) + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], {'no': 'No', 'yes': 'Yes'}) + + +class ButtonArrayQuestionTest(TestCase): + def setUp(self): + self.buttonarray_question = ButtonArrayQuestion( + key='test_key', + choices={ + 'no': 'No', + 'yes': 'Yes' + }, + ) + + def test_initialization(self): + self.assertEqual(self.buttonarray_question.key, 'test_key') + self.assertEqual(self.buttonarray_question.choices, {'no': 'No', 'yes': 'Yes'}) + + def test_action_method(self): + action_result = self.buttonarray_question.action() + self.assertIn('key', action_result) + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], {'no': 'No', 'yes': 'Yes'}) + + +class RangeQuestionTest(TestCase): + def setUp(self): + self.range_question = RangeQuestion( + key='test_key', + min_value=1, + max_value=10, + ) + + def test_initialization(self): + self.assertEqual(self.range_question.key, 'test_key') + self.assertEqual(self.range_question.min_value, 1) + self.assertEqual(self.range_question.max_value, 10) + + def test_action_method(self): + action_result = self.range_question.action() + self.assertIn('key', action_result) + self.assertIn('min_value', action_result) + self.assertIn('max_value', action_result) + self.assertEqual(action_result['min_value'], 1) + self.assertEqual(action_result['max_value'], 10) + + +class LikertQuestionCustomChoicesTest(TestCase): + def setUp(self): + self.likert_question = LikertQuestion( + key='test_key', + choices={ + 'no': 'No', + 'yes': 'Yes' + }, + ) + + def test_initialization(self): + self.assertEqual(self.likert_question.key, 'test_key') + self.assertEqual(self.likert_question.choices, {'no': 'No', 'yes': 'Yes'}) + + def test_action_method(self): + action_result = self.likert_question.action() + self.assertIn('key', action_result) + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], {'no': 'No', 'yes': 'Yes'}) + + +class LikertQuestionSevenScaleStepsTest(TestCase): + def setUp(self): + self.likert_question = LikertQuestion( + key='test_key', + scale_steps=7, + ) + + def test_initialization(self): + self.assertEqual(self.likert_question.key, 'test_key') + self.assertEqual(self.likert_question.choices, { + 1: "Completely Disagree", + 2: "Strongly Disagree", + 3: "Disagree", + 4: "Neither Agree nor Disagree", + 5: "Agree", + 6: "Strongly Agree", + 7: "Completely Agree", + }) + + def test_action_method(self): + action_result = self.likert_question.action() + self.assertIn('key', action_result) + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], { + 1: "Completely Disagree", + 2: "Strongly Disagree", + 3: "Disagree", + 4: "Neither Agree nor Disagree", + 5: "Agree", + 6: "Strongly Agree", + 7: "Completely Agree", + }) + + +class LikertQuestionFiveScaleStepsTest(TestCase): + def setUp(self): + self.likert_question = LikertQuestion( + key='test_key', + scale_steps=5, + ) + + def test_initialization(self): + self.assertEqual(self.likert_question.key, 'test_key') + self.assertEqual(self.likert_question.choices, { + 1: "Strongly Disagree", + 2: "Disagree", + 3: "Neither Agree nor Disagree", + 4: "Agree", + 5: "Strongly Agree", + }) + + def test_action_method(self): + action_result = self.likert_question.action() + self.assertIn('key', action_result) + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], { + 1: "Strongly Disagree", + 2: "Disagree", + 3: "Neither Agree nor Disagree", + 4: "Agree", + 5: "Strongly Agree", + }) + + +class LikertQuestionIconTest(TestCase): + def setUp(self): + self.likert_question_icon = LikertQuestionIcon( + key='test_key', + scale_steps=7, + likert_view="ICON_RANGE", + ) + + def test_initialization(self): + self.assertEqual(self.likert_question_icon.key, 'test_key') + self.assertEqual(self.likert_question_icon.view, 'ICON_RANGE') + self.assertEqual(self.likert_question_icon.choices, { + 1: 'fa-face-grin-hearts', + 2: 'fa-face-grin', + 3: 'fa-face-smile', + 4: 'fa-face-meh', + 5: 'fa-face-frown', + 6: 'fa-face-frown-open', + 7: 'fa-face-angry', + }) + + def test_action_method(self): + action_result = self.likert_question_icon.action() + self.assertIn('key', action_result) + self.assertIn('view', action_result) + self.assertEqual(action_result['view'], 'ICON_RANGE') + self.assertIn('choices', action_result) + self.assertEqual(action_result['choices'], { + 1: 'fa-face-grin-hearts', + 2: 'fa-face-grin', + 3: 'fa-face-smile', + 4: 'fa-face-meh', + 5: 'fa-face-frown', + 6: 'fa-face-frown-open', + 7: 'fa-face-angry', + }) diff --git a/backend/experiment/tests/test_actions_html.py b/backend/experiment/tests/test_actions_html.py new file mode 100644 index 000000000..79d913211 --- /dev/null +++ b/backend/experiment/tests/test_actions_html.py @@ -0,0 +1,11 @@ +from django.test import TestCase + +from experiment.actions.html import HTML + + +class HTMLTest(TestCase): + + def test_initialization(self): + test_html_body = "Test Body
", + heading="Test Heading", + button_label="Test Label", + button_link="http://example.com" + ) + self.assertEqual(info.body, "Test Body
") + self.assertEqual(info.heading, "Test Heading") + self.assertEqual(info.button_label, "Test Label") + self.assertEqual(info.button_link, "http://example.com") + + def test_initialization_only_body(self): + info = Info(body="Only Body
") + self.assertEqual(info.body, "Only Body
") + self.assertEqual(info.heading, "") + self.assertIsNone(info.button_label) + self.assertIsNone(info.button_link) + + def test_initialization_default_values(self): + info = Info(body="Body
", heading="Heading") + self.assertEqual(info.body, "Body
") + self.assertEqual(info.heading, "Heading") + self.assertIsNone(info.button_label) + self.assertIsNone(info.button_link) + + +if __name__ == '__main__': + unittest.main() diff --git a/backend/experiment/tests/test_actions_playlist.py b/backend/experiment/tests/test_actions_playlist.py new file mode 100644 index 000000000..98223082e --- /dev/null +++ b/backend/experiment/tests/test_actions_playlist.py @@ -0,0 +1,38 @@ +import unittest +from unittest.mock import MagicMock + +from experiment.actions.playlist import Playlist + + +class TestPlaylist(unittest.TestCase): + + def setUp(self): + self.mock_playlists = [ + MagicMock(id=1, name='Playlist 1'), + MagicMock(id=2, name='Playlist 2') + ] + + self.mock_playlists[0].name = 'Playlist 1' + self.mock_playlists[1].name = 'Playlist 2' + + def test_initialization_with_playlists(self): + playlist_action = Playlist(playlists=self.mock_playlists) + self.assertEqual(len(playlist_action.playlists), 2) + self.assertEqual(playlist_action.playlists[0]['id'], 1) + self.assertEqual(playlist_action.playlists[0]['name'], 'Playlist 1') + + def test_initialization_with_empty_list(self): + playlist_action = Playlist(playlists=[]) + self.assertEqual(playlist_action.playlists, []) + + def test_playlists_structure(self): + playlist_action = Playlist(playlists=self.mock_playlists) + for playlist in playlist_action.playlists: + self.assertIn('id', playlist) + self.assertIn('name', playlist) + self.assertIsInstance(playlist['id'], int) + self.assertIsInstance(playlist['name'], str) + + +if __name__ == '__main__': + unittest.main() diff --git a/backend/experiment/tests/test_actions_score.py b/backend/experiment/tests/test_actions_score.py new file mode 100644 index 000000000..579ec5544 --- /dev/null +++ b/backend/experiment/tests/test_actions_score.py @@ -0,0 +1,78 @@ +import unittest +from unittest.mock import Mock + +from experiment.actions.score import Score + + +class TestScore(unittest.TestCase): + + def setUp(self): + self.mock_session = Mock() + self.mock_session.last_score.return_value = 10 + self.mock_session.last_song.return_value = "Test Song" + self.mock_session.total_score.return_value = 50 + self.mock_session.rounds_passed.return_value = 2 + self.mock_session.experiment.rounds = 5 + + def test_initialization_full_parameters(self): + score = Score( + session=self.mock_session, + title="Test Title", + score=100, + score_message=lambda x: f"Score is {x}", + config={'show_section': True, 'show_total_score': True}, + icon="icon-test", + timer=5, + feedback="Test Feedback" + ) + self.assertEqual(score.title, "Test Title") + self.assertEqual(score.score, 100) + self.assertEqual(score.score_message(score.score), "Score is 100") + self.assertEqual(score.feedback, "Test Feedback") + self.assertEqual(score.config, {'show_section': True, 'show_total_score': True}) + self.assertEqual(score.icon, "icon-test") + self.assertEqual(score.texts, { + 'score': 'Total Score', + 'next': 'Next', + 'listen_explainer': 'You listened to:' + }) + self.assertEqual(score.timer, 5) + + def test_initialization_minimal_parameters(self): + score = Score(session=self.mock_session) + self.assertIsNone(score.title) + self.assertEqual(score.score, 10) + self.assertEqual(score.score_message, score.default_score_message) + self.assertIsNone(score.feedback) + self.assertEqual(score.config, {'show_section': False, 'show_total_score': False}) + self.assertIsNone(score.icon) + self.assertEqual(score.texts, { + 'score': 'Total Score', + 'next': 'Next', + 'listen_explainer': 'You listened to:' + }) + self.assertIsNone(score.timer) + + def test_action_serialization(self): + score = Score(session=self.mock_session, config={'show_section': True, 'show_total_score': True}) + action = score.action() + self.assertIn('view', action) + self.assertIn('last_song', action) + self.assertIn('total_score', action) + self.assertIn('score', action) + self.assertIn('score_message', action) + self.assertIn('texts', action) + self.assertIn('feedback', action) + self.assertIn('icon', action) + self.assertIn('timer', action) + + def test_default_score_message(self): + score = Score(session=self.mock_session) + self.assertIn(score.default_score_message(10), ["Correct"]) # Positive + self.assertIn(score.default_score_message(0), ["No points"]) # Zero + self.assertIn(score.default_score_message(-5), ["Incorrect"]) # Negative + self.assertIn(score.default_score_message(None), ["No points"]) # None + + +if __name__ == '__main__': + unittest.main() diff --git a/backend/experiment/tests/test_admin_experiment.py b/backend/experiment/tests/test_admin_experiment.py index d3f40e408..610398676 100644 --- a/backend/experiment/tests/test_admin_experiment.py +++ b/backend/experiment/tests/test_admin_experiment.py @@ -11,7 +11,7 @@ from session.models import Session # Expected field count per model -EXPECTED_EXPERIMENT_FIELDS = 13 +EXPECTED_EXPERIMENT_FIELDS = 14 EXPECTED_SESSION_FIELDS = 9 EXPECTED_RESULT_FIELDS = 12 EXPECTED_PARTICIPANT_FIELDS = 5 @@ -20,11 +20,13 @@ class MockRequest: pass + request = MockRequest() this_experiment_admin = ExperimentAdmin( model=Experiment, admin_site=AdminSite) + class TestAdminExperiment(TestCase): @classmethod @@ -62,20 +64,17 @@ def test_participant_model(self): participant_fields = [key for key in participant] self.assertEqual(len(participant_fields), EXPECTED_PARTICIPANT_FIELDS) - class TestAdminExperimentExport(TestCase): fixtures = ['playlist', 'experiment'] - + @classmethod def setUpTestData(cls): cls.participant = Participant.objects.create(unique_hash=42) cls.experiment = Experiment.objects.get(name='Hooked-China') - print(cls.experiment) for playlist in cls.experiment.playlists.all(): - playlist.update_sections() - print(cls.experiment.pk) + playlist.update_sections() cls.session = Session.objects.create( experiment=cls.experiment, participant=cls.participant, @@ -84,14 +83,15 @@ def setUpTestData(cls): Result.objects.create( session=Session.objects.first(), expected_response = i, - given_response = i + given_response = i, + question_key = 'test_question_' + str(i), ) Result.objects.create( participant=cls.participant, question_key= i, - given_response = i + given_response = i, ) - + def setUp(self): self.client = Client() @@ -133,3 +133,18 @@ def test_admin_export(self): # test response from forced download self.assertEqual(response.status_code, 200) self.assertEqual(response['content-type'], 'application/x-zip-compressed') + + def test_export_table_includes_question_key(self): + session_keys = ['session_start', 'session_end'] + result_keys = ['question_key'] + export_options = ['convert_result_json'] # Adjust based on your needs + + # Call the method under test + rows, fieldnames = self.experiment.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) + for i in range(len(rows)): + row = rows[i] + self.assertIn('question_key', row) + self.assertEqual(row['question_key'], 'test_question_' + str(i)) \ No newline at end of file diff --git a/backend/experiment/tests/test_forms.py b/backend/experiment/tests/test_forms.py new file mode 100644 index 000000000..2e73b3ddf --- /dev/null +++ b/backend/experiment/tests/test_forms.py @@ -0,0 +1,47 @@ +from django.test import TestCase + +from experiment.forms import ExperimentForm, ExportForm, TemplateForm, EXPERIMENT_RULES, SESSION_CHOICES, RESULT_CHOICES, EXPORT_OPTIONS, TEMPLATE_CHOICES + + +class ExperimentFormTest(TestCase): + def test_form_fields(self): + form = ExperimentForm() + self.assertIn('name', form.fields) + self.assertIn('slug', form.fields) + self.assertIn('active', form.fields) + self.assertIn('rules', form.fields) + self.assertIn('questions', form.fields) + self.assertIn('rounds', form.fields) + self.assertIn('bonus_points', form.fields) + self.assertIn('playlists', form.fields) + self.assertIn('experiment_series', form.fields) + + def test_rules_field_choices(self): + form = ExperimentForm() + expected_choices = [(i, EXPERIMENT_RULES[i].__name__) for i in EXPERIMENT_RULES] + expected_choices.append(("", "---------")) + self.assertEqual(form.fields['rules'].choices, sorted(expected_choices)) + + +class ExportFormTest(TestCase): + def test_form_fields(self): + form = ExportForm() + self.assertIn('export_session_fields', form.fields) + self.assertIn('export_result_fields', form.fields) + self.assertIn('export_options', form.fields) + + def test_field_choices(self): + form = ExportForm() + self.assertEqual(form.fields['export_session_fields'].choices, SESSION_CHOICES) + self.assertEqual(form.fields['export_result_fields'].choices, RESULT_CHOICES) + self.assertEqual(form.fields['export_options'].choices, EXPORT_OPTIONS) + + +class TemplateFormTest(TestCase): + def test_form_fields(self): + form = TemplateForm() + self.assertIn('select_template', form.fields) + + def test_template_choices(self): + form = TemplateForm() + self.assertEqual(form.fields['select_template'].choices, TEMPLATE_CHOICES) diff --git a/backend/experiment/tests/test_frontend_style.py b/backend/experiment/tests/test_frontend_style.py new file mode 100644 index 000000000..6e4ce8a01 --- /dev/null +++ b/backend/experiment/tests/test_frontend_style.py @@ -0,0 +1,41 @@ +import unittest + +from experiment.actions.frontend_style import FrontendStyle, EFrontendStyle + + +class TestFrontendStyle(unittest.TestCase): + + def test_init_with_valid_root_style(self): + style = FrontendStyle(EFrontendStyle.PRIMARY) + self.assertEqual(style.get_style('root'), EFrontendStyle.PRIMARY) + + def test_init_with_invalid_root_style(self): + with self.assertRaises(ValueError): + FrontendStyle("invalid-style") + + def test_get_style(self): + style = FrontendStyle(EFrontendStyle.SECONDARY) + self.assertEqual(style.get_style('root'), EFrontendStyle.SECONDARY) + + def test_get_style_non_existing_element(self): + style = FrontendStyle(EFrontendStyle.SECONDARY) + self.assertIsNone(style.get_style('non-existing')) + + def test_apply_valid_style(self): + style = FrontendStyle(EFrontendStyle.EMPTY) + style.apply_style('root', EFrontendStyle.INFO) + self.assertEqual(style.get_style('root'), EFrontendStyle.INFO) + + def test_apply_invalid_style(self): + style = FrontendStyle(EFrontendStyle.EMPTY) + with self.assertRaises(ValueError): + style.apply_style('root', "invalid-style") + + def test_to_dict(self): + style = FrontendStyle(EFrontendStyle.NEUTRAL) + expected_dict = {'root': EFrontendStyle.NEUTRAL.value } + self.assertEqual(style.to_dict(), expected_dict) + + +if __name__ == '__main__': + unittest.main() diff --git a/backend/experiment/tests/test_model_functions.py b/backend/experiment/tests/test_model_functions.py index 77a767aa1..2fb18c92f 100644 --- a/backend/experiment/tests/test_model_functions.py +++ b/backend/experiment/tests/test_model_functions.py @@ -1,6 +1,7 @@ from django.test import TestCase from experiment.models import Experiment, ExperimentSeries + class TestModelExperiment(TestCase): @classmethod def setUpTestData(cls): @@ -13,6 +14,7 @@ def test_separate_rules_instance(self): keys2 = [q.key for q in rules2.questions] assert keys1 != keys2 + class TestModelExperimentSeries(TestCase): def test_verbose_name_plural(self): diff --git a/backend/experiment/tests/test_rules_gold_msi.py b/backend/experiment/tests/test_rules_gold_msi.py new file mode 100644 index 000000000..d2025d08a --- /dev/null +++ b/backend/experiment/tests/test_rules_gold_msi.py @@ -0,0 +1,27 @@ +from django.test import TestCase +from django.core.files.uploadedfile import SimpleUploadedFile +from experiment.models import Experiment +from experiment.rules.gold_msi import GoldMSI + + +class TestGoldMSI(TestCase): + @classmethod + def setUpTestData(cls): + Experiment.objects.create( + name='test_md', + slug='MARKDOWN', + consent=SimpleUploadedFile('consent.md', b'#test', content_type='text/html') + ) + + def test_init(self): + goldMSI = GoldMSI() + assert goldMSI.ID == 'GOLD_MSI' + + def test_first_round(self): + experiment = Experiment.objects.first() + goldMSI = GoldMSI() + result = goldMSI.first_round(experiment) + + assert result[0].__class__.__name__ == 'Consent' + assert result[0].ID == 'CONSENT' + diff --git a/backend/experiment/tests/test_utils.py b/backend/experiment/tests/test_utils.py new file mode 100644 index 000000000..0ae83aea7 --- /dev/null +++ b/backend/experiment/tests/test_utils.py @@ -0,0 +1,14 @@ +from django.test import TestCase + +from experiment.utils import create_player_labels + + +class TestExperimentUtils(TestCase): + + def test_create_player_labels(self): + labels = create_player_labels(3, 'alphabetic') + assert labels == ['A', 'B', 'C'] + labels = create_player_labels(4, 'roman') + assert labels == ['I', 'II', 'III', 'IV'] + labels = create_player_labels(2) + assert labels == ['1', '2'] \ No newline at end of file diff --git a/backend/experiment/utils.py b/backend/experiment/utils.py index 03ed3354b..17dcaaa89 100644 --- a/backend/experiment/utils.py +++ b/backend/experiment/utils.py @@ -1,3 +1,5 @@ +import roman + def serialize(actions): ''' Serialize an array of actions ''' @@ -5,6 +7,7 @@ def serialize(actions): return [a.action() for a in actions] return actions.action() + def slugify(text): """Create a slug from given string""" non_url_safe = ['"', '#', '$', '%', '&', '+', @@ -16,12 +19,27 @@ def slugify(text): text = u'_'.join(text.split()) return text.lower() + def non_breaking_spaces(s): # Convert regular spaces to non breaking spacing # on the given string non_breaking_space = chr(160) return s.replace(" ", non_breaking_space) + def external_url(text, url): # Create a HTML element for an external url return '{}'.format(url, text) + + +def create_player_labels(num_labels, label_style='number'): + return [format_label(i, label_style) for i in range(num_labels)] + + +def format_label(number, label_style): + if label_style == 'alphabetic': + return chr(number + 65) + elif label_style == 'roman': + return roman.toRoman(number+1) + else: + return str(number+1) diff --git a/backend/experiment/validators.py b/backend/experiment/validators.py new file mode 100644 index 000000000..bf5ee0751 --- /dev/null +++ b/backend/experiment/validators.py @@ -0,0 +1,7 @@ +from django.core.validators import FileExtensionValidator + +valid_extensions = ['md', 'html'] + + +def consent_file_validator(): + return FileExtensionValidator(allowed_extensions=valid_extensions) diff --git a/backend/experiment/views.py b/backend/experiment/views.py index 0768f6a23..eeecc9b39 100644 --- a/backend/experiment/views.py +++ b/backend/experiment/views.py @@ -9,13 +9,13 @@ from .models import Experiment, Feedback from .utils import serialize from session.models import Session +from experiment.rules import EXPERIMENT_RULES logger = logging.getLogger(__name__) -from experiment.rules import EXPERIMENT_RULES - # Experiment + def get_experiment(request, slug): """Get experiment data from active experiment with given :slug DO NOT modify session data here, it will break participant_id system @@ -80,6 +80,7 @@ def get_experiment(request, slug): response.set_cookie(settings.LANGUAGE_COOKIE_NAME, None) return response + def post_feedback(request, slug): text = request.POST.get('feedback') experiment = experiment_or_404(slug) @@ -87,6 +88,7 @@ def post_feedback(request, slug): feedback.save() return JsonResponse({'status': 'ok'}) + def experiment_or_404(slug): # get experiment try: @@ -94,5 +96,6 @@ def experiment_or_404(slug): except Experiment.DoesNotExist: raise Http404("Experiment does not exist") + def default_questions(request, rules): return JsonResponse({'default_questions': [q.key for q in EXPERIMENT_RULES[rules]().questions]}) diff --git a/backend/manage.py b/backend/manage.py index 041efeacc..4ac5acba2 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -4,6 +4,7 @@ from os.path import join, dirname from dotenv import load_dotenv + def main(): env_path = join(dirname(__file__), '.env') load_dotenv(dotenv_path=env_path) diff --git a/backend/participant/apps.py b/backend/participant/apps.py index 103321f4c..cfc9ee2d2 100644 --- a/backend/participant/apps.py +++ b/backend/participant/apps.py @@ -1,4 +1,5 @@ from django.apps import AppConfig + class ParticipantConfig(AppConfig): name = 'participant' diff --git a/backend/participant/tests.py b/backend/participant/tests.py index d23212dc4..74d1f868b 100644 --- a/backend/participant/tests.py +++ b/backend/participant/tests.py @@ -7,6 +7,7 @@ from session.models import Session from result.models import Result + class ParticipantTest(TestCase): @classmethod @@ -35,21 +36,21 @@ def setUp(self): self.session = self.client.session self.session['country_code'] = 'BLA' self.session.save() - + def set_participant(self): self.session['participant_id'] = self.participant.id self.session.save() - + def test_current_view(self): self.set_participant() response = json.loads(self.client.get('/participant/').content) assert response.get('id') == self.participant.id assert int(response.get('hash')) == self.participant.unique_hash - assert response.get('csrf_token') != None + assert response.get('csrf_token') is not None def test_profile(self): assert len(self.participant.profile()) == 1 - + def test_profile_object(self): po = self.participant.profile_object() assert len(po.keys()) == 2 @@ -65,5 +66,5 @@ def test_country_code(self): self.client.get('/participant/') participant = Participant.objects.last() assert participant.country_code == 'BLA' - + diff --git a/backend/participant/utils.py b/backend/participant/utils.py index 3a3a8b51b..d1ec74ece 100644 --- a/backend/participant/utils.py +++ b/backend/participant/utils.py @@ -13,6 +13,7 @@ def located_in_nl(request): """Return True if the requesting IP-address is located in NL""" return country(request) == 'nl' + def country(request): """Get country code of requesting ip""" @@ -38,6 +39,7 @@ def country(request): return country_code + def get_country_code(ip_address): """Get country code from given ip address""" @@ -56,6 +58,7 @@ def get_country_code(ip_address): except: return None + def visitor_ip_address(request): """Get visitor ip address from request""" @@ -67,6 +70,7 @@ def visitor_ip_address(request): return request.META.get('REMOTE_ADDR') + def get_participant(request): # get participant from session participant_id = request.session.get(PARTICIPANT_KEY, -1) @@ -76,6 +80,7 @@ def get_participant(request): except Participant.DoesNotExist: raise + def get_or_create_participant(request): """Get a participant from URL, the session, or create/add a new one""" diff --git a/backend/participant/views.py b/backend/participant/views.py index cabc4ef98..f40584cde 100644 --- a/backend/participant/views.py +++ b/backend/participant/views.py @@ -38,7 +38,7 @@ def scores(request): 'messages': { 'title': _('My profile'), 'summary': ngettext( - 'You have participated in %(count)d Amsterdam Music Lab experiment. Your best score is:', + 'You have participated in %(count)d Amsterdam Music Lab experiment. Your best score is:', 'You have partcipated in %(count)d Amsterdam Music Lab experiments. Your best scores are:', len(scores)) % {'count': len(scores)}, 'points': _('points'), diff --git a/backend/requirements.in/base.txt b/backend/requirements.in/base.txt index 30aeb7e60..e53e70cfc 100644 --- a/backend/requirements.in/base.txt +++ b/backend/requirements.in/base.txt @@ -20,6 +20,9 @@ IPToCC # PostgrSQL database client psycopg2 +# to convert labels to Roman numerals +roman + # Sentry SDK, for monitoring performance & errors. See also sentry.io. sentry-sdk @@ -31,3 +34,6 @@ coverage # Generate a code coverage badge genbadge[coverage] + +# Convert markdown to html +django-markup[all_filter_dependencies] diff --git a/backend/requirements.in/dev.txt b/backend/requirements.in/dev.txt index 9cbdba765..58728f34b 100644 --- a/backend/requirements.in/dev.txt +++ b/backend/requirements.in/dev.txt @@ -17,3 +17,6 @@ requests # Add pip-tools so we can use pip-compile in our develop environment pip-tools + +# Flake8 for linting +flake8 diff --git a/backend/requirements/dev.txt b/backend/requirements/dev.txt index d2b22fdcf..eaf6c4b12 100644 --- a/backend/requirements/dev.txt +++ b/backend/requirements/dev.txt @@ -12,6 +12,8 @@ audioread==2.1.9 # via -r requirements.in/base.txt autopep8==1.5.7 # via -r requirements.in/dev.txt +bleach==6.1.0 + # via django-markup build==1.0.3 # via pip-tools certifi==2023.7.22 @@ -28,39 +30,56 @@ coverage==7.3.2 # via -r requirements.in/base.txt defusedxml==0.7.1 # via genbadge -django==3.2.23 +django==3.2.24 # via # -r requirements.in/base.txt # django-cors-headers # django-debug-toolbar # django-inline-actions + # django-markup django-cors-headers==3.10.0 # via -r requirements.in/base.txt -django-debug-toolbar==3.2.2 +django-debug-toolbar==4.3.0 # via -r requirements.in/dev.txt django-inline-actions==2.4.0 # via -r requirements.in/base.txt +django-markup[all_filter_dependencies]==1.8.1 + # via -r requirements.in/base.txt +docutils==0.20.1 + # via + # django-markup + # python-creole +flake8==4.0.1 + # via -r requirements.in/dev.txt genbadge[coverage]==1.1.1 # via -r requirements.in/base.txt +html5lib==1.1 + # via textile idna==3.3 # via requests importlib-metadata==7.0.0 - # via build + # via + # build + # markdown iptocc==2.1.2 # via -r requirements.in/base.txt isort==5.9.3 # via pylint lazy-object-proxy==1.6.0 # via astroid +markdown==3.5.2 + # via django-markup mccabe==0.6.1 - # via pylint + # via + # flake8 + # pylint numpy==1.22.0 # via pandas packaging==23.2 # via build pandas==1.3.4 # via iptocc -pillow==10.1.0 +pillow==10.2.0 # via genbadge pip-tools==7.3.0 # via -r requirements.in/dev.txt @@ -69,7 +88,13 @@ platformdirs==2.4.0 psycopg2==2.9.1 # via -r requirements.in/base.txt pycodestyle==2.8.0 - # via autopep8 + # via + # autopep8 + # flake8 +pyflakes==2.4.0 + # via flake8 +pygments==2.17.2 + # via django-markup pylint==2.11.1 # via # -r requirements.in/dev.txt @@ -81,6 +106,8 @@ pylint-plugin-utils==0.6 # via pylint-django pyproject-hooks==1.0.0 # via build +python-creole==1.4.10 + # via django-markup python-dateutil==2.8.2 # via pandas python-dotenv==0.19.1 @@ -89,18 +116,29 @@ pytz==2021.3 # via # django # pandas +regex==2023.12.25 + # via textile requests==2.31.0 # via # -r requirements.in/dev.txt # genbadge +roman==4.1 + # via -r requirements.in/base.txt sentry-sdk==1.38.0 # via -r requirements.in/base.txt six==1.16.0 - # via python-dateutil + # via + # bleach + # html5lib + # python-dateutil +smartypants==2.0.1 + # via django-markup sqlparse==0.4.4 # via # django # django-debug-toolbar +textile==4.0.2 + # via django-markup toml==0.10.2 # via # autopep8 @@ -120,6 +158,10 @@ urllib3==1.26.18 # via # requests # sentry-sdk +webencodings==0.5.1 + # via + # bleach + # html5lib wheel==0.42.0 # via pip-tools wrapt==1.13.2 diff --git a/backend/requirements/prod.txt b/backend/requirements/prod.txt index 81daf2f11..de4794ebd 100644 --- a/backend/requirements/prod.txt +++ b/backend/requirements/prod.txt @@ -8,6 +8,8 @@ asgiref==3.7.2 # via django audioread==3.0.0 # via -r requirements.in/base.txt +bleach==6.1.0 + # via django-markup certifi==2023.11.17 # via # requests @@ -20,31 +22,48 @@ coverage==7.3.2 # via -r requirements.in/base.txt defusedxml==0.7.1 # via genbadge -django==3.2.23 +django==3.2.24 # via # -r requirements.in/base.txt # django-cors-headers # django-inline-actions + # django-markup django-cors-headers==4.0.0 # via -r requirements.in/base.txt django-inline-actions==2.4.0 # via -r requirements.in/base.txt +django-markup[all_filter_dependencies]==1.8.1 + # via -r requirements.in/base.txt +docutils==0.20.1 + # via + # django-markup + # python-creole genbadge[coverage]==1.1.1 # via -r requirements.in/base.txt gunicorn==20.1.0 # via -r requirements.in/prod.txt +html5lib==1.1 + # via textile idna==3.6 # via requests +importlib-metadata==7.0.1 + # via markdown iptocc==2.1.2 # via -r requirements.in/base.txt +markdown==3.5.2 + # via django-markup numpy==1.24.3 # via pandas pandas==1.5.3 # via iptocc -pillow==10.1.0 +pillow==10.2.0 # via genbadge psycopg2==2.9.6 # via -r requirements.in/base.txt +pygments==2.17.2 + # via django-markup +python-creole==1.4.10 + # via django-markup python-dateutil==2.8.2 # via pandas python-dotenv==1.0.0 @@ -53,14 +72,25 @@ pytz==2023.3 # via # django # pandas +regex==2023.12.25 + # via textile requests==2.31.0 # via genbadge +roman==4.1 + # via -r requirements.in/base.txt sentry-sdk==1.38.0 # via -r requirements.in/base.txt six==1.16.0 - # via python-dateutil + # via + # bleach + # html5lib + # python-dateutil +smartypants==2.0.1 + # via django-markup sqlparse==0.4.4 # via django +textile==4.0.2 + # via django-markup tqdm==4.65.0 # via -r requirements.in/base.txt typing-extensions==4.6.3 @@ -69,6 +99,12 @@ urllib3==2.1.0 # via # requests # sentry-sdk +webencodings==0.5.1 + # via + # bleach + # html5lib +zipp==3.17.0 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/backend/result/apps.py b/backend/result/apps.py index 05553ec4d..8cc17182b 100644 --- a/backend/result/apps.py +++ b/backend/result/apps.py @@ -1,5 +1,6 @@ from django.apps import AppConfig + class ResultConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'result' diff --git a/backend/result/migrations/0002_data_migration.py b/backend/result/migrations/0002_data_migration.py index d89e5b517..f2ca60ff6 100644 --- a/backend/result/migrations/0002_data_migration.py +++ b/backend/result/migrations/0002_data_migration.py @@ -5,6 +5,7 @@ logger = logging.getLogger(__name__) + def populate_results(apps, schema_editor): ExperimentResult = apps.get_model('experiment', 'Result') Profile = apps.get_model('experiment', 'Profile') @@ -44,6 +45,7 @@ def populate_results(apps, schema_editor): OldParticipant = apps.get_model('experiment', 'Participant') OldParticipant.objects.all().delete() + def backwards_populate_results(apps, schema_editor): ExperimentResult = apps.get_model('experiment', 'Result') Profile = apps.get_model('experiment', 'Profile') @@ -82,12 +84,14 @@ def backwards_populate_results(apps, schema_editor): OldParticipant = apps.get_model('participant', 'Participant') OldParticipant.objects.all().delete() + def attempt_set_section(new_result, result): try: new_result.section = result.section except: logging.warning('result {} could not set section {}'.format(new_result.id, result.section.id)) + def get_or_create_participant(participant, apps, app_name='participant'): Participant = apps.get_model(app_name, 'Participant') try: @@ -103,6 +107,7 @@ def get_or_create_participant(participant, apps, app_name='participant'): new_participant.save() return new_participant + def get_or_create_session(session, new_participant, apps, app_name='session'): Session = apps.get_model(app_name, 'Session') try: diff --git a/backend/result/migrations/0004_migrate_json_data_to_JSONField.py b/backend/result/migrations/0004_migrate_json_data_to_JSONField.py index e330970d7..6d123752f 100644 --- a/backend/result/migrations/0004_migrate_json_data_to_JSONField.py +++ b/backend/result/migrations/0004_migrate_json_data_to_JSONField.py @@ -3,6 +3,7 @@ from django.db import migrations import json + def forwards_func(apps, schema_editor): Result = apps.get_model('result', 'Result') for result in Result.objects.all(): @@ -11,12 +12,14 @@ def forwards_func(apps, schema_editor): result.json_temp[key] = value result.save() + def reverse_func(apps, schema_editor): Result = apps.get_model('result', 'Result') for result in Result.objects.all(): result.json_data = json.dumps(result.json_temp) result.save() + class Migration(migrations.Migration): dependencies = [('result', '0003_result_json_temp'),] diff --git a/backend/result/models.py b/backend/result/models.py index dd3b03727..240112fc0 100644 --- a/backend/result/models.py +++ b/backend/result/models.py @@ -5,6 +5,7 @@ from django.core.exceptions import ValidationError + # Create your models here. class Result(models.Model): """Score for each step in a session""" diff --git a/backend/result/score.py b/backend/result/score.py index 6fca9dfc7..a1f528f5f 100644 --- a/backend/result/score.py +++ b/backend/result/score.py @@ -5,6 +5,7 @@ logger = logging.getLogger(__name__) + def check_expected_response(result): try: return result.expected_response @@ -12,6 +13,7 @@ def check_expected_response(result): logger.log(e) return None + def correctness_score(result, data): expected_response = check_expected_response(result) if expected_response and expected_response == result.given_response: @@ -19,22 +21,27 @@ def correctness_score(result, data): else: return 0 + def boolean_score(result, data): if result.given_response == 'yes': return 1 else: return 0 + def likert_score(result, data): return int(data['value']) + def reverse_likert_score(result, data): return int(data['scale_steps']) + 1 - int(data['value']) + def categories_likert_score(result, data): choices = list(data['choices'].keys()) return choices.index(data['value']) + 1 + def reaction_time_score(result, data): expected_response = check_expected_response(result) json_data = result.load_json_data() @@ -45,6 +52,7 @@ def reaction_time_score(result, data): return math.ceil(timeout - time) else: return math.floor(-time) + def song_sync_recognition_score(result, data): if result.given_response == 'TIMEOUT' or result.given_response == 'no': @@ -54,7 +62,8 @@ def song_sync_recognition_score(result, data): time = json_data.get('decision_time') timeout = json_data.get('config').get('response_time') return math.ceil(timeout - time) - + + def song_sync_continuation_score(result, data): ''' modify previous result and return None''' previous_result = result.session.get_previous_result(['recognize']) @@ -63,6 +72,7 @@ def song_sync_continuation_score(result, data): previous_result.save() return None + SCORING_RULES = { 'BOOLEAN': boolean_score, 'CORRECTNESS': correctness_score, diff --git a/backend/result/tests/test_result.py b/backend/result/tests/test_result.py index ab579e4c2..bd6784e7c 100644 --- a/backend/result/tests/test_result.py +++ b/backend/result/tests/test_result.py @@ -5,6 +5,7 @@ from result.models import Result from session.models import Session + class ResultTest(TestCase): @classmethod diff --git a/backend/result/tests/test_views.py b/backend/result/tests/test_views.py index b0ed4832e..f5d0454d6 100644 --- a/backend/result/tests/test_views.py +++ b/backend/result/tests/test_views.py @@ -10,6 +10,7 @@ from result.utils import handle_results + class ResultTest(TestCase): @classmethod @@ -27,11 +28,11 @@ def setUp(self): session = self.client.session session['participant_id'] = self.participant.id session.save() - + def test_get(self): response = self.client.get('/result/speed_swallow/') assert response.status_code == 204 - + def test_create(self): result = Result.objects.create( question_key="speed_swallow", @@ -53,10 +54,10 @@ def test_create(self): assert response.status_code == 200 assert Result.objects.count() == 1 response = self.client.get('/result/speed_swallow/') - assert json.loads(response.content).get('answer') != None + assert json.loads(response.content).get('answer') is not None response = self.client.post('/result/score/', request) assert Result.objects.count() == 1 - + def test_handle_results_with_form(self): result1 = Result.objects.create( session=self.session @@ -74,9 +75,10 @@ def test_handle_results_with_form(self): handle_results(data, self.session) assert self.session.result_count() == 2 json_data = self.session.result_set.first().json_data - assert json_data.get('config') != None + assert json_data.get('config') is not None assert json_data.get('decision_time') == 42 + class ScoringTest(TestCase): @classmethod @@ -101,7 +103,7 @@ def setUpTestData(cls): participant=cls.participant, playlist=playlist ) - + def likert_request(self, rule, value, profile=False): result = Result.objects.create( question_key="test", @@ -119,7 +121,7 @@ def likert_request(self, rule, value, profile=False): }] } return self.make_request(action) - + def choice_request(self): result = Result.objects.create( session=self.session, @@ -145,8 +147,8 @@ def choice_request(self): ], } return self.make_request(view) - - def correctness_request(self, value): + + def correctness_request(self, value): result = Result.objects.create( session = self.session, section = self.section, @@ -180,7 +182,7 @@ def song_sync_recognize_request(self, result_type): ] } return self.make_request(view) - + def song_sync_continue_request(self, result_type): result = Result.objects.create( question_key='correct_place', @@ -199,7 +201,7 @@ def song_sync_continue_request(self, result_type): ] } return self.make_request(view) - + def make_request(self, view): """ set up the Django session data, return a request wrapping the view and id of the custom Session object """ @@ -211,34 +213,34 @@ def make_request(self, view): "session_id": session_id, "json_data": json.dumps(view) } - + def test_likert_score(self): client_request = self.likert_request('LIKERT', 2) response = self.client.post('/result/score/', client_request) assert response.status_code == 200 assert self.session.result_set.count() == 1 assert self.session.result_set.last().score == 2 - + def test_likert_reversed(self): client_request = self.likert_request('REVERSE_LIKERT', 2) response = self.client.post('/result/score/', client_request) assert response.status_code == 200 assert self.session.result_set.count() == 1 assert self.session.result_set.last().score == 6 - + def test_likert_profile(self): client_request = self.likert_request('LIKERT', 6, True) response = self.client.post('/result/score/', client_request) assert response.status_code == 200 assert self.session.result_set.count() == 1 assert self.session.result_set.last().score == 6 - + def test_categories_to_likert(self): client_request = self.choice_request() response = self.client.post('/result/score/', client_request) assert response.status_code == 200 assert self.session.result_set.last().score == 2 - + def test_correctness(self): client_request = self.correctness_request('spam') response = self.client.post('/result/score/', client_request) @@ -249,7 +251,7 @@ def test_correctness(self): assert response.status_code == 200 assert self.session.result_set.count() == 2 assert self.session.result_set.last().score == 0 - + def test_song_sync(self): client_request = self.song_sync_recognize_request("TIMEOUT") response = self.client.post('/result/score/', client_request) diff --git a/backend/result/urls.py b/backend/result/urls.py index d989560ec..6eb92176a 100644 --- a/backend/result/urls.py +++ b/backend/result/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from result.views import current_profile, consent, get_result, score +from result.views import current_profile, consent, get_result, intermediate_score, score app_name = 'result' @@ -9,6 +9,8 @@ name='current_profile'), path('score/', score, name='result_score'), + path('intermediate_score/', intermediate_score, + name='intermediate_score'), path('consent/', consent, name='register_consent'), path('Error: {error}
; + } + return (Warning: No spectrograms found
; + const images = props.images; + if (!images) { + returnWarning: No images found
; } - const labels = props.playConfig.spectrogram_labels; + const labels = props.image_labels; - if (index >= 0 && index < spectrograms.length) { + if (index >= 0 && index < images.length) { return ( -Warning: No spectrograms available for index {index}
; } }, - [props.playConfig.spectrograms, props.playConfig.spectrogram_labels, playSection] + [props.images, props.image_labels, playSection] ); return ( -It can contain lists, headings, bold, italic and underlined text, you name it!
Final text
', + points: 'points', + button: { + text: 'Button', + link: 'https://www.google.com', + }, + logo: { + image: 'https://via.placeholder.com/150', + link: 'https://www.google.com', + }, + social: { + apps: ['facebook', 'whatsapp', 'twitter', 'weibo', 'share', 'clipboard'], + url: 'https://www.google.com', + message: 'Message', + hashtags: ['hashtag'], + text: 'Text', + }, + show_profile_link: true, + action_texts: { + all_experiments: 'All experiments', + profile: 'Profile', + }, + show_participant_link: true, + participant_id_only: false, + feedback_info: { + header: 'Feedback', + button: 'Submit', + thank_you: 'Thank you for your feedback!', + contact_body: 'Please contact us at info@example.com if you have any questions.
', + }, + experiment: { + slug: 'test', + }, + participant: 'test', + }, + decorators: [ + (Story) => ( +Please contact us at info@example.com if you have any questions.
' + }, + inline: false +} + +export const Default = { + args: { + children: ( +This is the HTML body
", + }, + config: { + style: "AUTOPLAY", + auto_advance: true, + response_time: 1000, + continue_label: "Continue", + show_continue_button: true, + }, + playback: { + view: "BUTTON", + instruction: "This is the instruction", + preload_message: "This is the preload message", + play_config: { + autoplay: true, + controls: true, + loop: true, + muted: true, + playback_rate: 1, + preload: "auto", + }, + sections: [ + { + start: 0, + end: 10, + text: "This is the first section", + }, + { + start: 10, + end: 20, + text: "This is the second section", + }, + ], + }, + feedback_form: { + form: [ + { + "key": "know_song", + "view": "BUTTON_ARRAY", + "explainer": "", + "question": "1. Do you know this song?", + "result_id": 17242, + "is_skippable": false, + "submits": false, + "style": "boolean", + "choices": { + "yes": "fa-check", + "unsure": "fa-question", + "no": "fa-xmark" + }, + "min_values": 1 + }, + { + "key": "like_song", + "view": "ICON_RANGE", + "explainer": "", + "question": "2. How much do you like this song?", + "result_id": 17241, + "is_skippable": false, + "submits": false, + "style": "gradient-7", + "choices": { + "1": "fa-face-grin-hearts", + "2": "fa-face-grin", + "3": "fa-face-smile", + "4": "fa-face-meh", + "5": "fa-face-frown", + "6": "fa-face-frown-open", + "7": "fa-face-angry" + } + } + ], + submit_label: "Submit", + skip_label: "Skip", + is_skippable: true, + is_profile: true, + }, + onNext: () => { }, + onResult: () => { }, + ...overrides, +}); + export const Default = { - args: { - html: { - body: "This is the HTML body
", + args: getDefaultArgs(), + decorators: [ + (Story) => ( +Please contact us at info@example.com if you have any questions.
' + }, + inline: true + }, + decorators: [ + (Story) => ( +Please contact us at info@example.com if you have any questions.
' + }, + inline: false + }, + decorators: [ + (Story) => ( +