From a174380594d42e175b43b6301ada3ee7d10144f5 Mon Sep 17 00:00:00 2001 From: tory Date: Mon, 17 Aug 2026 16:01:44 +0200 Subject: [PATCH] Update translation state to MACHINE_TRANSLATED when an outdated translation is machine translated without changes --- .../models/abstract_content_translation.py | 19 ++++ .../utils/machine_translation_api_client.py | 92 ++++++++++++++----- integreat_cms/locale/de/LC_MESSAGES/django.po | 17 ++++ .../release_notes/current/unreleased/4320.yml | 2 + tests/mt_api/mt_api_test.py | 88 ++++++++++++++++++ 5 files changed, 194 insertions(+), 24 deletions(-) create mode 100644 integreat_cms/release_notes/current/unreleased/4320.yml diff --git a/integreat_cms/cms/models/abstract_content_translation.py b/integreat_cms/cms/models/abstract_content_translation.py index 04bdfdd12d..8ebda3b847 100644 --- a/integreat_cms/cms/models/abstract_content_translation.py +++ b/integreat_cms/cms/models/abstract_content_translation.py @@ -632,6 +632,25 @@ def create_new_version_copy( return new_translation + def save_new_version( + self, + user: User | None = None, + ) -> AbstractContentTranslation: + """ + Create and save a new minor-edit version of this translation + """ + from ..utils.content_translation_utils import save_new_version_with_retry + from ..utils.link_ignore_preservation import preserve_ignored_links + + new_version = self.create_new_version_copy(user) + + with transaction.atomic(), preserve_ignored_links(self): + self.links.all().delete() + return save_new_version_with_retry( + new_version, + new_version.save, + ) + def replace_urls( self, urls_to_replace: dict[str, str], diff --git a/integreat_cms/core/utils/machine_translation_api_client.py b/integreat_cms/core/utils/machine_translation_api_client.py index d4b5c7033b..efe50553eb 100644 --- a/integreat_cms/core/utils/machine_translation_api_client.py +++ b/integreat_cms/core/utils/machine_translation_api_client.py @@ -18,6 +18,7 @@ from ...cms.constants.machine_translatable_fields import TRANSLATABLE_FIELDS from ...cms.constants.machine_translation_budget import MINIMAL +from ...cms.models.abstract_content_translation import AbstractContentTranslation from ...cms.utils.stringify_list import iter_to_string from ...textlab_api.utils import check_hix_score from .word_count import word_count @@ -31,7 +32,6 @@ Region, ) from ...cms.models.abstract_content_model import AbstractContentModel - from ...cms.models.abstract_content_translation import AbstractContentTranslation logger = logging.getLogger(__name__) @@ -59,6 +59,7 @@ class MachineTranslationApiClient(ABC): form_class: ModelFormMetaclass #: Successful translations successful_translations: list[TranslationContext] = [] + refreshed_translations: list[TranslationContext] = [] #: Translations with an attached API failure failed_translations: list[str] = [] #: Content objects untranslatable since no actual changes were made @@ -98,6 +99,7 @@ def reset(self) -> None: shown to the user. """ self.successful_translations = [] + self.refreshed_translations = [] self.failed_translations = [] self.failed_because_no_changes_made = [] self.failed_because_no_source_translation = [] @@ -172,15 +174,30 @@ def translate_queryset( self.model_name = meta.verbose_name.title() self.model_name_plural = meta.verbose_name_plural + # Prepare all content objects + all_contexts = self.prepare_content_objects() + + # Separate contexts into those with changes and those without + changed_contexts = [] + unchanged_contexts = [] + + for ctx in all_contexts: + if bool(ctx.translatable_attributes): + changed_contexts.append(ctx) + else: + unchanged_contexts.append(ctx) + + self.handle_unchanged_contexts(unchanged_contexts) + # Filter out content objects which can not be translated - context = self.prepare_content_objects() - context = self.filter_no_source_translation(context) - context = self.filter_unchanged_translations(context) - context = self.filter_insufficient_hix_score(context) - context = self.filter_exceeds_limit(context) + contexts_to_translate = self.filter_no_source_translation(changed_contexts) + contexts_to_translate = self.filter_insufficient_hix_score( + contexts_to_translate + ) + contexts_to_translate = self.filter_exceeds_limit(contexts_to_translate) - # Provider-API-specific implementation - self.invoke_translation_api(context) + # Provider-API-specific implementation for changed contexts + self.invoke_translation_api(contexts_to_translate) # Update remaining budget of the region region.mt_budget_used += sum( @@ -190,6 +207,7 @@ def translate_queryset( # Show success/error messages to the user self.alert_successful_translations() + self.alert_refreshed_translations() self.alert_failed_translations() self.alert_no_changes_made() self.alert_no_source_translation() @@ -197,31 +215,34 @@ def translate_queryset( self.alert_exceeds_limit() self.alert_too_long_text() - def filter_unchanged_translations( - self, context: list[TranslationContext] - ) -> list[TranslationContext]: + def handle_unchanged_contexts( + self, unchanged_contexts: list[TranslationContext] + ) -> None: """ - This method filters out entries from the context list - if there have been no changes made to the source_translation. + Handle content objects without changes. - The removed entries are stored in order to show users - batched error messages after all objects have been handled. + If there are no changes but the target translation is outdated, a new + translation version is created with ``minor_edit=True`` to refresh its + status without flagging it as a content change. + Otherwise, the translation is not updated because no changes were detected. - :param context: The list of translation contexts to filter - :return: The filtered list of translation contexts + Push notifications are excluded from this processing because they do not + use ``AbstractContentTranslation`` and therefore have no outdated status + to refresh. """ - filtered_context = [] - - for ctx in context: - if bool(ctx.translatable_attributes): - filtered_context.append(ctx) + for ctx in unchanged_contexts: + target_translation = ctx.existing_target_translation + if ( + isinstance(target_translation, AbstractContentTranslation) + and target_translation.is_outdated + ): + target_translation.save_new_version(self.request.user) + self.refreshed_translations.append(ctx) else: self.failed_because_no_changes_made.append( ctx.instance.best_translation.title ) - return filtered_context - def filter_no_source_translation( self, context: list[TranslationContext] ) -> list[TranslationContext]: @@ -469,6 +490,29 @@ def save_translation(self, ctx: TranslationContext, translation_data: dict) -> N else: self.mark_unsuccessful(ctx, content_translation_form.errors) + def alert_refreshed_translations(self) -> None: + """ + Add messages informing the user about refreshed translations + """ + if self.refreshed_translations: + messages.info( + self.request, + ngettext_lazy( + "{model_name} {object_names}: Translation into '{target_language}' was not necessary -> no changes detected. The translation date has been refreshed.", + "{model_name} {object_names}: Translation into '{target_language}' was not necessary -> no changes detected. The translation dates have been refreshed.", + len(self.refreshed_translations), + ).format( + model_name=self.model_name, + model_name_plural=self.model_name_plural, + target_language=self.target_language, + object_names=iter_to_string( + ctx.source_translation.title + for ctx in self.refreshed_translations + if ctx.source_translation is not None + ), + ), + ) + def alert_successful_translations(self) -> None: """ Add messages informing the user about successful translations diff --git a/integreat_cms/locale/de/LC_MESSAGES/django.po b/integreat_cms/locale/de/LC_MESSAGES/django.po index 54ac57b479..952516efff 100644 --- a/integreat_cms/locale/de/LC_MESSAGES/django.po +++ b/integreat_cms/locale/de/LC_MESSAGES/django.po @@ -11766,6 +11766,23 @@ msgstr "Englisch" msgid "Dutch" msgstr "Niederländisch" +#: core/utils/machine_translation_api_client.py +#, python-brace-format +msgid "" +"{model_name} {object_names}: Translation into '{target_language}' was not " +"necessary -> no changes detected. The translation date has been refreshed." +msgid_plural "" +"{model_name} {object_names}: Translation into '{target_language}' was not " +"necessary -> no changes detected. The translation dates have been refreshed." +msgstr[0] "" +"{model_name} {object_names}: Übersetzung ins '{target_language}' war nicht " +"erforderlich -> keine Änderungen festgestellt. Das Übersetzungsdatum wurde " +"aktualisiert." +msgstr[1] "" +"{model_name} {object_names}: Übersetzung ins '{target_language}' war nicht " +"erforderlich -> keine Änderungen festgestellt. Die Übersetzungsdaten wurden " +"aktualisiert." + #: core/utils/machine_translation_api_client.py #, python-brace-format msgid "" diff --git a/integreat_cms/release_notes/current/unreleased/4320.yml b/integreat_cms/release_notes/current/unreleased/4320.yml new file mode 100644 index 0000000000..bf1f3b83ce --- /dev/null +++ b/integreat_cms/release_notes/current/unreleased/4320.yml @@ -0,0 +1,2 @@ +en: Update translation state to MACHINE_TRANSLATED when an outdated translation is machine translated without changes +de: Setze den Übersetzungsstatus auf MACHINE_TRANSLATED, wenn eine veraltete Übersetzung ohne Änderungen maschinell übersetzt wird diff --git a/tests/mt_api/mt_api_test.py b/tests/mt_api/mt_api_test.py index 11c0956746..fce9d1695d 100644 --- a/tests/mt_api/mt_api_test.py +++ b/tests/mt_api/mt_api_test.py @@ -6,6 +6,7 @@ from integreat_cms.cms.constants.translation_status import ( MACHINE_TRANSLATED, + OUTDATED, UP_TO_DATE, ) @@ -570,6 +571,93 @@ def test_manual_update_mt_page( assert en_translation.minor_edit is True +@pytest.mark.django_db +@pytest.mark.parametrize("login_role_user", [EDITOR], indirect=True) +def test_mt_update_refreshes_outdated_translation_no_changes( + load_test_data: None, + login_role_user: tuple[Client, str], + settings: SettingsWrapper, + mock_server: MockServer, + caplog: LogCaptureFixture, +) -> None: + """ + When a source translation is changed and reverted with MT update enabled: + - target translation state is changed from OUTDATED to MACHINE_TRANSLATED + - the minor_edit flag for the new translation version is set to True + - no request is sent to the MT API + """ + mt_setup(["de"], ["en-gb", "en-us"], [], [], settings, mock_server) + + client, _role = login_role_user + + region = Region.objects.get(slug=REGION_SLUG) + + # Create a page with empty content + page_id = _create_page(client, REGION_SLUG, "Titel", "titel", "") + + # Enable automatic translation and create a new English translation + _edit_translation( + client, + page_id, + language_slug="de", + title="Titel", + content="", + region=region, + mt_translations_to_create="en", + ) + + en_translation = PageTranslation.objects.filter( + page__id=page_id, language__slug="en" + ).first() + + assert en_translation + assert en_translation.content == "" + assert en_translation.translation_state == MACHINE_TRANSLATED + assert mock_server.requests_counter == 1 + + # Change content of the German translation, without automatic translation + _edit_translation( + client, + page_id, + language_slug="de", + title="Titel", + content="

Inhalt

", + ) + + en_translation = PageTranslation.objects.filter( + page__id=page_id, language__slug="en" + ).first() + + assert en_translation + assert en_translation.translation_state == OUTDATED + + # Clear the German content again and update the English translation + _edit_translation( + client, + page_id, + language_slug="de", + title="Titel", + content="", + region=region, + mt_translations_to_update="en", + ) + + en_translation = PageTranslation.objects.filter( + page__id=page_id, language__slug="en" + ).first() + + assert en_translation + assert en_translation.content == "" + assert en_translation.minor_edit is True + assert en_translation.translation_state == MACHINE_TRANSLATED + assert mock_server.requests_counter == 1 # No new MT request should have been made + + assert_message_in_log( + "INFO Page \"Titel\": Translation into 'English' was not necessary -> no changes detected. The translation date has been refreshed.", + caplog, + ) + + def _create_page( client: Client, region_slug: str,