Files
Django-Translatable-Fields/django_translatable_fields/fields.py

244 lines
9.6 KiB
Python
Raw Normal View History

2025-08-02 20:08:33 +02:00
# -*- 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 <holger@backender.de>
Version: 0.1.0
"""
import json
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])
2025-08-02 20:08:33 +02:00
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