diff --git a/CHANGES b/CHANGES index f87cabc..fcd76b8 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,11 @@ +Release 1.3.2 (10 Sep 2016) +------------------------- +* fixes bug in ConditionalVersionField that produced 'maximum recursion error' when a model had a ManyToManyField with a field to same model (self-relation) + + Release 1.3.1 (15 Jul 2016) ------------------------- -* just pagckaging +* just packaging Release 1.3 (15 Jul 2016) diff --git a/LICENSE b/LICENSE index a376954..0f5d503 100644 --- a/LICENSE +++ b/LICENSE @@ -12,9 +12,6 @@ furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -Any use in a commercial product must be notified to the author by email -indicating company name and product name - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE diff --git a/Makefile b/Makefile index a309dfd..d77f204 100644 --- a/Makefile +++ b/Makefile @@ -12,14 +12,6 @@ develop: @pip install -U pip setuptools @sh -c "if [ '${DBENGINE}' = 'mysql' ]; then pip install MySQL-python; fi" @sh -c "if [ '${DBENGINE}' = 'pg' ]; then pip install -q psycopg2; fi" -# @sh -c "if [ '${DJANGO}' = '1.4.x' ]; then pip install 'django>=1.4,<1.5'; fi" -# @sh -c "if [ '${DJANGO}' = '1.5.x' ]; then pip install 'django>=1.5,<1.6'; fi" -# @sh -c "if [ '${DJANGO}' = '1.6.x' ]; then pip install 'django>=1.6,<1.7'; fi" -# @sh -c "if [ '${DJANGO}' = '1.7.x' ]; then pip install 'django>=1.7,<1.8'; fi" -# @sh -c "if [ '${DJANGO}' = '1.8.x' ]; then pip install 'django>=1.8,<1.9'; fi" -# @sh -c "if [ '${DJANGO}' = '1.9.x' ]; then pip install 'django>=1.9,<1.10'; fi" -# @sh -c "if [ '${DJANGO}' = 'last' ]; then pip install django; fi" -# @sh -c "if [ '${DJANGO}' = 'dev' ]; then pip install git+git://github.com/django/django.git; fi" @pip install -e .[dev] $(MAKE) .init-db @@ -32,7 +24,7 @@ develop: @sh -c "if [ '${DBENGINE}' = 'pg' ]; then psql -c 'CREATE DATABASE concurrency;' -U postgres; fi" test: - py.test -v + py.test -v --create-db qa: flake8 src/ tests/ @@ -41,7 +33,7 @@ qa: clean: - rm -fr ${BUILDDIR} dist *.egg-info .coverage coverage.xml + rm -fr ${BUILDDIR} dist *.egg-info .coverage coverage.xml .eggs find src -name __pycache__ -o -name "*.py?" -o -name "*.orig" -prune | xargs rm -rf find tests -name __pycache__ -o -name "*.py?" -o -name "*.orig" -prune | xargs rm -rf find src/concurrency/locale -name django.mo | xargs rm -f diff --git a/src/concurrency/__init__.py b/src/concurrency/__init__.py index d9cf76a..d06d5e4 100755 --- a/src/concurrency/__init__.py +++ b/src/concurrency/__init__.py @@ -5,7 +5,7 @@ __author__ = 'sax' default_app_config = 'concurrency.apps.ConcurrencyConfig' -VERSION = __version__ = (1, 3, 1, 'final', 0) +VERSION = __version__ = (1, 3, 2, 'final', 0) NAME = 'django-concurrency' diff --git a/src/concurrency/fields.py b/src/concurrency/fields.py index 5705fcb..2fb8ca3 100755 --- a/src/concurrency/fields.py +++ b/src/concurrency/fields.py @@ -8,6 +8,7 @@ from collections import OrderedDict from functools import update_wrapper +from django.db import models from django.db.models import signals from django.db.models.fields import Field from django.utils.encoding import force_text @@ -17,7 +18,7 @@ from concurrency.api import get_revision_of_object from concurrency.config import conf from concurrency.core import ConcurrencyOptions -from concurrency.utils import refetch +from concurrency.utils import refetch, fqn try: from django.apps import apps @@ -338,10 +339,12 @@ class ConditionalVersionField(AutoIncVersionField): def contribute_to_class(self, cls, name, virtual_only=False): super(ConditionalVersionField, self).contribute_to_class(cls, name, virtual_only) signals.post_init.connect(self._load_model, - sender=cls, weak=False) + sender=cls, + dispatch_uid=fqn(cls)) signals.post_save.connect(self._save_model, - sender=cls, weak=False) + sender=cls, + dispatch_uid=fqn(cls)) def _load_model(self, *args, **kwargs): instance = kwargs['instance'] @@ -365,11 +368,14 @@ def _get_hash(self, instance): if f.name not in ignore_fields]) else: fields = instance._concurrencymeta.check_fields - for field_name in fields: # do not use getattr here. we do not need extra sql to retrieve # FK. the raw value of the FK is enough - values[field_name] = opts.get_field(field_name).value_from_object(instance) + field = opts.get_field(field_name) + if isinstance(field, models.ManyToManyField): + values[field_name] = getattr(instance, field_name).values_list('pk', flat=True) + else: + values[field_name] = field.value_from_object(instance) return hashlib.sha1(force_text(values).encode('utf-8')).hexdigest() def _get_next_version(self, model_instance): diff --git a/src/concurrency/utils.py b/src/concurrency/utils.py index 0a235e1..093e82e 100644 --- a/src/concurrency/utils.py +++ b/src/concurrency/utils.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, unicode_literals +import inspect import logging import warnings @@ -121,3 +122,63 @@ def refetch(model_instance): Reload model instance from the database """ return model_instance.__class__.objects.get(pk=model_instance.pk) + + +def get_classname(o): + """ Returns the classname of an object r a class + + :param o: + :return: + """ + if inspect.isclass(o): + target = o + elif callable(o): + target = o + else: + target = o.__class__ + try: + return target.__qualname__ + except AttributeError: + return target.__name__ + + +def fqn(o): + """Returns the fully qualified class name of an object or a class + + :param o: object or class + :return: class name + + >>> fqn('str') + Traceback (most recent call last): + ... + ValueError: Invalid argument `str` + >>> class A(object): pass + >>> fqn(A) + 'wfp_commonlib.python.reflect.A' + + >>> fqn(A()) + 'wfp_commonlib.python.reflect.A' + + >>> from wfp_commonlib.python import RexList + >>> fqn(RexList.append) + 'wfp_commonlib.python.structure.RexList.append' + """ + parts = [] + + if inspect.ismethod(o): + try: + cls = o.im_class + except AttributeError: + # Python 3 eliminates im_class, substitutes __module__ and + # __qualname__ to provide similar information. + parts = (o.__module__, o.__qualname__) + else: + parts = (fqn(cls), get_classname(o)) + elif hasattr(o, '__module__'): + parts.append(o.__module__) + parts.append(get_classname(o)) + elif inspect.ismodule(o): + return o.__name__ + if not parts: + raise ValueError("Invalid argument `%s`" % o) + return ".".join(parts) diff --git a/tests/demoapp/demo/auth_migrations/0001_initial.py b/tests/demoapp/demo/auth_migrations/0001_initial.py index fd45a63..05e7ff5 100644 --- a/tests/demoapp/demo/auth_migrations/0001_initial.py +++ b/tests/demoapp/demo/auth_migrations/0001_initial.py @@ -1,77 +1,96 @@ # -*- coding: utf-8 -*- +# Generated by Django 1.9.6 on 2016-09-09 15:22 from __future__ import unicode_literals -from django.core import validators +import django.contrib.auth.models +import django.core.validators from django.db import migrations, models -from django.utils import timezone +import django.db.models.deletion +import django.utils.timezone class Migration(migrations.Migration): + + initial = True + dependencies = [ - ('contenttypes', '__first__'), + ('contenttypes', '0002_remove_content_type_name'), ] operations = [ migrations.CreateModel( - name='Permission', + name='User', 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')), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, 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(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 30 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=30, unique=True, validators=[django.core.validators.RegexValidator('^[\\w.@+-]+$', 'Enter a valid username. This value may contain only letters, numbers and @/./+/-/_ characters.')], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=30, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('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=django.utils.timezone.now, verbose_name='date joined')), ], options={ - 'ordering': ('content_type__app_label', 'content_type__model', 'codename'), - 'unique_together': set([('content_type', 'codename')]), - 'verbose_name': 'permission', - 'verbose_name_plural': 'permissions', + 'verbose_name_plural': 'users', + 'abstract': False, + 'swappable': 'AUTH_USER_MODEL', + 'verbose_name': 'user', }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], ), 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)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=80, unique=True, verbose_name='name')), ], options={ - 'verbose_name': 'group', 'verbose_name_plural': 'groups', + 'verbose_name': 'group', }, + managers=[ + ('objects', django.contrib.auth.models.GroupManager()), + ], ), migrations.CreateModel( - name='User', + name='Permission', 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', blank=True, null=True),), - ('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)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('codename', models.CharField(max_length=100, verbose_name='codename')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType', verbose_name='content type')), ], options={ - 'swappable': 'AUTH_USER_MODEL', - 'verbose_name': 'user', - 'verbose_name_plural': 'users', + 'verbose_name_plural': 'permissions', + 'ordering': ('content_type__app_label', 'content_type__model', 'codename'), + 'verbose_name': 'permission', }, + managers=[ + ('objects', django.contrib.auth.models.PermissionManager()), + ], + ), + migrations.AddField( + model_name='group', + name='permissions', + field=models.ManyToManyField(blank=True, to='auth.Permission', verbose_name='permissions'), + ), + migrations.AddField( + model_name='user', + name='groups', + field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups'), + ), + migrations.AddField( + model_name='user', + name='user_permissions', + field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'), + ), + migrations.AlterUniqueTogether( + name='permission', + unique_together=set([('content_type', 'codename')]), ), ] diff --git a/tests/demoapp/demo/migrations/0001_initial.py b/tests/demoapp/demo/migrations/0001_initial.py index c2f6bd0..1fe9761 100644 --- a/tests/demoapp/demo/migrations/0001_initial.py +++ b/tests/demoapp/demo/migrations/0001_initial.py @@ -1,12 +1,11 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.9.1 on 2016-01-09 17:12 +# Generated by Django 1.9.6 on 2016-09-09 15:41 from __future__ import unicode_literals -import django.db.models.deletion +import concurrency.fields from django.conf import settings from django.db import migrations, models - -import concurrency.fields +import django.db.models.deletion class Migration(migrations.Migration): @@ -14,10 +13,18 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('auth', '0002_concurrency_add_version_to_group'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ + migrations.CreateModel( + name='Anything', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=10)), + ], + ), migrations.CreateModel( name='AutoIncConcurrentModel', fields=[ @@ -50,6 +57,26 @@ class Migration(migrations.Migration): ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), + migrations.CreateModel( + name='ConditionalVersionModelSelfRelation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', concurrency.fields.ConditionalVersionField(default=1, help_text='record revision number')), + ('name', models.CharField(max_length=10)), + ], + ), + migrations.CreateModel( + name='ConditionalVersionModelWithoutMeta', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', concurrency.fields.ConditionalVersionField(default=1, help_text='record revision number')), + ('field1', models.CharField(blank=True, max_length=30, null=True, unique=True)), + ('field2', models.CharField(blank=True, max_length=30, null=True, unique=True)), + ('field3', models.CharField(blank=True, max_length=30, null=True, unique=True)), + ('anythings', models.ManyToManyField(to='demo.Anything')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), migrations.CreateModel( name='DropTriggerConcurrentModel', fields=[ @@ -105,6 +132,15 @@ class Migration(migrations.Migration): 'verbose_name': 'SimpleConcurrentModel', }, ), + migrations.CreateModel( + name='ThroughRelation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', concurrency.fields.ConditionalVersionField(default=1, help_text='record revision number')), + ('left', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='demo.ConditionalVersionModelSelfRelation')), + ('right', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='demo.ConditionalVersionModelSelfRelation')), + ], + ), migrations.CreateModel( name='TriggerConcurrentModel', fields=[ @@ -142,6 +178,16 @@ class Migration(migrations.Migration): ], bases=('demo.simpleconcurrentmodel',), ), + migrations.AddField( + model_name='conditionalversionmodelselfrelation', + name='relations', + field=models.ManyToManyField(blank=True, through='demo.ThroughRelation', to='demo.ConditionalVersionModelSelfRelation'), + ), + migrations.AddField( + model_name='anything', + name='a_relation', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='demo.ConditionalVersionModelWithoutMeta'), + ), migrations.CreateModel( name='ListEditableConcurrentModel', fields=[ diff --git a/tests/demoapp/demo/migrations/0002_auto_20160909_1544.py b/tests/demoapp/demo/migrations/0002_auto_20160909_1544.py new file mode 100644 index 0000000..e959cc2 --- /dev/null +++ b/tests/demoapp/demo/migrations/0002_auto_20160909_1544.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.6 on 2016-09-09 15:44 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('demo', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='conditionalversionmodelselfrelation', + name='relations', + ), + migrations.AddField( + model_name='conditionalversionmodelselfrelation', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='demo.ConditionalVersionModelSelfRelation'), + ), + ] diff --git a/tests/demoapp/demo/migrations/0002_conditionalversionmodelwithoutmeta.py b/tests/demoapp/demo/migrations/0002_conditionalversionmodelwithoutmeta.py deleted file mode 100644 index 2b89892..0000000 --- a/tests/demoapp/demo/migrations/0002_conditionalversionmodelwithoutmeta.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.conf import settings -from django.db import migrations, models - -import concurrency.fields - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('demo', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='ConditionalVersionModelWithoutMeta', - fields=[ - ('id', models.AutoField(auto_created=True, verbose_name='ID', serialize=False, primary_key=True)), - ('version', concurrency.fields.ConditionalVersionField(default=1, help_text='record revision number')), - ('field1', models.CharField(unique=True, blank=True, max_length=30, null=True)), - ('field2', models.CharField(unique=True, blank=True, max_length=30, null=True)), - ('field3', models.CharField(unique=True, blank=True, max_length=30, null=True)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, null=True)), - ], - ), - ] diff --git a/tests/demoapp/demo/migrations/0003_auto_20160224_0637.py b/tests/demoapp/demo/migrations/0003_auto_20160224_0637.py deleted file mode 100644 index d7d3b9e..0000000 --- a/tests/demoapp/demo/migrations/0003_auto_20160224_0637.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('demo', '0002_conditionalversionmodelwithoutmeta'), - ] - - operations = [ - migrations.CreateModel( - name='Anything', - fields=[ - ('id', models.AutoField(primary_key=True, verbose_name='ID', serialize=False, auto_created=True)), - ('name', models.CharField(max_length=10)), - ('a_relation', models.ForeignKey(to='demo.ConditionalVersionModelWithoutMeta')), - ], - ), - migrations.AddField( - model_name='conditionalversionmodelwithoutmeta', - name='anythings', - field=models.ManyToManyField(to='demo.Anything'), - ), - ] diff --git a/tests/demoapp/demo/models.py b/tests/demoapp/demo/models.py index 70a3690..757997e 100644 --- a/tests/demoapp/demo/models.py +++ b/tests/demoapp/demo/models.py @@ -226,6 +226,9 @@ class Anything(models.Model): name = models.CharField(max_length=10) a_relation = models.ForeignKey('demo.ConditionalVersionModelWithoutMeta') + class Meta: + app_label = 'demo' + class ConditionalVersionModelWithoutMeta(models.Model): """ @@ -240,3 +243,29 @@ class ConditionalVersionModelWithoutMeta(models.Model): class Meta: app_label = 'demo' + + +class ThroughRelation(models.Model): + version = ConditionalVersionField() + left = models.ForeignKey('demo.ConditionalVersionModelSelfRelation', + related_name='+') + right = models.ForeignKey('demo.ConditionalVersionModelSelfRelation', + related_name='+') + + class Meta: + app_label = 'demo' + + +class ConditionalVersionModelSelfRelation(models.Model): + """ + This model doesn't have ConcurrencyMeta defined. + """ + version = ConditionalVersionField() + name = models.CharField(max_length=10) + relations = models.ManyToManyField('self', + through='demo.ThroughRelation', + symmetrical=False, + blank=True) + + class Meta: + app_label = 'demo' diff --git a/tests/demoapp/demo/settings.py b/tests/demoapp/demo/settings.py index 97d34a0..ed05743 100644 --- a/tests/demoapp/demo/settings.py +++ b/tests/demoapp/demo/settings.py @@ -29,7 +29,8 @@ 'django.contrib.admin', 'concurrency', 'reversion', - 'demo'] + 'demo' + ] MIGRATION_MODULES = { diff --git a/tests/test_conditional.py b/tests/test_conditional.py index 2af9f28..0381065 100644 --- a/tests/test_conditional.py +++ b/tests/test_conditional.py @@ -6,7 +6,9 @@ from django.contrib.auth.models import User import pytest -from demo.models import ConditionalVersionModel, ConditionalVersionModelWithoutMeta +from demo.models import ConditionalVersionModel, ConditionalVersionModelWithoutMeta, \ + ConditionalVersionModelSelfRelation, \ + ThroughRelation from concurrency.exceptions import RecordModifiedError from concurrency.utils import refetch @@ -35,6 +37,14 @@ def instance_no_meta(user): ) +@pytest.fixture +def instance_self_relation(): + a = ConditionalVersionModelSelfRelation.objects.create() + b = ConditionalVersionModelSelfRelation.objects.create() + b.relations.add(a) + return b + + @pytest.mark.django_db def test_standard_save(instance): # only increment if checked field @@ -98,3 +108,17 @@ def test_conflict_no_meta(instance_no_meta): with pytest.raises(RecordModifiedError): instance_no_meta.save() + + +@pytest.mark.django_db() +def test_self_relations(): + a = ConditionalVersionModelSelfRelation.objects.create(name='a') + b = ConditionalVersionModelSelfRelation.objects.create(name='b') + + r = ThroughRelation.objects.create(left=a, + right=a) + r.save() + + a1 = ConditionalVersionModelSelfRelation.objects.get(pk=a.pk) + a1.name='a' + a1.save()