diff --git a/hypha/apply/funds/forms.py b/hypha/apply/funds/forms.py index 5ddeeb000e..2fc2c4601c 100644 --- a/hypha/apply/funds/forms.py +++ b/hypha/apply/funds/forms.py @@ -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") diff --git a/hypha/apply/funds/services.py b/hypha/apply/funds/services.py index 8d372e28b2..e3138d4624 100644 --- a/hypha/apply/funds/services.py +++ b/hypha/apply/funds/services.py @@ -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 @@ -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( @@ -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 diff --git a/hypha/apply/funds/templates/funds/applicationsubmission_admin_detail.html b/hypha/apply/funds/templates/funds/applicationsubmission_admin_detail.html index 9716487011..651e42eca2 100644 --- a/hypha/apply/funds/templates/funds/applicationsubmission_admin_detail.html +++ b/hypha/apply/funds/templates/funds/applicationsubmission_admin_detail.html @@ -98,4 +98,5 @@
{% trans "Reminders" %}
+ {% endblock %} diff --git a/hypha/apply/funds/templates/funds/applicationsubmission_detail.html b/hypha/apply/funds/templates/funds/applicationsubmission_detail.html index b495af2147..3e1106c51c 100644 --- a/hypha/apply/funds/templates/funds/applicationsubmission_detail.html +++ b/hypha/apply/funds/templates/funds/applicationsubmission_detail.html @@ -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 %} @@ -21,7 +21,9 @@ {% trans "Back to submissions" %} {% endif %} -

{{ object.title }} #{{ object.public_id|default:object.id }}

+

+ {{ object.title }} #{{ object.public_id|default:object.id }} +

{{ object.stage }} {{ object.page }} @@ -143,8 +145,9 @@
{% blocktrans with stage=object.previous.stage %}Your {{ stage }} applicatio {% endif %}
- - {% include "funds/includes/rendered_answers.html" %} +
+ {% include "funds/includes/rendered_answers.html" %} +
{% endif %} diff --git a/hypha/apply/funds/templates/funds/includes/admin_primary_actions.html b/hypha/apply/funds/templates/funds/includes/admin_primary_actions.html index 89892ff9b3..a821413866 100644 --- a/hypha/apply/funds/templates/funds/includes/admin_primary_actions.html +++ b/hypha/apply/funds/templates/funds/includes/admin_primary_actions.html @@ -84,6 +84,11 @@
{% trans "Actions to take" %}
{% trans "More actions" %} {% trans "Revisions" %} + + + + + {% heroicon_outline "information-circle" aria_hidden="true" size=15 stroke_width=2 class="inline align-baseline me-1" %} + + + + \ No newline at end of file diff --git a/hypha/apply/funds/templates/submissions/partials/submission-title.html b/hypha/apply/funds/templates/submissions/partials/submission-title.html new file mode 100644 index 0000000000..e3038ceb93 --- /dev/null +++ b/hypha/apply/funds/templates/submissions/partials/submission-title.html @@ -0,0 +1 @@ +

{{ object.title }} #{{ object.public_id|default:object.id }}

