Skip to content

Commit

Permalink
Merge branch 'release/3.1.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
BeritJanssen committed Dec 9, 2024
2 parents ea4f366 + 421273e commit c3f0cba
Show file tree
Hide file tree
Showing 49 changed files with 1,301 additions and 891 deletions.
82 changes: 57 additions & 25 deletions backend/experiment/actions/score.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,51 @@

from django.utils.translation import gettext as _

from result.models import Result
from session.models import Session
from .base_action import BaseAction


class Score(BaseAction): # pylint: disable=too-few-public-methods
class Score(BaseAction):
"""
Provide data for an intermediate score view
Provide data for a score view, presenting feedback to a participant after a Trial
Relates to client component: Score.ts
Relates to client component: Score.js
Args:
session: a Session object
title: the title of the score page
result: the result for which section and/or score should be reported
score: the score to report, will override result.score
score_message: a function which constructs feedback text based on the score
config: a dict with the following settings:
- show_section: whether metadata of the previous section should be shown
- show_total_score: whether the total score should be shown
icon: the name of a themify-icon shown with the view or None
timer: int or None. If int, wait for as many seconds until showing the next view
feedback: An additional feedback text
"""

ID = 'SCORE'

def __init__(self, session, title: str = None, score=None, score_message=None, config=None, icon=None, timer=None, feedback=None):
""" Score presents feedback to a participant after a Trial
- session: a Session object
- title: the title of the score page
- score_message: a function which constructs feedback text based on the score
- config: a dict with the following settings:
- show_section: whether metadata of the previous section should be shown
- show_total_score: whether the total score should be shown
- icon: the name of a themify-icon shown with the view or None
- timer: int or None. If int, wait for as many seconds until showing the next view
- feedback: An additional feedback text
"""
self.session = session
def __init__(
self,
session: Session,
title: str = '',
result: Result = None,
score: float = None,
score_message: str = '',
config: dict = {},
icon: str = None,
timer: int = None,
feedback: str = None,
):
self.title = title or _('Round {get_rounds_passed} / {total_rounds}').format(
get_rounds_passed=session.get_rounds_passed(),
total_rounds=self.session.block.rounds
total_rounds=session.block.rounds,
)
self.score = score or session.last_score()
self.score_message = score_message or self.default_score_message
self.session = session
self.score = self.get_score(score, result)
self.score_message = score_message or self.default_score_message(self.score)
self.feedback = feedback
self.config = {
'show_section': False,
Expand All @@ -46,30 +60,48 @@ def __init__(self, session, title: str = None, score=None, score_message=None, c
'next': _('Next'),
'listen_explainer': _('You listened to:')
}
self.last_song = result.section.song_label() if result else session.last_song()
self.timer = timer

def action(self):
"""Serialize score data"""
def action(self) -> dict:
"""Serialize score data
Returns:
dictionary with the relevant data for the Score.ts view
"""
# Create action
action = {
'view': self.ID,
'title': self.title,
'score': self.score,
'score_message': self.score_message(self.score),
'score_message': self.score_message,
'texts': self.texts,
'feedback': self.feedback,
'icon': self.icon,
'timer': self.timer
'timer': self.timer,
}
if self.config['show_section']:
action['last_song'] = self.session.last_song()
action['last_song'] = self.last_song
if self.config['show_total_score']:
action['total_score'] = self.session.total_score()
return action

def get_score(self, score: float = None, result: Result = None) -> float:
"""Retrieve the last relevant score, fall back to session.last_score() if neither score nor result are defined
Args:
score: the score passed from the rules file (optional)
result: a Result object passed from the rules file (opional)
"""
if score:
return score
elif result:
return result.score
else:
return self.session.last_score()

def default_score_message(self, score):
"""Fallback to generate a message for the given score"""

# None
if score is None:
score = 0
Expand Down
8 changes: 4 additions & 4 deletions backend/experiment/actions/tests/test_actions_score.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ def test_initialization_full_parameters(self):
session=self.mock_session,
title="Test Title",
score=100,
score_message=lambda x: f"Score is {x}",
score_message="Score is 100",
config={'show_section': True, 'show_total_score': True},
icon="icon-test",
timer=5,
feedback="Test Feedback"
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.score_message, "Score is 100")
self.assertEqual(score.feedback, "Test Feedback")
self.assertEqual(
score.config, {'show_section': True, 'show_total_score': True})
Expand All @@ -43,7 +43,7 @@ def test_initialization_minimal_parameters(self):
score = Score(session=self.mock_session)
self.assertIn('Round', score.title)
self.assertEqual(score.score, 10)
self.assertEqual(score.score_message, score.default_score_message)
self.assertEqual(score.score_message, score.default_score_message(score.score))
self.assertIsNone(score.feedback)
self.assertEqual(
score.config, {'show_section': False, 'show_total_score': False})
Expand Down
62 changes: 34 additions & 28 deletions backend/experiment/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,6 @@
from question.models import QuestionSeries, QuestionInSeries


