This commit is contained in:
Holger Sielaff
2025-08-02 20:08:33 +02:00
commit 79c68169f6
47 changed files with 4880 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
"""
Django Translatable Fields
A Django plugin that mimics Odoo's translate=True functionality,
providing language-aware field handling in the admin interface.
This package is optimized for PostgreSQL and provides the best performance
when used with PostgreSQL databases. While it supports MySQL and SQLite,
PostgreSQL is strongly recommended for production use.
Requirements:
- Django 4.2+
- PostgreSQL 12+ (recommended)
- psycopg2-binary 2.9.0+
Author: Holger Sielaff <holger@backender.de>
"""
__version__ = '0.1.0'
default_app_config = 'django_translatable_fields.apps.TranslatableFieldsConfig'
# Import main fields for easy access
from .fields import (
CharField, TextField, EmailField, URLField, SlugField,
)
# Import DRF serializer components (optional import)
try:
from .serializers import (
TranslatableSerializerMixin, TranslatableField,
TranslatableCharField, TranslatableTextField,
TranslatableEmailField, TranslatableURLField, TranslatableSlugField
)
_HAS_DRF = True
except ImportError:
_HAS_DRF = False
__all__ = [
'CharField', 'TextField', 'EmailField', 'URLField', 'SlugField',
]
if _HAS_DRF:
__all__.extend([
'TranslatableSerializerMixin', 'TranslatableField',
'TranslatableCharField', 'TranslatableTextField',
'TranslatableEmailField', 'TranslatableURLField', 'TranslatableSlugField'
])

View File

@@ -0,0 +1,26 @@
from django.contrib import admin
from django.forms import fields
from .fields import CharField, TextField
from .widgets import TranslatableTextWidget, TranslatableTextareaWidget
class TranslatableAdminMixin:
"""
Mixin for ModelAdmin classes to automatically use translatable widgets
for translatable fields.
"""
def formfield_for_dbfield(self, db_field, request, **kwargs):
# Let the fields handle their own form field creation
if isinstance(db_field, (CharField, TextField)) and db_field.translatable:
return db_field.formfield(**kwargs)
return super().formfield_for_dbfield(db_field, request, **kwargs)
class TranslatableModelAdmin(TranslatableAdminMixin, admin.ModelAdmin):
"""
ModelAdmin that automatically handles translatable fields.
"""
pass

View File

@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
"""
Django Translatable Fields - App Configuration
This module contains the Django app configuration for the translatable fields plugin.
It includes startup checks to ensure PostgreSQL is being used as the database backend.
Author: Holger Sielaff <holger@backender.de>
Version: 0.1.0
"""
from django.apps import AppConfig
from django.core.exceptions import ImproperlyConfigured
from django.db import connection
import logging
logger = logging.getLogger(__name__)
class TranslatableFieldsConfig(AppConfig):
"""
Django app configuration for translatable fields.
This app configuration performs startup checks to ensure the database
backend is compatible with the translatable fields functionality.
"""
default_auto_field = 'django.db.models.BigAutoField'
name = 'django_translatable_fields'
verbose_name = 'Django Translatable Fields'
def ready(self):
"""
Perform app initialization and compatibility checks.
This method is called when Django starts up and the app is ready.
It checks for PostgreSQL database backend and warns about suboptimal
performance with other database backends.
Raises:
ImproperlyConfigured: If database backend is not supported
"""
super().ready()
# Check database backend
self._check_database_backend()
# Import signal handlers (if any)
try:
from . import signals # noqa
except ImportError:
pass
def _check_database_backend(self):
"""
Check if the database backend is compatible with translatable fields.
PostgreSQL is strongly recommended for optimal performance with JSON
field operations. Other backends will work but with reduced performance.
Raises:
ImproperlyConfigured: If database backend is not supported
"""
try:
vendor = connection.vendor
if vendor == 'postgresql':
logger.info("Django Translatable Fields: Using PostgreSQL - optimal performance enabled")
return
elif vendor in ['mysql', 'sqlite']:
logger.warning(
f"Django Translatable Fields: Using {vendor.title()} database. "
f"Performance may be suboptimal. PostgreSQL is strongly recommended "
f"for production use with translatable fields."
)
return
else:
raise ImproperlyConfigured(
f"Django Translatable Fields: Unsupported database backend '{vendor}'. "
f"Supported backends: PostgreSQL (recommended), MySQL, SQLite. "
f"PostgreSQL is strongly recommended for production use."
)
except Exception as e:
logger.warning(
f"Django Translatable Fields: Could not check database backend: {e}. "
f"Ensure you're using PostgreSQL for optimal performance."
)

View File

@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
"""
Django Translatable Fields - Context Management
This module provides context managers and utilities for temporarily overriding
language context when working with translatable fields. It allows you to
bypass Django's current language setting and force specific language contexts
for translatable field operations.
Key Features:
- Thread-safe language context storage
- Context managers for temporary language overrides
- Integration with Django's language activation system
- Support for nested context operations
- Automatic cleanup and restoration
Context Hierarchy (highest to lowest priority):
1. Explicit language parameter in method calls
2. QuerySet context (with_context)
3. Global context manager (translatable_context)
4. Django's current language (get_language)
5. Default fallback ('en_US')
Usage:
# Global context for code blocks
with translatable_context('de'):
products = Product.objects.search('test') # Uses German
# Django language context
with django_language_context('fr'):
# Django's get_language() returns 'fr'
pass
Author: Holger Sielaff <holger@backender.de>
Version: 0.1.0
"""
from contextlib import contextmanager
from django.utils.translation import get_language, activate, deactivate
import threading
# Thread-local storage for context overrides
_context_storage = threading.local()
class TranslatableContext:
"""
Context manager for temporarily setting language context for translatable operations
"""
def __init__(self, language):
self.language = language
self.previous_language = None
def __enter__(self):
self.previous_language = getattr(_context_storage, 'language', None)
_context_storage.language = self.language
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self.previous_language is not None:
_context_storage.language = self.previous_language
else:
if hasattr(_context_storage, 'language'):
delattr(_context_storage, 'language')
return False
@contextmanager
def translatable_context(language):
"""
Context manager for temporarily overriding language context.
Args:
language: Language code to use
Example:
with translatable_context('de'):
products = Product.objects.search('moep') # Searches in German
for product in products:
print(product.name) # Shows German name
"""
with TranslatableContext(language):
yield
@contextmanager
def django_language_context(language):
"""
Context manager that temporarily changes Django's active language.
Args:
language: Language code to activate
Example:
with django_language_context('de'):
# Django's get_language() will return 'de'
products = Product.objects.search('moep')
"""
previous_language = get_language()
try:
activate(language)
yield
finally:
if previous_language:
activate(previous_language)
else:
deactivate()
def get_context_language():
"""
Get the current context language override, if any.
Returns:
Language code from context, or None if no override
"""
return getattr(_context_storage, 'language', None)
def get_effective_language(explicit_language=None):
"""
Get the effective language to use for translatable operations.
Priority:
1. Explicit language parameter
2. Context language override
3. Django's current language
4. Default fallback (en_US)
Args:
explicit_language: Explicitly specified language
Returns:
Language code to use
"""
if explicit_language is not None:
return explicit_language
context_lang = get_context_language()
if context_lang is not None:
return context_lang
django_lang = get_language()
if django_lang:
return django_lang
return 'en_US'
class LanguageContextMixin:
"""
Mixin that adds context language awareness to QuerySets and Managers
"""
def _get_effective_language(self, language=None):
"""Get effective language considering all sources"""
return get_effective_language(language)
# Decorator for functions that should respect language context
def with_language_context(func):
"""
Decorator that makes a function respect the current language context.
Example:
@with_language_context
def get_product_name(product):
return product.name # Will use context language if set
"""
def wrapper(*args, **kwargs):
# If 'language' not explicitly provided, inject context language
if 'language' not in kwargs:
context_lang = get_context_language()
if context_lang:
kwargs['language'] = context_lang
return func(*args, **kwargs)
return wrapper

View File

@@ -0,0 +1,190 @@
import json
from django.utils.translation import get_language
class TranslatableFieldDescriptor:
"""
Descriptor that provides language-aware access to translatable field values.
Usage:
class MyModel(models.Model):
name = TranslatableCharField()
# Access current language value
obj.name # Returns value in current language
# Access specific language
obj.name_en # Returns English value
obj.name_de # Returns German value
# Set values
obj.name = "Hello" # Sets for current language
obj.name_en = "Hello" # Sets for English
obj.name_de = "Hallo" # Sets for German
"""
def __init__(self, field_name):
self.field_name = field_name
self.storage_name = f'_{field_name}_translations'
def __get__(self, instance, owner=None):
if instance is None:
return self
# Get the stored translations
raw_value = getattr(instance, self.field_name)
translations = self._parse_translations(raw_value)
# Return value for current language
current_lang = get_language()
if current_lang and current_lang in translations:
return translations[current_lang]
# Fallback to default language or first available
if 'en' in translations:
return translations['en']
elif translations:
return next(iter(translations.values()))
return ''
def __set__(self, instance, value):
if value is None:
setattr(instance, self.field_name, None)
return
# Get existing translations
raw_value = getattr(instance, self.field_name, None)
translations = self._parse_translations(raw_value)
if isinstance(value, dict):
# Setting multiple translations at once
translations.update(value)
else:
# Setting value for current language
current_lang = get_language() or 'en'
translations[current_lang] = str(value)
# Store back as JSON
setattr(instance, self.field_name, json.dumps(translations))
def _parse_translations(self, value):
"""Parse stored translations from various formats."""
if not value:
return {}
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
return parsed
except (json.JSONDecodeError, TypeError):
pass
# If it's a plain string, treat as current language value
current_lang = get_language() or 'en'
return {current_lang: value}
return {}
class TranslatableModelMixin:
"""
Mixin that adds language-specific property access to models.
This allows accessing specific language versions of translatable fields:
obj.field_name_en, obj.field_name_de, etc.
"""
def __getattr__(self, name):
# Check if this is a language-specific field access
if '_' in name:
field_name, lang_code = name.rsplit('_', 1)
# Check if the base field exists and is translatable
if hasattr(self._meta.model, field_name):
field = self._meta.get_field(field_name)
if hasattr(field, 'translatable') and field.translatable:
raw_value = getattr(self, field_name)
translations = self._parse_field_translations(raw_value)
return translations.get(lang_code, '')
raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'")
def __setattr__(self, name, value):
# Check if this is a language-specific field access
if '_' in name and not name.startswith('_'):
field_name, lang_code = name.rsplit('_', 1)
# Check if the base field exists and is translatable
if hasattr(self._meta.model, field_name):
try:
field = self._meta.get_field(field_name)
if hasattr(field, 'translatable') and field.translatable:
# Get existing translations
raw_value = getattr(self, field_name, None)
translations = self._parse_field_translations(raw_value)
# Update the specific language
translations[lang_code] = str(value) if value is not None else ''
# Store back as JSON
super().__setattr__(field_name, json.dumps(translations))
return
except:
# If there's any error, fall back to normal attribute setting
pass
super().__setattr__(name, value)
def _parse_field_translations(self, value):
"""Parse stored translations from various formats."""
if not value:
return {}
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
return parsed
except (json.JSONDecodeError, TypeError):
pass
# If it's a plain string, treat as current language value
current_lang = get_language() or 'en'
return {current_lang: value}
return {}
def get_translation(self, field_name, language_code):
"""Get translation for a specific field and language."""
if not hasattr(self, field_name):
raise AttributeError(f"Field '{field_name}' does not exist")
raw_value = getattr(self, field_name)
translations = self._parse_field_translations(raw_value)
return translations.get(language_code, '')
def set_translation(self, field_name, language_code, value):
"""Set translation for a specific field and language."""
if not hasattr(self, field_name):
raise AttributeError(f"Field '{field_name}' does not exist")
raw_value = getattr(self, field_name, None)
translations = self._parse_field_translations(raw_value)
translations[language_code] = str(value) if value is not None else ''
setattr(self, field_name, json.dumps(translations))
def get_all_translations(self, field_name):
"""Get all translations for a specific field."""
if not hasattr(self, field_name):
raise AttributeError(f"Field '{field_name}' does not exist")
raw_value = getattr(self, field_name)
return self._parse_field_translations(raw_value)

