This commit is contained in:
Holger Sielaff
2026-02-04 16:49:47 +01:00
2 changed files with 159 additions and 66 deletions
+6 -5
View File
@@ -74,11 +74,12 @@ class TranslatableMixin:
if not self.translatable: if not self.translatable:
return value return value
# Check if we're in admin context by looking at the call stack # Check if we're in admin or API context by looking at the call stack
frames = inspect.getouterframes(inspect.currentframe()) frames = inspect.getouterframes(inspect.currentframe())
is_admin_context = os.environ.get('DJANGO_ADMIN_OVERRIDE', False) == '1' or any('admin' in frame.filename or 'forms' in frame.filename for frame in frames[:5]) is_admin_context = os.environ.get('DJANGO_ADMIN_OVERRIDE', False) == '1' or any('admin' in frame.filename or 'forms' in frame.filename for frame in frames[:5])
is_api_context = any('serializers' in frame.filename or 'rest_framework' in frame.filename or '/api/' in frame.filename for frame in frames[:10])
logging.debug(f"{self.__class__.__name__} from_db_value - admin context: {is_admin_context}") logging.debug(f"{self.__class__.__name__} from_db_value - admin context: {is_admin_context}, api context: {is_api_context}")
try: try:
translations = json.loads(value) if isinstance(value, str) else value translations = json.loads(value) if isinstance(value, str) else value
@@ -87,10 +88,10 @@ class TranslatableMixin:
logging.debug(f"{self.__class__.__name__} from_db_value - parsed translations: {translations}") 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 # For admin/forms/API, return the full dict so serializers/widgets can access all translations
if is_admin_context: if is_admin_context or is_api_context:
logging.debug( logging.debug(
f"{self.__class__.__name__} from_db_value - returning full dict for admin: {translations}") f"{self.__class__.__name__} from_db_value - returning full dict for admin/api: {translations}")
return translations return translations
# For regular use, return the appropriate language # For regular use, return the appropriate language
+153 -61
View File
@@ -20,18 +20,31 @@ Usage:
fields = ['id', 'name', 'description', 'price'] fields = ['id', 'name', 'description', 'price']
# API Usage: # API Usage:
# GET /api/products/?language=de -> Returns German translations # GET /api/products/?lang=de -> Returns German translations as strings
# GET /api/products/ -> Returns all translations as dicts # GET /api/products/ -> Returns all translations as dicts
# POST /api/products/ with {"name": "Test", "language": "de"} -> Saves to German # POST /api/products/ with {"name": "Test", "lang": "de"} -> Saves to German
Author: Holger Sielaff <holger@backender.de> Author: Holger Sielaff <holger@backender.de>
Version: 0.1.0 Version: 0.1.0
""" """
import json import json
import logging
from rest_framework import serializers from rest_framework import serializers
from django.utils.translation import get_language from django.utils.translation import get_language
# Optional drf-spectacular support for OpenAPI schema generation
try:
from drf_spectacular.utils import extend_schema_field
HAS_SPECTACULAR = True
except ImportError:
HAS_SPECTACULAR = False
# Dummy decorator if drf-spectacular is not installed
def extend_schema_field(schema):
def decorator(cls):
return cls
return decorator
class TranslatableSerializerMixin: class TranslatableSerializerMixin:
""" """
@@ -42,48 +55,59 @@ class TranslatableSerializerMixin:
writing translatable content via API endpoints. writing translatable content via API endpoints.
Features: Features:
- Language parameter detection (?language=de) - Language parameter detection (?lang=de or ?language=de for backward compatibility)
- Automatic field conversion for translatable fields - Automatic field conversion for translatable fields
- Support for both single-language and multi-language responses - Support for both single-language and multi-language responses
- Proper handling of JSON storage format - Proper handling of JSON storage format
Usage: Usage:
class MySerializer(TranslatableSerializerMixin, serializers.ModelSerializer): class MySerializer(TranslatableSerializerMixin, serializers.ModelSerializer):
class Meta: class Meta:
model = MyModel model = MyModel
fields = ['name', 'description'] # name and description are translatable fields = ['name', 'description'] # name and description are translatable
# API calls: # API calls:
# GET /api/items/?language=de -> returns German values # GET /api/items/?lang=de -> returns German values as strings
# GET /api/items/ -> returns full translation dicts # GET /api/items/ -> returns full translation dicts
# POST /api/items/ with language param -> saves to specific language # POST /api/items/ with {"name": "Test", "lang": "de"} -> saves to specific language
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._setup_translatable_fields() self._setup_translatable_fields()
def _setup_translatable_fields(self): def _setup_translatable_fields(self):
"""Identify and setup translatable fields in the serializer""" """Identify and setup translatable fields in the serializer"""
self._translatable_fields = [] self._translatable_fields = []
if hasattr(self, 'Meta') and hasattr(self.Meta, 'model'): # Method 1: Check Meta.translatable_fields (explicit definition)
if hasattr(self, 'Meta') and hasattr(self.Meta, 'translatable_fields'):
self._translatable_fields = list(self.Meta.translatable_fields)
# Method 2: Auto-detect from model fields with translatable=True
elif hasattr(self, 'Meta') and hasattr(self.Meta, 'model'):
model = self.Meta.model model = self.Meta.model
for field_name in self.fields: for field_name in self.fields:
if hasattr(model, field_name): if hasattr(model, field_name):
model_field = model._meta.get_field(field_name) model_field = model._meta.get_field(field_name)
if hasattr(model_field, 'translatable') and model_field.translatable: if hasattr(model_field, 'translatable') and model_field.translatable:
self._translatable_fields.append(field_name) self._translatable_fields.append(field_name)
# Replace translatable fields with TranslatableField for proper schema generation
for field_name in self._translatable_fields:
if field_name in self.fields:
self.fields[field_name] = TranslatableField()
def _get_request_language(self): def _get_request_language(self):
"""Get the language parameter from the request context""" """Get the language parameter from the request context"""
if hasattr(self, 'context') and 'request' in self.context: if hasattr(self, 'context') and 'request' in self.context:
request = self.context['request'] request = self.context['request']
# Check for language parameter in query params or data # Check for lang parameter in query params or data
language = request.query_params.get('language') # Also support 'language' for backward compatibility
if not language and hasattr(request, 'data'): lang = request.query_params.get('lang') or request.query_params.get('language')
language = request.data.get('language') if not lang and hasattr(request, 'data'):
return language lang = request.data.get('lang') or request.data.get('language')
return lang
return None return None
def to_representation(self, instance): def to_representation(self, instance):
@@ -138,53 +162,93 @@ class TranslatableSerializerMixin:
data[field_name] = field_value data[field_name] = field_value
return data return data
def to_internal_value(self, data): def update(self, instance, validated_data):
""" """
Convert API input data to internal representation. Override update to merge translatable fields for PATCH requests.
Logic: For PATCH: merge new translations with existing ones
- With language parameter: converts single values to {language: value} For PUT: replace completely (default behavior)
- 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() logger = logging.getLogger(__name__)
# Process translatable fields in input data request_method = None
for field_name in self._translatable_fields: if hasattr(self, 'context') and 'request' in self.context:
if field_name in data: request_method = self.context['request'].method
field_value = data[field_name]
logger.info(f"UPDATE METHOD CALLED - Version 2.0 - Method: {request_method}")
if request_language:
# Convert single value to translation dict for specific language # For PATCH: merge translatable fields
if not isinstance(field_value, dict): if request_method == 'PATCH':
data[field_name] = {request_language: str(field_value)} for field_name in self._translatable_fields:
# If it's already a dict, use it as-is if field_name in validated_data:
else: new_value = validated_data[field_name]
# No language specified:
# - If dict: use as-is (full translations) # Get existing value (model field returns dict in API context)
# - If not dict: assume English existing_value = getattr(instance, field_name, None)
if not isinstance(field_value, dict):
data[field_name] = {'en': str(field_value)} # Parse existing value
# If it's already a dict, leave it unchanged existing_translations = {}
if existing_value:
return super().to_internal_value(data) if isinstance(existing_value, str):
try:
existing_translations = json.loads(existing_value)
if not isinstance(existing_translations, dict):
existing_translations = {}
except (json.JSONDecodeError, TypeError):
existing_translations = {}
elif isinstance(existing_value, dict):
existing_translations = existing_value
# Parse new value
new_translations = {}
if new_value:
if isinstance(new_value, str):
try:
new_translations = json.loads(new_value)
if not isinstance(new_translations, dict):
new_translations = {}
except (json.JSONDecodeError, TypeError):
new_translations = {}
elif isinstance(new_value, dict):
new_translations = new_value
# Merge: existing + new (new overwrites)
if existing_translations or new_translations:
merged = existing_translations.copy()
merged.update(new_translations)
validated_data[field_name] = merged
return super().update(instance, validated_data)
@extend_schema_field({
'type': 'object',
'properties': {
'en': {'type': 'string', 'description': 'English translation'},
'de': {'type': 'string', 'description': 'German translation'},
'fr': {'type': 'string', 'description': 'French translation'},
},
'example': {
'en': 'Example text',
'de': 'Beispieltext',
'fr': 'Texte d\'exemple'
},
'description': 'Translatable field: accepts string (sets default language), dict with language codes, or JSON string'
})
class TranslatableField(serializers.Field): class TranslatableField(serializers.Field):
""" """
Custom DRF field for handling translatable content. Custom DRF field for handling translatable content.
This field can be used when you need more control over how translatable 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 fields are handled in your serializer, or when you want to use it
independently of the TranslatableSerializerMixin. independently of the TranslatableSerializerMixin.
Usage: Usage:
class ProductSerializer(serializers.ModelSerializer): class ProductSerializer(serializers.ModelSerializer):
name = TranslatableField() name = TranslatableField()
description = TranslatableField() description = TranslatableField()
class Meta: class Meta:
model = Product model = Product
fields = ['id', 'name', 'description', 'price'] fields = ['id', 'name', 'description', 'price']
@@ -196,10 +260,17 @@ class TranslatableField(serializers.Field):
def to_representation(self, value): def to_representation(self, value):
"""Convert database value to API representation""" """Convert database value to API representation"""
# Get lang parameter from request if not set explicitly on field
request_language = self.language
if not request_language and hasattr(self, 'parent') and self.parent:
if hasattr(self.parent, 'context') and 'request' in self.parent.context:
request = self.parent.context['request']
request_language = request.query_params.get('lang') or request.query_params.get('language')
if not value: if not value:
return '' if self.language else {} return '' if request_language else {}
# Handle JSON string from database # Value is already a dict in API context (from model field's from_db_value)
if isinstance(value, str): if isinstance(value, str):
try: try:
translations = json.loads(value) translations = json.loads(value)
@@ -209,11 +280,11 @@ class TranslatableField(serializers.Field):
translations = value translations = value
else: else:
translations = {'en': str(value)} translations = {'en': str(value)}
# Return specific language or full dict # Return specific language or full dict
if self.language: if request_language:
if self.language in translations: if request_language in translations:
return translations[self.language] return translations[request_language]
elif 'en' in translations: elif 'en' in translations:
return translations['en'] return translations['en']
elif translations: elif translations:
@@ -224,15 +295,36 @@ class TranslatableField(serializers.Field):
return translations return translations
def to_internal_value(self, data): def to_internal_value(self, data):
"""Convert API input to internal representation""" """
if self.language: Convert API input to internal representation.
# Convert single value to translation dict
- Handles lang parameter for single-value inputs
- Converts to dict format
- Merging for PATCH is handled by TranslatableSerializerMixin.update()
"""
# Get request context from parent serializer
request_language = None
if hasattr(self, 'parent') and self.parent:
# Get lang parameter from request
if hasattr(self.parent, 'context') and 'request' in self.parent.context:
request = self.parent.context['request']
request_language = request.query_params.get('lang') or request.query_params.get('language')
if not request_language and hasattr(request, 'data'):
request_language = request.data.get('lang') or request.data.get('language')
# Use self.language if set explicitly on the field
language = self.language or request_language
# Convert input to translation dict
if language:
# Language specified: convert single value to translation dict
if isinstance(data, dict): if isinstance(data, dict):
return data return data
else: else:
return {self.language: str(data)} return {language: str(data)}
else: else:
# Expect full translation dict # No language: expect full translation dict or assume English
if isinstance(data, dict): if isinstance(data, dict):
return data return data
else: else:
@@ -261,4 +353,4 @@ class TranslatableURLField(TranslatableField, serializers.URLField):
class TranslatableSlugField(TranslatableField, serializers.SlugField): class TranslatableSlugField(TranslatableField, serializers.SlugField):
"""Translatable SlugField for DRF serializers""" """Translatable SlugField for DRF serializers"""
pass pass