Skip to content

Commit

Permalink
Added: Add multi-languageBlockTranslatedContent in relation to `Blo…
Browse files Browse the repository at this point in the history
…ck` (#1227)

* feat: Add BlockTranslatedContent model and relate it to Block with a ManyToMany relationship

* refactor: Migrate block content and associated models

* refactor: Add `translated_content` as `ManyToMany` field of `Block` instead of on `BlockTranslatedContent`

* feat: Warn user about missing translated contents

* refactor: Remove hashtag, url, consent & language from block

* refactor: Move warnings to remarks column

* fix: Fix many tests after removing consent/url/hashtags from block model

* doc: Update consent's doc string

* fix: Fix minor test

* fix: Fix remainder of tests

* fix: Validating the model before saving the model and its relations results into errors as the validator looks for translated content using the FKs

* refactor: Handle missing language in content

* fix: Re-add Hooked ID

* refactor(`BlockTranslatedContent`) Refactor `Block`-`BlockTranslatedContent` relationship to `ForeignKey`

- Update Block model to use ForeignKey for translated_contents
- Modify migrations to reflect new relationship
- Adjust admin interface to handle inline editing of translated contents
- Update related queries and methods to use new relationship structure

* refactor: Use tabular inline for block translated content

* fix: Fix border radius top left in markdown input

* feat: Add all necessary fields for experiment translated content to its form

* chore: Remove unnecessary print statement

* fix: Add `BlockTranslatedContentInline` inline to `BlockAdmin`

* fix: Create temporary fix for tabular inline headings appearing out of its h2 element

See also:
- theatlantic/django-nested-admin#261
- theatlantic/django-nested-admin#259

* chore: Incorporate latest version of `django-nested-admin` that includes fix for the tabular inline form's heading

* refactor: Rename test to be more descriptive

* refactor: Incorporate the migration of #1240
  • Loading branch information
drikusroor authored Sep 4, 2024
1 parent 1c7743a commit 0204a9c
Show file tree
Hide file tree
Showing 39 changed files with 1,409 additions and 853 deletions.
31 changes: 16 additions & 15 deletions backend/experiment/actions/consent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.template.loader import render_to_string
from django.template import Template, Context
from django_markup.markup import formatter
from django.core.files import File

from .base_action import BaseAction

Expand All @@ -11,13 +12,13 @@ def get_render_format(url: str) -> str:
"""
Detect markdown file based on file extension
"""
if splitext(url)[1] == '.md':
return 'MARKDOWN'
return 'HTML'
if splitext(url)[1] == ".md":
return "MARKDOWN"
return "HTML"


def render_html_or_markdown(dry_text: str, render_format: str) -> str:
'''
"""
render html or markdown
Parameters:
Expand All @@ -26,19 +27,19 @@ def render_html_or_markdown(dry_text: str, render_format: str) -> str:
Returns:
a string of content rendered to html
'''
if render_format == 'HTML':
"""
if render_format == "HTML":
template = Template(dry_text)
context = Context()
return template.render(context)
if render_format == 'MARKDOWN':
return formatter(dry_text, filter_name='markdown')
if render_format == "MARKDOWN":
return formatter(dry_text, filter_name="markdown")


class Consent(BaseAction): # pylint: disable=too-few-public-methods
"""
Provide data for a view that ask consent for using the experiment data
- text: Uploaded file via block.consent (fileField)
- text: Uploaded file via an experiment's translated content's consent (fileField)
- title: The title to be displayed
- confirm: The text on the confirm button
- deny: The text on the deny button
Expand All @@ -50,7 +51,7 @@ class Consent(BaseAction): # pylint: disable=too-few-public-methods
"""

# default consent text, that can be used for multiple blocks
ID = 'CONSENT'
ID = "CONSENT"

default_text = "Lorem ipsum dolor sit amet, nec te atqui scribentur. Diam \
molestie posidonium te sit, ea sea expetenda suscipiantur \
Expand All @@ -63,21 +64,21 @@ class Consent(BaseAction): # pylint: disable=too-few-public-methods
amet, nec te atqui scribentur. Diam molestie posidonium te sit, \
ea sea expetenda suscipiantur contentiones."

def __init__(self, text, title='Informed consent', confirm='I agree', deny='Stop', url=''):
def __init__(self, text: File, title="Informed consent", confirm="I agree", deny="Stop", url=""):
# Determine which text to use
if text!='':
if text != "":
# Uploaded consent via file field: block.consent (High priority)
with text.open('r') as f:
with text.open("r") as f:
dry_text = f.read()
render_format = get_render_format(text.url)
elif url!='':
elif url != "":
# Template file via url (Low priority)
dry_text = render_to_string(url)
render_format = get_render_format(url)
else:
# use default text
dry_text = self.default_text
render_format = 'HTML'
render_format = "HTML"
# render text fot the consent component
self.text = render_html_or_markdown(dry_text, render_format)
self.title = title
Expand Down
97 changes: 77 additions & 20 deletions backend/experiment/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,35 @@
from io import BytesIO

from django.conf import settings
from django.contrib import admin
from django.contrib import admin, messages
from django.db import models
from django.utils import timezone
from django.core import serializers
from django.shortcuts import render, redirect
from django.forms import CheckboxSelectMultiple
from django.http import HttpResponse

from inline_actions.admin import InlineActionsModelAdminMixin
from django.urls import reverse
from django.utils.html import format_html

from nested_admin import NestedModelAdmin, NestedStackedInline, NestedTabularInline

from experiment.utils import check_missing_translations, get_flag_emoji, get_missing_content_blocks

from experiment.models import (
Block,
Experiment,
Phase,
Feedback,
SocialMediaConfig,
ExperimentTranslatedContent,
BlockTranslatedContent,
)
from question.admin import QuestionSeriesInline
from experiment.forms import (
ExperimentForm,
ExperimentTranslatedContentForm,
BlockForm,
ExportForm,
TemplateForm,
Expand All @@ -46,9 +51,19 @@ class FeedbackInline(admin.TabularInline):
extra = 0


class BlockTranslatedContentInline(NestedTabularInline):
model = BlockTranslatedContent

def get_extra(self, request, obj=None, **kwargs):
if obj:
return 0
return 1


class ExperimentTranslatedContentInline(NestedStackedInline):
model = ExperimentTranslatedContent
sortable_field_name = "index"
form = ExperimentTranslatedContentForm

def get_extra(self, request, obj=None, **kwargs):
if obj:
Expand All @@ -75,18 +90,14 @@ class BlockAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin):
"description",
"image",
"slug",
"url",
"hashtag",
"theme_config",
"language",
"active",
"rules",
"rounds",
"bonus_points",
"playlists",
"consent",
]
inlines = [QuestionSeriesInline, FeedbackInline]
inlines = [QuestionSeriesInline, FeedbackInline, BlockTranslatedContentInline]
form = BlockForm

# make playlists fields a list of checkboxes
Expand Down Expand Up @@ -232,6 +243,8 @@ def block_slug_link(self, obj):
class BlockInline(NestedStackedInline):
model = Block
sortable_field_name = "index"
inlines = [BlockTranslatedContentInline]
form = BlockForm

def get_extra(self, request, obj=None, **kwargs):
if obj:
Expand Down Expand Up @@ -289,6 +302,7 @@ class Media:

def name(self, obj):
content = obj.get_fallback_content()

return content.name if content else "No name"

def slug_link(self, obj):
Expand All @@ -300,17 +314,15 @@ def slug_link(self, obj):
)

def description_excerpt(self, obj):
fallback_content = obj.get_fallback_content()
description = (
fallback_content.description if fallback_content and fallback_content.description else "No description"
)
experiment_fallback_content = obj.get_fallback_content()
description = experiment_fallback_content.description if experiment_fallback_content else "No description"
if len(description) < 50:
return description

return description[:50] + "..."

def phases(self, obj):
phases = Phase.objects.filter(series=obj)
phases = Phase.objects.filter(experiment=obj)
return format_html(
", ".join([f'<a href="/admin/experiment/phase/{phase.id}/change/">{phase.name}</a>' for phase in phases])
)
Expand Down Expand Up @@ -382,13 +394,21 @@ def remarks(self, obj):
}
)

if not remarks_array:
remarks_array.append({"level": "success", "message": "✅ All good", "title": "No issues found."})

supported_languages = obj.translated_content.values_list("language", flat=True).distinct()

# TODO: Check if all blocks support the same languages as the experiment
# Implement this when the blocks have been updated to support multiple languages
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
Expand All @@ -399,12 +419,28 @@ def remarks(self, obj):
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>'
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.name} does not have content in {', '.join(missing_language_flags)}",
level=messages.WARNING,
)


admin.site.register(Experiment, ExperimentAdmin)

Expand All @@ -418,7 +454,7 @@ class PhaseAdmin(InlineActionsModelAdminMixin, admin.ModelAdmin):
"randomize",
"blocks",
)
fields = ["name", "series", "index", "dashboard", "randomize"]
fields = ["name", "experiment", "index", "dashboard", "randomize"]
inlines = [BlockInline]

def name_link(self, obj):
Expand All @@ -427,8 +463,8 @@ def name_link(self, obj):
return format_html('<a href="{}">{}</a>', url, obj_name)

def related_experiment(self, obj):
url = reverse("admin:experiment_experiment_change", args=[obj.series.pk])
content = obj.series.get_fallback_content()
url = reverse("admin:experiment_experiment_change", args=[obj.experiment.pk])
content = obj.experiment.get_fallback_content()
experiment_name = content.name if content else "No name"
return format_html('<a href="{}">{}</a>', url, experiment_name)

Expand All @@ -444,3 +480,24 @@ def blocks(self, obj):


admin.site.register(Phase, PhaseAdmin)


@admin.register(BlockTranslatedContent)
class BlockTranslatedContentAdmin(admin.ModelAdmin):
list_display = ["name", "block", "language"]
list_filter = ["language"]
search_fields = [
"name",
"block__name",
]

def blocks(self, obj):
# Block is manytomany, so we need to find it through the related name
blocks = Block.objects.filter(translated_contents=obj)

if not blocks:
return "No block"

return format_html(
", ".join([f'<a href="/admin/experiment/block/{block.id}/change/">{block.name}</a>' for block in blocks])
)
Loading

0 comments on commit 0204a9c

Please sign in to comment.