276 lines
10 KiB
Python
276 lines
10 KiB
Python
|
|
# -*- 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)
|