├── .gitignore ├── .travis.yml ├── CHANGES.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── ool └── __init__.py ├── setup.py └── tests ├── manage.py └── tests ├── __init__.py ├── admin.py ├── forms.py ├── models.py ├── settings.py ├── sqlite_settings.py ├── templates └── form.html ├── tests.py ├── travis_settings.py ├── urls.py └── views.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .coverage 3 | htmlcov 4 | dist/ 5 | *.egg-info/ 6 | sqlite_database 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - "2.7" 5 | - "3.4" 6 | - "3.5" 7 | - "3.6" 8 | env: 9 | - DB=sqlite DJANGO_PACKAGE=Django~=1.11 10 | - DB=postgres DJANGO_PACKAGE=Django~=1.11 11 | - DB=sqlite DJANGO_PACKAGE=Django~=2.0 12 | - DB=postgres DJANGO_PACKAGE=Django~=2.0 13 | - DB=postgres DJANGO_PACKAGE='--pre Django>2.0,<2.2' 14 | - DB=sqlite DJANGO_PACKAGE='--pre Django>2.0,<2.2' 15 | matrix: 16 | exclude: 17 | - python: "2.7" 18 | env: DB=sqlite DJANGO_PACKAGE=Django~=2.0 19 | - python: "2.7" 20 | env: DB=postgres DJANGO_PACKAGE=Django~=2.0 21 | - python: "2.7" 22 | env: DB=postgres DJANGO_PACKAGE='--pre Django>2.0,<2.2' 23 | - python: "2.7" 24 | env: DB=sqlite DJANGO_PACKAGE='--pre Django>2.0,<2.2' 25 | - python: "3.4" 26 | env: DB=postgres DJANGO_PACKAGE='--pre Django>2.0,<2.2' 27 | - python: "3.4" 28 | env: DB=sqlite DJANGO_PACKAGE='--pre Django>2.0,<2.2' 29 | install: 'pip install $DJANGO_PACKAGE && pip install .' 30 | script: make test SETTINGS=tests.travis_settings 31 | before_script: 32 | - psql -c 'create database test_ool;' -U postgres 33 | - pip install psycopg2-binary 34 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | CHANGES 2 | ======= 3 | 4 | 1.0.1 (unreleased) 5 | ------------------ 6 | 7 | - Nothing changed yet. 8 | 9 | 10 | 1.0.0 (2019-01-02) 11 | ------------------ 12 | - Django 1.11 support 13 | 14 | 0.1.0 (2016-12-01) 15 | ------------------ 16 | - First release 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Gavin Wahl 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are 7 | met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | * Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 16 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 17 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 18 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 19 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 20 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED 21 | TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 22 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 23 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 24 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include tests *.py 2 | recursive-include tests/templates/ *.html 3 | include .travis.yml 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TESTS=tests 2 | SETTINGS=tests.sqlite_settings 3 | 4 | test: 5 | cd tests && DJANGO_SETTINGS_MODULE=$(SETTINGS) $(COVERAGE_COMMAND) ./manage.py test $(TESTS) --verbosity=2 6 | 7 | coverage: 8 | +make test COVERAGE_COMMAND='coverage run --source=ool --branch' 9 | cd tests && coverage html 10 | 11 | .PHONY: test 12 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-optimistic-lock 2 | ====================== 3 | 4 | .. image:: https://secure.travis-ci.org/gavinwahl/django-optimistic-lock.png?branch=master 5 | :target: https://travis-ci.org/gavinwahl/django-optimistic-lock 6 | 7 | Implements an offline optimistic lock [1]_ for Django models. 8 | 9 | 10 | Usage 11 | ----- 12 | 13 | Add a ``VersionField`` and inherit from ``VersionedMixin``. 14 | 15 | .. code-block:: python 16 | 17 | from ool import VersionField, VersionedMixin 18 | 19 | class MyModel(VersionedMixin, models.Model): 20 | version = VersionField() 21 | 22 | 23 | Whenever ``MyModel`` is saved, the version will be checked to ensure 24 | the instance has not changed since it was last fetched. If there is a 25 | conflict, a ``ConcurrentUpdate`` exception will be raised. 26 | 27 | Implementation 28 | -------------- 29 | A ``VersionField`` is just an integer that increments itself every 30 | time its model is saved. ``VersionedMixin`` overrides ``_do_update`` 31 | (which is called by ``save`` to actually do the update) to add an extra 32 | condition to the update query -- that the version in the database is 33 | the same as the model's version. If they match, there have been no 34 | concurrent modifications. If they don't match, the UPDATE statement will 35 | not update any rows, and we know that someone else saved first. 36 | 37 | This produces SQL that looks something like:: 38 | 39 | UPDATE mymodel SET version = version + 1, ... WHERE id = %s AND version = %s 40 | 41 | When no rows were updated, we know someone else won and we need to raise 42 | a ``ConcurrentUpdate``. 43 | 44 | 45 | Comparison to ``django-concurrency`` 46 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 47 | `django-concurrency `_ before 48 | version 0.7 used ``SELECT FOR UPDATE`` to implement the version checking. I 49 | wanted to avoid database-level locking, so ``django-optimistic-lock`` adds a 50 | version filter to the update statement, as described by Martin Fowler [1]_. 51 | 52 | Additionally, ool takes a more minimalistic approach than 53 | django-concurrency by only doing one thing -- optimistic locking -- 54 | without any monkey-patching, middleware, settings variables, admin 55 | classes, or form fields. django-concurrency would probably make more sense 56 | if you're looking for something that will attempt to accommodate every 57 | situation out of the box. Use ool if you just want a straightforward model 58 | implementation and need to handle the UI and surrounding architecture 59 | yourself. 60 | 61 | Running the tests 62 | ----------------- 63 | :: 64 | 65 | make test 66 | 67 | 68 | .. [1] http://martinfowler.com/eaaCatalog/optimisticOfflineLock.html 69 | .. [2] https://code.djangoproject.com/ticket/16649 70 | -------------------------------------------------------------------------------- /ool/__init__.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django import forms 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.contrib.admin.widgets import AdminIntegerFieldWidget 5 | 6 | 7 | class ConcurrentUpdate(Exception): 8 | """ 9 | Raised when a model can not be saved due to a concurrent update. 10 | """ 11 | 12 | 13 | class ReadonlyInput(forms.TextInput): 14 | """ 15 | A HiddenInput would be perfect for version fields, but hidden 16 | inputs leave ugly empty rows in the admin. The version must 17 | be submitted, of course, to be checked, so we can't just use 18 | ModelAdmin.readonly_fields. 19 | 20 | Pending Django ticket #11277, this displays the version in an 21 | uneditable input so there's no empty row in the admin table. 22 | 23 | https://code.djangoproject.com/ticket/11277 24 | """ 25 | def __init__(self, *args, **kwargs): 26 | super(ReadonlyInput, self).__init__(*args, **kwargs) 27 | # just readonly, because disabled won't submit the value 28 | self.attrs['readonly'] = 'readonly' 29 | 30 | 31 | class VersionField(models.PositiveIntegerField): 32 | """ 33 | An integer field to track versions. Every time the model is saved, 34 | it is incremented by one. 35 | """ 36 | 37 | def __init__(self, *args, **kwargs): 38 | kwargs.setdefault('default', 0) 39 | super(VersionField, self).__init__(*args, **kwargs) 40 | 41 | def formfield(self, **kwargs): 42 | widget = kwargs.get('widget') 43 | if widget: 44 | if issubclass(widget, AdminIntegerFieldWidget): 45 | widget = ReadonlyInput() 46 | else: 47 | widget = forms.HiddenInput 48 | kwargs['widget'] = widget 49 | return super(VersionField, self).formfield(**kwargs) 50 | 51 | 52 | class VersionedMixin(object): 53 | """ 54 | Model mixin implementing version checking during saving. 55 | When a concurrent update is detected, saving is aborted and 56 | ConcurrentUpdate will be raised. 57 | """ 58 | 59 | def _do_update(self, base_qs, using, pk_val, values, update_fields, forced_update): 60 | version_field = self.get_version_field() 61 | 62 | # _do_update is called once for each model in the inheritance 63 | # hierarchy. We only care about the model with the version field. 64 | if version_field.model != base_qs.model: 65 | return super(VersionedMixin, self)._do_update( 66 | base_qs, using, pk_val, values, update_fields, forced_update) 67 | 68 | if version_field.attname in self.get_deferred_fields(): 69 | # With a deferred VersionField, it's not possible to do any 70 | # sensible concurrency checking, so throw an error. The 71 | # other option would be to treat deferring the VersionField 72 | # the same as excluding it from `update_fields` -- a way to 73 | # bypass checking altogether. 74 | raise RuntimeError("It doesn't make sense to save a model with a deferred VersionField") 75 | 76 | # pre_save may or may not have been called at this point, based on if 77 | # version_field is in update_fields. Since we need to reliably know the 78 | # old version, we can't increment there. 79 | old_version = version_field.value_from_object(self) 80 | 81 | # so increment it here instead. Now old_version is reliable. 82 | for i, value_tuple in enumerate(values): 83 | if isinstance(value_tuple[0], VersionField): 84 | assert old_version == value_tuple[2] 85 | values[i] = ( 86 | value_tuple[0], 87 | value_tuple[1], 88 | value_tuple[2] + 1, 89 | ) 90 | setattr(self, version_field.attname, old_version + 1) 91 | 92 | updated = super(VersionedMixin, self)._do_update( 93 | base_qs=base_qs.filter(**{version_field.attname: old_version}), 94 | using=using, 95 | pk_val=pk_val, 96 | values=values, 97 | update_fields=update_fields if values else None, # Make sure base_qs is always checked 98 | forced_update=forced_update 99 | ) 100 | 101 | if not updated and base_qs.filter(pk=pk_val).exists(): 102 | raise ConcurrentUpdate 103 | 104 | return updated 105 | 106 | def get_version_field(self): 107 | for field in self._meta.fields: 108 | if isinstance(field, VersionField): 109 | return field 110 | raise ImproperlyConfigured( 111 | 'VersionedMixin models must have a VersionField') 112 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup 3 | 4 | 5 | def read(fname): 6 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 7 | 8 | 9 | setup( 10 | name='django-optimistic-lock', 11 | version='1.0.1.dev0', 12 | description='Offline optimistic locking for Django', 13 | url='https://github.com/gavinwahl/django-optimistic-lock', 14 | long_description=read('README.rst'), 15 | license='BSD', 16 | author='Gavin Wahl', 17 | author_email='gavinwahl@gmail.com', 18 | packages=['ool'], 19 | install_requires=['django >= 1.11'], 20 | classifiers=[ 21 | 'Development Status :: 4 - Beta', 22 | 'Environment :: Web Environment', 23 | 'Framework :: Django', 24 | 'Intended Audience :: Developers', 25 | 'License :: OSI Approved :: BSD License', 26 | 'Programming Language :: Python', 27 | 'Programming Language :: Python :: 2.7', 28 | 'Programming Language :: Python :: 3.4', 29 | 'Programming Language :: Python :: 3.5', 30 | 'Programming Language :: Python :: 3.6', 31 | ], 32 | ) 33 | -------------------------------------------------------------------------------- /tests/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /tests/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gavinwahl/django-optimistic-lock/48ef79f7d2bd384ec10ed7cf23baccbc78f7949d/tests/tests/__init__.py -------------------------------------------------------------------------------- /tests/tests/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import SimpleModel 4 | 5 | admin.site.register(SimpleModel, admin.ModelAdmin) 6 | -------------------------------------------------------------------------------- /tests/tests/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from .models import SimpleModel 4 | 5 | class SimpleForm(forms.ModelForm): 6 | class Meta: 7 | model = SimpleModel 8 | fields = ['name', 'version'] 9 | -------------------------------------------------------------------------------- /tests/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from ool import VersionField, VersionedMixin 4 | 5 | 6 | class SimpleModel(VersionedMixin, models.Model): 7 | version = VersionField() 8 | name = models.CharField(max_length=100) 9 | 10 | def __unicode__(self): 11 | return self.name 12 | 13 | 14 | class ProxyModel(SimpleModel): 15 | class Meta: 16 | proxy = True 17 | 18 | 19 | class InheritedModel(SimpleModel): 20 | color = models.CharField(max_length=100) 21 | 22 | 23 | class NotVersionedModel(models.Model): 24 | name = models.CharField(max_length=100) 25 | 26 | 27 | class InheritedVersionedModel(VersionedMixin, NotVersionedModel): 28 | version = VersionField() 29 | color = models.CharField(max_length=100) 30 | 31 | 32 | class ImproperlyConfiguredModel(VersionedMixin, models.Model): 33 | pass 34 | 35 | 36 | class CounterModel(VersionedMixin, models.Model): 37 | version = VersionField() 38 | count = models.PositiveIntegerField(default=0) 39 | 40 | def __unicode__(self): 41 | return unicode(self.count) 42 | 43 | 44 | class AbstractModel(VersionedMixin, models.Model): 45 | version = VersionField() 46 | 47 | class Meta: 48 | abstract = True 49 | 50 | 51 | class ConcreteModel(AbstractModel): 52 | name = models.CharField(max_length=100) 53 | -------------------------------------------------------------------------------- /tests/tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 'w6bidenrf5q%byf-q82b%pli50i0qmweus6gt_3@k$=zg7ymd3' 2 | 3 | INSTALLED_APPS = ( 4 | 'django.contrib.sessions', 5 | 'django.contrib.contenttypes', 6 | 'django.contrib.auth', 7 | 'django.contrib.admin', 8 | 'django.contrib.staticfiles', 9 | 'tests', 10 | ) 11 | 12 | MIDDLEWARE = ( 13 | 'django.middleware.common.CommonMiddleware', 14 | 'django.middleware.csrf.CsrfViewMiddleware', 15 | 'django.contrib.sessions.middleware.SessionMiddleware', 16 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 17 | ) 18 | 19 | DATABASES = { 20 | 'default': { 21 | 'ENGINE': 'django.db.backends.sqlite3', 22 | 'NAME': 'sqlite_database', 23 | } 24 | } 25 | 26 | ROOT_URLCONF = 'tests.urls' 27 | 28 | STATIC_URL = '/static/' 29 | DEBUG = True 30 | 31 | TEMPLATES = [ 32 | { 33 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 34 | 'DIRS': [], 35 | 'APP_DIRS': True, 36 | 'OPTIONS': { 37 | 'context_processors': [ 38 | 'django.template.context_processors.debug', 39 | 'django.template.context_processors.request', 40 | 'django.contrib.auth.context_processors.auth', 41 | 'django.contrib.messages.context_processors.messages', 42 | ], 43 | }, 44 | }, 45 | ] 46 | -------------------------------------------------------------------------------- /tests/tests/sqlite_settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from tests.settings import * 4 | 5 | DATABASES = { 6 | 'default': { 7 | 'ENGINE': 'django.db.backends.sqlite3', 8 | 'NAME': '', 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/tests/templates/form.html: -------------------------------------------------------------------------------- 1 | 2 | form 3 |
4 | {{ form.as_p }} 5 | {% csrf_token %} 6 | 7 |
8 | -------------------------------------------------------------------------------- /tests/tests/tests.py: -------------------------------------------------------------------------------- 1 | from unittest import skipIf 2 | 3 | from django.test import TestCase, TransactionTestCase 4 | from django.core.exceptions import ImproperlyConfigured 5 | from django.urls import reverse 6 | from django.test.client import Client 7 | from django import db 8 | from django.db import transaction 9 | 10 | from ool import ConcurrentUpdate 11 | 12 | from .models import (SimpleModel, ProxyModel, InheritedModel, 13 | InheritedVersionedModel, ImproperlyConfiguredModel, 14 | CounterModel, ConcreteModel) 15 | from .forms import SimpleForm 16 | 17 | 18 | def refetch(model_instance): 19 | """ 20 | Gets a fresh model instance from the database 21 | """ 22 | return model_instance.__class__.objects.get(pk=model_instance.pk) 23 | 24 | 25 | class OolTests(TestCase): 26 | def different_creation_ways(self, model): 27 | # Create object using the default Manager's create method 28 | x = model.objects.create(name='baz') 29 | self.assertTrue(refetch(x).name == 'baz') 30 | x.delete() 31 | 32 | # Create object as a fresh Model instance rather than using Manager's create method 33 | x = model(name='baz') 34 | x.save() 35 | self.assertTrue(refetch(x).name == 'baz') 36 | x.delete() 37 | 38 | # Create object with preset PK value 39 | x = model(id=10, name='baz') 40 | x.save() 41 | y = refetch(x) 42 | self.assertTrue(y.name == 'baz') 43 | self.assertTrue(y.id == 10) 44 | x.delete() 45 | 46 | # Create object using default Manger's create method and preset PK value 47 | x = model.objects.create(id=10, name='foo') 48 | y = refetch(x) 49 | self.assertTrue(y.name == 'foo') 50 | self.assertTrue(y.id == 10) 51 | x.delete() 52 | 53 | def normal(self, model): 54 | x = model.objects.create(name='foo') 55 | self.assertTrue(refetch(x).name == 'foo') 56 | 57 | x.name = 'bar' 58 | x.save() 59 | 60 | self.assertTrue(refetch(x).name == 'bar') 61 | 62 | def conflict(self, model): 63 | x = model.objects.create(name='foo') 64 | 65 | # conflicting update 66 | y = refetch(x) 67 | y.save() 68 | 69 | with self.assertRaises(ConcurrentUpdate): 70 | with transaction.atomic(): 71 | x.save() 72 | self.assertEqual(refetch(x).name, 'foo') 73 | 74 | def test_version_matches_after_insert(self): 75 | x = SimpleModel(name='foo') 76 | x.save() 77 | self.assertEqual(x.version, refetch(x).version) 78 | 79 | def test_simple(self): 80 | self.different_creation_ways(SimpleModel) 81 | self.normal(SimpleModel) 82 | self.conflict(SimpleModel) 83 | self.update_fields_doesnt_update(SimpleModel) 84 | self.update_fields_still_checks(SimpleModel) 85 | 86 | def test_proxy(self): 87 | self.different_creation_ways(ProxyModel) 88 | self.normal(ProxyModel) 89 | self.conflict(ProxyModel) 90 | self.update_fields_doesnt_update(ProxyModel) 91 | self.update_fields_still_checks(ProxyModel) 92 | 93 | def test_inheritance(self): 94 | self.different_creation_ways(InheritedModel) 95 | self.normal(InheritedModel) 96 | self.conflict(InheritedModel) 97 | self.update_fields_doesnt_update(InheritedModel) 98 | self.update_fields_still_checks(InheritedModel) 99 | 100 | def test_unversioned_parent(self): 101 | self.different_creation_ways(InheritedVersionedModel) 102 | self.normal(InheritedVersionedModel) 103 | self.conflict(InheritedVersionedModel) 104 | self.update_fields_doesnt_update(InheritedVersionedModel) 105 | 106 | def test_unversioned_parent_fields(self): 107 | self.update_fields_still_checks(InheritedVersionedModel) 108 | 109 | def test_abstract(self): 110 | self.different_creation_ways(ConcreteModel) 111 | self.normal(ConcreteModel) 112 | self.conflict(ConcreteModel) 113 | self.update_fields_doesnt_update(ConcreteModel) 114 | self.update_fields_still_checks(ConcreteModel) 115 | 116 | def test_defer_version(self): 117 | """ 118 | It doesn't make sense to save after deferring version 119 | """ 120 | x = SimpleModel.objects.create(name='foo') 121 | x = SimpleModel.objects.defer('version').get(pk=x.pk) 122 | with self.assertRaises(RuntimeError): 123 | x.save() 124 | 125 | def test_defer_otherwise(self): 126 | """ 127 | We should be able to defer fields other than version 128 | """ 129 | x = SimpleModel.objects.create(name='foo') 130 | x = SimpleModel.objects.defer('name').get(pk=x.pk) 131 | x.save() 132 | 133 | def update_fields_doesnt_update(self, model): 134 | """ 135 | Calling save with update_fields not containing version doesn't update 136 | the version. 137 | """ 138 | x = model.objects.create(name='foo') 139 | y = refetch(x) 140 | 141 | y.name = 'bar' 142 | # bypass versioning by only updating a single field 143 | y.save(update_fields=['name']) 144 | # The version on the instance of y should match the database version. 145 | # This allows y to be saved again. 146 | self.assertEqual(refetch(y).version, y.version) 147 | 148 | x.save() 149 | self.assertEqual(refetch(x).name, 'foo') 150 | 151 | def update_fields_still_checks(self, model): 152 | """ 153 | Excluding the VersionField from update_fields should still check 154 | for conflicts. 155 | """ 156 | x = model.objects.create(name='foo') 157 | y = refetch(x) 158 | x.save() 159 | y.name = 'bar' 160 | with self.assertRaises(ConcurrentUpdate): 161 | y.save(update_fields=['name']) 162 | 163 | def test_get_version_field(self): 164 | self.assertEqual( 165 | SimpleModel._meta.get_field('version'), 166 | SimpleModel().get_version_field() 167 | ) 168 | 169 | with self.assertRaises(ImproperlyConfigured): 170 | ImproperlyConfiguredModel().get_version_field() 171 | 172 | 173 | class FormTests(TestCase): 174 | def setUp(self): 175 | self.obj = SimpleModel.objects.create(name='foo') 176 | 177 | def test_conflict(self): 178 | form = SimpleForm(instance=self.obj) 179 | form = SimpleForm(data=form.initial, instance=self.obj) 180 | 181 | refetch(self.obj).save() 182 | 183 | with self.assertRaises(ConcurrentUpdate): 184 | form.save() 185 | 186 | def test_tampering(self): 187 | """ 188 | When messing with the version in the form, an exception should be 189 | raised 190 | """ 191 | form = SimpleForm(instance=self.obj) 192 | data = form.initial 193 | data['version'] = str(int(data['version']) + 1) 194 | form = SimpleForm(data=data, instance=self.obj) 195 | 196 | with self.assertRaises(ConcurrentUpdate): 197 | form.save() 198 | 199 | def test_omit(self): 200 | form = SimpleForm(instance=self.obj) 201 | data = form.initial 202 | del data['version'] 203 | form = SimpleForm(data=data, instance=self.obj) 204 | self.assertFalse(form.is_valid()) 205 | 206 | def test_field_is_hidden(self): 207 | form = SimpleForm(instance=self.obj) 208 | self.assertInHTML( 209 | '', 210 | form.as_p() 211 | ) 212 | 213 | def test_actually_works(self): 214 | form = SimpleForm(instance=self.obj) 215 | data = form.initial 216 | data['name'] = 'bar' 217 | form = SimpleForm(data=data, instance=self.obj) 218 | self.obj = form.save() 219 | self.assertEqual(self.obj.name, 'bar') 220 | 221 | def test_admin(self): 222 | """ 223 | VersionFields must be rendered as a readonly text input in the admin. 224 | """ 225 | from django.contrib.auth.models import User 226 | User.objects.create_superuser( 227 | username='foo', 228 | password='foo', 229 | email='foo@example.com' 230 | ) 231 | c = Client() 232 | c.login(username='foo', password='foo') 233 | resp = c.get( 234 | reverse('admin:tests_simplemodel_change', args=(self.obj.pk,)) 235 | ) 236 | 237 | self.assertInHTML( 238 | '', 239 | resp.content.decode() 240 | ) 241 | 242 | 243 | def test_concurrently(times): 244 | """ 245 | Add this decorator to small pieces of code that you want to test 246 | concurrently to make sure they don't raise exceptions when run at the 247 | same time. E.g., some Django views that do a SELECT and then a subsequent 248 | INSERT might fail when the INSERT assumes that the data has not changed 249 | since the SELECT. 250 | """ 251 | def test_concurrently_decorator(test_func): 252 | def wrapper(*args, **kwargs): 253 | exceptions = [] 254 | import threading 255 | 256 | def call_test_func(): 257 | try: 258 | test_func(*args, **kwargs) 259 | except Exception as e: 260 | exceptions.append(e) 261 | raise 262 | finally: 263 | db.close_old_connections() 264 | threads = [] 265 | for i in range(times): 266 | threads.append(threading.Thread(target=call_test_func)) 267 | for t in threads: 268 | t.start() 269 | for t in threads: 270 | t.join() 271 | if exceptions: 272 | raise Exception( 273 | 'test_concurrently intercepted %s exceptions: %s' % 274 | (len(exceptions), exceptions)) 275 | return wrapper 276 | return test_concurrently_decorator 277 | 278 | 279 | class ThreadTests(TransactionTestCase): 280 | @skipIf(db.connection.vendor == 'sqlite', 281 | "in-memory sqlite db can't be used between threads") 282 | def test_threads(self): 283 | """ 284 | Run 25 threads, each incrementing a shared counter 5 times. 285 | """ 286 | 287 | obj = CounterModel.objects.create() 288 | transaction.commit() 289 | 290 | @test_concurrently(25) 291 | def run(): 292 | for i in range(5): 293 | while True: 294 | x = refetch(obj) 295 | transaction.commit() 296 | x.count += 1 297 | try: 298 | x.save() 299 | transaction.commit() 300 | except ConcurrentUpdate: 301 | # retry 302 | pass 303 | else: 304 | break 305 | run() 306 | 307 | self.assertEqual(refetch(obj).count, 5 * 25) 308 | -------------------------------------------------------------------------------- /tests/tests/travis_settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import os 3 | 4 | from tests.settings import * 5 | 6 | if os.environ['DB'] == 'sqlite': 7 | DATABASES = { 8 | 'default': { 9 | 'ENGINE': 'django.db.backends.sqlite3', 10 | 'NAME': '', 11 | } 12 | } 13 | else: 14 | DATABASES = { 15 | 'default': { 16 | 'ENGINE': 'django.db.backends.postgresql_psycopg2', 17 | 'NAME': 'test_ool', 18 | 'USER': 'postgres' 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | from django.contrib import admin 4 | 5 | from . import views 6 | 7 | urlpatterns = [ 8 | url(r'^form/(?P.+)/$', views.form), 9 | url(r'^admin/', admin.site.urls), 10 | ] 11 | -------------------------------------------------------------------------------- /tests/tests/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import UpdateView 2 | 3 | from .models import SimpleModel 4 | from .forms import SimpleForm 5 | 6 | 7 | form = UpdateView.as_view( 8 | form_class=SimpleForm, 9 | model=SimpleModel, 10 | template_name='form.html', 11 | success_url='/form/%(id)s/', 12 | ) 13 | --------------------------------------------------------------------------------