Skip to content

Commit

Permalink
Initial implementation of application translations
Browse files Browse the repository at this point in the history
  • Loading branch information
wes-otf committed Sep 13, 2024
1 parent a9c0cc2 commit 651378e
Show file tree
Hide file tree
Showing 17 changed files with 610 additions and 5 deletions.
14 changes: 14 additions & 0 deletions hypha/apply/funds/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,20 @@ def submissions_cant_have_external_reviewers(self, submissions):
return False


class TranslateSubmissionForm(forms.Form):
from_lang = forms.ChoiceField(choices=[])
to_lang = forms.ChoiceField(choices=[])

def is_valid(self) -> bool:
self.cleaned_data = self.data
# TODO: WA fix logic to actually validate the form
return True

def clean(self):
self.cleaned_data = self.data
return self.cleaned_data


def make_role_reviewer_fields():
role_fields = []
staff_reviewers = User.objects.staff().only("full_name", "pk")
Expand Down
56 changes: 56 additions & 0 deletions hypha/apply/funds/services.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import re

from bs4 import BeautifulSoup
from django.apps import apps
from django.conf import settings
from django.core.exceptions import PermissionDenied
Expand All @@ -20,6 +23,7 @@
from hypha.apply.funds.models.assigned_reviewers import AssignedReviewers
from hypha.apply.funds.workflow import INITIAL_STATE
from hypha.apply.review.options import DISAGREE, MAYBE
from hypha.apply.translate.translate import translate


def bulk_archive_submissions(
Expand Down Expand Up @@ -260,3 +264,55 @@ def annotate_review_recommendation_and_count(submissions: QuerySet) -> QuerySet:
),
)
return submissions


def translate_submission_form_data(
submission, from_code: str, to_code: str
) -> dict | None:
"""Translate the content of an application's `form_data`
Args:
submission: the submission to translate
from_code: the ISO 639 code of the original language
to_code: the ISO 639 code of the language to translate to
Returns:
The `form_data` with values translated if succcessful, otherwise `None`
Raises:
ValueError if an invalid `from_code` or `to_code` is requested
"""

translated_form_data = {}

form_data = submission.live_revision.form_data

for key in form_data:
# Only translate content fields or the title - don't with name, email, etc.
if key == "title":
translated_form_data[key] = translate(form_data[key], from_code, to_code)
elif key == "title" or re.match(
r"([a-z]|\d){8}(-([a-z]|\d){4}){3}-([a-z]|\d){12}", key
):
field_html = BeautifulSoup(form_data[key], "html.parser")
if field_html.find():
text_fields = [
field
for field in field_html.findAll(["span", "p", "strong", "td"])
if field.find(string=True, recursive=False)
]
for field in text_fields:
if field.string:
field["lang"] = to_code
field.string = translate(field.string, from_code, to_code)

translated_form_data[key] = str(field_html)
else:
translated_form_data[key] = translate(
form_data[key], from_code, to_code
)
else:
translated_form_data[key] = form_data[key]

return translated_form_data
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,5 @@ <h5 class="m-0">{% trans "Reminders" %}</h5>
<script src="{% static 'js/jquery.fancybox.min.js' %}"></script>
<script src="{% static 'js/fancybox-global.js' %}"></script>
<script src="{% static 'js/toggle-related.js' %}"></script>
<script src="{% static 'js/application-translate.js' %}"></script>
{% endblock %}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
{% load i18n static workflow_tags wagtailcore_tags statusbar_tags archive_tags submission_tags %}
{% load heroicons %}

{% block title %}#{{ object.public_id|default_if_none:object.id}}: {{ object.title }}{% endblock %}
{% block title %}{{ object|doc_title }}{% endblock %}
{% block body_class %}{% endblock %}
{% block content %}
{% if object.round.specific.is_sealed %}
Expand All @@ -21,7 +21,9 @@
{% trans "Back to submissions" %}
</a>
{% endif %}
<h1 class="mt-2 mb-0 font-medium">{{ object.title }}<span class="text-gray-400"> #{{ object.public_id|default:object.id }}</span></h1>
<h1 class="mt-2 mb-0 font-medium">
<span id="app-title">{{ object.title }}</span><span class="text-gray-400"> #{{ object.public_id|default:object.id }}</span>
</h1>
<div class="mt-1 text-sm font-medium heading heading--meta">
<span>{{ object.stage }}</span>
<span>{{ object.page }}</span>
Expand Down Expand Up @@ -143,8 +145,9 @@ <h5>{% blocktrans with stage=object.previous.stage %}Your {{ stage }} applicatio
{% endif %}
</div>
</header>

