From 797e56a0cdd568aa33bf4624a36e94a1899ecf5b Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Wed, 21 May 2025 14:27:38 +0300 Subject: [PATCH] Features: 1) Add `translate_fields` management command for field translations via DeepL; 2) Add slug population script in `0022_category_slug` migration. Fixes: 1) Update locale codes in `deepl_translate.py` to lowercase for consistency. Extra: 1) Minor refactoring to ensure compatibility and avoid undefined behavior in slug population logic. --- core/management/commands/deepl_translate.py | 34 ++--- core/management/commands/translate_fields.py | 137 +++++++++++++++++++ core/migrations/0022_category_slug.py | 8 ++ 3 files changed, 162 insertions(+), 17 deletions(-) create mode 100644 core/management/commands/translate_fields.py diff --git a/core/management/commands/deepl_translate.py b/core/management/commands/deepl_translate.py index 29f87405..17e5b944 100644 --- a/core/management/commands/deepl_translate.py +++ b/core/management/commands/deepl_translate.py @@ -9,23 +9,23 @@ from django.core.management.base import BaseCommand, CommandError # Mapping from Django locale codes to DeepL API codes DEEPL_TARGET_LANGUAGES_MAPPING = { - "en-GB": "EN-GB", - "ar-AR": "AR", - "cs-CZ": "CS", - "da-DK": "DA", - "de-DE": "DE", - "en-US": "EN-US", - "es-ES": "ES", - "fr-FR": "FR", - "hi-IN": "unsupported", - "it-IT": "IT", - "ja-JP": "JA", - "kk-KZ": "unsupported", - "nl-NL": "NL", - "pl-PL": "PL", - "pt-BR": "PT-BR", - "ro-RO": "RO", - "ru-RU": "RU", + "en-gb": "EN-GB", + "ar-ar": "AR", + "cs-cz": "CS", + "da-dk": "DA", + "de-de": "DE", + "en-us": "EN-US", + "es-es": "ES", + "fr-fr": "FR", + "hi-in": "unsupported", + "it-it": "IT", + "ja-jp": "JA", + "kk-kz": "unsupported", + "nl-nl": "NL", + "pl-pl": "PL", + "pt-br": "PT-BR", + "ro-ro": "RO", + "ru-ru": "RU", "zh-hans": "ZH-HANS", } diff --git a/core/management/commands/translate_fields.py b/core/management/commands/translate_fields.py new file mode 100644 index 00000000..69f322b1 --- /dev/null +++ b/core/management/commands/translate_fields.py @@ -0,0 +1,137 @@ +import importlib +import os + +import requests +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +DEEPL_API_URL = "https://api-free.deepl.com/v2/translate" + +DEEPL_TARGET_LANGUAGES_MAPPING = { + "en-gb": "EN-GB", + "ar-ar": "AR", + "cs-cz": "CS", + "da-dk": "DA", + "de-de": "DE", + "en-us": "EN-US", + "es-es": "ES", + "fr-fr": "FR", + "hi-in": "unsupported", + "it-it": "IT", + "ja-jp": "JA", + "kk-kz": "unsupported", + "nl-nl": "NL", + "pl-pl": "PL", + "pt-br": "PT-BR", + "ro-ro": "RO", + "ru-ru": "RU", + "zh-hans": "ZH-HANS", +} + + +class Command(BaseCommand): + help = ( + "Translate a model field into another language via DeepL and store it " + "in the translated_ field created by django-modeltranslation." + ) + + def add_arguments(self, parser): + parser.add_argument( + "-t", "--target", required=True, + help=( + "Dotted path to the field to translate, " + "e.g. core.models.Product.description" + ), + ) + parser.add_argument( + "-l", "--language", required=True, + help=( + "Modeltranslation language code to translate into, " + "e.g. de-de, fr-fr, zh-hans" + ), + ) + + def handle(self, *args, **options): + target = options["target"] + lang = options["language"].lower() + + if lang not in DEEPL_TARGET_LANGUAGES_MAPPING: + raise CommandError(f"Unknown language '{lang}'.") + deepl_lang = DEEPL_TARGET_LANGUAGES_MAPPING[lang] + if deepl_lang == "unsupported": + raise CommandError(f"DeepL does not support translating into '{lang}'.") + + try: + module_path, model_name, field_name = target.rsplit(".", 2) + except ValueError: + raise CommandError( + "Invalid target format. Use app.module.Model.field, e.g. core.models.Product.description" + ) + + try: + module = importlib.import_module(module_path) + model = getattr(module, model_name) + except (ImportError, AttributeError) as e: + raise CommandError(f"Could not import model '{model_name}' from '{module_path}': {e}") + + dest_suffix = lang.replace("-", "_") + dest_field = f"{field_name}_{dest_suffix}" + + if not hasattr(model, dest_field): + raise CommandError( + f"Model '{model_name}' has no field '{dest_field}'. " + "Did you run makemigrations/migrate after setting up modeltranslation?" + ) + + auth_key = os.environ.get("DEEPL_AUTH_KEY") + if not auth_key: + raise CommandError("Environment variable DEEPL_AUTH_KEY is not set.") + + qs = model.objects.exclude(**{f"{field_name}__isnull": True}) \ + .exclude(**{f"{field_name}": ""}) + total = qs.count() + if total == 0: + self.stdout.write("No instances with non-empty source field found.") + return + + self.stdout.write(f"Translating {total} objects from '{field_name}' into '{dest_field}'.") + + for obj in qs.iterator(): + src_text = getattr(obj, field_name) + existing = getattr(obj, dest_field, None) + if existing: + self.stdout.write(f"Skipping {obj.pk}: '{dest_field}' already set.") + continue + + resp = requests.post( + DEEPL_API_URL, + data={ + "auth_key": auth_key, + "text": src_text, + "target_lang": deepl_lang, + }, + timeout=30, + ) + if resp.status_code != 200: + self.stderr.write( + f"DeepL API error for {obj.pk}: {resp.status_code} {resp.text}" + ) + continue + + data = resp.json() + try: + translated = data["translations"][0]["text"] + except (KeyError, IndexError): + self.stderr.write(f"Unexpected DeepL response for {obj.pk}: {data}") + continue + + setattr(obj, dest_field, translated) + try: + with transaction.atomic(): + obj.save(update_fields=[dest_field]) + except Exception as e: + self.stderr.write(f"Error saving {obj.pk}: {e}") + else: + self.stdout.write(f"✓ {obj.pk}") + + self.stdout.write(self.style.SUCCESS("Done.")) diff --git a/core/migrations/0022_category_slug.py b/core/migrations/0022_category_slug.py index f034cd70..77aa0215 100644 --- a/core/migrations/0022_category_slug.py +++ b/core/migrations/0022_category_slug.py @@ -4,12 +4,20 @@ import django_extensions.db.fields from django.db import migrations +def populate_slugs(apps, schema_editor): + Category = apps.get_model('core', 'Category') + for category in Category.objects.all(): + if not category.slug: + category.save() + + class Migration(migrations.Migration): dependencies = [ ('core', '0021_rename_name_ar_ar_attribute_name_ar_ar_and_more'), ] operations = [ + migrations.RunPython(populate_slugs, reverse_code=migrations.RunPython.noop), migrations.AddField( model_name='category', name='slug',