diff --git a/.gitignore b/.gitignore index 96ab7fb..2398af7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +˜* +~build .idea .tox .* diff --git a/.travis.yml b/.travis.yml index 9f51124..faf0063 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,47 +2,23 @@ language: python services: - MySQL - PostgreSQL -python: - - 2.7 - - 3.2 - - 3.3 env: - - DJANGO="1.4.x" DBENGINE=mysql - - DJANGO="1.4.x" DBENGINE=pg - - - DJANGO="1.5.x" DBENGINE=mysql - - DJANGO="1.5.x" DBENGINE=pg - - - DJANGO="1.6.x" DBENGINE=mysql - - DJANGO="1.6.x" DBENGINE=pg - - - DJANGO="dev" DBENGINE=mysql - - DJANGO="dev" DBENGINE=pg + - TESTENV=p27d14pg + - TESTENV=p27d15pg + - TESTENV=p27d16pg + - TESTENV=p27d17pg + - TESTENV=p27dtrunkpg + - TESTENV=p33d16pg + - TESTENV=p27d16mysql install: - make install-deps script: - - make init-db ci - -matrix: - exclude: - - python: 3.2 - env: DJANGO="1.4.x" DBENGINE=mysql - - python: 3.2 - env: DJANGO="1.4.x" DBENGINE=pg - - python: 3.2 - env: DJANGO="1.5.x" DBENGINE=mysql - - python: 3.2 - env: DJANGO="1.5.x" DBENGINE=pg - - python: 3.2 - env: DJANGO="dev" DBENGINE=mysql - - python: 3.2 - env: DJANGO="dev" DBENGINE=pg - - python: 3.2 - env: DJANGO="1.6.x" DBENGINE=mysql + - make init-db + - tox -e $TESTENV after_success: diff --git a/CHANGES b/CHANGES index a1d27b4..3147602 100644 --- a/CHANGES +++ b/CHANGES @@ -1,5 +1,14 @@ Release 0.8 ----------- +* django 1.7 compatibility +* fixes typo in ``delete_selected_confirmation.html`` template +* python 3.2/3.3 compatibility + +Release 0.7.1 +------------- + +* backward compatibility updates. Do not check for concurrency if `0` is passed as version value + (ie. no value provided by the form) Release 0.7 diff --git a/Makefile b/Makefile index 45630e2..e882d23 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,12 @@ VERSION=2.0.0 BUILDDIR='~build' PYTHONPATH := ${PWD}/demo/:${PWD} -DJANGO_14=django==1.4.10 -DJANGO_15=django==1.5.5 -DJANGO_16=django==1.6.1 +DJANGO_14='django>=1.4,<1.5' +DJANGO_15='django>=1.5,<1.6' +DJANGO_16='django>=1.6,>1.7' +DJANGO_17='https://www.djangoproject.com/download/1.7c1/tarball/' DJANGO_DEV=git+git://github.com/django/django.git -DBNAME=concurrency +DBENGINE?=pg @@ -15,7 +16,8 @@ mkbuilddir: install-deps: pip install -q \ - -r requirements.pip python-coveralls + -r requirements.pip python-coveralls \ + django_extensions locale: @@ -25,17 +27,17 @@ locale: init-db: - @sh -c "if [ '${DBENGINE}' = 'mysql' ]; then mysql -e 'DROP DATABASE IF EXISTS ${DBNAME};'; fi" + @sh -c "if [ '${DBENGINE}' = 'mysql' ]; then mysql -e 'DROP DATABASE IF EXISTS concurrency;'; fi" @sh -c "if [ '${DBENGINE}' = 'mysql' ]; then pip install MySQL-python; fi" - @sh -c "if [ '${DBENGINE}' = 'mysql' ]; then mysql -e 'CREATE DATABASE IF NOT EXISTS ${DBNAME};'; fi" + @sh -c "if [ '${DBENGINE}' = 'mysql' ]; then mysql -e 'CREATE DATABASE IF NOT EXISTS concurrency;'; fi" - @sh -c "if [ '${DBENGINE}' = 'pg' ]; then psql -c 'DROP DATABASE IF EXISTS ${DBNAME};' -U postgres; fi" - @sh -c "if [ '${DBENGINE}' = 'pg' ]; then psql -c 'CREATE DATABASE ${DBNAME};' -U postgres; fi" + @sh -c "if [ '${DBENGINE}' = 'pg' ]; then psql -c 'DROP DATABASE IF EXISTS concurrency;' -U postgres; fi" + @sh -c "if [ '${DBENGINE}' = 'pg' ]; then psql -c 'CREATE DATABASE concurrency;' -U postgres; fi" @sh -c "if [ '${DBENGINE}' = 'pg' ]; then pip install -q psycopg2; fi" test: - demo/manage.py test concurrency --settings=${DJANGO_SETTINGS_MODULE} -v2 + py.test -vvv coverage: mkbuilddir @@ -46,6 +48,7 @@ ci: init-db install-deps @sh -c "if [ '${DJANGO}' = '1.4.x' ]; then pip install ${DJANGO_14}; fi" @sh -c "if [ '${DJANGO}' = '1.5.x' ]; then pip install ${DJANGO_15}; fi" @sh -c "if [ '${DJANGO}' = '1.6.x' ]; then pip install ${DJANGO_16}; fi" + @sh -c "if [ '${DJANGO}' = '1.7.x' ]; then pip install ${DJANGO_17}; fi" @sh -c "if [ '${DJANGO}' = 'dev' ]; then pip install ${DJANGO_DEV}; fi" @pip install coverage @python -c "from __future__ import print_function;import django;print('Django version:', django.get_version())" @@ -65,7 +68,7 @@ clonedigger: mkbuilddir docs: mkbuilddir mkdir -p ${BUILDDIR}/docs - sphinx-build -aE docs/source ${BUILDDIR}/docs + sphinx-build -aE docs/ ${BUILDDIR}/docs ifdef BROWSE firefox ${BUILDDIR}/docs/index.html endif diff --git a/README.rst b/README.rst index 17598fb..d799579 100644 --- a/README.rst +++ b/README.rst @@ -12,13 +12,12 @@ Django Concurrency django-concurrency is an optimistic lock [1]_ implementation for Django. -Tested with: 1.4.x, 1.5.x, 1.6.x, trunk. +Tested with: 1.4.x, 1.5.x, 1.6.x, 1.7 trunk. It prevents users from doing concurrent editing in Django both from UI and from a django command. - How it works ------------ sample code:: @@ -38,6 +37,18 @@ Now if you try:: you will get a ``RecordModifiedError`` on ``b.save()`` +Similar projects +---------------- + +Other projects that handle concurrent editing are `django-optimistic-lock`_ and `django-locking`_ anyway concurrency is "a batteries included" optimistic lock management system, here some features not available elsewhere: + + * can be applied to any model; not only your code (ie. django.contrib.auth.Group) + * works with django 1.4 and 1.5 + * handle `list-editable`_ ChangeList. (handle `#11313 `_) + * manage concurrency conflicts in admin's actions + * can intercept changes performend out of the django app (ie using pgAdmin, phpMyAdmin, Toads) (using `TriggerVersionField_` + + Links ~~~~~ @@ -76,6 +87,17 @@ Links :target: https://requires.io/github/saxix/django-concurrency/requirements/?branch=develop :alt: Requirements Status +.. |wheel| image:: https://pypip.in/wheel/blackhole/badge.png + +_list-editable: https://django-concurrency.readthedocs.org/en/latest/admin.html#list-editable + +.. _list-editable: https://django-concurrency.readthedocs.org/en/latest/admin.html#list-editable + +.. _django-locking: https://github.com/stdbrouw/django-locking + +.. _django-optimistic-lock: https://github.com/gavinwahl/django-optimistic-lock + +.. _TriggerVersionField: https://django-concurrency.readthedocs.org/en/latest/fields.html#triggerversionfield .. [1] http://en.wikipedia.org/wiki/Optimistic_concurrency_control diff --git a/concurrency/__init__.py b/concurrency/__init__.py index 1a97c18..89cca66 100755 --- a/concurrency/__init__.py +++ b/concurrency/__init__.py @@ -2,7 +2,7 @@ import datetime import os -VERSION = __version__ = (0, 8, 0, 'alpha', 0) +VERSION = __version__ = (0, 8, 0, 'final', 0) __author__ = 'sax' @@ -41,7 +41,8 @@ def get_git_changeset(): repo_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) git_log = subprocess.Popen('git log --pretty=format:%ct --quiet -1 HEAD', stdout=subprocess.PIPE, stderr=subprocess.PIPE, - shell=True, cwd=repo_dir, universal_newlines=True) + shell=True, cwd=repo_dir, + universal_newlines=True) timestamp = git_log.communicate()[0] try: timestamp = datetime.datetime.utcfromtimestamp(int(timestamp)) diff --git a/concurrency/admin.py b/concurrency/admin.py index 9f8a959..8491751 100644 --- a/concurrency/admin.py +++ b/concurrency/admin.py @@ -1,4 +1,4 @@ -## -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- from __future__ import absolute_import, unicode_literals import operator import re @@ -7,8 +7,8 @@ from django.contrib import admin, messages from django.core.exceptions import ImproperlyConfigured, ValidationError from django.db.models import Q -from django.forms.formsets import (ManagementForm, TOTAL_FORM_COUNT, INITIAL_FORM_COUNT, - MAX_NUM_FORM_COUNT) +from django.forms.formsets import (ManagementForm, TOTAL_FORM_COUNT, + INITIAL_FORM_COUNT, MAX_NUM_FORM_COUNT) from django.forms.models import BaseModelFormSet from django.utils.safestring import mark_safe from django.contrib.admin import helpers diff --git a/concurrency/api.py b/concurrency/api.py index 328a307..25cf70d 100644 --- a/concurrency/api.py +++ b/concurrency/api.py @@ -77,11 +77,13 @@ def apply_concurrency_check(model, fieldname, versionclass): :type versionclass: concurrency.fields.VersionField subclass """ if hasattr(model, '_concurrencymeta'): - raise ImproperlyConfigured("%s is already under concurrency management" % model) + return + # raise ImproperlyConfigured("%s is already under concurrency management" % model) logger.debug('Applying concurrency check to %s' % model) ver = versionclass() + # import ipdb; ipdb.set_trace() ver.contribute_to_class(model, fieldname) model._concurrencymeta._field = ver diff --git a/concurrency/db/backends/common.py b/concurrency/db/backends/common.py index 2ac64df..ba5bb99 100644 --- a/concurrency/db/backends/common.py +++ b/concurrency/db/backends/common.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- + class TriggerMixin(object): def drop_triggers(self): for trigger_name in self.list_triggers(): diff --git a/concurrency/db/backends/mysql/base.py b/concurrency/db/backends/mysql/base.py index 6e99a9c..abf3eda 100644 --- a/concurrency/db/backends/mysql/base.py +++ b/concurrency/db/backends/mysql/base.py @@ -20,5 +20,3 @@ def drop_trigger(self, trigger_name): cursor = self.cursor() result = cursor.execute("DROP TRIGGER IF EXISTS %s;" % trigger_name) return result - - diff --git a/concurrency/db/backends/mysql/creation.py b/concurrency/db/backends/mysql/creation.py index d8202e9..ec4021b 100644 --- a/concurrency/db/backends/mysql/creation.py +++ b/concurrency/db/backends/mysql/creation.py @@ -16,17 +16,18 @@ class MySQLCreation(DatabaseCreation): FOR EACH ROW SET NEW.{field.column} = OLD.{field.column}+1; """ - def _create_trigger(self, field): import MySQLdb as Database from warnings import filterwarnings, resetwarnings - filterwarnings('ignore', message='Trigger does not exist', category=Database.Warning) + filterwarnings('ignore', message='Trigger does not exist', + category=Database.Warning) opts = field.model._meta trigger_name = get_trigger_name(field, opts) - stm = self.sql.format(trigger_name=trigger_name, opts=opts, field=field) + stm = self.sql.format(trigger_name=trigger_name, + opts=opts, field=field) cursor = self.connection._clone().cursor() try: cursor.execute(stm) diff --git a/concurrency/db/backends/postgresql_psycopg2/base.py b/concurrency/db/backends/postgresql_psycopg2/base.py index 55785d1..bf9a508 100644 --- a/concurrency/db/backends/postgresql_psycopg2/base.py +++ b/concurrency/db/backends/postgresql_psycopg2/base.py @@ -20,7 +20,7 @@ def list_triggers(self): return [m[1] for m in cursor.fetchall()] def drop_trigger(self, trigger_name): - if not trigger_name in self.list_triggers(): + if trigger_name not in self.list_triggers(): return [] cursor = self.cursor() table_name = re.sub('^concurrency_(.*)_[ui]', '\\1', trigger_name) diff --git a/concurrency/db/backends/sqlite3/base.py b/concurrency/db/backends/sqlite3/base.py index e34cca8..efa2150 100644 --- a/concurrency/db/backends/sqlite3/base.py +++ b/concurrency/db/backends/sqlite3/base.py @@ -11,7 +11,7 @@ def __init__(self, *args, **kwargs): def list_triggers(self): cursor = self.cursor() - result = cursor.execute("select name from sqlite_master where type = 'trigger';") + result = cursor.execute("select name from sqlite_master where type='trigger';") return [m[0] for m in result.fetchall()] def drop_trigger(self, trigger_name): diff --git a/concurrency/fields.py b/concurrency/fields.py index 50e5d0f..f5a83da 100755 --- a/concurrency/fields.py +++ b/concurrency/fields.py @@ -146,7 +146,7 @@ def _do_update(model_instance, base_qs, using, pk_val, values, update_fields, fo break if values: - if model_instance._concurrencymeta.enabled: + if model_instance._concurrencymeta.enabled and old_version: filter_kwargs = {'pk': pk_val, version_field.attname: old_version} updated = base_qs.filter(**filter_kwargs)._update(values) >= 1 if not updated: @@ -208,7 +208,6 @@ def pre_save(self, model_instance, add): # always returns the same value return int(getattr(model_instance, self.attname, 0)) - @staticmethod def _increment_version_number(obj): old_value = get_revision_of_object(obj) diff --git a/concurrency/templates/concurrency/delete_selected_confirmation.html b/concurrency/templates/concurrency/delete_selected_confirmation.html index 420a013..03cf6ad 100644 --- a/concurrency/templates/concurrency/delete_selected_confirmation.html +++ b/concurrency/templates/concurrency/delete_selected_confirmation.html @@ -6,7 +6,7 @@ {% block breadcrumbs %} diff --git a/concurrency/templatetags/concurrency.py b/concurrency/templatetags/concurrency.py index be4b151..1cf2537 100644 --- a/concurrency/templatetags/concurrency.py +++ b/concurrency/templatetags/concurrency.py @@ -14,7 +14,8 @@ def identity(obj): returns a string representing "," of the passed object """ if hasattr(obj, '_concurrencymeta'): - return mark_safe("{0},{1}".format(unlocalize(obj.pk), get_revision_of_object(obj))) + return mark_safe("{0},{1}".format(unlocalize(obj.pk), + get_revision_of_object(obj))) else: return mark_safe(unlocalize(obj.pk)) diff --git a/demo/demoproject/demoapp/models.py b/demo/demoproject/demoapp/models.py index d252a12..369909b 100644 --- a/demo/demoproject/demoapp/models.py +++ b/demo/demoproject/demoapp/models.py @@ -3,18 +3,18 @@ class DemoModel(models.Model): - version = fields.IntegerVersionField() + # version = fields.IntegerVersionField() char = models.CharField(max_length=255) integer = models.IntegerField() class Meta: app_label = 'demoapp' - -class ProxyDemoModel(DemoModel): - class Meta: - app_label = 'demoapp' - proxy = True +# +# class ProxyDemoModel(DemoModel): +# class Meta: +# app_label = 'demoapp' +# proxy = True def proxy_factory(name): diff --git a/demo/demoproject/requirements.pip b/demo/demoproject/requirements.pip index dd38553..5e2c320 100644 --- a/demo/demoproject/requirements.pip +++ b/demo/demoproject/requirements.pip @@ -1,3 +1,3 @@ -django-import-export==0.1.5 +django-import-export==0.2.4 diff-match-patch==20121119 django-extensions diff --git a/docs/_ext/version.py b/docs/_ext/version.py index 1ccd3ee..19979a2 100644 --- a/docs/_ext/version.py +++ b/docs/_ext/version.py @@ -2,6 +2,7 @@ from sphinx import addnodes, roles from sphinx.util.console import bold from sphinx.util.compat import Directive +from sphinx.writers.html import SmartyPantsHTMLTranslator # RE for option descriptions without a '--' prefix simple_option_desc_re = re.compile( @@ -38,6 +39,64 @@ def setup(app): indextemplate="pair: %s; release", ) +class DjangoHTMLTranslator(SmartyPantsHTMLTranslator): + """ + Django-specific reST to HTML tweaks. + """ + + # Don't use border=1, which docutils does by default. + def visit_table(self, node): + self.context.append(self.compact_p) + self.compact_p = True + self._table_row_index = 0 # Needed by Sphinx + self.body.append(self.starttag(node, 'table', CLASS='docutils')) + + def depart_table(self, node): + self.compact_p = self.context.pop() + self.body.append('\n') + + def visit_desc_parameterlist(self, node): + self.body.append('(') # by default sphinx puts around the "(" + self.first_param = 1 + self.optional_param_level = 0 + self.param_separator = node.child_text_separator + self.required_params_left = sum([isinstance(c, addnodes.desc_parameter) + for c in node.children]) + + def depart_desc_parameterlist(self, node): + self.body.append(')') + + + version_text = { + 'deprecated': 'Deprecated in Django-Concurrency %s', + 'versionchanged': 'Changed in Django-Concurrency %s', + 'versionadded': 'New in Django-Concurrency %s', + } + + def visit_versionmodified(self, node): + self.body.append( + self.starttag(node, 'div', CLASS=node['type']) + ) + version_text = self.version_text.get(node['type']) + if version_text: + title = "%s%s" % ( + version_text % node['version'], + ":" if len(node) else "." + ) + self.body.append('%s ' % title) + + def depart_versionmodified(self, node): + self.body.append("\n") + + # Give each section a unique ID -- nice for custom CSS hooks + def visit_section(self, node): + old_ids = node.get('ids', []) + node['ids'] = ['s-' + i for i in old_ids] + node['ids'].extend(old_ids) + SmartyPantsHTMLTranslator.visit_section(self, node) + node['ids'] = old_ids + + class VersionDirective(Directive): has_content = True @@ -47,32 +106,24 @@ class VersionDirective(Directive): option_spec = {} def run(self): + if len(self.arguments) > 1: + msg = """Only one argument accepted for directive '{directive_name}::'. + Comments should be provided as content, + not as an extra argument.""".format(directive_name=self.name) + raise self.error(msg) + env = self.state.document.settings.env - arg0 = self.arguments[0] - is_nextversion = env.config.next_version == arg0 ret = [] node = addnodes.versionmodified() ret.append(node) - if is_nextversion: + + if self.arguments[0] == env.config.next_version: node['version'] = "Development version" else: - if len(self.arguments) == 1: - # linktext = 'Please, see the Changelog <0_0_4>' - # xrefs = roles.XRefRole()('release', linktext, linktext, self.lineno, self.state) - # node.extend(xrefs[0]) - - linktext = 'Please, see the Changelog ' - xrefs = roles.XRefRole()('doc', linktext, linktext, self.lineno, self.state) - node.extend(xrefs[0]) + node['version'] = self.arguments[0] - node['version'] = arg0 node['type'] = self.name - if len(self.arguments) == 2: - inodes, messages = self.state.inline_text(self.arguments[1], self.lineno + 1) - node.extend(inodes) - if self.content: - self.state.nested_parse(self.content, self.content_offset, node) - ret = ret + messages + if self.content: + self.state.nested_parse(self.content, self.content_offset, node) env.note_versionchange(node['type'], node['version'], node, self.lineno) return ret - diff --git a/docs/api.rst b/docs/api.rst index bd2a05a..3579a8e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -137,13 +137,24 @@ is these cirumstances you can check it manually :: ``apply_concurrency_check()`` ------------------------------ + .. versionadded:: 0.4 +.. versionchanged:: 0.8 + Add concurrency check to existing classes. .. autofunction:: concurrency.api.apply_concurrency_check +.. note:: With Django 1.7 and the new migrations management, this utility does + not work anymore. To add concurrency management to a external Model, + you need to use a migration to add a `VersionField` to the desired Model. + + +.. note:: See ``tests.auth_migrations`` for a example how to add ``IntegerVersionField`` + to ``auth.Permission``) + .. _disable_concurrency: diff --git a/docs/conf.py b/docs/conf.py index a1922d3..db9579c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -45,8 +45,8 @@ 'sphinx.ext.coverage', 'sphinx.ext.viewcode', 'version', - 'github', - 'djangodocs'] + 'github'] + intersphinx_mapping = { 'python': ('http://python.readthedocs.org/en/v2.7.3/', None), 'django': ('http://django.readthedocs.org/en/latest/', None), @@ -56,7 +56,7 @@ 'django_issue': ('https://code.djangoproject.com/ticket/%s', 'issue #'), } -next_version = '0.9' + todo_include_todos = True # Add any paths that contain templates here, relative to this directory. @@ -71,9 +71,13 @@ # The master toctree document. master_doc = 'index' +# HTML translator class for the builder +html_translator_class = "version.DjangoHTMLTranslator" + # General information about the project. project = u'Django Concurrency' -copyright = u'2012, Stefano Apostolico' +copyright = u'2012-2014, Stefano Apostolico' + # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -83,7 +87,7 @@ version = ".".join(map(str, concurrency.VERSION[0:2])) # The full version, including alpha/beta/rc tags. release = concurrency.get_version() - +next_version = '0.8' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -125,12 +129,12 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -if os.environ.get('READTHEDOCS', None) == 'True': - html_theme = "sphinx_rtd_theme" -else: - import sphinx_rtd_theme - html_theme = "sphinx_rtd_theme" - html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] +# if os.environ.get('READTHEDOCS', None) == 'True': +# html_theme = "sphinx_rtd_theme" +# else: +# import sphinx_rtd_theme +# html_theme = "sphinx_rtd_theme" +# html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the diff --git a/docs/cookbook.rst b/docs/cookbook.rst index e8f08dd..6f3d864 100644 --- a/docs/cookbook.rst +++ b/docs/cookbook.rst @@ -69,7 +69,8 @@ Add version management to new models Add version management to Django and/or plugged in applications models ----------------------------------------------------------------------- -.. versionchanged:: 0.4 +.. versionchanged:: 0.8 + Concurrency can work even with existing models, anyway if you are adding concurrency management to an existing database remember to edit the database's tables: diff --git a/docs/fields.rst b/docs/fields.rst index 08c9446..96bab8b 100644 --- a/docs/fields.rst +++ b/docs/fields.rst @@ -40,8 +40,11 @@ The trigger is automatically created during ``syncdb()`` or you can use the :ref simply add the ability to create/manipulate triggers, no changes to original code. +.. _triggers: + + ``triggers`` management command -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To easy work with |concurrency| created database triggers new command ``triggers`` is provided. It can: diff --git a/docs/index.rst b/docs/index.rst index 83d5218..6977cd0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,4 +1,4 @@ - .. include:: globals.txt +.. include:: globals.txt .. _index: ================== @@ -29,10 +29,10 @@ Overview - django-concurrency is an optimistic locking library for Django Models +django-concurrency is an optimistic locking library for Django Models - It prevents users from doing concurrent editing in Django both from UI and from a - django command. +It prevents users from doing concurrent editing in Django both from UI and from a +django command. .. note:: |concurrency| requires Django >= 1.4 @@ -79,7 +79,7 @@ to prevent other updates during the internal django ``save()`` execution. django >= 1.6 ~~~~~~~~~~~~~ - Full implementation of ``optimistic-lock`` pattern using a SQL clause like: +Full implementation of ``optimistic-lock`` pattern using a SQL clause like: .. code-block:: sql @@ -91,13 +91,13 @@ django >= 1.6 Why two protocols ? ~~~~~~~~~~~~~~~~~~~ - The initial implementation of |concurrency| used the real pattern [1]_, +The initial implementation of |concurrency| used the real pattern [1]_, but it required a partial rewrite of original Django's code and it was very hard to maintain/keep updated, for this reason starting from version 0.3, :ref:`select_for_update()` was used. With the new implementation (django 1.6) the optimistic lock pattern it -is easier to implement, starting from version 0.7 |concurrency| uses different implementation +is easier to implement. Starting from version 0.7 |concurrency| uses different implementation depending on the django version used. .. note:: From 1.0 support for django < 1.6 will be drooped diff --git a/docs/install.rst b/docs/install.rst index 467d0f0..85ec256 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -12,7 +12,7 @@ Using ``pip``:: Go to https://github.com/saxix/django-concurrency if you need to download a package or clone the repo. -|concurrency| does not need to be added into ``INSTALLED_APPS`` unless you want to run the tests +|concurrency| does not need to be added into ``INSTALLED_APPS`` unless you want to run the tests or use the templatetags. diff --git a/requirements.pip b/requirements.pip index c8da1c7..0385f9d 100644 --- a/requirements.pip +++ b/requirements.pip @@ -1,14 +1,14 @@ -six>=1.4.1 -mock>=1.0.1 -WebTest>=2.0.11 +coverage django-webtest>=1.7.5 -tox>=1.6.1 -pytest>=2.5.1 +ipdb +mock>=1.0.1 +py>=1.4.19 +pytest-cache pytest-cov>=1.6 pytest-django>=2.4 -sample_data_utils>=0.4 -sphinx_rtd_theme>=0.1.5 -Sphinx>=1.1.3 +pytest>=2.5.1 setuptools>=2.0.2 -py>=1.4.19 -coverage +six>=1.4.1 +Sphinx>=1.1.3 +tox>=1.6.1 +WebTest>=2.0.11 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..8b13789 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/apps.py b/tests/apps.py new file mode 100644 index 0000000..ac6b8ff --- /dev/null +++ b/tests/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ConcurrencyTestConfig(AppConfig): + name = 'tests' + label = 'tests' + verbose_name = 'Concurrency Tests' diff --git a/tests/auth_migrations/0001_initial.py b/tests/auth_migrations/0001_initial.py new file mode 100644 index 0000000..ce6e1c3 --- /dev/null +++ b/tests/auth_migrations/0001_initial.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.core import validators +from django.db import models, migrations +from django.utils import timezone +from concurrency.fields import IntegerVersionField + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '__first__'), + ] + + operations = [ + migrations.CreateModel( + name='Permission', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('name', models.CharField(max_length=50, verbose_name='name')), + ('content_type', models.ForeignKey(to='contenttypes.ContentType', to_field='id')), + ('codename', models.CharField(max_length=100, verbose_name='codename')), + ], + options={ + 'ordering': ('content_type__app_label', 'content_type__model', 'codename'), + 'unique_together': set([('content_type', 'codename')]), + 'verbose_name': 'permission', + 'verbose_name_plural': 'permissions', + }, + ), + migrations.CreateModel( + name='Group', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('name', models.CharField(unique=True, max_length=80, verbose_name='name')), + ('permissions', models.ManyToManyField(to='auth.Permission', verbose_name='permissions', blank=True)), + ], + options={ + 'verbose_name': 'group', + 'verbose_name_plural': 'groups', + }, + ), + migrations.CreateModel( + name='User', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(default=timezone.now, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', unique=True, max_length=30, verbose_name='username', validators=[validators.RegexValidator('^[\\w.@+-]+$', 'Enter a valid username.', 'invalid')])), + ('first_name', models.CharField(max_length=30, verbose_name='first name', blank=True)), + ('last_name', models.CharField(max_length=30, verbose_name='last name', blank=True)), + ('email', models.EmailField(max_length=75, verbose_name='email address', blank=True)), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=timezone.now, verbose_name='date joined')), + ('groups', models.ManyToManyField(to='auth.Group', verbose_name='groups', blank=True)), + ('user_permissions', models.ManyToManyField(to='auth.Permission', verbose_name='user permissions', blank=True)), + ], + options={ + 'swappable': 'AUTH_USER_MODEL', + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + }, + ), + ] diff --git a/tests/auth_migrations/__init__.py b/tests/auth_migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/auth_migrations/concurrency_add_version_to_permission.py b/tests/auth_migrations/concurrency_add_version_to_permission.py new file mode 100644 index 0000000..4bc667f --- /dev/null +++ b/tests/auth_migrations/concurrency_add_version_to_permission.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.core import validators +from django.db import models, migrations +from django.utils import timezone +from concurrency.fields import IntegerVersionField + + +class Migration(migrations.Migration): + """ + To enabe this migration you must add this code to your settings + + MIGRATION_MODULES = { + ... + ... + 'auth': 'tests.auth_migrations', + } + + """ + dependencies = [ + ('auth', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='Permission', + name='version', + field=IntegerVersionField(), + + )] diff --git a/tests/base.py b/tests/base.py index 369d486..af88f2e 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,4 +1,5 @@ import django +from django.utils import timezone import pytest from django.test import TransactionTestCase from django.contrib.auth.models import User @@ -13,18 +14,12 @@ apply_concurrency_check(Permission, 'version', IntegerVersionField) -DJANGO_TRUNK = django.VERSION[:2] == (1, 7) +DJANGO_TRUNK = django.VERSION[:2] >= (1, 8) -skipIfDjangoTrunk = pytest.mark.skipif(DJANGO_TRUNK, - reason="Skip if django == 1.7") -onlyDjangoTrunk = pytest.mark.skipif(DJANGO_TRUNK, - reason="Skip if django != 1.7") +win32only = pytest.mark.skipif("sys.platform != 'win32'") -failIfTrunk = pytest.mark.xfail(DJANGO_TRUNK, - reason="python trunk api changes") - -skipIfDjango14 = pytest.mark.skipif(django.VERSION[:2] == (1, 4), - reason="Skip if django == 1.4") +skipIfDjangoVersion = lambda v: pytest.mark.skipif(django.VERSION[:2] >= v, + reason="Skip if django>={}".format(v)) class AdminTestCase(WebTestMixin, TransactionTestCase): @@ -32,12 +27,13 @@ class AdminTestCase(WebTestMixin, TransactionTestCase): def setUp(self): super(AdminTestCase, self).setUp() + self.user, __ = User.objects.get_or_create(is_superuser=True, is_staff=True, is_active=True, + last_login=timezone.now(), email='sax@example.com', username='sax') - admin_register_models() diff --git a/conftest.py b/tests/conftest.py similarity index 87% rename from conftest.py rename to tests/conftest.py index e2c25d8..6ca6714 100644 --- a/conftest.py +++ b/tests/conftest.py @@ -4,9 +4,6 @@ warnings.filterwarnings("ignore", category=DeprecationWarning) -def pytest_collection_modifyitems(items): - pass - def pytest_configure(config): from django.conf import settings @@ -15,12 +12,13 @@ def pytest_configure(config): os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' try: - from django.apps import AppConfig + from django.apps import AppConfig # noqa import django django.setup() except ImportError: pass + def runtests(args=None): import pytest diff --git a/tests/migrations/0001_initial.py b/tests/migrations/0001_initial.py new file mode 100644 index 0000000..a1aaeeb --- /dev/null +++ b/tests/migrations/0001_initial.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import concurrency.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='AutoIncConcurrentModel', + fields=[ + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), + ('version', concurrency.fields.AutoIncVersionField(help_text='record revision number', db_column=b'cm_version_id', default=1)), + ('username', models.CharField(blank=True, null=True, max_length=30)), + ('date_field', models.DateField(blank=True, null=True)), + ], + options={ + 'verbose_name': b'AutoIncConcurrentModel', + 'verbose_name_plural': b'AutoIncConcurrentModel', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='ConcreteModel', + fields=[ + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), + ('version', concurrency.fields.IntegerVersionField(help_text='record revision number', db_column=b'cm_version_id', default=1)), + ('username', models.CharField(blank=True, null=True, max_length=30, unique=True)), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='SimpleConcurrentModel', + fields=[ + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), + ('version', concurrency.fields.IntegerVersionField(help_text='record revision number', db_column=b'cm_version_id', default=1)), + ('username', models.CharField(blank=True, null=True, max_length=30, unique=True)), + ('date_field', models.DateField(blank=True, null=True)), + ], + options={ + 'verbose_name': b'SimpleConcurrentModel', + 'verbose_name_plural': b'SimpleConcurrentModels', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='InheritedModel', + fields=[ + ('simpleconcurrentmodel_ptr', models.OneToOneField(auto_created=True, to='tests.SimpleConcurrentModel', primary_key=True, serialize=False)), + ('extra_field', models.CharField(blank=True, null=True, max_length=30, unique=True)), + ], + options={ + }, + bases=('tests.simpleconcurrentmodel',), + ), + migrations.CreateModel( + name='CustomSaveModel', + fields=[ + ('simpleconcurrentmodel_ptr', models.OneToOneField(auto_created=True, to='tests.SimpleConcurrentModel', primary_key=True, serialize=False)), + ('extra_field', models.CharField(blank=True, null=True, max_length=30, unique=True)), + ], + options={ + }, + bases=('tests.simpleconcurrentmodel',), + ), + migrations.CreateModel( + name='ConcurrencyDisabledModel', + fields=[ + ('simpleconcurrentmodel_ptr', models.OneToOneField(auto_created=True, to='tests.SimpleConcurrentModel', primary_key=True, serialize=False)), + ('dummy_char', models.CharField(blank=True, null=True, max_length=30)), + ], + options={ + }, + bases=('tests.simpleconcurrentmodel',), + ), + migrations.CreateModel( + name='TestIssue3Model', + fields=[ + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), + ('username', models.CharField(blank=True, null=True, max_length=30)), + ('last_name', models.CharField(blank=True, null=True, max_length=30)), + ('char_field', models.CharField(blank=True, null=True, max_length=30)), + ('date_field', models.DateField(blank=True, null=True)), + ('version', models.CharField(blank=True, null=True, max_length=10, default=b'abc')), + ('revision', concurrency.fields.IntegerVersionField(help_text='record revision number', db_column=b'cm_version_id', default=1)), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='TestModelGroup', + fields=[ + ('group_ptr', models.OneToOneField(auto_created=True, to='auth.Group', primary_key=True, serialize=False)), + ('username', models.CharField(verbose_name=b'username', max_length=50)), + ], + options={ + }, + bases=('auth.group',), + ), + migrations.CreateModel( + name='TriggerConcurrentModel', + fields=[ + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), + ('version', concurrency.fields.TriggerVersionField(help_text='record revision number', db_column=b'cm_version_id', default=1)), + ('username', models.CharField(blank=True, null=True, max_length=30)), + ('count', models.IntegerField(default=0)), + ], + options={ + 'verbose_name': b'TriggerConcurrentModel', + 'verbose_name_plural': b'TriggerConcurrentModels', + }, + bases=(models.Model,), + ), + migrations.CreateModel( + name='ListEditableConcurrentModel', + fields=[ + ], + options={ + 'proxy': True, + 'verbose_name': b'ListEditableConcurrentModel', + 'verbose_name_plural': b'ListEditableConcurrentModels', + }, + bases=('tests.simpleconcurrentmodel',), + ), + migrations.CreateModel( + name='NoActionsConcurrentModel', + fields=[ + ], + options={ + 'proxy': True, + 'verbose_name': b'NoActions-ConcurrentModel', + 'verbose_name_plural': b'NoActions-ConcurrentModels', + }, + bases=('tests.simpleconcurrentmodel',), + ), + migrations.CreateModel( + name='ProxyModel', + fields=[ + ], + options={ + 'proxy': True, + 'verbose_name': b'ProxyModel', + 'verbose_name_plural': b'ProxyModels', + }, + bases=('tests.simpleconcurrentmodel',), + ), + + + ] diff --git a/tests/migrations/__init__.py b/tests/migrations/__init__.py new file mode 100644 index 0000000..6aa2bd1 --- /dev/null +++ b/tests/migrations/__init__.py @@ -0,0 +1,21 @@ +""" +Django migrations + +This package does not contain South migrations. South migrations can be found +in the ``south_migrations`` package. +""" + +SOUTH_ERROR_MESSAGE = """\n +For South support, customize the SOUTH_MIGRATION_MODULES setting like so: + + SOUTH_MIGRATION_MODULES = { + 'tests': 'tests.south_migrations', + } +""" + +# Ensure the user is not using Django 1.6 or below with South +try: + from django.db import migrations # noqa +except ImportError: + from django.core.exceptions import ImproperlyConfigured + raise ImproperlyConfigured(SOUTH_ERROR_MESSAGE) diff --git a/tests/models.py b/tests/models.py index 0edaabc..f49584f 100644 --- a/tests/models.py +++ b/tests/models.py @@ -14,7 +14,7 @@ class SimpleConcurrentModel(models.Model): date_field = models.DateField(blank=True, null=True) class Meta: - app_label = 'concurrency' + app_label = 'tests' verbose_name = "SimpleConcurrentModel" verbose_name_plural = "SimpleConcurrentModels" @@ -28,7 +28,7 @@ class AutoIncConcurrentModel(models.Model): date_field = models.DateField(blank=True, null=True) class Meta: - app_label = 'concurrency' + app_label = 'tests' verbose_name = "AutoIncConcurrentModel" verbose_name_plural = "AutoIncConcurrentModel" @@ -42,7 +42,7 @@ class TriggerConcurrentModel(models.Model): count = models.IntegerField(default=0) class Meta: - app_label = 'concurrency' + app_label = 'tests' verbose_name = "TriggerConcurrentModel" verbose_name_plural = "TriggerConcurrentModels" @@ -52,7 +52,7 @@ def __unicode__(self): class ProxyModel(SimpleConcurrentModel): class Meta: - app_label = 'concurrency' + app_label = 'tests' proxy = True verbose_name = "ProxyModel" verbose_name_plural = "ProxyModels" @@ -62,7 +62,7 @@ class InheritedModel(SimpleConcurrentModel): extra_field = models.CharField(max_length=30, blank=True, null=True, unique=True) class Meta: - app_label = 'concurrency' + app_label = 'tests' class CustomSaveModel(SimpleConcurrentModel): @@ -72,7 +72,7 @@ def save(self, *args, **kwargs): super(CustomSaveModel, self).save(*args, **kwargs) class Meta: - app_label = 'concurrency' + app_label = 'tests' class AbstractModel(models.Model): @@ -80,7 +80,7 @@ class AbstractModel(models.Model): username = models.CharField(max_length=30, blank=True, null=True, unique=True) class Meta: - app_label = 'concurrency' + app_label = 'tests' abstract = True @@ -88,32 +88,33 @@ class ConcreteModel(AbstractModel): pass class Meta: - app_label = 'concurrency' + app_label = 'tests' + # class TestCustomUser(User): -# version = IntegerVersionField(db_column='cm_version_id') +# version = IntegerVersionField(db_column='cm_version_id') # # class Meta: -# app_label = 'concurrency' +# app_label = 'tests' # # def __unicode__(self): # return "{0.__class__.__name__} #{0.pk}".format(self) class TestModelGroup(Group): - #HACK: this field is here because all tests relies on that + # HACK: this field is here because all tests relies on that # and we need a 'fresh' model to check for on-the-fly addition - # of version field. (added in concurrency 0.3.0) + # of version field. (added in tests 0.3.0) username = models.CharField('username', max_length=50) class Meta: - app_label = 'concurrency' + app_label = 'tests' # class TestModelGroupWithCustomSave(TestModelGroup): # class Meta: -# app_label = 'concurrency' +# app_label = 'tests' # # def save(self, *args, **kwargs): # super(TestModelGroupWithCustomSave, self).save(*args, **kwargs) @@ -130,7 +131,7 @@ class TestIssue3Model(models.Model): revision = IntegerVersionField(db_column='cm_version_id') class Meta: - app_label = 'concurrency' + app_label = 'tests' class ListEditableConcurrentModel(SimpleConcurrentModel): @@ -139,7 +140,7 @@ class ListEditableConcurrentModel(SimpleConcurrentModel): """ class Meta: - app_label = 'concurrency' + app_label = 'tests' proxy = True verbose_name = "ListEditableConcurrentModel" verbose_name_plural = "ListEditableConcurrentModels" @@ -151,7 +152,7 @@ class NoActionsConcurrentModel(SimpleConcurrentModel): """ class Meta: - app_label = 'concurrency' + app_label = 'tests' proxy = True verbose_name = "NoActions-ConcurrentModel" verbose_name_plural = "NoActions-ConcurrentModels" @@ -161,7 +162,8 @@ class ConcurrencyDisabledModel(SimpleConcurrentModel): dummy_char = models.CharField(max_length=30, blank=True, null=True) class Meta: - app_label = 'concurrency' + app_label = 'tests' class ConcurrencyMeta: enabled = False + diff --git a/tests/settings.py b/tests/settings.py index 99002ed..d166b05 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -17,9 +17,28 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.admin', + # 'django.contrib.admin.apps.SimpleAdminConfig' 'concurrency', 'tests'] +SOUTH_MIGRATION_MODULES = { + 'tests': 'tests.south_migrations', +} + +MIGRATION_MODULES = { + 'tests': 'tests.migrations', + 'auth': 'tests.auth_migrations', +} + +MIDDLEWARE_CLASSES = ( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +) + TEMPLATE_DIRS = ['tests/templates'] LOGGING = { @@ -64,14 +83,13 @@ } } -DBNAME = os.environ.get('DBNAME', 'concurrency') -db = os.environ.get('DBENGINE', None) +db = os.environ.get('DBENGINE', 'pg') if db == 'pg': DATABASES = { 'default': { # 'ENGINE': 'django.db.backends.postgresql_psycopg2', 'ENGINE': 'concurrency.db.backends.postgresql_psycopg2', - 'NAME': DBNAME, + 'NAME': 'concurrency', 'HOST': '127.0.0.1', 'PORT': '', 'USER': 'postgres', @@ -81,7 +99,7 @@ 'default': { # 'ENGINE': 'django.db.backends.mysql', 'ENGINE': 'concurrency.db.backends.mysql', - 'NAME': DBNAME, + 'NAME': 'concurrency', 'HOST': '127.0.0.1', 'PORT': '', 'USER': 'root', @@ -94,6 +112,6 @@ DATABASES = { 'default': { 'ENGINE': 'concurrency.db.backends.sqlite3', - 'NAME': '%s.sqlite' % DBNAME, + 'NAME': 'concurrency.sqlite', 'HOST': '', 'PORT': ''}} diff --git a/tests/south_migrations/__init__.py b/tests/south_migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_admin_actions.py b/tests/test_admin_actions.py index 6e2dfe3..fe13d8d 100644 --- a/tests/test_admin_actions.py +++ b/tests/test_admin_actions.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- -# import django -from tests.base import AdminTestCase, SENTINEL, failIfTrunk +from tests.base import AdminTestCase, SENTINEL, skipIfDjangoVersion from tests.models import SimpleConcurrentModel from tests.util import unique_id @@ -31,7 +30,7 @@ def test_dummy_action(self): self.assertIn('**concurrent_update**', res) self.assertNotIn('**action_update**', res) - @failIfTrunk + @skipIfDjangoVersion((1, 7)) def test_delete_allowed_if_no_updates(self): id = next(unique_id) SimpleConcurrentModel.objects.get_or_create(pk=id) diff --git a/tests/test_admin_edit.py b/tests/test_admin_edit.py index ea0de4b..939ee24 100644 --- a/tests/test_admin_edit.py +++ b/tests/test_admin_edit.py @@ -59,7 +59,7 @@ class TestConcurrentModelAdmin(AdminTestCase): def test_standard_update(self): target, __ = SimpleConcurrentModel.objects.get_or_create(username='aaa') - url = reverse('admin:concurrency_simpleconcurrentmodel_change', args=[target.pk]) + url = reverse('admin:tests_simpleconcurrentmodel_change', args=[target.pk]) res = self.app.get(url, user='sax') target = res.context['original'] old_version = target.version @@ -71,7 +71,7 @@ def test_standard_update(self): self.assertGreater(new_version, old_version) def test_creation(self): - url = reverse('admin:concurrency_simpleconcurrentmodel_add') + url = reverse('admin:tests_simpleconcurrentmodel_add') res = self.app.get(url, user='sax') form = res.form form['username'] = 'CHAR' @@ -81,7 +81,7 @@ def test_creation(self): def test_conflict(self): target, __ = SimpleConcurrentModel.objects.get_or_create(username='aaa') - url = reverse('admin:concurrency_simpleconcurrentmodel_change', args=[target.pk]) + url = reverse('admin:tests_simpleconcurrentmodel_change', args=[target.pk]) res = self.app.get(url, user='sax') form = res.form @@ -105,7 +105,7 @@ def _create_conflict(self, pk): u.save() def test_creation(self): - url = reverse('admin:concurrency_simpleconcurrentmodel_add') + url = reverse('admin:tests_simpleconcurrentmodel_add') res = self.app.get(url, user='sax') form = res.form form['username'] = 'CHAR' @@ -114,7 +114,7 @@ def test_creation(self): self.assertGreater(SimpleConcurrentModel.objects.get(username='CHAR').version, 0) def test_creation_with_customform(self): - url = reverse('admin:concurrency_simpleconcurrentmodel_add') + url = reverse('admin:tests_simpleconcurrentmodel_add') res = self.app.get(url, user='sax') form = res.form username = next(nextname) @@ -123,14 +123,14 @@ def test_creation_with_customform(self): self.assertTrue(SimpleConcurrentModel.objects.filter(username=username).exists()) self.assertGreater(SimpleConcurrentModel.objects.get(username=username).version, 0) - #test no other errors are raised + # test no other errors are raised res = form.submit() self.assertEqual(res.status_code, 200) self.assertContains(res, "SimpleConcurrentModel with this Username already exists.") def test_standard_update(self): target, __ = SimpleConcurrentModel.objects.get_or_create(username='aaa') - url = reverse('admin:concurrency_simpleconcurrentmodel_change', args=[target.pk]) + url = reverse('admin:tests_simpleconcurrentmodel_change', args=[target.pk]) res = self.app.get(url, user='sax') target = res.context['original'] old_version = target.version @@ -144,7 +144,7 @@ def test_standard_update(self): def test_conflict(self): target, __ = SimpleConcurrentModel.objects.get_or_create(username='aaa') assert target.version - url = reverse('admin:concurrency_simpleconcurrentmodel_change', args=[target.pk]) + url = reverse('admin:tests_simpleconcurrentmodel_change', args=[target.pk]) res = self.app.get(url, user='sax') form = res.form @@ -159,7 +159,7 @@ def test_conflict(self): def test_sanity_signer(self): target, __ = SimpleConcurrentModel.objects.get_or_create(username='aaa') - url = reverse('admin:concurrency_simpleconcurrentmodel_change', args=[target.pk]) + url = reverse('admin:tests_simpleconcurrentmodel_change', args=[target.pk]) res = self.app.get(url, user='sax') form = res.form version1 = int(str(form['version'].value).split(":")[0]) diff --git a/tests/test_base.py b/tests/test_base.py index 9abe440..4de3363 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,4 +1,5 @@ import pytest +from concurrency.core import _set_version from concurrency.exceptions import RecordModifiedError from concurrency.utils import refetch from tests.util import with_all_models, unique_id, nextname, with_std_models @@ -25,3 +26,23 @@ def test_conflict(model_class): with pytest.raises(RecordModifiedError): instance.save() assert copy.get_concurrency_version() > instance.get_concurrency_version() + + +@pytest.mark.django_db(transaction=False) +@with_std_models +def test_do_not_check_if_no_version(model_class): + id = next(unique_id) + instance = model_class.objects.get_or_create(pk=id)[0] + instance.save() + + copy = refetch(instance) + copy.save() + + with pytest.raises(RecordModifiedError): + _set_version(instance, 1) + instance.save() + + _set_version(instance, 0) + instance.save() + assert instance.get_concurrency_version() > 0 + assert instance.get_concurrency_version() != copy.get_concurrency_version() diff --git a/tests/test_manager.py b/tests/test_manager.py index f41ac3a..95d4410 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -1,9 +1,10 @@ import pytest from concurrency.exceptions import RecordModifiedError from concurrency.utils import refetch -from tests.models import (SimpleConcurrentModel, AutoIncConcurrentModel, CustomSaveModel, - InheritedModel, ConcreteModel, ProxyModel) -from tests.util import with_all_models, unique_id, nextname, with_models, with_std_models +from tests.models import (SimpleConcurrentModel, AutoIncConcurrentModel, + CustomSaveModel, InheritedModel, ConcreteModel, + ProxyModel) +from tests.util import unique_id, nextname, with_models, with_std_models @pytest.mark.django_db diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 2083677..5f4242a 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -70,7 +70,7 @@ def test_in_admin(self): saved, __ = SimpleConcurrentModel.objects.get_or_create(pk=id) - url = reverse('admin:concurrency_simpleconcurrentmodel_change', args=[saved.pk]) + url = reverse('admin:tests_simpleconcurrentmodel_change', args=[saved.pk]) res = self.app.get(url, user='sax') form = res.form diff --git a/tests/test_triggerversionfield.py b/tests/test_triggerversionfield.py index be56c34..02cf396 100644 --- a/tests/test_triggerversionfield.py +++ b/tests/test_triggerversionfield.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- -from django.core import signals -from django.db import connections, IntegrityError -import mock import pytest +import mock +from django.db import connections, IntegrityError from concurrency.exceptions import RecordModifiedError from concurrency.utils import refetch from tests.models import TriggerConcurrentModel @@ -17,9 +16,6 @@ def reset_queries(**kwargs): conn.queries = [] -signals.request_started.connect(reset_queries) - - class CaptureQueriesContext(object): """ Context manager that captures queries executed by the specified connection. diff --git a/tests/urls.py b/tests/urls.py index 8519df0..f21b417 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -5,7 +5,7 @@ try: - from django.apps import AppConfig + from django.apps import AppConfig # noqa import django django.setup() except ImportError: @@ -13,9 +13,6 @@ admin.autodiscover() -class SimpleConcurrentMpdel(object): - pass - urlpatterns = patterns('', url('cm/(?P\d+)/', diff --git a/tests/util.py b/tests/util.py index 8e8a616..5782bbc 100644 --- a/tests/util.py +++ b/tests/util.py @@ -1,27 +1,19 @@ from contextlib import contextmanager from functools import partial, update_wrapper +import itertools import pytest -from sample_data_utils.utils import infinite, _sequence_counters -from sample_data_utils.sample import text # noqa from concurrency.config import conf from tests.models import * # noqa from itertools import count from tests.models import TriggerConcurrentModel -def sequence(prefix, cache=None): - if cache is None: - cache = _sequence_counters - if cache == -1: - cache = {} +def sequence(prefix): + infinite = itertools.count() + while 1: + yield "{0}-{1}".format(prefix, next(infinite)) - if prefix not in cache: - cache[prefix] = infinite() - while cache[prefix]: - yield "{0}-{1}".format(prefix, next(cache[prefix])) - - -nextname = sequence('username', cache={}) +nextname = sequence('username') unique_id = count(1) @@ -49,7 +41,7 @@ def clone_instance(model_instance): def with_models(*models, **kwargs): ignore = kwargs.pop('ignore', []) if ignore: - models = filter(models, lambda x: not x in ignore) + models = filter(models, lambda x: x not in ignore) ids = [m.__name__ for m in models] diff --git a/tox.ini b/tox.ini index bab423d..4774a8f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,14 +1,18 @@ [tox] envlist = - d14,d15,d16,trunk,py32stable,py33stable + p27d14pg,p27d15pg,p27d16pg,p27d17pg,p27dtrunkpg,p27d16mysql,p33d16pg + +[flake8] +max-complexity = 12 +max-line-length = 160 +exclude = .tox,migrations,.git,docs,diff_match_patch.py, deploy/**,settings +ignore = E501,E401,W391,E128,E261 [pytest] norecursedirs = data .tox concurrency addopts = -vvv - -p no:cacheprovider - -p no:cache - -p no:runfailed + -p no:sugar -p no:xdist -p no:pep8 --tb=short @@ -31,7 +35,8 @@ deps = -r{toxinidir}/requirements.pip commands = - make init-db ci -f {toxinidir}/Makefile + make init-db ci + #make init-db ci -f {toxinidir}/Makefile [testenv:docs] commands = @@ -40,32 +45,43 @@ setenv = DJANGO=1.4.x -[testenv:d14] +[testenv:p27d14pg] basepython = python2.7 setenv = DJANGO=1.4.x -[testenv:d15] +[testenv:p27d15pg] basepython = python2.7 setenv = DJANGO=1.5.x -[testenv:d16] +[testenv:p27d16pg] basepython = python2.7 setenv = DJANGO=1.6.x -[testenv:trunk] +[testenv:p27d17pg] +basepython = python2.7 +setenv = + DJANGO=1.7.x + +[testenv:p27dtrunkpg] basepython = python2.7 setenv = DJANGO=dev -[testenv:py32stable] +[testenv:p32d16pg] basepython = python3.2 setenv = DJANGO=1.6.x -[testenv:py33stable] +[testenv:p33d16pg] basepython = python3.3 setenv = DJANGO=1.6.x + +[testenv:p27d16mysql] +basepython = python2.7 +setenv = + DJANGO=1.6.x + DBENGINE=mysql