diff --git a/.gitignore b/.gitignore index b393d99..26abb08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ -.idea *.sqlite3 -result *.pyc *.pyo __pycache__ +*.DS_Store +*.egg* + +dist/ +docs/_build/ +.idea/ +node_modules/ +result diff --git a/translatable_fields/CHANGELOG.rst b/translatable_fields/CHANGELOG.rst new file mode 100644 index 0000000..126a25d --- /dev/null +++ b/translatable_fields/CHANGELOG.rst @@ -0,0 +1,7 @@ +Changelog +========= + +0.0.1 +----- + +* Initial release diff --git a/translatable_fields/LICENSE b/translatable_fields/LICENSE new file mode 100644 index 0000000..67ec320 --- /dev/null +++ b/translatable_fields/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2012 Selwin Ong + +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/translatable_fields/README.rst b/translatable_fields/README.rst new file mode 100644 index 0000000..9998a5a --- /dev/null +++ b/translatable_fields/README.rst @@ -0,0 +1,90 @@ +========================== +Django Translatable Fields +========================== + +Translatable model fields for Django with admin integration. Uses PostgreSQL JSONField. + +Installation +============ + + +* Add application in `settings.py` + +.. code:: python + + INSTALLED_APPS = ( + ... + 'translatable_fields', + ... + ) + +* Specify languages in `settings.py` + +.. code:: python + + # Internationalization + + LANGUAGE_CODE = 'en' + LANGUAGES = ( + ('en', 'English'), + ('ru', 'Русский') + ) + + +* Add `TranslatableField` model fields + +.. code:: python + + from django.db import models + from django.utils.translation import ugettext_lazy as _ + + from translatable_fields.models import TranslatableField + + + class Position(models.Model): + ... + title = TranslatableField( + verbose_name=_('title') + ) + description = TranslatableField( + verbose_name=_('description') + ) + ... + +* Create custom model admin form + +.. code:: python + + from django import forms + from django.contrib.postgres.forms import JSONField + from ckeditor_uploader.widgets import CKEditorUploadingWidget + + from careers.models.position import Position + from translatable_fields.widgets import TranslatableWidget + + + class PositionAdminForm(forms.ModelForm): + title = JSONField(widget=TranslatableWidget(widget=forms.TextInput)) + description = JSONField(widget=TranslatableWidget(widget=CKEditorUploadingWidget)) + + class Meta: + model = Position + fields = ( + ... + 'title', + 'description', + ... + ) + +* Create custom model admin with custom form + +.. code:: python + + from django.contrib import admin + + from careers.forms.admin.position import PositionAdminForm + + + class PositionAdmin(admin.ModelAdmin): + form = PositionAdminForm + diff --git a/translatable_fields/__init__.py b/translatable_fields/__init__.py new file mode 100644 index 0000000..901e511 --- /dev/null +++ b/translatable_fields/__init__.py @@ -0,0 +1 @@ +VERSION = "0.0.1" diff --git a/translatable_fields/models.py b/translatable_fields/models.py new file mode 100644 index 0000000..33c3bc2 --- /dev/null +++ b/translatable_fields/models.py @@ -0,0 +1,26 @@ +from django.contrib.postgres.fields import JSONField + +from translatable_fields.value import TranslatableValue + + +class TranslatableField(JSONField): + def from_db_value(self, value, *args, **kwargs): + if value is None: + return value + + instance = TranslatableValue() + instance.update(value) + + return instance + + def to_python(self, value): + if isinstance(value, TranslatableValue): + return value + + if value is None: + return value + + instance = TranslatableValue() + instance.update(value) + + return instance diff --git a/translatable_fields/static/translatable_fields/translatable_fields.css b/translatable_fields/static/translatable_fields/translatable_fields.css new file mode 100755 index 0000000..4575037 --- /dev/null +++ b/translatable_fields/static/translatable_fields/translatable_fields.css @@ -0,0 +1,47 @@ +.translatable_field { + display: inline-block; +} + +.translatable_field__tabs { + display: block; + margin: 0 0 6px 0; +} + +.translatable_field__tabs-item { + display: inline-block; + margin-left: 5px; + border-radius: 4px; + padding: 0 10px; + background: #79aec8; + color: #fff; + font-weight: 400; + opacity: 0.5; +} + +.translatable_field__tabs-item:first-child { + margin-left: 0; +} + +.translatable_field__tabs-item:hover { + background: #417690; + opacity: 1; +} + +.translatable_field__tabs-item label, .inline-group .translatable_field__tabs-item label { + width: auto; + font-size: 11px; + padding: 2px 0 1px 0; + text-decoration: none; + color: #fff; +} + +.translatable_field__tabs-item_active, +.translatable_field__tabs-item_active:hover { + background: #79aec8; + border-color: #79aec8; + opacity: 1; +} + +.translatable_field p.file-upload { + margin-left: 0; +} diff --git a/translatable_fields/static/translatable_fields/translatable_fields.js b/translatable_fields/static/translatable_fields/translatable_fields.js new file mode 100755 index 0000000..e922f34 --- /dev/null +++ b/translatable_fields/static/translatable_fields/translatable_fields.js @@ -0,0 +1,37 @@ +(function($) { + var syncTabs = function($container, lang) { + $container.find('.translatable_field__tabs-item label:contains("'+lang+'")').each(function(){ + $(this).parents('.translatable_field').find('.translatable_field__tabs-item').removeClass('translatable_field__tabs-item_active'); + $(this).parents('.translatable_field__tabs-item').addClass('translatable_field__tabs-item_active'); + $(this).parents('.translatable_field').find('.translatable_field__widgets-item').hide(); + $('#'+$(this).attr('for')).parents('.translatable_field__widgets-item').show(); + }); + }; + + $(function (){ + $('.translatable_field__widgets-item').hide(); + // set first tab as active + $('.translatable_field').each(function () { + $(this).find('.translatable_field__tabs-item:first').addClass('translatable_field__tabs-item_active'); + syncTabs($(this), $(this).find('.translatable_field__tabs-item:first label').text()); + }); + // try set active last selected tab + if (window.sessionStorage) { + var lang = window.sessionStorage.getItem('translatable-field-lang'); + if (lang) { + $('.translatable_field').each(function () { + syncTabs($(this), lang); + }); + } + } + + $('.translatable_field__tabs-item label').click(function(event) { + event.preventDefault(); + syncTabs($(this).parents('.translatable_field'), $(this).text()); + if (window.sessionStorage) { + window.sessionStorage.setItem('translatable-field-lang', $(this).text()); + } + return false; + }); + }); +})(django.jQuery); diff --git a/translatable_fields/templates/translatable_fields/multiwidget.html b/translatable_fields/templates/translatable_fields/multiwidget.html new file mode 100755 index 0000000..cc80bc6 --- /dev/null +++ b/translatable_fields/templates/translatable_fields/multiwidget.html @@ -0,0 +1,17 @@ +
+
+ {% for widget in widget.subwidgets %} +
+ +
+ {% endfor %} +
+
+ {% for widget in widget.subwidgets %} +
+{# {% include widget.template_name %}#} + {{ widget.html }} +
+ {% endfor %} +
+
diff --git a/translatable_fields/value.py b/translatable_fields/value.py new file mode 100644 index 0000000..141d6e2 --- /dev/null +++ b/translatable_fields/value.py @@ -0,0 +1,18 @@ +from django.conf import settings +from django.utils import translation + + +class TranslatableValue(dict): + def __str__(self): + language = translation.get_language() or settings.LANGUAGE_CODE + languages = [language] + + if len(self): + languages.append(list(self.keys())[0]) + + for lang_code in languages: + value = self.get(lang_code) + if value: + return value or "" + + return "" diff --git a/translatable_fields/widgets.py b/translatable_fields/widgets.py new file mode 100644 index 0000000..6461d28 --- /dev/null +++ b/translatable_fields/widgets.py @@ -0,0 +1,123 @@ +import json + +from django import forms +from django.conf import settings + + +class TranslatableWidget(forms.MultiWidget): + template_name = "translatable_fields/multiwidget.html" + widget = forms.Textarea + + class Media: + js = (settings.STATIC_URL + "translatable_fields/translatable_fields.js",) + css = { + "all": ( + settings.STATIC_URL + "translatable_fields/translatable_fields.css", + ) + } + + def __init__(self, widget=None, *args, **kwargs): + if widget: + self.widget = widget + + initial_widgets = [self.widget for _ in settings.LANGUAGES] + + super().__init__(initial_widgets, *args, **kwargs) + + for ((lang_code, lang_name), sub_widget) in zip( + settings.LANGUAGES, self.widgets + ): + sub_widget.attrs["lang"] = lang_code + sub_widget.lang_code = lang_code + sub_widget.lang_name = lang_name + + def decompress(self, value): + """ + Returns a list of decompressed values for the given compressed value. + The given value can be assumed to be valid, but not necessarily + non-empty. + """ + + if isinstance(value, str): + try: + value = json.loads(value) + except ValueError: + value = {} + + result = [] + for lang_code, _ in settings.LANGUAGES: + if value: + result.append(value.get(lang_code)) + else: + result.append(None) + + return result + + def value_from_datadict(self, data, files, name): + result = dict( + [ + ( + widget.lang_code, + widget.value_from_datadict(data, files, name + "_%s" % i), + ) + for i, widget in enumerate(self.widgets) + ] + ) + if all(map(lambda x: x == "", result.values())): + return "" + return result + + def get_context(self, name, value, attrs): + context = super(forms.MultiWidget, self).get_context(name, value, attrs) + if self.is_localized: + for widget in self.widgets: + widget.is_localized = self.is_localized + # value is a list of values, each corresponding to a widget + # in self.widgets. + if not isinstance(value, list): + value = self.decompress(value) + + final_attrs = context["widget"]["attrs"] + input_type = final_attrs.pop("type", None) + id_ = final_attrs.get("id") + subwidgets = [] + + for i, widget in enumerate(self.widgets): + if input_type is not None: + widget.input_type = input_type + widget_name = "%s_%s" % (name, i) + try: + widget_value = value[i] + except IndexError: + widget_value = None + if id_: + widget_attrs = final_attrs.copy() + widget_attrs["id"] = "%s_%s" % (id_, i) + else: + widget_attrs = final_attrs + widget_attrs = self.build_widget_attrs(widget, widget_value, widget_attrs) + widget_context = widget.get_context( + widget_name, widget_value, widget_attrs + )["widget"] + widget_context.update( + dict(lang_code=widget.lang_code, lang_name=widget.lang_name) + ) + + widget_context["html"] = widget.render( + widget_name, widget_value, widget_attrs + ) + subwidgets.append(widget_context) + context["widget"]["subwidgets"] = subwidgets + + return context + + @staticmethod + def build_widget_attrs(widget, value, attrs): + attrs = dict(attrs) # Copy attrs to avoid modifying the argument. + + if ( + not widget.use_required_attribute(value) or not widget.is_required + ) and "required" in attrs: + del attrs["required"] + + return attrs