├── synchro
├── management
│ ├── __init__.py
│ └── commands
│ │ ├── __init__.py
│ │ └── synchronize.py
├── __init__.py
├── core.py
├── urls.py
├── test_urls.py
├── apps.py
├── signals.py
├── templates
│ └── synchro.html
├── views.py
├── models.py
├── locale
│ ├── es
│ │ └── LC_MESSAGES
│ │ │ └── django.po
│ ├── fr
│ │ └── LC_MESSAGES
│ │ │ └── django.po
│ ├── pl
│ │ └── LC_MESSAGES
│ │ │ └── django.po
│ └── de
│ │ └── LC_MESSAGES
│ │ └── django.po
├── handlers.py
├── settings.py
├── utility.py
└── tests.py
├── AUTHORS
├── MANIFEST.in
├── .gitignore
├── LICENSE
├── setup.py
├── runtests.py
└── README.rst
/synchro/management/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/synchro/management/commands/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/synchro/__init__.py:
--------------------------------------------------------------------------------
1 | # flake8: noqa
2 | default_app_config = 'synchro.apps.SynchroConfig'
3 |
--------------------------------------------------------------------------------
/AUTHORS:
--------------------------------------------------------------------------------
1 | Jacek Tomaszewski
2 |
3 | Contibutors
4 | -----------
5 |
6 | Ivan Fedoseev
7 | Dirk Eschler
8 | Joanna Maryniak
9 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.rst LICENSE runtests.py
2 | recursive-include */templates *
3 | recursive-include */locale *.po *.mo
--------------------------------------------------------------------------------
/synchro/core.py:
--------------------------------------------------------------------------------
1 | from utility import NaturalManager, reset_synchro
2 | from management.commands.synchronize import call_synchronize
3 | from signals import DisableSynchroLog, disable_synchro_log
4 |
--------------------------------------------------------------------------------
/synchro/urls.py:
--------------------------------------------------------------------------------
1 | # flake8: noqa
2 | from django.conf.urls import url
3 |
4 | from views import synchro
5 |
6 |
7 | urlpatterns = (
8 | url(r'^$', synchro, name='synchro'),
9 | )
10 |
--------------------------------------------------------------------------------
/synchro/test_urls.py:
--------------------------------------------------------------------------------
1 | # flake8: noqa
2 | from django.contrib import admin
3 | from django.conf.urls import url, include
4 |
5 |
6 | urlpatterns = (
7 | url(r'^admin/', include(admin.site.urls)),
8 | url(r'^synchro/', include('synchro.urls')),
9 | )
10 |
--------------------------------------------------------------------------------
/synchro/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class SynchroConfig(AppConfig):
5 | name = 'synchro'
6 | verbose_name = 'Synchro'
7 |
8 | def ready(self):
9 | from signals import synchro_connect
10 | synchro_connect()
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.py[co]
2 |
3 | # Packages
4 | *.egg
5 | *.egg-info
6 | dist
7 | build
8 | eggs
9 | parts
10 | bin
11 | var
12 | sdist
13 | develop-eggs
14 | .installed.cfg
15 |
16 | # Installer logs
17 | pip-log.txt
18 |
19 | # Unit test / coverage reports
20 | .coverage
21 | .tox
22 |
23 | #Translations
24 | *.mo
25 |
26 | #Mr Developer
27 | .mr.developer.cfg
28 |
--------------------------------------------------------------------------------
/synchro/signals.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 |
3 | from django.db.models.signals import post_save, post_delete, m2m_changed
4 |
5 |
6 | def synchro_connect():
7 | from handlers import save_changelog_add_chg, save_changelog_del, save_changelog_m2m
8 | post_save.connect(save_changelog_add_chg, dispatch_uid='synchro_add_chg')
9 | post_delete.connect(save_changelog_del, dispatch_uid='synchro_del')
10 | m2m_changed.connect(save_changelog_m2m, dispatch_uid='synchro_m2m')
11 |
12 |
13 | def synchro_disconnect():
14 | post_save.disconnect(dispatch_uid='synchro_add_chg')
15 | post_delete.disconnect(dispatch_uid='synchro_del')
16 | m2m_changed.disconnect(dispatch_uid='synchro_m2m')
17 |
18 |
19 | class DisableSynchroLog(object):
20 | def __enter__(self):
21 | synchro_disconnect()
22 |
23 | def __exit__(self, *args, **kwargs):
24 | synchro_connect()
25 | return False
26 |
27 |
28 | def disable_synchro_log(f):
29 | @wraps(f)
30 | def inner(*args, **kwargs):
31 | with DisableSynchroLog():
32 | return f(*args, **kwargs)
33 | return inner
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2012 Jacek Tomaszewski
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | from setuptools import setup, find_packages
3 |
4 | setup(
5 | name='django-synchro',
6 | description='Django app for database data synchronization.',
7 | long_description=open('README.rst').read(),
8 | version='0.7',
9 | author='Jacek Tomaszewski',
10 | author_email='jacek.tomek@gmail.com',
11 | url='https://github.com/zlorf/django-synchro',
12 | license='MIT',
13 | install_requires=(
14 | 'django-dbsettings>=0.7',
15 | 'django>=1.7',
16 | ),
17 | classifiers=[
18 | 'Development Status :: 4 - Beta',
19 | 'Environment :: Web Environment',
20 | 'License :: OSI Approved :: MIT License',
21 | 'Operating System :: OS Independent',
22 | 'Programming Language :: Python',
23 | 'Framework :: Django',
24 | 'Framework :: Django :: 1.7',
25 | 'Framework :: Django :: 1.8',
26 | 'Framework :: Django :: 1.9',
27 | 'Framework :: Django :: 1.10',
28 | 'Framework :: Django :: 1.11',
29 | ],
30 | packages=find_packages(),
31 | include_package_data = True,
32 | )
33 |
--------------------------------------------------------------------------------
/synchro/templates/synchro.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/base_site.html" %}
2 | {% load i18n admin_modify %}
3 |
4 | {% block coltype %}colMS{% endblock %}
5 |
6 | {% block title %}{% trans "Synchronization" %}{{ block.super }}{% endblock %}
7 |
8 | {% block breadcrumbs %}{% if not is_popup %}
9 |
13 | {% endif %}{% endblock %}
14 |
15 | {% block content %}
16 |
17 |
{% trans "Synchronization" %}
18 |
28 |
29 | {% endblock %}
30 |
--------------------------------------------------------------------------------
/synchro/views.py:
--------------------------------------------------------------------------------
1 | from django.contrib.admin.views.decorators import staff_member_required
2 | from django.contrib import messages
3 | from django.template.response import TemplateResponse
4 | from django.utils.translation import ugettext_lazy as _
5 |
6 | from synchro.core import call_synchronize, reset_synchro
7 | from synchro.models import options
8 | from synchro import settings
9 |
10 |
11 | @staff_member_required
12 | def synchro(request):
13 | if 'synchro' in request.POST:
14 | try:
15 | msg = call_synchronize()
16 | messages.add_message(request, messages.INFO, msg)
17 | except Exception as e:
18 | if settings.DEBUG:
19 | raise
20 | msg = _('An error occured: %(msg)s (%(type)s)') % {'msg': str(e),
21 | 'type': e.__class__.__name__}
22 | messages.add_message(request, messages.ERROR, msg)
23 | elif 'reset' in request.POST and settings.ALLOW_RESET:
24 | reset_synchro()
25 | msg = _('Synchronization has been reset.')
26 | messages.add_message(request, messages.INFO, msg)
27 | return TemplateResponse(request, 'synchro.html', {'last': options.last_check,
28 | 'reset_allowed': settings.ALLOW_RESET})
29 |
--------------------------------------------------------------------------------
/synchro/models.py:
--------------------------------------------------------------------------------
1 | import django
2 | from django.contrib.admin.models import ADDITION, CHANGE, DELETION
3 | from django.contrib.contenttypes.models import ContentType
4 | from django.contrib.contenttypes.fields import GenericForeignKey
5 | from django.db import models
6 | from django.utils.timezone import now
7 | import dbsettings
8 |
9 |
10 | M2M_CHANGE = 4
11 |
12 | ACTIONS = (
13 | (ADDITION, 'Add'),
14 | (CHANGE, 'Change'),
15 | (DELETION, 'Delete'),
16 | (M2M_CHANGE, 'M2m Change'),
17 | )
18 |
19 |
20 | class SynchroSettings(dbsettings.Group):
21 | last_check = dbsettings.DateTimeValue('Last synchronization', default=now())
22 | options = SynchroSettings()
23 |
24 |
25 | class Reference(models.Model):
26 | content_type = models.ForeignKey(ContentType)
27 | local_object_id = models.CharField(max_length=20)
28 | remote_object_id = models.CharField(max_length=20)
29 |
30 | class Meta:
31 | unique_together = ('content_type', 'local_object_id')
32 |
33 |
34 | class ChangeLog(models.Model):
35 | content_type = models.ForeignKey(ContentType)
36 | object_id = models.CharField(max_length=20)
37 | object = GenericForeignKey()
38 | date = models.DateTimeField(auto_now=True)
39 | action = models.PositiveSmallIntegerField(choices=ACTIONS)
40 |
41 | def __unicode__(self):
42 | return u'ChangeLog for %s (%s)' % (unicode(self.object), self.get_action_display())
43 |
44 |
45 | class DeleteKey(models.Model):
46 | changelog = models.OneToOneField(ChangeLog)
47 | key = models.CharField(max_length=200)
48 |
--------------------------------------------------------------------------------
/synchro/locale/es/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # Copyright (C) 2013 Jacek Tomaszewski
2 | # This file is distributed under the same license as the django-synchro package.
3 | #
4 | # Joanna Maryniak , 2013
5 | #, fuzzy
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: 0.6\n"
9 | "Report-Msgid-Bugs-To: \n"
10 | "POT-Creation-Date: 2013-03-23 14:45+0100\n"
11 | "Last-Translator: Joanna Maryniak \n"
12 | "Language: ES\n"
13 | "MIME-Version: 1.0\n"
14 | "Content-Type: text/plain; charset=UTF-8\n"
15 | "Content-Transfer-Encoding: 8bit\n"
16 | "Plural-Forms: nplurals=2; plural=(n != 1)\n"
17 |
18 |
19 | #: views.py:17
20 | #, python-format
21 | msgid "An error occured: %(msg)s (%(type)s)"
22 | msgstr "Había un error: %(msg)s (%(type)s)"
23 |
24 | #: views.py:22
25 | msgid "Synchronization has been reset."
26 | msgstr "La sincronización es estada reinicializada."
27 |
28 | #: management/commands/synchronize.py:272
29 | msgid "Synchronization performed successfully."
30 | msgstr "La sincronización finalizó exitosamente."
31 |
32 | #: management/commands/synchronize.py:274
33 | msgid "No changes since last synchronization."
34 | msgstr "No hay cambios desde la última sincronización."
35 |
36 | #: templates/synchro.html:7 templates/synchro.html.py:12
37 | #: templates/synchro.html:18
38 | msgid "Synchronization"
39 | msgstr "Sincronización"
40 |
41 | #: templates/synchro.html:11
42 | msgid "Home"
43 | msgstr "Portada"
44 |
45 | #: templates/synchro.html:21
46 | msgid "Last synchro time"
47 | msgstr "El tiempo de la última sincronización"
48 |
49 | #: templates/synchro.html:22
50 | msgid "Synchronize"
51 | msgstr "Sincroniza"
52 |
53 | #: templates/synchro.html:23
54 | msgid "Reset synchronization"
55 | msgstr "Reiniciliza la sincronización"
56 |
57 | #: templates/synchro.html:24
58 | msgid ""
59 | "All changes from last synchronization up to now will be forgotten and won't "
60 | "be synchronized in the future. Are you sure you want to proceed?"
61 | msgstr ""
62 | "Todos los cambios desde la última sincronización estarán olvidados y no estarán"
63 | "sincronizados en el futuro. ¿Estás seguro que deseas continuar?"
64 |
--------------------------------------------------------------------------------
/synchro/locale/fr/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # Copyright (C) 2013 Jacek Tomaszewski
2 | # This file is distributed under the same license as the django-synchro package.
3 | #
4 | # Joanna Maryniak , 2013
5 | #, fuzzy
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: 0.6\n"
9 | "Report-Msgid-Bugs-To: \n"
10 | "POT-Creation-Date: 2013-03-18 22:45+0100\n"
11 | "Last-Translator: Joanna Maryniak \n"
12 | "Language: FR\n"
13 | "MIME-Version: 1.0\n"
14 | "Content-Type: text/plain; charset=UTF-8\n"
15 | "Content-Transfer-Encoding: 8bit\n"
16 | "Plural-Forms: nplurals=2; plural=(n > 1)\n"
17 |
18 |
19 | #: views.py:17
20 | #, python-format
21 | msgid "An error occured: %(msg)s (%(type)s)"
22 | msgstr "Une erreur est survenue: %(msg)s (%(type)s)"
23 |
24 | #: views.py:22
25 | msgid "Synchronization has been reset."
26 | msgstr "La synchronisation a été remise à zéro."
27 |
28 | #: management/commands/synchronize.py:272
29 | msgid "Synchronization performed successfully."
30 | msgstr "La synchronisation a réussi."
31 |
32 | #: management/commands/synchronize.py:274
33 | msgid "No changes since last synchronization."
34 | msgstr "Aucun changement n'a été effectué depuis la dernière synchronisation."
35 |
36 | #: templates/synchro.html:7 templates/synchro.html.py:12
37 | #: templates/synchro.html:18
38 | msgid "Synchronization"
39 | msgstr "Synchronisation"
40 |
41 | #: templates/synchro.html:11
42 | msgid "Home"
43 | msgstr "Accueil"
44 |
45 | #: templates/synchro.html:21
46 | msgid "Last synchro time"
47 | msgstr "Le temps de la dernière synchronisation"
48 |
49 | #: templates/synchro.html:22
50 | msgid "Synchronize"
51 | msgstr "Synchronise"
52 |
53 | #: templates/synchro.html:23
54 | msgid "Reset synchronization"
55 | msgstr "Remets à zéro."
56 |
57 | #: templates/synchro.html:24
58 | msgid ""
59 | "All changes from last synchronization up to now will be forgotten and won't "
60 | "be synchronized in the future. Are you sure you want to proceed?"
61 | msgstr ""
62 | "Tous les changements depuis la dernière synchronisation vont être oubliés et ne vont pas"
63 | "être synchronisés dans l'avenir. Voulez-vous continuer?"
64 |
--------------------------------------------------------------------------------
/synchro/locale/pl/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # Copyright (C) 2013 Jacek Tomaszewski
2 | # This file is distributed under the same license as the django-synchro package.
3 | #
4 | # Jacek Tomaszewski , 2013
5 | #, fuzzy
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: PACKAGE VERSION\n"
9 | "Report-Msgid-Bugs-To: \n"
10 | "POT-Creation-Date: 2013-03-16 20:48+0100\n"
11 | "Last-Translator: Jacek Tomaszewski \n"
12 | "Language: PL\n"
13 | "MIME-Version: 1.0\n"
14 | "Content-Type: text/plain; charset=UTF-8\n"
15 | "Content-Transfer-Encoding: 8bit\n"
16 | "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 "
17 | "|| n%100>=20) ? 1 : 2)\n"
18 |
19 | #: views.py:17
20 | #, python-format
21 | msgid "An error occured: %(msg)s (%(type)s)"
22 | msgstr "Wystąpił błąd: %(msg)s (%(type)s)"
23 |
24 | #: views.py:22
25 | msgid "Synchronization has been reset."
26 | msgstr "Synchronizacja została zresetowana."
27 |
28 | #: management/commands/synchronize.py:273
29 | msgid "Synchronization performed successfully."
30 | msgstr "Synchronizacja wykonana poprawnie."
31 |
32 | #: management/commands/synchronize.py:275
33 | msgid "No changes since last synchronization."
34 | msgstr "Nie było zmian od czasu ostatniej synchronizacji."
35 |
36 | #: templates/synchro.html:7 templates/synchro.html.py:12
37 | #: templates/synchro.html:18
38 | msgid "Synchronization"
39 | msgstr "Synchronizacja"
40 |
41 | #: templates/synchro.html:11
42 | msgid "Home"
43 | msgstr "Początek"
44 |
45 | #: templates/synchro.html:21
46 | msgid "Last synchro time"
47 | msgstr "Czas ostatniej synchronizacji"
48 |
49 | #: templates/synchro.html:22
50 | msgid "Synchronize"
51 | msgstr "Synchronizuj"
52 |
53 | #: templates/synchro.html:23
54 | #, fuzzy
55 | msgid "Reset synchronization"
56 | msgstr "Synchronizacja"
57 |
58 | #: templates/synchro.html:24
59 | msgid ""
60 | "All changes from last synchronization up to now will be forgotten and won't "
61 | "be synchronized in the future. Are you sure you want to proceed?"
62 | msgstr ""
63 | "Wszystkie zmiany od czasu ostatniej synchronizacji aż do teraz zostaną zapomniane"
64 | "i nie będzie ich już można później zsynchronizować. Czy na pewno chcesz kontynuować?"
65 |
--------------------------------------------------------------------------------
/synchro/handlers.py:
--------------------------------------------------------------------------------
1 | import settings
2 | settings.prepare()
3 | from models import ChangeLog, DeleteKey, ADDITION, CHANGE, DELETION, M2M_CHANGE
4 |
5 |
6 | def delete_redundant_change(cl):
7 | """
8 | Takes ChangeLog instance as argument and if previous ChangeLog for the same object
9 | has the same type, deletes it.
10 | It ensures that if several object's changes were made one-by-one, only one ChangeLog is stored
11 | afterwards.
12 | """
13 | cls = (ChangeLog.objects.filter(content_type=cl.content_type, object_id=cl.object_id)
14 | .exclude(pk=cl.pk).order_by('-date', '-pk'))
15 | if len(cls) > 0 and cls[0].action == cl.action:
16 | cls[0].delete()
17 |
18 |
19 | def save_changelog_add_chg(sender, instance, created, using, **kwargs):
20 | if sender in settings.MODELS and using == settings.LOCAL:
21 | if created:
22 | ChangeLog.objects.create(object=instance, action=ADDITION)
23 | else:
24 | cl = ChangeLog.objects.create(object=instance, action=CHANGE)
25 | delete_redundant_change(cl)
26 | elif sender in settings.INTER_MODELS and using == settings.LOCAL:
27 | rel = settings.INTER_MODELS[sender]
28 | # It doesn't matter if we select forward or reverse object here; arbitrary choose forward
29 | real_instance = getattr(instance, rel.field.m2m_field_name())
30 | cl = ChangeLog.objects.create(object=real_instance, action=M2M_CHANGE)
31 | delete_redundant_change(cl)
32 |
33 |
34 | def save_changelog_del(sender, instance, using, **kwargs):
35 | if sender in settings.MODELS and using == settings.LOCAL:
36 | cl = ChangeLog.objects.create(object=instance, action=DELETION)
37 | try:
38 | k = repr(instance.natural_key())
39 | DeleteKey.objects.create(changelog=cl, key=k)
40 | except AttributeError:
41 | pass
42 |
43 |
44 | def save_changelog_m2m(sender, instance, model, using, action, **kwargs):
45 | if ((model in settings.MODELS or instance.__class__ in settings.MODELS)
46 | and action.startswith('post') and using == settings.LOCAL):
47 | cl = ChangeLog.objects.create(object=instance, action=M2M_CHANGE)
48 | delete_redundant_change(cl)
49 |
--------------------------------------------------------------------------------
/runtests.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import django
3 | from django.conf import settings
4 | from django.core.management import call_command
5 |
6 |
7 | if not settings.configured:
8 | settings.configure(
9 | DATABASES = {
10 | 'default': {
11 | 'ENGINE': 'django.db.backends.sqlite3',
12 | 'NAME': ':memory:',
13 | },
14 | 'remote_db': {
15 | 'ENGINE': 'django.db.backends.sqlite3',
16 | 'NAME': ':memory:',
17 | }
18 | },
19 | INSTALLED_APPS = (
20 | 'django.contrib.admin',
21 | 'django.contrib.auth',
22 | 'django.contrib.contenttypes',
23 | 'django.contrib.sites',
24 | 'django.contrib.sessions',
25 | 'dbsettings',
26 | 'synchro',
27 | ),
28 | SITE_ID = 1,
29 | SYNCHRO_REMOTE = 'remote_db',
30 | # ROOT_URLCONF ommited, because in Django 1.11 it need to be a valid module
31 | USE_I18N = True,
32 | MIDDLEWARE_CLASSES=(
33 | 'django.contrib.sessions.middleware.SessionMiddleware',
34 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
35 | 'django.contrib.messages.middleware.MessageMiddleware',
36 | ),
37 | TEMPLATES = [
38 | {
39 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
40 | 'DIRS': [],
41 | 'APP_DIRS': True,
42 | 'OPTIONS': {
43 | 'context_processors': [
44 | 'django.contrib.auth.context_processors.auth',
45 | 'django.template.context_processors.debug',
46 | 'django.template.context_processors.i18n',
47 | 'django.template.context_processors.media',
48 | 'django.template.context_processors.static',
49 | 'django.template.context_processors.tz',
50 | 'django.contrib.messages.context_processors.messages',
51 | ],
52 | },
53 | },
54 | ],
55 | )
56 |
57 | if django.VERSION >= (1, 7):
58 | django.setup()
59 | call_command('test', 'synchro')
60 |
--------------------------------------------------------------------------------
/synchro/locale/de/LC_MESSAGES/django.po:
--------------------------------------------------------------------------------
1 | # Copyright (C) 2013 Jacek Tomaszewski
2 | # This file is distributed under the same license as the django-synchro package.
3 | #
4 | # Dirk Eschler , 2013
5 | #, fuzzy
6 | msgid ""
7 | msgstr ""
8 | "Project-Id-Version: django-synchro\n"
9 | "Report-Msgid-Bugs-To: https://github.com/zlorf/django-synchro\n"
10 | "POT-Creation-Date: 2013-03-18 22:05+0100\n"
11 | "PO-Revision-Date: 2013-03-18 23:02+0100\n"
12 | "Last-Translator: Dirk Eschler \n"
13 | "Language-Team: German \n"
14 | "Language: de\n"
15 | "MIME-Version: 1.0\n"
16 | "Content-Type: text/plain; charset=UTF-8\n"
17 | "Content-Transfer-Encoding: 8bit\n"
18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
19 | "X-Generator: Lokalize 1.5\n"
20 |
21 | #: views.py:17
22 | #, python-format
23 | msgid "An error occured: %(msg)s (%(type)s)"
24 | msgstr "Ein Fehler ist aufgetreten: %(msg)s (%(type)s)"
25 |
26 | #: views.py:22
27 | msgid "Synchronization has been reset."
28 | msgstr "Synchronisation wurde zurückgesetzt."
29 |
30 | #: management/commands/synchronize.py:273
31 | msgid "Synchronization performed successfully."
32 | msgstr "Synchronisation erfolgreich durchgeführt."
33 |
34 | #: management/commands/synchronize.py:275
35 | msgid "No changes since last synchronization."
36 | msgstr "Keine Änderungen seit der letzten Synchronisation."
37 |
38 | #: templates/synchro.html:7 templates/synchro.html.py:12
39 | #: templates/synchro.html:18
40 | msgid "Synchronization"
41 | msgstr "Synchronisation"
42 |
43 | #: templates/synchro.html:11
44 | msgid "Home"
45 | msgstr "Start"
46 |
47 | #: templates/synchro.html:21
48 | msgid "Last synchro time"
49 | msgstr "Zeitpunkt der letzten Synchronisation"
50 |
51 | #: templates/synchro.html:22
52 | msgid "Synchronize"
53 | msgstr "Synchronisieren"
54 |
55 | #: templates/synchro.html:23
56 | msgid "Reset synchronization"
57 | msgstr "Synchronisation zurücksetzen"
58 |
59 | #: templates/synchro.html:24
60 | msgid ""
61 | "All changes from last synchronization up to now will be forgotten and won't "
62 | "be synchronized in the future. Are you sure you want to proceed?"
63 | msgstr ""
64 | "Sämtliche Änderungen der letzten Synchronisation bis zum aktuellen Zeitpunkt "
65 | "werden zurückgesetzt und zukünftig nicht synchronisiert. Wirklich fortfahren?"
66 |
67 |
68 |
--------------------------------------------------------------------------------
/synchro/settings.py:
--------------------------------------------------------------------------------
1 | from django.apps import apps
2 | from django.conf import settings
3 | from django.core.exceptions import ImproperlyConfigured
4 |
5 |
6 | def get_all_models(app):
7 | try:
8 | app_conf = apps.get_app_config(app)
9 | except LookupError:
10 | for config in apps.get_app_configs():
11 | if config.name == app:
12 | app_conf = config
13 | break
14 | return app_conf.get_models()
15 |
16 |
17 | def gel_listed_models(app, l):
18 | def parse(model):
19 | m = apps.get_model(app, model)
20 | if m is None:
21 | raise ImproperlyConfigured(
22 | 'SYNCHRO_MODELS: Model %s not found in %s app.' % (model, app))
23 | return m
24 | return map(parse, l)
25 |
26 |
27 | def parse_models(l):
28 | res = []
29 | for entry in l:
30 | if len(entry) == 1:
31 | entry = entry[0]
32 | if type(entry) == str:
33 | res.extend(get_all_models(entry))
34 | else:
35 | app = entry[0]
36 | res.extend(gel_listed_models(app, entry[1:]))
37 | return res
38 |
39 |
40 | def _get_remote_field(m2m):
41 | return m2m.remote_field if hasattr(m2m, 'remote_field') else m2m.related
42 |
43 | def get_intermediary(models):
44 | res = {}
45 | for model in models:
46 | res.update((m2m.rel.through, _get_remote_field(m2m)) for m2m in model._meta.many_to_many
47 | if not m2m.rel.through._meta.auto_created)
48 | return res
49 |
50 | MODELS = INTER_MODELS = []
51 |
52 |
53 | def prepare():
54 | global MODELS, INTER_MODELS
55 | MODELS = parse_models(getattr(settings, 'SYNCHRO_MODELS', ()))
56 | # Since user-defined m2m intermediary objects don't send m2m_changed signal,
57 | # we need to listen to those models.
58 | INTER_MODELS = get_intermediary(MODELS)
59 |
60 | if apps.ready:
61 | # In order to prevent exception in Django 1.7
62 | prepare()
63 |
64 | REMOTE = getattr(settings, 'SYNCHRO_REMOTE', None)
65 | LOCAL = 'default'
66 | ALLOW_RESET = getattr(settings, 'SYNCHRO_ALLOW_RESET', True)
67 | DEBUG = getattr(settings, 'SYNCHRO_DEBUG', False)
68 |
69 | if REMOTE is None:
70 | if not hasattr(settings, 'SYNCHRO_REMOTE'):
71 | import warnings
72 | warnings.warn('SYNCHRO_REMOTE not specified. Synchronization is disabled.', RuntimeWarning)
73 | elif REMOTE not in settings.DATABASES:
74 | raise ImproperlyConfigured('SYNCHRO_REMOTE invalid - no such database: %s.' % REMOTE)
75 |
--------------------------------------------------------------------------------
/synchro/utility.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from django.core.exceptions import MultipleObjectsReturned, ValidationError
4 | from django.db.models import Manager, Model
5 | from django.db.models.base import ModelBase
6 |
7 |
8 | class NaturalManager(Manager):
9 | """
10 | Manager must be able to instantiate without arguments in order to work with M2M.
11 | Hence this machinery to store arguments in class.
12 | Somehow related to Django bug #13313.
13 | """
14 | allow_many = False
15 |
16 | def get_by_natural_key(self, *args):
17 | lookups = dict(zip(self.fields, args))
18 | try:
19 | return self.get(**lookups)
20 | except MultipleObjectsReturned:
21 | if self.allow_many:
22 | return self.filter(**lookups)[0]
23 | raise
24 |
25 | def __new__(cls, *fields, **options):
26 | """
27 | Creates actual manager, which can be further subclassed and instantiated without arguments.
28 | """
29 | if ((not fields and hasattr(cls, 'fields') and hasattr(cls, 'allow_many')) or
30 | fields and not isinstance(fields[0], basestring)):
31 | # Class was already prepared.
32 | return super(NaturalManager, cls).__new__(cls)
33 |
34 | assert fields, 'No fields specified in %s constructor' % cls
35 | _fields = fields
36 | _allow_many = options.get('allow_many', False)
37 | manager = options.get('manager', Manager)
38 | if not issubclass(manager, Manager):
39 | raise ValidationError(
40 | '%s manager class must be a subclass of django.db.models.Manager.'
41 | % manager.__name__)
42 |
43 | class NewNaturalManager(cls, manager):
44 | fields = _fields
45 | allow_many = _allow_many
46 |
47 | def __init__(self, *args, **kwargs):
48 | # Intentionally ignore arguments
49 | super(NewNaturalManager, self).__init__()
50 | return super(NaturalManager, cls).__new__(NewNaturalManager)
51 |
52 |
53 | class _NaturalKeyModelBase(ModelBase):
54 | def __new__(cls, name, bases, attrs):
55 | parents = [b for b in bases if isinstance(b, _NaturalKeyModelBase)]
56 | if not parents:
57 | return super(_NaturalKeyModelBase, cls).__new__(cls, name, bases, attrs)
58 | kwargs = {}
59 | if 'objects' in attrs:
60 | kwargs['manager'] = attrs['objects'].__class__
61 | kwargs.update(attrs.pop('_natural_manager_kwargs', {}))
62 | attrs['objects'] = NaturalManager(*attrs['_natural_key'], **kwargs)
63 | return super(_NaturalKeyModelBase, cls).__new__(cls, name, bases, attrs)
64 |
65 |
66 | class NaturalKeyModel(Model):
67 | __metaclass__ = _NaturalKeyModelBase
68 | _natural_key = ()
69 |
70 | def natural_key(self):
71 | return tuple(getattr(self, field) for field in self._natural_key)
72 |
73 | class Meta:
74 | abstract = True
75 |
76 |
77 | def reset_synchro():
78 | from models import ChangeLog, Reference, options
79 | options.last_check = datetime.now()
80 | ChangeLog.objects.all().delete()
81 | Reference.objects.all().delete()
82 |
--------------------------------------------------------------------------------
/synchro/management/commands/synchronize.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from django import VERSION
4 | from django.contrib.contenttypes.models import ContentType
5 | from django.core.exceptions import ObjectDoesNotExist
6 | from django.core.management.base import BaseCommand, CommandError
7 | from django.db import transaction
8 | from django.utils.translation import ugettext_lazy as _t
9 |
10 | from synchro.models import Reference, ChangeLog, DeleteKey, options as app_options
11 | from synchro.models import ADDITION, CHANGE, DELETION, M2M_CHANGE
12 | from synchro.settings import REMOTE, LOCAL
13 |
14 |
15 | if not hasattr(transaction, 'atomic'):
16 | # Django < 1.6 stub
17 | transaction.atomic = transaction.commit_on_success
18 |
19 |
20 | def get_object_for_this_type_using(self, using, **kwargs):
21 | return self.model_class()._default_manager.using(using).get(**kwargs)
22 | ContentType.get_object_for_this_type_using = get_object_for_this_type_using
23 |
24 |
25 | def find_ref(ct, id):
26 | """
27 | Retrieves referenced remote object. Also deletes invalid reference.
28 |
29 | Returns (remote, reference) or (None, None).
30 | """
31 | try:
32 | ref = Reference.objects.get(content_type=ct, local_object_id=id)
33 | try:
34 | rem = ct.get_object_for_this_type_using(REMOTE, pk=ref.remote_object_id)
35 | return rem, ref
36 | except ObjectDoesNotExist:
37 | ref.delete()
38 | return None, None
39 | except Reference.DoesNotExist:
40 | return None, None
41 |
42 |
43 | def find_natural(ct, loc, key=None):
44 | """Tries to find remote object for specified natural key or loc.natural_key."""
45 | try:
46 | key = key or loc.natural_key()
47 | model = ct.model_class()
48 | return model.objects.db_manager(REMOTE).get_by_natural_key(*key)
49 | except (AttributeError, ObjectDoesNotExist):
50 | return None
51 |
52 |
53 | def is_remote_newer(loc, rem):
54 | try:
55 | loc_ct = ContentType.objects.get_for_model(loc)
56 | rem_ct = ContentType.objects.db_manager(REMOTE).get_for_model(rem)
57 | loc_time = (ChangeLog.objects.filter(content_type=loc_ct, object_id=loc.pk)
58 | .order_by('-date')[0].date)
59 | rem_time = (ChangeLog.objects.filter(content_type=rem_ct, object_id=rem.pk)
60 | .order_by('-date').using(REMOTE)[0].date)
61 | return rem_time >= loc_time
62 | except (ObjectDoesNotExist, IndexError):
63 | return False
64 |
65 |
66 | def save_with_fks(ct, obj, new_pk):
67 | """
68 | Saves object in REMOTE, ensuring that every of it fk is present in REMOTE.
69 | Many-to-many relations are handled separately.
70 | """
71 | old_id = obj.pk
72 | obj._state.db = REMOTE
73 |
74 | fks = (f for f in obj._meta.fields if f.rel)
75 | for f in fks:
76 | fk_id = f.value_from_object(obj)
77 | if fk_id is not None:
78 | fk_ct = ContentType.objects.get_for_model(f.rel.to)
79 | rem, _ = ensure_exist(fk_ct, fk_id)
80 | f.save_form_data(obj, rem)
81 |
82 | obj.pk = new_pk
83 | obj.save(using=REMOTE)
84 | r, n = Reference.objects.get_or_create(content_type=ct, local_object_id=old_id,
85 | defaults={'remote_object_id': obj.pk})
86 | if not n and r.remote_object_id != obj.pk:
87 | r.remote_object_id = obj.pk
88 | r.save()
89 |
90 | M2M_CACHE = {}
91 |
92 |
93 | def save_m2m(ct, obj, remote):
94 | """Synchronize m2m fields from obj to remote."""
95 | model_name = obj.__class__
96 |
97 | if model_name not in M2M_CACHE:
98 | # collect m2m fields information: both direct and reverse
99 | res = {}
100 | for f in obj._meta.many_to_many:
101 | me = f.m2m_field_name()
102 | he_id = '%s_id' % f.m2m_reverse_field_name()
103 | res[f.attname] = (f.rel.to, f.rel.through, me, he_id)
104 | if VERSION < (1, 8):
105 | m2m = obj._meta.get_all_related_many_to_many_objects()
106 | else:
107 | m2m = [f for f in obj._meta.get_fields(include_hidden=True)
108 | if f.many_to_many and f.auto_created]
109 | for rel in m2m:
110 | f = rel.field
111 | if rel.get_accessor_name() is None:
112 | # In case of symmetrical relation
113 | continue
114 | me = f.m2m_reverse_field_name()
115 | he_id = '%s_id' % f.m2m_field_name()
116 | related_model = rel.model if VERSION < (1, 8) else rel.related_model
117 | res[rel.get_accessor_name()] = (related_model, f.rel.through, me, he_id)
118 | M2M_CACHE[model_name] = res
119 |
120 | _m2m = {}
121 |
122 | # handle m2m fields
123 | for f, (to, through, me, he_id) in M2M_CACHE[model_name].iteritems():
124 | fk_ct = ContentType.objects.get_for_model(to)
125 | out = []
126 | if through._meta.auto_created:
127 | for fk_id in getattr(obj, f).using(LOCAL).values_list('pk', flat=True):
128 | rem, _ = ensure_exist(fk_ct, fk_id)
129 | out.append(rem)
130 | else:
131 | # some intermediate model is used for this m2m
132 | inters = through.objects.filter(**{me: obj}).using(LOCAL)
133 | for inter in inters:
134 | ensure_exist(fk_ct, getattr(inter, he_id))
135 | out.append(inter)
136 | _m2m[f] = not through._meta.auto_created, out
137 |
138 | for f, (intermediary, out) in _m2m.iteritems():
139 | if not intermediary:
140 | setattr(remote, f, out)
141 | else:
142 | getattr(remote, f).clear()
143 | for inter in out:
144 | # we don't need to set any of objects on inter. References will do it all.
145 | ct = ContentType.objects.get_for_model(inter)
146 | save_with_fks(ct, inter, None)
147 |
148 |
149 | def create_with_fks(ct, obj, pk):
150 | """Performs create, but firstly disables synchro of some user defined fields (if any)"""
151 | skip = getattr(obj, 'SYNCHRO_SKIP', ())
152 | raw = obj.__class__()
153 | for f in skip:
154 | setattr(obj, f, getattr(raw, f))
155 | return save_with_fks(ct, obj, pk)
156 |
157 |
158 | def change_with_fks(ct, obj, rem):
159 | """Performs change, but firstly disables synchro of some user defined fields (if any)"""
160 | skip = getattr(obj, 'SYNCHRO_SKIP', ())
161 | for f in skip:
162 | setattr(obj, f, getattr(rem, f))
163 | return save_with_fks(ct, obj, rem.pk)
164 |
165 |
166 | def ensure_exist(ct, id):
167 | """
168 | Ensures that remote object exists for specified ct/id. If not, create it.
169 | Returns remote object and reference.
170 | """
171 | obj = ct.get_object_for_this_type(pk=id)
172 | rem, ref = find_ref(ct, obj.pk)
173 | if rem is not None:
174 | return rem, ref
175 | rem = find_natural(ct, obj)
176 | if rem is not None:
177 | ref = Reference.objects.create(content_type=ct, local_object_id=id, remote_object_id=rem.pk)
178 | return rem, ref
179 | return perform_add(ct, id)
180 |
181 |
182 | def perform_add(ct, id, log=None):
183 | obj = ct.get_object_for_this_type(pk=id)
184 | rem = find_natural(ct, obj)
185 | if rem is not None:
186 | if not is_remote_newer(obj, rem):
187 | change_with_fks(ct, obj, rem)
188 | rem = obj
189 | else:
190 | new_pk = None if obj._meta.has_auto_field else obj.pk
191 | create_with_fks(ct, obj, new_pk)
192 | rem = obj
193 | ref, _ = Reference.objects.get_or_create(content_type=ct, local_object_id=id,
194 | remote_object_id=rem.pk)
195 | return rem, ref
196 |
197 |
198 | def perform_chg(ct, id, log=None):
199 | obj = ct.get_object_for_this_type(pk=id)
200 | rem, ref = find_ref(ct, obj.pk)
201 | if rem is not None:
202 | return change_with_fks(ct, obj, rem)
203 | rem = find_natural(ct, obj)
204 | if rem is not None:
205 | return change_with_fks(ct, obj, rem)
206 | perform_add(ct, id)
207 |
208 |
209 | def perform_del(ct, id, log):
210 | rem, ref = find_ref(ct, id)
211 | if rem is not None:
212 | return rem.delete()
213 | try:
214 | raw_key = log.deletekey.key
215 | key = eval(raw_key)
216 | rem = find_natural(ct, None, key)
217 | if rem is not None:
218 | rem.delete()
219 | except DeleteKey.DoesNotExist:
220 | pass
221 |
222 |
223 | def perform_m2m(ct, id, log=None):
224 | obj = ct.get_object_for_this_type(pk=id)
225 | rem, ref = find_ref(ct, obj.pk)
226 | if rem is not None:
227 | return save_m2m(ct, obj, rem)
228 | rem = find_natural(ct, obj)
229 | if rem is not None:
230 | return save_m2m(ct, obj, rem)
231 | rem, _ = perform_add(ct, id)
232 | return save_m2m(ct, obj, rem)
233 |
234 |
235 | ACTIONS = {
236 | ADDITION: perform_add,
237 | CHANGE: perform_chg,
238 | DELETION: perform_del,
239 | M2M_CHANGE: perform_m2m,
240 | }
241 |
242 |
243 | class Command(BaseCommand):
244 | args = ''
245 | help = '''Perform synchronization.'''
246 |
247 | def handle(self, *args, **options):
248 | # ``synchronize`` is extracted from ``handle`` since call_command has
249 | # no easy way of returning a result
250 | ret = self.synchronize(*args, **options)
251 | if options['verbosity'] > 0:
252 | self.stdout.write(u'%s\n' % ret)
253 |
254 | @transaction.atomic
255 | @transaction.atomic(using=REMOTE)
256 | def synchronize(self, *args, **options):
257 | if REMOTE is None:
258 | # Because of BaseCommand bug (#18387, fixed in Django 1.5), we cannot use CommandError
259 | # in tests. Hence this hook.
260 | exception_class = options.get('exception_class', CommandError)
261 | raise exception_class('No REMOTE database specified in settings.')
262 |
263 | since = app_options.last_check
264 | last_time = datetime.now()
265 | logs = ChangeLog.objects.filter(date__gt=since).select_related().order_by('date', 'pk')
266 |
267 | # Don't synchronize if object should be added/changed and later deleted;
268 | to_del = {}
269 | for log in logs:
270 | if log.action == DELETION:
271 | to_del[(log.content_type, log.object_id)] = log.date
272 |
273 | for log in logs:
274 | last_time = log.date
275 | del_time = to_del.get((log.content_type, log.object_id))
276 | if last_time == del_time and log.action == DELETION:
277 | ACTIONS[log.action](log.content_type, log.object_id, log)
278 | # delete record so that next actions with the same time can be performed
279 | del to_del[(log.content_type, log.object_id)]
280 | if del_time is None or last_time > del_time:
281 | ACTIONS[log.action](log.content_type, log.object_id, log)
282 |
283 | if len(logs):
284 | app_options.last_check = last_time
285 | return _t('Synchronization performed successfully.')
286 | else:
287 | return _t('No changes since last synchronization.')
288 |
289 |
290 | def call_synchronize(**kwargs):
291 | "Shortcut to call management command and get return message."
292 | return Command().synchronize(**kwargs)
293 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | ==============
2 | django-synchro
3 | ==============
4 |
5 |
6 | Aim & purpose
7 | =============
8 |
9 | This app is for synchronization of django objects between databases.
10 |
11 | It logs information about objects' manipulations (additions, changes, deletions).
12 | When synchronization is launched, all objects logged from the last checkpoint are synced to another database.
13 |
14 | **Important note**: This app doesn't log detailed information about changes (e.g. which fields were updated),
15 | just that such manipulation occured. When the synchronization is performed, the objects are synced with their newest, actual values.
16 | (however, you can specify some fields to be `skipped` during synchronization, see below__).
17 |
18 | __ `Skipping fields`_
19 |
20 | Example 1
21 | ---------
22 |
23 | Consider scenario:
24 |
25 | - there is one production project deployed on the web
26 | - and the same project is deployed on some office computer in case of main server failure
27 |
28 | Assuming that the local database is regularly synced (eg. once a day the main database is exported and imported into the local system),
29 | in case of a long main server downtime the staff may use the local project (inserting objects etc.).
30 |
31 | After the server is up again, the local changes (from the point of the last checkpoint) can be painlessly synchronized to the remote server.
32 |
33 | Example 2
34 | ---------
35 |
36 | You can also synchronize databases both ways, not only in the slave-master model like in the previous example.
37 |
38 | However, it is probably better (if possible) to have a common database rather than to have
39 | one for every project deployment and to perform synchronization between them.
40 |
41 |
42 | Requirements
43 | ============
44 |
45 | The app is tested to work with Django 1.7 - 1.11. If you want to use app in older versions of Django,
46 | use the 0.6 release.
47 |
48 | The app needs ``django-dbsettings`` to store the time of last synchronization.
49 |
50 | Installation
51 | ============
52 |
53 | 1. Install app (**note**: ``django-dbsettings`` is required and please view its install notes,
54 | such as `cache backend` important remarks)::
55 |
56 | $ pip install django-synchro
57 |
58 | or download it manually along with dependencies and put in python path.
59 |
60 | #. Configure ``DATABASES``.
61 |
62 | #. Add ``synchro`` and ``dbsettings`` to ``INSTALLED_APPS``.
63 |
64 | #. Specify in your ``settings.py`` what is `remote database` name and which models should be watched and synchronized::
65 |
66 | SYNCHRO_REMOTE = 'remote'
67 | SYNCHRO_MODELS = (
68 | 'my_first_app', # all models from my_first_app
69 | ('my_second_app', 'model1', 'model2'), # only listed models (letter case doesn't matter)
70 | 'my_third_app', # all models again
71 | 'django.contrib.sites', # you may specify fully qualified name...
72 | 'auth', # or just app label
73 | )
74 |
75 | Later, `REMOTE` will mean `remote database`.
76 |
77 |
78 | Usage
79 | =====
80 |
81 | Synchronization
82 | ---------------
83 |
84 | Just invoke ``synchronize`` management command::
85 |
86 | $ ./manage.py synchronize
87 |
88 | Admin synchro view
89 | ------------------
90 |
91 | In order to allow performing synchronization without shell access, you can use special admin view.
92 |
93 | Include in your urls::
94 |
95 | url(r'^synchro/', include('synchro.urls', 'synchro', 'synchro')),
96 |
97 | Then the view will be available at reversed url: ``synchro:synchro``.
98 |
99 | The view provides two buttons: one to perform synchronization, and the other to
100 | `reset checkpoint`__. If you would like to disable the reset button, set
101 | ``SYNCHRO_ALLOW_RESET = False`` in your ``settings.py``.
102 |
103 | Debugging
104 | ---------
105 |
106 | In order to track a cause of exception during synchronization, set ``SYNCHRO_DEBUG = True``
107 | (and ``DEBUG = True`` as well) in your ``settings.py`` and try to perform synchronization by admin view.
108 |
109 | __ Checkpoints_
110 |
111 | ``SYNCHRO_REMOTE`` setting
112 | --------------------------
113 |
114 | Generally, ``SYNCHRO_REMOTE`` setting can behave in 3 different ways:
115 |
116 | 1. The most naturally: it holds name of `REMOTE` database. When ``synchronize`` is called, ``sychro`` will
117 | sync objects from `LOCAL` database to `REMOTE` one.
118 | #. When ``SYNCHRO_REMOTE`` is ``None``: it means that no `REMOTE` is needed as ``synchro`` will only store
119 | logs (see below__). It's useful on `REMOTE` itself.
120 | #. When ``SYNCHRO_REMOTE`` is not specified at all, it behaves just like above (as if it was ``None``), but
121 | will show a RuntimeWarning.
122 |
123 | __ synchro_on_remote_
124 |
125 |
126 | Remarks and features
127 | ====================
128 |
129 | QuerySet ``update`` issue
130 | -------------------------
131 |
132 | Django-synchro logs information about objects modifications and later use it when asked for synchronization.
133 |
134 | The logging take place using the ``post_save`` and ``post_delete`` signal handlers.
135 |
136 | That means that actions which don't emmit those signals (like ``objects.update`` method) would result
137 | in no log stored, hence no synchronization of actions' objects.
138 |
139 | **So, please remind**: objects modified via ``objects.update`` won't be synchronized unless some special code is prepared
140 | (eg. calling ``save`` on all updated objects or manually invoking ``post_save`` signal).
141 |
142 | Natural keys
143 | ------------
144 |
145 | For efficient objects finding, it is **highly suggested** to provide ``natural_key`` object method
146 | and ``get_by_natural_key`` manager method.
147 | This will allow easy finding whether the synchronized object exists in `REMOTE` and to prevent duplicating.
148 |
149 | Although adding ``natural_key`` to model definition is relatively quick, extending a manager may
150 | require extra work in cases when the default manager is used::
151 |
152 | class MyManager(models.Manager):
153 | def get_by_natural_key(self, code, day):
154 | return self.get(code=code, day=day)
155 |
156 | class MyModel(models.Model):
157 | ...
158 | objects = MyManager()
159 | def natural_key(self):
160 | return self.code, self.day
161 |
162 | To minimalize the effort of implementing a custom manager, a shortcut is provided::
163 |
164 | from synchro.core import NaturalManager
165 |
166 | class MyModel(models.Model):
167 | ...
168 | objects = NaturalManager('code', 'day')
169 | def natural_key(self):
170 | return self.code, self.day
171 |
172 | Or even easier (effect is exactly the same)::
173 |
174 | from synchro.core import NaturalKeyModel
175 |
176 | class MyModel(NaturalKeyModel):
177 | ...
178 | _natural_key = ('code', 'day')
179 |
180 | ``NaturalManager`` extends the built-in Manager by default; you can change its superclass using ``manager`` keyword::
181 |
182 | from synchro.core import NaturalManager
183 |
184 | class MyVeryCustomManager(models.Manager):
185 | ... # some mumbo-jumbo magic
186 |
187 | class MyModel(models.Model):
188 | ...
189 | objects = NaturalManager('code', 'day', manager=MyVeryCustomManager)
190 | def natural_key(self):
191 | return self.code, self.day
192 |
193 | When using ``NaturalKeyModel``, ``NaturalManager`` will extend the defined (``objects``) manager::
194 |
195 | from synchro.core import NaturalKeyModel
196 |
197 | class MyVeryCustomManager(models.Manager):
198 | ... # some mumbo-jumbo magic
199 |
200 | class MyModel(NaturalKeyModel):
201 | ...
202 | _natural_key = ('code', 'day')
203 | objects = MyVeryCustomManager()
204 |
205 | Side note: in fact invoking ``NaturalManager`` creates a new class being ``NaturalManager``'s subclass.
206 |
207 | The purpose of a natural key is to *uniquely* distinguish among model instances;
208 | however, there are situations where it is impossible. You can choose such fields that will cause
209 | ``get_by_natural_key`` to find more than one object. In such a situation, it will raise
210 | ``MultipleObjectsReturned`` exception and the synchronization will fail.
211 |
212 | But you can tell ``NaturalManager`` that you are aware of such a situation and that it
213 | should just take the first object found::
214 |
215 | class Person(models.Model):
216 | ...
217 | # combination of person name and city is not unique
218 | objects = NaturalManager('first_name', 'last_name', 'city', allow_many=True)
219 | def natural_key(self):
220 | return self.first_name, self.last_name, self.city
221 |
222 | Or with ``NaturalKeyModel``::
223 |
224 | class Person(NaturalKeyModel):
225 | ...
226 | # combination of person name and city is not unique
227 | _natural_key = ('first_name', 'last_name', 'city')
228 | _natural_manager_kwargs = {'allow_many': True} # I know, it looks quite ugly
229 |
230 | Don't use ``allow_many`` unless you are completely sure what you are doing and what
231 | you want to achieve.
232 |
233 | Side note: if ``natural_key`` consist of only one field, be sure to return a tuple anyway::
234 |
235 | class MyModel(models.Model):
236 | ...
237 | objects = NaturalManager('code')
238 | def natural_key(self):
239 | return self.code, # comma makes it tuple
240 |
241 | Or to assign tuple in ``NaturalKeyModel``::
242 |
243 | _natural_key = ('code',)
244 |
245 | Previously, there were ``natural_manager`` function that was used instead of ``NaturalManager``
246 | - however, it's deprecated.
247 |
248 | Skipping fields
249 | ---------------
250 |
251 | If your model has some fields that should not be synchronized, like computed fields
252 | (eg. field with payment balances, which is updated on every order save - in ``order.post_save`` signal),
253 | you can exclude them from synchronization::
254 |
255 | class MyModel(models.Model):
256 | ...
257 | SYNCHRO_SKIP = ('balance',)
258 |
259 | When a new object is synchronized, all its skipped fields will be reset to default values on `REMOTE`.
260 | Of course, the `LOCAL` object will stay untouched.
261 |
262 | Temporary logging disabling
263 | ---------------------------
264 |
265 | If you don't want to log some actions::
266 |
267 | from synchro.core import DisableSynchroLog
268 |
269 | with DisableSynchroLog():
270 | mymodel.name = foo
271 | mymodel.save()
272 |
273 | Or, in a less robust way, with a decorator::
274 |
275 | from synchro.core import disable_synchro_log
276 |
277 | @disable_synchro_log
278 | def foo(mymodel):
279 | mymodel.name = foo
280 | mymodel.save()
281 |
282 | Signals
283 | -------
284 |
285 | That's a harder part.
286 |
287 | If your signal handlers modify other objects, such an action will be probably reproduced twice:
288 |
289 | - first, when the model will be updated on `REMOTE`, then normal `REMOTE` signal handler will launch
290 | - second time, because the original signal handler's action was logged, the whole modified object will be synchronized;
291 | this is probably undesirable.
292 |
293 | Consider a bad scenario:
294 |
295 | 1. Initially databases are synced. There is an object ``A`` in each of the databases. ``A.foo`` and ``A.bar`` values are both 1.
296 | #. On `REMOTE`, we change ``A.foo`` to 42 and save.
297 | #. On `LOCAL`, we save object ``X``. In some ``X`` signal handler, ``A.bar`` is incremented.
298 | #. We perform synchronization:
299 |
300 | a. ``X`` is synced.
301 | #. ``X`` signal handler is invoked on `REMOTE`, resulting in `REMOTE`'s ``A.bar`` incrementation.
302 | So far so good. `REMOTE`'s ``A.bar == 2`` and ``A.foo == 42``, just like it should.
303 | #. Because ``A`` change (during step 3) was logged, ``A`` is synced. *Not good* -
304 | `REMOTE` value of ``A.foo`` will be overwritten with 1
305 | (because `LOCAL` version is considered newer, as it was saved later).
306 |
307 | It happened because the signal handler actions were logged.
308 |
309 | To prevent this from happening, wrap handler with ``DisableSynchroLog``::
310 |
311 | @receiver(models.signals.post_delete, sender=Parcel)
312 | def update_agent_balance_delete(sender, instance, *args, **kwargs):
313 | with DisableSynchroLog():
314 | instance.agent.balance -= float(instance.payment_left))
315 | instance.agent.save()
316 |
317 | Or with the decorator::
318 |
319 | @receiver(models.signals.post_delete, sender=Parcel)
320 | @disable_synchro_log
321 | def update_agent_balance_delete(sender, instance, *args, **kwargs):
322 | instance.agent.balance -= float(instance.payment_left))
323 | instance.agent.save()
324 |
325 | If using the decorator, be sure to place it after connecting to the signal, not before - otherwise it won't work.
326 |
327 | ``Update`` issue again
328 | ......................
329 |
330 | One can benefit from the fact that ``objects.update`` is not logged and use it in signal handlers instead of ``DisableSynchroLog``.
331 |
332 | Signal handlers for multi-db
333 | ............................
334 |
335 | Just a reminder note.
336 |
337 | When a synchronization is performed, signal handlers are invoked for created/updated/deleted `REMOTE` objects.
338 | And those signals are of course handled on the `LOCAL` machine.
339 |
340 | That means: signal handlers (and probably other part of project code) must be ready to handle both `LOCAL`
341 | and `REMOTE` objects. It must use ``using(...)`` clause or ``db_manager(...)`` to ensure that the proper database
342 | is used::
343 |
344 | def reset_specials(sender, instance, *args, **kwargs):
345 | Offer.objects.db_manager(instance._state.db).filter(date__lt=instance.date).update(special=False)
346 |
347 | Plain ``objects``, without ``db_manager`` or ``using``, always use the ``default`` database (which means `LOCAL`).
348 |
349 | But that is normal in multi-db projects.
350 |
351 | .. _synchro_on_remote:
352 |
353 | Synchro on `REMOTE` and time comparing
354 | --------------------------------------
355 |
356 | If you wish only to synchronize one-way (always from `LOCAL` to `REMOTE`), you may be tempted not to include
357 | ``synchro`` in `REMOTE` ``INSTALLED_APPS``.
358 |
359 | Yes, you can do that and you will save some resources - logs won't be stored.
360 |
361 | But keeping ``synchro`` active on `REMOTE` is a better idea. It will pay at synchonization: the synchro will look
362 | at logs and determine which object is newer. If the `LOCAL` one is older, it won't be synced.
363 |
364 | You probably should set ``SYNCHRO_REMOTE = None`` on `REMOTE` if no synchronizations will be
365 | performed there (alternatively, you can add some dummy sqlite database to ``DATABASES``).
366 |
367 | Checkpoints
368 | -----------
369 |
370 | If you wish to reset sychronization status (that is - delete logs and set checkpoint)::
371 |
372 | from synchro.core import reset_synchro
373 |
374 | reset_synchro()
375 |
376 | Or raw way of manually changing synchro checkpoint::
377 |
378 | from synchro.models import options
379 |
380 | options.last_check = datetime.datetime.now() # or any time you wish
381 |
382 | ----------
383 |
384 | Changelog
385 | =========
386 |
387 | **0.7** (12/11/2017)
388 | - Support Django 1.8 - 1.11
389 | - Dropped support for Django 1.6 and older
390 | - Backward incompatibility:
391 | you need to refactor all `from synchro import ...`
392 | into `from synchro.core import ...`
393 |
394 | **0.6** (27/12/2014)
395 | - Support Django 1.7
396 | - Fixed deprecation warnings
397 |
398 | **0.5.2** (29/07/2014)
399 | - Fixed dangerous typo
400 | - Added 'reset' button to synchro view and SYNCHRO_ALLOW_RESET setting
401 | - Prepared all texts for translation
402 | - Added PL, DE, FR, ES translations
403 | - Added ``SYNCHRO_DEBUG`` setting
404 |
405 | **0.5.1** (28/02/2013)
406 | Fixed a few issues with 0.5 release
407 |
408 | **0.5** (27/02/2013)
409 | - Refactored code to be compatible with Django 1.5
410 | - Required Django version increased from 1.3 to 1.4 (the code was already using some
411 | 1.4-specific functions)
412 | - Removed deprecated natural_manager function
413 |
414 | **0.4.2** (18/10/2012)
415 | - Fixed issue with app loading (thanks to Alexander Todorov for reporting)
416 | - Added 1 test regarding the issue above
417 |
418 | **0.4.1** (23/09/2012)
419 | - Fixed symmetrical m2m synchronization
420 | - Added 1 test regarding the issue above
421 |
422 | **0.4** (16/09/2012)
423 | - **Deprecation**: natural_manager function is deprecated. Use NaturalManager instead
424 | - Refactored NaturalManager class so that it plays well with models involved in m2m relations
425 | - Refactored NaturalManager class so that natural_manager function is unnecessary
426 | - Added NaturalKeyModel base class
427 | - Fixed bug with m2m user-defined intermediary table synchronization
428 | - Fixed bugs with m2m changes synchronization
429 | - Added 3 tests regarding m2m aspects
430 |
431 | **0.3.1** (12/09/2012)
432 | - ``SYNCHRO_REMOTE`` setting is not required anymore.
433 | Its lack will only block ``synchronize`` command
434 | - Added 2 tests regarding the change above
435 | - Updated README
436 |
437 | **0.3** (04/09/2012)
438 | - **Backward incompatible**: Changed ``Reference`` fields type from ``Integer`` to ``Char`` in
439 | order to store non-numeric keys
440 | - Included 24 tests
441 | - Refactored NaturalManager class so that it is accessible and importable
442 | - Exception is raised if class passed to natural_manager is not Manager subclass
443 | - Switched to dbsettings-bundled DateTimeValue
444 | - Updated README
445 |
446 | **0.2** (10/06/2012)
447 | Initial PyPI release
448 |
449 | **0.1**
450 | Local development
451 |
452 | ----------
453 |
454 | :Author: Jacek Tomaszewski
455 | :Thanks: to my wife for text correction
456 |
--------------------------------------------------------------------------------
/synchro/tests.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from django import VERSION
4 | from django.conf import settings
5 | from django.core.exceptions import ValidationError, ImproperlyConfigured
6 | from django.core.management import call_command, CommandError
7 | from django.core.urlresolvers import reverse
8 | from django.db import models
9 | from django.db.models import F
10 | from django.db.models.signals import pre_save, post_save, post_delete
11 | from django.dispatch import receiver
12 | from django.test import TestCase
13 | from django.test.utils import override_settings
14 | try:
15 | from unittest.case import skipUnless
16 | except ImportError:
17 | from django.utils.unittest.case import skipUnless
18 |
19 | from models import ChangeLog
20 | import settings as synchro_settings
21 | from signals import DisableSynchroLog, disable_synchro_log
22 | from utility import NaturalManager, reset_synchro, NaturalKeyModel
23 |
24 | from django.contrib.auth import get_user_model
25 | User = get_user_model()
26 |
27 | def user_model_quite_standard():
28 | "Check if installed User object is not too custom for the tests to instantiate it."
29 | from django.contrib.auth.models import User as StandardUser
30 | if (User.USERNAME_FIELD == StandardUser.USERNAME_FIELD and
31 | User.REQUIRED_FIELDS == StandardUser.REQUIRED_FIELDS):
32 | return True
33 | return False
34 |
35 | LOCAL = 'default'
36 | REMOTE = settings.SYNCHRO_REMOTE
37 | # List of test models
38 | SETTINGS = {
39 | 'SYNCHRO_MODELS': (
40 | ('synchro', 'testmodel', 'PkModelWithSkip', 'ModelWithKey', 'ModelWithFK', 'A', 'X',
41 | 'M2mModelWithKey', 'M2mAnother', 'M2mModelWithInter', 'M2mSelf', 'ModelWithFKtoKey'),
42 | ),
43 | 'ROOT_URLCONF': 'synchro.test_urls',
44 | }
45 |
46 |
47 | def contrib_apps(*apps):
48 | """Check if all listed apps are installed."""
49 | for app in apps:
50 | if 'django.contrib.%s' % app not in settings.INSTALLED_APPS:
51 | return False
52 | return True
53 |
54 |
55 | # #### Test models ################################
56 |
57 |
58 | class TestModel(models.Model):
59 | name = models.CharField(max_length=10)
60 | cash = models.IntegerField(default=0)
61 |
62 |
63 | class PkModelWithSkip(models.Model):
64 | name = models.CharField(max_length=10, primary_key=True)
65 | cash = models.IntegerField(default=0)
66 | visits = models.PositiveIntegerField(default=0)
67 | SYNCHRO_SKIP = ('visits',)
68 |
69 |
70 | class ModelWithFK(models.Model):
71 | name = models.CharField(max_length=10)
72 | visits = models.PositiveIntegerField(default=0)
73 | link = models.ForeignKey(PkModelWithSkip, related_name='links')
74 |
75 |
76 | @receiver(pre_save, sender=ModelWithFK)
77 | def save_prev(sender, instance, **kwargs):
78 | """Save object's previous state (before save)."""
79 | try:
80 | instance._prev = sender.objects.db_manager(instance._state.db).get(pk=instance.pk)
81 | except sender.DoesNotExist:
82 | instance._prev = None
83 |
84 |
85 | @receiver(post_save, sender=ModelWithFK)
86 | @disable_synchro_log
87 | def update_visits(sender, instance, created, **kwargs):
88 | """Update parent visits."""
89 | if not created:
90 | # Side note: in the statement below it should be instance._prev.link in case of link change,
91 | # but it requires some refreshing from database (since instance._prev.link and instance.link
92 | # are two different instances of the same object). For this test
93 | instance.link.visits -= instance._prev.visits
94 | instance.link.save()
95 | instance.link.visits += instance.visits
96 | instance.link.save()
97 |
98 |
99 | class CustomManager(models.Manager):
100 | def foo(self):
101 | return 'bar'
102 |
103 | def none(self): # Overrides Manager method
104 | return 'Not a single object!'
105 |
106 |
107 | class MyNaturalManager(NaturalManager, CustomManager):
108 | fields = ('name',)
109 |
110 |
111 | class ModelWithKey(NaturalKeyModel):
112 | name = models.CharField(max_length=10)
113 | cash = models.IntegerField(default=0)
114 | visits = models.PositiveIntegerField(default=0)
115 | SYNCHRO_SKIP = ('visits',)
116 | _natural_key = ('name',)
117 |
118 | objects = CustomManager()
119 | another_objects = MyNaturalManager()
120 |
121 |
122 | class ModelWithFKtoKey(models.Model):
123 | name = models.CharField(max_length=10)
124 | link = models.ForeignKey(ModelWithKey, related_name='links')
125 |
126 |
127 | class M2mModelWithKey(models.Model):
128 | foo = models.IntegerField(default=1)
129 | objects = NaturalManager('foo')
130 |
131 | def natural_key(self):
132 | return self.foo,
133 |
134 |
135 | class M2mAnother(models.Model):
136 | bar = models.IntegerField(default=1)
137 | m2m = models.ManyToManyField('M2mModelWithKey', related_name='r_m2m')
138 |
139 |
140 | class M2mModelWithInter(models.Model):
141 | bar = models.IntegerField(default=1)
142 | m2m = models.ManyToManyField('M2mModelWithKey', related_name='r_m2m_i',
143 | through='M2mIntermediate')
144 |
145 |
146 | class M2mNotExplicitlySynced(models.Model):
147 | # This model is not listed in SYNCHRO_MODELS
148 | foo = models.IntegerField(default=1)
149 |
150 |
151 | class M2mIntermediate(models.Model):
152 | with_key = models.ForeignKey(M2mModelWithKey)
153 | with_inter = models.ForeignKey(M2mModelWithInter)
154 | # To get everything worse, use another FK here, in order to test intermediate sync.
155 | extra = models.ForeignKey(M2mNotExplicitlySynced)
156 | cash = models.IntegerField()
157 |
158 |
159 | class M2mSelf(models.Model):
160 | foo = models.IntegerField(default=1)
161 | m2m = models.ManyToManyField('self')
162 |
163 |
164 | class A(models.Model):
165 | foo = models.IntegerField(default=1)
166 | bar = models.IntegerField(default=1)
167 |
168 |
169 | class X(models.Model):
170 | name = models.CharField(max_length=10)
171 |
172 |
173 | def update_bar_bad(sender, using, **kwargs):
174 | a = A.objects.db_manager(using).all()[0]
175 | a.bar += 1
176 | a.save()
177 |
178 |
179 | @disable_synchro_log
180 | def update_bar_good_dis(sender, using, **kwargs):
181 | a = A.objects.db_manager(using).all()[0]
182 | a.bar += 1
183 | a.save()
184 |
185 |
186 | def update_bar_good_upd(sender, using, **kwargs):
187 | A.objects.db_manager(using).update(bar=F('bar') + 1) # update don't emmit signals
188 |
189 |
190 | # #### Tests themselves ###########################
191 |
192 |
193 | @override_settings(**SETTINGS)
194 | class SynchroTests(TestCase):
195 | multi_db = True
196 |
197 | @classmethod
198 | def setUpClass(cls):
199 | """Update SYNCHRO_MODELS and reload them"""
200 | super(SynchroTests, cls).setUpClass()
201 | if VERSION < (1, 8):
202 | with override_settings(**SETTINGS):
203 | reload(synchro_settings)
204 | else:
205 | reload(synchro_settings)
206 |
207 | @classmethod
208 | def tearDownClass(cls):
209 | """Clean up after yourself: restore the previous SYNCHRO_MODELS"""
210 | super(SynchroTests, cls).tearDownClass()
211 | reload(synchro_settings)
212 |
213 | def _assertDbCount(self, db, num, cls):
214 | self.assertEqual(num, cls.objects.db_manager(db).count())
215 |
216 | def assertLocalCount(self, num, cls):
217 | self._assertDbCount(LOCAL, num, cls)
218 |
219 | def assertRemoteCount(self, num, cls):
220 | self._assertDbCount(REMOTE, num, cls)
221 |
222 | def synchronize(self, **kwargs):
223 | call_command('synchronize', verbosity=0, **kwargs)
224 |
225 | def wait(self):
226 | """
227 | Since tests are run too fast, we need to wait for a moment, so that some ChangeLog objects
228 | could be considered "old" and not synchronized again.
229 | Waiting one second every time this method is called would lengthen tests - so instead we
230 | simulate time shift.
231 | """
232 | ChangeLog.objects.update(date=F('date') - datetime.timedelta(seconds=1))
233 | ChangeLog.objects.db_manager(REMOTE).update(date=F('date') - datetime.timedelta(seconds=1))
234 |
235 | def reset(self):
236 | reset_synchro()
237 |
238 | def assertNoActionOnSynchronize(self, sender, save=True, delete=True):
239 | def fail(**kwargs):
240 | self.fail('Signal caught - action performed.')
241 | if save:
242 | post_save.connect(fail, sender=sender)
243 | if delete:
244 | post_delete.connect(fail, sender=sender)
245 | self.synchronize()
246 | post_save.disconnect(fail, sender=sender)
247 | post_delete.disconnect(fail, sender=sender)
248 |
249 |
250 | class SimpleSynchroTests(SynchroTests):
251 | """Cover basic functionality."""
252 |
253 | def test_settings(self):
254 | """Check if test SYNCHRO_MODELS is loaded."""
255 | self.assertIn(TestModel, synchro_settings.MODELS)
256 | self.assertIn(PkModelWithSkip, synchro_settings.MODELS)
257 | self.assertNotIn(ChangeLog, synchro_settings.MODELS)
258 |
259 | def test_app_paths(self):
260 | """Check if app in SYNCHRO_MODELS can be stated in any way."""
261 | from django.contrib.auth.models import Group
262 | self.assertNotIn(Group, synchro_settings.MODELS)
263 |
264 | INSTALLED_APPS = settings.INSTALLED_APPS
265 | if 'django.contrib.auth' not in INSTALLED_APPS:
266 | INSTALLED_APPS = INSTALLED_APPS + ('django.contrib.auth',)
267 | with override_settings(INSTALLED_APPS=INSTALLED_APPS):
268 | # fully qualified path
269 | with override_settings(SYNCHRO_MODELS=('django.contrib.auth',)):
270 | reload(synchro_settings)
271 | self.assertIn(Group, synchro_settings.MODELS)
272 | # app label
273 | with override_settings(SYNCHRO_MODELS=('auth',)):
274 | reload(synchro_settings)
275 | self.assertIn(Group, synchro_settings.MODELS)
276 |
277 | # Restore previous state
278 | reload(synchro_settings)
279 | self.assertNotIn(Group, synchro_settings.MODELS)
280 |
281 | def test_settings_with_invalid_remote(self):
282 | """Check if specifying invalid remote results in exception."""
283 | with override_settings(SYNCHRO_REMOTE='invalid'):
284 | with self.assertRaises(ImproperlyConfigured):
285 | reload(synchro_settings)
286 | # Restore previous state
287 | reload(synchro_settings)
288 | self.assertEqual(REMOTE, synchro_settings.REMOTE)
289 |
290 | def test_settings_without_remote(self):
291 | """Check if lack of REMOTE in settings cause synchronization disablement."""
292 | import synchro.management.commands.synchronize
293 | try:
294 | with override_settings(SYNCHRO_REMOTE=None):
295 | reload(synchro_settings)
296 | reload(synchro.management.commands.synchronize)
297 | self.assertIsNone(synchro_settings.REMOTE)
298 | self.assertLocalCount(0, ChangeLog)
299 | TestModel.objects.create(name='James', cash=7)
300 | self.assertLocalCount(1, TestModel)
301 | # ChangeLog created successfully despite lack of REMOTE
302 | self.assertLocalCount(1, ChangeLog)
303 |
304 | self.assertRaises(CommandError, self.synchronize)
305 |
306 | finally:
307 | # Restore previous state
308 | reload(synchro_settings)
309 | reload(synchro.management.commands.synchronize)
310 | self.assertEqual(REMOTE, synchro_settings.REMOTE)
311 |
312 | def test_simple_synchro(self):
313 | """Check object creation and checkpoint storage."""
314 | prev = datetime.datetime.now()
315 | a = TestModel.objects.create(name='James', cash=7)
316 | self.assertLocalCount(1, TestModel)
317 | self.assertRemoteCount(0, TestModel)
318 | self.synchronize()
319 | self.assertLocalCount(1, TestModel)
320 | self.assertRemoteCount(1, TestModel)
321 | b = TestModel.objects.db_manager(REMOTE).all()[0]
322 | self.assertFalse(a is b)
323 | self.assertEqual(a.name, b.name)
324 | self.assertEqual(a.cash, b.cash)
325 | from synchro.models import options
326 | self.assertTrue(options.last_check >= prev.replace(microsecond=0))
327 |
328 | def test_auto_pk(self):
329 | """
330 | Test if auto pk is *not* overwritten.
331 | Although local object has the same pk as remote one, new object will be created,
332 | because pk is automatic.
333 | """
334 | some = TestModel.objects.db_manager(REMOTE).create(name='Remote James', cash=77)
335 | a = TestModel.objects.create(name='James', cash=7)
336 | self.assertEquals(a.pk, some.pk)
337 | self.synchronize()
338 | self.assertLocalCount(1, TestModel)
339 | self.assertRemoteCount(2, TestModel)
340 | self.assertTrue(TestModel.objects.db_manager(REMOTE).get(name='James'))
341 | self.assertTrue(TestModel.objects.db_manager(REMOTE).get(name='Remote James'))
342 |
343 | def test_not_auto_pk(self):
344 | """
345 | Test if explicit pk *is overwritten*.
346 | If local object has the same pk as remote one, remote object will be completely overwritten.
347 | """
348 | some = PkModelWithSkip.objects.db_manager(REMOTE).create(name='James', cash=77, visits=5)
349 | a = PkModelWithSkip.objects.create(name='James', cash=7, visits=42)
350 | self.assertEquals(a.pk, some.pk)
351 | self.synchronize()
352 | self.assertLocalCount(1, PkModelWithSkip)
353 | self.assertRemoteCount(1, PkModelWithSkip)
354 | b = PkModelWithSkip.objects.db_manager(REMOTE).get(name='James')
355 | self.assertEqual(7, b.cash)
356 | # Because whole object is copied, skipping use default value.
357 | self.assertEqual(0, b.visits)
358 |
359 | def test_change(self):
360 | """Test simple change"""
361 | a = TestModel.objects.create(name='James', cash=7)
362 | self.synchronize()
363 | self.wait()
364 | a.name = 'Bond'
365 | a.save()
366 | self.synchronize()
367 | b = TestModel.objects.db_manager(REMOTE).get(cash=7)
368 | self.assertEqual(a.name, b.name)
369 |
370 | def test_skipping_add(self):
371 | """Test if field is skipped during creation - that is, cleared."""
372 | PkModelWithSkip.objects.create(name='James', cash=7, visits=42)
373 | self.synchronize()
374 | b = PkModelWithSkip.objects.db_manager(REMOTE).get(name='James')
375 | self.assertEqual(7, b.cash)
376 | self.assertEqual(0, b.visits) # Skipping use default value when creating
377 |
378 | def test_skipping_change(self):
379 | """Test if field is skipped."""
380 | a = PkModelWithSkip.objects.create(name='James', cash=7)
381 | self.synchronize()
382 | b = PkModelWithSkip.objects.db_manager(REMOTE).get(name='James')
383 | b.visits = 42
384 | b.save()
385 | self.wait()
386 | a.cash = 77
387 | a.save()
388 | self.synchronize()
389 | b = PkModelWithSkip.objects.db_manager(REMOTE).get(name='James')
390 | self.assertEqual(a.cash, b.cash)
391 | self.assertEqual(42, b.visits)
392 |
393 | def test_deletion(self):
394 | """Test deletion."""
395 | a = TestModel.objects.create(name='James', cash=7)
396 | self.synchronize()
397 | self.assertRemoteCount(1, TestModel)
398 | self.wait()
399 | a.delete()
400 | self.synchronize()
401 | self.assertRemoteCount(0, TestModel)
402 |
403 | def test_untracked_deletion(self):
404 | """Test if deletion is not performed on lack of Reference and key."""
405 | TestModel.objects.db_manager(REMOTE).create(name='James', cash=7)
406 | a = TestModel.objects.create(name='James', cash=7)
407 | self.reset()
408 | a.delete()
409 | self.synchronize()
410 | self.assertLocalCount(0, TestModel)
411 | self.assertRemoteCount(1, TestModel)
412 |
413 | def test_add_del(self):
414 | """Test if no unnecessary action is performed if added and deleted."""
415 | a = TestModel.objects.create(name='James')
416 | a.delete()
417 | self.assertNoActionOnSynchronize(TestModel)
418 | self.assertRemoteCount(0, TestModel)
419 |
420 | def test_chg_del(self):
421 | """Test if no unnecessary action is performed if changed and deleted."""
422 | a = TestModel.objects.create(name='James', cash=7)
423 | self.synchronize()
424 | self.wait()
425 | a.name = 'Bond'
426 | a.save()
427 | a.delete()
428 | self.assertNoActionOnSynchronize(TestModel, delete=False)
429 | self.assertRemoteCount(0, TestModel)
430 |
431 | def test_add_chg_del_add_chg(self):
432 | """Combo."""
433 | a = TestModel.objects.create(name='James', cash=7)
434 | a.name = 'Bond'
435 | a.save()
436 | a.delete()
437 | a = TestModel.objects.create(name='Vimes', cash=7)
438 | a.cash = 77
439 | a.save()
440 | self.synchronize()
441 | self.assertRemoteCount(1, TestModel)
442 | b = TestModel.objects.db_manager(REMOTE).get(name='Vimes')
443 | self.assertEqual(a.cash, b.cash)
444 |
445 | def test_reference(self):
446 | """Test if object once synchronized is linked with remote instance."""
447 | some = TestModel.objects.db_manager(REMOTE).create(name='Remote James', cash=77)
448 | a = TestModel.objects.create(name='James', cash=7)
449 | self.assertEquals(a.pk, some.pk)
450 | self.synchronize()
451 | self.assertRemoteCount(2, TestModel)
452 | b = TestModel.objects.db_manager(REMOTE).get(name='James')
453 | self.assertNotEquals(a.pk, b.pk)
454 | b.name = 'Bond'
455 | b.save() # This change will be discarded
456 | self.wait()
457 | a.cash = 42
458 | a.save()
459 | self.synchronize()
460 | b = TestModel.objects.db_manager(REMOTE).get(pk=b.pk)
461 | self.assertEqual(a.name, b.name)
462 | self.assertEqual(a.cash, b.cash)
463 |
464 | def test_reference2(self):
465 | """Test if reference is created for model found with natural key."""
466 | ModelWithKey.objects.db_manager(REMOTE).create(name='James')
467 | loc = ModelWithKey.objects.create(name='James')
468 | self.wait()
469 | ModelWithFKtoKey.objects.create(name='Test', link=loc)
470 | self.synchronize()
471 | self.assertRemoteCount(1, ModelWithFKtoKey)
472 | self.assertRemoteCount(1, ModelWithKey)
473 |
474 | def test_time_comparing(self):
475 | """Test if synchronization is not performed if REMOTE object is newer."""
476 | a = TestModel.objects.create(name="James", cash=7)
477 | self.synchronize()
478 | self.assertRemoteCount(1, TestModel)
479 | self.wait()
480 | a.cash = 42 # local change
481 | a.save()
482 | self.wait()
483 | b = TestModel.objects.db_manager(REMOTE).get(name="James")
484 | b.cash = 77 # remote change, done later
485 | b.save()
486 | self.assertNoActionOnSynchronize(TestModel)
487 | self.assertRemoteCount(1, TestModel)
488 | b = TestModel.objects.db_manager(REMOTE).get(name="James")
489 | self.assertEqual(77, b.cash) # remote object hasn't changed
490 |
491 | @skipUnless(contrib_apps('admin', 'auth', 'sessions'),
492 | 'admin, auth or sessions not in INSTALLED_APPS')
493 | @skipUnless(user_model_quite_standard(), 'Too custom User model')
494 | def test_admin(self):
495 | """Test if synchronization can be performed via admin interface."""
496 | path = reverse('synchro')
497 | user = User._default_manager.create_user('admin', 'mail', 'admin')
498 | self.client.login(username='admin', password='admin')
499 | # test if staff status is required
500 | response = self.client.get(path)
501 | try:
502 | self.assertTemplateUsed(response, 'admin/login.html')
503 | except AssertionError: # Django >= 1.7
504 | self.assertIn('location', response._headers)
505 | self.assertIn('/admin/login/', response._headers['location'][1])
506 | user.is_staff = True
507 | user.save()
508 | # superuser
509 | self.assertTemplateUsed(self.client.get(path), 'synchro.html')
510 | # actual synchronization
511 | self.reset()
512 | TestModel.objects.create(name='James', cash=7)
513 | self.assertRemoteCount(0, TestModel)
514 | self.client.post(path, {'synchro': True}) # button clicked
515 | self.assertRemoteCount(1, TestModel)
516 | # resetting
517 | self.assertGreater(ChangeLog.objects.count(), 0)
518 | self.client.post(path, {'reset': True}) # button clicked
519 | self.assertEqual(ChangeLog.objects.count(), 0)
520 |
521 | def test_translation(self):
522 | """Test if texts are translated."""
523 | from django.utils.translation import override
524 | from django.utils.encoding import force_unicode
525 | from synchro.core import call_synchronize
526 | languages = ('en', 'pl', 'de', 'es', 'fr')
527 | messages = set()
528 | for lang in languages:
529 | with override(lang):
530 | messages.add(force_unicode(call_synchronize()))
531 | self.assertEqual(len(messages), len(languages), 'Some language is missing.')
532 |
533 |
534 | class AdvancedSynchroTests(SynchroTests):
535 | """Cover additional features."""
536 |
537 | def test_manager_class(self):
538 | """Test if NaturalManager works."""
539 | self.assertIsInstance(ModelWithKey.objects, NaturalManager)
540 | self.assertIsInstance(ModelWithKey.another_objects, NaturalManager)
541 | # Test if it subclasses user manager as well
542 | self.assertIsInstance(ModelWithKey.objects, CustomManager)
543 | self.assertIsInstance(ModelWithKey.another_objects, CustomManager)
544 | self.assertEqual('bar', ModelWithKey.objects.foo())
545 | self.assertEqual('bar', ModelWithKey.another_objects.foo())
546 | # Check proper MRO: NaturalManager, user manager, Manager
547 | self.assertTrue(hasattr(ModelWithKey.objects, 'get_by_natural_key'))
548 | self.assertTrue(hasattr(ModelWithKey.another_objects, 'get_by_natural_key'))
549 | self.assertEqual('Not a single object!', ModelWithKey.objects.none())
550 | self.assertEqual('Not a single object!', ModelWithKey.another_objects.none())
551 | self.assertSequenceEqual([], ModelWithKey.objects.all())
552 | self.assertSequenceEqual([], ModelWithKey.another_objects.all())
553 |
554 | # Test get_by_natural_key
555 | obj = ModelWithKey.objects.create(name='James')
556 | self.assertEqual(obj.pk, ModelWithKey.objects.get_by_natural_key('James').pk)
557 | self.assertEqual(obj.pk, ModelWithKey.another_objects.get_by_natural_key('James').pk)
558 |
559 | # Test instantiating (DJango #13313: manager must be instantiable without arguments)
560 | try:
561 | ModelWithKey.objects.__class__()
562 | ModelWithKey.another_objects.__class__()
563 | except TypeError:
564 | self.fail('Cannot instantiate.')
565 |
566 | # Test if class checking occurs
567 | def wrong():
568 | class BadManager:
569 | pass
570 |
571 | class X(models.Model):
572 | x = models.IntegerField()
573 | objects = NaturalManager('x', manager=BadManager)
574 | self.assertRaises(ValidationError, wrong) # User manager must subclass Manager
575 |
576 | # Test if manager without fields raises exception
577 | def wrong2():
578 | class X(models.Model):
579 | x = models.IntegerField()
580 | objects = NaturalManager()
581 | self.assertRaises(AssertionError, wrong2)
582 |
583 | def test_natural_key(self):
584 | """
585 | Test if natural key works.
586 | If local object has the same key as remote one, remote object will be updated.
587 | """
588 | b = ModelWithKey.objects.db_manager(REMOTE).create(name='James', cash=77, visits=5)
589 | a = ModelWithKey.objects.create(name='James', cash=7, visits=42, pk=2)
590 | self.assertNotEquals(a.pk, b.pk)
591 | self.synchronize()
592 | self.assertLocalCount(1, ModelWithKey)
593 | self.assertRemoteCount(1, ModelWithKey)
594 | remote = ModelWithKey.objects.db_manager(REMOTE).get(name='James')
595 | self.assertEqual(7, remote.cash)
596 | # Because remote object is found, skipping use remote value (not default).
597 | self.assertEqual(5, remote.visits)
598 |
599 | def test_natural_key_deletion(self):
600 | """
601 | Test if natural key works on deletion.
602 | When no Reference exist, delete object matching natural key.
603 | """
604 | ModelWithKey.objects.db_manager(REMOTE).create(name='James', cash=77, visits=5)
605 | a = ModelWithKey.objects.create(name='James', cash=7, visits=42, pk=2)
606 | self.reset()
607 | a.delete()
608 | self.synchronize()
609 | self.assertLocalCount(0, ModelWithKey)
610 | self.assertRemoteCount(0, ModelWithKey)
611 |
612 | def test_foreign_keys(self):
613 | """Test if foreign keys are synchronized."""
614 | a = PkModelWithSkip.objects.create(name='James')
615 | self.reset() # Even if parent model is not recorded!
616 | ModelWithFK.objects.create(name='1', link=a)
617 | ModelWithFK.objects.create(name='2', link=a)
618 | self.synchronize()
619 | self.assertRemoteCount(1, PkModelWithSkip)
620 | self.assertRemoteCount(2, ModelWithFK)
621 | b = PkModelWithSkip.objects.db_manager(REMOTE).get(name='James')
622 | self.assertEqual(2, b.links.count())
623 | # Check if all submodels belong to remote db
624 | self.assertTrue(all(map(lambda x: x._state.db == REMOTE, b.links.all())))
625 |
626 | def test_disabling(self):
627 | """Test if logging can be disabled."""
628 | # with context
629 | with DisableSynchroLog():
630 | TestModel.objects.create(name='James')
631 | self.synchronize()
632 | self.assertLocalCount(1, TestModel)
633 | self.assertRemoteCount(0, TestModel)
634 |
635 | # with decorator
636 | @disable_synchro_log
637 | def create():
638 | PkModelWithSkip.objects.create(name='James')
639 | create()
640 | self.synchronize()
641 | self.assertLocalCount(1, PkModelWithSkip)
642 | self.assertRemoteCount(0, PkModelWithSkip)
643 |
644 |
645 | class SignalSynchroTests(SynchroTests):
646 | """Cover signals tests."""
647 |
648 | def test_signals_and_skip(self):
649 | """Some signal case from real life."""
650 | a = PkModelWithSkip.objects.create(name='James', cash=7)
651 | self.synchronize()
652 | self.wait()
653 | b = PkModelWithSkip.objects.db_manager(REMOTE).get(name='James')
654 | b.cash = 77 # some remote changes
655 | b.visits = 10
656 | b.save()
657 | self.assertEqual(0, a.visits)
658 | self.assertEqual(10, b.visits)
659 | # Adding some submodels
660 | self.wait()
661 | ModelWithFK.objects.create(name='1', link=a, visits=30)
662 | ModelWithFK.objects.create(name='2', link=a, visits=2)
663 | self.synchronize()
664 | self.assertRemoteCount(1, PkModelWithSkip)
665 | a = PkModelWithSkip.objects.get(name='James')
666 | b = PkModelWithSkip.objects.db_manager(REMOTE).get(name='James')
667 | self.assertEqual(32, a.visits)
668 | self.assertEqual(42, b.visits)
669 | self.assertEqual(77, b.cash) # No change in cash
670 | # Change
671 | self.wait()
672 | m2 = ModelWithFK.objects.get(name='2')
673 | m2.visits = 37
674 | m2.save()
675 | self.synchronize()
676 | a = PkModelWithSkip.objects.get(name='James')
677 | b = PkModelWithSkip.objects.db_manager(REMOTE).get(name='James')
678 | self.assertEqual(67, a.visits)
679 | self.assertEqual(77, b.visits)
680 | self.assertEqual(77, b.cash) # Still no change in cash
681 |
682 | def _test_signals_scenario(self, handler, expected):
683 | """Scenario from README."""
684 | A.objects.create()
685 | self.synchronize()
686 | b = A.objects.db_manager(REMOTE).all()[0]
687 | b.foo = 42
688 | b.save()
689 | self.wait()
690 | post_save.connect(handler, sender=X, dispatch_uid='update_bar')
691 | X.objects.create(name='X')
692 | self.synchronize()
693 | a = A.objects.all()[0]
694 | b = A.objects.db_manager(REMOTE).all()[0]
695 | self.assertEqual(2, a.bar) # handler was invoked
696 | self.assertEqual(2, b.bar) # handler was invoked
697 | self.assertEqual(1, a.foo)
698 | self.assertEqual(expected, b.foo)
699 | post_save.disconnect(sender=X, dispatch_uid='update_bar')
700 |
701 | def test_signals_bad_scenario(self):
702 | """Demonstrate bad scenario from README."""
703 | self._test_signals_scenario(update_bar_bad, 1) # BAD RESULT
704 |
705 | def test_signals_good_scenario(self):
706 | """Demonstrate solution for scenario from README (disable log)."""
707 | self._test_signals_scenario(update_bar_good_dis, 42) # GOOD RESULT
708 |
709 | def test_signals_alternative_good_scenario(self):
710 | """Demonstrate solution for scenario from README (use update)."""
711 | self._test_signals_scenario(update_bar_good_upd, 42) # GOOD RESULT
712 |
713 |
714 | class M2MSynchroTests(SynchroTests):
715 | """Cover many2many relations tests."""
716 |
717 | def test_natural_manager(self):
718 | """Test if natural manager can be instantiated when using M2M."""
719 | test = M2mModelWithKey.objects.create()
720 | obj = M2mAnother.objects.create()
721 | obj.m2m.add(test) # this would fail if NaturalManager could not be instantiated
722 | self.assertEqual(test.pk, obj.m2m.get_by_natural_key(1).pk)
723 |
724 | def test_simple_m2m(self):
725 | """Test if m2m field is synced properly."""
726 | test = M2mModelWithKey.objects.create()
727 | a = M2mAnother.objects.create()
728 |
729 | # add
730 | a.m2m.add(test)
731 | self.synchronize()
732 | self.assertRemoteCount(1, M2mAnother)
733 | self.assertRemoteCount(1, M2mModelWithKey)
734 | b = M2mAnother.objects.db_manager(REMOTE).all()[0]
735 | k = M2mModelWithKey.objects.db_manager(REMOTE).all()[0]
736 | self.assertEqual(1, b.m2m.count())
737 | self.assertEqual(1, k.r_m2m.count())
738 | b_k = b.m2m.all()[0]
739 | self.assertEqual(b_k.pk, k.pk)
740 | self.assertEqual(b_k.foo, k.foo)
741 |
742 | # clear
743 | self.wait()
744 | a.m2m.clear()
745 | self.synchronize()
746 | self.assertEqual(0, b.m2m.count())
747 | self.assertEqual(0, k.r_m2m.count())
748 |
749 | # reverse add
750 | self.wait()
751 | a2 = M2mAnother.objects.create(bar=2)
752 | test.r_m2m.add(a, a2)
753 | self.synchronize()
754 | self.assertRemoteCount(2, M2mAnother)
755 | self.assertRemoteCount(1, M2mModelWithKey)
756 | b2 = M2mAnother.objects.db_manager(REMOTE).filter(bar=2)[0]
757 | self.assertEqual(1, b.m2m.count())
758 | self.assertEqual(1, b2.m2m.count())
759 | self.assertEqual(2, k.r_m2m.count())
760 |
761 | # reverse remove
762 | self.wait()
763 | test.r_m2m.remove(a)
764 | self.synchronize()
765 | self.assertRemoteCount(2, M2mAnother)
766 | self.assertRemoteCount(1, M2mModelWithKey)
767 | self.assertEqual(0, b.m2m.count())
768 | self.assertEqual(1, b2.m2m.count())
769 | self.assertEqual(1, k.r_m2m.count())
770 |
771 | # reverse clear
772 | self.wait()
773 | test.r_m2m.clear()
774 | self.synchronize()
775 | self.assertRemoteCount(2, M2mAnother)
776 | self.assertRemoteCount(1, M2mModelWithKey)
777 | self.assertEqual(0, b.m2m.count())
778 | self.assertEqual(0, b2.m2m.count())
779 | self.assertEqual(0, k.r_m2m.count())
780 |
781 | def test_intermediary_m2m(self):
782 | """Test if m2m field with explicit intermediary is synced properly."""
783 | test = M2mNotExplicitlySynced.objects.create(foo=77)
784 | key = M2mModelWithKey.objects.create()
785 | a = M2mModelWithInter.objects.create()
786 | M2mIntermediate.objects.create(with_key=key, with_inter=a, cash=42, extra=test)
787 | self.assertEqual(1, a.m2m.count())
788 | self.assertEqual(1, key.r_m2m_i.count())
789 | self.synchronize()
790 | self.assertRemoteCount(1, M2mNotExplicitlySynced)
791 | self.assertRemoteCount(1, M2mModelWithKey)
792 | self.assertRemoteCount(1, M2mModelWithInter)
793 | b = M2mModelWithInter.objects.db_manager(REMOTE).all()[0]
794 | k = M2mModelWithKey.objects.db_manager(REMOTE).all()[0]
795 | self.assertEqual(1, b.m2m.count())
796 | self.assertEqual(1, k.r_m2m_i.count())
797 | b_k = b.m2m.all()[0]
798 | self.assertEqual(b_k.pk, k.pk)
799 | self.assertEqual(b_k.foo, k.foo)
800 | self.assertEqual(1, b.m2m.all()[0].foo)
801 | # intermediary
802 | self.assertRemoteCount(1, M2mIntermediate)
803 | inter = M2mIntermediate.objects.db_manager(REMOTE).all()[0]
804 | self.assertEqual(42, inter.cash)
805 | self.assertEqual(77, inter.extra.foo) # check if extra FK model get synced
806 |
807 | # changing
808 | self.wait()
809 | key2 = M2mModelWithKey.objects.create(foo=42)
810 | a.m2m.clear()
811 | M2mIntermediate.objects.create(with_key=key2, with_inter=a, cash=77, extra=test)
812 | self.synchronize()
813 | self.assertRemoteCount(1, M2mNotExplicitlySynced)
814 | self.assertRemoteCount(2, M2mModelWithKey)
815 | self.assertRemoteCount(1, M2mModelWithInter)
816 | b = M2mModelWithInter.objects.db_manager(REMOTE).all()[0]
817 | self.assertEqual(1, b.m2m.count())
818 | self.assertEqual(42, b.m2m.all()[0].foo)
819 | # intermediary
820 | self.assertRemoteCount(1, M2mIntermediate)
821 | inter = M2mIntermediate.objects.db_manager(REMOTE).all()[0]
822 | self.assertEqual(77, inter.cash)
823 |
824 | # intermadiate change
825 | self.wait()
826 | inter = M2mIntermediate.objects.all()[0]
827 | inter.cash = 1
828 | inter.save()
829 | self.synchronize()
830 | # No changes here
831 | self.assertRemoteCount(1, M2mNotExplicitlySynced)
832 | self.assertRemoteCount(2, M2mModelWithKey)
833 | self.assertRemoteCount(1, M2mModelWithInter)
834 | b = M2mModelWithInter.objects.db_manager(REMOTE).all()[0]
835 | self.assertEqual(1, b.m2m.count())
836 | self.assertEqual(42, b.m2m.all()[0].foo)
837 | # Still one intermediary
838 | self.assertRemoteCount(1, M2mIntermediate)
839 | inter = M2mIntermediate.objects.db_manager(REMOTE).all()[0]
840 | self.assertEqual(1, inter.cash)
841 |
842 | # Tricky: clear from other side of relation.
843 | self.wait()
844 | key2.r_m2m_i.clear()
845 | self.synchronize()
846 | b = M2mModelWithInter.objects.db_manager(REMOTE).all()[0]
847 | self.assertEqual(0, b.m2m.count())
848 | self.assertRemoteCount(0, M2mIntermediate)
849 |
850 | def test_self_m2m(self):
851 | """Test if m2m symmetrical field is synced properly."""
852 | test = M2mSelf.objects.create(foo=42)
853 | a = M2mSelf.objects.create(foo=1)
854 |
855 | # add
856 | a.m2m.add(test)
857 | self.synchronize()
858 | self.assertRemoteCount(2, M2mSelf)
859 | b = M2mSelf.objects.db_manager(REMOTE).get(foo=1)
860 | k = M2mSelf.objects.db_manager(REMOTE).get(foo=42)
861 | self.assertEqual(1, b.m2m.count())
862 | self.assertEqual(1, k.m2m.count())
863 | b_k = b.m2m.all()[0]
864 | self.assertEqual(b_k.pk, k.pk)
865 | self.assertEqual(b_k.foo, k.foo)
866 |
867 | # clear
868 | self.wait()
869 | a.m2m.clear()
870 | self.synchronize()
871 | self.assertEqual(0, b.m2m.count())
872 | self.assertEqual(0, k.m2m.count())
873 |
--------------------------------------------------------------------------------