Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions hypha/apply/funds/fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from typing import Any, Iterable, Literal, Optional

from argostranslate.package import Package
from django import forms


class LanguageChoiceField(forms.ChoiceField):
def __init__(
self,
role: Literal["to", "from"],
available_packages: Iterable[Package],
choices: Optional[Iterable[str]] = set(),
**kwargs,
) -> None:
self.available_packages = available_packages

# Ensure the given language is either "to" or "from"
if role not in ["to", "from"]:
raise ValueError(f'Invalid role "{role}", must be "to" or "from"')

self.role = role

super().__init__(choices=choices, **kwargs)
self.widget.attrs.update({"data-placeholder": f"{role.capitalize()}..."})

def validate(self, value: Any) -> None:
"""Basic validation to ensure the language (depending on role) is available in the installed packages

Only checks the language exists as from/to code, doesn't validate based on from -> to.
"""
if self.role == "from":
valid_list = [package.from_code for package in self.available_packages]
else:
valid_list = [package.to_code for package in self.available_packages]

if value not in valid_list:
raise forms.ValidationError(
"The specified language is either invalid or not installed"
)
28 changes: 28 additions & 0 deletions hypha/apply/funds/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
from wagtail.signal_handlers import disable_reference_index_auto_update

from hypha.apply.categories.models import MetaTerm
from hypha.apply.translate.utils import get_available_translations
from hypha.apply.users.models import User

from .fields import LanguageChoiceField
from .models import (
ApplicationSubmission,
AssignedReviewers,
Expand Down Expand Up @@ -377,6 +379,32 @@ def submissions_cant_have_external_reviewers(self, submissions):
return False


class TranslateSubmissionForm(forms.Form):
available_packages = get_available_translations()

from_lang = LanguageChoiceField("from", available_packages)
to_lang = LanguageChoiceField("to", available_packages)

def clean(self):
form_data = self.cleaned_data
try:
from_code = form_data["from_lang"]
to_code = form_data["to_lang"]

to_packages = get_available_translations([from_code])

if to_code not in [package.to_code for package in to_packages]:
self.add_error(
"to_lang",
"The specified language is either invalid or not installed",
)

return form_data
except KeyError as err:
# If one of the fields could not be parsed, there is likely bad input being given
raise forms.ValidationError("Invalid input selected") from err


def make_role_reviewer_fields():
role_fields = []
staff_reviewers = User.objects.staff().only("full_name", "pk")
Expand Down
90 changes: 90 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, element
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 import translate


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


def translate_application_form_data(application, from_code: str, to_code: str) -> dict:
"""Translate the content of an application's live revision `form_data`.
Will parse fields that contain both plaintext & HTML, extracting & replacing strings.

NOTE: Mixed formatting like `<p>Hey from <strong>Hypha</strong></p>` will result in a
string that is stripped of text formatting (untranslated: `<p>Hey from Hypha</p>`). On
the other hand, unmixed strings like `<p><strong>Hey from Hypha</strong></p>` will be
replaced within formatting tags.

Args:
application: the application 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 (including nested HTML strings)

Raises:
ValueError if an invalid `from_code` or `to_code` is requested
"""
form_data: dict = application.live_revision.form_data

translated_form_data = form_data.copy()

# Only translate content fields or the title - don't with name, email, etc.
translated_form_data["title"] = translate.translate(
form_data["title"], from_code, to_code
)

# RegEx to match wagtail's generated field UIDs - ie. "97c51cea-ab47-4a64-a64a-15d893788ef2"
uid_regex = re.compile(r"([a-z]|\d){8}(-([a-z]|\d){4}){3}-([a-z]|\d){12}")
fields_to_translate = [
key
for key in form_data
if uid_regex.match(key) and isinstance(form_data[key], str)
]

for key in fields_to_translate:
field_html = BeautifulSoup(form_data[key], "html.parser")
if field_html.find(): # Check if BS detected any HTML
for field in field_html.find_all(has_valid_str):
# Removes formatting if mixed into the tag to prioritize context in translation
# ie. `<p>Hey <strong>y'all</strong></p>` -> `<p>Hey y'all</p>` (but translated)
to_translate = field.string if field.string else field.text
field.clear()
field.string = translate.translate(to_translate, from_code, to_code)

translated_form_data[key] = str(field_html)
# Ensure the field value isn't empty & translate as is
elif form_data[key].strip():
translated_form_data[key] = translate.translate(
form_data[key], from_code, to_code
)

return translated_form_data


def has_valid_str(tag: element.Tag) -> bool:
"""Checks that an Tag contains a valid text element and/or string.

Args:
tag: a `bs4.element.Tag`
Returns:
bool: True if has a valid string that isn't whitespace or `-`
"""
text_elem = tag.name in ["span", "p", "strong", "em", "td", "a"]

try:
# try block logic handles elements that have text directly in them
# ie. `<p>test</p>` or `<em>yeet!</em>` would return true as string values would be contained in tag.string
ret = bool(
text_elem
and tag.find(string=True, recursive=False)
and tag.string.strip(" -\n")
)
return ret
except AttributeError:
# except block logic handles embedded tag strings where tag.string == None but the specified tag DOES contain a string
# ie. `<p>Hypha is <strong>cool</strong></p>` contains the string "Hypha is" but due to the strong tag being mixed in will
# have None for the tag.string value.
# tags like `<p> <em>Hypha rocks</em> </p>` will return false as the <p> tag contains no valid strings, it's child does.
tag_contents = "".join(tag.find_all(string=True, recursive=False))
ret = bool(tag.text and tag.text.strip() and tag_contents.strip())
return ret
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% extends "funds/applicationsubmission_detail.html" %}
{% load i18n static workflow_tags review_tags determination_tags heroicons %}
{% load i18n static workflow_tags review_tags determination_tags translate_tags heroicons %}

{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/fancybox.css' %}">
Expand Down Expand Up @@ -98,4 +98,8 @@ <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/behaviours/collapse.js' %}"></script>
<script src="{% static 'js/toggle-related.js' %}"></script>
{% if request.user|can_translate_submission %}
<script src="{% static 'js/translate-application.js' %}"></script>
{% endif %}
{% endblock %}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{% extends "base-apply.html" %}
{% load i18n static workflow_tags wagtailcore_tags statusbar_tags archive_tags submission_tags %}
{% load i18n static workflow_tags wagtailcore_tags statusbar_tags archive_tags submission_tags translate_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 %}
Expand All @@ -22,7 +22,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 @@ -144,8 +146,15 @@ <h5>{% blocktrans with stage=object.previous.stage %}Your {{ stage }} applicatio
{% endif %}
</div>
</header>

{% include "funds/includes/rendered_answers.html" %}
{% if request.user|can_translate_submission %}
<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>
{% else %}
<div class="wrapper">
{% include "funds/includes/rendered_answers.html" %}
</div>
{% endif %}

</article>
{% endif %}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% load i18n %}
{% load heroicons primaryactions_tags %}
{% load heroicons primaryactions_tags translate_tags %}

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

Expand Down Expand Up @@ -84,6 +84,13 @@ <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>

{% if request.user|can_translate_submission %}
<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>
{% endif %}

<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 translate_tags heroicons %}
{% if request.user|can_translate_submission and 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>
Loading