├── docs ├── .gitkeep ├── images │ ├── widget.png │ └── field_with_langs.jpeg └── diagrams │ ├── struct.png │ └── struct.uml ├── tof ├── tests │ ├── __init__.py │ └── tests.py ├── management │ ├── __init__.py │ └── commands │ │ └── benchmark.py ├── migrations │ ├── __init__.py │ ├── 0002_auto_20191118_1000.py │ ├── 0003_auto_20191127_0907.py │ └── 0001_initial.py ├── views.py ├── fixtures │ └── languages_data.json.gz ├── __init__.py ├── apps.py ├── static │ └── tof │ │ ├── js │ │ ├── translation_inline.js │ │ ├── translatable_fields_widget.js │ │ ├── translation_form.js │ │ └── translatable_fields_form.js │ │ └── css │ │ └── style.css ├── managers.py ├── settings.py ├── templates │ └── tof │ │ └── multiwidget.html ├── utils.py ├── fields.py ├── decorators.py ├── forms.py ├── admin.py └── models.py ├── example_project ├── main │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0003_vintage_description.py │ │ ├── 0002_auto_20191119_1134.py │ │ ├── 0004_winery.py │ │ └── 0001_initial.py │ ├── apps.py │ ├── templates │ │ └── main │ │ │ └── index.html │ ├── views.py │ ├── models.py │ └── admin.py ├── example │ ├── __init__.py │ ├── asgi.py │ ├── wsgi.py │ ├── urls.py │ └── settings.py ├── pyproject.toml ├── manage.py └── poetry.lock ├── AUTHORS ├── CONTRIBUTING.md ├── .editorconfig ├── .github └── workflows │ ├── publish.yml │ ├── style.yml │ └── tests.yml ├── setup.cfg ├── LICENSE ├── pyproject.toml ├── .gitignore ├── wiki_django_tof.md ├── README.md └── README_ru.md /docs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tof/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tof/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tof/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example_project/main/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example_project/example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example_project/main/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tof/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /docs/images/widget.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mom1/django-tof/HEAD/docs/images/widget.png -------------------------------------------------------------------------------- /docs/diagrams/struct.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mom1/django-tof/HEAD/docs/diagrams/struct.png -------------------------------------------------------------------------------- /docs/images/field_with_langs.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mom1/django-tof/HEAD/docs/images/field_with_langs.jpeg -------------------------------------------------------------------------------- /tof/fixtures/languages_data.json.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mom1/django-tof/HEAD/tof/fixtures/languages_data.json.gz -------------------------------------------------------------------------------- /example_project/main/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MainConfig(AppConfig): 5 | name = 'main' 6 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Alphabetical list of contributors: 2 | 3 | * Ракитин Алексей (maybejke) 4 | * Stolpasov Maksim (MaxST) 5 | 6 | With support from: 7 | * Maxim Danilov (Danilovmy) 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Fork the original repository at: 4 | Send a merge request with your changes in separate branch 5 | -------------------------------------------------------------------------------- /tof/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Author: MaxST 3 | # @Date: 2019-10-28 19:01:21 4 | # @Last Modified by: MaxST 5 | # @Last Modified time: 2019-11-17 15:10:42 6 | default_app_config = 'tof.apps.TofConfig' 7 | -------------------------------------------------------------------------------- /example_project/main/templates/main/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | index 5 | 6 | 7 |

Current language is:

8 |

{{ request.LANGUAGE_CODE }}

