Initial
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.swp
|
||||
.idea
|
||||
.venv
|
||||
venv
|
||||
!CHANGELOG.md
|
||||
!README.md
|
||||
/*.md
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Holger Sielaff
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
33
MANIFEST.in
Normal file
33
MANIFEST.in
Normal file
@@ -0,0 +1,33 @@
|
||||
# Include the license file
|
||||
include LICENSE
|
||||
|
||||
# Include the README
|
||||
include README.md
|
||||
|
||||
# Include the changelog
|
||||
include CHANGELOG.md
|
||||
|
||||
# Include package data
|
||||
recursive-include django_translatable_fields/static *
|
||||
recursive-include django_translatable_fields/templates *
|
||||
recursive-include django_translatable_fields/locale *.po *.mo
|
||||
|
||||
# Include management commands
|
||||
recursive-include django_translatable_fields/management *
|
||||
|
||||
# Exclude compiled Python files
|
||||
global-exclude *.pyc
|
||||
global-exclude *.pyo
|
||||
global-exclude __pycache__
|
||||
global-exclude .DS_Store
|
||||
global-exclude *.so
|
||||
|
||||
# Exclude development files
|
||||
exclude .gitignore
|
||||
exclude .pre-commit-config.yaml
|
||||
exclude tox.ini
|
||||
exclude .coverage
|
||||
prune tests/
|
||||
prune .git/
|
||||
prune .github/
|
||||
prune debian/
|
||||
51
debian/README.Debian
vendored
Normal file
51
debian/README.Debian
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
django-translatable-fields for Debian
|
||||
======================================
|
||||
|
||||
This package provides Django Translatable Fields, a Django plugin that mimics
|
||||
Odoo's translate=True functionality with a modern admin interface.
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
The package is installed as python3-django-translatable-fields and provides
|
||||
the django_translatable_fields Python module.
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
Add 'django_translatable_fields' to your INSTALLED_APPS in Django settings:
|
||||
|
||||
INSTALLED_APPS = [
|
||||
# ... other apps
|
||||
'django_translatable_fields',
|
||||
]
|
||||
|
||||
Configure your supported languages:
|
||||
|
||||
LANGUAGES = [
|
||||
('en', 'English'),
|
||||
('de', 'German'),
|
||||
('fr', 'French'),
|
||||
('es', 'Spanish'),
|
||||
]
|
||||
|
||||
Example usage in models:
|
||||
|
||||
from django.db import models
|
||||
from django_translatable_fields.fields import CharField, TextField
|
||||
from django_translatable_fields.descriptors import TranslatableModelMixin
|
||||
|
||||
class Product(TranslatableModelMixin, models.Model):
|
||||
name = CharField(max_length=200, translatable=True)
|
||||
description = TextField(translatable=True)
|
||||
|
||||
Documentation
|
||||
-------------
|
||||
|
||||
See /usr/share/doc/python3-django-translatable-fields/ for complete
|
||||
documentation and examples.
|
||||
|
||||
The example directory contains comprehensive usage examples demonstrating
|
||||
all available field types and features.
|
||||
|
||||
-- Holger Sielaff <holger@backender.de> Fri, 02 Aug 2024 12:00:00 +0200
|
||||
13
debian/changelog
vendored
Normal file
13
debian/changelog
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
python-django-translatable-fields (0.1.0-1) unstable; urgency=medium
|
||||
|
||||
* Initial release.
|
||||
* Django plugin that mimics Odoo's translate=True functionality
|
||||
* Language-aware fields with JSON storage
|
||||
* Rich admin interface with modal overlays
|
||||
* WYSIWYG editor for TextField translations
|
||||
* Draggable and resizable modal windows
|
||||
* Django REST Framework support
|
||||
* Field validation for Email, URL, and Slug fields per language
|
||||
* Support for CharField, TextField, EmailField, URLField, SlugField
|
||||
|
||||
-- Holger Sielaff <holger@backender.de> Fri, 02 Aug 2024 12:00:00 +0200
|
||||
1
debian/compat
vendored
Normal file
1
debian/compat
vendored
Normal file
@@ -0,0 +1 @@
|
||||
13
|
||||
45
debian/control
vendored
Normal file
45
debian/control
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
Source: python-django-translatable-fields
|
||||
Section: python
|
||||
Priority: optional
|
||||
Maintainer: Holger Sielaff <holger@backender.de>
|
||||
Build-Depends: debhelper-compat (= 13),
|
||||
dh-python,
|
||||
python3-all,
|
||||
python3-setuptools,
|
||||
pybuild-plugin-pyproject,
|
||||
python3-django (>= 4.2)
|
||||
Standards-Version: 4.6.0
|
||||
Homepage: https://repo.backender.de/holger/Django-Translatable-Fields
|
||||
Vcs-Git: https://repo.backender.de/holger/Django-Translatable-Fields.git
|
||||
Vcs-Browser: https://repo.backender.de/holger/Django-Translatable-Fields
|
||||
Rules-Requires-Root: no
|
||||
|
||||
Package: python3-django-translatable-fields
|
||||
Architecture: all
|
||||
Depends: ${python3:Depends},
|
||||
${misc:Depends},
|
||||
python3-django (>= 4.2)
|
||||
Recommends: python3-djangorestframework (>= 3.14.0)
|
||||
Description: Django plugin that mimics Odoo's translate=True functionality
|
||||
Django Translatable Fields provides automatic language-aware field handling
|
||||
with a beautiful admin interface integration, similar to Odoo's translate=True
|
||||
functionality.
|
||||
.
|
||||
Key features:
|
||||
* Language-Aware Fields: Automatic translation handling based on Django's
|
||||
current language
|
||||
* Rich Admin Interface: Modal overlay with WYSIWYG editor for easy
|
||||
translation management
|
||||
* JSON Storage: Efficient storage of translations in a single database column
|
||||
* Easy Integration: Drop-in replacement for Django's standard fields
|
||||
* Draggable & Resizable: Modern modal interface with drag and resize
|
||||
capabilities
|
||||
* DRF Support: Built-in Django REST Framework serializers for API development
|
||||
* Field Validation: Proper validation for Email, URL, and Slug fields per
|
||||
language
|
||||
* Type Safety: Full support for Django's field types with translation
|
||||
capabilities
|
||||
.
|
||||
The plugin supports CharField, TextField, EmailField, URLField, and SlugField
|
||||
with automatic JSON-based translation storage and language-aware access
|
||||
patterns.
|
||||
27
debian/copyright
vendored
Normal file
27
debian/copyright
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: django-translatable-fields
|
||||
Upstream-Contact: Holger Sielaff <holger@backender.de>
|
||||
Source: https://repo.backender.de/holger/Django-Translatable-Fields
|
||||
|
||||
Files: *
|
||||
Copyright: 2024 Holger Sielaff <holger@backender.de>
|
||||
License: MIT
|
||||
|
||||
License: MIT
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
2
debian/python3-django-translatable-fields.docs
vendored
Normal file
2
debian/python3-django-translatable-fields.docs
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
1
debian/python3-django-translatable-fields.examples
vendored
Normal file
1
debian/python3-django-translatable-fields.examples
vendored
Normal file
@@ -0,0 +1 @@
|
||||
example/*
|
||||
2
debian/python3-django-translatable-fields.install
vendored
Normal file
2
debian/python3-django-translatable-fields.install
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Install package files
|
||||
django_translatable_fields usr/lib/python3/dist-packages/
|
||||
15
debian/rules
vendored
Executable file
15
debian/rules
vendored
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/make -f
|
||||
|
||||
export PYBUILD_NAME=django-translatable-fields
|
||||
export PYBUILD_DISABLE=test
|
||||
|
||||
%:
|
||||
dh $@ --with python3 --buildsystem=pybuild
|
||||
|
||||
override_dh_auto_clean:
|
||||
dh_auto_clean
|
||||
rm -rf build/
|
||||
rm -rf *.egg-info/
|
||||
|
||||
override_dh_auto_test:
|
||||
# Skip tests for now - can be enabled later if test suite is added
|
||||
1
debian/source/format
vendored
Normal file
1
debian/source/format
vendored
Normal file
@@ -0,0 +1 @@
|
||||
3.0 (quilt)
|
||||
4
debian/watch
vendored
Normal file
4
debian/watch
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
version=4
|
||||
opts="searchmode=plain" \
|
||||
https://repo.backender.de/holger/Django-Translatable-Fields/tags \
|
||||
https://repo.backender.de/holger/Django-Translatable-Fields/archive/v@ANY_VERSION@@ARCHIVE_EXT@
|
||||
261
django_translatable_fields.egg-info/PKG-INFO
Normal file
261
django_translatable_fields.egg-info/PKG-INFO
Normal file
@@ -0,0 +1,261 @@
|
||||
Metadata-Version: 2.1
|
||||
Name: django-translatable-fields
|
||||
Version: 0.1.0
|
||||
Summary: Django plugin that mimics Odoo's translate=True functionality with admin interface integration
|
||||
Author-email: Holger Sielaff <holger@backender.de>
|
||||
Maintainer-email: Holger Sielaff <holger@backender.de>
|
||||
License: MIT License
|
||||
|
||||
Copyright (c) 2024 Holger Sielaff
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Project-URL: Homepage, https://repo.backender.de/holger/Django-Translatable-Fields
|
||||
Project-URL: Documentation, https://repo.backender.de/holger/Django-Translatable-Fields#readme
|
||||
Project-URL: Repository, https://repo.backender.de/holger/Django-Translatable-Fields.git
|
||||
Project-URL: Bug Tracker, https://repo.backender.de/holger/Django-Translatable-Fields/issues
|
||||
Project-URL: Changelog, https://repo.backender.de/holger/Django-Translatable-Fields/blob/main/CHANGELOG.md
|
||||
Keywords: django,translation,internationalization,i18n,multilingual,translatable,fields,admin
|
||||
Classifier: Development Status :: 4 - Beta
|
||||
Classifier: Environment :: Web Environment
|
||||
Classifier: Framework :: Django
|
||||
Classifier: Framework :: Django :: 4.2
|
||||
Classifier: Framework :: Django :: 5.0
|
||||
Classifier: Framework :: Django :: 5.1
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: License :: OSI Approved :: MIT License
|
||||
Classifier: Operating System :: OS Independent
|
||||
Classifier: Programming Language :: Python
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Programming Language :: Python :: 3.9
|
||||
Classifier: Programming Language :: Python :: 3.10
|
||||
Classifier: Programming Language :: Python :: 3.11
|
||||
Classifier: Programming Language :: Python :: 3.12
|
||||
Classifier: Topic :: Internet :: WWW/HTTP
|
||||
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
||||
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
||||
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
||||
Requires-Python: >=3.9
|
||||
Description-Content-Type: text/markdown
|
||||
Provides-Extra: dev
|
||||
Provides-Extra: drf
|
||||
License-File: LICENSE
|
||||
|
||||
# Django Translatable Fields
|
||||
|
||||
[](https://badge.fury.io/py/django-translatable-fields)
|
||||
[](https://pypi.org/project/django-translatable-fields/)
|
||||
[](https://pypi.org/project/django-translatable-fields/)
|
||||
[](LICENSE)
|
||||
|
||||
A Django plugin that mimics Odoo's `translate=True` functionality, providing automatic language-aware field handling with a beautiful admin interface integration.
|
||||
|
||||
## Features
|
||||
|
||||
- 🌍 **Language-Aware Fields**: Automatic translation handling based on Django's current language
|
||||
- 📝 **Rich Admin Interface**: Modal overlay with WYSIWYG editor for easy translation management
|
||||
- 🗄️ **JSON Storage**: Efficient storage of translations in a single database column
|
||||
- 🔧 **Easy Integration**: Drop-in replacement for Django's standard fields
|
||||
- 🎨 **Draggable & Resizable**: Modern modal interface with drag and resize capabilities
|
||||
- 🚀 **DRF Support**: Built-in Django REST Framework serializers for API development
|
||||
- ✅ **Field Validation**: Proper validation for Email, URL, and Slug fields per language
|
||||
- 🎯 **Type Safety**: Full support for Django's field types with translation capabilities
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Installation
|
||||
|
||||
```bash
|
||||
pip install django-translatable-fields
|
||||
```
|
||||
|
||||
Add to your `INSTALLED_APPS`:
|
||||
|
||||
```python
|
||||
INSTALLED_APPS = [
|
||||
# ... other apps
|
||||
'django_translatable_fields',
|
||||
]
|
||||
```
|
||||
|
||||
### 2. Define Your Model
|
||||
|
||||
```python
|
||||
from django.db import models
|
||||
from django_translatable_fields.fields import CharField, TextField, EmailField, URLField, SlugField
|
||||
from django_translatable_fields.descriptors import TranslatableModelMixin
|
||||
|
||||
class Product(TranslatableModelMixin, models.Model):
|
||||
name = CharField(max_length=200, translatable=True)
|
||||
description = TextField(translatable=True)
|
||||
slug = SlugField(max_length=200, translatable=True)
|
||||
support_email = EmailField(translatable=True)
|
||||
website = URLField(translatable=True)
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
```
|
||||
|
||||
### 3. Configure Admin
|
||||
|
||||
```python
|
||||
from django.contrib import admin
|
||||
from django_translatable_fields.admin import TranslatableModelAdmin
|
||||
from .models import Product
|
||||
|
||||
@admin.register(Product)
|
||||
class ProductAdmin(TranslatableModelAdmin):
|
||||
list_display = ['name', 'slug', 'price']
|
||||
fields = ['name', 'description', 'slug', 'support_email', 'website', 'price']
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
```
|
||||
|
||||
### 4. Usage
|
||||
|
||||
```python
|
||||
from django.utils.translation import activate
|
||||
|
||||
# Create a product
|
||||
product = Product.objects.create(
|
||||
name="Awesome Product",
|
||||
description="This is an amazing product",
|
||||
slug="awesome-product",
|
||||
support_email="support@example.com",
|
||||
website="https://example.com",
|
||||
price=29.99
|
||||
)
|
||||
|
||||
# Set translations for all fields
|
||||
product.name_en = "Awesome Product"
|
||||
product.name_de = "Fantastisches Produkt"
|
||||
product.name_fr = "Produit Fantastique"
|
||||
|
||||
product.description_en = "This is an amazing product"
|
||||
product.description_de = "Das ist ein fantastisches Produkt"
|
||||
product.description_fr = "C'est un produit fantastique"
|
||||
|
||||
product.slug_en = "awesome-product"
|
||||
product.slug_de = "fantastisches-produkt"
|
||||
product.slug_fr = "produit-fantastique"
|
||||
|
||||
product.support_email_en = "support@example.com"
|
||||
product.support_email_de = "support@beispiel.de"
|
||||
product.support_email_fr = "support@exemple.fr"
|
||||
|
||||
product.website_en = "https://example.com"
|
||||
product.website_de = "https://beispiel.de"
|
||||
product.website_fr = "https://exemple.fr"
|
||||
|
||||
product.save()
|
||||
|
||||
# Language-aware access
|
||||
activate('en')
|
||||
print(product.name) # "Awesome Product"
|
||||
print(product.slug) # "awesome-product"
|
||||
print(product.website) # "https://example.com"
|
||||
|
||||
activate('de')
|
||||
print(product.name) # "Fantastisches Produkt"
|
||||
print(product.slug) # "fantastisches-produkt"
|
||||
print(product.website) # "https://beispiel.de"
|
||||
|
||||
activate('fr')
|
||||
print(product.name) # "Produit Fantastique"
|
||||
print(product.slug) # "produit-fantastique"
|
||||
print(product.website) # "https://exemple.fr"
|
||||
|
||||
# Get all translations for any field
|
||||
name_translations = product.get_all_translations('name')
|
||||
print(name_translations) # {'en': 'Awesome Product', 'de': 'Fantastisches Produkt', 'fr': 'Produit Fantastique'}
|
||||
|
||||
url_translations = product.get_all_translations('website')
|
||||
print(url_translations) # {'en': 'https://example.com', 'de': 'https://beispiel.de', 'fr': 'https://exemple.fr'}
|
||||
```
|
||||
|
||||
## Admin Interface
|
||||
|
||||
The plugin provides an Odoo-style admin interface:
|
||||
|
||||
- **Primary Field**: Shows the default language (English) input field
|
||||
- **Translate Button**: Click the translate icon (🌐) next to any translatable field
|
||||
- **Modal Overlay**: Opens a modal with input fields for all configured languages
|
||||
- **Clean Interface**: Only the primary language is visible initially, keeping the interface clean
|
||||
|
||||
Just like in Odoo, the translate functionality is accessed through a button next to each translatable field, opening an overlay with all language versions.
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure your languages in Django settings:
|
||||
|
||||
```python
|
||||
LANGUAGES = [
|
||||
('en', 'English'),
|
||||
('de', 'German'),
|
||||
('fr', 'French'),
|
||||
('es', 'Spanish'),
|
||||
]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Fields
|
||||
|
||||
- `CharField`: Like Django's `CharField` but with `translatable=True` parameter
|
||||
- `TextField`: Like Django's `TextField` but with `translatable=True` parameter
|
||||
- `EmailField`: Like Django's `EmailField` but with `translatable=True` parameter
|
||||
- `URLField`: Like Django's `URLField` but with `translatable=True` parameter
|
||||
- `SlugField`: Like Django's `SlugField` but with `translatable=True` parameter
|
||||
|
||||
Both fields accept a `translatable=True` parameter (default: True).
|
||||
|
||||
### Model Methods
|
||||
|
||||
When using `TranslatableModelMixin`:
|
||||
|
||||
- `obj.field_name`: Returns value in current language
|
||||
- `obj.field_name_en`: Returns English translation
|
||||
- `obj.get_translation(field_name, language_code)`: Get specific translation
|
||||
- `obj.set_translation(field_name, language_code, value)`: Set specific translation
|
||||
- `obj.get_all_translations(field_name)`: Get all translations as dict
|
||||
|
||||
### Admin Classes
|
||||
|
||||
- `TranslatableModelAdmin`: Use instead of `ModelAdmin` for automatic widget handling
|
||||
- `TranslatableAdminMixin`: Mixin for custom admin classes
|
||||
|
||||
## Storage Format
|
||||
|
||||
Translations are stored as JSON in the database:
|
||||
|
||||
```json
|
||||
{
|
||||
"en": "Hello World",
|
||||
"de": "Hallo Welt",
|
||||
"fr": "Bonjour le monde"
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Django >= 3.2
|
||||
- Python >= 3.8
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
30
django_translatable_fields.egg-info/SOURCES.txt
Normal file
30
django_translatable_fields.egg-info/SOURCES.txt
Normal file
@@ -0,0 +1,30 @@
|
||||
CHANGELOG.md
|
||||
LICENSE
|
||||
MANIFEST.in
|
||||
README.md
|
||||
pyproject.toml
|
||||
setup.py
|
||||
django_translatable_fields/__init__.py
|
||||
django_translatable_fields/admin.py
|
||||
django_translatable_fields/apps.py
|
||||
django_translatable_fields/context.py
|
||||
django_translatable_fields/descriptors.py
|
||||
django_translatable_fields/fields.py
|
||||
django_translatable_fields/forms.py
|
||||
django_translatable_fields/managers.py
|
||||
django_translatable_fields/operations.py
|
||||
django_translatable_fields/search.py
|
||||
django_translatable_fields/serializers.py
|
||||
django_translatable_fields/setup.py
|
||||
django_translatable_fields/widgets.py
|
||||
django_translatable_fields.egg-info/PKG-INFO
|
||||
django_translatable_fields.egg-info/SOURCES.txt
|
||||
django_translatable_fields.egg-info/dependency_links.txt
|
||||
django_translatable_fields.egg-info/requires.txt
|
||||
django_translatable_fields.egg-info/top_level.txt
|
||||
django_translatable_fields/management/__init__.py
|
||||
django_translatable_fields/management/commands/__init__.py
|
||||
django_translatable_fields/management/commands/makemigrations_translatable.py
|
||||
django_translatable_fields/static/django_translatable_fields/translatable.css
|
||||
django_translatable_fields/static/django_translatable_fields/translatable.js
|
||||
django_translatable_fields/templates/django_translatable_fields/language_switcher.html
|
||||
1
django_translatable_fields.egg-info/dependency_links.txt
Normal file
1
django_translatable_fields.egg-info/dependency_links.txt
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
13
django_translatable_fields.egg-info/requires.txt
Normal file
13
django_translatable_fields.egg-info/requires.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
Django>=4.2
|
||||
|
||||
[dev]
|
||||
black
|
||||
django-stubs
|
||||
flake8
|
||||
isort
|
||||
mypy
|
||||
pytest
|
||||
pytest-django
|
||||
|
||||
[drf]
|
||||
djangorestframework>=3.14.0
|
||||
1
django_translatable_fields.egg-info/top_level.txt
Normal file
1
django_translatable_fields.egg-info/top_level.txt
Normal file
@@ -0,0 +1 @@
|
||||
django_translatable_fields
|
||||
48
django_translatable_fields/__init__.py
Normal file
48
django_translatable_fields/__init__.py
Normal 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'
|
||||
])
|
||||
26
django_translatable_fields/admin.py
Normal file
26
django_translatable_fields/admin.py
Normal 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
|
||||
89
django_translatable_fields/apps.py
Normal file
89
django_translatable_fields/apps.py
Normal 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."
|
||||
)
|
||||
178
django_translatable_fields/context.py
Normal file
178
django_translatable_fields/context.py
Normal 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
|
||||
190
django_translatable_fields/descriptors.py
Normal file
190
django_translatable_fields/descriptors.py
Normal 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)
|
||||
243
django_translatable_fields/fields.py
Normal file
243
django_translatable_fields/fields.py
Normal file
@@ -0,0 +1,243 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Django Translatable Fields - Core Field Definitions
|
||||
|
||||
This module provides the core functionality for creating translatable database fields
|
||||
that store translations as JSON and provide language-aware access similar to Odoo's
|
||||
translate=True functionality.
|
||||
|
||||
Key Features:
|
||||
- Automatic JSON storage of translations in database
|
||||
- Language-aware field access based on Django's current language
|
||||
- Admin interface integration with translation modal overlays
|
||||
- Migration support for translate=True/False transitions
|
||||
- Database-specific optimizations for search operations
|
||||
|
||||
Usage:
|
||||
from django_translatable_fields import CharField, TextField
|
||||
|
||||
class MyModel(models.Model):
|
||||
name = CharField(max_length=200, translatable=True)
|
||||
description = TextField(translatable=True)
|
||||
|
||||
Author: Holger Sielaff <holger@backender.de>
|
||||
Version: 0.1.0
|
||||
"""
|
||||
|
||||
import json
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import get_language, gettext_lazy as _
|
||||
import logging
|
||||
import inspect
|
||||
|
||||
|
||||
class TranslatableMixin:
|
||||
"""
|
||||
Mixin that provides translatable functionality to any Django field.
|
||||
Stores translations as JSON and provides language-aware access.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.translatable = kwargs.pop('translatable', False)
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.translatable and hasattr(self, '_validators') and self.validators:
|
||||
self._original_validators = self.validators.copy()
|
||||
self.validators = []
|
||||
|
||||
def contribute_to_class(self, cls, name, **kwargs):
|
||||
"""Override to detect translatable changes and trigger migrations"""
|
||||
super().contribute_to_class(cls, name, **kwargs)
|
||||
|
||||
# Store field info for migration detection
|
||||
if not hasattr(cls._meta, '_translatable_fields'):
|
||||
cls._meta._translatable_fields = {}
|
||||
|
||||
cls._meta._translatable_fields[name] = {
|
||||
'translatable': self.translatable,
|
||||
'field_type': self.__class__.__name__
|
||||
}
|
||||
|
||||
def deconstruct(self):
|
||||
"""Include translatable parameter in field deconstruction for migrations"""
|
||||
name, path, args, kwargs = super().deconstruct()
|
||||
kwargs['translatable'] = self.translatable
|
||||
return name, path, args, kwargs
|
||||
|
||||
def from_db_value(self, value, expression, connection):
|
||||
logging.debug(f"{self.__class__.__name__} from_db_value - received: {repr(value)}")
|
||||
|
||||
if value is None:
|
||||
return value
|
||||
|
||||
if not self.translatable:
|
||||
return value
|
||||
|
||||
# Check if we're in admin context by looking at the call stack
|
||||
frames = inspect.getouterframes(inspect.currentframe())
|
||||
is_admin_context = any('admin' in frame.filename or 'forms' in frame.filename for frame in frames[:5])
|
||||
|
||||
logging.debug(f"{self.__class__.__name__} from_db_value - admin context: {is_admin_context}")
|
||||
|
||||
try:
|
||||
translations = json.loads(value) if isinstance(value, str) else value
|
||||
if not isinstance(translations, dict):
|
||||
return value
|
||||
|
||||
logging.debug(f"{self.__class__.__name__} from_db_value - parsed translations: {translations}")
|
||||
|
||||
# For admin/forms, return the full dict so widgets can access all translations
|
||||
if is_admin_context:
|
||||
logging.debug(
|
||||
f"{self.__class__.__name__} from_db_value - returning full dict for admin: {translations}")
|
||||
return translations
|
||||
|
||||
# For regular use, return the appropriate language
|
||||
current_lang = get_language()
|
||||
if current_lang and current_lang in translations:
|
||||
result = translations[current_lang]
|
||||
logging.debug(
|
||||
f"{self.__class__.__name__} from_db_value - returning for language {current_lang}: {repr(result)}")
|
||||
return result
|
||||
|
||||
# Fallback to default language or first available
|
||||
if 'en' in translations:
|
||||
result = translations['en']
|
||||
logging.debug(f"{self.__class__.__name__} from_db_value - returning english fallback: {repr(result)}")
|
||||
return result
|
||||
elif translations:
|
||||
result = next(iter(translations.values()))
|
||||
logging.debug(f"{self.__class__.__name__} from_db_value - returning first available: {repr(result)}")
|
||||
return result
|
||||
|
||||
logging.debug(f"{self.__class__.__name__} from_db_value - returning empty string")
|
||||
return ''
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logging.debug(f"{self.__class__.__name__} from_db_value - JSON decode error, returning raw value")
|
||||
return value
|
||||
|
||||
def to_python(self, value):
|
||||
if value is None:
|
||||
return value
|
||||
|
||||
if not self.translatable:
|
||||
return super().to_python(value)
|
||||
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
|
||||
try:
|
||||
return json.loads(value) if isinstance(value, str) else value
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return value
|
||||
|
||||
def get_prep_value(self, value):
|
||||
if not self.translatable:
|
||||
return super().get_prep_value(value)
|
||||
|
||||
if value is None:
|
||||
return value
|
||||
|
||||
# If it's already a JSON string from database, don't double encode
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
# Try to parse it - if it parses, it's already JSON
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, dict):
|
||||
return value # Return as-is, it's already proper JSON
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
# If parsing fails, treat as regular string for current language
|
||||
current_lang = get_language() or 'en'
|
||||
return json.dumps({current_lang: str(value)})
|
||||
|
||||
if isinstance(value, dict):
|
||||
# Filter out empty values and ensure all values are strings
|
||||
clean_dict = {k: str(v) for k, v in value.items() if v and str(v).strip()}
|
||||
return json.dumps(clean_dict) if clean_dict else None
|
||||
|
||||
# Fallback for other types
|
||||
current_lang = get_language() or 'en'
|
||||
return json.dumps({current_lang: str(value)})
|
||||
|
||||
def validate(self, value, model_instance):
|
||||
if not self.translatable or not value or not isinstance(value, (str, dict)):
|
||||
super().validate(value, model_instance)
|
||||
|
||||
# Validate each translation individually
|
||||
for lang_code, translation in value.items():
|
||||
if not isinstance(lang_code, str) or not isinstance(translation, str):
|
||||
raise ValidationError(_('Invalid translation format'))
|
||||
|
||||
# Validate each translation string with appropriate validators
|
||||
if translation.strip(): # Only validate non-empty values
|
||||
try:
|
||||
# Run field-specific validation for each translation
|
||||
self._validate_translation(translation, lang_code)
|
||||
except ValidationError as e:
|
||||
# Re-raise with language context
|
||||
raise ValidationError(f"Invalid value for language '{lang_code}': {e.message}")
|
||||
|
||||
def _validate_translation(self, value, lang_code):
|
||||
"""Validate a single translation value with field-specific validators"""
|
||||
for validator in getattr(self, '_original_validators', []):
|
||||
validator(value)
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
# Only apply translatable widgets to text-like fields
|
||||
if self.translatable and isinstance(self,
|
||||
(models.CharField, models.TextField, models.SlugField, models.EmailField,
|
||||
models.URLField)):
|
||||
# Use specific form fields for fields with validators
|
||||
if isinstance(self, models.EmailField):
|
||||
from .forms import TranslatableEmailFormField
|
||||
defaults = {'form_class': TranslatableEmailFormField}
|
||||
elif isinstance(self, models.SlugField):
|
||||
from .forms import TranslatableSlugFormField
|
||||
defaults = {'form_class': TranslatableSlugFormField}
|
||||
elif isinstance(self, models.URLField):
|
||||
from .forms import TranslatableURLFormField
|
||||
defaults = {'form_class': TranslatableURLFormField}
|
||||
else:
|
||||
# For CharField and TextField, use the unified form field
|
||||
from .forms import TranslatableUnifiedFormField
|
||||
|
||||
# Determine if this is char-like based on max_length or field type
|
||||
is_charfield = (
|
||||
hasattr(self, 'max_length') and self.max_length is not None
|
||||
) or isinstance(self, models.CharField)
|
||||
|
||||
defaults = {
|
||||
'form_class': TranslatableUnifiedFormField,
|
||||
'is_charfield': is_charfield,
|
||||
'max_length': getattr(self, 'max_length', None),
|
||||
}
|
||||
|
||||
defaults.update(kwargs)
|
||||
return super().formfield(**defaults)
|
||||
return super().formfield(**kwargs)
|
||||
|
||||
|
||||
class CharField(TranslatableMixin, models.CharField):
|
||||
""" A translatable Char Field"""
|
||||
pass
|
||||
|
||||
|
||||
class TextField(TranslatableMixin, models.TextField):
|
||||
""" A translatable Text Field """
|
||||
pass
|
||||
|
||||
|
||||
class EmailField(TranslatableMixin, models.EmailField):
|
||||
"""Translatable email field"""
|
||||
pass
|
||||
|
||||
|
||||
class URLField(TranslatableMixin, models.URLField):
|
||||
"""Translatable URL field"""
|
||||
pass
|
||||
|
||||
|
||||
class SlugField(TranslatableMixin, models.SlugField):
|
||||
"""Translatable slug field"""
|
||||
pass
|
||||
224
django_translatable_fields/forms.py
Normal file
224
django_translatable_fields/forms.py
Normal 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)
|
||||
0
django_translatable_fields/management/__init__.py
Normal file
0
django_translatable_fields/management/__init__.py
Normal 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}")
|
||||
313
django_translatable_fields/managers.py
Normal file
313
django_translatable_fields/managers.py
Normal 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
|
||||
129
django_translatable_fields/operations.py
Normal file
129
django_translatable_fields/operations.py
Normal 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}"
|
||||
235
django_translatable_fields/search.py
Normal file
235
django_translatable_fields/search.py
Normal 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'
|
||||
}
|
||||
264
django_translatable_fields/serializers.py
Normal file
264
django_translatable_fields/serializers.py
Normal 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
|
||||
109
django_translatable_fields/setup.py
Normal file
109
django_translatable_fields/setup.py
Normal 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,
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()">×</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, '"').replace(/'/g, ''');
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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>
|
||||
0
django_translatable_fields/templatetags/__init__.py
Normal file
0
django_translatable_fields/templatetags/__init__.py
Normal file
253
django_translatable_fields/templatetags/translatable_tags.py
Normal file
253
django_translatable_fields/templatetags/translatable_tags.py
Normal 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)
|
||||
276
django_translatable_fields/widgets.py
Normal file
276
django_translatable_fields/widgets.py
Normal 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)
|
||||
166
example/README.md
Normal file
166
example/README.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Django Translatable Fields - Examples
|
||||
|
||||
This directory contains comprehensive examples demonstrating all features of django-translatable-fields.
|
||||
|
||||
## Files Overview
|
||||
|
||||
- **`models.py`** - Example models showcasing all translatable field types
|
||||
- **`admin.py`** - Admin configurations for the example models
|
||||
- **`usage_examples.py`** - Practical usage examples and demonstrations
|
||||
|
||||
## Example Models
|
||||
|
||||
### 1. Product Model
|
||||
Demonstrates basic translatable fields:
|
||||
- `CharField` - Product name
|
||||
- `TextField` - Product description
|
||||
- `SlugField` - SEO-friendly URL slug
|
||||
- `EmailField` - Support contact email
|
||||
- `URLField` - Product website URL
|
||||
|
||||
### 2. Category Model
|
||||
Shows category organization with translations:
|
||||
- `CharField` - Category name
|
||||
- `TextField` - Category description
|
||||
- `SlugField` - URL-friendly category slug
|
||||
- `CharField` - SEO meta description
|
||||
|
||||
### 3. BlogPost Model
|
||||
Comprehensive example with all field types:
|
||||
- `CharField` - Blog post title and meta description
|
||||
- `TextField` - Main blog post content
|
||||
- `SlugField` - URL slug for the post
|
||||
- `EmailField` - Author contact email
|
||||
- `URLField` - External reference URL
|
||||
|
||||
### 4. Company Model
|
||||
Business-focused model with contact information:
|
||||
- `CharField` - Company name
|
||||
- `TextField` - Company description and address
|
||||
- `SlugField` - Company URL slug
|
||||
- `EmailField` - Main contact email
|
||||
- `URLField` - Company website
|
||||
|
||||
## Running the Examples
|
||||
|
||||
### 1. Django Shell Usage
|
||||
|
||||
```python
|
||||
# Start Django shell
|
||||
python manage.py shell
|
||||
|
||||
# Import and run examples
|
||||
from example.usage_examples import run_all_examples
|
||||
run_all_examples()
|
||||
```
|
||||
|
||||
### 2. Individual Example Functions
|
||||
|
||||
```python
|
||||
from example.usage_examples import (
|
||||
create_sample_data,
|
||||
demonstrate_language_switching,
|
||||
demonstrate_direct_access,
|
||||
demonstrate_translation_methods,
|
||||
demonstrate_all_field_types
|
||||
)
|
||||
|
||||
# Create sample data
|
||||
product, blog_post, company = create_sample_data()
|
||||
|
||||
# Try different demonstrations
|
||||
demonstrate_language_switching()
|
||||
demonstrate_direct_access()
|
||||
demonstrate_translation_methods()
|
||||
demonstrate_all_field_types()
|
||||
```
|
||||
|
||||
## Usage Patterns Demonstrated
|
||||
|
||||
### 1. Language-Aware Access
|
||||
```python
|
||||
from django.utils.translation import activate
|
||||
|
||||
# Fields automatically return values based on current language
|
||||
activate('en')
|
||||
print(product.name) # "Awesome Widget"
|
||||
|
||||
activate('de')
|
||||
print(product.name) # "Fantastisches Widget"
|
||||
```
|
||||
|
||||
### 2. Direct Language Access
|
||||
```python
|
||||
# Access specific language directly
|
||||
print(product.name_en) # "Awesome Widget"
|
||||
print(product.name_de) # "Fantastisches Widget"
|
||||
print(product.name_fr) # "Widget Fantastique"
|
||||
```
|
||||
|
||||
### 3. Translation Management
|
||||
```python
|
||||
# Get all translations for a field
|
||||
translations = product.get_all_translations('name')
|
||||
# {'en': 'Awesome Widget', 'de': 'Fantastisches Widget', 'fr': 'Widget Fantastique'}
|
||||
|
||||
# Get specific translation
|
||||
german_name = product.get_translation('name', 'de')
|
||||
|
||||
# Set translation programmatically
|
||||
product.set_translation('name', 'es', 'Widget Fantástico')
|
||||
product.save()
|
||||
```
|
||||
|
||||
### 4. All Field Types in Action
|
||||
The examples demonstrate proper usage of:
|
||||
- **CharField** - Text fields with max_length
|
||||
- **TextField** - Longer text content
|
||||
- **SlugField** - URL-friendly identifiers
|
||||
- **EmailField** - Email addresses with validation
|
||||
- **URLField** - Web URLs with validation
|
||||
|
||||
## Admin Interface Features
|
||||
|
||||
The admin examples show:
|
||||
- Clean interface with translate buttons
|
||||
- Modal overlays for translation management
|
||||
- WYSIWYG editor for TextField content
|
||||
- Proper field validation per language
|
||||
- Drag and resize functionality for modals
|
||||
|
||||
## Field Validation
|
||||
|
||||
Each field type includes proper validation:
|
||||
- **EmailField** - Valid email format per language
|
||||
- **URLField** - Valid URL format per language
|
||||
- **SlugField** - Slug format validation per language
|
||||
- **CharField/TextField** - Length and content validation
|
||||
|
||||
## Integration with Django Features
|
||||
|
||||
Examples demonstrate compatibility with:
|
||||
- Django admin interface
|
||||
- Model forms and validation
|
||||
- Django's internationalization system
|
||||
- Search functionality
|
||||
- List filters and display options
|
||||
- Prepopulated fields (for slugs)
|
||||
|
||||
## Best Practices Shown
|
||||
|
||||
1. **Model Design** - Proper use of translatable vs non-translatable fields
|
||||
2. **Admin Configuration** - Clean, user-friendly admin interfaces
|
||||
3. **Data Access** - Efficient translation retrieval and management
|
||||
4. **Validation** - Proper field validation across languages
|
||||
5. **SEO Optimization** - Language-specific slugs and meta descriptions
|
||||
|
||||
## Testing Your Implementation
|
||||
|
||||
Use these examples to:
|
||||
1. Verify all field types work correctly
|
||||
2. Test language switching functionality
|
||||
3. Validate admin interface behavior
|
||||
4. Check translation storage and retrieval
|
||||
5. Ensure proper field validation
|
||||
|
||||
The examples provide a solid foundation for implementing translatable fields in your own Django applications.
|
||||
1
example/__init__.py
Normal file
1
example/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Example app for django-translatable-fields
|
||||
60
example/admin.py
Normal file
60
example/admin.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Example admin configuration demonstrating django-translatable-fields usage.
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django_translatable_fields.admin import TranslatableModelAdmin
|
||||
from .models import Product, Category, BlogPost, Company
|
||||
|
||||
|
||||
@admin.register(Product)
|
||||
class ProductAdmin(TranslatableModelAdmin):
|
||||
"""
|
||||
Admin for Product model with translatable fields.
|
||||
"""
|
||||
list_display = ['name', 'slug', 'price', 'created_at']
|
||||
list_filter = ['created_at']
|
||||
search_fields = ['name', 'description']
|
||||
fields = ['name', 'description', 'slug', 'support_email', 'website', 'price']
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
|
||||
|
||||
@admin.register(Category)
|
||||
class CategoryAdmin(TranslatableModelAdmin):
|
||||
"""
|
||||
Admin for Category model with translatable fields.
|
||||
"""
|
||||
list_display = ['name', 'slug']
|
||||
search_fields = ['name', 'description']
|
||||
fields = ['name', 'description', 'slug', 'meta_description']
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
|
||||
|
||||
@admin.register(BlogPost)
|
||||
class BlogPostAdmin(TranslatableModelAdmin):
|
||||
"""
|
||||
Admin for BlogPost model demonstrating all translatable field types.
|
||||
"""
|
||||
list_display = ['title', 'slug', 'published', 'publish_date', 'created_at']
|
||||
list_filter = ['published', 'publish_date', 'created_at']
|
||||
search_fields = ['title', 'content', 'meta_description']
|
||||
fields = [
|
||||
'title', 'content', 'slug', 'author_email', 'external_link',
|
||||
'meta_description', 'published', 'publish_date'
|
||||
]
|
||||
prepopulated_fields = {'slug': ('title',)}
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
|
||||
@admin.register(Company)
|
||||
class CompanyAdmin(TranslatableModelAdmin):
|
||||
"""
|
||||
Admin for Company model with comprehensive translatable fields.
|
||||
"""
|
||||
list_display = ['name', 'slug', 'contact_email', 'founded_year', 'is_active']
|
||||
list_filter = ['is_active', 'founded_year']
|
||||
search_fields = ['name', 'description', 'address']
|
||||
fields = [
|
||||
'name', 'description', 'slug', 'contact_email', 'website',
|
||||
'address', 'founded_year', 'employee_count', 'is_active'
|
||||
]
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
91
example/models.py
Normal file
91
example/models.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Example models demonstrating django-translatable-fields usage.
|
||||
"""
|
||||
from django.db import models
|
||||
from django_translatable_fields.fields import CharField, TextField, EmailField, URLField, SlugField
|
||||
from django_translatable_fields.descriptors import TranslatableModelMixin
|
||||
|
||||
|
||||
class Product(TranslatableModelMixin, models.Model):
|
||||
"""
|
||||
Example product model with translatable fields.
|
||||
"""
|
||||
name = CharField(max_length=200, translatable=True, help_text="Product name")
|
||||
description = TextField(translatable=True, help_text="Product description")
|
||||
slug = SlugField(max_length=200, translatable=True, help_text="SEO-friendly URL slug")
|
||||
support_email = EmailField(translatable=True, help_text="Support contact email")
|
||||
website = URLField(translatable=True, blank=True, help_text="Product website URL")
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'example'
|
||||
|
||||
def __str__(self):
|
||||
return self.name or f"Product {self.id}"
|
||||
|
||||
|
||||
class Category(TranslatableModelMixin, models.Model):
|
||||
"""
|
||||
Example category model with translatable fields.
|
||||
"""
|
||||
name = CharField(max_length=100, translatable=True, help_text="Category name")
|
||||
description = TextField(translatable=True, blank=True, help_text="Category description")
|
||||
slug = SlugField(max_length=100, translatable=True, help_text="URL-friendly category slug")
|
||||
meta_description = CharField(max_length=160, translatable=True, blank=True, help_text="SEO meta description")
|
||||
|
||||
class Meta:
|
||||
app_label = 'example'
|
||||
verbose_name_plural = "Categories"
|
||||
|
||||
def __str__(self):
|
||||
return self.name or f"Category {self.id}"
|
||||
|
||||
|
||||
class BlogPost(TranslatableModelMixin, models.Model):
|
||||
"""
|
||||
Example blog post model demonstrating all translatable field types.
|
||||
"""
|
||||
title = CharField(max_length=200, translatable=True, help_text="Blog post title")
|
||||
content = TextField(translatable=True, help_text="Main blog post content")
|
||||
slug = SlugField(max_length=200, translatable=True, help_text="URL slug for the post")
|
||||
author_email = EmailField(translatable=True, help_text="Author contact email")
|
||||
external_link = URLField(translatable=True, blank=True, help_text="External reference URL")
|
||||
meta_description = CharField(max_length=160, translatable=True, blank=True, help_text="SEO meta description")
|
||||
|
||||
# Non-translatable fields
|
||||
published = models.BooleanField(default=False)
|
||||
publish_date = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'example'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.title or f"Blog Post {self.id}"
|
||||
|
||||
|
||||
class Company(TranslatableModelMixin, models.Model):
|
||||
"""
|
||||
Example company model with contact information in multiple languages.
|
||||
"""
|
||||
name = CharField(max_length=200, translatable=True, help_text="Company name")
|
||||
description = TextField(translatable=True, help_text="Company description")
|
||||
slug = SlugField(max_length=200, translatable=True, help_text="Company URL slug")
|
||||
contact_email = EmailField(translatable=True, help_text="Main contact email")
|
||||
website = URLField(translatable=True, help_text="Company website")
|
||||
address = TextField(translatable=True, help_text="Company address")
|
||||
|
||||
# Non-translatable fields
|
||||
founded_year = models.IntegerField(null=True, blank=True)
|
||||
employee_count = models.IntegerField(null=True, blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'example'
|
||||
verbose_name_plural = "Companies"
|
||||
|
||||
def __str__(self):
|
||||
return self.name or f"Company {self.id}"
|
||||
263
example/usage_examples.py
Normal file
263
example/usage_examples.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Usage examples for django-translatable-fields demonstrating all field types.
|
||||
|
||||
This file shows practical examples of how to work with translatable fields
|
||||
in various scenarios, including data creation, language switching, and
|
||||
accessing translations programmatically.
|
||||
"""
|
||||
|
||||
from django.utils.translation import activate, get_language
|
||||
from .models import Product, Category, BlogPost, Company
|
||||
|
||||
|
||||
def create_sample_data():
|
||||
"""
|
||||
Create sample data with translations in multiple languages.
|
||||
"""
|
||||
print("Creating sample data with translations...")
|
||||
|
||||
# Create a product with all field types
|
||||
product = Product.objects.create(
|
||||
name="Awesome Widget",
|
||||
description="This is an amazing product that does everything you need.",
|
||||
slug="awesome-widget",
|
||||
support_email="support@example.com",
|
||||
website="https://example.com/products/awesome-widget",
|
||||
price=99.99
|
||||
)
|
||||
|
||||
# Add German translations
|
||||
product.name_de = "Fantastisches Widget"
|
||||
product.description_de = "Das ist ein fantastisches Produkt, das alles macht, was Sie brauchen."
|
||||
product.slug_de = "fantastisches-widget"
|
||||
product.support_email_de = "support@beispiel.de"
|
||||
product.website_de = "https://beispiel.de/produkte/fantastisches-widget"
|
||||
|
||||
# Add French translations
|
||||
product.name_fr = "Widget Fantastique"
|
||||
product.description_fr = "C'est un produit fantastique qui fait tout ce dont vous avez besoin."
|
||||
product.slug_fr = "widget-fantastique"
|
||||
product.support_email_fr = "support@exemple.fr"
|
||||
product.website_fr = "https://exemple.fr/produits/widget-fantastique"
|
||||
|
||||
product.save()
|
||||
|
||||
# Create a blog post
|
||||
blog_post = BlogPost.objects.create(
|
||||
title="How to Use Awesome Widgets",
|
||||
content="This comprehensive guide will show you everything about widgets...",
|
||||
slug="how-to-use-awesome-widgets",
|
||||
author_email="author@example.com",
|
||||
external_link="https://external-resource.com",
|
||||
meta_description="Complete guide to using awesome widgets effectively",
|
||||
published=True
|
||||
)
|
||||
|
||||
# Add translations for blog post
|
||||
blog_post.title_de = "Wie man fantastische Widgets verwendet"
|
||||
blog_post.content_de = "Dieser umfassende Leitfaden zeigt Ihnen alles über Widgets..."
|
||||
blog_post.slug_de = "wie-man-fantastische-widgets-verwendet"
|
||||
blog_post.author_email_de = "autor@beispiel.de"
|
||||
blog_post.external_link_de = "https://externe-ressource.de"
|
||||
blog_post.meta_description_de = "Vollständiger Leitfaden zur effektiven Nutzung fantastischer Widgets"
|
||||
|
||||
blog_post.save()
|
||||
|
||||
# Create a company
|
||||
company = Company.objects.create(
|
||||
name="Tech Innovations Inc.",
|
||||
description="Leading technology company specializing in innovative solutions.",
|
||||
slug="tech-innovations",
|
||||
contact_email="contact@techinnovations.com",
|
||||
website="https://techinnovations.com",
|
||||
address="123 Tech Street, Silicon Valley, CA 94000",
|
||||
founded_year=2010,
|
||||
employee_count=150
|
||||
)
|
||||
|
||||
# Add translations for company
|
||||
company.name_de = "Tech Innovationen GmbH"
|
||||
company.description_de = "Führendes Technologieunternehmen mit Spezialisierung auf innovative Lösungen."
|
||||
company.slug_de = "tech-innovationen"
|
||||
company.contact_email_de = "kontakt@techinnovationen.de"
|
||||
company.website_de = "https://techinnovationen.de"
|
||||
company.address_de = "Techstraße 123, 10115 Berlin, Deutschland"
|
||||
|
||||
company.save()
|
||||
|
||||
return product, blog_post, company
|
||||
|
||||
|
||||
def demonstrate_language_switching():
|
||||
"""
|
||||
Demonstrate how fields automatically return values based on current language.
|
||||
"""
|
||||
print("\nDemonstrating language-aware field access...")
|
||||
|
||||
# Get a product (assuming one exists)
|
||||
try:
|
||||
product = Product.objects.first()
|
||||
if not product:
|
||||
print("No products found. Create sample data first.")
|
||||
return
|
||||
|
||||
print(f"Current language: {get_language()}")
|
||||
|
||||
# English
|
||||
activate('en')
|
||||
print(f"English - Name: {product.name}")
|
||||
print(f"English - Website: {product.website}")
|
||||
print(f"English - Email: {product.support_email}")
|
||||
|
||||
# German
|
||||
activate('de')
|
||||
print(f"German - Name: {product.name}")
|
||||
print(f"German - Website: {product.website}")
|
||||
print(f"German - Email: {product.support_email}")
|
||||
|
||||
# French
|
||||
activate('fr')
|
||||
print(f"French - Name: {product.name}")
|
||||
print(f"French - Website: {product.website}")
|
||||
print(f"French - Email: {product.support_email}")
|
||||
|
||||
# Reset to English
|
||||
activate('en')
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
|
||||
def demonstrate_direct_access():
|
||||
"""
|
||||
Demonstrate direct access to specific language translations.
|
||||
"""
|
||||
print("\nDemonstrating direct language access...")
|
||||
|
||||
try:
|
||||
product = Product.objects.first()
|
||||
if not product:
|
||||
print("No products found. Create sample data first.")
|
||||
return
|
||||
|
||||
print("Direct access to specific languages:")
|
||||
print(f"English name: {product.name_en}")
|
||||
print(f"German name: {product.name_de}")
|
||||
print(f"French name: {product.name_fr}")
|
||||
|
||||
print(f"English slug: {product.slug_en}")
|
||||
print(f"German slug: {product.slug_de}")
|
||||
print(f"French slug: {product.slug_fr}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
|
||||
def demonstrate_translation_methods():
|
||||
"""
|
||||
Demonstrate helper methods for working with translations.
|
||||
"""
|
||||
print("\nDemonstrating translation helper methods...")
|
||||
|
||||
try:
|
||||
product = Product.objects.first()
|
||||
if not product:
|
||||
print("No products found. Create sample data first.")
|
||||
return
|
||||
|
||||
# Get all translations for a field
|
||||
name_translations = product.get_all_translations('name')
|
||||
print(f"All name translations: {name_translations}")
|
||||
|
||||
website_translations = product.get_all_translations('website')
|
||||
print(f"All website translations: {website_translations}")
|
||||
|
||||
# Get specific translation
|
||||
german_name = product.get_translation('name', 'de')
|
||||
print(f"German name via method: {german_name}")
|
||||
|
||||
# Set translation programmatically
|
||||
product.set_translation('name', 'es', 'Widget Fantástico')
|
||||
product.save()
|
||||
|
||||
spanish_name = product.get_translation('name', 'es')
|
||||
print(f"Spanish name (newly set): {spanish_name}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
|
||||
def demonstrate_all_field_types():
|
||||
"""
|
||||
Demonstrate all available translatable field types.
|
||||
"""
|
||||
print("\nDemonstrating all translatable field types...")
|
||||
|
||||
try:
|
||||
# Create an instance with all field types
|
||||
company = Company.objects.create(
|
||||
name="Demo Company", # CharField
|
||||
description="This is a demo company.", # TextField
|
||||
slug="demo-company", # SlugField
|
||||
contact_email="demo@company.com", # EmailField
|
||||
website="https://democompany.com", # URLField
|
||||
address="123 Demo Street, Demo City" # TextField
|
||||
)
|
||||
|
||||
print("Created company with all field types:")
|
||||
print(f"CharField (name): {company.name}")
|
||||
print(f"TextField (description): {company.description}")
|
||||
print(f"SlugField (slug): {company.slug}")
|
||||
print(f"EmailField (contact_email): {company.contact_email}")
|
||||
print(f"URLField (website): {company.website}")
|
||||
print(f"TextField (address): {company.address}")
|
||||
|
||||
# Add translations for each field type
|
||||
company.name_de = "Demo Firma"
|
||||
company.description_de = "Das ist eine Demo-Firma."
|
||||
company.slug_de = "demo-firma"
|
||||
company.contact_email_de = "demo@firma.de"
|
||||
company.website_de = "https://demofirma.de"
|
||||
company.address_de = "Demostraße 123, Demo Stadt"
|
||||
|
||||
company.save()
|
||||
|
||||
activate('de')
|
||||
print("\nGerman translations:")
|
||||
print(f"CharField (name): {company.name}")
|
||||
print(f"TextField (description): {company.description}")
|
||||
print(f"SlugField (slug): {company.slug}")
|
||||
print(f"EmailField (contact_email): {company.contact_email}")
|
||||
print(f"URLField (website): {company.website}")
|
||||
print(f"TextField (address): {company.address}")
|
||||
|
||||
activate('en') # Reset
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
|
||||
def run_all_examples():
|
||||
"""
|
||||
Run all examples in sequence.
|
||||
"""
|
||||
print("=== Django Translatable Fields - Usage Examples ===")
|
||||
|
||||
# Create sample data
|
||||
product, blog_post, company = create_sample_data()
|
||||
|
||||
# Run demonstrations
|
||||
demonstrate_language_switching()
|
||||
demonstrate_direct_access()
|
||||
demonstrate_translation_methods()
|
||||
demonstrate_all_field_types()
|
||||
|
||||
print("\n=== Examples completed successfully! ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# This can be run in Django shell with:
|
||||
# python manage.py shell
|
||||
# >>> from example.usage_examples import run_all_examples
|
||||
# >>> run_all_examples()
|
||||
run_all_examples()
|
||||
125
pyproject.toml
Normal file
125
pyproject.toml
Normal file
@@ -0,0 +1,125 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "django-translatable-fields"
|
||||
version = "0.1.0"
|
||||
description = "Django plugin that mimics Odoo's translate=True functionality with admin interface integration"
|
||||
readme = "README.md"
|
||||
license = {file = "LICENSE"}
|
||||
authors = [
|
||||
{name = "Holger Sielaff", email = "holger@backender.de"}
|
||||
]
|
||||
maintainers = [
|
||||
{name = "Holger Sielaff", email = "holger@backender.de"}
|
||||
]
|
||||
keywords = [
|
||||
"django",
|
||||
"translation",
|
||||
"internationalization",
|
||||
"i18n",
|
||||
"multilingual",
|
||||
"translatable",
|
||||
"fields",
|
||||
"admin"
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Web Environment",
|
||||
"Framework :: Django",
|
||||
"Framework :: Django :: 4.2",
|
||||
"Framework :: Django :: 5.0",
|
||||
"Framework :: Django :: 5.1",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"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 :: Application Frameworks",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
]
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"Django>=4.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest",
|
||||
"pytest-django",
|
||||
"black",
|
||||
"isort",
|
||||
"flake8",
|
||||
"mypy",
|
||||
"django-stubs",
|
||||
]
|
||||
drf = [
|
||||
"djangorestframework>=3.14.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://repo.backender.de/holger/Django-Translatable-Fields"
|
||||
Documentation = "https://repo.backender.de/holger/Django-Translatable-Fields#readme"
|
||||
Repository = "https://repo.backender.de/holger/Django-Translatable-Fields.git"
|
||||
"Bug Tracker" = "https://repo.backender.de/holger/Django-Translatable-Fields/issues"
|
||||
Changelog = "https://repo.backender.de/holger/Django-Translatable-Fields/blob/main/CHANGELOG.md"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["django_translatable_fields"]
|
||||
include-package-data = true
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
django_translatable_fields = [
|
||||
"static/django_translatable_fields/*",
|
||||
"templates/django_translatable_fields/*",
|
||||
"locale/*/LC_MESSAGES/*",
|
||||
]
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ['py39']
|
||||
include = '\.pyi?$'
|
||||
extend-exclude = '''
|
||||
/(
|
||||
# directories
|
||||
\.eggs
|
||||
| \.git
|
||||
| \.hg
|
||||
| \.mypy_cache
|
||||
| \.tox
|
||||
| \.venv
|
||||
| build
|
||||
| dist
|
||||
)/
|
||||
'''
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
multi_line_output = 3
|
||||
line_length = 88
|
||||
known_django = "django"
|
||||
known_first_party = "django_translatable_fields"
|
||||
sections = ["FUTURE", "STDLIB", "DJANGO", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.9"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
plugins = ["mypy_django_plugin.main"]
|
||||
|
||||
[tool.django-stubs]
|
||||
django_settings_module = "tests.settings"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "tests.settings"
|
||||
python_files = ["tests.py", "test_*.py", "*_tests.py"]
|
||||
addopts = "--tb=short"
|
||||
testpaths = ["tests"]
|
||||
12
setup.py
Normal file
12
setup.py
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Setup script for django-translatable-fields.
|
||||
|
||||
For modern Python packaging, most configuration is in pyproject.toml.
|
||||
This setup.py is kept for compatibility with older pip versions.
|
||||
"""
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
# All configuration is now in pyproject.toml
|
||||
setup()
|
||||
Reference in New Issue
Block a user