diff --git a/hypha/apply/funds/fields.py b/hypha/apply/funds/fields.py new file mode 100644 index 0000000000..629c99deff --- /dev/null +++ b/hypha/apply/funds/fields.py @@ -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" + ) diff --git a/hypha/apply/funds/forms.py b/hypha/apply/funds/forms.py index 5ddeeb000e..ac9d3f1a09 100644 --- a/hypha/apply/funds/forms.py +++ b/hypha/apply/funds/forms.py @@ -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, @@ -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") diff --git a/hypha/apply/funds/services.py b/hypha/apply/funds/services.py index 8d372e28b2..0a971cee4d 100644 --- a/hypha/apply/funds/services.py +++ b/hypha/apply/funds/services.py @@ -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 @@ -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( @@ -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 `
Hey from Hypha
` will result in a + string that is stripped of text formatting (untranslated: `Hey from Hypha
`). On + the other hand, unmixed strings like `Hey from Hypha
` 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. `Hey y'all
` -> `Hey y'all
` (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. `test
` or `yeet!` 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. `Hypha is cool
` contains the string "Hypha is" but due to the strong tag being mixed in will + # have None for the tag.string value. + # tags like `Hypha rocks
` will return false as thetag 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 diff --git a/hypha/apply/funds/templates/funds/applicationsubmission_admin_detail.html b/hypha/apply/funds/templates/funds/applicationsubmission_admin_detail.html index cadddf3ffe..62463ee0f4 100644 --- a/hypha/apply/funds/templates/funds/applicationsubmission_admin_detail.html +++ b/hypha/apply/funds/templates/funds/applicationsubmission_admin_detail.html @@ -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 %} @@ -98,4 +98,8 @@