class FeedbackInline(admin.TabularInline):
"""Inline to show results linked to given participant"""

model = Feedback
fields = ["text"]
extra = 0


class BlockTranslatedContentInline(NestedTabularInline):
model = BlockTranslatedContent

Expand Down Expand Up @@ -92,7 +84,7 @@ class BlockAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin):
"bonus_points",
"playlists",
]
inlines = [QuestionSeriesInline, FeedbackInline, BlockTranslatedContentInline]
inlines = [QuestionSeriesInline, BlockTranslatedContentInline]
form = BlockForm

# make playlists fields a list of checkboxes
Expand Down Expand Up @@ -156,7 +148,6 @@ def export(self, request, obj, parent_obj=None):
data=str(serializers.serialize("json", all_feedback.order_by("pk"))),
)


# create forced download response
response = HttpResponse(zip_buffer.getbuffer())
response["Content-Type"] = "application/x-zip-compressed"
Expand Down Expand Up @@ -295,7 +286,7 @@ def name(self, obj):
content = obj.get_fallback_content()

return content.name if content else "No name"

def redirect_to_overview(self):
return redirect(reverse("admin:experiment_experiment_changelist"))

Expand All @@ -321,25 +312,33 @@ def duplicate(self, request, obj, parent_obj=None):

# Validate slug
if not extension.isalnum():
messages.add_message(request,
messages.ERROR,
f"{extension} is nog a valid slug extension. Only alphanumeric characters are allowed.")
messages.add_message(
request,
messages.ERROR,
f"{extension} is nog a valid slug extension. Only alphanumeric characters are allowed.",
)
if extension.lower() != extension:
messages.add_message(request,
messages.ERROR,
f"{extension} is nog a valid slug extension. Only lowercase characters are allowed.")
messages.add_message(
request,
messages.ERROR,
f"{extension} is nog a valid slug extension. Only lowercase characters are allowed.",
)
# Check for duplicate slugs
for exp in Experiment.objects.all():
if exp.slug == f"{obj.slug}{slug_extension}":
messages.add_message(request,
messages.ERROR,
f"An experiment with slug: {obj.slug}{slug_extension} already exists. Please choose a different slug extension.")
messages.add_message(
request,
messages.ERROR,
f"An experiment with slug: {obj.slug}{slug_extension} already exists. Please choose a different slug extension.",
)
for as_block in obj.associated_blocks():
for block in Block.objects.all():
if f"{as_block.slug}{slug_extension}" == block.slug:
messages.add_message(request,
messages.ERROR,
f"A block with slug: {block.slug}{slug_extension} already exists. Please choose a different slug extension.")
messages.add_message(
request,
messages.ERROR,
f"A block with slug: {block.slug}{slug_extension} already exists. Please choose a different slug extension.",
)
# Return to form with error messages
if len(messages.get_messages(request)) != 0:
return render(
Expand All @@ -349,9 +348,9 @@ def duplicate(self, request, obj, parent_obj=None):
)

# order_by is inserted here to prevent a query error
exp_contents = obj.translated_content.order_by('name').all()
exp_contents = obj.translated_content.order_by("name").all()
# order_by is inserted here to prevent a query error
exp_phases = obj.phases.order_by('index').all()
exp_phases = obj.phases.order_by("index").all()

# Duplicate Experiment object
exp_copy = obj
Expand Down Expand Up @@ -380,7 +379,7 @@ def duplicate(self, request, obj, parent_obj=None):
# Duplicate blocks in this phase
for block in these_blocks:
# order_by is inserted here to prevent a query error
block_contents = block.translated_contents.order_by('name').all()
block_contents = block.translated_contents.order_by("name").all()
these_playlists = block.playlists.all()
question_series = QuestionSeries.objects.filter(block=block)

Expand All @@ -393,7 +392,7 @@ def duplicate(self, request, obj, parent_obj=None):
block_copy.playlists.set(these_playlists)