9 | {% for wine in wines %} 10 | {{ wine.title }}|{{ wine.description }}
11 | {% endfor %} 12 | 13 | 14 | -------------------------------------------------------------------------------- /example_project/main/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Author: MaxST 3 | # @Date: 2019-10-28 20:30:42 4 | # @Last Modified by: MaxST 5 | # @Last Modified time: 2019-11-18 15:11:01 6 | from django.views.generic import TemplateView 7 | 8 | from .models import Wine 9 | 10 | 11 | class Index(TemplateView): 12 | extra_context = {'wines': Wine.objects.all()} 13 | template_name = 'main/index.html' 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Check http://editorconfig.org for more information 2 | # This is the main config file for this project: 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | end_of_line = lf 9 | indent_style = space 10 | insert_final_newline = true 11 | indent_size = 2 12 | 13 | [*.{py,kv}] 14 | indent_size = 4 15 | 16 | [*.{md,rst,in}] 17 | indent_style = ignore 18 | indent_size = ignore 19 | -------------------------------------------------------------------------------- /example_project/example/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for example project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/dev/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /example_project/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/dev/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example_project/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "example_project" 3 | version = "0.3.0" 4 | description = "" 5 | authors = ["MaxST "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.6" 9 | django = "^3.0" 10 | django-tof = {path = "..", develop = true} 11 | 12 | [tool.poetry.dev-dependencies] 13 | ipdb = "*" 14 | django-debug-toolbar = "*" 15 | mixer = "^6.1.3" 16 | coverage = "^5.0" 17 | 18 | [build-system] 19 | requires = ["poetry>=0.12"] 20 | build-backend = "poetry.masonry.api" 21 | -------------------------------------------------------------------------------- /example_project/main/migrations/0003_vintage_description.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0rc1 on 2019-11-27 09:07 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('main', '0002_auto_20191119_1134'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='vintage', 15 | name='description', 16 | field=models.TextField(blank=True, null=True, verbose_name='Description'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tof/migrations/0002_auto_20191118_1000.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0b1 on 2019-11-18 10:00 2 | from django.core.management import call_command 3 | from django.db import migrations 4 | 5 | 6 | def load_fixture(apps, schema_editor): 7 | call_command('loaddata', 'languages_data.json.gz') 8 | 9 | 10 | def del_fixture(apps, schema_editor): 11 | language = apps.get_model('tof', 'Language') 12 | language.objects.all().delete() 13 | 14 | 15 | class Migration(migrations.Migration): 16 | 17 | dependencies = [ 18 | ('tof', '0001_initial'), 19 | ] 20 | 21 | operations = [ 22 | migrations.RunPython(load_fixture, reverse_code=del_fixture), 23 | ] 24 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | publish: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - name: Set up Python 11 | uses: actions/setup-python@v1 12 | with: 13 | python-version: "3.x" 14 | - name: Install dependencies 15 | run: | 16 | python -m pip install --upgrade pip 17 | python -m pip install poetry 18 | - name: Build and publish 19 | env: 20 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 21 | run: | 22 | python -m poetry publish --build -u __token__ -p $PYPI_TOKEN 23 | -------------------------------------------------------------------------------- /example_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | max-line-length = 128 6 | statistics = False 7 | 8 | # Excluding some directories: 9 | exclude = 10 | .git 11 | __pycache__ 12 | migrations 13 | manage.py 14 | example_project 15 | 16 | # Flake plugins: 17 | inline-quotes = single 18 | accept-encodings = utf-8 19 | 20 | # Disable some pydocstyle checks: 21 | ignore = D100, D104, D106, D401, X100, W504 22 | 23 | 24 | [isort] 25 | include_trailing_comma=true 26 | multi_line_output=5 27 | indent=4 28 | combine_as_imports=true 29 | use_parentheses=true 30 | balanced_wrapping=true 31 | skip_glob=*/tof/**/migrations/*.py,*/tests/**/*.py 32 | 33 | 34 | [yapf] 35 | based_on_style = pep8 36 | column_limit = 128 37 | i18n_comment=NOQA 38 | -------------------------------------------------------------------------------- /.github/workflows/style.yml: -------------------------------------------------------------------------------- 1 | name: Check style 2 | on: [push] 3 | jobs: 4 | style: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v1 8 | - name: Set up Python 9 | uses: actions/setup-python@v1 10 | with: 11 | python-version: "3.x" 12 | - name: Install dependencies 13 | run: | 14 | python -m pip install --upgrade pip 15 | pip install flake8 flake8-broken-line flake8-bugbear flake8-builtins flake8-coding flake8-commas flake8-comprehensions flake8-eradicate flake8-quotes pep8-naming darglint 16 | - name: Lint with flake8 17 | run: | 18 | # stop the build if there are Python syntax errors or undefined names 19 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 20 | # exit-zero treats all errors as warnings. 21 | flake8 . --count --exit-zero --statistics 22 | -------------------------------------------------------------------------------- /tof/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Author: MaxST 3 | # @Date: 2019-10-29 10:05:01 4 | # @Last Modified by: MaxST 5 | # @Last Modified time: 2019-11-22 14:24:05 6 | import sys 7 | 8 | from django.apps import AppConfig 9 | from django.db import connection 10 | 11 | 12 | class TofConfig(AppConfig): 13 | """Класс настроек приложения. 14 | 15 | Тут будем при старте сервера кэшировать список переводимых полей 16 | 17 | Attributes: 18 | name: Имя приложения 19 | """ 20 | name = 'tof' 21 | 22 | def ready(self): 23 | # Exception if did not make migration 24 | if connection.introspection.table_names(): 25 | for arg in ('migrate', 'makemigrations'): 26 | if arg in sys.argv: 27 | return # pragma: no cover 28 | for field in self.models_module.TranslatableField.objects.all(): 29 | field.add_translation_to_class() 30 | -------------------------------------------------------------------------------- /example_project/main/migrations/0002_auto_20191119_1134.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0b1 on 2019-11-19 11:34 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('main', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterModelOptions( 15 | name='wine', 16 | options={'ordering': ('sort',), 'verbose_name': 'wine', 'verbose_name_plural': 'wine-plural'}, 17 | ), 18 | migrations.CreateModel( 19 | name='Vintage', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('year', models.IntegerField(default=0, verbose_name='Year')), 23 | ('wine', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vintages', to='main.Wine')), 24 | ], 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /example_project/main/migrations/0004_winery.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0 on 2019-12-10 19:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('main', '0003_vintage_description'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Winery', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('title', models.CharField(blank=True, default='', max_length=250, verbose_name='Title')), 18 | ('description', models.TextField(blank=True, null=True, verbose_name='Description')), 19 | ('sort', models.IntegerField(blank=True, default=0, null=True, verbose_name='Sort')), 20 | ], 21 | options={ 22 | 'verbose_name': 'Winery', 23 | 'verbose_name_plural': 'Winery-plural', 24 | 'ordering': ('sort',), 25 | }, 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test tof 2 | on: [push] 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | max-parallel: 4 8 | matrix: 9 | python-version: [3.6, 3.7, 3.8] 10 | fail-fast: false 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v1 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | python -m pip install poetry 21 | cd ./example_project/ 22 | python -m poetry install 23 | - name: Test with django test 24 | run: | 25 | cd ./example_project/ 26 | python -m poetry run coverage run --source='tof' manage.py test tof 27 | python -m poetry run coverage xml 28 | - name: Codecov 29 | uses: codecov/codecov-action@v1.0.5 30 | with: 31 | token: ${{ secrets.CODECOV_TOKEN }} 32 | file: ./example_project/coverage.xml 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Stolpasov Maksim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tof/static/tof/js/translation_inline.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: MaxST 3 | * @Date: 2019-12-04 16:33:59 4 | * @Last Modified by: MaxST 5 | * @Last Modified time: 2019-12-15 17:00:55 6 | */ 7 | (function ($) { 8 | "use strict"; 9 | $(document).ready(function () { 10 | var fld = $(".field-field .admin-autocomplete").not("[name*=__prefix__]"); 11 | fld.attr("readonly", true); 12 | fld.on("select2:opening", function (e) { 13 | if ($(this).attr("readonly") || $(this).is(":hidden")) { 14 | e.preventDefault(); 15 | } 16 | }); 17 | fld.each(function () { 18 | if ($(this).is("[readonly]")) { 19 | $(this) 20 | .closest(".form-group") 21 | .find("span.select2-selection__choice__remove") 22 | .first() 23 | .remove(); 24 | $(this) 25 | .closest(".form-group") 26 | .find("li.select2-search") 27 | .first() 28 | .remove(); 29 | $(this) 30 | .closest(".form-group") 31 | .find("span.select2-selection__clear") 32 | .first() 33 | .remove(); 34 | } 35 | }); 36 | }); 37 | }(jQuery)); 38 | -------------------------------------------------------------------------------- /tof/managers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Author: MaxST 3 | # @Date: 2019-11-17 15:02:55 4 | # @Last Modified by: MaxST 5 | # @Last Modified time: 2019-11-19 16:40:49 6 | from django.db import models 7 | 8 | from .decorators import tof_filter, tof_prefetch 9 | 10 | 11 | class DecoratedMixIn: 12 | @tof_filter # noqa 13 | def filter(self, *args, **kwargs): # noqa 14 | return super().filter(*args, **kwargs) 15 | 16 | @tof_filter # noqa 17 | def exclude(self, *args, **kwargs): 18 | return super().exclude(*args, **kwargs) 19 | 20 | @tof_filter # noqa 21 | def get(self, *args, **kwargs): 22 | return super().get(*args, **kwargs) 23 | 24 | 25 | class TranslationsQuerySet(DecoratedMixIn, models.QuerySet): 26 | pass 27 | 28 | 29 | class TranslationManager(DecoratedMixIn, models.Manager): 30 | default_name = 'trans_objects' 31 | _queryset_class = TranslationsQuerySet 32 | 33 | def __init__(self, name=None): 34 | self.default_name = name or self.default_name 35 | super().__init__() 36 | 37 | @tof_prefetch() 38 | def get_queryset(self, *args, **kwargs): 39 | return super().get_queryset(*args, **kwargs) 40 | -------------------------------------------------------------------------------- /tof/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Author: MaxST 3 | # @Date: 2019-10-29 17:39:13 4 | # @Last Modified by: MaxST 5 | # @Last Modified time: 2019-11-26 11:57:39 6 | from django.conf import settings 7 | from django.core.exceptions import ImproperlyConfigured 8 | 9 | DEFAULT_LANGUAGE = getattr(settings, 'DEFAULT_LANGUAGE', 'en') or 'en' 10 | 11 | SITE_ID = getattr(settings, 'SITE_ID', None) 12 | 13 | # need support siteid 14 | # maybe beter use information about neighbors 15 | FALLBACK_LANGUAGES = { 16 | SITE_ID: ('en', 'de', 'ru'), 17 | 'fr': ('nl', ), 18 | } 19 | 20 | FALLBACK_LANGUAGES = getattr(settings, 'FALLBACK_LANGUAGES', FALLBACK_LANGUAGES) 21 | 22 | if not isinstance(FALLBACK_LANGUAGES, dict): 23 | raise ImproperlyConfigured('FALLBACK_LANGUAGES is not dict') # pragma: no cover 24 | 25 | for item in FALLBACK_LANGUAGES.values(): 26 | if not isinstance(item, (list, tuple)): 27 | raise ImproperlyConfigured('FALLBACK_LANGUAGES`s values must list ot tuple') # pragma: no cover 28 | 29 | # can be '__all__', 'current', ['en', 'de'], {'en', ('en', 'de', 'ru')} 30 | DEFAULT_FILTER_LANGUAGE = getattr(settings, 'DEFAULT_FILTER_LANGUAGE', 'current') 31 | 32 | CHANGE_DEFAULT_MANAGER = getattr(settings, 'CHANGE_DEFAULT_MANAGER', True) 33 | -------------------------------------------------------------------------------- /tof/templates/tof/multiwidget.html: -------------------------------------------------------------------------------- 1 | {% load i18n static %} 2 | {% spaceless %} 3 | {% get_current_language as LANGUAGE_CODE %} 4 | {% with widget.attrs.id as common_id %} 5 |
6 | {% for widget in widget.subwidgets %} 7 | 8 | 11 |
12 | {% include widget.template_name %} 13 |
14 | {% endfor %} 15 | 20 | 21 |
22 |
23 | {% endwith %} 24 | {% endspaceless %} 25 | -------------------------------------------------------------------------------- /example_project/main/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0b1 on 2019-10-29 13:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Wine', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('title', models.CharField(blank=True, default='', max_length=250, verbose_name='Title')), 19 | ('description', models.TextField(blank=True, null=True, verbose_name='Description')), 20 | ('active', models.BooleanField(default=False, verbose_name='Active')), 21 | ('sort', models.IntegerField(blank=True, default=0, null=True, verbose_name='Sort')), 22 | ('temperature_from', models.FloatField(blank=True, help_text='in ° C', null=True, verbose_name='Temperature_from')), 23 | ('temperature_to', models.FloatField(blank=True, help_text='in ° C', null=True, verbose_name='Temperature_to')), 24 | ], 25 | options={ 26 | 'verbose_name': 'wine', 27 | 'verbose_name_plural': 'wine-plural', 28 | 'ordering': ('title',), 29 | }, 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /tof/static/tof/css/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: MaxST 3 | * @Date: 2019-11-26 13:42:30 4 | * @Last Modified by: MaxST 5 | * @Last Modified time: 2019-11-29 14:51:37 6 | */ 7 | .itab { 8 | display: none; 9 | opacity: 0; 10 | position: absolute; 11 | } 12 | .tabbed-area { 13 | overflow: hidden; 14 | display: flex; 15 | flex-wrap: wrap; 16 | padding: 0; 17 | } 18 | label.ltab { 19 | text-align: center; 20 | outline: none; 21 | border: 1px solid #CCCCCC; 22 | background: #eeeeee repeat-x; 23 | border-bottom-width: 0; 24 | color: #666666; 25 | padding: 4px 10px; 26 | margin-top: 2px; 27 | -moz-border-radius-topright: 4px; 28 | -webkit-border-top-right-radius: 4px; 29 | -moz-border-radius-topleft: 4px; 30 | -webkit-border-top-left-radius: 4px; 31 | border-top-left-radius: 4px; 32 | border-top-right-radius: 4px; 33 | display: block; 34 | width: 18px; 35 | } 36 | .ltab:hover { 37 | background-color: #ddd; 38 | cursor: pointer; 39 | } 40 | .itab:checked + .ltab { 41 | background: #7CA0C7 repeat-x; 42 | color: #fff; 43 | padding: 6px 10px 4px; 44 | margin-top: 0; 45 | } 46 | .tab { 47 | display: none; 48 | order: 99; 49 | flex-grow: 1; 50 | width: 100%; 51 | } 52 | .itab:checked + .ltab + .tab { 53 | display: block; 54 | } 55 | .related-widget-wrapper-link.add-language, 56 | .related-widget-wrapper-link.add-language:hover { 57 | text-decoration: initial; 58 | opacity: .8; 59 | color: initial; 60 | } 61 | -------------------------------------------------------------------------------- /example_project/example/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """example URL Configuration 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/dev/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | from django.conf import settings 18 | from django.conf.urls.i18n import i18n_patterns # for local 19 | from django.contrib import admin 20 | from django.shortcuts import redirect 21 | from django.urls import include, path 22 | from main.views import Index 23 | 24 | urlpatterns = i18n_patterns( 25 | path('admin/', admin.site.urls), 26 | path('', Index.as_view()), 27 | ) 28 | 29 | if settings.DEBUG: 30 | if 'debug_toolbar' in settings.INSTALLED_APPS: 31 | import debug_toolbar 32 | urlpatterns = [ 33 | path('__debug__/', include(debug_toolbar.urls)), 34 | ] + urlpatterns 35 | else: 36 | urlpatterns = i18n_patterns(path('admin/password_change/', lambda x: redirect('admin:index'))) + urlpatterns 37 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = 'django-tof' 3 | version = "0.3.2" 4 | description = 'Translate fields of django models without having to restart the server, without changing the model code.' 5 | authors = ['MaxST '] 6 | license = 'MIT' 7 | repository = 'https://github.com/mom1/django-tof' 8 | homepage = 'https://github.com/mom1/django-tof/wiki/django-tof' 9 | keywords = ['translations', 'translate', 'django', 'model'] 10 | readme = 'README.md' 11 | classifiers = [ 12 | 'Development Status :: 5 - Production/Stable', 13 | 'Environment :: Web Environment', 14 | 'Framework :: Django', 15 | 'Framework :: Django :: 2.2', 16 | 'Framework :: Django :: 3.0', 17 | 'Intended Audience :: Developers', 18 | 'License :: OSI Approved :: MIT License', 19 | 'Operating System :: OS Independent', 20 | 'Programming Language :: Python', 21 | 'Programming Language :: Python :: 3.6', 22 | 'Programming Language :: Python :: 3.7', 23 | 'Programming Language :: Python :: 3.8', 24 | 'Topic :: Database', 25 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 26 | 'Topic :: Text Processing :: Linguistic' 27 | ] 28 | include = ['AUTHORS'] 29 | exclude = ['setup.cfg'] 30 | packages = [ 31 | { include = 'tof' }, 32 | ] 33 | 34 | [tool.poetry.dependencies] 35 | python = '^3.6' 36 | django = '>=2.2' 37 | 38 | [tool.poetry.dev-dependencies] 39 | 40 | [build-system] 41 | requires = ['poetry>=0.12'] 42 | build-backend = 'poetry.masonry.api' 43 | -------------------------------------------------------------------------------- /tof/migrations/0003_auto_20191127_0907.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0rc1 on 2019-11-27 09:07 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('contenttypes', '0002_remove_content_type_name'), 11 | ('tof', '0002_auto_20191118_1000'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='translatablefield', 17 | name='content_type', 18 | field=models.ForeignKey(limit_choices_to=models.Q(_negated=True, app_label='tof'), on_delete=django.db.models.deletion.CASCADE, related_name='translatablefields', to='contenttypes.ContentType'), 19 | ), 20 | migrations.AlterField( 21 | model_name='translation', 22 | name='content_type', 23 | field=models.ForeignKey(limit_choices_to=models.Q(_negated=True, app_label='tof'), on_delete=django.db.models.deletion.CASCADE, related_name='translations', to='contenttypes.ContentType'), 24 | ), 25 | migrations.AlterField( 26 | model_name='translation', 27 | name='field', 28 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='translations', to='tof.TranslatableField'), 29 | ), 30 | migrations.AlterField( 31 | model_name='translation', 32 | name='lang', 33 | field=models.ForeignKey(limit_choices_to=models.Q(is_active=True), on_delete=django.db.models.deletion.CASCADE, related_name='translations', to='tof.Language'), 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /tof/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Author: MaxST 3 | # @Date: 2019-10-30 14:19:55 4 | # @Last Modified by: MaxST 5 | # @Last Modified time: 2019-12-01 15:56:34 6 | from django.utils.html import html_safe 7 | from django.utils.translation import get_language 8 | from .settings import DEFAULT_LANGUAGE, FALLBACK_LANGUAGES, SITE_ID 9 | 10 | 11 | @html_safe 12 | class TranslatableText(str): 13 | def __getattr__(self, attr): 14 | if len(attr) == 2: 15 | attrs = vars(self) 16 | for lang in self.get_fallback_languages(attr): 17 | if lang in attrs: 18 | return attrs[lang] 19 | return attrs.get('_origin') or '' 20 | raise AttributeError(attr) 21 | 22 | def __getitem__(self, key): 23 | return str(self)[key] 24 | 25 | def __str__(self): 26 | return getattr(self, self.get_lang(), '') 27 | 28 | def __repr__(self): 29 | return f"'{self}'" 30 | 31 | def __eq__(self, other): 32 | return str(self) == str(other) 33 | 34 | def __add__(self, other): 35 | return f'{self}{other}' 36 | 37 | def __radd__(self, other): 38 | return f'{other}{self}' 39 | 40 | def __bool__(self): 41 | return bool(vars(self)) 42 | 43 | @staticmethod 44 | def get_lang(): 45 | lang, *_ = get_language().partition('-') 46 | return lang 47 | 48 | def get_fallback_languages(self, attr): 49 | for fallback in (FALLBACK_LANGUAGES.get(attr) or (), FALLBACK_LANGUAGES.get(SITE_ID) or ()): 50 | yield from (lang for lang in fallback if lang != attr) 51 | yield DEFAULT_LANGUAGE 52 | -------------------------------------------------------------------------------- /example_project/main/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Author: MaxST 3 | # @Date: 2019-10-28 20:30:42 4 | # @Last Modified by: MaxST 5 | # @Last Modified time: 2019-12-10 22:48:00 6 | from django.db import models 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | 10 | class Winery(models.Model): 11 | class Meta: 12 | verbose_name = _('Winery') 13 | verbose_name_plural = _('Winery-plural') 14 | ordering = ('sort', ) 15 | 16 | title = models.CharField(_('Title'), max_length=250, default='', blank=True, null=False) 17 | description = models.TextField(_('Description'), null=True, blank=True) 18 | sort = models.IntegerField(_('Sort'), default=0, blank=True, null=True) 19 | 20 | 21 | class Wine(models.Model): 22 | class Meta: 23 | verbose_name = _('wine') 24 | verbose_name_plural = _('wine-plural') 25 | ordering = ('sort', ) 26 | 27 | title = models.CharField(_('Title'), max_length=250, default='', blank=True, null=False) 28 | description = models.TextField(_('Description'), null=True, blank=True) 29 | active = models.BooleanField(_('Active'), default=False) 30 | sort = models.IntegerField(_('Sort'), default=0, blank=True, null=True) 31 | 32 | temperature_from = models.FloatField(_('Temperature_from'), help_text=_('in ° C'), null=True, blank=True) 33 | temperature_to = models.FloatField(_('Temperature_to'), help_text=_('in ° C'), null=True, blank=True) 34 | 35 | 36 | class Vintage(models.Model): 37 | wine = models.ForeignKey(Wine, related_name='vintages', on_delete=models.CASCADE) 38 | year = models.IntegerField(_('Year'), default=0) 39 | description = models.TextField(_('Description'), null=True, blank=True) 40 | -------------------------------------------------------------------------------- /tof/management/commands/benchmark.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Author: MaxST 3 | # @Date: 2019-11-25 13:13:55 4 | # @Last Modified by: MaxST 5 | # @Last Modified time: 2019-11-25 15:24:50 6 | import timeit 7 | 8 | from django.contrib.contenttypes.models import ContentType 9 | from django.core.management.base import BaseCommand 10 | from django.utils import translation 11 | from main.models import Wine 12 | from mixer.backend.django import mixer 13 | from tof.models import TranslatableField 14 | 15 | 16 | class Command(BaseCommand): 17 | def handle(self, *args, **options): 18 | n = 1000 19 | nruns = 10 20 | translation.activate('it') 21 | 22 | ct = ContentType.objects.get_for_model(Wine) 23 | TranslatableField.objects.all().delete() 24 | fld = TranslatableField.objects.create(name='title', title='Title', content_type=ct) 25 | fld.save() 26 | Wine.objects.all().delete() 27 | some_models = mixer.cycle(n).blend(Wine, title=mixer.sequence('Wine {0}')) 28 | with translation.override('it'): 29 | for instance in some_models: 30 | instance.title = 'it ' + instance.title 31 | instance.save() 32 | 33 | print( 34 | 'TOF', 35 | timeit.timeit(""" 36 | for m in Wine.objects.all(): 37 | if not m.title.it.startswith('it'): 38 | raise ValueError(m.title) 39 | """, 40 | globals=globals(), 41 | number=1)) 42 | print( 43 | 'TOF', 44 | timeit.timeit(""" 45 | for m in Wine.objects.all(): 46 | if not m.title.it.startswith('it'): 47 | raise ValueError(m.title) 48 | """, 49 | globals=globals(), 50 | number=nruns) / nruns) 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | pip-wheel-metadata/ 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | db.sqlite3-journal 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Environments 87 | .env 88 | .venv 89 | env/ 90 | venv/ 91 | ENV/ 92 | env.bak/ 93 | venv.bak/ 94 | 95 | # pycharm 96 | .idea/ 97 | 98 | # Spyder project settings 99 | .spyderproject 100 | .spyproject 101 | 102 | # Rope project settings 103 | .ropeproject 104 | 105 | # mkdocs documentation 106 | /site 107 | 108 | # mypy 109 | .mypy_cache/ 110 | -------------------------------------------------------------------------------- /example_project/main/admin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # @Author: MaxST 3 | # @Date: 2019-10-28 20:30:42 4 | # @Last Modified by: MaxST 5 | # @Last Modified time: 2019-12-10 22:54:21 6 | from django.contrib import admin 7 | from tof.admin import TofAdmin, TranslationTabularInline 8 | from tof.decorators import tof_prefetch 9 | 10 | from .models import Vintage, Wine, Winery 11 | 12 | 13 | @admin.register(Winery) 14 | class WineryAdmin(TofAdmin): 15 | """Example translatable field №3 16 | 17 | This class is example where you can see Tabular inline 18 | 19 | Attributes: 20 | list_display: [description] 21 | search_fields: [description] 22 | inlines 23 | """ 24 | list_display = ('title', 'description', 'sort') 25 | search_fields = ('title', ) 26 | inlines = (TranslationTabularInline, ) 27 | 28 | 29 | @admin.register(Wine) 30 | class WineAdmin(TofAdmin): 31 | """Example translatable field №2 32 | 33 | This class is example where translatable field save values to all added languages 34 | 35 | Attributes: 36 | list_display: [description] 37 | search_fields: [description] 38 | form: [description] 39 | """ 40 | list_display = ('title', 'description', 'active', 'sort') 41 | search_fields = ('title', ) 42 | only_current_lang = ('description', ) 43 | 44 | 45 | @admin.register(Vintage) 46 | class VintageAdmin(admin.ModelAdmin): 47 | """Example translatable field №1 48 | 49 | This class is example where translatable field save value only in current language 50 | 51 | Attributes: 52 | list_display: [description] 53 | """ 54 | list_display = search_fields = ('wine__title', 'year', 'description') 55 | 56 | def wine__title(self, obj, *args, **kwargs): 57 | return obj.wine.title 58 | 59 | @tof_prefetch('wine') 60 | def get_queryset(self, request): 61 | return super().get_queryset(request) 62 | -------------------------------------------------------------------------------- /tof/static/tof/js/translatable_fields_widget.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: MaxST 3 | * @Date: 2019-11-26 13:42:43 4 | * @Last Modified by: MaxST 5 | * @Last Modified time: 2019-12-15 17:14:38 6 | */ 7 | (function ($) { 8 | "use strict"; 9 | function dismissRelatedLookupPopupLang(win, chosenId) { 10 | $ = django.jQuery; 11 | var name = windowname_to_id(win.name); 12 | var elem = document.getElementById(name); 13 | if (elem.className.indexOf("vManyToManyRawIdAdminField") !== -1 && elem.value) { 14 | elem.value += "," + chosenId; 15 | } 16 | if (elem.className.indexOf("itab") !== -1 && elem.value) { 17 | var newItab = $(".itab", $(elem.parentNode)).first().clone(); 18 | var newLtab = $(".ltab", $(elem.parentNode)).first().clone(); 19 | var newTab = $(".tab", $(elem.parentNode)).first().clone(); 20 | var arrId = newItab.attr("id").split("_"); 21 | arrId[0] = chosenId; 22 | arrId[arrId.length - 1] = chosenId; 23 | var additionalId = arrId.join("_"); 24 | var oldLabel = $('.ltab[for="' + additionalId + '"]'); 25 | if (oldLabel.length === 0) { 26 | newItab.attr("id", additionalId); 27 | newLtab.attr("for", additionalId); 28 | newLtab.text(chosenId); 29 | newTab.children().attr({id: arrId.slice(1).join("_"), name: arrId.slice(2).join("_"), value: "", lang: chosenId}).text("").val(""); 30 | var destination = ".tabbed-area._" + newItab.attr("name") + " .add-tab"; 31 | newItab.insertBefore(destination); 32 | newLtab.insertBefore(destination); 33 | newTab.insertBefore(destination); 34 | newLtab.click(); 35 | } else { 36 | oldLabel.click(); 37 | } 38 | } else { 39 | document.getElementById(name).value = chosenId; 40 | } 41 | win.close(); 42 | } 43 | $(document).ready(function () { 44 | window.dismissRelatedLookupPopup = dismissRelatedLookupPopupLang; 45 | }); 46 | })(jQuery); 47 | -------------------------------------------------------------------------------- /docs/diagrams/struct.uml: -------------------------------------------------------------------------------- 1 | @startuml 2 | package models.py <> { 3 | class Translations { 4 | + content_type 5 | + object_id 6 | + {field} content_object : GenericForeignKey() 7 | + field 8 | + lang 9 | + value 10 | } 11 | 12 | abstract class TranslationsFieldsMixin { 13 | + {field} _translations : GenericRelation('Translations') 14 | + __init__() 15 | # {method} _all_translations : cached_property Dict 16 | + save() 17 | + _add_deferred_translated_field(name) : classmethod 18 | + _del_deferred_translated_field(name) : classmethod 19 | } 20 | 21 | class TranslatableFields { 22 | + name 23 | + title 24 | + content_type 25 | + save() 26 | + delete() 27 | } 28 | 29 | class Language { 30 | + iso_639_1 31 | + iso_639_2T 32 | + iso_639_2B 33 | + iso_639_3 34 | + family 35 | } 36 | Translations -up-> Language : "lang" 37 | Translations -down-> TranslatableFields 38 | TranslationsFieldsMixin -> Translations 39 | } 40 | package apps.py <>{ 41 | class TofConfig(AppConfig) { 42 | + name = 'tof' 43 | + ready() 44 | } 45 | } 46 | 47 | package query_utils.py <>{ 48 | class DeferredTranslatedAttribute { 49 | field 50 | __init__(field) 51 | __get__(instance) 52 | get_translation(instance=None, field_name=None, language=None) 53 | get_fallback_languages(lang) : lru_cache(maxsize=32) 54 | get_lang(is_obj=False) 55 | get_field_name(ct=None) 56 | get_trans_field_name() 57 | __set__(instance, value) 58 | save(instance) 59 | __delete__(instance) 60 | } 61 | } 62 | 63 | package other_django_apps <>{ 64 | class SomeModel{ 65 | + title 66 | + description 67 | } 68 | } 69 | 70 | note right of SomeModel : Динамически наследуется в\napps.py TofConfig.ready() 71 | 72 | TranslationsFieldsMixin <|-- SomeModel 73 | TranslationsFieldsMixin "1" *-right- "1..*" DeferredTranslatedAttribute : "_field_tof" 74 | Translations --> SomeModel : "content_type" 75 | TranslatableFields --> SomeModel : "content_type" 76 | @enduml 77 | -------------------------------------------------------------------------------- /tof/static/tof/js/translation_form.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: MaxST 3 | * @Date: 2019-11-09 13:52:25 4 | * @Last Modified by: MaxST 5 | * @Last Modified time: 2019-12-15 17:15:01 6 | */ 7 | (function ($) { 8 | "use strict"; 9 | window.generic_view_json = function (url, text) { 10 | var $drop = $("#id_object_id"); 11 | var $select = $drop; 12 | var value = $drop.val(); 13 | if (! $select.parent().is(".related-widget-wrapper")) { 14 | $select = $('