diff --git a/AUTHORS b/AUTHORS index 7e3806e..3cabeb5 100644 --- a/AUTHORS +++ b/AUTHORS @@ -6,3 +6,4 @@ Contibutors Ivan Fedoseev Dirk Eschler Joanna Maryniak +Mlier diff --git a/README.rst b/README.rst index 240a913..6913f6e 100644 --- a/README.rst +++ b/README.rst @@ -42,8 +42,8 @@ one for every project deployment and to perform synchronization between them. Requirements ============ -The app is tested to work with Django 1.7 - 1.11. If you want to use app in older versions of Django, -use the 0.6 release. +The app is tested to work with Django 2.0 - 2.2. If you want to use app in older versions of Django 1, +use the 0.7 release. The app needs ``django-dbsettings`` to store the time of last synchronization. @@ -92,7 +92,13 @@ In order to allow performing synchronization without shell access, you can use s Include in your urls:: - url(r'^synchro/', include('synchro.urls', 'synchro', 'synchro')), + from django.urls import path, include + + urlpatterns = [ + ... + path('synchro/', include('synchro.urls')), + ... + ] Then the view will be available at reversed url: ``synchro:synchro``. @@ -384,6 +390,14 @@ Or raw way of manually changing synchro checkpoint:: Changelog ========= +**0.8.1** (23/01/2020) + - Support Django 2.0 - 2.2 and 3.0 + - Support Python 3.5 - 3.8 + +**0.8** (02/12/2019) + - Support Django 2.0 - 2.2 + - Dropped support for Django 1.8 - 1.11 + **0.7** (12/11/2017) - Support Django 1.8 - 1.11 - Dropped support for Django 1.6 and older diff --git a/runtests.py b/runtests.py index d136086..1008fc2 100755 --- a/runtests.py +++ b/runtests.py @@ -1,59 +1,15 @@ #!/usr/bin/env python +import os +import sys + import django from django.conf import settings -from django.core.management import call_command - - -if not settings.configured: - settings.configure( - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', - }, - 'remote_db': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', - } - }, - INSTALLED_APPS = ( - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sites', - 'django.contrib.sessions', - 'dbsettings', - 'synchro', - ), - SITE_ID = 1, - SYNCHRO_REMOTE = 'remote_db', - # ROOT_URLCONF ommited, because in Django 1.11 it need to be a valid module - USE_I18N = True, - MIDDLEWARE_CLASSES=( - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - ), - TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.contrib.auth.context_processors.auth', - 'django.template.context_processors.debug', - 'django.template.context_processors.i18n', - 'django.template.context_processors.media', - 'django.template.context_processors.static', - 'django.template.context_processors.tz', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, - ], - ) +from django.test.utils import get_runner -if django.VERSION >= (1, 7): +if __name__ == "__main__": + os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings' django.setup() -call_command('test', 'synchro') + TestRunner = get_runner(settings) + test_runner = TestRunner() # debug : verbosity=2, keepdb=True + failures = test_runner.run_tests(["tests"]) + sys.exit(bool(failures)) diff --git a/setup.py b/setup.py index 108c78a..c359cbc 100644 --- a/setup.py +++ b/setup.py @@ -5,14 +5,14 @@ name='django-synchro', description='Django app for database data synchronization.', long_description=open('README.rst').read(), - version='0.7', + version='0.8.1', author='Jacek Tomaszewski', author_email='jacek.tomek@gmail.com', url='https://github.com/zlorf/django-synchro', license='MIT', install_requires=( - 'django-dbsettings>=0.7', - 'django>=1.7', + 'django-dbsettings>=0.11.0', + 'django>=2.0', ), classifiers=[ 'Development Status :: 4 - Beta', @@ -21,12 +21,12 @@ 'Operating System :: OS Independent', 'Programming Language :: Python', 'Framework :: Django', - 'Framework :: Django :: 1.7', - 'Framework :: Django :: 1.8', - 'Framework :: Django :: 1.9', - 'Framework :: Django :: 1.10', - 'Framework :: Django :: 1.11', + 'Framework :: Django :: 2.0', + 'Framework :: Django :: 2.1', + 'Framework :: Django :: 2.2', + 'Framework :: Django :: 3.0', ], packages=find_packages(), include_package_data = True, + zip_safe=False, ) diff --git a/synchro/apps.py b/synchro/apps.py index 7073200..3bccb18 100644 --- a/synchro/apps.py +++ b/synchro/apps.py @@ -6,5 +6,5 @@ class SynchroConfig(AppConfig): verbose_name = 'Synchro' def ready(self): - from signals import synchro_connect + from .signals import synchro_connect synchro_connect() diff --git a/synchro/core.py b/synchro/core.py index 8dcf883..19e2f41 100644 --- a/synchro/core.py +++ b/synchro/core.py @@ -1,3 +1,3 @@ -from utility import NaturalManager, reset_synchro -from management.commands.synchronize import call_synchronize -from signals import DisableSynchroLog, disable_synchro_log +from .utility import NaturalManager, reset_synchro +from .management.commands.synchronize import call_synchronize +from .signals import DisableSynchroLog, disable_synchro_log diff --git a/synchro/handlers.py b/synchro/handlers.py index c524407..2af1152 100644 --- a/synchro/handlers.py +++ b/synchro/handlers.py @@ -1,6 +1,6 @@ -import settings +from . import settings settings.prepare() -from models import ChangeLog, DeleteKey, ADDITION, CHANGE, DELETION, M2M_CHANGE +from .models import ChangeLog, DeleteKey, ADDITION, CHANGE, DELETION, M2M_CHANGE def delete_redundant_change(cl): diff --git a/synchro/management/commands/synchronize.py b/synchro/management/commands/synchronize.py index 6628b2d..729f4ae 100644 --- a/synchro/management/commands/synchronize.py +++ b/synchro/management/commands/synchronize.py @@ -1,16 +1,17 @@ -from datetime import datetime - from django import VERSION from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.core.management.base import BaseCommand, CommandError from django.db import transaction from django.utils.translation import ugettext_lazy as _t +from django.utils import timezone from synchro.models import Reference, ChangeLog, DeleteKey, options as app_options from synchro.models import ADDITION, CHANGE, DELETION, M2M_CHANGE from synchro.settings import REMOTE, LOCAL +import pytz + if not hasattr(transaction, 'atomic'): # Django < 1.6 stub @@ -71,11 +72,11 @@ def save_with_fks(ct, obj, new_pk): old_id = obj.pk obj._state.db = REMOTE - fks = (f for f in obj._meta.fields if f.rel) + fks = (f for f in obj._meta.fields if f.remote_field) for f in fks: fk_id = f.value_from_object(obj) if fk_id is not None: - fk_ct = ContentType.objects.get_for_model(f.rel.to) + fk_ct = ContentType.objects.get_for_model(f.remote_field.model) rem, _ = ensure_exist(fk_ct, fk_id) f.save_form_data(obj, rem) @@ -100,7 +101,7 @@ def save_m2m(ct, obj, remote): for f in obj._meta.many_to_many: me = f.m2m_field_name() he_id = '%s_id' % f.m2m_reverse_field_name() - res[f.attname] = (f.rel.to, f.rel.through, me, he_id) + res[f.attname] = (f.remote_field.model, f.remote_field.through, me, he_id) if VERSION < (1, 8): m2m = obj._meta.get_all_related_many_to_many_objects() else: @@ -114,13 +115,13 @@ def save_m2m(ct, obj, remote): me = f.m2m_reverse_field_name() he_id = '%s_id' % f.m2m_field_name() related_model = rel.model if VERSION < (1, 8) else rel.related_model - res[rel.get_accessor_name()] = (related_model, f.rel.through, me, he_id) + res[rel.get_accessor_name()] = (related_model, f.remote_field.through, me, he_id) M2M_CACHE[model_name] = res _m2m = {} # handle m2m fields - for f, (to, through, me, he_id) in M2M_CACHE[model_name].iteritems(): + for f, (to, through, me, he_id) in M2M_CACHE[model_name].items(): fk_ct = ContentType.objects.get_for_model(to) out = [] if through._meta.auto_created: @@ -135,9 +136,9 @@ def save_m2m(ct, obj, remote): out.append(inter) _m2m[f] = not through._meta.auto_created, out - for f, (intermediary, out) in _m2m.iteritems(): + for f, (intermediary, out) in _m2m.items(): if not intermediary: - setattr(remote, f, out) + getattr(remote, f).set(out) else: getattr(remote, f).clear() for inter in out: @@ -187,7 +188,7 @@ def perform_add(ct, id, log=None): change_with_fks(ct, obj, rem) rem = obj else: - new_pk = None if obj._meta.has_auto_field else obj.pk + new_pk = None if obj._meta.auto_field else obj.pk create_with_fks(ct, obj, new_pk) rem = obj ref, _ = Reference.objects.get_or_create(content_type=ct, local_object_id=id, @@ -261,7 +262,8 @@ def synchronize(self, *args, **options): raise exception_class('No REMOTE database specified in settings.') since = app_options.last_check - last_time = datetime.now() + since = since.replace(tzinfo=pytz.utc) + last_time = timezone.now() logs = ChangeLog.objects.filter(date__gt=since).select_related().order_by('date', 'pk') # Don't synchronize if object should be added/changed and later deleted; @@ -281,6 +283,7 @@ def synchronize(self, *args, **options): ACTIONS[log.action](log.content_type, log.object_id, log) if len(logs): + last_time = last_time.strftime("%Y-%m-%d %H:%M:%S.%f") app_options.last_check = last_time return _t('Synchronization performed successfully.') else: diff --git a/synchro/migrations/0001_initial.py b/synchro/migrations/0001_initial.py new file mode 100644 index 0000000..ce83822 --- /dev/null +++ b/synchro/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 2.1.10 on 2019-07-19 03:42 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='ChangeLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.CharField(max_length=20)), + ('date', models.DateTimeField(auto_now=True)), + ('action', models.PositiveSmallIntegerField(choices=[(1, 'Add'), (2, 'Change'), (3, 'Delete'), (4, 'M2m Change')])), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ], + ), + migrations.CreateModel( + name='DeleteKey', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=200)), + ('changelog', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='synchro.ChangeLog')), + ], + ), + migrations.CreateModel( + name='Reference', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('local_object_id', models.CharField(max_length=20)), + ('remote_object_id', models.CharField(max_length=20)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ], + ), + migrations.AlterUniqueTogether( + name='reference', + unique_together={('content_type', 'local_object_id')}, + ), + ] diff --git a/synchro/migrations/__init__.py b/synchro/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/synchro/models.py b/synchro/models.py index 6a31b34..436824f 100644 --- a/synchro/models.py +++ b/synchro/models.py @@ -19,11 +19,13 @@ class SynchroSettings(dbsettings.Group): last_check = dbsettings.DateTimeValue('Last synchronization', default=now()) + + options = SynchroSettings() class Reference(models.Model): - content_type = models.ForeignKey(ContentType) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) local_object_id = models.CharField(max_length=20) remote_object_id = models.CharField(max_length=20) @@ -32,7 +34,7 @@ class Meta: class ChangeLog(models.Model): - content_type = models.ForeignKey(ContentType) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.CharField(max_length=20) object = GenericForeignKey() date = models.DateTimeField(auto_now=True) @@ -43,5 +45,5 @@ def __unicode__(self): class DeleteKey(models.Model): - changelog = models.OneToOneField(ChangeLog) + changelog = models.OneToOneField(ChangeLog, on_delete=models.CASCADE) key = models.CharField(max_length=200) diff --git a/synchro/settings.py b/synchro/settings.py index 59a896c..3375d2e 100644 --- a/synchro/settings.py +++ b/synchro/settings.py @@ -43,8 +43,8 @@ def _get_remote_field(m2m): def get_intermediary(models): res = {} for model in models: - res.update((m2m.rel.through, _get_remote_field(m2m)) for m2m in model._meta.many_to_many - if not m2m.rel.through._meta.auto_created) + res.update((m2m.remote_field.through, _get_remote_field(m2m)) for m2m in model._meta.many_to_many + if not m2m.remote_field.through._meta.auto_created) return res MODELS = INTER_MODELS = [] diff --git a/synchro/signals.py b/synchro/signals.py index de1618c..4ac27c2 100644 --- a/synchro/signals.py +++ b/synchro/signals.py @@ -4,7 +4,7 @@ def synchro_connect(): - from handlers import save_changelog_add_chg, save_changelog_del, save_changelog_m2m + from .handlers import save_changelog_add_chg, save_changelog_del, save_changelog_m2m post_save.connect(save_changelog_add_chg, dispatch_uid='synchro_add_chg') post_delete.connect(save_changelog_del, dispatch_uid='synchro_del') m2m_changed.connect(save_changelog_m2m, dispatch_uid='synchro_m2m') diff --git a/synchro/urls.py b/synchro/urls.py index 975c958..c0dfea7 100644 --- a/synchro/urls.py +++ b/synchro/urls.py @@ -1,9 +1,10 @@ # flake8: noqa from django.conf.urls import url +from django.urls import path -from views import synchro +from .views import synchro urlpatterns = ( - url(r'^$', synchro, name='synchro'), + path('', synchro, name='synchro'), ) diff --git a/synchro/utility.py b/synchro/utility.py index ea5ee0d..20109fa 100644 --- a/synchro/utility.py +++ b/synchro/utility.py @@ -1,8 +1,7 @@ -from datetime import datetime - from django.core.exceptions import MultipleObjectsReturned, ValidationError from django.db.models import Manager, Model from django.db.models.base import ModelBase +from django.utils import timezone class NaturalManager(Manager): @@ -27,7 +26,7 @@ def __new__(cls, *fields, **options): Creates actual manager, which can be further subclassed and instantiated without arguments. """ if ((not fields and hasattr(cls, 'fields') and hasattr(cls, 'allow_many')) or - fields and not isinstance(fields[0], basestring)): + fields and not isinstance(fields[0], str)): # Class was already prepared. return super(NaturalManager, cls).__new__(cls) @@ -63,8 +62,7 @@ def __new__(cls, name, bases, attrs): return super(_NaturalKeyModelBase, cls).__new__(cls, name, bases, attrs) -class NaturalKeyModel(Model): - __metaclass__ = _NaturalKeyModelBase +class NaturalKeyModel(Model, metaclass=_NaturalKeyModelBase): _natural_key = () def natural_key(self): @@ -75,7 +73,7 @@ class Meta: def reset_synchro(): - from models import ChangeLog, Reference, options - options.last_check = datetime.now() + from .models import ChangeLog, Reference, options + options.last_check = timezone.now() ChangeLog.objects.all().delete() Reference.objects.all().delete() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/models.py b/tests/models.py new file mode 100644 index 0000000..b67ed2a --- /dev/null +++ b/tests/models.py @@ -0,0 +1,140 @@ +from django.db import models + +from django.dispatch import receiver +from django.db.models.signals import pre_save, post_save, post_delete +from django.db.models import F + +from synchro.signals import DisableSynchroLog, disable_synchro_log +from synchro.utility import NaturalManager, reset_synchro, NaturalKeyModel + + +class TestModel(models.Model): + name = models.CharField(max_length=10) + cash = models.IntegerField(default=0) + + +class PkModelWithSkip(models.Model): + name = models.CharField(max_length=10, primary_key=True) + cash = models.IntegerField(default=0) + visits = models.PositiveIntegerField(default=0) + SYNCHRO_SKIP = ('visits',) + + +class ModelWithFK(models.Model): + name = models.CharField(max_length=10) + visits = models.PositiveIntegerField(default=0) + link = models.ForeignKey(PkModelWithSkip, related_name='links', on_delete=models.CASCADE) + + +@receiver(pre_save, sender=ModelWithFK) +def save_prev(sender, instance, **kwargs): + """Save object's previous state (before save).""" + try: + instance._prev = sender.objects.db_manager(instance._state.db).get(pk=instance.pk) + except sender.DoesNotExist: + instance._prev = None + + +@receiver(post_save, sender=ModelWithFK) +@disable_synchro_log +def update_visits(sender, instance, created, **kwargs): + """Update parent visits.""" + if not created: + # Side note: in the statement below it should be instance._prev.link in case of link change, + # but it requires some refreshing from database (since instance._prev.link and instance.link + # are two different instances of the same object). For this test + instance.link.visits -= instance._prev.visits + instance.link.save() + instance.link.visits += instance.visits + instance.link.save() + + +class CustomManager(models.Manager): + def foo(self): + return 'bar' + + def none(self): # Overrides Manager method + return 'Not a single object!' + + +class MyNaturalManager(NaturalManager, CustomManager): + fields = ('name',) + + +class ModelWithKey(NaturalKeyModel): + name = models.CharField(max_length=10) + cash = models.IntegerField(default=0) + visits = models.PositiveIntegerField(default=0) + SYNCHRO_SKIP = ('visits',) + _natural_key = ('name',) + + objects = CustomManager() + another_objects = MyNaturalManager() + + +class ModelWithFKtoKey(models.Model): + name = models.CharField(max_length=10) + link = models.ForeignKey(ModelWithKey, related_name='links', on_delete=models.CASCADE) + + +class M2mModelWithKey(models.Model): + foo = models.IntegerField(default=1) + objects = NaturalManager('foo') + + def natural_key(self): + return self.foo, + + +class M2mAnother(models.Model): + bar = models.IntegerField(default=1) + m2m = models.ManyToManyField('M2mModelWithKey', related_name='r_m2m') + + +class M2mModelWithInter(models.Model): + bar = models.IntegerField(default=1) + m2m = models.ManyToManyField('M2mModelWithKey', related_name='r_m2m_i', + through='M2mIntermediate') + + +class M2mNotExplicitlySynced(models.Model): + # This model is not listed in SYNCHRO_MODELS + foo = models.IntegerField(default=1) + + +class M2mIntermediate(models.Model): + with_key = models.ForeignKey(M2mModelWithKey, on_delete=models.CASCADE) + with_inter = models.ForeignKey(M2mModelWithInter, on_delete=models.CASCADE) + # To get everything worse, use another FK here, in order to test intermediate sync. + extra = models.ForeignKey(M2mNotExplicitlySynced, on_delete=models.CASCADE) + cash = models.IntegerField() + + +class M2mSelf(models.Model): + foo = models.IntegerField(default=1) + m2m = models.ManyToManyField('self') + + +class A(models.Model): + foo = models.IntegerField(default=1) + bar = models.IntegerField(default=1) + + +class X(models.Model): + name = models.CharField(max_length=10) + + +def update_bar_bad(sender, using, **kwargs): + a = A.objects.db_manager(using).all()[0] + a.bar += 1 + a.save() + + +@disable_synchro_log +def update_bar_good_dis(sender, using, **kwargs): + a = A.objects.db_manager(using).all()[0] + a.bar += 1 + a.save() + + +def update_bar_good_upd(sender, using, **kwargs): + A.objects.db_manager(using).update(bar=F('bar') + 1) # update don't emmit signals diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..7bd6b77 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python +import django +from django.conf import settings +from django.core.management import call_command + + +SECRET_KEY = 'fake-key' + +DEBUG = True + +INSTALLED_APPS = ( + # Required contrib apps. + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sites', + 'django.contrib.sessions', + 'django.contrib.messages', + # Our app and it's test app. + 'dbsettings', + 'synchro', + 'tests', +) + +ROOT_URLCONF = 'tests.test_urls' + +SITE_ID = 1 +SYNCHRO_REMOTE = 'remote_db' +SYNCHRO_MODELS = ( + ('tests', 'testmodel', 'PkModelWithSkip', 'ModelWithKey', 'ModelWithFK', 'A', 'X', + 'M2mModelWithKey', 'M2mAnother', 'M2mModelWithInter', 'M2mSelf', 'ModelWithFKtoKey'), + ) + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + 'TEST': { + 'NAME': 'auto_tests', + } + }, + 'remote_db': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + } +} + +MIDDLEWARE = ( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.locale.LocaleMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', +) + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = False + +USE_TZ = True diff --git a/synchro/test_urls.py b/tests/test_urls.py similarity index 69% rename from synchro/test_urls.py rename to tests/test_urls.py index eb43781..300b25e 100644 --- a/synchro/test_urls.py +++ b/tests/test_urls.py @@ -1,9 +1,8 @@ -# flake8: noqa from django.contrib import admin from django.conf.urls import url, include urlpatterns = ( - url(r'^admin/', include(admin.site.urls)), + url(r'^admin/', admin.site.urls), url(r'^synchro/', include('synchro.urls')), ) diff --git a/synchro/tests.py b/tests/tests.py similarity index 85% rename from synchro/tests.py rename to tests/tests.py index 295cc68..db3a083 100644 --- a/synchro/tests.py +++ b/tests/tests.py @@ -4,11 +4,10 @@ from django.conf import settings from django.core.exceptions import ValidationError, ImproperlyConfigured from django.core.management import call_command, CommandError -from django.core.urlresolvers import reverse +from django.urls import reverse from django.db import models from django.db.models import F from django.db.models.signals import pre_save, post_save, post_delete -from django.dispatch import receiver from django.test import TestCase from django.test.utils import override_settings try: @@ -16,14 +15,22 @@ except ImportError: from django.utils.unittest.case import skipUnless -from models import ChangeLog -import settings as synchro_settings -from signals import DisableSynchroLog, disable_synchro_log -from utility import NaturalManager, reset_synchro, NaturalKeyModel +from importlib import reload + +from synchro.models import ChangeLog +from synchro import settings as synchro_settings +from synchro.signals import DisableSynchroLog, disable_synchro_log +from synchro.utility import NaturalManager, reset_synchro, NaturalKeyModel + +from .models import TestModel, PkModelWithSkip, PkModelWithSkip, ModelWithKey, ModelWithFK, A, X, M2mModelWithKey, \ + M2mAnother, M2mModelWithInter, M2mSelf, ModelWithFKtoKey, M2mNotExplicitlySynced, M2mIntermediate + +from .models import CustomManager, update_bar_good_upd, update_bar_bad, update_bar_good_dis from django.contrib.auth import get_user_model User = get_user_model() + def user_model_quite_standard(): "Check if installed User object is not too custom for the tests to instantiate it." from django.contrib.auth.models import User as StandardUser @@ -32,16 +39,10 @@ def user_model_quite_standard(): return True return False + LOCAL = 'default' REMOTE = settings.SYNCHRO_REMOTE -# List of test models -SETTINGS = { - 'SYNCHRO_MODELS': ( - ('synchro', 'testmodel', 'PkModelWithSkip', 'ModelWithKey', 'ModelWithFK', 'A', 'X', - 'M2mModelWithKey', 'M2mAnother', 'M2mModelWithInter', 'M2mSelf', 'ModelWithFKtoKey'), - ), - 'ROOT_URLCONF': 'synchro.test_urls', -} +SETTINGS = {} def contrib_apps(*apps): @@ -55,137 +56,6 @@ def contrib_apps(*apps): # #### Test models ################################ -class TestModel(models.Model): - name = models.CharField(max_length=10) - cash = models.IntegerField(default=0) - - -class PkModelWithSkip(models.Model): - name = models.CharField(max_length=10, primary_key=True) - cash = models.IntegerField(default=0) - visits = models.PositiveIntegerField(default=0) - SYNCHRO_SKIP = ('visits',) - - -class ModelWithFK(models.Model): - name = models.CharField(max_length=10) - visits = models.PositiveIntegerField(default=0) - link = models.ForeignKey(PkModelWithSkip, related_name='links') - - -@receiver(pre_save, sender=ModelWithFK) -def save_prev(sender, instance, **kwargs): - """Save object's previous state (before save).""" - try: - instance._prev = sender.objects.db_manager(instance._state.db).get(pk=instance.pk) - except sender.DoesNotExist: - instance._prev = None - - -@receiver(post_save, sender=ModelWithFK) -@disable_synchro_log -def update_visits(sender, instance, created, **kwargs): - """Update parent visits.""" - if not created: - # Side note: in the statement below it should be instance._prev.link in case of link change, - # but it requires some refreshing from database (since instance._prev.link and instance.link - # are two different instances of the same object). For this test - instance.link.visits -= instance._prev.visits - instance.link.save() - instance.link.visits += instance.visits - instance.link.save() - - -class CustomManager(models.Manager): - def foo(self): - return 'bar' - - def none(self): # Overrides Manager method - return 'Not a single object!' - - -class MyNaturalManager(NaturalManager, CustomManager): - fields = ('name',) - - -class ModelWithKey(NaturalKeyModel): - name = models.CharField(max_length=10) - cash = models.IntegerField(default=0) - visits = models.PositiveIntegerField(default=0) - SYNCHRO_SKIP = ('visits',) - _natural_key = ('name',) - - objects = CustomManager() - another_objects = MyNaturalManager() - - -class ModelWithFKtoKey(models.Model): - name = models.CharField(max_length=10) - link = models.ForeignKey(ModelWithKey, related_name='links') - - -class M2mModelWithKey(models.Model): - foo = models.IntegerField(default=1) - objects = NaturalManager('foo') - - def natural_key(self): - return self.foo, - - -class M2mAnother(models.Model): - bar = models.IntegerField(default=1) - m2m = models.ManyToManyField('M2mModelWithKey', related_name='r_m2m') - - -class M2mModelWithInter(models.Model): - bar = models.IntegerField(default=1) - m2m = models.ManyToManyField('M2mModelWithKey', related_name='r_m2m_i', - through='M2mIntermediate') - - -class M2mNotExplicitlySynced(models.Model): - # This model is not listed in SYNCHRO_MODELS - foo = models.IntegerField(default=1) - - -class M2mIntermediate(models.Model): - with_key = models.ForeignKey(M2mModelWithKey) - with_inter = models.ForeignKey(M2mModelWithInter) - # To get everything worse, use another FK here, in order to test intermediate sync. - extra = models.ForeignKey(M2mNotExplicitlySynced) - cash = models.IntegerField() - - -class M2mSelf(models.Model): - foo = models.IntegerField(default=1) - m2m = models.ManyToManyField('self') - - -class A(models.Model): - foo = models.IntegerField(default=1) - bar = models.IntegerField(default=1) - - -class X(models.Model): - name = models.CharField(max_length=10) - - -def update_bar_bad(sender, using, **kwargs): - a = A.objects.db_manager(using).all()[0] - a.bar += 1 - a.save() - - -@disable_synchro_log -def update_bar_good_dis(sender, using, **kwargs): - a = A.objects.db_manager(using).all()[0] - a.bar += 1 - a.save() - - -def update_bar_good_upd(sender, using, **kwargs): - A.objects.db_manager(using).update(bar=F('bar') + 1) # update don't emmit signals - # #### Tests themselves ########################### @@ -198,11 +68,7 @@ class SynchroTests(TestCase): def setUpClass(cls): """Update SYNCHRO_MODELS and reload them""" super(SynchroTests, cls).setUpClass() - if VERSION < (1, 8): - with override_settings(**SETTINGS): - reload(synchro_settings) - else: - reload(synchro_settings) + reload(synchro_settings) @classmethod def tearDownClass(cls): @@ -333,7 +199,7 @@ def test_auto_pk(self): """ some = TestModel.objects.db_manager(REMOTE).create(name='Remote James', cash=77) a = TestModel.objects.create(name='James', cash=7) - self.assertEquals(a.pk, some.pk) + self.assertEqual(a.pk, some.pk) self.synchronize() self.assertLocalCount(1, TestModel) self.assertRemoteCount(2, TestModel) @@ -347,7 +213,7 @@ def test_not_auto_pk(self): """ some = PkModelWithSkip.objects.db_manager(REMOTE).create(name='James', cash=77, visits=5) a = PkModelWithSkip.objects.create(name='James', cash=7, visits=42) - self.assertEquals(a.pk, some.pk) + self.assertEqual(a.pk, some.pk) self.synchronize() self.assertLocalCount(1, PkModelWithSkip) self.assertRemoteCount(1, PkModelWithSkip) @@ -446,11 +312,11 @@ def test_reference(self): """Test if object once synchronized is linked with remote instance.""" some = TestModel.objects.db_manager(REMOTE).create(name='Remote James', cash=77) a = TestModel.objects.create(name='James', cash=7) - self.assertEquals(a.pk, some.pk) + self.assertEqual(a.pk, some.pk) self.synchronize() self.assertRemoteCount(2, TestModel) b = TestModel.objects.db_manager(REMOTE).get(name='James') - self.assertNotEquals(a.pk, b.pk) + self.assertNotEqual(a.pk, b.pk) b.name = 'Bond' b.save() # This change will be discarded self.wait() @@ -521,13 +387,13 @@ def test_admin(self): def test_translation(self): """Test if texts are translated.""" from django.utils.translation import override - from django.utils.encoding import force_unicode + from django.utils.encoding import force_text from synchro.core import call_synchronize languages = ('en', 'pl', 'de', 'es', 'fr') messages = set() for lang in languages: with override(lang): - messages.add(force_unicode(call_synchronize())) + messages.add(force_text(call_synchronize())) self.assertEqual(len(messages), len(languages), 'Some language is missing.') @@ -587,7 +453,7 @@ def test_natural_key(self): """ b = ModelWithKey.objects.db_manager(REMOTE).create(name='James', cash=77, visits=5) a = ModelWithKey.objects.create(name='James', cash=7, visits=42, pk=2) - self.assertNotEquals(a.pk, b.pk) + self.assertNotEqual(a.pk, b.pk) self.synchronize() self.assertLocalCount(1, ModelWithKey) self.assertRemoteCount(1, ModelWithKey) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..7d0b010 --- /dev/null +++ b/tox.ini @@ -0,0 +1,17 @@ +[tox] +distribute = False +envlist = + py{35,36,37,38}-django{20,21,22}, + py{36,37,38}-django{30} + +[testenv] +downloadcache = {toxworkdir}/_download/ +commands = + python -V + django-admin.py --version + {envpython} runtests.py +deps = + django20: Django>=2.0,<2.1 + django21: Django>=2.1,<2.2 + django22: Django>=2.2,<2.3 + django30: Django>=3.0,<3.1