├── .gitignore ├── AUTHORS ├── LICENSE ├── MANIFEST.in ├── README.rst ├── runtests.py ├── setup.py └── synchro ├── __init__.py ├── apps.py ├── core.py ├── handlers.py ├── locale ├── de │ └── LC_MESSAGES │ │ └── django.po ├── es │ └── LC_MESSAGES │ │ └── django.po ├── fr │ └── LC_MESSAGES │ │ └── django.po └── pl │ └── LC_MESSAGES │ └── django.po ├── management ├── __init__.py └── commands │ ├── __init__.py │ └── synchronize.py ├── models.py ├── settings.py ├── signals.py ├── templates └── synchro.html ├── test_urls.py ├── tests.py ├── urls.py ├── utility.py └── views.py /.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 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Jacek Tomaszewski 2 | 3 | Contibutors 4 | ----------- 5 | 6 | Ivan Fedoseev 7 | Dirk Eschler 8 | Joanna Maryniak 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE runtests.py 2 | recursive-include */templates * 3 | recursive-include */locale *.po *.mo -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | default_app_config = 'synchro.apps.SynchroConfig' 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zlorf/django-synchro/c876752f727890c79f73c1026f51aab53a832ceb/synchro/management/__init__.py -------------------------------------------------------------------------------- /synchro/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zlorf/django-synchro/c876752f727890c79f73c1026f51aab53a832ceb/synchro/management/commands/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 |
19 | {% csrf_token %} 20 |
{% trans 'Last synchro time' %}: {{ last }}. 21 |

22 | 23 | {% if reset_allowed %} 24 |

26 | {% endif %} 27 |
28 |
29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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 | --------------------------------------------------------------------------------