{% include "funds/includes/rendered_answers.html" %}
<div class="wrapper" hx-get="{% url 'funds:submissions:partial-translate-answers' object.id %}" hx-trigger="translateSubmission from:body" hx-indicator="#translate-card-loading" hx-vals='js:{fl: event.detail.from_lang, tl: event.detail.to_lang}'>
{% include "funds/includes/rendered_answers.html" %}
</div>

</article>
{% endif %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ <h5>{% trans "Actions to take" %}</h5>
<summary class="sidebar__separator sidebar__separator--medium">{% trans "More actions" %}</summary>
<a class="button button--white button--full-width button--bottom-space" href="{% url 'funds:submissions:revisions:list' submission_pk=object.id %}">{% trans "Revisions" %}</a>

<button class="button button--white button--full-width button--bottom-space" hx-get="{% url 'funds:submissions:translate' pk=object.pk %}" hx-target="#htmx-modal">
{% heroicon_outline "language" aria_hidden="true" size=15 stroke_width=2 class="inline align-baseline me-1" %}
{% trans "Translate" %}
</button>

<button
class="button button--white button--full-width button--bottom-space"
hx-get="{% url 'funds:submissions:metaterms_update' pk=object.pk %}"
Expand Down
44 changes: 43 additions & 1 deletion hypha/apply/funds/templates/funds/includes/rendered_answers.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
{% load i18n wagtailusers_tags workflow_tags %}
{% load i18n wagtailusers_tags workflow_tags heroicons %}
{% if from_lang_name and to_lang_name %}
<div class="w-full text-center my-2 py-5 border rounded-lg shadow-md">
<span>
{% heroicon_outline "language" aria_hidden="true" size=15 stroke_width=2 class="inline align-baseline me-1" %}
{% blocktrans %} This application is translated from {{from_lang_name}} to {{to_lang_name}}. {% endblocktrans %}
<a href="{% url 'funds:submissions:detail' object.id %}">
{% trans "See original" %}
</a>
</span>
</div>
{% else %}
<div id="translate-card-loading" class="w-full text-center my-2 py-5 border rounded-lg shadow-md animate-pulse htmx-indicator">
<span class="w-full bg-gray-200 rounded-lg"></span>
</div>
{% endif %}
<h3 class="text-xl border-b pb-2 font-bold">{% trans "Proposal Information" %}</h3>
<div class="hypha-grid hypha-grid--proposal-info">
{% if object.get_value_display != "-" %}
Expand Down Expand Up @@ -43,3 +58,30 @@ <h5 class="text-base">{% trans "Organization name" %}</h5>
<div class="rich-text rich-text--answers">
{{ object.output_answers }}
</div>

<style type="text/css">
#translate-card-loading span {
width: 490px;
}

#translate-card-loading.htmx-indicator{
height: 0;
margin: 0;
padding: 0;
overflow: hidden;
align-content: center;
border-width: 0px;
}
#translate-card-loading.htmx-request.htmx-indicator{
height: 64px;
transition: height 0.25s ease-in;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
border-width: 1px;
}

#translate-card-loading.htmx-request.htmx-indicator span {
display: inline-block;
height: 1rem;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
{% load i18n static heroicons translate_tags %}
{% modal_title %}{% trans "Translate" %}{% endmodal_title %}
<form
class="px-2 pb-4 form"
id="translate_form"
method="POST"
action="{{ request.path }}"
hx-post="{{ request.path }}"
>
{% csrf_token %}
{{ form.media }}
{% for hidden in form.hidden_fields %}
{{ hidden }}
{% endfor %}

<div>
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<div class="alert alert-danger">
<strong>{{ error|escape }}</strong>
</div>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<div class="alert alert-danger">
<strong>{{ error|escape }}</strong>
</div>
{% endfor %}
{% endif %}
<div class="flex mt-3 justify-center space-x-2">
<fieldset class="w-2/5">
<div>
{{ form.from_lang }}
</div>
</fieldset>
<div class="flex flex-col justify-center">
{% heroicon_outline "arrow-right" aria_hidden="true" size=15 stroke_width=2 class="inline align-baseline me-1" %}
</div>
<fieldset class="w-2/5">
<div>
{{ form.to_lang }}
</div>
</fieldset>
</div>
</div>

<div class="mt-5 sm:gap-4 sm:mt-4 sm:flex sm:flex-row-reverse">