\ No newline at end of file diff --git a/hypha/apply/funds/templatetags/submission_tags.py b/hypha/apply/funds/templatetags/submission_tags.py index ed1cc8782c..a4c152b83b 100644 --- a/hypha/apply/funds/templatetags/submission_tags.py +++ b/hypha/apply/funds/templatetags/submission_tags.py @@ -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( diff --git a/hypha/apply/funds/templatetags/translate_tags.py b/hypha/apply/funds/templatetags/translate_tags.py new file mode 100644 index 0000000000..6446e3ec2e --- /dev/null +++ b/hypha/apply/funds/templatetags/translate_tags.py @@ -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)) diff --git a/hypha/apply/funds/urls.py b/hypha/apply/funds/urls.py index 895c088b52..114fb337d6 100644 --- a/hypha/apply/funds/urls.py +++ b/hypha/apply/funds/urls.py @@ -27,12 +27,14 @@ SubmissionResultView, SubmissionsByStatus, SubmissionSealedView, + TranslateSubmissionView, UpdateLeadView, UpdateMetaTermsView, UpdatePartnersView, UpdateReviewersView, htmx_archive_unarchive_submission, partial_screening_card, + partial_translate_answers, reminder_list, submission_success, ) @@ -197,6 +199,11 @@ partial_meta_terms_card, name="partial-meta-terms-card", ), + path( + "partial/translate/answers", + partial_translate_answers, + name="partial-translate-answers", + ), path( "project/create/", CreateProjectView.as_view(), @@ -212,6 +219,11 @@ ReminderCreateView.as_view(), name="create_reminder", ), + path( + "translate/", + TranslateSubmissionView.as_view(), + name="translate", + ), path( "progress/", ProgressSubmissionView.as_view(), name="progress" ), diff --git a/hypha/apply/funds/views.py b/hypha/apply/funds/views.py index 7ad6ed2260..4ca25be02a 100644 --- a/hypha/apply/funds/views.py +++ b/hypha/apply/funds/views.py @@ -2,6 +2,7 @@ from copy import copy from datetime import timedelta from typing import Generator, Tuple +from urllib.parse import urlparse import django_tables2 as tables from django.conf import settings @@ -21,6 +22,7 @@ HttpRequest, HttpResponse, HttpResponseRedirect, + QueryDict, ) from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse_lazy @@ -61,11 +63,13 @@ DeterminationCreateOrUpdateView, ) from hypha.apply.funds.models.screening import ScreeningStatus +from hypha.apply.funds.templatetags.submission_tags import doc_title from hypha.apply.projects.forms import ProjectCreateForm from hypha.apply.review.models import Review from hypha.apply.stream_forms.blocks import GroupToggleBlock from hypha.apply.todo.options import PROJECT_WAITING_PAF from hypha.apply.todo.views import add_task_to_user +from hypha.apply.translate.utils import get_lang_name_from_code, get_translation_params from hypha.apply.users.decorators import ( is_apply_staff, staff_or_finance_required, @@ -91,6 +95,7 @@ BatchUpdateSubmissionLeadForm, CreateReminderForm, ProgressSubmissionForm, + TranslateSubmissionForm, UpdateMetaTermsForm, UpdatePartnersForm, UpdateReviewersForm, @@ -986,6 +991,64 @@ def post(self, *args, **kwargs): ) +@method_decorator(staff_required, name="dispatch") +class TranslateSubmissionView(View): + template = "funds/includes/translate_application_form.html" + + def dispatch(self, request, *args, **kwargs): + self.submission = get_object_or_404(ApplicationSubmission, id=kwargs.get("pk")) + if not request.user.is_org_faculty: + messages.warning( + self.request, + "User attempted to translate submission but is not org faculty", + ) + return HttpResponseRedirect(self.submission.get_absolute_url()) + return super(TranslateSubmissionView, self).dispatch(request, *args, **kwargs) + + def get(self, *args, **kwargs): + translate_form = TranslateSubmissionForm() + return render( + self.request, + self.template, + context={ + "form": translate_form, + "value": _("Update"), + "object": self.submission, + }, + ) + + def post(self, request, *args, **kwargs): + form = TranslateSubmissionForm(self.request.POST) + + if form.is_valid(): + FROM_LANG_KEY = "from_lang" + TO_LANG_KEY = "to_lang" + + from_lang = form.cleaned_data[FROM_LANG_KEY] + to_lang = form.cleaned_data[TO_LANG_KEY] + + return HttpResponse( + status=204, + headers={ + "HX-Trigger": json.dumps( + { + "translateSubmission": { + FROM_LANG_KEY: from_lang, + TO_LANG_KEY: to_lang, + } + } + ), + }, + ) + + return render( + self.request, + self.template, + context={"form": form, "value": _("Update"), "object": self.submission}, + status=400, + ) + + @login_required @user_passes_test(is_apply_staff) @require_http_methods(["GET"]) @@ -1096,6 +1159,31 @@ def dispatch(self, request, *args, **kwargs): redirect = SubmissionSealedView.should_redirect(request, submission) return redirect or super().dispatch(request, *args, **kwargs) + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + self.object = self.get_object() + + extra_context = {} + + # Check for language params - if they exist and are valid then update the context + if lang_params := get_translation_params(request=request): + from_lang, to_lang = lang_params + try: + self.object.form_data = services.translate_submission_form_data( + self.object, from_lang, to_lang + ) + extra_context.update( + { + "from_lang_name": get_lang_name_from_code(from_lang), + "to_lang_name": get_lang_name_from_code(to_lang), + } + ) + except ValueError: + # Language package isn't valid or installed, redirect to the submission w/o params + return redirect(self.object.get_absolute_url()) + + context = self.get_context_data(object=self.object, **extra_context) + return self.render_to_response(context) + def get_context_data(self, **kwargs): other_submissions = ( self.model.objects.filter(user=self.object.user) @@ -1170,6 +1258,93 @@ def partial_screening_card(request, pk): return render(request, "funds/includes/screening_status_block.html", ctx) +@login_required +def partial_translate_answers(request: HttpRequest, pk: int) -> HttpResponse: + """Partial to translate submissions's answers + + Args: + request: HttpRequest object + pk: pk of the submission to translate + + """ + submission = get_object_or_404(ApplicationSubmission, pk=pk) + + if not request.user.is_org_faculty or request.method != "GET": + return HttpResponse(status=204) + + ctx = {"object": submission} + + # The existing params that were in the URL when the request was made + prev_params = get_translation_params(request.headers.get("Hx-Current-Url", "")) + # The requested params provided in the GET request + params = get_translation_params(request=request) + + updated_url = submission.get_absolute_url() + + message = None + + if params and not params[0] == params[1] and not params == prev_params: + from_lang, to_lang = params + try: + submission.form_data = services.translate_submission_form_data( + submission, from_lang, to_lang + ) + + if current_url := request.headers.get("Hx-Current-Url"): + updated_params = QueryDict(urlparse(current_url).query, mutable=True) + updated_params["fl"] = from_lang + updated_params["tl"] = to_lang + updated_url = f"{updated_url}?{updated_params.urlencode()}" + + to_lang_name = get_lang_name_from_code(to_lang) + from_lang_name = get_lang_name_from_code(from_lang) + + message = _("Submission translated from {fl} to {tl}.").format( + fl=from_lang_name, tl=to_lang_name + ) + + ctx.update( + { + "object": submission, + "from_lang_name": from_lang_name, + "to_lang_name": to_lang_name, + } + ) + except ValueError: + # TODO: WA Error/failed message type rather than success + message = _("Submission translation failed. Contact your Administrator.") + return HttpResponse( + status=400, + headers={"HX-Trigger": json.dumps({"showMessage": {message}})}, + ) + + elif params == prev_params: + message = _("Translation cleared.") + + response = render(request, "funds/includes/rendered_answers.html", ctx) + + trigger_dict = {} + if title := submission.form_data.get("title"): + trigger_dict.update( + { + "translatedSubmission": { + "appTitle": title, + "docTitle": doc_title(submission), + } + } + ) + + if message: + trigger_dict.update({"showMessage": message}) + + if trigger_dict: + response["HX-Trigger"] = json.dumps(trigger_dict) + + response["HX-Replace-Url"] = updated_url + + return response + + class ReviewerSubmissionDetailView(ActivityContextMixin, DelegateableView, DetailView): template_name_suffix = "_reviewer_detail" model = ApplicationSubmission diff --git a/hypha/apply/translate/__init__.py b/hypha/apply/translate/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hypha/apply/translate/translate.py b/hypha/apply/translate/translate.py new file mode 100644 index 0000000000..b672ced18d --- /dev/null +++ b/hypha/apply/translate/translate.py @@ -0,0 +1,58 @@ +from typing import List, Optional + +import argostranslate.package +import argostranslate.translate + + +def get_available_translations( + from_codes: Optional[List[str]] = None, +) -> List[argostranslate.package.Package]: + """Get languages available for translation + + Args: + from_codes: optionally specify a list of languages to view available translations to + + Returns: + A list of argostranslate package objects that are installed and available. + """ + + available_packages = argostranslate.package.get_installed_packages() + + if not from_codes: + return available_packages + + return list(filter(lambda x: x.from_code in from_codes, available_packages)) + + +def translate(string: str, from_code: str, to_code: str) -> str: + """Translate a string from one language to another + + Requires the request language's argostranslate package to be installed first + + Args: + string: the string 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: + str: the translated string + + Raises: + ValueError: if the requested language translation package is not installed or request is invalid + """ + + if from_code == to_code: + raise ValueError("Translation from_code cannot match to_code") + + available_translations = get_available_translations([from_code]) + + if not available_translations or to_code not in [ + package.to_code for package in available_translations + ]: + raise ValueError(f"Package {from_code} -> {to_code} is not installed") + + print(f"\nAttempting to translate {from_code} -> {to_code}\n ") + + translated_text = argostranslate.translate.translate(string, from_code, to_code) + + return translated_text diff --git a/hypha/apply/translate/utils.py b/hypha/apply/translate/utils.py new file mode 100644 index 0000000000..aabbcd65ed --- /dev/null +++ b/hypha/apply/translate/utils.py @@ -0,0 +1,44 @@ +from typing import Tuple +from urllib.parse import parse_qs, urlparse + +import argostranslate.translate +from django.http import HttpRequest + + +def get_translation_params( + url: str = None, request: HttpRequest = None +) -> Tuple[str, str] | None: + r"""Attempts to extract the `fl` (from language) & `tl` (to language) params from the provided URL or request object + + Return values are *not* validated to ensure languages are valid & packages exist. + + Args: + url: the URL to extract the params from + + Returns: + tuple: in the format of (\, \) + + Raises: + ValueError: If `url`/`request` are not provided OR if both are provided + """ + + # Ensure either url or request is provided but not both. + if not (bool(url) ^ bool(request)): + raise ValueError("Either a URL or HttpRequest must be provided.") + + if url: + query_dict = {k: v[0] for (k, v) in parse_qs(urlparse(url).query).items()} + else: + query_dict = request.GET + + if (to_lang := query_dict.get("tl")) and (from_lang := query_dict.get("fl")): + return (from_lang, to_lang) + + return None + + +def get_lang_name_from_code(from_code: str) -> str | None: + try: + return argostranslate.translate.get_language_from_code(from_code).name + except AttributeError: + return None diff --git a/hypha/settings/django.py b/hypha/settings/django.py index 8682287c4d..dceb815967 100644 --- a/hypha/settings/django.py +++ b/hypha/settings/django.py @@ -84,10 +84,13 @@ "django.contrib.sitemaps", "django.forms", "formtools", + "corsheaders", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "elevate.middleware.ElevateMiddleware", diff --git a/hypha/static_src/javascript/application-translate.js b/hypha/static_src/javascript/application-translate.js new file mode 100644 index 0000000000..b7a4f99e27 --- /dev/null +++ b/hypha/static_src/javascript/application-translate.js @@ -0,0 +1,11 @@ +(function () { + "use strict"; + htmx.on("translatedSubmission", (event) => { + if (event.detail?.appTitle) { + document.getElementById("app-title").textContent = + event.detail.appTitle; + } + + if (event.detail?.docTitle) document.title = event.detail.docTitle; + }); +})();