Skip to content

Commit 7a68e69

Browse files
authored
Add the ability to translate submissions (redux) (#4219)
This builds on the #4134 PR that initially introduced machine translations into Hypha. This isolates the translation behavior; putting pip dependencies in a separate `requirements-translate.txt` and will not attempt any translate imports unless the setting for it is true. Other small changes are also a full docs page explaining how to install language packages & changing the setting once again from `SUBMISSION_TRANSLATIONS_ENABLED` to `APPLICATION_TRANSLATIONS_ENABLED` to reflect the system wide shift away from submission terminology.
1 parent 976fb9d commit 7a68e69

33 files changed

+1697
-9
lines changed

.github/workflows/hypha-ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ jobs:
3737
DJANGO_SETTINGS_MODULE: hypha.settings.test
3838
SEND_MESSAGES: false
3939
PYTHONDONTWRITEBYTECODE: 1
40+
APPLICATION_TRANSLATIONS_ENABLED: 1 # Run tests for machine translation logic
4041

4142
services:
4243
postgres:
@@ -75,6 +76,7 @@ jobs:
7576
run: |
7677
uv venv
7778
uv pip install -r requirements-dev.txt
79+
uv pip install -r requirements-translate.txt
7880
7981
- name: Check Django migrations
8082
if: matrix.group == 1

docs/setup/administrators/configuration.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,16 @@ Set this to enable Djangos settings for secure cookies.
245245

246246
COOKIE_SECURE = env.bool('COOKIE_SECURE', False)
247247

248+
----
249+
250+
Machine translation settings for applications
251+
252+
See [here](machine-translations.md) for more information on setting up machine translations
253+
254+
APPLICATION_TRANSLATIONS_ENABLED = env.bool("APPLICATION_TRANSLATIONS_ENABLED", False)
255+
256+
----
257+
248258
## Slack settings
249259

250260
SLACK_TOKEN = env.str('SLACK_TOKEN', None)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Machine translations
2+
3+
Hypha has the ability to utilize [argostranslate](https://github.com/argosopentech/argos-translate) for machine translations of submitted application content. This is disabled by default and the dependencies are not installed to prevent unneeded bloat due to [PyTorch](https://pytorch.org/)'s large language models.
4+
5+
6+
## Installing dependencies
7+
8+
As referenced in the [production deployment guide](../deployment/production/stand-alone.md), it is required to install the dependencies needed for machine translation dependencies via
9+
10+
```bash
11+
python3 -m pip install -r requirements-translate.txt
12+
```
13+
14+
This requirements file will specifically attempt to install the CPU version of [PyTorch](https://pytorch.org/) if available on the detected platform to play better with heroku (doesn't support GPU processing) and to minimize package bloat (CPU package is ~300MB less than the normal GPU). Depending on your use case, you may want to adjust this.
15+
16+
17+
## Installing languages
18+
19+
Argostranslate handles translations via it's own packages - ie. Arabic -> English translation would be one package, while English -> Arabic would be another.
20+
21+
Installing/uninstalling these packages can be done with the management commands `install_languages`/`uninstall_languages` respectively, utilizing the format of <from language code>_<to language code>. For example, installing the Arabic -> English & French -> English packages would look like:
22+
23+
```bash
24+
python3 manage.py install_languages ar_en fr_en
25+
```
26+
27+
## Enabling on the system
28+
29+
To enable machine translations on an instance, the proper configuration variables need to be set. These can be found in the [configuration options](configuration.md#hypha-custom-settings)

docs/setup/deployment/development/stand-alone.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,12 @@ source venv/bin/activate
278278
python3 -m pip install -r requirements-dev.txt
279279
```
280280

281+
If utilizing application machine translations, install the required dependencies:
282+
283+
```shell
284+
python3 -m pip install -r requirements-translate.txt
285+
```
286+
281287
Run:
282288
```shell
283289
make serve-docs

docs/setup/deployment/production/stand-alone.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ Next, install the required packages using:
105105
python3 -m pip install -r requirements.txt
106106
```
107107

108+
If utilizing application machine translations, install the required dependencies:
109+
110+
```shell
111+
python3 -m pip install -r requirements-translate.txt
112+
```
113+
108114
### Install Node packages
109115

110116
All the needed Node packages are listed in `package.json`. Install them with this command.

hypha/apply/funds/templates/funds/applicationsubmission_admin_detail.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{% extends "funds/applicationsubmission_detail.html" %}
2-
{% load i18n static workflow_tags review_tags determination_tags heroicons %}
2+
{% load i18n static workflow_tags review_tags determination_tags translate_tags heroicons %}
33

44
{% block extra_css %}
55
<link rel="stylesheet" href="{% static 'css/fancybox.css' %}">
@@ -98,4 +98,8 @@ <h5 class="m-0">{% trans "Reminders" %}</h5>
9898
<script src="{% static 'js/jquery.fancybox.min.js' %}"></script>
9999
<script src="{% static 'js/fancybox-global.js' %}"></script>
100100
<script src="{% static 'js/behaviours/collapse.js' %}"></script>
101+
<script src="{% static 'js/toggle-related.js' %}"></script>
102+
{% if request.user|can_translate_submission %}
103+
<script src="{% static 'js/translate-application.js' %}"></script>
104+
{% endif %}
101105
{% endblock %}

hypha/apply/funds/templates/funds/applicationsubmission_detail.html

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{% extends "base-apply.html" %}
2-
{% load i18n static workflow_tags wagtailcore_tags statusbar_tags archive_tags submission_tags %}
2+
{% load i18n static workflow_tags wagtailcore_tags statusbar_tags archive_tags submission_tags translate_tags %}
33
{% load heroicons %}
44
{% load can from permission_tags %}
55

@@ -148,8 +148,15 @@ <h5>{% blocktrans with stage=object.previous.stage %}Your {{ stage }} applicatio
148148
{% endif %}
149149
</div>
150150
</header>
151-
152-
{% include "funds/includes/rendered_answers.html" %}
151+
{% if request.user|can_translate_submission %}
152+
<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}'>
153+
{% include "funds/includes/rendered_answers.html" %}
154+
</div>
155+
{% else %}
156+
<div class="wrapper">
157+
{% include "funds/includes/rendered_answers.html" %}
158+
</div>
159+
{% endif %}
153160

154161
</article>
155162
{% endif %}

hypha/apply/funds/templates/funds/includes/admin_primary_actions.html

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{% load i18n %}
2-
{% load heroicons primaryactions_tags %}
2+
{% load heroicons primaryactions_tags translate_tags %}
33

44
<h5>{% trans "Actions to take" %}</h5>
55

@@ -84,6 +84,13 @@ <h5>{% trans "Actions to take" %}</h5>
8484
<summary class="sidebar__separator sidebar__separator--medium">{% trans "More actions" %}</summary>
8585
<a class="button button--white button--full-width button--bottom-space" href="{% url 'funds:submissions:revisions:list' submission_pk=object.id %}">{% trans "Revisions" %}</a>
8686

87+
{% if request.user|can_translate_submission %}
88+
<button class="button button--white button--full-width button--bottom-space" hx-get="{% url 'funds:submissions:translate' pk=object.pk %}" hx-target="#htmx-modal">
89+
{% heroicon_outline "language" aria_hidden="true" size=15 stroke_width=2 class="inline align-baseline me-1" %}
90+
{% trans "Translate" %}
91+
</button>
92+
{% endif %}
93+
8794
<button
8895
class="button button--white button--full-width button--bottom-space"
8996
hx-get="{% url 'funds:submissions:metaterms_update' pk=object.pk %}"

hypha/apply/funds/templates/funds/includes/rendered_answers.html

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
1-
{% load i18n wagtailusers_tags workflow_tags %}
2-
1+
{% load i18n wagtailusers_tags workflow_tags translate_tags heroicons %}
2+
{% if request.user|can_translate_submission %}
3+
{% if from_lang_name and to_lang_name %}
4+
{# For active translations #}
5+
<div class="w-full text-center my-2 py-5 border rounded-lg shadow-md">
6+
<span>
7+
{% heroicon_outline "language" aria_hidden="true" size=15 stroke_width=2 class="inline align-baseline me-1" %}
8+
{% blocktrans %} This application is translated from {{from_lang_name}} to {{to_lang_name}}. {% endblocktrans %}
9+
<a href="{% url 'funds:submissions:detail' object.id %}">
10+
{% trans "See original" %}
11+
</a>
12+
</span>
13+
</div>
14+
{% else %}
15+
{# For a translation loading indicator #}
16+
<div id="translate-card-loading" class="w-full text-center h-0 m-0 p-0 overflow-hidden content-center rounded-lg shadow-md animate-pulse htmx-indicator">
17+
<span class="w-[490px] bg-gray-200 rounded-lg"></span>
18+
</div>
19+
{% endif %}
20+
{% endif %}
321
<h3 class="text-xl border-b pb-2 font-bold">{% trans "Proposal Information" %}</h3>
422
<div class="hypha-grid hypha-grid--proposal-info">
523
{% if object.get_value_display != "-" %}
@@ -44,3 +62,18 @@ <h5 class="text-base">{% trans "Organization name" %}</h5>
4462
<div class="rich-text rich-text--answers">
4563
{{ object.output_answers }}
4664
</div>
65+
66+
<style type="text/css">
67+
#translate-card-loading.htmx-request.htmx-indicator{
68+
height: 64px;
69+
transition: height 0.25s ease-in;
70+
margin-top: 0.5rem;
71+
margin-bottom: 0.5rem;
72+
border-width: 1px;
73+
}
74+
75+
#translate-card-loading.htmx-request.htmx-indicator span {
76+
display: inline-block;
77+
height: 1rem;
78+
}
79+
</style>
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
{% load i18n static heroicons translate_tags %}
2+
{% modal_title %}{% trans "Translate" %}{% endmodal_title %}
3+
<form
4+
class="px-2 pb-4 form"
5+
id="translate_form"
6+
method="POST"
7+
action="{{ request.path }}"
8+
hx-post="{{ request.path }}"
9+
>
10+
{% csrf_token %}
11+
{{ form.media }}
12+
{% for hidden in form.hidden_fields %}
13+
{{ hidden }}
14+
{% endfor %}
15+
16+
<div>
17+
{% if form.errors %}
18+
{% for field in form %}
19+
{% for error in field.errors %}
20+
<div class="alert alert-danger">
21+
<strong>{{ error|escape }}</strong>
22+
</div>
23+
{% endfor %}
24+
{% endfor %}
25+
{% for error in form.non_field_errors %}
26+
<div class="alert alert-danger">
27+
<strong>{{ error|escape }}</strong>
28+
</div>
29+
{% endfor %}
30+
{% endif %}
31+
<div class="flex mt-3 justify-center space-x-2">
32+
<fieldset class="w-2/5">
33+
<div>
34+
{{ form.from_lang }}
35+
</div>
36+
</fieldset>
37+
<div class="flex flex-col justify-center">
38+
{% heroicon_outline "arrow-right" aria_hidden="true" size=15 stroke_width=2 class="inline align-baseline me-1" %}
39+
</div>
40+
<fieldset class="w-2/5">
41+
<div>
42+
{{ form.to_lang }}
43+
</div>
44+
</fieldset>
45+
</div>
46+
</div>
47+
48+
<div class="mt-5 sm:gap-4 sm:mt-4 sm:flex sm:flex-row-reverse">
49+
50+
{# Button text inserted below to prevent redundant translations #}
51+
<button id="translate-btn" class="w-full button button--primary sm:w-auto" type="submit"></button>
52+
53+
<button
54+
type="button"
55+
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"
56+
@click="show = false"
57+
>{% trans "Cancel" %}</button>
58+
<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>
59+
</div>
60+
</form>
61+
62+
<script type="module">
63+
import Choices from "{% static 'js/esm/choices.js-10-2-0.js' %}";
64+
65+
const choices = JSON.parse('{{ json_choices|safe }}')
66+
67+
{# Define translations for the button text #}
68+
const CLEAR_TEXT = "{% trans "Clear" %}"
69+
const TRANSLATE_TEXT = "{% trans "Translate" %}"
70+
71+
function getToChoices(from_lang) {
72+
const selected = choices.find((choice) => choice.value === from_lang)
73+
return selected ? selected.to : []
74+
}
75+
76+
// Check if a given from/to lang combo is the active translation based on the JSON provided from the server
77+
function isTranslationActive(newFromLang, newToLang) {
78+
const active = choices.find((choice) => choice.selected === true);
79+
if (!active) return false
80+
81+
const activeFrom = active.value;
82+
const activeTo = active.to.find((to) => to.selected === true);
83+
84+
return (newFromLang === activeFrom && newToLang == activeTo)
85+
}
86+
87+
// Change the button text to indicate the ability to clear the translation
88+
function showClearBtn(show) {
89+
translateBtn.textContent = show ? CLEAR_TEXT : TRANSLATE_TEXT
90+
}
91+
92+
const selectFromLang = new Choices(document.getElementById('id_from_lang'), { allowHTML: true }).setChoices(choices);
93+
const selectToLang = new Choices(document.getElementById('id_to_lang'), { allowHTML: true });
94+
const translateBtn = document.getElementById('translate-btn');
95+
96+
// Initial setting of "to language" choices/disabling of field depending on starting "from language" values
97+
if(selectFromLang.getValue()?.value) {
98+
selectToLang.setChoices(getToChoices(selectFromLang.getValue().value))
99+
showClearBtn(true)
100+
} else {
101+
showClearBtn(false)
102+
selectToLang.disable();
103+
}
104+
105+
// Event handler for when the "from language" selection is updated
106+
selectFromLang.passedElement.element.addEventListener('change', (event) => {
107+
const toLangChoices = getToChoices(event.detail.value)
108+
if (toLangChoices.length > 0) {
109+
selectToLang.setChoices(toLangChoices, 'value', 'label', true)
110+
selectToLang.enable();
111+
showClearBtn(isTranslationActive(event.detail.value, selectToLang.getValue().value));
112+
} else {
113+
selectToLang.disable();
114+
showClearBtn(false);
115+
translateBtn.disabled = true;
116+
}
117+
});
118+
119+
// Event handler for when "to language" selection is updated
120+
selectToLang.passedElement.element.addEventListener('change', (event) => {
121+
if (isTranslationActive(selectFromLang.getValue().value, event.detail.value)) {
122+
showClearBtn(true);
123+
}
124+
})
125+
</script>

0 commit comments

Comments
 (0)