├── .coveragerc ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .travis_old.yml ├── CONTRIBUTING.md ├── CONTRIBUTORS ├── LICENSE ├── MANIFEST.in ├── README.md ├── activatable_model ├── __init__.py ├── apps.py ├── models.py ├── signals.py ├── tests │ ├── __init__.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_activatablemodelwrelandcascade.py │ │ └── __init__.py │ ├── models.py │ └── tests.py ├── urls.py ├── validation.py └── version.py ├── docs └── release_notes.rst ├── manage.py ├── publish.py ├── requirements ├── requirements-testing.txt └── requirements.txt ├── run_tests.py ├── settings.py ├── setup.cfg ├── setup.py └── tox_old.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = 4 | */migrations/* 5 | activatable_model/version.py 6 | source = activatable_model 7 | [report] 8 | exclude_lines = 9 | # Have to re-enable the standard pragma 10 | pragma: no cover 11 | 12 | # Don't complain if tests don't hit defensive assertion code: 13 | raise NotImplementedError 14 | fail_under = 100 15 | show_missing = 1 16 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # copied from django-cte 2 | name: activatable_model tests 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master,develop] 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | python: ['3.7', '3.8', '3.9'] 16 | # Time to switch to pytest or nose2?? 17 | # nosetests is broken on 3.10 18 | # AttributeError: module 'collections' has no attribute 'Callable' 19 | # https://github.com/nose-devs/nose/issues/1099 20 | django: 21 | - 'Django~=3.2.0' 22 | - 'Django~=4.0.0' 23 | - 'Django~=4.1.0' 24 | - 'Django~=4.2.0' 25 | experimental: [false] 26 | exclude: 27 | - python: '3.7' 28 | django: 'Django~=4.0.0' 29 | - python: '3.7' 30 | django: 'Django~=4.1.0' 31 | - python: '3.7' 32 | django: 'Django~=4.2.0' 33 | services: 34 | postgres: 35 | image: postgres:latest 36 | env: 37 | POSTGRES_DB: postgres 38 | POSTGRES_PASSWORD: postgres 39 | POSTGRES_USER: postgres 40 | ports: 41 | - 5432:5432 42 | options: >- 43 | --health-cmd pg_isready 44 | --health-interval 10s 45 | --health-timeout 5s 46 | --health-retries 5 47 | steps: 48 | - uses: actions/checkout@v2 49 | - uses: actions/setup-python@v2 50 | with: 51 | python-version: ${{ matrix.python }} 52 | - name: Setup 53 | run: | 54 | python --version 55 | pip install --upgrade pip wheel 56 | pip install -r requirements/requirements.txt 57 | pip install -r requirements/requirements-testing.txt 58 | pip install "${{ matrix.django }}" 59 | pip freeze 60 | - name: Run tests 61 | env: 62 | DB_SETTINGS: >- 63 | { 64 | "ENGINE":"django.db.backends.postgresql_psycopg2", 65 | "NAME":"activatable_model", 66 | "USER":"postgres", 67 | "PASSWORD":"postgres", 68 | "HOST":"localhost", 69 | "PORT":"5432" 70 | } 71 | run: | 72 | coverage run manage.py test activatable_model 73 | coverage report --fail-under=99 74 | continue-on-error: ${{ matrix.experimental }} 75 | - name: Check style 76 | run: flake8 activatable_model 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled python files 2 | *.pyc 3 | 4 | # Vim files 5 | *.swp 6 | *.swo 7 | 8 | # Coverage files 9 | .coverage 10 | htmlcov/ 11 | 12 | # Setuptools distribution folder. 13 | /dist/ 14 | /build/ 15 | 16 | # Python egg metadata, regenerated from source files by setuptools. 17 | /*.egg-info 18 | /*.egg 19 | .eggs/ 20 | 21 | # Virtualenv 22 | env/ 23 | venv/ 24 | 25 | # OSX 26 | .DS_Store 27 | 28 | # Pycharm 29 | .idea/ 30 | -------------------------------------------------------------------------------- /.travis_old.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | sudo: false 4 | 5 | python: 6 | - "3.6" 7 | - "3.7" 8 | - "3.8" 9 | 10 | env: 11 | matrix: 12 | - DJANGO=2.2 13 | - DJANGO=3.0 14 | - DJANGO=3.1 15 | - DJANGO=master 16 | 17 | addons: 18 | postgresql: '9.6' 19 | 20 | matrix: 21 | include: 22 | - { python: "3.6", env: TOXENV=flake8 } 23 | 24 | allow_failures: 25 | - env: DJANGO=master 26 | 27 | install: 28 | - pip install tox-travis 29 | 30 | 31 | before_script: 32 | - psql -c 'CREATE DATABASE activatable_model;' -U postgres 33 | 34 | script: 35 | - tox 36 | 37 | after_success: 38 | coveralls 39 | 40 | notifications: 41 | email: false 42 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | Contributions and issues are most welcome! All issues and pull requests are 3 | handled through GitHub on the 4 | [ambitioninc repository](https://github.com/ambitioninc/django-activatable-model/issues). 5 | Also, please check for any existing issues before filing a new one. If you have 6 | a great idea but it involves big changes, please file a ticket before making a 7 | pull request! We want to make sure you don't spend your time coding something 8 | that might not fit the scope of the project. 9 | 10 | ## Running the tests 11 | 12 | To get the source source code and run the unit tests, run: 13 | ```bash 14 | git clone git://github.com/ambitioninc/django-activatable-model.git 15 | cd django-activatable-model 16 | virtualenv env 17 | . env/bin/activate 18 | python setup.py install 19 | coverage run setup.py test 20 | coverage report --fail-under=100 21 | ``` 22 | 23 | While 100% code coverage does not make a library bug-free, it significantly 24 | reduces the number of easily caught bugs! Please make sure coverage is at 100% 25 | before submitting a pull request! 26 | 27 | ## Code Quality 28 | 29 | For code quality, please run flake8: 30 | ```bash 31 | pip install flake8 32 | flake8 . 33 | ``` 34 | 35 | ## Code Styling 36 | Please arrange imports with the following style 37 | 38 | ```python 39 | # Standard library imports 40 | import os 41 | 42 | # Third party package imports 43 | from mock import patch 44 | from django.conf import settings 45 | 46 | # Local package imports 47 | from activatable_model.version import __version__ 48 | ``` 49 | 50 | Please follow 51 | [Google's python style](http://google-styleguide.googlecode.com/svn/trunk/pyguide.html) 52 | guide wherever possible. 53 | 54 | 55 | 56 | ## Release Checklist 57 | 58 | Before a new release, please go through the following checklist: 59 | 60 | * Bump version in activatable_model/version.py 61 | * Add a release note at the bottom of the [README.md](README.md) 62 | * Git tag the version 63 | * Upload to pypi: 64 | ```bash 65 | pip install wheel 66 | python setup.py sdist bdist_wheel upload 67 | ``` 68 | 69 | ## Vulnerability Reporting 70 | 71 | For any security issues, please do NOT file an issue or pull request on GitHub! 72 | Please contact [security@ambition.com](mailto:security@ambition.com) with the 73 | GPG key provided on [Ambition's website](http://ambition.com/security/). 74 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | Wes Kendall @wesleykendall wes.kendall@ambition.com (primary) 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Ambition 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | recursive-include requirements * 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/ambitioninc/django-activatable-model.png)](https://travis-ci.org/ambitioninc/django-activatable-model) 2 | # Django Activatable Model 3 | 4 | Provides functionality for Django models that have active and inactive states. 5 | Features of this app are: 6 | 7 | 1. An abstract BaseActivatableModel that allows the user to specify an 8 | 'activatable' (i.e. Boolean) field 9 | 1. A model_activations_changed signal that fires when models' activatable field 10 | are changed or bulk updated 11 | 1. Validation to ensure activatable models cannot be cascade deleted 12 | 1. Overriding of delete methods so that the activatable flag is set to False 13 | instead of the model(s) being deleted (unless force=True) 14 | 1. Manager/QuerySet methods to activate and deactivate models 15 | 16 | ## Installation 17 | ```bash 18 | pip install django-activatable-model 19 | ``` 20 | 21 | Add `activatable_model` to the list of `INSTALLED_APPS`. Although this app does 22 | not define any concrete models, it does connect signals that Django needs to 23 | know about. 24 | 25 | ## Basic Usage 26 | Assume you have a model called `Account` in your app, and it is an activatable 27 | model that has a `name` field and a `ForeignKey` to a `Group` model. 28 | 29 | ```python 30 | from activatable_model.models import BaseActivatableModel 31 | 32 | class Account(BaseActivatableModel): 33 | is_active = models.BooleanField(default=False) 34 | name = models.CharField(max_length=64) 35 | group = models.ForeignKey(Group) 36 | ``` 37 | 38 | By just inheriting `BaseActivatableModel`, your model will need to define an 39 | `is_active` boolean field (this field name can be changed, more on that later). 40 | If you create an `Account` model, the `model_activations_changed` signal will 41 | be sent with an `is_active` keyword argument set to False and an `instance_ids` 42 | keyword argument that is a list of the single created account id. Similarly, if 43 | you updated the `is_active` flag at any time via the `.save()` method, the 44 | `model_activations_changed` signal will be emitted again. This allows the user 45 | to do things like this: 46 | 47 | ```python 48 | from django.dispatch import receiver 49 | from activatable_model import model_activations_changed 50 | 51 | @receiver(model_activations_changed, sender=Account) 52 | def do_something_on_deactivation(sender, instance_ids, is_active, **kwargs): 53 | if not is_active: 54 | # Either an account was deactivated or an inactive account was created... 55 | for account in Account.objects.filter(id__in=instance_ids): 56 | # Do something with every deactivated account 57 | ``` 58 | 59 | ## Activatable Model Deletion 60 | Django activatable model is meant for models that should never be deleted but 61 | rather activated/deactivated instead. Given the assumption that activatable 62 | models should never be deleted, Django activatable model does some magic 63 | underneath to ensure your activatable models are properly updated when the user 64 | calls `.delete()`. Instead of deleting the object(s) directly, the `is_active` 65 | flag is set to False and `model_activations_changed` is fired. 66 | 67 | ```python 68 | account = Account.objects.create(is_active=True) 69 | account.delete() # Or Account.objects.all().delete() 70 | 71 | # The account still exists 72 | print Account.objects.count() 73 | 1 74 | 75 | # But it is deactivated 76 | print Account.objects.get().is_active 77 | False 78 | ``` 79 | 80 | The user can override this behavior by passing `force=True` to the model or 81 | queryset's `.delete()` method. 82 | 83 | Along with overriding deletion, Django activatable model also overrides cascade 84 | deletion. No model that inherits `BaseActivatableModel` can be cascade deleted 85 | by another model. This is accomplished by connecting to Django's `pre_syncdb` 86 | signal and verifying that all `ForeignKey` and `OneToOneField` fields of 87 | activatable models have their `on_delete` arguments set to something other than 88 | the default of `models.CASCADE`. 89 | 90 | In fact, our `Account` model will not pass validation. In order to make it 91 | validate properly on syncdb, it must do the following: 92 | 93 | ```python 94 | from django.db import models 95 | 96 | class Account(BaseActivatableModel): 97 | is_active = models.BooleanField(default=False) 98 | name = models.CharField(max_length=64) 99 | group = models.ForeignKey(Group, on_delete=models.PROTECT) 100 | ``` 101 | 102 | This will ensure a `ProtectedError` is thrown every time a Group is deleted. 103 | For other options on foreign key deletion behavior, see 104 | [Django's docs](https://docs.djangoproject.com/en/1.7/ref/models/fields/#django.db.models.ForeignKey.on_delete). 105 | 106 | ### Cascade Overrides (new in version 0.8.0 ) 107 | As mentioned above, activatable models cannot be cascade deleted. However, 108 | this default behavior can be overridden by setting the the class variable, 109 | `ALLOW_CASCADE_DELETE = True`. If set to True, than cascade deletion will 110 | be allowed. Note however, that this will be a hard delete, meaning that 111 | cascade deletion will completely remove your record from the database rather 112 | than applying the ActivatibleModel magic of simply marking it as inactive. 113 | 114 | ## Manager and QuerySet methods 115 | Django activatable models automatically use an `ActivatableManager` manager 116 | that uses an `ActivatableQuerySet` queryset. This provides the following 117 | functionality: 118 | 119 | 1. Two methods - `activate()` and `deactivate()` that can be applied to a 120 | queryset 121 | 1. Overriding the `update()` method so that it emits 122 | `model_activations_changed` when the `is_active` flag is updated 123 | 1. Overriding the `delete()` method so that it calls `deactivate()` unless 124 | `force=True` 125 | 126 | ## Overriding the activatable field name 127 | The name of the activatable field can be overridden by defining the 128 | `ACTIVATABLE_FIELD_NAME` constant on the model to something else. By default, 129 | this constant is set to `is_active`. An example is as follows: 130 | 131 | ```python 132 | from activatable_model import BaseActivatableModel 133 | 134 | class Account(BaseActivatableModel): 135 | ACTIVATABLE_FIELD_NAME = 'active' 136 | active = models.BooleanField(default=False) 137 | ``` 138 | 139 | In the above example, the model instructs the activatable model app to use 140 | `active` as the activatable field on the model. If the user does not define a 141 | `BooleanField` on the model with the same name as `ACTIVATABLE_FIELD_NAME`, a 142 | `ValidationError` is raised during syncdb / migrate. 143 | 144 | ## Release Notes 145 | * 0.5.1 146 | * Optimize individual saves so that they dont perform an additional query when checking if model activations have been updated 147 | * 0.5.0 148 | * Changed the signal to send instance_ids as a keyword argument rather than the instances. This pushes fetching the updated models in signal handlers onto the application 149 | * 0.4.2 150 | * Fixed bug when activating a queryset that was filtered by the active flag 151 | * 0.3.1 152 | * Added Django 1.7 app config 153 | * 0.3.0 154 | * Added Django 1.7 support and backwards compatibility with Django 1.6 155 | 156 | * 0.2.0 157 | * When upgrading to this version, users will have to explicitly add the 158 | `is_active` field to any models that inherited `BaseActivatableModel`. This 159 | field had a default value of False before, so be sure to add that as the 160 | default for the boolean field. 161 | 162 | ## License 163 | MIT License (see the [LICENSE](LICENSE) file in this repo) 164 | -------------------------------------------------------------------------------- /activatable_model/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .signals import model_activations_changed, model_activations_updated 3 | from .version import __version__ 4 | 5 | -------------------------------------------------------------------------------- /activatable_model/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ActivatableModelConfig(AppConfig): 5 | name = 'activatable_model' 6 | verbose_name = 'Django Activatable Model' 7 | 8 | def ready(self): 9 | from activatable_model.validation import validate_activatable_models 10 | validate_activatable_models() 11 | -------------------------------------------------------------------------------- /activatable_model/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from manager_utils import ManagerUtilsQuerySet, ManagerUtilsManager 4 | 5 | from activatable_model.signals import model_activations_changed, model_activations_updated 6 | 7 | 8 | class ActivatableQuerySet(ManagerUtilsQuerySet): 9 | """ 10 | Provides bulk activation/deactivation methods. 11 | """ 12 | def update(self, *args, **kwargs): 13 | if self.model.ACTIVATABLE_FIELD_NAME in kwargs: 14 | # Fetch the instances that are about to be updated if they have an activatable flag. This 15 | # is because their activatable flag may be changed in the subsequent update, causing us 16 | # to potentially lose what this original query referenced 17 | new_active_state_kwargs = { 18 | self.model.ACTIVATABLE_FIELD_NAME: kwargs.get(self.model.ACTIVATABLE_FIELD_NAME) 19 | } 20 | changed_instance_ids = list(self.exclude(**new_active_state_kwargs).values_list('id', flat=True)) 21 | updated_instance_ids = list(self.values_list('id', flat=True)) 22 | 23 | ret_val = super(ActivatableQuerySet, self).update(*args, **kwargs) 24 | 25 | if self.model.ACTIVATABLE_FIELD_NAME in kwargs and updated_instance_ids: 26 | # send the instances that were updated to the activation signals 27 | model_activations_changed.send( 28 | self.model, instance_ids=changed_instance_ids, 29 | is_active=kwargs[self.model.ACTIVATABLE_FIELD_NAME]) 30 | model_activations_updated.send( 31 | self.model, instance_ids=updated_instance_ids, 32 | is_active=kwargs[self.model.ACTIVATABLE_FIELD_NAME]) 33 | return ret_val 34 | 35 | def activate(self): 36 | return self.update(**{ 37 | self.model.ACTIVATABLE_FIELD_NAME: True 38 | }) 39 | 40 | def deactivate(self): 41 | return self.update(**{ 42 | self.model.ACTIVATABLE_FIELD_NAME: False 43 | }) 44 | 45 | def delete(self, force=False): 46 | return super(ActivatableQuerySet, self).delete() if force else self.deactivate() 47 | 48 | 49 | class ActivatableManager(ManagerUtilsManager): 50 | def get_queryset(self): 51 | return ActivatableQuerySet(self.model) 52 | 53 | def activate(self): 54 | return self.get_queryset().activate() 55 | 56 | def deactivate(self): 57 | return self.get_queryset().deactivate() 58 | 59 | 60 | class BaseActivatableModel(models.Model): 61 | """ 62 | Adds an is_active flag and processes information about when an is_active flag is changed. 63 | """ 64 | class Meta: 65 | abstract = True 66 | 67 | # The name of the Boolean field that determines if this model is active or inactive. A field 68 | # must be defined with this name, and it must be a BooleanField. Note that the reason we don't 69 | # define a BooleanField is because this would eliminate the ability for the user to easily 70 | # define default values for the field and if it is indexed. 71 | ACTIVATABLE_FIELD_NAME = 'is_active' 72 | 73 | # There are situations where you might actually want other models to be able to force-delete 74 | # you ActivatibleModel. In this case, no special delete action is taken and you model will 75 | # be removed from the database. To enable this behavior, set ALLOW_CASCADE_DELETE to True 76 | ALLOW_CASCADE_DELETE = False 77 | 78 | objects = ActivatableManager() 79 | 80 | # The original activatable field value, for determining when it changes 81 | __original_activatable_value = None 82 | 83 | def __init__(self, *args, **kwargs): 84 | super(BaseActivatableModel, self).__init__(*args, **kwargs) 85 | 86 | # Keep track of the updated status of the activatable field 87 | self.activatable_field_updated = self.id is None 88 | 89 | # Keep track of the original activatable value to know when it changes 90 | self.__original_activatable_value = getattr(self, self.ACTIVATABLE_FIELD_NAME) 91 | 92 | def __setattr__(self, key, value): 93 | if key == self.ACTIVATABLE_FIELD_NAME: 94 | self.activatable_field_updated = True 95 | return super(BaseActivatableModel, self).__setattr__(key, value) 96 | 97 | def save(self, *args, **kwargs): 98 | """ 99 | A custom save method that handles figuring out when something is activated or deactivated. 100 | """ 101 | current_activable_value = getattr(self, self.ACTIVATABLE_FIELD_NAME) 102 | is_active_changed = self.id is None or self.__original_activatable_value != current_activable_value 103 | self.__original_activatable_value = current_activable_value 104 | 105 | ret_val = super(BaseActivatableModel, self).save(*args, **kwargs) 106 | 107 | # Emit the signals for when the is_active flag is changed 108 | if is_active_changed: 109 | model_activations_changed.send(self.__class__, instance_ids=[self.id], is_active=current_activable_value) 110 | if self.activatable_field_updated: 111 | model_activations_updated.send(self.__class__, instance_ids=[self.id], is_active=current_activable_value) 112 | 113 | return ret_val 114 | 115 | def delete(self, force=False, **kwargs): 116 | """ 117 | It is impossible to delete an activatable model unless force is True. This function instead sets it to inactive. 118 | """ 119 | if force: 120 | return super(BaseActivatableModel, self).delete(**kwargs) 121 | else: 122 | setattr(self, self.ACTIVATABLE_FIELD_NAME, False) 123 | return self.save(update_fields=[self.ACTIVATABLE_FIELD_NAME]) 124 | -------------------------------------------------------------------------------- /activatable_model/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | 4 | # providing_args=['instance_ids', 'is_active'] 5 | model_activations_changed = Signal() 6 | 7 | # providing_args=['instance_ids', 'is_active'] 8 | model_activations_updated = Signal() 9 | -------------------------------------------------------------------------------- /activatable_model/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ambitioninc/django-activatable-model/ef5c8c3b8092fbddcfb3deead024d3ec7025e796/activatable_model/tests/__init__.py -------------------------------------------------------------------------------- /activatable_model/tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import models, migrations 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='ActivatableModel', 15 | fields=[ 16 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 17 | ('is_active', models.BooleanField(default=False)), 18 | ('char_field', models.CharField(max_length=64)), 19 | ], 20 | options={ 21 | 'abstract': False, 22 | }, 23 | ), 24 | migrations.CreateModel( 25 | name='ActivatableModelWNonDefaultField', 26 | fields=[ 27 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 28 | ('active', models.BooleanField(default=False)), 29 | ('char_field', models.CharField(max_length=64)), 30 | ], 31 | options={ 32 | 'abstract': False, 33 | }, 34 | ), 35 | migrations.CreateModel( 36 | name='ActivatableModelWRel', 37 | fields=[ 38 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 39 | ('is_active', models.BooleanField(default=False)), 40 | ], 41 | options={ 42 | 'abstract': False, 43 | }, 44 | ), 45 | migrations.CreateModel( 46 | name='Rel', 47 | fields=[ 48 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 49 | ('is_active', models.BooleanField(default=False)), 50 | ('char_field', models.CharField(max_length=64)), 51 | ], 52 | ), 53 | migrations.AddField( 54 | model_name='activatablemodelwrel', 55 | name='rel_field', 56 | field=models.ForeignKey(to='tests.Rel', on_delete=django.db.models.deletion.PROTECT), 57 | ), 58 | ] 59 | -------------------------------------------------------------------------------- /activatable_model/tests/migrations/0002_activatablemodelwrelandcascade.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.9 on 2017-06-28 17:24 3 | 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('tests', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='ActivatableModelWRelAndCascade', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('is_active', models.BooleanField(default=False)), 20 | ('rel_field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='tests.Rel')), 21 | ], 22 | options={ 23 | 'abstract': False, 24 | }, 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /activatable_model/tests/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ambitioninc/django-activatable-model/ef5c8c3b8092fbddcfb3deead024d3ec7025e796/activatable_model/tests/migrations/__init__.py -------------------------------------------------------------------------------- /activatable_model/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from activatable_model.models import BaseActivatableModel 4 | 5 | 6 | class ActivatableModel(BaseActivatableModel): 7 | is_active = models.BooleanField(default=False) 8 | char_field = models.CharField(max_length=64) 9 | 10 | 11 | class Rel(models.Model): 12 | is_active = models.BooleanField(default=False) 13 | char_field = models.CharField(max_length=64) 14 | 15 | 16 | class ActivatableModelWRel(BaseActivatableModel): 17 | is_active = models.BooleanField(default=False) 18 | rel_field = models.ForeignKey(Rel, on_delete=models.PROTECT) 19 | 20 | 21 | class ActivatableModelWRelAndCascade(BaseActivatableModel): 22 | ALLOW_CASCADE_DELETE = True 23 | is_active = models.BooleanField(default=False) 24 | rel_field = models.ForeignKey(Rel, on_delete=models.CASCADE) 25 | 26 | 27 | class ActivatableModelWNonDefaultField(BaseActivatableModel): 28 | ACTIVATABLE_FIELD_NAME = 'active' 29 | active = models.BooleanField(default=False) 30 | char_field = models.CharField(max_length=64) 31 | -------------------------------------------------------------------------------- /activatable_model/tests/tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from django.core.exceptions import ValidationError 3 | from django.db import models 4 | from django.test import TestCase, TransactionTestCase 5 | from django_dynamic_fixture import G 6 | from mock import patch, MagicMock, call 7 | 8 | from activatable_model.models import BaseActivatableModel 9 | from activatable_model.signals import model_activations_changed, model_activations_updated 10 | from activatable_model.validation import get_activatable_models, validate_activatable_models 11 | from activatable_model.tests.models import ( 12 | ActivatableModel, 13 | ActivatableModelWRel, 14 | Rel, 15 | ActivatableModelWNonDefaultField, 16 | ActivatableModelWRelAndCascade, 17 | ) 18 | 19 | 20 | class BaseMockActivationsSignalHanderTest(TestCase): 21 | """ 22 | Connects a mock to the model_activations_changed signal so that it can be easily tested. 23 | """ 24 | def setUp(self): 25 | super(BaseMockActivationsSignalHanderTest, self).setUp() 26 | self.mock_model_activations_changed_handler = MagicMock() 27 | model_activations_changed.connect(self.mock_model_activations_changed_handler) 28 | self.mock_model_activations_updated_handler = MagicMock() 29 | model_activations_updated.connect(self.mock_model_activations_updated_handler) 30 | 31 | def tearDown(self): 32 | super(BaseMockActivationsSignalHanderTest, self).tearDown() 33 | model_activations_changed.disconnect(self.mock_model_activations_changed_handler) 34 | 35 | 36 | class CascadeTest(TransactionTestCase): 37 | """ 38 | Tests that cascade deletes cant happen on an activatable test model. 39 | """ 40 | def test_no_cascade(self): 41 | rel = G(Rel) 42 | G(ActivatableModelWRel, rel_field=rel) 43 | with self.assertRaises(models.ProtectedError): 44 | rel.delete() 45 | 46 | def test_allowed_cascade(self): 47 | rel = G(Rel) 48 | rel_id = rel.id 49 | G(ActivatableModelWRelAndCascade, rel_field=rel) 50 | rel.delete() 51 | self.assertEqual(ActivatableModelWRelAndCascade.objects.filter(id=rel_id).count(), 0) 52 | 53 | 54 | class ManagerQuerySetTest(BaseMockActivationsSignalHanderTest): 55 | """ 56 | Tests custom functionality in the manager and queryset for activatable models. Tests it 57 | on models that use the default is_active field and models that define their own 58 | custom activatable field. 59 | """ 60 | def test_update_no_is_active(self): 61 | G(ActivatableModel, is_active=False) 62 | G(ActivatableModel, is_active=False) 63 | ActivatableModel.objects.update(char_field='hi') 64 | self.assertEquals(ActivatableModel.objects.filter(char_field='hi', is_active=False).count(), 2) 65 | self.assertEquals(self.mock_model_activations_changed_handler.call_count, 2) 66 | 67 | def test_update_no_is_active_custom(self): 68 | G(ActivatableModelWNonDefaultField, active=False) 69 | G(ActivatableModelWNonDefaultField, active=False) 70 | ActivatableModelWNonDefaultField.objects.update(char_field='hi') 71 | self.assertEquals(ActivatableModelWNonDefaultField.objects.filter(char_field='hi', active=False).count(), 2) 72 | self.assertEquals(self.mock_model_activations_changed_handler.call_count, 2) 73 | 74 | def test_update_w_is_active(self): 75 | m1 = G(ActivatableModel, is_active=False) 76 | m2 = G(ActivatableModel, is_active=False) 77 | ActivatableModel.objects.filter(is_active=False).update(char_field='hi', is_active=True) 78 | self.assertEquals(ActivatableModel.objects.filter(char_field='hi', is_active=True).count(), 2) 79 | self.assertEquals(self.mock_model_activations_changed_handler.call_count, 3) 80 | 81 | call_args = self.mock_model_activations_changed_handler.call_args 82 | self.assertEquals(call_args[1]['is_active'], True) 83 | self.assertEquals(set(call_args[1]['instance_ids']), set([m1.id, m2.id])) 84 | self.assertEquals(call_args[1]['sender'], ActivatableModel) 85 | 86 | def test_update_w_is_active_custom(self): 87 | m1 = G(ActivatableModelWNonDefaultField, active=False) 88 | m2 = G(ActivatableModelWNonDefaultField, active=False) 89 | ActivatableModelWNonDefaultField.objects.update(char_field='hi', active=True) 90 | self.assertEquals(ActivatableModelWNonDefaultField.objects.filter(char_field='hi', active=True).count(), 2) 91 | self.assertEquals(self.mock_model_activations_changed_handler.call_count, 3) 92 | 93 | call_args = self.mock_model_activations_changed_handler.call_args 94 | self.assertEquals(call_args[1]['is_active'], True) 95 | self.assertEquals(set(call_args[1]['instance_ids']), set([m1.id, m2.id])) 96 | self.assertEquals(call_args[1]['sender'], ActivatableModelWNonDefaultField) 97 | 98 | def test_activate(self): 99 | models = [ 100 | G(ActivatableModel, is_active=False), 101 | G(ActivatableModel, is_active=True), 102 | ] 103 | ActivatableModel.objects.activate() 104 | self.assertEquals(ActivatableModel.objects.filter(is_active=True).count(), 2) 105 | static_kwargs = { 106 | 'sender': ActivatableModel, 107 | 'signal': model_activations_changed, 108 | } 109 | self.mock_model_activations_changed_handler.assert_has_calls([ 110 | call(instance_ids=[models[0].id], is_active=False, **static_kwargs), 111 | call(instance_ids=[models[1].id], is_active=True, **static_kwargs), 112 | call(instance_ids=[models[0].id], is_active=True, **static_kwargs), 113 | ]) 114 | static_kwargs['signal'] = model_activations_updated 115 | self.mock_model_activations_updated_handler.assert_has_calls([ 116 | call(instance_ids=[models[0].id], is_active=False, **static_kwargs), 117 | call(instance_ids=[models[1].id], is_active=True, **static_kwargs), 118 | call(instance_ids=[models[0].id, models[1].id], is_active=True, **static_kwargs), 119 | ]) 120 | 121 | def test_activate_custom(self): 122 | models = [ 123 | G(ActivatableModelWNonDefaultField, active=False), 124 | G(ActivatableModelWNonDefaultField, active=True), 125 | ] 126 | ActivatableModelWNonDefaultField.objects.activate() 127 | self.assertEquals(ActivatableModelWNonDefaultField.objects.filter(active=True).count(), 2) 128 | static_kwargs = { 129 | 'sender': ActivatableModelWNonDefaultField, 130 | 'signal': model_activations_changed, 131 | } 132 | self.mock_model_activations_changed_handler.assert_has_calls([ 133 | call(instance_ids=[models[0].id], is_active=False, **static_kwargs), 134 | call(instance_ids=[models[1].id], is_active=True, **static_kwargs), 135 | call(instance_ids=[models[0].id], is_active=True, **static_kwargs), 136 | ]) 137 | static_kwargs['signal'] = model_activations_updated 138 | self.mock_model_activations_updated_handler.assert_has_calls([ 139 | call(instance_ids=[models[0].id], is_active=False, **static_kwargs), 140 | call(instance_ids=[models[1].id], is_active=True, **static_kwargs), 141 | call(instance_ids=[models[0].id, models[1].id], is_active=True, **static_kwargs), 142 | ]) 143 | 144 | def test_deactivate(self): 145 | models = [ 146 | G(ActivatableModel, is_active=False), 147 | G(ActivatableModel, is_active=True), 148 | ] 149 | ActivatableModel.objects.deactivate() 150 | self.assertEquals(ActivatableModel.objects.filter(is_active=False).count(), 2) 151 | static_kwargs = { 152 | 'sender': ActivatableModel, 153 | 'signal': model_activations_changed, 154 | } 155 | self.mock_model_activations_changed_handler.assert_has_calls([ 156 | call(instance_ids=[models[0].id], is_active=False, **static_kwargs), 157 | call(instance_ids=[models[1].id], is_active=True, **static_kwargs), 158 | call(instance_ids=[models[1].id], is_active=False, **static_kwargs), 159 | ]) 160 | static_kwargs['signal'] = model_activations_updated 161 | self.mock_model_activations_updated_handler.assert_has_calls([ 162 | call(instance_ids=[models[0].id], is_active=False, **static_kwargs), 163 | call(instance_ids=[models[1].id], is_active=True, **static_kwargs), 164 | call(instance_ids=[models[0].id, models[1].id], is_active=False, **static_kwargs), 165 | ]) 166 | 167 | def test_deactivate_custom(self): 168 | models = [ 169 | G(ActivatableModelWNonDefaultField, active=False), 170 | G(ActivatableModelWNonDefaultField, active=True), 171 | ] 172 | ActivatableModelWNonDefaultField.objects.deactivate() 173 | self.assertEquals(ActivatableModelWNonDefaultField.objects.filter(active=False).count(), 2) 174 | static_kwargs = { 175 | 'sender': ActivatableModelWNonDefaultField, 176 | 'signal': model_activations_changed, 177 | } 178 | self.mock_model_activations_changed_handler.assert_has_calls([ 179 | call(instance_ids=[models[0].id], is_active=False, **static_kwargs), 180 | call(instance_ids=[models[1].id], is_active=True, **static_kwargs), 181 | call(instance_ids=[models[1].id], is_active=False, **static_kwargs), 182 | ]) 183 | static_kwargs['signal'] = model_activations_updated 184 | self.mock_model_activations_updated_handler.assert_has_calls([ 185 | call(instance_ids=[models[0].id], is_active=False, **static_kwargs), 186 | call(instance_ids=[models[1].id], is_active=True, **static_kwargs), 187 | call(instance_ids=[models[0].id, models[1].id], is_active=False, **static_kwargs), 188 | ]) 189 | 190 | def test_delete_no_force(self): 191 | G(ActivatableModel, is_active=False) 192 | G(ActivatableModel, is_active=True) 193 | ActivatableModel.objects.all().delete() 194 | self.assertEquals(ActivatableModel.objects.filter(is_active=False).count(), 2) 195 | self.assertEquals(self.mock_model_activations_changed_handler.call_count, 3) 196 | 197 | def test_delete_no_force_custom(self): 198 | G(ActivatableModelWNonDefaultField, active=False) 199 | G(ActivatableModelWNonDefaultField, active=True) 200 | ActivatableModelWNonDefaultField.objects.all().delete() 201 | self.assertEquals(ActivatableModelWNonDefaultField.objects.filter(active=False).count(), 2) 202 | self.assertEquals(self.mock_model_activations_changed_handler.call_count, 3) 203 | 204 | def test_delete_w_force(self): 205 | G(ActivatableModel, is_active=False) 206 | G(ActivatableModel, is_active=True) 207 | ActivatableModel.objects.all().delete(force=True) 208 | self.assertFalse(ActivatableModel.objects.exists()) 209 | self.assertEquals(self.mock_model_activations_changed_handler.call_count, 2) 210 | 211 | def test_delete_w_force_custom(self): 212 | G(ActivatableModelWNonDefaultField, active=False) 213 | G(ActivatableModelWNonDefaultField, active=True) 214 | ActivatableModelWNonDefaultField.objects.all().delete(force=True) 215 | self.assertFalse(ActivatableModelWNonDefaultField.objects.exists()) 216 | self.assertEquals(self.mock_model_activations_changed_handler.call_count, 2) 217 | 218 | 219 | class SaveTest(BaseMockActivationsSignalHanderTest): 220 | """ 221 | Tests the custom save function in the BaseActivatableModel. 222 | """ 223 | def test_create(self): 224 | m = G(ActivatableModel, is_active=False) 225 | call_args = self.mock_model_activations_changed_handler.call_args 226 | self.assertEquals(call_args[1]['is_active'], False) 227 | self.assertEquals(call_args[1]['instance_ids'], [m.id]) 228 | self.assertEquals(call_args[1]['sender'], ActivatableModel) 229 | updated_call_args = self.mock_model_activations_updated_handler.call_args 230 | self.assertEquals(updated_call_args[1]['is_active'], False) 231 | self.assertEquals(updated_call_args[1]['instance_ids'], [m.id]) 232 | self.assertEquals(updated_call_args[1]['sender'], ActivatableModel) 233 | 234 | def test_save_not_changed(self): 235 | m = G(ActivatableModel, is_active=False) 236 | m.is_active = False 237 | m.save() 238 | 239 | self.assertEquals(self.mock_model_activations_changed_handler.call_count, 1) 240 | self.assertEquals(self.mock_model_activations_updated_handler.call_count, 2) 241 | 242 | def test_save_changed(self): 243 | m = G(ActivatableModel, is_active=False) 244 | m.is_active = True 245 | m.save() 246 | 247 | # changed 248 | self.assertEquals(self.mock_model_activations_changed_handler.call_count, 2) 249 | call_args = self.mock_model_activations_changed_handler.call_args 250 | self.assertEquals(call_args[1]['is_active'], True) 251 | self.assertEquals(call_args[1]['instance_ids'], [m.id]) 252 | self.assertEquals(call_args[1]['sender'], ActivatableModel) 253 | # updated 254 | self.assertEquals(self.mock_model_activations_updated_handler.call_count, 2) 255 | updated_call_args = self.mock_model_activations_updated_handler.call_args 256 | self.assertEquals(updated_call_args[1]['is_active'], True) 257 | self.assertEquals(updated_call_args[1]['instance_ids'], [m.id]) 258 | self.assertEquals(updated_call_args[1]['sender'], ActivatableModel) 259 | 260 | def test_save_changed_custom(self): 261 | m = G(ActivatableModelWNonDefaultField, active=False) 262 | m.active = True 263 | m.save() 264 | 265 | # changed 266 | self.assertEquals(self.mock_model_activations_changed_handler.call_count, 2) 267 | call_args = self.mock_model_activations_changed_handler.call_args 268 | self.assertEquals(call_args[1]['is_active'], True) 269 | self.assertEquals(call_args[1]['instance_ids'], [m.id]) 270 | self.assertEquals(call_args[1]['sender'], ActivatableModelWNonDefaultField) 271 | # updated 272 | self.assertEquals(self.mock_model_activations_updated_handler.call_count, 2) 273 | updated_call_args = self.mock_model_activations_updated_handler.call_args 274 | self.assertEquals(updated_call_args[1]['is_active'], True) 275 | self.assertEquals(updated_call_args[1]['instance_ids'], [m.id]) 276 | self.assertEquals(updated_call_args[1]['sender'], ActivatableModelWNonDefaultField) 277 | 278 | 279 | class SingleDeleteTest(BaseMockActivationsSignalHanderTest): 280 | """ 281 | Tests calling delete on a single model that inherits BaseActivatableModel. 282 | """ 283 | def test_delete_no_force_no_active_changed(self): 284 | m = G(ActivatableModel, is_active=False) 285 | m.delete() 286 | m = ActivatableModel.objects.get(id=m.id) 287 | self.assertFalse(m.is_active) 288 | self.assertEquals(self.mock_model_activations_changed_handler.call_count, 1) 289 | self.assertEquals(self.mock_model_activations_updated_handler.call_count, 2) 290 | 291 | def test_delete_no_force_active_changed(self): 292 | m = G(ActivatableModel, is_active=True) 293 | m.delete() 294 | m = ActivatableModel.objects.get(id=m.id) 295 | self.assertFalse(m.is_active) 296 | self.assertEquals(self.mock_model_activations_changed_handler.call_count, 2) 297 | self.assertEquals(self.mock_model_activations_updated_handler.call_count, 2) 298 | 299 | def test_delete_force(self): 300 | m = G(ActivatableModel, is_active=False) 301 | m.delete(force=True) 302 | self.assertFalse(ActivatableModel.objects.exists()) 303 | 304 | 305 | class ValidateDbTest(TestCase): 306 | """ 307 | Tests that activatable models are validated properly upon pre_syncdb signal. 308 | """ 309 | def test_get_activatable_models(self): 310 | activatable_models = get_activatable_models() 311 | self.assertEquals( 312 | set( 313 | [ 314 | ActivatableModel, 315 | ActivatableModelWRel, 316 | ActivatableModelWRelAndCascade, 317 | ActivatableModelWNonDefaultField 318 | ] 319 | ), 320 | set(activatable_models) 321 | ) 322 | 323 | def test_all_valid_models(self): 324 | """ 325 | All models should validate fine. 326 | """ 327 | validate_activatable_models() 328 | 329 | @patch('activatable_model.validation.get_activatable_models') 330 | def test_activatable_field_is_not_boolean(self, mock_get_activatable_models): 331 | """ 332 | SET_NULL is a valid option for foreign keys in activatable models. 333 | """ 334 | # Make this an object and not an actual django model. This prevents it from always 335 | # being included when syncing the db. This is true for all other test models in this file. 336 | class NonBooleanModel(BaseActivatableModel): 337 | class Meta: 338 | abstract = True 339 | 340 | is_active = models.CharField() 341 | ctype = models.ForeignKey(ContentType, null=True, on_delete=models.SET_NULL) 342 | 343 | mock_get_activatable_models.return_value = [NonBooleanModel] 344 | with self.assertRaises(ValidationError): 345 | validate_activatable_models() 346 | 347 | @patch('activatable_model.validation.get_activatable_models') 348 | def test_activatable_field_is_not_defined(self, mock_get_activatable_models): 349 | """ 350 | SET_NULL is a valid option for foreign keys in activatable models. 351 | """ 352 | # Make this an object and not an actual django model. This prevents it from always 353 | # being included when syncing the db. This is true for all other test models in this file. 354 | class NoValidFieldModel(BaseActivatableModel): 355 | class Meta: 356 | abstract = True 357 | 358 | ACTIVATABLE_FIELD_NAME = 'active' 359 | is_active = models.BooleanField() 360 | ctype = models.ForeignKey(ContentType, null=True, on_delete=models.SET_NULL) 361 | 362 | mock_get_activatable_models.return_value = [NoValidFieldModel] 363 | with self.assertRaises(ValidationError): 364 | validate_activatable_models() 365 | 366 | @patch('activatable_model.validation.get_activatable_models') 367 | def test_foreign_key_is_null(self, mock_get_activatable_models): 368 | """ 369 | SET_NULL is a valid option for foreign keys in activatable models. 370 | """ 371 | # Make this an object and not an actual django model. This prevents it from always 372 | # being included when syncing the db. This is true for all other test models in this file. 373 | class CascadableModel(BaseActivatableModel): 374 | class Meta: 375 | abstract = True 376 | 377 | is_active = models.BooleanField(default=False) 378 | ctype = models.ForeignKey(ContentType, null=True, on_delete=models.SET_NULL) 379 | 380 | mock_get_activatable_models.return_value = [CascadableModel] 381 | validate_activatable_models() 382 | self.assertEquals(mock_get_activatable_models.call_count, 1) 383 | 384 | @patch('activatable_model.validation.get_activatable_models') 385 | def test_foreign_key_protect(self, mock_get_activatable_models): 386 | """ 387 | PROTECT is a valid option for foreign keys in activatable models. 388 | """ 389 | # Make this an object and not an actual django model. This prevents it from always 390 | # being included when syncing the db. This is true for all other test models in this file. 391 | class CascadableModel(BaseActivatableModel): 392 | class Meta: 393 | abstract = True 394 | 395 | is_active = models.BooleanField(default=False) 396 | ctype = models.ForeignKey(ContentType, null=True, on_delete=models.PROTECT) 397 | 398 | mock_get_activatable_models.return_value = [CascadableModel] 399 | validate_activatable_models() 400 | self.assertEquals(mock_get_activatable_models.call_count, 1) 401 | 402 | @patch('activatable_model.validation.get_activatable_models') 403 | def test_foreign_key_cascade(self, mock_get_activatable_models): 404 | """ 405 | The default cascade behavior is invalid for activatable models. 406 | """ 407 | class CascadableModel(BaseActivatableModel): 408 | class Meta: 409 | abstract = True 410 | 411 | is_active = models.BooleanField(default=False) 412 | ctype = models.ForeignKey(ContentType, on_delete=models.CASCADE) 413 | 414 | mock_get_activatable_models.return_value = [CascadableModel] 415 | with self.assertRaises(ValidationError): 416 | validate_activatable_models() 417 | 418 | @patch('activatable_model.validation.get_activatable_models') 419 | def test_one_to_one_is_null(self, mock_get_activatable_models): 420 | """ 421 | SET_NULL is a valid option for foreign keys in activatable models. 422 | """ 423 | # Make this an object and not an actual django model. This prevents it from always 424 | # being included when syncing the db. This is true for all other test models in this file. 425 | class CascadableModel(BaseActivatableModel): 426 | class Meta: 427 | abstract = True 428 | 429 | is_active = models.BooleanField(default=False) 430 | ctype = models.OneToOneField(ContentType, null=True, on_delete=models.SET_NULL) 431 | 432 | mock_get_activatable_models.return_value = [CascadableModel] 433 | validate_activatable_models() 434 | self.assertEquals(mock_get_activatable_models.call_count, 1) 435 | 436 | @patch('activatable_model.validation.get_activatable_models') 437 | def test_one_to_one_protect(self, mock_get_activatable_models): 438 | """ 439 | PROTECT is a valid option for foreign keys in activatable models. 440 | """ 441 | # Make this an object and not an actual django model. This prevents it from always 442 | # being included when syncing the db. This is true for all other test models in this file. 443 | class CascadableModel(BaseActivatableModel): 444 | class Meta: 445 | abstract = True 446 | 447 | is_active = models.BooleanField(default=False) 448 | ctype = models.OneToOneField(ContentType, null=True, on_delete=models.PROTECT) 449 | 450 | mock_get_activatable_models.return_value = [CascadableModel] 451 | validate_activatable_models() 452 | self.assertEquals(mock_get_activatable_models.call_count, 1) 453 | 454 | @patch('activatable_model.validation.get_activatable_models') 455 | def test_one_to_one_cascade(self, mock_get_activatable_models): 456 | """ 457 | The default cascade behavior is invalid for activatable models. 458 | """ 459 | class CascadableModel(BaseActivatableModel): 460 | class Meta: 461 | abstract = True 462 | 463 | is_active = models.BooleanField(default=False) 464 | ctype = models.OneToOneField(ContentType, on_delete=models.CASCADE) 465 | 466 | mock_get_activatable_models.return_value = [CascadableModel] 467 | with self.assertRaises(ValidationError): 468 | validate_activatable_models() 469 | 470 | 471 | class ModelUpdatedSignalTest(BaseMockActivationsSignalHanderTest): 472 | """ 473 | Tests the updated signal test 474 | """ 475 | def test_no_activatable_field_updated(self): 476 | m = G(ActivatableModel, is_active=False) 477 | m_from_db = ActivatableModel.objects.get(id=m.id) 478 | m_from_db.char_field = 'foo' 479 | m_from_db.save() 480 | self.assertEquals(self.mock_model_activations_updated_handler.call_count, 1) 481 | -------------------------------------------------------------------------------- /activatable_model/urls.py: -------------------------------------------------------------------------------- 1 | urlpatterns = [] # pragma: no cover 2 | -------------------------------------------------------------------------------- /activatable_model/validation.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from activatable_model.models import BaseActivatableModel 4 | from django.apps import apps 5 | from django.core.exceptions import ValidationError 6 | from django.db import models 7 | 8 | 9 | def get_activatable_models(): 10 | all_models = chain(*[app.get_models() for app in apps.get_app_configs()]) 11 | return [model for model in all_models if issubclass(model, BaseActivatableModel)] 12 | 13 | 14 | def validate_activatable_models(): 15 | """ 16 | Raises a ValidationError for any ActivatableModel that has ForeignKeys or OneToOneFields that will 17 | cause cascading deletions to occur. This function also raises a ValidationError if the activatable 18 | model has not defined a Boolean field with the field name defined by the ACTIVATABLE_FIELD_NAME variable 19 | on the model. 20 | """ 21 | for model in get_activatable_models(): 22 | # Verify the activatable model has an activatable boolean field 23 | activatable_field = next(( 24 | f for f in model._meta.fields 25 | if f.__class__ == models.BooleanField and f.name == model.ACTIVATABLE_FIELD_NAME 26 | ), None) 27 | if activatable_field is None: 28 | raise ValidationError(( 29 | 'Model {0} is an activatable model. It must define an activatable BooleanField that ' 30 | 'has a field name of model.ACTIVATABLE_FIELD_NAME (which defaults to is_active)'.format(model) 31 | )) 32 | 33 | # Ensure all foreign keys and onetoone fields will not result in cascade deletions if not cascade deletable 34 | if not model.ALLOW_CASCADE_DELETE: 35 | for field in model._meta.fields: 36 | if field.__class__ in (models.ForeignKey, models.OneToOneField): 37 | if field.remote_field.on_delete == models.CASCADE: 38 | raise ValidationError(( 39 | 'Model {0} is an activatable model. All ForeignKey and OneToOneFields ' 40 | 'must set on_delete methods to something other than CASCADE (the default). ' 41 | 'If you want to explicitely allow cascade deletes, then you must set the ' 42 | 'ALLOW_CASCADE_DELETE=True class variable on your model.' 43 | ).format(model)) 44 | -------------------------------------------------------------------------------- /activatable_model/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '3.1.0' 2 | -------------------------------------------------------------------------------- /docs/release_notes.rst: -------------------------------------------------------------------------------- 1 | Release Notes 2 | ============= 3 | 4 | v3.1.0 5 | ------ 6 | * Drop django 2 7 | * Add Django 4.2 8 | 9 | v3.0.1 10 | ------ 11 | * Fix manifest 12 | 13 | v3.0.0 14 | ------ 15 | * Django 3.2, 4.0, 4.1 16 | * support python 3.7, 3.8, 3.9 17 | * drop python 3.6 18 | 19 | v2.0.0 20 | ------ 21 | * Only support Django 2.2, 3.0, 3.1 22 | * Only support python 3.6, 3.7, 3.8 23 | 24 | v1.2.1 25 | ------ 26 | * Fix setup file 27 | 28 | v1.2.0 29 | ------ 30 | * Python 3.7 31 | * Django 2.1 32 | * Django 2.2 33 | 34 | v1.1.0 35 | ------ 36 | * Use tox to support more versions 37 | 38 | v1.0.0 39 | ------ 40 | * Remove python 2.7 support 41 | * Remove python 3.4 support 42 | * Remove Django 1.9 support 43 | * Remove Django 1.10 support 44 | * Add Django 2.0 support 45 | 46 | v0.9.0 47 | ------ 48 | * Add python 3.6 support 49 | * Drop django 1.8 support 50 | * Add django 1.10 support 51 | * Add django 1.11 support 52 | 53 | v0.7.3 54 | ------ 55 | * Add python 3.5 support, drop django 1.7 support 56 | 57 | v0.7.2 58 | ------ 59 | * Republishing to test a pypi issue 60 | 61 | v0.7.1 62 | ------ 63 | * Added Django 1.7 support back 64 | 65 | v0.7.0 66 | ------ 67 | * Added support for Django 1.9 and dropped support for Django 1.7 68 | * Removed model importing from __init__ file, so all model import paths need to use the full activatable_model.models path 69 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | 4 | from settings import configure_settings 5 | 6 | 7 | if __name__ == '__main__': 8 | configure_settings() 9 | 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /publish.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | subprocess.call(['rm', '-r', 'dist/']) 4 | subprocess.call(['pip', 'install', 'wheel']) 5 | subprocess.call(['pip', 'install', 'twine']) 6 | subprocess.call(['python', 'setup.py', 'clean', '--all']) 7 | subprocess.call(['python', 'setup.py', 'register', 'sdist', 'bdist_wheel']) 8 | subprocess.call(['twine', 'upload', 'dist/*']) 9 | subprocess.call(['rm', '-r', 'dist/']) 10 | subprocess.call(['rm', '-r', 'build/']) 11 | -------------------------------------------------------------------------------- /requirements/requirements-testing.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | #coveralls 3 | psycopg2 4 | django-dynamic-fixture 5 | django-nose 6 | mock 7 | flake8 8 | -------------------------------------------------------------------------------- /requirements/requirements.txt: -------------------------------------------------------------------------------- 1 | Django>=3.2 2 | django-manager-utils>=3.1.0 3 | -------------------------------------------------------------------------------- /run_tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides the ability to run test on a standalone Django app. 3 | """ 4 | import sys 5 | from optparse import OptionParser 6 | from settings import configure_settings 7 | 8 | 9 | # Configure the default settings 10 | configure_settings() 11 | 12 | 13 | # Django nose must be imported here since it depends on the settings being configured 14 | from django_nose import NoseTestSuiteRunner 15 | 16 | 17 | def run_tests(*test_args, **kwargs): 18 | if not test_args: 19 | test_args = ['activatable_model'] 20 | 21 | kwargs.setdefault('interactive', False) 22 | 23 | test_runner = NoseTestSuiteRunner(**kwargs) 24 | 25 | failures = test_runner.run_tests(test_args) 26 | sys.exit(failures) 27 | 28 | 29 | if __name__ == '__main__': 30 | parser = OptionParser() 31 | parser.add_option('--verbosity', dest='verbosity', action='store', default=1, type=int) 32 | 33 | (options, args) = parser.parse_args() 34 | 35 | run_tests(*args, **options.__dict__) 36 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | from django.conf import settings 5 | 6 | 7 | def configure_settings(): 8 | """ 9 | Configures settings for manage.py and for run_tests.py. 10 | """ 11 | if not settings.configured: 12 | # Determine the database settings depending on if a test_db var is set in CI mode or not 13 | test_db = os.environ.get('DB', None) 14 | if test_db is None: 15 | db_config = { 16 | 'ENGINE': 'django.db.backends.postgresql', 17 | 'NAME': 'activatable_model', 18 | 'USER': 'postgres', 19 | 'PASSWORD': '', 20 | 'HOST': 'db', 21 | } 22 | elif test_db == 'postgres': 23 | db_config = { 24 | 'ENGINE': 'django.db.backends.postgresql', 25 | 'NAME': 'activatable_model', 26 | 'USER': 'postgres', 27 | 'PASSWORD': '', 28 | 'HOST': 'db', 29 | } 30 | else: 31 | raise RuntimeError('Unsupported test DB {0}'.format(test_db)) 32 | 33 | # Check env for db override (used for github actions) 34 | if os.environ.get('DB_SETTINGS'): 35 | db_config = json.loads(os.environ.get('DB_SETTINGS')) 36 | 37 | settings.configure( 38 | TEST_RUNNER='django_nose.NoseTestSuiteRunner', 39 | SECRET_KEY='*', 40 | NOSE_ARGS=['--nocapture', '--nologcapture', '--verbosity=1'], 41 | MIDDLEWARE_CLASSES={}, 42 | DATABASES={ 43 | 'default': db_config, 44 | }, 45 | INSTALLED_APPS=( 46 | 'django.contrib.auth', 47 | 'django.contrib.contenttypes', 48 | 'django.contrib.sessions', 49 | 'django.contrib.admin', 50 | 'activatable_model', 51 | 'activatable_model.tests', 52 | ), 53 | ROOT_URLCONF='activatable_model.urls', 54 | DEBUG=False, 55 | ) 56 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | exclude = build,docs,venv,env,*.egg,migrations,south_migrations 4 | max-complexity = 10 5 | ignore = E402 6 | 7 | [bdist_wheel] 8 | universal = 1 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # import multiprocessing to avoid this bug (http://bugs.python.org/issue15881#msg170215) 2 | import multiprocessing 3 | assert multiprocessing 4 | import re 5 | from setuptools import setup, find_packages 6 | 7 | 8 | def get_version(): 9 | """ 10 | Extracts the version number from the version.py file. 11 | """ 12 | VERSION_FILE = 'activatable_model/version.py' 13 | mo = re.search(r'^__version__ = [\'"]([^\'"]*)[\'"]', open(VERSION_FILE, 'rt').read(), re.M) 14 | if mo: 15 | return mo.group(1) 16 | else: 17 | raise RuntimeError('Unable to find version string in {0}.'.format(VERSION_FILE)) 18 | 19 | 20 | def get_lines(file_path): 21 | return open(file_path, 'r').read().split('\n') 22 | 23 | 24 | install_requires = get_lines('requirements/requirements.txt') 25 | tests_require = get_lines('requirements/requirements-testing.txt') 26 | 27 | 28 | setup( 29 | name='django-activatable-model', 30 | version=get_version(), 31 | description='Django Activatable Model', 32 | long_description=open('README.md').read(), 33 | long_description_content_type='text/markdown', 34 | url='https://github.com/ambitioninc/django-activatable-model', 35 | author='Wes Kendall', 36 | author_email='opensource@ambition.com', 37 | keywords='Django, Model, Activatable', 38 | packages=find_packages(), 39 | classifiers=[ 40 | 'Programming Language :: Python', 41 | 'Programming Language :: Python :: 3.7', 42 | 'Programming Language :: Python :: 3.8', 43 | 'Programming Language :: Python :: 3.9', 44 | 'Intended Audience :: Developers', 45 | 'License :: OSI Approved :: MIT License', 46 | 'Operating System :: OS Independent', 47 | 'Framework :: Django', 48 | 'Framework :: Django :: 3.2', 49 | 'Framework :: Django :: 4.0', 50 | 'Framework :: Django :: 4.1', 51 | 'Framework :: Django :: 4.2', 52 | ], 53 | license='MIT', 54 | install_requires=install_requires, 55 | tests_require=tests_require, 56 | extras_require={'dev': tests_require}, 57 | test_suite='run_tests.run_tests', 58 | include_package_data=True, 59 | zip_safe=False, 60 | ) 61 | -------------------------------------------------------------------------------- /tox_old.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | flake8 4 | py{36,37}-django22 5 | py{36,37,38}-django30 6 | py{36,37,38}-django31 7 | py{36,37,38}-djangomaster 8 | 9 | [testenv] 10 | setenv = 11 | DB = postgres 12 | deps = 13 | django22: Django>=2.2,<3.0 14 | django30: Django>=3.0,<3.1 15 | django31: Django>=3.1,<3.2 16 | djangomaster: https://github.com/django/django/archive/master.tar.gz 17 | -rrequirements/requirements-testing.txt 18 | commands = 19 | coverage run setup.py test 20 | coverage report --fail-under=100 21 | 22 | [testenv:flake8] 23 | deps = flake8 24 | commands = flake8 activatable_model 25 | 26 | [travis:env] 27 | DJANGO = 28 | 2.2: django22 29 | 3.0: django30 30 | 3.1: django31 31 | master: djangomaster 32 | --------------------------------------------------------------------------------