Skip to content

Commit

Permalink
Merge pull request #1330 from Amsterdam-Music-Lab/feature-er/duplicat…
Browse files Browse the repository at this point in the history
…e-experiment

Feature er/duplicate experiment
  • Loading branch information
Evert-R authored Nov 12, 2024
2 parents 327b206 + 47c85b3 commit 48ef536
Show file tree
Hide file tree
Showing 4 changed files with 334 additions and 3 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
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 %}
142 changes: 140 additions & 2 deletions backend/experiment/tests/test_admin_experiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
from django.urls import reverse
from django.utils.html import format_html
from experiment.admin import BlockAdmin, ExperimentAdmin, PhaseAdmin
from experiment.models import Block, Experiment, Phase, ExperimentTranslatedContent
from experiment.models import Block, Experiment, Phase, ExperimentTranslatedContent, BlockTranslatedContent
from participant.models import Participant
from result.models import Result
from session.models import Session

from section.models import Playlist
from theme.models import ThemeConfig
from question.models import QuestionSeries, QuestionInSeries, Question
from question.questions import create_default_questions

# Expected field count per model
EXPECTED_BLOCK_FIELDS = 10
Expand Down Expand Up @@ -237,3 +240,138 @@ def test_experiment_with_blocks(self):
", ".join([f"{block.slug}" for block in [block1, block2]])
)
self.assertEqual(blocks, expected_blocks)


class TestDuplicateExperiment(TestCase):
@classmethod
def setUpTestData(cls):
cls.experiment = Experiment.objects.create(slug="original")
ExperimentTranslatedContent.objects.create(
experiment=cls.experiment,
language="en",
name="original experiment",
)
ExperimentTranslatedContent.objects.create(
experiment=cls.experiment,
language="nl",
name="origineel experiment",
)
cls.first_phase = Phase.objects.create(
index=1, randomize=False, dashboard=True, experiment=cls.experiment
)
cls.second_phase = Phase.objects.create(
index=2, randomize=False, dashboard=True, experiment=cls.experiment
)
cls.playlist1 = Playlist.objects.create(name="first")
cls.playlist2 = Playlist.objects.create(name="second")
cls.theme = ThemeConfig.objects.create(name='test_theme')

cls.block1 = Block.objects.create(slug="block1", phase=cls.first_phase, theme_config=cls.theme)
cls.block2 = Block.objects.create(slug="block2", phase=cls.first_phase, theme_config=cls.theme)
cls.block3 = Block.objects.create(slug="block3", phase=cls.second_phase, theme_config=cls.theme)
cls.block4 = Block.objects.create(slug="block4", phase=cls.second_phase, theme_config=cls.theme)

cls.block1.playlists.add(cls.playlist1)
cls.block1.playlists.add(cls.playlist2)
cls.block1.save()
create_default_questions()
cls.question_series = QuestionSeries.objects.create(block=cls.block2, index=0)
cls.questions = Question.objects.all()
index = 0
for question in cls.questions:
QuestionInSeries.objects.create(question_series = cls.question_series,
question=question,
index=index)
index += 1

cls.questions_in_series = QuestionInSeries.objects.all()

BlockTranslatedContent.objects.create(
block=cls.block1,
language="en",
name="First block",
description="Block1 description"
)
BlockTranslatedContent.objects.create(
block=cls.block1,
language="nl",
name="Eerste blok",
description="Block1 omschrijving"
)
BlockTranslatedContent.objects.create(
block=cls.block2,
language="en",
name="Second block",
description="Block2 description"
)
BlockTranslatedContent.objects.create(
block=cls.block2,
language="nl",
name="Tweede blok",
description="Block2 omschrijving"
)
BlockTranslatedContent.objects.create(
block=cls.block3,
language="en",
name="Third block",
description="Block3 description"
)
BlockTranslatedContent.objects.create(
block=cls.block3,
language="nl",
name="Derde blok",
description="Block3 omschrijving"
)
BlockTranslatedContent.objects.create(
block=cls.block4,
language="en",
name="Fourth block",
description="Block4 description"
)
BlockTranslatedContent.objects.create(
block=cls.block4,
language="nl",
name="Vierde blok",
description="Block4 omschrijving"
)

def setUp(self):
self.admin = ExperimentAdmin(model=Experiment, admin_site=AdminSite)

def test_duplicate_experiment(self):
request = MockRequest()
request.POST = {"_duplicate": "",
"slug-extension": "duplitest"}
response = self.admin.duplicate(request, self.experiment)

new_exp = Experiment.objects.last()
all_experiments = Experiment.objects.all()
all_exp_content = ExperimentTranslatedContent.objects.all()

all_phases = Phase.objects.all()

all_blocks = Block.objects.all()
last_block = Block.objects.last()
all_block_content = BlockTranslatedContent.objects.all()
new_block1 = Block.objects.get(slug="block1-duplitest")

all_question_series = QuestionSeries.objects.all()
all_questions = Question.objects.all()

self.assertEqual(all_experiments.count(), 2)
self.assertEqual(all_exp_content.count(), 4)
self.assertEqual(new_exp.slug, 'original-duplitest')

self.assertEqual(all_phases.count(), 4)

self.assertEqual(all_blocks.count(), 8)
self.assertEqual(all_block_content.count(), 16)
self.assertEqual(last_block.slug, 'block4-duplitest')
self.assertEqual(last_block.theme_config.name, 'test_theme')

self.assertEqual(new_block1.playlists.all().count(), 2)

self.assertEqual(all_question_series.count(), 2)
self.assertEqual(self.questions.count(), (all_questions.count()))

self.assertEqual(response.status_code, 302)

0 comments on commit 48ef536

Please sign in to comment.