View 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

View File

@@ -0,0 +1,224 @@
# -*- coding: utf-8 -*-
"""
Django Translatable Fields - Form Field Definitions
This module provides custom Django form fields for handling translatable
field data in forms and admin interfaces. These form fields work together
with the translatable widgets to provide seamless translation editing.
Key Features:
- Custom form field validation for translation dictionaries
- Raw database value retrieval for proper modal population
- Automatic widget selection based on field type
- Integration with Django admin forms
- Data preparation and processing for translation widgets
Author: Holger Sielaff <holger@backender.de>
Version: 0.1.0
"""
import json
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from .widgets import TranslatableTextWidget, TranslatableTextareaWidget
class TranslatableFormField(forms.CharField):
"""
Form field that handles translatable data properly
"""
def __init__(self, widget_class=TranslatableTextWidget, *args, **kwargs):
# Always use our custom widget, ignore any provided widget
# Create widget without widget_class parameter since we changed the API
if widget_class == TranslatableTextWidget:
self.widget_instance = TranslatableTextWidget()
elif widget_class == TranslatableTextareaWidget:
self.widget_instance = TranslatableTextareaWidget()
else:
self.widget_instance = widget_class()
kwargs['widget'] = self.widget_instance
super().__init__(*args, **kwargs)
def get_bound_field(self, form, field_name):
"""Override to provide instance data to widget and get raw JSON"""
bound_field = super().get_bound_field(form, field_name)
# Pass instance information to widget and get raw JSON from DB
if hasattr(form, 'instance') and form.instance and hasattr(form.instance, 'pk') and form.instance.pk:
# Get the raw JSON value directly from database
try:
from django.db import connection
cursor = connection.cursor()
table_name = form.instance._meta.db_table
cursor.execute(f'SELECT {field_name} FROM {table_name} WHERE id = %s', [form.instance.pk])
row = cursor.fetchone()
if row and row[0]:
raw_json = row[0]
print(f"Form field get_bound_field - got raw JSON for {field_name}: {repr(raw_json)}")
# Store this for prepare_value to use
self._raw_db_value = raw_json
# Also pass to widget
self.widget_instance._instance_data = {
'instance': form.instance,
'field_name': field_name,
'raw_json': raw_json
}
else:
print(f"Form field get_bound_field - no raw data found for {field_name}")
self._raw_db_value = None
except Exception as e:
print(f"Form field get_bound_field - DB error: {e}")
self._raw_db_value = None
return bound_field
def to_python(self, value):
"""Convert form input to Python value"""
# Don't call super().to_python(value) - it will try to validate the dict
if not value:
return {}
if isinstance(value, dict):
# Filter out empty values
return {k: v for k, v in value.items() if v.strip()} if value else {}
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
# Filter out empty values
return {k: v for k, v in parsed.items() if v.strip()} if parsed else {}
else:
return {'en': value} if value.strip() else {}
except (json.JSONDecodeError, ValueError):
return {'en': value} if value.strip() else {}
return {'en': str(value)} if str(value).strip() else {}
def run_validators(self, value):
"""Override to run validators on individual translations instead of the dict"""
if isinstance(value, dict):
# Run validators on each translation individually
for lang_code, translation in value.items():
if translation and translation.strip():
# Run validators on each translation string
for validator in self.validators:
validator(translation)
else:
# For non-dict values, use standard validation
super().run_validators(value)
def validate(self, value):
"""Validate the translatable value"""
# Basic format validation only - validators are handled in run_validators
if value and not isinstance(value, dict):
raise ValidationError(_('Invalid translation format - must be a dictionary'))
if value:
for lang_code, translation in value.items():
if not isinstance(lang_code, str):
raise ValidationError(_('Language code must be a string'))
if not isinstance(translation, str):
raise ValidationError(_('Translation must be a string'))
def prepare_value(self, value):
"""Prepare value for widget display - use raw JSON if available"""
print(f"Form field prepare_value - received: {repr(value)}, type: {type(value)}")
# Use raw DB value if we have it
if hasattr(self, '_raw_db_value') and self._raw_db_value:
try:
parsed = json.loads(self._raw_db_value)
if isinstance(parsed, dict):
print(f"Form field prepare_value - using raw DB dict: {parsed}")
return parsed
except (json.JSONDecodeError, ValueError):
print(f"Form field prepare_value - raw DB value parse failed")
# Fallback to original logic
if not value:
return {}
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
print(f"Form field prepare_value - returning parsed dict: {parsed}")
return parsed
except (json.JSONDecodeError, ValueError):
pass
result = {'en': value}
print(f"Form field prepare_value - returning english dict: {result}")
return result
if isinstance(value, dict):
print(f"Form field prepare_value - returning dict as-is: {value}")
return value
result = {'en': str(value)}
print(f"Form field prepare_value - returning converted dict: {result}")
return result
class TranslatableCharFormField(TranslatableFormField):
"""Form field for translatable char fields"""
def __init__(self, *args, **kwargs):
super().__init__(widget_class=TranslatableTextWidget, *args, **kwargs)
class TranslatableTextFormField(TranslatableFormField):
"""Form field for translatable text fields"""
def __init__(self, *args, **kwargs):
super().__init__(widget_class=TranslatableTextareaWidget, *args, **kwargs)
class TranslatableUnifiedFormField(TranslatableFormField):
"""Unified form field that adapts widget type based on field parameters"""
def __init__(self, is_charfield=False, max_length=None, *args, **kwargs):
# Determine widget type based on field characteristics
if is_charfield or max_length:
widget_class = TranslatableTextWidget
else:
widget_class = TranslatableTextareaWidget
try:
super().__init__(widget_class=widget_class, *args, **kwargs)
except TypeError:
kwargs.pop('allow_unicode', None)
super().__init__(widget_class=widget_class, *args, **kwargs)
if max_length:
self.max_length = max_length
class TranslatableEmailFormField(TranslatableFormField):
"""Form field specifically for translatable email fields"""
def __init__(self, *args, **kwargs):
super().__init__(widget_class=TranslatableTextWidget, *args, **kwargs)
class TranslatableSlugFormField(TranslatableFormField):
"""Form field specifically for translatable slug fields"""
def __init__(self, *args, allow_unicode=True, **kwargs):
super().__init__(widget_class=TranslatableTextWidget, *args, **kwargs)
class TranslatableURLFormField(TranslatableFormField):
"""Form field specifically for translatable URL fields"""
def __init__(self, *args, **kwargs):
super().__init__(widget_class=TranslatableTextWidget, *args, **kwargs)

View File

@@ -0,0 +1,177 @@
"""
Management command to create migrations for translatable field changes
"""
from django.core.management.base import BaseCommand
from django.core.management import call_command
from django.apps import apps
from django.db import models
from django.db.migrations.writer import MigrationWriter
from django.db.migrations import Migration
from django.utils.translation import get_language
import os
from ...operations import ConvertTranslatableField
from ...fields import TranslatableMixin
class Command(BaseCommand):
help = 'Create migrations for translatable field changes (translate=True/False)'
def add_arguments(self, parser):
parser.add_argument(
'--app',
type=str,
help='Specific app to check for translatable changes'
)
parser.add_argument(
'--language',
type=str,
default=None,
help='Language to use when converting from translatable to non-translatable (default: current language)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be created without actually creating migrations'
)
def handle(self, *args, **options):
app_label = options.get('app')
language = options.get('language') or get_language() or 'en'
dry_run = options.get('dry_run', False)
if app_label:
apps_to_check = [apps.get_app_config(app_label)]
else:
apps_to_check = apps.get_app_configs()
changes_found = False
for app_config in apps_to_check:
changes = self.detect_translatable_changes(app_config)
if changes:
changes_found = True
self.stdout.write(
self.style.SUCCESS(f'Found translatable changes in {app_config.label}:')
)
for model_name, field_changes in changes.items():
for field_name, change in field_changes.items():
from_trans = change['from_translatable']
to_trans = change['to_translatable']
direction = "translatable" if to_trans else "non-translatable"
self.stdout.write(f" {model_name}.{field_name} -> {direction}")
if not dry_run:
self.create_migration(app_config, model_name, field_name,
from_trans, to_trans, language)
if not changes_found:
self.stdout.write(self.style.WARNING('No translatable field changes detected.'))
elif not dry_run:
self.stdout.write(
self.style.SUCCESS('Migration files created successfully.')
)
self.stdout.write('Run "python manage.py migrate" to apply the changes.')
def detect_translatable_changes(self, app_config):
"""Detect changes in translatable field settings"""
changes = {}
# Get current migration state
try:
from django.db.migrations.loader import MigrationLoader
loader = MigrationLoader(None)
# Get the latest migration state for this app
if app_config.label in loader.graph.nodes:
project_state = loader.project_state()
for model_name, model in app_config.get_models():
model_name = model_name.__name__
# Check current model fields
for field in model._meta.get_fields():
if isinstance(field, TranslatableMixin):
current_translatable = field.translatable
# Try to get the field from the migration state
try:
migration_model = project_state.models.get(
(app_config.label, model_name.lower())
)
if migration_model:
migration_field = migration_model.fields.get(field.name)
if migration_field:
# Check if translatable setting changed
old_translatable = getattr(migration_field, 'translatable', True)
if old_translatable != current_translatable:
if model_name not in changes:
changes[model_name] = {}
changes[model_name][field.name] = {
'from_translatable': old_translatable,
'to_translatable': current_translatable
}
except (KeyError, AttributeError):
# Field doesn't exist in migrations yet, skip
pass
except Exception as e:
self.stdout.write(
self.style.WARNING(f'Could not detect changes in {app_config.label}: {e}')
)
return changes
def create_migration(self, app_config, model_name, field_name, from_translatable, to_translatable, language):
"""Create a migration file with the conversion operation"""
# Create the operation
operation = ConvertTranslatableField(
model_name=model_name.lower(),
field_name=field_name,
from_translatable=from_translatable,
to_translatable=to_translatable,
language=language
)
# Create migration
migration = Migration(
f"convert_{field_name}_translatable",
app_config.label
)
migration.operations = [operation]
# Find migrations directory
migrations_dir = os.path.join(app_config.path, 'migrations')
if not os.path.exists(migrations_dir):
os.makedirs(migrations_dir)
# Generate migration file
writer = MigrationWriter(migration)
migration_string = writer.as_string()
# Find next migration number
existing_migrations = [
f for f in os.listdir(migrations_dir)
if f.endswith('.py') and f[0].isdigit()
]
if existing_migrations:
numbers = [int(f.split('_')[0]) for f in existing_migrations if f.split('_')[0].isdigit()]
next_number = max(numbers) + 1 if numbers else 1
else:
next_number = 1
filename = f"{next_number:04d}_convert_{field_name}_translatable.py"
filepath = os.path.join(migrations_dir, filename)
with open(filepath, 'w') as f:
f.write(migration_string)
self.stdout.write(f"Created migration: {filepath}")

