Skip to content

Commit

Permalink
Merge branch 'develop' of https://github.com/Amsterdam-Music-Lab/MUSCLE
Browse files Browse the repository at this point in the history
… into develop
  • Loading branch information
BeritJanssen committed Nov 12, 2024
2 parents 100da44 + 48ef536 commit 48ea7b3
Show file tree
Hide file tree
Showing 10 changed files with 564 additions and 131 deletions.
130 changes: 129 additions & 1 deletion backend/experiment/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from section.models import Section, Song
from result.models import Result
from participant.models import Participant
from question.models import QuestionSeries, QuestionInSeries


class FeedbackInline(admin.TabularInline):
Expand Down Expand Up @@ -273,7 +274,7 @@ class ExperimentAdmin(InlineActionsModelAdminMixin, NestedModelAdmin):
"active",
"theme_config",
]
inline_actions = ["experimenter_dashboard"]
inline_actions = ["experimenter_dashboard", "duplicate"]
form = ExperimentForm
inlines = [
ExperimentTranslatedContentInline,
Expand All @@ -288,6 +289,9 @@ 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"))

def slug_link(self, obj):
dev_mode = settings.DEBUG is True
Expand All @@ -299,6 +303,130 @@ def slug_link(self, obj):

slug_link.short_description = "Slug"

def duplicate(self, request, obj, parent_obj=None):
"""Duplicate an experiment"""

if "_duplicate" in request.POST:
# Get slug from the form
extension = request.POST.get("slug-extension")
if extension == "":
extension = "copy"
slug_extension = f"-{extension}"

# 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.")
if extension.lower() != extension:
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.")
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.")
# Return to form with error messages
if len(messages.get_messages(request)) != 0:
return render(
request,
"duplicate-experiment.html",
context={"exp": obj},
)

# order_by is inserted here to prevent a query error
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()

# Duplicate Experiment object
exp_copy = obj
exp_copy.pk = None
exp_copy._state.adding = True
exp_copy.slug = f"{obj.slug}{slug_extension}"
exp_copy.save()

# Duplicate experiment translated content objects
for content in exp_contents:
exp_content_copy = content
exp_content_copy.pk = None
exp_content_copy._state.adding = True
exp_content_copy.experiment = exp_copy
exp_content_copy.save()

# Duplicate phases
for phase in exp_phases:
these_blocks = Block.objects.filter(phase=phase)

phase_copy = phase
phase_copy.pk = None
phase_copy._state.adding = True
phase_copy.save()

# 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()
these_playlists = block.playlists.all()
question_series = QuestionSeries.objects.filter(block=block)

block_copy = block
block_copy.pk = None
block_copy._state.adding = True
block_copy.slug = f"{block.slug}{slug_extension}"
block_copy.phase = phase_copy
block_copy.save()
block_copy.playlists.set(these_playlists)

# Duplicate Block translated content objects
for content in block_contents:
block_content_copy = content
block_content_copy.pk = None
block_content_copy._state.adding = True
block_content_copy.block = block_copy
block_content_copy.save()

# Duplicate the Block QuestionSeries
for series in question_series:
all_in_series = QuestionInSeries.objects.filter(question_series=series)
these_questions = series.questions.all()
series_copy = series
series_copy.pk = None
series_copy._state.adding = True
series_copy.block = block_copy
series_copy.index = block.index
series_copy.save()

# Duplicate the QuestionSeries QuestionInSeries
for in_series in all_in_series:
in_series_copy = in_series
in_series_copy.pk = None
in_series_copy._state.adding = True
in_series_copy.question_series = series
in_series_copy.save()
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()

# Show experiment duplicate form
return render(
request,
"duplicate-experiment.html",
context={"exp": obj},
)

def experimenter_dashboard(self, request, obj, parent_obj=None):
"""Open researchers dashboard for an experiment"""
all_blocks = obj.associated_blocks()
Expand Down
2 changes: 1 addition & 1 deletion backend/experiment/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ class Phase(models.Model):
def __str__(self):
default_content = self.experiment.get_fallback_content()
experiment_name = default_content.name if default_content else None
compound_name = experiment_name or self.experiment.slug or "Unnamed phase"
compound_name = experiment_name or self.experiment.slug or "Unnamed experiment"
return f"{compound_name} ({self.index})"

class Meta:
Expand Down
11 changes: 3 additions & 8 deletions backend/experiment/rules/categorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,13 @@ def get_intro_explainer(self):
def next_round(self, session: Session):
json_data = session.json_data

if json_data.get("started"):
if not json_data.get("phase"):
actions = [self.get_intro_explainer()]
questions = self.get_open_questions(session)
if questions:
actions.extend(questions)
session.save_json_data({"started": True})
return actions

json_data = session.json_data

# Plan experiment on the first call to next_round
if not json_data.get("phase"):
json_data = self.plan_experiment(session)
return actions

# Check if this participant already has a session
if json_data == "REPEAT":
Expand Down Expand Up @@ -232,6 +226,7 @@ def next_round(self, session: Session):
session=session,
final_text=final_text + final_message,
total_score=round(score_percent),
rank=rank,
points="% correct",
)
return final
Expand Down
49 changes: 27 additions & 22 deletions backend/experiment/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from experiment.actions.consent import Consent
from image.serializers import serialize_image
from participant.models import Participant
from result.models import Result
from session.models import Session
from theme.serializers import serialize_theme
from .models import Block, Experiment, Phase, SocialMediaConfig
Expand Down Expand Up @@ -90,29 +91,26 @@ def serialize_social_media_config(
}


