diff --git a/scripts/sync_translations.py b/scripts/sync_translations.py index d9e6bfcc078..f427d812c98 100644 --- a/scripts/sync_translations.py +++ b/scripts/sync_translations.py @@ -7,6 +7,13 @@ - New project link: * https://app.transifex.com/open-edx/openedx-translations/ + + +Variable names meaning: + + - current_translation: translation in the new "open-edx/openedx-translations" project + - translation_from_old_project: translation in the old "open-edx/edx-platform" or "open-edx/xblocks" projects + """ import argparse @@ -23,6 +30,19 @@ ORGANIZATION_SLUG = 'open-edx' +def parse_tx_date(date_str): + """ + Parse a date string coming from Transifex into a datetime object. + """ + if date_str: + if date_str.endswith('Z'): + date_str = date_str.replace('Z', '+00:00') + + return datetime.fromisoformat(date_str) + + return None + + class Command: workflow_file_path = '.github/workflows/sync-translations.yml' @@ -116,42 +136,51 @@ def sync_translations(self, language_code, old_resource, new_resource): for translation in self.get_translations(language_code=language_code, resource=old_resource) } - for new_translation in self.get_translations(language_code=language_code, resource=new_resource): - translation_id = self.get_translation_id(new_translation) + for current_translation in self.get_translations(language_code=language_code, resource=new_resource): + translation_id = self.get_translation_id(current_translation) if translation_from_old_project := translations_from_old_project.get(translation_id): self.sync_translation_entry( - translation_id=translation_id, translation_from_old_project=translation_from_old_project, - new_translation=new_translation, + current_translation=current_translation, ) - def sync_translation_entry(self, translation_id, translation_from_old_project, new_translation): + def sync_translation_entry(self, translation_from_old_project, current_translation): """ Sync a single translation entry from the old project to the new one. + + Return: + str: status code + - updated: if the entry was updated + - skipped: if the entry was skipped + - updated-dry-run: if the entry was updated in dry-run mode """ + translation_id = self.get_translation_id(current_translation) + updates = {} for attr in ['reviewed', 'proofread', 'strings']: if old_attr_value := getattr(translation_from_old_project, attr, None): - if old_attr_value != getattr(new_translation, attr, None): + if old_attr_value != getattr(current_translation, attr, None): updates[attr] = old_attr_value # Avoid overwriting more recent translations in the open-edx/openedx-translations project newer_translation_found = False - if translation_from_old_project.datetime_translated and new_translation.datetime_translated: - old_project_translation_time = datetime.fromisoformat( - translation_from_old_project.datetime_translated.replace('Z', '+00:00')) - new_project_translation_time = datetime.fromisoformat( - new_translation.datetime_translated.replace('Z', '+00:00')) + old_project_translation_time = parse_tx_date(translation_from_old_project.datetime_translated) + new_project_translation_time = parse_tx_date(current_translation.datetime_translated) + if old_project_translation_time and new_project_translation_time: newer_translation_found = new_project_translation_time > old_project_translation_time if updates: if newer_translation_found: print(translation_id, updates, '[Skipped: new project translation is more recent]') + return 'skipped' else: print(translation_id, updates, '[Dry run]' if self.is_dry_run() else '') - if not self.is_dry_run(): - new_translation.save(**updates) + if self.is_dry_run(): + return 'updated-dry-run' + else: + current_translation.save(**updates) + return 'updated' def sync_tags(self, old_resource, new_resource): """ diff --git a/scripts/tests/test_sync_translations.py b/scripts/tests/test_sync_translations.py index 49068d768da..199c0e3fd2c 100644 --- a/scripts/tests/test_sync_translations.py +++ b/scripts/tests/test_sync_translations.py @@ -1,32 +1,107 @@ """ Tests for sync_translations.py """ +from dataclasses import dataclass +from datetime import datetime, timezone import types +from typing import Union +import pytest import responses + from transifex.api import transifex_api, Project from transifex.api.jsonapi import Resource from transifex.api.jsonapi.auth import BearerAuthentication from . import response_data -from ..sync_translations import Command, ORGANIZATION_SLUG +from ..sync_translations import ( + Command, + ORGANIZATION_SLUG, + parse_tx_date, +) HOST = transifex_api.HOST -def sync_command(args=None): - result = Command( - tx_api=transifex_api, - dry_run=True, - simulate_github_workflow=False, - environ={ +def sync_command(**kwargs): + command_args = { + 'tx_api': transifex_api, + 'dry_run': True, + 'simulate_github_workflow': False, + 'environ': { 'TX_API_TOKEN': 'dummy-token' } - ) + } + command_args.update(kwargs) + result = Command(**command_args) result.tx_api.make_auth_headers = BearerAuthentication('dummy-token') return result +@dataclass +class ResourceStringMock: + """ + String entry in Transifex. + + Mock class for the transifex.api.ResourceString class. + """ + key: str + context: str = '' + + +@dataclass +class ResourceTranslationMock: + """ + Translation for an entry in Transifex. + + Mock class for the transifex.api.ResourceTranslation class. + """ + resource_string: ResourceStringMock + strings: dict + reviewed: bool + proofread: bool + datetime_translated: str + + _updates: dict = None # Last updates applied via `save()` + + def save(self, **updates): + """ + Mock ResourceTranslation.save() method. + """ + self._updates = updates + + @property + def updates(self): + """ + Return the last updates applied via `save()`. + """ + return self._updates + + @classmethod + def factory( + cls, + key='key', + context='', + translation: Union[str, None] = 'dummy translation', + **kwargs + ): + mock_kwargs = dict( + resource_string=ResourceStringMock( + key=key, + context=context + ), + strings={ + key: translation, + }, + reviewed=False, + proofread=False, + datetime_translated='2021-01-01T00:00:00Z', + ) + + mock_kwargs.update(kwargs) + return cls(**mock_kwargs) + + @responses.activate def test_get_transifex_organization_projects(): """ @@ -91,9 +166,151 @@ def test_get_translations(): assert items[0].id == response_data.RESPONSE_GET_LANGUAGE['data'][0]['id'] -@responses.activate -def test_more_recent_translations_not_overridden(): +def test_translations_entry_update_empty_translation(): + """ + Test updating an entry from old project where `current_translation` is empty. + """ + command = sync_command(dry_run=False) + + translation_from_old_project = ResourceTranslationMock.factory( + key='test_key', + translation='old translation', + reviewed=True, + datetime_translated='2023-01-01T00:00:00Z', + ) + + # Current translation is empty + current_translation = ResourceTranslationMock.factory( + key='test_key', + translation=None, + datetime_translated=None, + ) + + status = command.sync_translation_entry( + translation_from_old_project, current_translation + ) + + assert status == 'updated' + assert current_translation.updates == { + 'strings': { + 'test_key': 'old translation' + }, + 'reviewed': True, + } + + +@pytest.mark.parametrize( + 'old_project_date, current_translation_date, new_translation_str', + [ + # As long as the current translation is _not_ more recent, it should be updated + (None, '2023-01-01T00:00:00Z', None), + (None, '2023-01-01T00:00:00Z', 'some translation'), + ('2023-01-01T00:00:00Z', '2023-01-01T00:00:00Z', 'some translation'), # Same date + ('2023-01-01T00:00:00Z', '2021-01-01T00:00:00Z', 'some translation'), # New project has newer date + ('2023-01-01T00:00:00Z', None, 'some translation'), + ('2023-01-01T00:00:00Z', None, None), + ] +) +def test_translations_entry_update_translation(old_project_date, current_translation_date, new_translation_str): + """ + Test updating an entry from old project where `current_translation` is has outdated translation. + """ + command = sync_command(dry_run=False) + + translation_from_old_project = ResourceTranslationMock.factory( + key='test_key', + translation='old translation', + reviewed=True, + datetime_translated=old_project_date, + ) + + current_translation = ResourceTranslationMock.factory( + key='test_key', + translation=new_translation_str, + datetime_translated=current_translation_date, + ) + + status = command.sync_translation_entry( + translation_from_old_project, current_translation + ) + + assert status == 'updated' + assert current_translation.updates == { + 'strings': { + 'test_key': 'old translation' + }, + 'reviewed': True, + } + + +def test_translations_entry_more_recent_translation(): """ Verify that the more recent translations in the open-edx/openedx-translations project are not overridden. """ - assert False, 'Not implemented yet' + command = sync_command(dry_run=False) + + translation_from_old_project = ResourceTranslationMock.factory( + key='test_key', + translation='one translation', + reviewed=True, + datetime_translated='2019-01-01T00:00:00Z', + ) + + # Current translation is empty + current_translation = ResourceTranslationMock.factory( + key='test_key', + translation='more recent translation', + datetime_translated='2023-01-01T00:00:00Z', + ) + + status = command.sync_translation_entry( + translation_from_old_project, current_translation + ) + + assert status == 'skipped' + assert not current_translation.updates, 'save() should not be called' + + +def test_translations_entry_dry_run(): + """ + Verify that --dry-run option skips the save() call. + """ + command = sync_command(dry_run=True) + + translation_from_old_project = ResourceTranslationMock.factory( + key='test_key', + translation='old translation', + reviewed=True, + datetime_translated='2023-01-01T00:00:00Z', + ) + + current_translation = ResourceTranslationMock.factory( + key='test_key', + translation=None, + datetime_translated=None, + ) + + status = command.sync_translation_entry( + translation_from_old_project, current_translation + ) + + assert status == 'updated-dry-run' + assert not current_translation.updates, 'save() should not be called in --dry-run mode' + + +@pytest.mark.parametrize( + "date_str, parse_result, test_message", + [ + (None, None, 'None cannot be parsed'), + ('2023-01-01T00:00:00Z', datetime(2023, 1, 1, 0, 0, tzinfo=timezone.utc), + 'Z suffix is replaced with the explict "+00:00" timezone'), + ('2023-01-01T00:00:00', datetime(2023, 1, 1, 0, 0), + 'If there is no Z suffix, no timezone is added'), + ] +) +def test_parse_tx_date(date_str, parse_result, test_message): + """ + Tests for parse_tx_date() helper function. + """ + assert parse_tx_date(date_str) == parse_result, test_message +