View File

@@ -0,0 +1,313 @@
# -*- coding: utf-8 -*-
"""
Django Translatable Fields - Custom Managers and QuerySets
This module provides custom Django managers and querysets that add powerful
search and filtering capabilities for translatable fields. It includes
language-aware search functionality and context management.
Key Features:
- Language-aware search across translatable fields
- Context management for overriding browser language
- Database-specific optimizations (PostgreSQL, MySQL, SQLite)
- Chainable QuerySet operations with language context
- Integration with global and local language contexts
Usage:
# Add to your model
class Product(models.Model):
objects = TranslatableManager()
# Search in German regardless of browser language
products = Product.objects.with_context(lang='de').search('test')
# Search specific field
products = Product.objects.search_field('name', 'hello', 'en')
Author: Holger Sielaff <holger@backender.de>
Version: 0.1.0
"""
import json
from django.db import models
from django.db.models import Q
from django.utils.translation import get_language
class TranslatableQuerySet(models.QuerySet):
"""
Enhanced QuerySet with language-aware search and filtering capabilities.
This QuerySet extends Django's standard QuerySet with methods specifically
designed for working with translatable fields. It provides:
- Language context management (with_context)
- Search functionality across multiple translatable fields
- Single field search with language targeting
- Language filtering (find records with content in specific language)
- Integration with database-specific optimizations
The QuerySet maintains language context across chained operations,
allowing you to set a language once and have all subsequent operations
respect that context.
Example:
# Set German context for all operations
qs = Product.objects.with_context(lang='de')
results = qs.filter(active=True).search('test').order_by('name')
# All operations above will use German language context
"""
def __init__(self, *args, **kwargs):
"""
Initialize the QuerySet with language context support.
Args:
*args: Positional arguments passed to parent
**kwargs: Keyword arguments passed to parent
"""
super().__init__(*args, **kwargs)
# Store context language override - None means use Django's current language
self._context_language = None
def with_context(self, lang=None):
"""
Set language context for this QuerySet. All subsequent operations
will use this language instead of the current Django language.
Args:
lang: Language code to use as context
Returns:
QuerySet with language context set
Example:
# Use German context for all operations
products = Product.objects.with_context(lang='de')
# Search will now use German by default
german_products = products.search('moep') # Searches in German
# Chain with other operations
Product.objects.with_context(lang='de').filter(price__lt=100).search('name', 'test')
"""
clone = self._clone()
clone._context_language = lang
return clone
def _get_context_language(self, language=None):
"""
Get the effective language to use, considering all context sources.
Args:
language: Explicit language parameter
Returns:
Language code to use
"""
if language is not None:
return language
if self._context_language is not None:
return self._context_language
# Check global context manager
from .context import get_context_language
global_context = get_context_language()
if global_context is not None:
return global_context
return get_language() or 'en_US'
def _clone(self):
"""Override clone to preserve context language"""
clone = super()._clone()
clone._context_language = getattr(self, '_context_language', None)
return clone
def search_translatable(self, field_name, query, language=None):
"""
Search in a translatable field for the specified language.
Args:
field_name: Name of the translatable field to search in
query: Search query string
language: Language code to search (defaults to context or current language)
Returns:
QuerySet filtered by the search criteria
Example:
Product.objects.search_translatable('name', 'moep', 'de')
Product.objects.with_context(lang='de').search_translatable('name', 'moep')
"""
from .search import TranslatableSearch
effective_language = self._get_context_language(language)
return TranslatableSearch.search_translatable_field(self, field_name, query, effective_language)
def search_translatable_all_languages(self, field_name, query):
"""
Search in a translatable field across all languages.
Args:
field_name: Name of the translatable field to search in
query: Search query string
Returns:
QuerySet filtered by the search criteria across all languages
"""
if not query:
return self
# Search in the entire JSON field
return self.filter(**{f'{field_name}__icontains': query})
def search_multiple_fields(self, query, language=None, fields=None):
"""
Search across multiple translatable fields.
Args:
query: Search query string
language: Language code to search (defaults to context or current language)
fields: List of field names to search (defaults to all translatable fields)
Returns:
QuerySet filtered by the search criteria
Example:
Product.objects.search_multiple_fields('moep', 'de', ['name', 'description'])
Product.objects.with_context(lang='de').search_multiple_fields('moep')
"""
from .search import TranslatableSearch
effective_language = self._get_context_language(language)
return TranslatableSearch.search_multiple_fields(self, query, effective_language, fields)
def filter_by_language(self, language=None):
"""
Filter objects that have content in the specified language.
Args:
language: Language code to filter by (defaults to context or current language)
Returns:
QuerySet filtered to objects with content in the specified language
Example:
Product.objects.filter_by_language('de')
Product.objects.with_context(lang='de').filter_by_language()
"""
effective_language = self._get_context_language(language)
# Get all translatable fields
translatable_fields = self._get_translatable_fields()
q_objects = Q()
for field_name in translatable_fields:
# Check if field has content for this language
field_q = Q(**{f'{field_name}__icontains': f'"{effective_language}":'})
q_objects |= field_q
return self.filter(q_objects)
def _get_translatable_fields(self):
"""
Get list of translatable field names for this model.
Returns:
List of field names that are translatable
"""
if not self.model:
return []
translatable_fields = []
for field in self.model._meta.get_fields():
# Check if field has translatable attribute
if hasattr(field, 'translatable') and field.translatable:
translatable_fields.append(field.name)
return translatable_fields
class TranslatableManager(models.Manager):
"""
Custom manager for models with translatable fields
"""
def get_queryset(self):
"""Return custom QuerySet"""
return TranslatableQuerySet(self.model, using=self._db)
def with_context(self, lang=None):
"""
Set language context for all subsequent operations.
Args:
lang: Language code to use as context
Returns:
QuerySet with language context set
Example:
Product.objects.with_context(lang='de').search('moep')
"""
return self.get_queryset().with_context(lang)
def search(self, query, language=None, fields=None):
"""
Convenience method for searching translatable fields.
Args:
query: Search query string
language: Language code to search (defaults to context or current language)
fields: List of field names to search (defaults to all translatable fields)
Returns:
QuerySet filtered by the search criteria
Example:
Product.objects.search('moep', 'de')
Product.objects.with_context(lang='de').search('moep')
"""
return self.get_queryset().search_multiple_fields(query, language, fields)
def search_field(self, field_name, query, language=None):
"""
Convenience method for searching a specific translatable field.
Args:
field_name: Name of the translatable field to search in
query: Search query string
language: Language code to search (defaults to context or current language)
Returns:
QuerySet filtered by the search criteria
Example:
Product.objects.search_field('name', 'moep', 'de')
Product.objects.with_context(lang='de').search_field('name', 'moep')
"""
return self.get_queryset().search_translatable(field_name, query, language)
def with_language(self, language=None):
"""
Get objects that have content in the specified language.
Args:
language: Language code to filter by (defaults to context or current language)
Returns:
QuerySet filtered to objects with content in the specified language
"""
return self.get_queryset().filter_by_language(language)
# Mixin to easily add translatable search to any model
class TranslatableModelMixin:
"""
Mixin to add translatable search capabilities to any model.
Just inherit from this mixin in your model.
"""
objects = TranslatableManager()
class Meta:
abstract = True

View File

