Initial
This commit is contained in:
243
django_translatable_fields/fields.py
Normal file
243
django_translatable_fields/fields.py
Normal file
@@ -0,0 +1,243 @@
|
||||
# -*- 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 = 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
|
||||
Reference in New Issue
Block a user