265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540 | class ExperimentAdmin(InlineActionsModelAdminMixin, NestedModelAdmin):
list_display = (
"name",
"slug_link",
"remarks",
"active",
)
fields = [
"slug",
"active",
"theme_config",
]
inline_actions = ["experimenter_dashboard", "duplicate"]
form = ExperimentForm
inlines = [
ExperimentTranslatedContentInline,
PhaseInline,
SocialMediaConfigInline,
]
class Media:
css = {"all": ("experiment_admin.css",)}
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
url = f"http://localhost:3000/{obj.slug}" if dev_mode else f"/{obj.slug}"
return format_html(
f'<a href="{url}" target="_blank" rel="noopener noreferrer" title="Open {obj.slug} experiment group in new tab" >{obj.slug} <small>↗</small></a>'
)
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()
all_participants = obj.current_participants()
all_sessions = obj.export_sessions()
collect_data = {
"participant_count": len(all_participants),
"session_count": len(all_sessions),
}
blocks = [
{
"id": block.id,
"slug": block.slug,
"started": len(all_sessions.filter(block=block)),
"finished": len(
all_sessions.filter(
block=block,
finished_at__isnull=False,
)
),
"participant_count": len(block.current_participants()),
"participants": block.current_participants(),
}
for block in all_blocks
]
return render(
request,
"experiment-dashboard.html",
context={
"experiment": obj,
"blocks": blocks,
"sessions": all_sessions,
"participants": all_participants,
"collect_data": collect_data,
},
)
def remarks(self, obj):
remarks_array = []
# Check if there is any translated content
content = obj.translated_content.first()
if not content:
remarks_array.append(
{
"level": "warning",
"message": "📝 No content",
"title": "Add content in at least one language to this experiment.",
}
)
has_consent = obj.translated_content.filter(consent__isnull=False).exclude(consent="").first()
if not has_consent:
remarks_array.append(
{
"level": "info",
"message": "📋 No consent form",
"title": "You may want to add a consent form (approved by an ethical board) to this experiment.",
}
)
supported_languages = obj.translated_content.values_list("language", flat=True).distinct()
missing_content_block_translations = check_missing_translations(obj)
if missing_content_block_translations:
remarks_array.append(
{
"level": "warning",
"message": "🌍 Missing block content",
"title": missing_content_block_translations,
}
)
if not remarks_array:
remarks_array.append({"level": "success", "message": "✅ All good", "title": "No issues found."})
# TODO: Check if all theme configs support the same languages as the experiment
# Implement this when the theme configs have been updated to support multiple languages
# TODO: Check if all social media configs support the same languages as the experiment
# Implement this when the social media configs have been updated to support multiple languages
return format_html(
"\n".join(
[
f'<span class="badge badge-{remark["level"]} whitespace-nowrap text-xs mt-1" title="{remark.get("title") if remark.get("title") else remark["message"]}">{remark["message"]}</span><br>'
for remark in remarks_array
]
)
)
def save_model(self, request, obj, form, change):
# Save the model
super().save_model(request, obj, form, change)
# Check for missing translations after saving
missing_content_blocks = get_missing_content_blocks(obj)
if missing_content_blocks:
for block, missing_languages in missing_content_blocks:
missing_language_flags = [get_flag_emoji(language) for language in missing_languages]
self.message_user(
request,
f"Block {block.slug} does not have content in {', '.join(missing_language_flags)}",
level=messages.WARNING,
)
|