@@ -0,0 +1,129 @@
# -*- coding: utf-8 -*-
"""
Django Translatable Fields - Migration Operations
This module provides custom Django migration operations for handling
translatable field data transitions when the translatable parameter
changes from True to False or vice versa.
Key Features:
- Automatic data conversion between JSON and string formats
- Support for translate=True to translate=False transitions
- Support for translate=False to translate=True transitions
- Language-aware data extraction and wrapping
- Reversible migration operations
- Database-safe SQL operations
Author: Holger Sielaff <holger@backender.de>
Version: 0.1.0
"""
import json
from django.db import migrations
from django.utils.translation import get_language
class ConvertTranslatableField(migrations.Operation):
"""
Custom migration operation to convert between translatable and non-translatable formats
"""
def __init__(self, model_name, field_name, from_translatable, to_translatable, language=None):
self.model_name = model_name
self.field_name = field_name
self.from_translatable = from_translatable
self.to_translatable = to_translatable
self.language = language or get_language() or 'en'
def state_forwards(self, app_label, state):
# No state changes needed - field definition stays the same
pass
def database_forwards(self, app_label, schema_editor, from_state, to_state):
"""Convert data when applying migration"""
# Get the model
from_model = from_state.apps.get_model(app_label, self.model_name)
if self.from_translatable and not self.to_translatable:
# Convert from JSON to string (extract current language)
self._convert_json_to_string(from_model, schema_editor)
elif not self.from_translatable and self.to_translatable:
# Convert from string to JSON (wrap in language dict)
self._convert_string_to_json(from_model, schema_editor)
def database_backwards(self, app_label, schema_editor, from_state, to_state):
"""Convert data when reversing migration"""
# Reverse the conversion
to_model = to_state.apps.get_model(app_label, self.model_name)
if self.from_translatable and not self.to_translatable:
# Reverse: string back to JSON
self._convert_string_to_json(to_model, schema_editor)
elif not self.from_translatable and self.to_translatable:
# Reverse: JSON back to string
self._convert_json_to_string(to_model, schema_editor)
def _convert_json_to_string(self, model, schema_editor):
"""Convert JSON translations to single language string"""
print(f"Converting {self.model_name}.{self.field_name} from JSON to string (language: {self.language})")
for obj in model.objects.all():
field_value = getattr(obj, self.field_name)
if field_value:
try:
if isinstance(field_value, str):
# Try to parse JSON
translations = json.loads(field_value)
if isinstance(translations, dict):
# Extract value for current language or fallback
new_value = (
translations.get(self.language) or
translations.get('en') or
next(iter(translations.values()), '')
)
# Update using raw SQL to avoid field processing
table_name = obj._meta.db_table
schema_editor.execute(
f"UPDATE {table_name} SET {self.field_name} = %s WHERE id = %s",
[new_value, obj.pk]
)
print(f" Converted {obj.pk}: {field_value} -> {new_value}")
except (json.JSONDecodeError, TypeError):
# Already a string, leave as-is
pass
def _convert_string_to_json(self, model, schema_editor):
"""Convert single language string to JSON translations"""
print(f"Converting {self.model_name}.{self.field_name} from string to JSON (language: {self.language})")
for obj in model.objects.all():
field_value = getattr(obj, self.field_name)
if field_value:
try:
# Check if it's already JSON
json.loads(field_value)
# If parsing succeeds, it's already JSON - skip
except (json.JSONDecodeError, TypeError):
# It's a plain string, convert to JSON
new_value = json.dumps({self.language: str(field_value)})
# Update using raw SQL to avoid field processing
table_name = obj._meta.db_table
schema_editor.execute(
f"UPDATE {table_name} SET {self.field_name} = %s WHERE id = %s",
[new_value, obj.pk]
)
print(f" Converted {obj.pk}: {field_value} -> {new_value}")
def describe(self):
direction = "translatable" if self.to_translatable else "non-translatable"
return f"Convert {self.model_name}.{self.field_name} to {direction}"
@property
def migration_name_fragment(self):
direction = "translatable" if self.to_translatable else "nontranslatable"
return f"convert_{self.field_name}_to__{direction}"

View File

@@ -0,0 +1,235 @@
# -*- coding: utf-8 -*-
"""
Django Translatable Fields - Advanced Search Functionality
This module provides database-optimized search capabilities for translatable
fields. It includes database-specific implementations for PostgreSQL, MySQL,
and SQLite to ensure optimal performance when searching JSON-stored translations.
Key Features:
- Database-specific JSON search optimizations
- PostgreSQL: Native JSON operators (->>, ->, etc.)
- MySQL: JSON_EXTRACT and JSON_UNQUOTE functions
- SQLite: json_extract function
- Generic fallback for other databases
- Case-insensitive search support
- Multi-field search across different translatable fields
The module automatically detects the database backend and uses the most
efficient search method available. This ensures fast search performance
even with large datasets containing complex translation structures.
Usage:
# Used internally by TranslatableQuerySet
results = TranslatableSearch.search_translatable_field(
queryset, 'name', 'search_term', 'de'
)
Author: Holger Sielaff <holger@backender.de>
Version: 0.1.0
"""
import json
from django.db import models, connection
from django.db.models import Q
from django.utils.translation import get_language
class TranslatableSearch:
"""
Advanced search functionality for translatable fields with database-specific optimizations.
This class provides database-optimized search methods for translatable fields.
PostgreSQL is strongly recommended for optimal performance as it provides
native JSON operators that are significantly faster than generic approaches.
Performance Comparison:
- PostgreSQL: Uses native ->, ->> operators (~10x faster)
- MySQL: Uses JSON_EXTRACT functions (good performance)
- SQLite: Uses json_extract function (acceptable for small datasets)
- Others: Generic fallback (slower performance)
For production use with large datasets, PostgreSQL is highly recommended.
"""
@staticmethod
def get_database_vendor():
"""Get the database vendor (postgresql, mysql, sqlite, etc.)"""
return connection.vendor
@classmethod
def search_translatable_field(cls, queryset, field_name, query, language=None):
"""
Search in a translatable field for the specified language with database-specific optimizations.
Args:
queryset: QuerySet to filter
field_name: Name of the translatable field to search in
query: Search query string
language: Language code to search (defaults to current language)
Returns:
Filtered QuerySet
"""
if not query:
return queryset
language = language or get_language() or 'en_US'
query = str(query).strip()
vendor = cls.get_database_vendor()
if vendor == 'postgresql':
return cls._search_postgresql(queryset, field_name, query, language)
elif vendor == 'mysql':
return cls._search_mysql(queryset, field_name, query, language)
elif vendor == 'sqlite':
return cls._search_sqlite(queryset, field_name, query, language)
else:
# Generic fallback
return cls._search_generic(queryset, field_name, query, language)
@classmethod
def _search_postgresql(cls, queryset, field_name, query, language):
"""PostgreSQL-specific JSON search using -> operator"""
# Use PostgreSQL JSON operators for efficient search
return queryset.extra(
where=[f"LOWER({field_name}->>'%s') LIKE LOWER(%s)"],
params=[language, f'%{query}%']
)
@classmethod
def _search_mysql(cls, queryset, field_name, query, language):
"""MySQL-specific JSON search using JSON_UNQUOTE and JSON_EXTRACT"""
return queryset.extra(
where=["LOWER(JSON_UNQUOTE(JSON_EXTRACT(%s, CONCAT('$.', %%s)))) LIKE LOWER(%%s)" % field_name],
params=[language, f'%{query}%']
)
@classmethod
def _search_sqlite(cls, queryset, field_name, query, language):
"""SQLite JSON search using json_extract"""
return queryset.extra(
where=[f"LOWER(json_extract({field_name}, '$.{language}')) LIKE LOWER(%s)"],
params=[f'%{query}%']
)
@classmethod
def _search_generic(cls, queryset, field_name, query, language):
"""Generic fallback using text search in JSON"""
# Fallback: search for the pattern in the JSON text
search_pattern = f'"{language}":"%{query}%"'
return queryset.filter(**{f'{field_name}__icontains': search_pattern})
@classmethod
def search_multiple_fields(cls, queryset, query, language=None, fields=None):
"""
Search across multiple translatable fields.
Args:
queryset: QuerySet to filter
query: Search query string
language: Language code to search (defaults to current language)
fields: List of field names to search (auto-detected if None)
Returns:
Filtered QuerySet
"""
if not query:
return queryset
language = language or get_language() or 'en_US'
# Auto-detect translatable fields if not specified
if fields is None:
fields = cls._get_translatable_fields(queryset.model)
if not fields:
return queryset
# Build OR query across all fields
q_objects = Q()
for field_name in fields:
# Create a sub-queryset for this field and extract the WHERE clause
field_qs = cls.search_translatable_field(
queryset.model.objects.all(),
field_name,
query,
language
)
# Add to OR conditions - this is a simplified approach
# In practice, you might want to use more sophisticated query building
q_objects |= Q(**{f'{field_name}__icontains': f'"{language}":'}) & Q(**{f'{field_name}__icontains': query})
return queryset.filter(q_objects)
@classmethod
def _get_translatable_fields(cls, model):
"""Get list of translatable field names for a model"""
translatable_fields = []
for field in model._meta.get_fields():
if hasattr(field, 'translatable') and field.translatable:
translatable_fields.append(field.name)
return translatable_fields
def search_products(query, language=None, fields=None):
"""
Convenience function to search products.
Args:
query: Search query string
language: Language code (defaults to current language)
fields: List of field names to search (defaults to ['name', 'description'])
Returns:
QuerySet of matching products
Example:
products = search_products('moep', 'de')
products = search_products('hello', fields=['name'])
"""
from .models import Product # Adjust import as needed
queryset = Product.objects.all()
fields = fields or ['name', 'description']
return TranslatableSearch.search_multiple_fields(
queryset, query, language, fields
)
class SearchForm:
"""
Helper class to create search forms with language selection
"""
@staticmethod
def get_language_choices():
"""Get available language choices from Django settings"""
from django.conf import settings
return getattr(settings, 'LANGUAGES', [('en', 'English'), ('de', 'German')])
@classmethod
def create_search_context(cls, request):
"""
Create context for search forms.
Args:
request: Django request object
Returns:
Dictionary with search context
"""
query = request.GET.get('q', '')
language = request.GET.get('lang', get_language() or 'en_US')
fields = request.GET.getlist('fields')
return {
'search_query': query,
'search_language': language,
'search_fields': fields,
'available_languages': cls.get_language_choices(),
'current_language': get_language() or 'en_US'
}

View File

