Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added: Add server-side playlist validation to rules files #995

Merged
merged 9 commits into from
May 28, 2024
24 changes: 24 additions & 0 deletions backend/experiment/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,30 @@ def __init__(self, *args, **kwargs):
required=False
)

def clean_playlists(self):

# Check if there is a rules id selected and key exists
if 'rules' not in self.cleaned_data:
return

# Validat the rules' playlist
rule_id = self.cleaned_data['rules']
cl = EXPERIMENT_RULES[rule_id]
rules = cl()

playlists = self.cleaned_data['playlists']
playlist_errors = []

# Validate playlists
for playlist in playlists:
errors = rules.validate_playlist(playlist)

for error in errors:
playlist_errors.append(f"Playlist [{playlist.name}]: {error}")

if playlist_errors:
self.add_error('playlists', playlist_errors)

class Meta:
model = Experiment
fields = ['name', 'slug', 'active', 'rules',
Expand Down
16 changes: 16 additions & 0 deletions backend/experiment/rules/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from django.conf import settings

from experiment.actions import Final, Form, Trial
from experiment.models import Experiment
from section.models import Playlist
from experiment.questions.demographics import DEMOGRAPHICS
from experiment.questions.goldsmiths import MSI_OTHER
from experiment.questions.utils import question_by_key, unanswered_questions
Expand Down Expand Up @@ -156,3 +158,17 @@ def social_media_info(self, experiment, score):
'url': experiment.url or current_url,
'hashtags': [experiment.hashtag or experiment.slug, "amsterdammusiclab", "citizenscience"]
}

def validate_playlist(self, playlist: Playlist):
errors = []
# Common validations across experiments
if not playlist:
errors.append('The experiment must have a playlist.')
return errors

sections = playlist.section_set.all()

if not sections:
errors.append('The experiment must have at least one section.')

return errors
16 changes: 9 additions & 7 deletions backend/experiment/rules/congosamediff.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@

import random
import re
import math
import string
from django.utils.translation import gettext_lazy as _
from experiment.actions.utils import final_action_with_optional_button
Expand Down Expand Up @@ -30,7 +29,9 @@ def first_round(self, experiment: Experiment):
"""

# Do a validity check on the experiment
self.validate(experiment)
errors = self.validate_playlist(experiment.playlists.first())
if errors:
raise ValueError('The experiment playlist is not valid: \n- ' + '\n- '.join(errors))

# 1. Playlist
playlist = Playlist(experiment.playlists.all())
Expand Down Expand Up @@ -241,12 +242,14 @@ def get_total_trials_count(self, session: Session):
total_trials_count = practice_trials_count + total_unique_exp_trials_count + 1
return total_trials_count

def validate(self, experiment: Experiment):
def validate_playlist(self, playlist: PlaylistModel):

errors = []

super().validate_playlist(playlist) # Call the base class validate_playlist to perform common checks

# All sections need to have a group value
sections = experiment.playlists.first().section_set.all()
sections = playlist.section_set.all()
for section in sections:
file_name = section.song.name if section.song else 'No name'
# every section.group should consist of a number
Expand All @@ -265,7 +268,7 @@ def validate(self, experiment: Experiment):
if not sections.exclude(tag__contains='practice').exists():
errors.append('At least one section should not have the tag "practice"')

# Every non-practice group should have the same number of variants
# Every non-practice group should have the same number of variants
# that should be labeled with a single uppercase letter
groups = sections.values('group').distinct()
variants = sections.exclude(tag__contains='practice').values('tag')
Expand All @@ -283,8 +286,7 @@ def validate(self, experiment: Experiment):
total_variants_stringified = ', '.join(unique_variants)
errors.append(f'Group {group["group"]} should have the same number of variants as the total amount of variants ({variants_count}; {total_variants_stringified}) but has {group_variants.count()} ({group_variants_stringified})')

if errors:
raise ValueError('The experiment playlist is not valid: \n- ' + '\n- '.join(errors))
return errors

def get_participant_group_variant(self, participant_id: int, group_number: int, groups_amount: int, variants_amount: int) -> str:

Expand Down
11 changes: 11 additions & 0 deletions backend/experiment/rules/tests/test_base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.test import TestCase
from django.conf import settings
from experiment.models import Experiment
from section.models import Playlist
from ..base import Base


Expand All @@ -27,3 +28,13 @@ def test_social_media_info(self):
# Check for double slashes
self.assertNotIn(social_media_info['url'], '//')
self.assertEqual(social_media_info['hashtags'], ['music-lab', 'amsterdammusiclab', 'citizenscience'])

def test_validate_playlist(self):
base = Base()
playlist = None
errors = base.validate_playlist(playlist)
self.assertEqual(errors, ['The experiment must have a playlist.'])

playlist = Playlist()
errors = base.validate_playlist(playlist)
self.assertEqual(errors, ['The experiment must have at least one section.'])
3 changes: 2 additions & 1 deletion backend/experiment/urls.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from django.urls import path
from django.views.generic.base import TemplateView
from .views import get_experiment, get_experiment_collection, post_feedback, default_questions, render_markdown
from .views import get_experiment, get_experiment_collection, post_feedback, default_questions, render_markdown, validate_experiment_playlist

app_name = 'experiment'

urlpatterns = [
# Experiment
path('render_markdown/', render_markdown, name='render_markdown'),
path('validate_playlist/<str:rules_id>', validate_experiment_playlist, name='validate_experiment_playlist'),
path('<slug:slug>/', get_experiment, name='experiment'),
path('<slug:slug>/feedback/', post_feedback, name='feedback'),
path('collection/<slug:slug>/', get_experiment_collection,
Expand Down
46 changes: 46 additions & 0 deletions backend/experiment/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from django_markup.markup import formatter

from .models import Experiment, ExperimentCollection, ExperimentCollectionGroup, Feedback
from section.models import Playlist
from experiment.serializers import serialize_actions, serialize_experiment_collection, serialize_experiment_collection_group
from experiment.rules import EXPERIMENT_RULES
from experiment.actions.utils import COLLECTION_KEY
Expand Down Expand Up @@ -132,3 +133,48 @@ def render_markdown(request):
return JsonResponse({'html': formatter(markdown, filter_name='markdown')})

return JsonResponse({'html': ''})


def validate_experiment_playlist(
request: HttpRequest,
rules_id: str
) -> JsonResponse:
"""
Validate the playlist of an experiment based on the used rules
"""

if request.method != 'POST':
return JsonResponse({'status': 'error', 'message': 'Only POST requests are allowed'})

if not request.body:
return JsonResponse({'status': 'error', 'message': 'No body found in request'})

if request.content_type != 'application/json':
return JsonResponse({'status': 'error', 'message': 'Only application/json content type is allowed'})

json_body = json.loads(request.body)
playlist_ids = json_body.get('playlists', [])
playlists = Playlist.objects.filter(id__in=playlist_ids)

if not playlists:
return JsonResponse({'status': 'error', 'message': 'The experiment must have a playlist.'})

rules = EXPERIMENT_RULES[rules_id]()

if not rules.validate_playlist:
return JsonResponse({'status': 'warn', 'message': 'This rulesset does not have a playlist validation.'})

playlist_errors = []

for playlist in playlists:
errors = rules.validate_playlist(playlist)
if errors:
playlist_errors.append({
'playlist': playlist.name,
'errors': errors
})

if playlist_errors:
return JsonResponse({'status': 'error', 'message': 'There are errors in the playlist.', 'errors': playlist_errors})

return JsonResponse({'status': 'ok', 'message': 'The playlist is valid.'})
Loading