commit 79c68169f6c5ac0a214bd93185543b244519ff47 Author: Holger Sielaff Date: Sat Aug 2 20:08:33 2025 +0200 Initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef45ccd --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__ +*.pyc +*.swp +.idea +.venv +venv +!CHANGELOG.md +!README.md +/*.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9c75dd4 --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..38205b9 --- /dev/null +++ b/MANIFEST.in @@ -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/ \ No newline at end of file diff --git a/debian/README.Debian b/debian/README.Debian new file mode 100644 index 0000000..292af21 --- /dev/null +++ b/debian/README.Debian @@ -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 Fri, 02 Aug 2024 12:00:00 +0200 \ No newline at end of file diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..5150488 --- /dev/null +++ b/debian/changelog @@ -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 Fri, 02 Aug 2024 12:00:00 +0200 \ No newline at end of file diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..ca7bf83 --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +13 \ No newline at end of file diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..3dcc47f --- /dev/null +++ b/debian/control @@ -0,0 +1,45 @@ +Source: python-django-translatable-fields +Section: python +Priority: optional +Maintainer: Holger Sielaff +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. \ No newline at end of file diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..837ecd4 --- /dev/null +++ b/debian/copyright @@ -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 +Source: https://repo.backender.de/holger/Django-Translatable-Fields + +Files: * +Copyright: 2024 Holger Sielaff +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. \ No newline at end of file diff --git a/debian/python3-django-translatable-fields.docs b/debian/python3-django-translatable-fields.docs new file mode 100644 index 0000000..805347a --- /dev/null +++ b/debian/python3-django-translatable-fields.docs @@ -0,0 +1,2 @@ +README.md +CHANGELOG.md \ No newline at end of file diff --git a/debian/python3-django-translatable-fields.examples b/debian/python3-django-translatable-fields.examples new file mode 100644 index 0000000..e5a7b8d --- /dev/null +++ b/debian/python3-django-translatable-fields.examples @@ -0,0 +1 @@ +example/* \ No newline at end of file diff --git a/debian/python3-django-translatable-fields.install b/debian/python3-django-translatable-fields.install new file mode 100644 index 0000000..446b3e3 --- /dev/null +++ b/debian/python3-django-translatable-fields.install @@ -0,0 +1,2 @@ +# Install package files +django_translatable_fields usr/lib/python3/dist-packages/ \ No newline at end of file diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..3629fc8 --- /dev/null +++ b/debian/rules @@ -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 \ No newline at end of file diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..46ebe02 --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) \ No newline at end of file diff --git a/debian/watch b/debian/watch new file mode 100644 index 0000000..d954be3 --- /dev/null +++ b/debian/watch @@ -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@ \ No newline at end of file diff --git a/django_translatable_fields.egg-info/PKG-INFO b/django_translatable_fields.egg-info/PKG-INFO new file mode 100644 index 0000000..482d88f --- /dev/null +++ b/django_translatable_fields.egg-info/PKG-INFO @@ -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 +Maintainer-email: Holger Sielaff +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 + +[![PyPI version](https://badge.fury.io/py/django-translatable-fields.svg)](https://badge.fury.io/py/django-translatable-fields) +[![Python versions](https://img.shields.io/pypi/pyversions/django-translatable-fields.svg)](https://pypi.org/project/django-translatable-fields/) +[![Django versions](https://img.shields.io/pypi/djversions/django-translatable-fields.svg)](https://pypi.org/project/django-translatable-fields/) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](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 diff --git a/django_translatable_fields.egg-info/SOURCES.txt b/django_translatable_fields.egg-info/SOURCES.txt new file mode 100644 index 0000000..2560dbc --- /dev/null +++ b/django_translatable_fields.egg-info/SOURCES.txt @@ -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 \ No newline at end of file diff --git a/django_translatable_fields.egg-info/dependency_links.txt b/django_translatable_fields.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/django_translatable_fields.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/django_translatable_fields.egg-info/requires.txt b/django_translatable_fields.egg-info/requires.txt new file mode 100644 index 0000000..c5937f8 --- /dev/null +++ b/django_translatable_fields.egg-info/requires.txt @@ -0,0 +1,13 @@ +Django>=4.2 + +[dev] +black +django-stubs +flake8 +isort +mypy +pytest +pytest-django + +[drf] +djangorestframework>=3.14.0 diff --git a/django_translatable_fields.egg-info/top_level.txt b/django_translatable_fields.egg-info/top_level.txt new file mode 100644 index 0000000..2477d19 --- /dev/null +++ b/django_translatable_fields.egg-info/top_level.txt @@ -0,0 +1 @@ +django_translatable_fields diff --git a/django_translatable_fields/__init__.py b/django_translatable_fields/__init__.py new file mode 100644 index 0000000..e70da5a --- /dev/null +++ b/django_translatable_fields/__init__.py @@ -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 +""" + +__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' + ]) \ No newline at end of file diff --git a/django_translatable_fields/admin.py b/django_translatable_fields/admin.py new file mode 100644 index 0000000..7e9f2d4 --- /dev/null +++ b/django_translatable_fields/admin.py @@ -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 \ No newline at end of file diff --git a/django_translatable_fields/apps.py b/django_translatable_fields/apps.py new file mode 100644 index 0000000..39e5b6d --- /dev/null +++ b/django_translatable_fields/apps.py @@ -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 +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." + ) \ No newline at end of file diff --git a/django_translatable_fields/context.py b/django_translatable_fields/context.py new file mode 100644 index 0000000..ba5a10e --- /dev/null +++ b/django_translatable_fields/context.py @@ -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 +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 \ No newline at end of file diff --git a/django_translatable_fields/descriptors.py b/django_translatable_fields/descriptors.py new file mode 100644 index 0000000..b5d9b69 --- /dev/null +++ b/django_translatable_fields/descriptors.py @@ -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) \ No newline at end of file diff --git a/django_translatable_fields/fields.py b/django_translatable_fields/fields.py new file mode 100644 index 0000000..f0f3133 --- /dev/null +++ b/django_translatable_fields/fields.py @@ -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 +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 diff --git a/django_translatable_fields/forms.py b/django_translatable_fields/forms.py new file mode 100644 index 0000000..26bae17 --- /dev/null +++ b/django_translatable_fields/forms.py @@ -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 +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) \ No newline at end of file diff --git a/django_translatable_fields/management/__init__.py b/django_translatable_fields/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_translatable_fields/management/commands/__init__.py b/django_translatable_fields/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_translatable_fields/management/commands/makemigrations_translatable.py b/django_translatable_fields/management/commands/makemigrations_translatable.py new file mode 100644 index 0000000..0a17706 --- /dev/null +++ b/django_translatable_fields/management/commands/makemigrations_translatable.py @@ -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}") \ No newline at end of file diff --git a/django_translatable_fields/managers.py b/django_translatable_fields/managers.py new file mode 100644 index 0000000..9d10f95 --- /dev/null +++ b/django_translatable_fields/managers.py @@ -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 +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 \ No newline at end of file diff --git a/django_translatable_fields/operations.py b/django_translatable_fields/operations.py new file mode 100644 index 0000000..ec77afa --- /dev/null +++ b/django_translatable_fields/operations.py @@ -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 +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}" \ No newline at end of file diff --git a/django_translatable_fields/search.py b/django_translatable_fields/search.py new file mode 100644 index 0000000..5cf2f67 --- /dev/null +++ b/django_translatable_fields/search.py @@ -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 +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' + } \ No newline at end of file diff --git a/django_translatable_fields/serializers.py b/django_translatable_fields/serializers.py new file mode 100644 index 0000000..03b0dd6 --- /dev/null +++ b/django_translatable_fields/serializers.py @@ -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 +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 \ No newline at end of file diff --git a/django_translatable_fields/setup.py b/django_translatable_fields/setup.py new file mode 100644 index 0000000..d4ae103 --- /dev/null +++ b/django_translatable_fields/setup.py @@ -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 +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, +) \ No newline at end of file diff --git a/django_translatable_fields/static/django_translatable_fields/translatable.css b/django_translatable_fields/static/django_translatable_fields/translatable.css new file mode 100644 index 0000000..4a041a3 --- /dev/null +++ b/django_translatable_fields/static/django_translatable_fields/translatable.css @@ -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; +} \ No newline at end of file diff --git a/django_translatable_fields/static/django_translatable_fields/translatable.js b/django_translatable_fields/static/django_translatable_fields/translatable.js new file mode 100644 index 0000000..6cb9711 --- /dev/null +++ b/django_translatable_fields/static/django_translatable_fields/translatable.js @@ -0,0 +1,525 @@ +// Ensure modal HTML is added to the page only once +document.addEventListener('DOMContentLoaded', function() { + if (!document.getElementById('translateModal')) { + const modalHTML = ` + + `; + 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 = ` +
+ + +
+ `; + } else { + inputHtml = ``; + } + + console.log(`Debug: Generated input HTML for ${langCode}: ${inputHtml}`); + + bodyContent += ` +
+ + ${inputHtml} +
+ `; + }); + + 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 = ` + + + + + + + + + + + + + + + + + + `; + + // 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
+ if (!/<[^>]+>/.test(htmlContent)) { + htmlContent = htmlContent.replace(/\n/g, '
'); + } + + 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>/gi, '
'); + content = content.replace(/

\s*<\/p>/gi, ''); + + // Convert div elements to paragraphs (some browsers use divs) + content = content.replace(/]*)>/gi, ''); + content = content.replace(/<\/div>/gi, '

'); + + // Remove unnecessary attributes and clean up spacing + content = content.replace(/\s+/g, ' '); + content = content.replace(/> <'); + + // Ensure we don't have nested paragraphs + content = content.replace(/]*>]*>/gi, '

'); + content = content.replace(/<\/p><\/p>/gi, '

'); + + // 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 +} \ No newline at end of file diff --git a/django_translatable_fields/templates/django_translatable_fields/language_switcher.html b/django_translatable_fields/templates/django_translatable_fields/language_switcher.html new file mode 100644 index 0000000..77a0a3d --- /dev/null +++ b/django_translatable_fields/templates/django_translatable_fields/language_switcher.html @@ -0,0 +1,96 @@ +{% load translatable_tags %} +
+
+ {{ current_value }} +
+ + {% if available_languages|length > 1 %} +
+ + + +
+ {% endif %} +
+ + + + \ No newline at end of file diff --git a/django_translatable_fields/templatetags/__init__.py b/django_translatable_fields/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_translatable_fields/templatetags/translatable_tags.py b/django_translatable_fields/templatetags/translatable_tags.py new file mode 100644 index 0000000..7161210 --- /dev/null +++ b/django_translatable_fields/templatetags/translatable_tags.py @@ -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 %} + + + {{ product.name|translate }} + + + {{ product.name|translate:"de" }} + + + {% if product.name|has_translation:"fr" %} + French available! + {% endif %} + +Author: Holger Sielaff +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) \ No newline at end of file diff --git a/django_translatable_fields/widgets.py b/django_translatable_fields/widgets.py new file mode 100644 index 0000000..df60e84 --- /dev/null +++ b/django_translatable_fields/widgets.py @@ -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 +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('
') + + # 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''' + + + + ''') + + # 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'') + + 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'') + + + output.append('
') + + 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) \ No newline at end of file diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..d5863c8 --- /dev/null +++ b/example/README.md @@ -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. \ No newline at end of file diff --git a/example/__init__.py b/example/__init__.py new file mode 100644 index 0000000..23d79b6 --- /dev/null +++ b/example/__init__.py @@ -0,0 +1 @@ +# Example app for django-translatable-fields \ No newline at end of file diff --git a/example/admin.py b/example/admin.py new file mode 100644 index 0000000..a3b3c3f --- /dev/null +++ b/example/admin.py @@ -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',)} \ No newline at end of file diff --git a/example/models.py b/example/models.py new file mode 100644 index 0000000..ed66e05 --- /dev/null +++ b/example/models.py @@ -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}" \ No newline at end of file diff --git a/example/usage_examples.py b/example/usage_examples.py new file mode 100644 index 0000000..2bafbe3 --- /dev/null +++ b/example/usage_examples.py @@ -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() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d8673fe --- /dev/null +++ b/pyproject.toml @@ -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"] \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..0bdbd89 --- /dev/null +++ b/setup.py @@ -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() \ No newline at end of file