# Duplicate Block translated content objects
for content in block_contents:
for content in block_contents:
block_content_copy = content
block_content_copy.pk = None
block_content_copy._state.adding = True
Expand Down Expand Up @@ -421,7 +420,7 @@ def duplicate(self, request, obj, parent_obj=None):
series_copy.questions.set(these_questions)

return self.redirect_to_overview()

# Go back to experiment overview
if "_back" in request.POST:
return self.redirect_to_overview()
Expand All @@ -438,15 +437,18 @@ def experimenter_dashboard(self, request, obj, parent_obj=None):
all_blocks = obj.associated_blocks()
all_participants = obj.current_participants()
all_sessions = obj.export_sessions()
all_feedback = obj.export_feedback()
collect_data = {
"participant_count": len(all_participants),
"session_count": len(all_sessions),
"feedback_count": len(all_feedback),
}

blocks = [
{
"id": block.id,
"slug": block.slug,
"name": block,
"started": len(all_sessions.filter(block=block)),
"finished": len(
all_sessions.filter(
Expand All @@ -468,6 +470,7 @@ def experimenter_dashboard(self, request, obj, parent_obj=None):
"blocks": blocks,
"sessions": all_sessions,
"participants": all_participants,
"feedback": all_feedback,
"collect_data": collect_data,
},
)
Expand Down Expand Up @@ -545,6 +548,7 @@ def save_model(self, request, obj, form, change):
level=messages.WARNING,
)


class PhaseAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin):
list_display = (
"name_link",
Expand Down Expand Up @@ -576,6 +580,7 @@ def blocks(self, obj):

return format_html(", ".join([block.slug for block in blocks]))


class BlockTranslatedContentAdmin(admin.ModelAdmin):
list_display = ["name", "block", "language"]
list_filter = ["language"]
Expand All @@ -595,5 +600,6 @@ def blocks(self, obj):
", ".join([f'<a href="/admin/experiment/block/{block.id}/change/">{block.name}</a>' for block in blocks])
)


admin.site.register(Block, BlockAdmin)
admin.site.register(Experiment, ExperimentAdmin)
4 changes: 2 additions & 2 deletions backend/experiment/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,8 @@ class Meta:
}

class Media:
js = ["block_admin.js"]
css = {"all": ["block_admin.css"]}
js = ["block_admin.js", "collapsible_blocks.js"]
css = {"all": ["block_admin.css", "collapsible_blocks.css"]}


class ExportForm(Form):
Expand Down
17 changes: 17 additions & 0 deletions backend/experiment/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ def __str__(self):
translated_content = self.get_fallback_content()
return translated_content.name if translated_content else self.slug

@property
def name(self):
content = self.get_fallback_content()
return content.name if content and content.name else ""

class Meta:
verbose_name_plural = "Experiments"

Expand Down Expand Up @@ -83,6 +88,18 @@ def current_participants(self) -> list["Participant"]:
participants[session.participant.id] = session.participant
return participants.values()

def export_feedback(self) -> QuerySet[Session]:
"""export feedback for the blocks in this experiment
Returns:
Associated block feedback
"""

all_feedback = Feedback.objects.none()
for block in self.associated_blocks():
all_feedback |= Feedback.objects.filter(block=block)
return all_feedback

def get_fallback_content(self) -> "ExperimentTranslatedContent":
"""Get fallback content for the experiment
Expand Down
2 changes: 1 addition & 1 deletion backend/experiment/rules/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def get_play_again_url(self, session: Session):
if session.participant.participant_id_url
else ""
)
return f"/{session.block.slug}{participant_id_url_param}"
return f"/block/{session.block.slug}{participant_id_url_param}"

def calculate_intermediate_score(self, session, result):
"""process result data during a trial (i.e., between next_round calls)
Expand Down
4 changes: 2 additions & 2 deletions backend/experiment/rules/hooked.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,5 +330,5 @@ def next_heard_before_action(self, session: Session, round_number: int) -> Trial
def get_score(self, session: Session, round_number: int) -> Score:
config = {"show_section": True, "show_total_score": True}
title = self.get_trial_title(session, round_number)
previous_score = session.last_result(self.counted_result_keys).score
return Score(session, config=config, title=title, score=previous_score)
previous_result = session.last_result(self.counted_result_keys)
return Score(session, config=config, title=title, result=previous_result)
Loading

0 comments on commit c3f0cba

Please sign in to comment.