├── .gitignore ├── .travis.yml ├── CHANGES.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── django_cloneable ├── __init__.py └── models.py ├── pytest.ini ├── requirements.txt ├── setup.py ├── tests ├── __init__.py ├── models.py ├── requirements.txt ├── settings.py ├── test_cloneable.py └── test_imports.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | /dist/ 3 | /*.egg-info/ 4 | /.tox/ 5 | /tests/db.sqlite 6 | /.cache/ 7 | /.coverage 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 2.7 3 | sudo: false 4 | cache: 5 | - pip 6 | env: 7 | - TOXENV=flake8 8 | - TOXENV=py27-18 9 | #- TOXENV=py27-19 10 | - TOXENV=py35-18 11 | #- TOXENV=py35-19 12 | - TOXENV=pypy-18 13 | #- TOXENV=pypy-19 14 | install: 15 | - pip install tox 16 | script: 17 | - tox 18 | deploy: 19 | provider: pypi 20 | user: ddanier 21 | password: 22 | secure: LSWtthKrHSi27TgnOvt48JVya+PCIqH7jhuN5g6TYs9NTevSH9Y2fm3kdAAYx5cdB1sRDlMt6zCkk8LMk4/jx2QIQLNn8fvC5z8QNrH83lMYaZeaJZQwMmzcm0e4PtJJvDd4jax7xmMsqKSGykDp/TWNXrAo6d6vP2vdfMgANpgcq0KN7IuSAczxoPWPmM1HeaFvLWILsi00m+ZVaOOR0qf0kgHcXh9qlSjnq1H8T3LwqoUc3jUB7crLh1uePNO8934+Ep8QKhwJAI7gN0wdCXo/mf0S6qgkVMFnLqdMOiLLhY6Lsk/xQykDLTBzLtS00bvsAPR+0BsVxc/3io1Pi6v1Ls44gAOLfHTGJHsybZ+tIv/juO9HBLpn+Fy+8UChomrS+Ya0WJKULK+++C7g4WATKDnDTXn/qTl/w+ELEt2lZylU6kkxY3/wnWGoRrnM/j/zIs7y+y4Rak8Dp6Jz1+YZ5bKbs0Z6payuomjTxp8SfhV9Ek/ngYrLy0ZwzjldVifSX+6K3WxRcbpwkopHlyPjSnPoQmHDUVgraRfKoSBUoSxVc9uVlmwDAjbNLW6ie9Jg2Q/tyoLF4RV385aaI8P1t7UOZjnFGE4UzgBBSmxZCB4LDxQjf2G/WEdLLYOEcQLQC2joAXtSnZym2iL2FKwHZeuirApa3kkiUnaMclA= 23 | on: 24 | tags: true 25 | repo: team23/django_cloneable 26 | condition: "$TOXENV = py27-18" 27 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | DEV 5 | --- 6 | 7 | 8 | 9 | 0.1.0 10 | ----- 11 | 12 | - Initial release. 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Team23 GmbH & Co. KG 2 | (David Danier, Gregor Müllegger) 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | 11 | 2. 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 | 3. Neither the name of Django nor the names of its contributors may be used 16 | to endorse or promote products derived from this software without 17 | specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 23 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 24 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 26 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 28 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include CHANGES.rst 4 | recursive-include tests *.py *.html *.txt 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-cloneable 2 | ================ 3 | 4 | |pypi-badge| |build-status| 5 | 6 | .. |build-status| image:: https://travis-ci.org/team23/django_cloneable.svg 7 | :target: https://travis-ci.org/team23/django_cloneable 8 | 9 | .. |pypi-badge| image:: https://img.shields.io/pypi/v/django-cloneable.svg 10 | :target: https://pypi.python.org/pypi/django-cloneable 11 | 12 | **django-cloneable** provides a ``CloneableMixin`` class that has a ``clone()`` 13 | method. It can be mixed into any Django model. 14 | 15 | The ``clone()`` method must be called on an already saved instance (one that 16 | has ``pk`` set). It then returns a new instance of the model that has all the 17 | same field values as the original instance, but it will be a seperate database 18 | row with a distinct primary key. 19 | 20 | An example: 21 | 22 | .. code:: python 23 | 24 | from django.db import models 25 | from django_cloneable import CloneableMixin 26 | 27 | class Ingredient(CloneableMixin, models.Model): 28 | name = models.CharField(max_length=50) 29 | is_spicy = models.BooleanField(default=False) 30 | 31 | class Pizza(CloneableMixin, models.Model): 32 | name = models.CharField(max_length=50) 33 | price = models.IntegerField() 34 | ingredients = models.ManyToManyField(Ingredient) 35 | 36 | tomatos = Ingredient.objects.create(name='Tomato', is_spicy=False) 37 | cheese = Ingredient.objects.create(name='Cheese', is_spicy=False) 38 | chili = Ingredient.objects.create(name='Chili', is_spicy=True) 39 | 40 | margarita = Pizza.objects.create(name='Margarita') 41 | margarita.ingredients.add(tomatos) 42 | margarita.ingredients.add(cheese) 43 | 44 | diabolo = margarita.clone(attrs={'name': 'Diabolo'}) 45 | diabolo.ingerdients.add(chili) 46 | 47 | # Nothing has changed on the original instance. 48 | assert margarita.name == 'Margarita' 49 | assert margarita.ingredients.all() == [tomatos, cheese] 50 | 51 | # The original m2m values were copied, and the new values were added. 52 | assert diabolo.name == 'Diabolo' 53 | assert diabolo.ingredients.all() == [tomatos, cheese, chili] 54 | 55 | As shown in the example, you can provide the ``attrs`` that shall be replaced 56 | in the cloned object. That lets you change the cloned instance before it gets 57 | saved. By default the clone will be saved to the database, so that it has 58 | ``pk`` when it gets returned. 59 | 60 | There are numerous hooks on how you can modify the cloning logic. The best way 61 | to learn about them is to have a look at the implementation of 62 | ``CloneableMixin``. 63 | 64 | Development 65 | ----------- 66 | 67 | Install the dependencies (including the test dependencies) with:: 68 | 69 | pip install -r requirements.txt 70 | 71 | Then you can run all tests with:: 72 | 73 | tox 74 | -------------------------------------------------------------------------------- /django_cloneable/__init__.py: -------------------------------------------------------------------------------- 1 | # from .models import CloneableMixin # noqa 2 | 3 | 4 | __version__ = '0.1.1.dev1' 5 | -------------------------------------------------------------------------------- /django_cloneable/models.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | from django.db import models 3 | 4 | 5 | __all__ = ('CloneableMixin',) 6 | 7 | 8 | # Based on http://djangosnippets.org/snippets/1271/ 9 | class ModelCloneHelper(object): 10 | def __init__(self, instance): 11 | self.instance = instance 12 | 13 | def _clone_copy(self): 14 | import copy 15 | if not self.instance.pk: 16 | raise ValueError('Instance must be saved before it can be cloned.') 17 | return copy.copy(self.instance) 18 | 19 | def _clone_prepare(self, duplicate, exclude=None): 20 | # Setting pk to None tricks Django into not trying to overwrite the old 21 | # object (we will force the INSERT later) 22 | def unset_pk_and_parent_relation(cls): 23 | meta = cls._meta 24 | setattr(duplicate, meta.pk.attname, None) 25 | for parent, field in meta.parents.items(): 26 | unset_pk_and_parent_relation(parent) 27 | 28 | unset_pk_and_parent_relation(duplicate.__class__) 29 | 30 | exclude = exclude or [] 31 | for field in self.instance._meta.fields: 32 | # Fields given as list in ``exclude`` will be assigned the field's 33 | # default value. That makes it possible to exclude fields from 34 | # cloning. 35 | if field.name in exclude: 36 | setattr(duplicate, field.attname, field.get_default()) 37 | # TODO: Is this really needed? 38 | # update auto_now(_add) fields 39 | if isinstance(field, ( 40 | models.DateField, 41 | models.TimeField, 42 | models.DateTimeField)): 43 | if field.auto_now or field.auto_now_add: 44 | field.pre_save(duplicate, True) # clone is an add, always 45 | 46 | def _clone_attrs(self, duplicate, attrs, exclude=None): 47 | """ 48 | Will apply the ``attrs`` (a ``dict``) to the given instance. That makes 49 | it possible to exclude fields from cloning. 50 | """ 51 | if attrs: 52 | for attname, value in attrs.items(): 53 | setattr(duplicate, attname, value) 54 | 55 | def _clone_copy_m2m(self, duplicate, exclude=None): 56 | exclude = exclude or [] 57 | # copy.copy loses all ManyToMany relations. 58 | for field in self.instance._meta.many_to_many: 59 | # Skip this field. 60 | if field.name in exclude: 61 | continue 62 | # handle m2m using through 63 | remote_field = _get_remote_field(field) 64 | if remote_field.through and not remote_field.through._meta.auto_created: 65 | # through-model must be cloneable 66 | if hasattr(remote_field.through, 'clone'): 67 | qs = remote_field.through._default_manager.filter( 68 | **{field.m2m_field_name(): self.instance}) 69 | for m2m_obj in qs: 70 | m2m_obj.clone(attrs={ 71 | field.m2m_field_name(): duplicate 72 | }) 73 | else: 74 | qs = remote_field.through._default_manager.filter( 75 | **{field.m2m_field_name(): self.instance}) 76 | for m2m_obj in qs: 77 | # TODO: Allow switching to different helper? 78 | m2m_clone_helper = ModelCloneHelper(m2m_obj) 79 | m2m_clone_helper.clone(attrs={ 80 | field.m2m_field_name(): duplicate 81 | }) 82 | # normal m2m, this is easy 83 | else: 84 | objs = getattr(self.instance, field.attname).all() 85 | try: 86 | # Django <= 1.11 87 | setattr(duplicate, field.attname, objs) 88 | except TypeError: 89 | # Django 2 90 | getattr(duplicate, field.name).set(objs) 91 | 92 | def _clone_copy_reverse_m2m(self, duplicate, exclude=None): 93 | exclude = exclude or [] 94 | qs = [ 95 | f for f in self.instance._meta.get_fields(include_hidden=True) 96 | if f.many_to_many and f.auto_created 97 | ] 98 | for relation in qs: 99 | # handle m2m using through 100 | remote_field = _get_remote_field(relation.field) 101 | if ( 102 | remote_field.through and 103 | not remote_field.through._meta.auto_created): 104 | # Skip this field. 105 | # TODO: Not sure if this is the right value to check for.. 106 | if remote_field.related_name in exclude: 107 | continue 108 | # through-model must be cloneable 109 | if hasattr(remote_field.through, 'clone'): 110 | qs = remote_field.through._default_manager.filter(**{ 111 | relation.field.m2m_reverse_field_name(): self.instance 112 | }) 113 | for m2m_obj in qs: 114 | m2m_obj.clone(attrs={ 115 | relation.field.m2m_reverse_field_name(): duplicate 116 | }) 117 | else: 118 | qs = remote_field.through._default_manager.filter(**{ 119 | relation.field.m2m_reverse_field_name(): self.instance 120 | }) 121 | for m2m_obj in qs: 122 | # TODO: Allow switching to different helper? 123 | m2m_clone_helper = ModelCloneHelper(m2m_obj) 124 | m2m_clone_helper.clone(attrs={ 125 | relation.field.m2m_reverse_field_name(): duplicate 126 | }) 127 | # normal m2m, this is easy 128 | else: 129 | # Skip this field. 130 | if remote_field.related_name in exclude: 131 | continue 132 | objs_rel_manager = getattr( 133 | self.instance, 134 | remote_field.related_name) 135 | objs = objs_rel_manager.all() 136 | try: 137 | # Django <= 1.11 138 | setattr(duplicate, remote_field.related_name, objs) 139 | except TypeError: 140 | # Django 2 141 | getattr(duplicate, remote_field.related_name).set(objs) 142 | 143 | def clone(self, attrs=None, commit=True, m2m_clone_reverse=True, 144 | exclude=None): 145 | """Returns a new copy of the current object""" 146 | 147 | clone_copy = getattr(self.instance, '_clone_copy', self._clone_copy) 148 | duplicate = clone_copy() 149 | 150 | clone_prepare = getattr(self.instance, '_clone_prepare', 151 | self._clone_prepare) 152 | clone_prepare(duplicate, exclude=exclude) 153 | 154 | clone_attrs = getattr(self.instance, '_clone_attrs', self._clone_attrs) 155 | clone_attrs(duplicate, attrs, exclude=exclude) 156 | 157 | def clone_m2m(clone_reverse=m2m_clone_reverse): 158 | clone_copy_m2m = getattr(self.instance, '_clone_copy_m2m', 159 | self._clone_copy_m2m) 160 | clone_copy_m2m(duplicate, exclude=exclude) 161 | if clone_reverse: 162 | clone_copy_reverse_m2m = getattr( 163 | self.instance, 164 | '_clone_copy_reverse_m2m', 165 | self._clone_copy_reverse_m2m) 166 | clone_copy_reverse_m2m(duplicate, exclude=exclude) 167 | 168 | if commit: 169 | duplicate.save(force_insert=True) 170 | clone_m2m() 171 | else: 172 | duplicate.clone_m2m = clone_m2m 173 | return duplicate 174 | 175 | 176 | def _get_remote_field(field): 177 | if hasattr(field, 'remote_field'): 178 | # Django 2 179 | return field.remote_field 180 | elif hasattr(field, 'rel'): 181 | # Django <= 1.11 182 | return field.rel 183 | return None 184 | 185 | 186 | class CloneableMixin(models.Model): 187 | ''' Adds a clone() method to models 188 | 189 | Cloning is done by first copying the object using copy.copy. After this 190 | the primary key (pk) is removed, passed attributes are set 191 | (obj.clone(attrs={…})). 192 | 193 | If commit=True (default) all m2m relations will be cloned and saved, too. 194 | This includes reverse m2m relations, except if you pass 195 | m2m_clone_reverse=False. Even intermediate m2m relations (through model) 196 | will be cloned, when the intermediate model itself is cloneable (has a 197 | clone method, does not need to use CloneableMixin, but needs to allow at 198 | least passing attrs and doing an a automated save like commit=True). 199 | 200 | If you don't want the object to be saved directly (commit=False), the clone 201 | will get an additional clone_m2m method, so you can handle m2m relations 202 | outside of clone() (see Django forms save_m2m()). This method will save 203 | reverse m2m relations, if m2m_clone_reverse was True (default). Besides you 204 | may overwrite this behaviour by passing clone_reverse=True/False. 205 | 206 | clone() uses some helper methods, which may be extended/replaced in 207 | child classes. These include: 208 | * _clone_copy(): create the copy 209 | * _clone_prepare(): prepare the obj, so it can be saved 210 | (currently only removed pk and updated auto_now(_add) fields) 211 | * _clone_attrs(): set all attributes passed to clone() 212 | * _clone_copy_m2m(): clones all m2m relations 213 | * _clone_copy_reverse_m2m(): clones all reverse m2m relations 214 | ''' 215 | 216 | CLONE_HELPER_CLASS = ModelCloneHelper 217 | 218 | def _clone_copy(self): 219 | return self._clone_helper._clone_copy() 220 | 221 | def _clone_prepare(self, duplicate, exclude=None): 222 | return self._clone_helper._clone_prepare(duplicate, exclude=exclude) 223 | 224 | def _clone_attrs(self, duplicate, attrs, exclude=None): 225 | return self._clone_helper._clone_attrs(duplicate, attrs, 226 | exclude=exclude) 227 | 228 | def _clone_copy_m2m(self, duplicate, exclude=None): 229 | return self._clone_helper._clone_copy_m2m(duplicate, exclude=exclude) 230 | 231 | def _clone_copy_reverse_m2m(self, duplicate, exclude=None): 232 | return self._clone_helper._clone_copy_reverse_m2m(duplicate, 233 | exclude=exclude) 234 | 235 | def clone(self, attrs=None, commit=True, m2m_clone_reverse=True, 236 | exclude=None): 237 | """Returns a new copy of the current object""" 238 | self._clone_helper = self.CLONE_HELPER_CLASS(self) 239 | 240 | duplicate = self._clone_copy() 241 | self._clone_prepare(duplicate, exclude=exclude) 242 | self._clone_attrs(duplicate, attrs, exclude=exclude) 243 | 244 | def clone_m2m(clone_reverse=m2m_clone_reverse): 245 | self._clone_copy_m2m(duplicate, exclude=exclude) 246 | if clone_reverse: 247 | self._clone_copy_reverse_m2m(duplicate, exclude=exclude) 248 | del self._clone_helper 249 | 250 | if commit: 251 | duplicate.save(force_insert=True) 252 | clone_m2m() 253 | else: 254 | duplicate.clone_m2m = clone_m2m 255 | 256 | return duplicate 257 | 258 | class Meta: 259 | abstract = True 260 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --cov=django_cloneable --cov-report=term-missing 3 | norecursedirs = .tox 4 | DJANGO_SETTINGS_MODULE = tests.settings 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=1.8,<1.9 2 | -r tests/requirements.txt 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import codecs 3 | import re 4 | from os import path 5 | from distutils.core import setup 6 | from setuptools import find_packages 7 | 8 | 9 | def read(*parts): 10 | return codecs.open(path.join(path.dirname(__file__), *parts), 11 | encoding='utf-8').read() 12 | 13 | 14 | def find_version(*file_paths): 15 | version_file = read(*file_paths) 16 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", 17 | version_file, re.M) 18 | if version_match: 19 | return version_match.group(1) 20 | raise RuntimeError("Unable to find version string.") 21 | 22 | 23 | setup( 24 | name='django-cloneable', 25 | version=find_version('django_cloneable', '__init__.py'), 26 | author=u'David Danier', 27 | author_email='david.danier@team23.de', 28 | packages=find_packages( 29 | exclude=['tests', 'tests.*']), 30 | include_package_data=True, 31 | url='https://github.com/team23/django_cloneable', 32 | license='BSD licence, see LICENSE file', 33 | description=( 34 | "Let's you clone a Django model instance including " 35 | "many to many fields"), 36 | long_description=u'\n\n'.join(( 37 | read('README.rst'), 38 | read('CHANGES.rst'))), 39 | install_requires=[ 40 | 41 | ], 42 | classifiers=[ 43 | 'Development Status :: 4 - Beta', 44 | 'Environment :: Web Environment', 45 | 'Framework :: Django', 46 | 'Framework :: Django :: 1.8', 47 | 'Framework :: Django :: 1.9', 48 | 'Framework :: Django :: 1.10', 49 | 'Framework :: Django :: 1.11', 50 | 'Intended Audience :: Developers', 51 | 'License :: OSI Approved :: BSD License', 52 | 'Natural Language :: English', 53 | 'Programming Language :: Python', 54 | 'Programming Language :: Python :: 2', 55 | 'Programming Language :: Python :: 2.7', 56 | 'Programming Language :: Python :: 3', 57 | 'Programming Language :: Python :: 3.5', 58 | ], 59 | zip_safe=False, 60 | ) 61 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/team23/django_cloneable/dc77673a9c5771c6e9142849b6b2f0969f203955/tests/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from django.db import models 3 | 4 | from django_cloneable.models import CloneableMixin 5 | 6 | 7 | class ModelWithFields(CloneableMixin, models.Model): 8 | char = models.CharField(max_length=50, default='') 9 | integer = models.IntegerField(default=0) 10 | date = models.DateField(default=date(2000, 1, 1)) 11 | 12 | 13 | class ModelWithCustomPK(CloneableMixin, models.Model): 14 | key = models.CharField(max_length=50, primary_key=True) 15 | value = models.IntegerField() 16 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | flake8 2 | pytest 3 | pytest-cov 4 | pytest-django 5 | pytest-pythonpath 6 | pytest-xdist 7 | 8 | coverage 9 | mock == 1.3.0 10 | tox >= 1.8 11 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import warnings 3 | warnings.simplefilter('always') 4 | 5 | test_dir = os.path.dirname(os.path.abspath(__file__)) 6 | 7 | DATABASES = { 8 | 'default': { 9 | 'NAME': os.path.join(test_dir, 'db.sqlite'), 10 | 'ENGINE': 'django.db.backends.sqlite3', 11 | }, 12 | } 13 | 14 | USE_I18N = True 15 | USE_L10N = True 16 | 17 | INSTALLED_APPS = [ 18 | 'django.contrib.contenttypes', 19 | 'django.contrib.staticfiles', 20 | 'django_cloneable', 21 | 'tests', 22 | ] 23 | 24 | STATICFILES_FINDERS = ( 25 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 26 | ) 27 | 28 | MIDDLEWARE_CLASSES = () 29 | 30 | TEMPLATE_DIRS = ( 31 | os.path.join(test_dir, 'templates'), 32 | ) 33 | 34 | STATIC_URL = '/static/' 35 | 36 | SECRET_KEY = '0' 37 | 38 | SITE_ID = 1 39 | -------------------------------------------------------------------------------- /tests/test_cloneable.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from django.test import TestCase 3 | 4 | from .models import ModelWithCustomPK 5 | from .models import ModelWithFields 6 | 7 | 8 | class CloneableTests(TestCase): 9 | def test_cloning_generates_a_new_instance(self): 10 | i1 = ModelWithFields.objects.create( 11 | char='custom value', 12 | integer=23, 13 | date=date(2015, 12, 31)) 14 | 15 | i2 = i1.clone() 16 | 17 | assert i2.pk is not None 18 | assert i1.pk != i2.pk 19 | assert i1.char == i2.char 20 | assert i1.integer == i2.integer 21 | assert i1.date == i2.date 22 | 23 | def test_must_provide_new_pk_if_its_custom_field(self): 24 | i1 = ModelWithCustomPK.objects.create(key='foo', value=42) 25 | i2 = i1.clone(attrs={'key': 'bar'}) 26 | 27 | assert i2.pk == 'bar' 28 | assert i1.pk != i2.pk 29 | assert i2.value == 42 30 | -------------------------------------------------------------------------------- /tests/test_imports.py: -------------------------------------------------------------------------------- 1 | def test_imports(): 2 | import django_cloneable # noqa 3 | from django_cloneable.models import CloneableMixin 4 | 5 | assert CloneableMixin is not None 6 | 7 | 8 | def test_has_version(): 9 | import django_cloneable 10 | 11 | assert django_cloneable.__version__.count('.') >= 2 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 1.8 3 | envlist = 4 | flake8, 5 | py27-{18,19,110,111}, 6 | py37-{18,19,110,111,20,21}, 7 | pypy-{18,19,110,111} 8 | 9 | skip_missing_interpreters = true 10 | 11 | [testenv] 12 | deps = 13 | 18: Django >= 1.8, < 1.9 14 | 19: Django >= 1.9, < 1.10 15 | 110: Django >= 1.10, < 1.11 16 | 111: Django >= 1.11, < 2.0 17 | 20: Django >= 2.0, < 2.1 18 | 21: Django >= 2.1, < 2.2 19 | -r{toxinidir}/tests/requirements.txt 20 | commands = pytest 21 | 22 | [testenv:flake8] 23 | commands = flake8 django_cloneable 24 | --------------------------------------------------------------------------------