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

357 lines
14 KiB
Python

# -*- 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/?lang=de -> Returns German translations as strings
# GET /api/products/ -> Returns all translations as dicts
# POST /api/products/ with {"name": "Test", "lang": "de"} -> Saves to German
Author: Holger Sielaff <holger@backender.de>
Version: 0.1.0
"""
import json
import logging
from rest_framework import serializers
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:
"""
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 (?lang=de or ?language=de for backward compatibility)
- 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/?lang=de -> returns German values as strings
# GET /api/items/ -> returns full translation dicts
# POST /api/items/ with {"name": "Test", "lang": "de"} -> 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 = []
# 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
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)
# 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):
"""Get the language parameter from the request context"""
if hasattr(self, 'context') and 'request' in self.context:
request = self.context['request']
# Check for lang parameter in query params or data
# Also support 'language' for backward compatibility
lang = request.query_params.get('lang') or request.query_params.get('language')
if not lang and hasattr(request, 'data'):
lang = request.data.get('lang') or request.data.get('language')
return lang
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 update(self, instance, validated_data):
"""
Override update to merge translatable fields for PATCH requests.
For PATCH: merge new translations with existing ones
For PUT: replace completely (default behavior)
"""
logger = logging.getLogger(__name__)
request_method = None
if hasattr(self, 'context') and 'request' in self.context:
request_method = self.context['request'].method
logger.info(f"UPDATE METHOD CALLED - Version 2.0 - Method: {request_method}")
# For PATCH: merge translatable fields
if request_method == 'PATCH':
for field_name in self._translatable_fields:
if field_name in validated_data:
new_value = validated_data[field_name]
# Get existing value (model field returns dict in API context)
existing_value = getattr(instance, field_name, None)
# Parse existing value
existing_translations = {}
if existing_value:
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):
"""
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"""
# 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:
return '' if request_language else {}
# Value is already a dict in API context (from model field's from_db_value)
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 request_language:
if request_language in translations:
return translations[request_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.
- 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):
return data
else:
return {language: str(data)}
else:
# No language: expect full translation dict or assume English
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