244 lines
9.6 KiB
Python
244 lines
9.6 KiB
Python
# -*- 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])
|
|
|
|
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
|