def serialize_phase(phase: Phase, participant: Participant) -> dict:
def serialize_phase(phase: Phase, participant: Participant, times_played: int) -> dict:
"""Serialize phase
Args:
phase: Phase instance
participant: Participant instance
Returns:
Dashboard info for a participant
A dictionary of the dashboard (if applicable), the next block, and the total score of the phase
"""
blocks = list(phase.blocks.order_by("index").all())

blocks = list(Block.objects.filter(phase=phase.id).order_by("index"))
next_block = get_upcoming_block(phase, participant, times_played)
if not next_block:
return None

total_score = get_total_score(blocks, participant)
if phase.randomize:
shuffle(blocks)

next_block = get_upcoming_block(blocks, participant, phase.dashboard)

total_score = get_total_score(blocks, participant)

if not next_block:
return None

return {
"dashboard": [serialize_block(block, participant) for block in blocks] if phase.dashboard else [],
"nextBlock": next_block,
Expand All @@ -139,21 +137,27 @@ def serialize_block(block_object: Block, language: str = "en") -> dict:
}


def get_upcoming_block(block_list: list[Block], participant: Participant, repeat_allowed: bool = True):
def get_upcoming_block(phase: Phase, participant: Participant, times_played: int):
"""return next block with minimum finished sessions for this participant
if repeated blocks are not allowed (dashboard=False) and there are only finished sessions, return None
if all blocks have been played an equal number of times, return None
Args:
block_list: List of Block instances
participant: Participant instance
repeat_allowed: Allow repeating a block
phase: Phase for which next block needs to be picked
participant: Participant for which next block needs to be picked
"""
blocks = list(phase.blocks.all())

finished_session_counts = [get_finished_session_count(block, participant) for block in block_list]
minimum_session_count = min(finished_session_counts)
if not repeat_allowed and minimum_session_count != 0:
return None
return serialize_block(block_list[finished_session_counts.index(minimum_session_count)], participant)
shuffle(blocks)
finished_session_counts = [
get_finished_session_count(block, participant) for block in blocks
]

min_session_count = min(finished_session_counts)
if not phase.dashboard:
if times_played != min_session_count:
return None
next_block_index = finished_session_counts.index(min_session_count)
return serialize_block(blocks[next_block_index])


def get_started_session_count(block: Block, participant: Participant) -> int:
Expand Down Expand Up @@ -182,8 +186,9 @@ def get_finished_session_count(block: Block, participant: Participant) -> int:
Number of finished sessions for this block and participant
"""

count = Session.objects.filter(block=block, participant=participant, finished_at__isnull=False).count()
return count
return Session.objects.filter(
block=block, participant=participant, finished_at__isnull=False
).count()


def get_total_score(blocks: list, participant: Participant) -> int:
Expand Down
4 changes: 4 additions & 0 deletions backend/experiment/static/experiment_admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@
.mt-1 {
margin-top: 0.25rem;
}

#changelist table input {
margin-right: 0.3rem;
}
61 changes: 61 additions & 0 deletions backend/experiment/templates/duplicate-experiment.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{% extends "admin/base_site.html" %} {% load inline_action_tags %} {% block content %}
<style>
form {
margin-top: 3rem;
max-width: 1024px;
}

label {
display: block;
margin-bottom: 1rem;
}

input[type="text"] {
width: 25rem;
}

input[type="submit"] {
margin-top: 3rem;
}

.help,
.error {
color: var(--body-quiet-color);
font-size: 12px;
}
.error {
color: var(--error-fg);
}
#back-button {
float: right;
}

</style>
<h1>Duplicate experiment with slug: {{exp.slug}}</h1>
<p>The following objects will be duplicated:</p>
<ul>
<li>Experiment, ExperimentTranslatedContent</li>
<li>Phase</li>
<li>Block, BlockTranslatedContent</li>
<li>QuestionSeries, QuestionInSeries</li>
</ul>
<p>The following existing objects and files will be reused and assigned to the duplicated objects:</p>
<ul>
<li>Playlist</li>
<li>ThemeConfig</li>
<li>Question</li>
<li>Consent files</li>
</ul>

<form action="" method="post">
{% csrf_token %}{% render_inline_action_fields %}

<label for="slug-extension">Enter an extension to add to the slugs in the duplicated experiment:</label>
<input type="text" name="slug-extension" maxlength="128" id="slug-extension" autocapitalize="off"><br>

<p class="help">E.g. entering 'copy' here will add '-copy' to the existing Experiment and Block slugs.</p>

<input type="submit" name="_duplicate" value="Duplicate" id="duplicate" /><br>
<input type="submit" name="_back" value="Go back" id="back-button" />
</form>
{% endblock %}
Loading

0 comments on commit 48ea7b3

Please sign in to comment.