{# Button text inserted below to prevent redundant translations #}
<button id="translate-btn" class="w-full button button--primary sm:w-auto" type="submit"></button>

<button
type="button"
class="inline-flex items-center justify-center w-full px-3 py-2 mt-3 text-sm font-semibold text-gray-900 bg-white rounded-sm shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto"
@click="show = false"
>{% trans "Cancel" %}</button>
<span class="inline-block" data-tooltip="{% trans "Translations are an experimental feature and may be inaccurate" %}">{% heroicon_outline "information-circle" aria_hidden="true" size=15 stroke_width=2 class="inline align-baseline me-1" %}</span>
</div>
</form>

<script type="module">
import Choices from "{% static 'js/esm/choices.js-10-2-0.js' %}";

{% get_language_choices_json as choices_json %}
const choices = JSON.parse('{{ choices_json }}')

{# Define translations for the button text #}
const CLEAR_TEXT = "{% trans "Clear" %}"
const TRANSLATE_TEXT = "{% trans "Translate" %}"

function getToLangChoices(from_lang) {
const selected = choices.find((choice) => choice.code === from_lang)
if (!selected) return []

const default_lang = selected.selectedTo ? selected.selectedTo : selected.to[0].code

return selected.to.map((choice) => {return {value: choice.code, label: choice.name, selected: default_lang}})
}

function checkForActiveTranslation(newFromLang, newToLang) {
const active = choices.find((choice) => choice.selected === true);

if (!active) return false

const activeFrom = active.code;
const activeTo = active.selectedTo;

return (newFromLang === activeFrom && newToLang == activeTo)
}

function showClearBtn(show) {
translateBtn.textContent = show ? CLEAR_TEXT : TRANSLATE_TEXT
}

const fromLangChoices = choices.map((choice) => {
return {value: choice.code, label: choice.name, selected: choice.selected}
})

const selectFromLang = new Choices(document.getElementById('id_from_lang'), { allowHTML: true }).setChoices(fromLangChoices);
const selectToLang = new Choices(document.getElementById('id_to_lang'), { allowHTML: true });
const translateBtn = document.getElementById('translate-btn');
const clearBtn = document.getElementById('clear-btn');

if(selectFromLang.getValue()?.value) {
selectToLang.setChoices(getToLangChoices(selectFromLang.getValue().value))
showClearBtn(true)
} else {
showClearBtn(false)
selectToLang.disable();
}

selectFromLang.passedElement.element.addEventListener('change', (event) => {
if (fromLangChoices.map((choice) => choice.value).includes(event.detail.value)) {
selectToLang.setChoices(getToLangChoices(event.detail.value), 'value', 'label', true)
selectToLang.enable();
showClearBtn(checkForActiveTranslation(event.detail.value, selectToLang.getValue().value));
} else {
selectToLang.disable();
}
});

selectToLang.passedElement.element.addEventListener('change', (event) => {
if (checkForActiveTranslation(selectFromLang.getValue().value, event.detail.value)) {
showClearBtn(true);
}
})
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1 class="mt-2 mb-0 font-medium">{{ object.title }}<span class="text-gray-400"> #{{ object.public_id|default:object.id }}</span></h1>
6 changes: 6 additions & 0 deletions hypha/apply/funds/templatetags/submission_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ def submission_links(value):
return mark_safe(value)


@register.filter
def doc_title(submission) -> str:
id = submission.public_id if submission.public_id else object.id
return f"#{id}: { submission.title }"


@register.simple_tag
def user_can_delete_submission(submission, user):
permission, _ = has_permission(
Expand Down
46 changes: 46 additions & 0 deletions hypha/apply/funds/templatetags/translate_tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import json

from django import template
from django.conf import settings
from django.utils.safestring import mark_safe

from hypha.apply.translate.translate import get_available_translations
from hypha.apply.translate.utils import get_lang_name_from_code, get_translation_params

register = template.Library()


@register.simple_tag(takes_context=True)
def get_language_choices_json(context) -> str:
available_translations = get_available_translations()
from_langs = {package.from_code for package in available_translations}
default_to_lang = settings.LANGUAGE_CODE
default_from_lang = None

# If there's existing lang params, use those as the default in the form
if (current_url := context["request"].headers.get("Hx-Current-Url")) and (
params := get_translation_params(current_url)
):
default_from_lang, default_to_lang = params

choices = []
for lang in from_langs:
to_langs = [
package.to_code
for package in available_translations
if package.from_code == lang
]
choices.append(
{
"code": lang,
"name": get_lang_name_from_code(lang),
"to": [
{"code": to_lang, "name": get_lang_name_from_code(to_lang)}
for to_lang in to_langs
],
"selectedTo": default_to_lang if default_to_lang in to_langs else None,
"selected": lang == default_from_lang,
}
)

return mark_safe(json.dumps(choices))
Loading

0 comments on commit 651378e

Please sign in to comment.