# -*- coding: utf-8 -*- """ Django Translatable Fields - Core Field Definitions This module provides the core functionality for creating translatable database fields that store translations as JSON and provide language-aware access similar to Odoo's translate=True functionality. Key Features: - Automatic JSON storage of translations in database - Language-aware field access based on Django's current language - Admin interface integration with translation modal overlays - Migration support for translate=True/False transitions - Database-specific optimizations for search operations Usage: from django_translatable_fields import CharField, TextField class MyModel(models.Model): name = CharField(max_length=200, translatable=True) description = TextField(translatable=True) Author: Holger Sielaff Version: 0.1.0 """ import json import os from django.db import models from django.core.exceptions import ValidationError from django.utils.translation import get_language, gettext_lazy as _ import logging import inspect class TranslatableMixin: """ Mixin that provides translatable functionality to any Django field. Stores translations as JSON and provides language-aware access. """ def __init__(self, *args, **kwargs): self.translatable = kwargs.pop('translatable', False) super().__init__(*args, **kwargs) if self.translatable and hasattr(self, '_validators') and self.validators: self._original_validators = self.validators.copy() self.validators = [] def contribute_to_class(self, cls, name, **kwargs): """Override to detect translatable changes and trigger migrations""" super().contribute_to_class(cls, name, **kwargs) # Store field info for migration detection if not hasattr(cls._meta, '_translatable_fields'): cls._meta._translatable_fields = {} cls._meta._translatable_fields[name] = { 'translatable': self.translatable, 'field_type': self.__class__.__name__ } def deconstruct(self): """Include translatable parameter in field deconstruction for migrations""" name, path, args, kwargs = super().deconstruct() kwargs['translatable'] = self.translatable return name, path, args, kwargs def from_db_value(self, value, expression, connection): logging.debug(f"{self.__class__.__name__} from_db_value - received: {repr(value)}") if value is None: return value if not self.translatable: return value # Check if we're in admin context by looking at the call stack frames = inspect.getouterframes(inspect.currentframe()) is_admin_context = os.environ.get('DJANGO_ADMIN_OVERRIDE', False) == '1' or any('admin' in frame.filename or 'forms' in frame.filename for frame in frames[:5]) logging.debug(f"{self.__class__.__name__} from_db_value - admin context: {is_admin_context}") try: translations = json.loads(value) if isinstance(value, str) else value if not isinstance(translations, dict): return value logging.debug(f"{self.__class__.__name__} from_db_value - parsed translations: {translations}") # For admin/forms, return the full dict so widgets can access all translations if is_admin_context: logging.debug( f"{self.__class__.__name__} from_db_value - returning full dict for admin: {translations}") return translations # For regular use, return the appropriate language current_lang = get_language() if current_lang and current_lang in translations: result = translations[current_lang] logging.debug( f"{self.__class__.__name__} from_db_value - returning for language {current_lang}: {repr(result)}") return result # Fallback to default language or first available if 'en' in translations: result = translations['en'] logging.debug(f"{self.__class__.__name__} from_db_value - returning english fallback: {repr(result)}") return result elif translations: result = next(iter(translations.values())) logging.debug(f"{self.__class__.__name__} from_db_value - returning first available: {repr(result)}") return result logging.debug(f"{self.__class__.__name__} from_db_value - returning empty string") return '' except (json.JSONDecodeError, TypeError): logging.debug(f"{self.__class__.__name__} from_db_value - JSON decode error, returning raw value") return value def to_python(self, value): if value is None: return value if not self.translatable: return super().to_python(value) if isinstance(value, dict): return value try: return json.loads(value) if isinstance(value, str) else value except (json.JSONDecodeError, TypeError): return value def get_prep_value(self, value): if not self.translatable: return super().get_prep_value(value) if value is None: return value # If it's already a JSON string from database, don't double encode if isinstance(value, str): try: # Try to parse it - if it parses, it's already JSON parsed = json.loads(value) if isinstance(parsed, dict): return value # Return as-is, it's already proper JSON except (json.JSONDecodeError, TypeError): pass # If parsing fails, treat as regular string for current language current_lang = get_language() or 'en' return json.dumps({current_lang: str(value)}) if isinstance(value, dict): # Filter out empty values and ensure all values are strings clean_dict = {k: str(v) for k, v in value.items() if v and str(v).strip()} return json.dumps(clean_dict) if clean_dict else None # Fallback for other types current_lang = get_language() or 'en' return json.dumps({current_lang: str(value)}) def validate(self, value, model_instance): if not self.translatable or not value or not isinstance(value, (str, dict)): super().validate(value, model_instance) # Validate each translation individually for lang_code, translation in value.items(): if not isinstance(lang_code, str) or not isinstance(translation, str): raise ValidationError(_('Invalid translation format')) # Validate each translation string with appropriate validators if translation.strip(): # Only validate non-empty values try: # Run field-specific validation for each translation self._validate_translation(translation, lang_code) except ValidationError as e: # Re-raise with language context raise ValidationError(f"Invalid value for language '{lang_code}': {e.message}") def _validate_translation(self, value, lang_code): """Validate a single translation value with field-specific validators""" for validator in getattr(self, '_original_validators', []): validator(value) def formfield(self, **kwargs): # Only apply translatable widgets to text-like fields if self.translatable and isinstance(self, (models.CharField, models.TextField, models.SlugField, models.EmailField, models.URLField)): # Use specific form fields for fields with validators if isinstance(self, models.EmailField): from .forms import TranslatableEmailFormField defaults = {'form_class': TranslatableEmailFormField} elif isinstance(self, models.SlugField): from .forms import TranslatableSlugFormField defaults = {'form_class': TranslatableSlugFormField} elif isinstance(self, models.URLField): from .forms import TranslatableURLFormField defaults = {'form_class': TranslatableURLFormField} else: # For CharField and TextField, use the unified form field from .forms import TranslatableUnifiedFormField # Determine if this is char-like based on max_length or field type is_charfield = ( hasattr(self, 'max_length') and self.max_length is not None ) or isinstance(self, models.CharField) defaults = { 'form_class': TranslatableUnifiedFormField, 'is_charfield': is_charfield, 'max_length': getattr(self, 'max_length', None), } defaults.update(kwargs) return super().formfield(**defaults) return super().formfield(**kwargs) class CharField(TranslatableMixin, models.CharField): """ A translatable Char Field""" pass class TextField(TranslatableMixin, models.TextField): """ A translatable Text Field """ pass class EmailField(TranslatableMixin, models.EmailField): """Translatable email field""" pass class URLField(TranslatableMixin, models.URLField): """Translatable URL field""" pass class SlugField(TranslatableMixin, models.SlugField): """Translatable slug field""" pass