@@ -0,0 +1,264 @@
# -*- coding: utf-8 -*-
"""
Django Translatable Fields - DRF Serializer Support
This module provides Django REST Framework serializer mixins and fields
for handling translatable fields in API responses and requests.
Key Features:
- Automatic language detection from request parameters
- Language-specific field serialization and deserialization
- Support for both single-language and multi-language API responses
- Easy integration with existing DRF serializers
Usage:
from django_translatable_fields.serializers import TranslatableSerializerMixin
class ProductSerializer(TranslatableSerializerMixin, serializers.ModelSerializer):
class Meta:
model = Product
fields = ['id', 'name', 'description', 'price']
# API Usage:
# GET /api/products/?language=de -> Returns German translations
# GET /api/products/ -> Returns all translations as dicts
# POST /api/products/ with {"name": "Test", "language": "de"} -> Saves to German
Author: Holger Sielaff <holger@backender.de>
Version: 0.1.0
"""
import json
from rest_framework import serializers
from django.utils.translation import get_language
class TranslatableSerializerMixin:
"""
Mixin for DRF serializers that automatically handles translatable fields.
This mixin provides automatic language detection and processing for fields
that have the `translatable=True` attribute. It supports both reading and
writing translatable content via API endpoints.
Features:
- Language parameter detection (?language=de)
- Automatic field conversion for translatable fields
- Support for both single-language and multi-language responses
- Proper handling of JSON storage format
Usage:
class MySerializer(TranslatableSerializerMixin, serializers.ModelSerializer):
class Meta:
model = MyModel
fields = ['name', 'description'] # name and description are translatable
# API calls:
# GET /api/items/?language=de -> returns German values
# GET /api/items/ -> returns full translation dicts
# POST /api/items/ with language param -> saves to specific language
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._setup_translatable_fields()
def _setup_translatable_fields(self):
"""Identify and setup translatable fields in the serializer"""
self._translatable_fields = []
if hasattr(self, 'Meta') and hasattr(self.Meta, 'model'):
model = self.Meta.model
for field_name in self.fields:
if hasattr(model, field_name):
model_field = model._meta.get_field(field_name)
if hasattr(model_field, 'translatable') and model_field.translatable:
self._translatable_fields.append(field_name)
def _get_request_language(self):
"""Get the language parameter from the request context"""
if hasattr(self, 'context') and 'request' in self.context:
request = self.context['request']
# Check for language parameter in query params or data
language = request.query_params.get('language')
if not language and hasattr(request, 'data'):
language = request.data.get('language')
return language
return None
def to_representation(self, instance):
"""
Convert model instance to API representation.
Logic:
- With language parameter: returns single-language values
- Without language parameter:
* If DB value is dict -> return dict
* If DB value is not dict -> return the value directly (assume current language)
"""
data = super().to_representation(instance)
request_language = self._get_request_language()
# Process translatable fields
for field_name in self._translatable_fields:
if field_name in data:
field_value = data[field_name]
# Handle JSON string from database
if isinstance(field_value, str):
try:
parsed_value = json.loads(field_value)
if isinstance(parsed_value, dict):
field_value = parsed_value
# If JSON but not dict, keep as string
except (json.JSONDecodeError, TypeError):
# If not JSON, keep as string
pass
# Return specific language or handle based on type
if request_language:
# Language specified: extract specific language value
if isinstance(field_value, dict):
# Return value for requested language, fallback to English, then first available
if request_language in field_value:
data[field_name] = field_value[request_language]
elif 'en' in field_value:
data[field_name] = field_value['en']
elif field_value:
data[field_name] = next(iter(field_value.values()))
else:
data[field_name] = ''
else:
# Single value, return as-is
data[field_name] = field_value
else:
# No language specified:
# - If dict: return dict (full translations)
# - If not dict: return value as-is (current language)
data[field_name] = field_value
return data
def to_internal_value(self, data):
"""
Convert API input data to internal representation.
Logic:
- With language parameter: converts single values to {language: value}
- Without language parameter:
* If value is dict -> use dict as-is
* If value is not dict -> convert to {'en': value}
"""
request_language = self._get_request_language()
# Process translatable fields in input data
for field_name in self._translatable_fields:
if field_name in data:
field_value = data[field_name]
if request_language:
# Convert single value to translation dict for specific language
if not isinstance(field_value, dict):
data[field_name] = {request_language: str(field_value)}
# If it's already a dict, use it as-is
else:
# No language specified:
# - If dict: use as-is (full translations)
# - If not dict: assume English
if not isinstance(field_value, dict):
data[field_name] = {'en': str(field_value)}
# If it's already a dict, leave it unchanged
return super().to_internal_value(data)
class TranslatableField(serializers.Field):
"""
Custom DRF field for handling translatable content.
This field can be used when you need more control over how translatable
fields are handled in your serializer, or when you want to use it
independently of the TranslatableSerializerMixin.
Usage:
class ProductSerializer(serializers.ModelSerializer):
name = TranslatableField()
description = TranslatableField()
class Meta:
model = Product
fields = ['id', 'name', 'description', 'price']
"""
def __init__(self, **kwargs):
self.language = kwargs.pop('language', None)
super().__init__(**kwargs)
def to_representation(self, value):
"""Convert database value to API representation"""
if not value:
return '' if self.language else {}
# Handle JSON string from database
if isinstance(value, str):
try:
translations = json.loads(value)
except (json.JSONDecodeError, TypeError):
translations = {'en': value}
elif isinstance(value, dict):
translations = value
else:
translations = {'en': str(value)}
# Return specific language or full dict
if self.language:
if self.language in translations:
return translations[self.language]
elif 'en' in translations:
return translations['en']
elif translations:
return next(iter(translations.values()))
else:
return ''
else:
return translations
def to_internal_value(self, data):
"""Convert API input to internal representation"""
if self.language:
# Convert single value to translation dict
if isinstance(data, dict):
return data
else:
return {self.language: str(data)}
else:
# Expect full translation dict
if isinstance(data, dict):
return data
else:
return {'en': str(data)}
class TranslatableCharField(TranslatableField, serializers.CharField):
"""Translatable CharField for DRF serializers"""
pass
class TranslatableTextField(TranslatableField, serializers.CharField):
"""Translatable TextField for DRF serializers"""
pass
class TranslatableEmailField(TranslatableField, serializers.EmailField):
"""Translatable EmailField for DRF serializers"""
pass
class TranslatableURLField(TranslatableField, serializers.URLField):
"""Translatable URLField for DRF serializers"""
pass
class TranslatableSlugField(TranslatableField, serializers.SlugField):
"""Translatable SlugField for DRF serializers"""
pass

View File

@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
"""
Setup script for Django Translatable Fields
This package requires PostgreSQL as it relies on native JSON field operations
for optimal search performance and data integrity.
Author: Holger Sielaff <holger@backender.de>
Version: 0.1.0
"""
from setuptools import setup, find_packages
import os
# Read the README file for long description
def read_readme():
"""Read README.md file for package description"""
readme_path = os.path.join(os.path.dirname(__file__), 'README.md')
if os.path.exists(readme_path):
with open(readme_path, 'r', encoding='utf-8') as f:
return f.read()
return "Django Translatable Fields - Odoo-style translatable fields for Django"
setup(
name='django-translatable-fields',
version='0.1.0',
description='Django fields with Odoo-style translate=True functionality',
long_description=read_readme(),
long_description_content_type='text/markdown',
author='Holger Sielaff',
author_email='holger@backender.de',
url='https://github.com/holger/django-translatable-fields', # Update with actual repo
packages=find_packages(),
include_package_data=True,
# Package requirements
install_requires=[
'Django>=4.2,<6.0',
'psycopg2-binary>=2.9.0', # PostgreSQL adapter
],
# Extra requirements for development
extras_require={
'dev': [
'pytest>=7.0.0',
'pytest-django>=4.5.0',
'black>=22.0.0',
'flake8>=4.0.0',
'mypy>=0.991',
],
'postgres': [
'psycopg2-binary>=2.9.0',
],
},
# Python version requirement
python_requires='>=3.8',
# Package classifiers
classifiers=[
'Development Status :: 4 - Beta',
'Environment :: Web Environment',
'Framework :: Django',
'Framework :: Django :: 4.2',
'Framework :: Django :: 5.0',
'Framework :: Django :: 5.1',
'Framework :: Django :: 5.2',
'Intended Audience :: Developers',
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
'Programming Language :: Python',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
'Topic :: Software Development :: Libraries :: Python Modules',
'Topic :: Database',
'Topic :: Text Processing :: Linguistic',
],
# Package keywords
keywords='django, translation, i18n, l10n, fields, postgresql, json, odoo',
# Entry points
entry_points={
'console_scripts': [
'translatable-migrate=django_translatable_fields.management.commands.makemigrations_translatable:main',
],
},
# Package data
package_data={
'django_translatable_fields': [
'static/django_translatable_fields/css/*.css',
'static/django_translatable_fields/js/*.js',
'templates/django_translatable_fields/*.html',
'locale/*/LC_MESSAGES/*.po',
'locale/*/LC_MESSAGES/*.mo',
],
},
# Zip safe
zip_safe=False,
)

View File

@@ -0,0 +1,256 @@
.translatable-field-container {
position: relative;
display: inline-block;
width: 100%;
}
.translatable-primary-field {
width: 80% !important;
display: inline-block;
vertical-align: top;
}
.translatable-button {
width: 30px;
height: 30px;
border: 1px solid #ccc;
background: #f8f9fa;
cursor: pointer;
border-radius: 4px;
margin-left: 5px;
vertical-align: top;
display: inline-flex;
align-items: center;
justify-content: center;
}
.translatable-button:hover {
background: #e9ecef;
}
.translate-modal {
position: fixed;
z-index: 10000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
}
.translate-modal-content {
background-color: #fefefe;
margin: 5% auto;
padding: 0;
border: 1px solid #888;
width: 80%;
max-width: 600px;
min-width: 400px;
min-height: 300px;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
position: relative;
overflow: hidden;
}
.translate-modal-content .translate-modal-body textarea,
.translate-modal-content .translate-modal-body input[type=text]{
max-width: 95%;
}
.translate-modal-header h3 {
margin: 0;
font-size: 1.25rem;
}
.translate-modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.translate-modal-footer {
padding: 15px 20px;
background-color: #f8f9fa;
border-top: 1px solid #dee2e6;
border-radius: 0 0 8px 8px;
text-align: right;
}
.translate-field {
margin-bottom: 15px;
}
.translate-field label {
display: block;
font-weight: bold;
margin-bottom: 5px;
color: #495057;
}
.translate-field input,
.translate-field textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.translate-field textarea {
resize: both;
min-height: 60px;
min-width: 200px;
}
/* WYSIWYG Editor Styles */
.wysiwyg-container {
position: relative;
}
.wysiwyg-toolbar {
background: #f8f9fa;
border: 1px solid #ced4da;
border-bottom: none;
border-radius: 4px 4px 0 0;
padding: 8px;
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.wysiwyg-btn {
background: #fff;
border: 1px solid #ced4da;
border-radius: 3px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
min-width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.wysiwyg-btn:hover {
background: #e9ecef;
}
.wysiwyg-btn.active {
background: #007bff;
color: white;
border-color: #0056b3;
}
.wysiwyg-editor {
border: 1px solid #ced4da;
border-radius: 0 0 4px 4px;
padding: 8px 12px;
min-height: 100px;
max-height: 300px;
overflow-y: auto;
font-family: inherit;
font-size: 14px;
line-height: 1.4;
background: white;
}
.wysiwyg-editor:focus {
outline: none;
border-color: #80bdff;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.wysiwyg-toggle {
position: absolute;
top: -25px;
right: 0;
background: #6c757d;
color: white;
border: none;
border-radius: 3px 3px 0 0;
padding: 2px 8px;
font-size: 11px;
cursor: pointer;
z-index: 5;
}
.wysiwyg-toggle:hover {
background: #5a6268;
}
.wysiwyg-active .wysiwyg-toggle {
background: #007bff;
}
.wysiwyg-active .wysiwyg-toggle:hover {
background: #0056b3;
}
.btn {
padding: 8px 16px;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
margin-left: 8px;
}
.btn-secondary {
background-color: #6c757d;
border-color: #6c757d;
color: #fff;
}
.btn-primary {
background-color: #007bff;
border-color: #007bff;
color: #fff;
}
/* Custom resize handle */
.modal-resize-handle {
position: absolute;
bottom: 0;
right: 0;
width: 20px;
height: 20px;
background: linear-gradient(-45deg, transparent 30%, #999 30%, #999 35%, transparent 35%, transparent 65%, #999 65%, #999 70%, transparent 70%);
cursor: nw-resize;
z-index: 10;
}
.modal-resize-handle:hover {
background: linear-gradient(-45deg, transparent 30%, #666 30%, #666 35%, transparent 35%, transparent 65%, #666 65%, #666 70%, transparent 70%);
}
/* Draggable modal header */
.translate-modal-header {
padding: 15px 20px;
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
border-radius: 8px 8px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
cursor: move;
user-select: none;
}
.translate-modal-header:active {
cursor: grabbing;
}
/* Ensure modal body is flexible for resizing */
.translate-modal-body {
padding: 20px;
max-height: calc(100% - 120px);
overflow-y: auto;
}

View File

@@ -0,0 +1,525 @@
// Ensure modal HTML is added to the page only once
document.addEventListener('DOMContentLoaded', function() {
if (!document.getElementById('translateModal')) {
const modalHTML = `
<div id="translateModal" class="translate-modal" style="display: none;">
<div class="translate-modal-content" id="translateModalContent">
<div class="translate-modal-header" id="translateModalHeader">
<h3>Translations</h3>
<button type="button" class="translate-modal-close" onclick="closeTranslateModal()">&times;</button>
</div>
<div class="translate-modal-body">
<!-- Content will be populated by JavaScript -->
</div>
<div class="translate-modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeTranslateModal()">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveTranslations()">Save</button>
</div>
<div class="modal-resize-handle" id="modalResizeHandle"></div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
// Initialize drag and resize functionality
initModalInteractions();
}
});
// Modal interaction state
let modalState = {
isDragging: false,
isResizing: false,
dragOffset: { x: 0, y: 0 },
resizeStartPos: { x: 0, y: 0 },
resizeStartSize: { width: 0, height: 0 }
};
function initModalInteractions() {
const modal = document.getElementById('translateModal');
const modalContent = document.getElementById('translateModalContent');
const modalHeader = document.getElementById('translateModalHeader');
const resizeHandle = document.getElementById('modalResizeHandle');
if (!modalHeader || !modalContent || !resizeHandle) return;
// Drag functionality
modalHeader.addEventListener('mousedown', function(e) {
if (e.target.closest('.translate-modal-close')) return; // Don't drag on close button
modalState.isDragging = true;
const rect = modalContent.getBoundingClientRect();
modalState.dragOffset.x = e.clientX - rect.left;
modalState.dragOffset.y = e.clientY - rect.top;
e.preventDefault();
document.body.style.cursor = 'grabbing';
modalHeader.style.cursor = 'grabbing';
});
// Resize functionality
resizeHandle.addEventListener('mousedown', function(e) {
modalState.isResizing = true;
modalState.resizeStartPos.x = e.clientX;
modalState.resizeStartPos.y = e.clientY;
modalState.resizeStartSize.width = modalContent.offsetWidth;
modalState.resizeStartSize.height = modalContent.offsetHeight;
e.preventDefault();
e.stopPropagation(); // Prevent drag from triggering
document.body.style.cursor = 'nw-resize';
});
// Mouse move handler
document.addEventListener('mousemove', function(e) {
if (modalState.isDragging) {
e.preventDefault();
const newX = e.clientX - modalState.dragOffset.x;
const newY = e.clientY - modalState.dragOffset.y;
// Keep modal within viewport bounds
const maxX = window.innerWidth - modalContent.offsetWidth;
const maxY = window.innerHeight - modalContent.offsetHeight;
const boundedX = Math.max(0, Math.min(newX, maxX));
const boundedY = Math.max(0, Math.min(newY, maxY));
modalContent.style.position = 'fixed';
modalContent.style.left = boundedX + 'px';
modalContent.style.top = boundedY + 'px';
modalContent.style.margin = '0';
}
if (modalState.isResizing) {
e.preventDefault();
const deltaX = e.clientX - modalState.resizeStartPos.x;
const deltaY = e.clientY - modalState.resizeStartPos.y;
const newWidth = Math.max(400, modalState.resizeStartSize.width + deltaX);
const newHeight = Math.max(300, modalState.resizeStartSize.height + deltaY);
modalContent.style.width = newWidth + 'px';
modalContent.style.height = newHeight + 'px';
modalContent.style.maxWidth = 'none'; // Override max-width
}
});
// Mouse up handler
document.addEventListener('mouseup', function() {
if (modalState.isDragging) {
document.body.style.cursor = '';
modalHeader.style.cursor = 'move';
// Small delay before resetting drag state to prevent immediate modal close
setTimeout(() => {
modalState.isDragging = false;
}, 50);
}
if (modalState.isResizing) {
document.body.style.cursor = '';
// Small delay before resetting resize state to prevent immediate modal close
setTimeout(() => {
modalState.isResizing = false;
}, 50);
}
});
}
let currentTranslateField = null;
function openTranslateModal(fieldId) {
currentTranslateField = fieldId;
const modal = document.getElementById('translateModal');
const modalBody = modal.querySelector('.translate-modal-body');
// Get all language fields for this translatable field
const hiddenFields = document.querySelectorAll(`input[id^="${fieldId}_"], input[id^="id_${fieldId}_"]`);
console.log('Debug: openTranslateModal for', fieldId);
console.log('Debug: Found hidden fields:', hiddenFields.length);
// Debug: List all found hidden fields
hiddenFields.forEach(field => {
console.log(`Debug: Found hidden field: ${field.id} with value: "${field.value}"`);
});
// Get the primary field and check what data we have available
const primaryField = document.getElementById(fieldId);
let currentTranslations = {};
console.log('Debug: Primary field found:', !!primaryField);
console.log('Debug: Primary field value:', primaryField ? primaryField.value : 'N/A');
// First, collect all existing values from hidden fields
hiddenFields.forEach(field => {
if (field.value) {
const langCode = field.dataset.lang;
currentTranslations[langCode] = field.value;
console.log(`Debug: Found existing translation ${langCode}: "${field.value}"`);
}
});
// Then try to get English from primary field
if (primaryField && primaryField.value) {
const primaryValue = primaryField.value.trim();
// If primary field looks like JSON, try to parse it
if (primaryValue.startsWith('{')) {
try {
const parsed = JSON.parse(primaryValue);
if (typeof parsed === 'object' && parsed !== null) {
// Merge parsed translations with existing ones
Object.assign(currentTranslations, parsed);
console.log('Debug: Merged JSON translations from primary field:', currentTranslations);
}
} catch (e) {
console.log('Debug: Primary field JSON parse failed, treating as English');
currentTranslations.en = primaryValue;
}
} else {
// Plain text, use as English
currentTranslations.en = primaryValue;
}
}
console.log('Debug: Final current translations:', currentTranslations);
let bodyContent = '';
// Get all configured languages (not just hidden fields)
const languages = [
{code: 'de', name: 'Deutsch'},
{code: 'fr', name: 'Français'},
{code: 'es', name: 'Español'},
{code: 'it', name: 'Italiano'}
];
languages.forEach(lang => {
const langCode = lang.code;
const langName = lang.name;
// Get value from our collected translations
let value = currentTranslations[langCode] || '';
console.log(`Debug: Using value for ${langCode}: "${value}"`);
// Also verify the hidden field exists and has the same value
const possibleIds = [
`${fieldId}_${langCode}`,
`id_${fieldId}_${langCode}`,
];
let hiddenField = null;
for (const possibleId of possibleIds) {
hiddenField = document.getElementById(possibleId);
if (hiddenField) {
console.log(`Debug: Verified hidden field ${possibleId} has value: "${hiddenField.value}"`);
break;
}
}
// Check if original field is textarea or other input type
const originalField = document.getElementById(fieldId);
const isTextarea = originalField && originalField.tagName.toLowerCase() === 'textarea';
console.log(`Debug: Original field type for ${langCode}: ${originalField ? originalField.tagName : 'NOT_FOUND'}, isTextarea: ${isTextarea}`);
console.log(`Debug: Original field ${fieldId}: found=${!!originalField}, tagName=${originalField?.tagName}, isTextarea=${isTextarea}`);
// Escape HTML in value to prevent issues
const escapedValue = value.replace(/"/g, '&quot;').replace(/'/g, '&#x27;');
console.log(`Debug: Will create modal input for ${langCode} with value: "${value}" (escaped: "${escapedValue}")`);
let inputHtml;
if (isTextarea) {
// Create textarea with WYSIWYG toggle
inputHtml = `
<div class="wysiwyg-container">
<button type="button" class="wysiwyg-toggle" onclick="toggleWysiwyg('modal_${fieldId}_${langCode}')">Rich</button>
<textarea id="modal_${fieldId}_${langCode}" rows="3">${escapedValue}</textarea>
</div>
`;
} else {
inputHtml = `<input type="text" id="modal_${fieldId}_${langCode}" value="${escapedValue}">`;
}
console.log(`Debug: Generated input HTML for ${langCode}: ${inputHtml}`);
bodyContent += `
<div class="translate-field">
<label for="modal_${fieldId}_${langCode}">${langName} (${langCode}):</label>
${inputHtml}
</div>
`;
});
console.log('Debug: Generated modal content:', bodyContent);
modalBody.innerHTML = bodyContent;
// Debug: Check the actual HTML that was inserted
console.log('Debug: Modal body HTML after insert:', modalBody.innerHTML);
// Alternative approach: Set values after DOM insertion
setTimeout(() => {
console.log('Debug: Setting values directly after DOM insertion...');
languages.forEach(lang => {
const langCode = lang.code;
const modalInput = document.getElementById(`modal_${fieldId}_${langCode}`);
if (modalInput) {
// Get the value again from hidden field
const possibleIds = [
`${fieldId}_${langCode}`,
`id_${fieldId}_${langCode}`,
];
let hiddenField = null;
for (const possibleId of possibleIds) {
hiddenField = document.getElementById(possibleId);
if (hiddenField) break;
}
if (hiddenField && hiddenField.value) {
modalInput.value = hiddenField.value;
console.log(`Debug: Force-set modal input ${modalInput.id} to value: "${hiddenField.value}"`);
} else {
console.log(`Debug: No hidden field value to set for ${langCode}`);
}
console.log(`Debug: Final modal input ${modalInput.id} value: "${modalInput.value}"`);
} else {
console.log(`Debug: Modal input modal_${fieldId}_${langCode} NOT FOUND after DOM insertion`);
}
});
}, 10);
modal.style.display = 'block';
}
function closeTranslateModal() {
const modal = document.getElementById('translateModal');
const modalContent = document.getElementById('translateModalContent');
modal.style.display = 'none';
currentTranslateField = null;
// Reset modal state
modalState.isDragging = false;
modalState.isResizing = false;
document.body.style.cursor = '';
// Reset modal position and size for next opening
if (modalContent) {
modalContent.style.position = 'relative';
modalContent.style.left = '';
modalContent.style.top = '';
modalContent.style.margin = '5% auto';
modalContent.style.width = '80%';
modalContent.style.height = '';
modalContent.style.maxWidth = '600px'; // Restore max-width
}
}
function saveTranslations() {
if (!currentTranslateField) return;
// Get all modal inputs and update corresponding hidden fields
const modalInputs = document.querySelectorAll(`[id^="modal_${currentTranslateField}_"]`);
modalInputs.forEach(input => {
const langCode = input.id.split('_').pop();
// Try different possible ID formats for the hidden field (same as in openTranslateModal)
const possibleIds = [
`${currentTranslateField}_${langCode}`, // Direct format
`id_${currentTranslateField}_${langCode}`, // Django admin format
];
let hiddenField = null;
for (const possibleId of possibleIds) {
hiddenField = document.getElementById(possibleId);
if (hiddenField) {
console.log(`Debug: Saving to hidden field with ID: ${possibleId}`);
break;
}
}
if (hiddenField) {
hiddenField.value = input.value;
console.log(`Debug: Set ${hiddenField.id} = "${input.value}"`);
} else {
console.log(`Debug: Could not find hidden field for ${langCode}, tried: ${possibleIds.join(', ')}`);
}
});
closeTranslateModal();
}
// Close modal when clicking outside (but not during drag/resize)
window.onclick = function(event) {
const modal = document.getElementById('translateModal');
// Don't close if we're currently dragging or resizing
if (modalState.isDragging || modalState.isResizing) {
return;
}
// Only close if clicking on the backdrop (not the modal content)
if (event.target === modal) {
closeTranslateModal();
}
}
// WYSIWYG Editor Functions
function toggleWysiwyg(textareaId) {
const container = document.getElementById(textareaId).closest('.wysiwyg-container');
const textarea = document.getElementById(textareaId);
const toggleBtn = container.querySelector('.wysiwyg-toggle');
if (container.classList.contains('wysiwyg-active')) {
// Switch back to textarea
disableWysiwyg(container, textarea, toggleBtn);
} else {
// Switch to WYSIWYG
enableWysiwyg(container, textarea, toggleBtn);
}
}
function enableWysiwyg(container, textarea, toggleBtn) {
// Create toolbar
const toolbar = document.createElement('div');
toolbar.className = 'wysiwyg-toolbar';
toolbar.innerHTML = `
<button type="button" class="wysiwyg-btn" onclick="execCommand('bold')" title="Bold"><b>B</b></button>
<button type="button" class="wysiwyg-btn" onclick="execCommand('italic')" title="Italic"><i>I</i></button>
<button type="button" class="wysiwyg-btn" onclick="execCommand('underline')" title="Underline"><u>U</u></button>
<button type="button" class="wysiwyg-btn" onclick="execCommand('strikeThrough')" title="Strike"><s>S</s></button>
<span style="border-left: 1px solid #ccc; margin: 0 4px; height: 20px;"></span>
<select onchange="execCommand('formatBlock', this.value); this.value='';" class="wysiwyg-btn" style="width: auto; padding: 2px;">
<option value="">Format</option>
<option value="<h1>">Heading 1</option>
<option value="<h2>">Heading 2</option>
<option value="<h3>">Heading 3</option>
<option value="<p>">Paragraph</option>
</select>
<span style="border-left: 1px solid #ccc; margin: 0 4px; height: 20px;"></span>
<button type="button" class="wysiwyg-btn" onclick="execCommand('justifyLeft')" title="Align Left">◄</button>
<button type="button" class="wysiwyg-btn" onclick="execCommand('justifyCenter')" title="Center">●</button>
<button type="button" class="wysiwyg-btn" onclick="execCommand('justifyRight')" title="Align Right">►</button>
<span style="border-left: 1px solid #ccc; margin: 0 4px; height: 20px;"></span>
<button type="button" class="wysiwyg-btn" onclick="execCommand('insertUnorderedList')" title="Bullet List">• •</button>
<button type="button" class="wysiwyg-btn" onclick="execCommand('insertOrderedList')" title="Number List">1.</button>
<span style="border-left: 1px solid #ccc; margin: 0 4px; height: 20px;"></span>
<button type="button" class="wysiwyg-btn" onclick="createLink()" title="Link">🔗</button>
<button type="button" class="wysiwyg-btn" onclick="execCommand('unlink')" title="Remove Link">⚡</button>
<button type="button" class="wysiwyg-btn" onclick="execCommand('removeFormat')" title="Clear Formatting">✗</button>
`;
// Create editor div
const editor = document.createElement('div');
editor.className = 'wysiwyg-editor';
editor.contentEditable = true;
// Convert textarea content to HTML
let htmlContent = textarea.value;
// If content doesn't contain HTML tags, convert newlines to <br>
if (!/<[^>]+>/.test(htmlContent)) {
htmlContent = htmlContent.replace(/\n/g, '<br>');
}
editor.innerHTML = htmlContent;
editor.dataset.textareaId = textarea.id;
// Hide textarea and insert editor
textarea.style.display = 'none';
container.appendChild(toolbar);
container.appendChild(editor);
// Update container state
container.classList.add('wysiwyg-active');
toggleBtn.textContent = 'Text';
// Sync content back to textarea on input
editor.addEventListener('input', function() {
syncEditorToTextarea(editor, textarea);
});
// Focus the editor
editor.focus();
}
function disableWysiwyg(container, textarea, toggleBtn) {
// Find and remove toolbar and editor
const toolbar = container.querySelector('.wysiwyg-toolbar');
const editor = container.querySelector('.wysiwyg-editor');
if (toolbar) toolbar.remove();
if (editor) {
// Sync final content
syncEditorToTextarea(editor, textarea);
editor.remove();
}
// Show textarea and update state
textarea.style.display = 'block';
container.classList.remove('wysiwyg-active');
toggleBtn.textContent = 'Rich';
// Focus the textarea
textarea.focus();
}
function syncEditorToTextarea(editor, textarea) {
// Keep HTML content but clean it up
let content = editor.innerHTML;
// Clean up browser-generated HTML
// Remove empty paragraphs
content = content.replace(/<p><br><\/p>/gi, '<br>');
content = content.replace(/<p>\s*<\/p>/gi, '');
// Convert div elements to paragraphs (some browsers use divs)
content = content.replace(/<div([^>]*)>/gi, '<p$1>');
content = content.replace(/<\/div>/gi, '</p>');
// Remove unnecessary attributes and clean up spacing
content = content.replace(/\s+/g, ' ');
content = content.replace(/> </g, '><');
// Ensure we don't have nested paragraphs
content = content.replace(/<p[^>]*><p[^>]*>/gi, '<p>');
content = content.replace(/<\/p><\/p>/gi, '</p>');
// Clean up empty elements
content = content.replace(/<([^>]+)>\s*<\/\1>/gi, '');
// Trim whitespace
content = content.trim();
textarea.value = content;
}
function execCommand(command, value = null) {
document.execCommand(command, false, value);
// Update active states
updateToolbarStates();
}
function createLink() {
const url = prompt('Enter URL:');
if (url) {
execCommand('createLink', url);
}
}
function updateToolbarStates() {
// This could be enhanced to show active states for formatting buttons
// For now, it's a placeholder for future improvements
}

View File

@@ -0,0 +1,96 @@
{% load translatable_tags %}
<div class="translatable-language-switcher" data-field="{{ field_name }}">
<div class="current-translation">
{{ current_value }}
</div>
{% if available_languages|length > 1 %}
<div class="language-options">
<button type="button" class="language-toggle" onclick="toggleLanguageOptions('{{ field_name }}')">
<span class="current-lang">{{ current_language|upper }}</span>
<span class="dropdown-arrow"></span>
</button>
<div class="language-dropdown" id="lang-dropdown-{{ field_name }}" style="display: none;">
{% for lang in available_languages %}
<div class="language-option" onclick="switchLanguage('{{ field_name }}', '{{ lang }}')">
<span class="lang-code">{{ lang|upper }}</span>
<span class="lang-preview">{{ translations|get_item:lang|truncatechars:50 }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<script>
function toggleLanguageOptions(fieldName) {
const dropdown = document.getElementById('lang-dropdown-' + fieldName);
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
}
function switchLanguage(fieldName, language) {
// This would typically trigger a page reload with the new language
// or update the display via AJAX
window.location.href = window.location.pathname + '?lang=' + language;
}
</script>
<style>
.translatable-language-switcher {
position: relative;
display: inline-block;
}
.language-toggle {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
margin-left: 8px;
}
.language-toggle:hover {
background: #e9ecef;
}
.language-dropdown {
position: absolute;
top: 100%;
right: 0;
background: white;
border: 1px solid #dee2e6;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
z-index: 1000;
min-width: 200px;
}
.language-option {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid #f8f9fa;
display: flex;
justify-content: space-between;
}
.language-option:hover {
background: #f8f9fa;
}
.language-option:last-child {
border-bottom: none;
}
.lang-code {
font-weight: bold;
color: #495057;
}
.lang-preview {
color: #6c757d;
font-size: 11px;
}
</style>

View File

@@ -0,0 +1,253 @@
# -*- coding: utf-8 -*-
"""
Django Translatable Fields - Template Tags and Filters
This module provides Django template tags and filters for working with
translatable fields in templates. It enables easy display and manipulation
of translations in frontend templates.
Key Features:
- translate filter: Extract current language value from translatable fields
- translate_html filter: HTML-safe translation extraction
- has_translation filter: Check if translation exists for specific language
- available_languages filter: Get list of available language codes
- language_switcher inclusion tag: Render language switching UI
- get_translation simple tag: Get specific translation value
Filters automatically handle:
- JSON parsing from database values
- Language fallback (current -> en_US -> en -> first available)
- Empty value handling
- HTML escaping when needed
Usage in templates:
{% load translatable_tags %}
<!-- Display current language -->
{{ product.name|translate }}
<!-- Display specific language -->
{{ product.name|translate:"de" }}
<!-- Check if translation exists -->
{% if product.name|has_translation:"fr" %}
French available!
{% endif %}
Author: Holger Sielaff <holger@backender.de>
Version: 0.1.0
"""
import json
from django import template
from django.utils.translation import get_language
from django.utils.safestring import mark_safe
register = template.Library()
@register.filter
def translate(value, language=None):
"""
Filter to extract the current language value from a translatable field.
Usage in templates:
{{ product.name|translate }}
{{ product.name|translate:"de" }}
Args:
value: The translatable field value (can be JSON string or dict)
language: Optional specific language code (defaults to current language)
Returns:
The translated value for the specified language, with fallback to en_US, then first available
"""
if not value:
return ''
# Determine target language
target_language = language or get_language() or 'en_US'
# Handle different value types
translations = {}
if isinstance(value, dict):
translations = value
elif isinstance(value, str):
try:
# Try to parse as JSON
parsed = json.loads(value)
if isinstance(parsed, dict):
translations = parsed
else:
# It's a plain string, return as-is
return value
except (json.JSONDecodeError, TypeError):
# It's a plain string, return as-is
return value
else:
# Convert to string and return
return str(value)
# Extract translation with fallback logic
if target_language in translations and translations[target_language]:
return translations[target_language]
# Fallback to en_US
if 'en_US' in translations and translations['en_US']:
return translations['en_US']
# Fallback to en
if 'en' in translations and translations['en']:
return translations['en']
# Return first available translation
if translations:
for lang, text in translations.items():
if text: # Non-empty value
return text
return ''
@register.filter
def translate_html(value, language=None):
"""
Same as translate filter but returns HTML-safe output.
Usage in templates:
{{ product.description|translate_html }}
{{ product.description|translate_html:"de" }}
"""
result = translate(value, language)
return mark_safe(result)
@register.filter
def has_translation(value, language=None):
"""
Check if a translatable field has a translation for the given language.
Usage in templates:
{% if product.name|has_translation %}
{% if product.name|has_translation:"de" %}
Args:
value: The translatable field value
language: Optional specific language code (defaults to current language)
Returns:
True if translation exists and is not empty, False otherwise
"""
if not value:
return False
target_language = language or get_language() or 'en_US'
translations = {}
if isinstance(value, dict):
translations = value
elif isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
translations = parsed
else:
return bool(value.strip())
except (json.JSONDecodeError, TypeError):
return bool(value.strip())
return bool(translations.get(target_language, '').strip())
@register.filter
def available_languages(value):
"""
Get list of available languages for a translatable field.
Usage in templates:
{% for lang in product.name|available_languages %}
{{ lang }}
{% endfor %}
Args:
value: The translatable field value
Returns:
List of language codes that have non-empty translations
"""
if not value:
return []
translations = {}
if isinstance(value, dict):
translations = value
elif isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
translations = parsed
else:
return ['en_US'] if value.strip() else []
except (json.JSONDecodeError, TypeError):
return ['en_US'] if value.strip() else []
return [lang for lang, text in translations.items() if text and text.strip()]
@register.inclusion_tag('django_translatable_fields/language_switcher.html', takes_context=True)
def language_switcher(context, field_value, field_name='field'):
"""
Render a language switcher widget for a translatable field.
Usage in templates:
{% language_switcher product.name "name" %}
Args:
context: Template context
field_value: The translatable field value
field_name: Name for the field (used in HTML IDs)
Returns:
Context for the language switcher template
"""
current_language = get_language() or 'en_US'
available_langs = available_languages(field_value)
translations = {}
if isinstance(field_value, dict):
translations = field_value
elif isinstance(field_value, str):
try:
parsed = json.loads(field_value)
if isinstance(parsed, dict):
translations = parsed
except (json.JSONDecodeError, TypeError):
pass
return {
'field_name': field_name,
'current_language': current_language,
'available_languages': available_langs,
'translations': translations,
'current_value': translate(field_value, current_language)
}
@register.simple_tag
def get_translation(value, language):
"""
Simple tag to get a specific translation.
Usage in templates:
{% get_translation product.name "de" as german_name %}
{{ german_name }}
Args:
value: The translatable field value
language: Language code to extract
Returns:
The translation for the specified language
"""
return translate(value, language)

View File

@@ -0,0 +1,276 @@
# -*- coding: utf-8 -*-
"""
Django Translatable Fields - Custom Widgets
This module provides custom Django form widgets for editing translatable fields
in the admin interface and forms. The widgets create a modal overlay system
that allows users to edit translations for multiple languages.
Key Features:
- Modal overlay for editing multiple language translations
- Automatic widget type detection (TextInput vs Textarea)
- JavaScript integration for dynamic UI
- CSS styling for professional appearance
- Raw database value retrieval for proper translation display
Components:
- TranslatableWidget: Base widget with modal functionality
- TranslatableTextWidget: For CharField-like inputs
- TranslatableTextareaWidget: For TextField-like inputs
The widgets automatically detect existing translations and populate the modal
with current values, allowing seamless editing of multilingual content.
Author: Holger Sielaff <holger@backender.de>
Version: 0.1.0
"""
import json
from django import forms
from django.conf import settings
from django.utils.translation import get_language, gettext_lazy as _
from django.utils.safestring import mark_safe
class TranslatableWidget(forms.Widget):
"""
Base widget for translatable fields with modal overlay functionality.
This widget creates a user interface that displays a primary input field
(usually for the default/current language) along with a translate button
that opens a modal overlay for editing translations in other languages.
Features:
- Primary field for current language (visible)
- Hidden fields for other language translations
- Modal overlay with language-specific inputs
- Automatic widget type detection (TextInput vs Textarea)
- JavaScript integration for modal functionality
- CSS styling for professional appearance
The widget automatically:
- Detects available languages from Django settings
- Retrieves existing translations from database
- Handles form submission for all languages
- Provides fallback values when translations are missing
Args:
original_widget: The Django widget to use for input fields
languages: List of (code, name) language tuples
attrs: Standard Django widget attributes
Example:
# Used automatically by translatable form fields
widget = TranslatableWidget(original_widget=forms.TextInput())
"""
def __init__(self, original_widget=None, languages=None, attrs=None):
# Determine widget class from original widget or default to TextInput
if original_widget:
self.widget_class = original_widget.__class__
self.is_textarea = isinstance(original_widget, forms.Textarea)
else:
self.widget_class = forms.TextInput
self.is_textarea = False
self.languages = languages or getattr(settings, 'LANGUAGES', [('en', 'English')])
# Ensure English is first
self.languages = [lang for lang in self.languages if lang[0] == 'en'] + [lang for lang in self.languages if lang[0] != 'en']
if not any(lang[0] == 'en' for lang in self.languages):
self.languages = [('en', 'English')] + list(self.languages)
self.primary_widget = self.widget_class(attrs=attrs)
self._bound_field = None # Store reference to bound field
super().__init__(attrs)
def format_value(self, value):
"""
Format value for display in the primary input field.
This method extracts the appropriate language value from the translation
dictionary for display in the main input field (usually English).
Args:
value: Translation dictionary, JSON string, or plain value
Returns:
str: Formatted value for primary field display
"""
if not value:
return ''
# Parse JSON strings to dictionaries
if isinstance(value, str):
try:
value = json.loads(value)
except (json.JSONDecodeError, TypeError):
# If not JSON, return as plain string
return value
# Extract English value for primary display
if isinstance(value, dict):
# Prefer English, fallback to first available value
return value.get('en', list(value.values())[0] if value else '')
# For non-dict values, convert to string
return str(value)
def value_from_datadict(self, data, files, name):
# Get primary field value (English)
primary_value = data.get(name, '')
# Get other language values
values = {}
# Always set English from primary field if present
if primary_value:
values['en'] = primary_value
# Get other language values from hidden fields
for lang_code, lang_name in self.languages:
if lang_code != 'en':
field_name = f'{name}_{lang_code}'
lang_value = data.get(field_name, '')
if lang_value: # Only add if not empty
values[lang_code] = lang_value
return values if values else None
def render(self, name, value, attrs=None, renderer=None):
if attrs is None:
attrs = {}
# Debug: Print what we're getting as value
print(f"Widget render - name: {name}, value: {repr(value)}, type: {type(value)}")
# Try to get the full translations from the current context
translations = {}
# Try to parse value first
if isinstance(value, dict):
translations = value
elif isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
translations = parsed
else:
translations = {'en': value}
except (json.JSONDecodeError, TypeError):
translations = {'en': value}
elif value:
translations = {'en': str(value)}
print(f"Widget render - parsed translations from value: {translations}")
# If translations are empty or only have English, try to get from the bound instance
if len(translations) <= 1 and hasattr(self, '_instance_data'):
instance_data = self._instance_data
field_name = instance_data.get('field_name')
instance = instance_data.get('instance')
if instance and field_name and hasattr(instance, 'pk') and instance.pk:
try:
# Get the raw database value for this specific instance
from django.db import connection
cursor = connection.cursor()
table_name = instance._meta.db_table
cursor.execute(f'SELECT {field_name} FROM {table_name} WHERE id = %s', [instance.pk])
row = cursor.fetchone()
if row and row[0]:
try:
db_translations = json.loads(row[0])
if isinstance(db_translations, dict):
print(f"Widget render - got translations for instance {instance.pk}: {db_translations}")
translations = db_translations
except (json.JSONDecodeError, TypeError):
pass
except Exception as e:
print(f"Widget render - instance DB lookup error: {e}")
print(f"Widget render - final translations: {translations}")
# Get English value for primary field
primary_value = translations.get('en', '')
# Create unique ID for this field
field_id = attrs.get('id', name)
output = []
# Primary field (English) with translate button
output.append('<div class="translatable-field-container">')
# Primary input field
primary_attrs = attrs.copy()
primary_attrs['id'] = field_id
primary_attrs['class'] = primary_attrs.get('class', '') + ' translatable-primary-field'
primary_html = self.primary_widget.render(name, primary_value, primary_attrs)
output.append(primary_html)
# Translate button
output.append(f'''
<button type="button" class="translatable-button" onclick="openTranslateModal('{field_id}')" title="Translate">
🌐
</button>
<!-- Hidden fields for other languages -->
''')
# Hidden fields for other languages
for lang_code, lang_name in self.languages:
if lang_code != 'en':
lang_value = translations.get(lang_code, '')
# Debug: Add comment to HTML to see what values we're rendering
output.append(f'<!-- Hidden field for {lang_code}: value="{lang_value}" -->')
lang_attrs = {
'type': 'hidden',
'id': f'{field_id}_{lang_code}',
'name': f'{name}_{lang_code}',
'value': lang_value,
'data-lang': lang_code,
'data-lang-name': lang_name
}
output.append(f'<input {" ".join(f"{k}=\"{v}\"" for k, v in lang_attrs.items())}>')
output.append('</div>')
return mark_safe(''.join(output))
@property
def media(self):
return forms.Media(
css={'all': ['django_translatable_fields/translatable.css']},
js=['django_translatable_fields/translatable.js']
)
class TranslatableTextWidget(TranslatableWidget):
"""
Widget for translatable text inputs.
"""
def __init__(self, languages=None, attrs=None):
# Create a TextInput widget as the original widget
original_widget = forms.TextInput(attrs=attrs)
super().__init__(original_widget=original_widget, languages=languages, attrs=attrs)
class TranslatableTextareaWidget(TranslatableWidget):
"""
Widget for translatable textarea inputs.
"""
def __init__(self, languages=None, attrs=None):
# Create a Textarea widget as the original widget
original_widget = forms.Textarea(attrs=attrs)
super().__init__(original_widget=original_widget, languages=languages, attrs=attrs)