├── .coveragerc ├── .editorconfig ├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── behaviors ├── __init__.py ├── apps.py ├── behaviors.py ├── compat.py ├── forms.py ├── managers.py └── querysets.py ├── docs ├── Makefile ├── authors.rst ├── conf.py ├── contributing.rst ├── history.rst ├── index.rst ├── installation.rst ├── make.bat ├── readme.rst └── usage.rst ├── manage.py ├── requirements.txt ├── requirements_dev.txt ├── requirements_test.txt ├── runtests.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── settings.py ├── templates │ ├── authoredmock_form.html │ └── base.html ├── test_behaviors.py ├── test_forms.py ├── test_managers.py ├── test_mixins_overrides.py ├── test_querysets.py ├── urls.py └── views.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | 4 | [report] 5 | omit = 6 | *site-packages* 7 | *tests* 8 | *.tox* 9 | *__init__.py* 10 | show_missing = True 11 | exclude_lines = 12 | raise NotImplementedError 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{py,rst,ini}] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.{html,css,scss,json,yml}] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | 22 | [Makefile] 23 | indent_style = tab 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * Django Behaviors version: 2 | * Django version: 3 | * Python version: 4 | * Operating System: 5 | 6 | ### Description 7 | 8 | Describe what you were trying to get done. 9 | Tell us what happened, what went wrong, and what you expected to happen. 10 | 11 | ### What I Did 12 | 13 | ``` 14 | Paste the command(s) you ran and the output. 15 | If there was a crash, please include the traceback here. 16 | ``` 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | 4 | # C extensions 5 | *.so 6 | 7 | # Packages 8 | *.egg 9 | *.egg-info 10 | dist 11 | build 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | lib 20 | lib64 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | htmlcov 30 | 31 | # Translations 32 | *.mo 33 | 34 | # Mr Developer 35 | .mr.developer.cfg 36 | .project 37 | .pydevproject 38 | 39 | # Pycharm/Intellij/VSCode 40 | .idea 41 | .vscode/ 42 | 43 | # Complexity 44 | output/*.html 45 | output/*/index.html 46 | 47 | # Sphinx 48 | docs/_build 49 | 50 | # Virtualenv folder 51 | .env/ 52 | venv/ 53 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.org 2 | 3 | language: python 4 | cache: pip 5 | python: 6 | - "2.7" 7 | - "3.5" 8 | - "3.6" 9 | - "3.7" 10 | 11 | sudo: false 12 | 13 | env: 14 | - DJANGO=1.8 15 | - DJANGO=1.11 16 | - DJANGO=2.1 17 | - DJANGO=2.2 18 | 19 | matrix: 20 | fast_finish: true 21 | exclude: 22 | # Python/Django combinations that aren't officially supported 23 | - { python: 2.7, env: DJANGO=2.1 } 24 | - { python: 2.7, env: DJANGO=2.2 } 25 | 26 | # command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors 27 | install: pip install tox-travis -r requirements_test.txt 28 | 29 | # command to run tests using coverage, e.g. python setup.py test 30 | script: tox 31 | 32 | after_success: 33 | - codecov -e TOX_ENV 34 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Ryan Castner 9 | 10 | Contributors 11 | ------------ 12 | 13 | * apirobot 14 | * abekroenem 15 | * f213 16 | * pabarros 17 | * richardnias 18 | * kazqvaizer 19 | * vonmaster 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | You can contribute in many ways: 9 | 10 | Types of Contributions 11 | ---------------------- 12 | 13 | Report Bugs 14 | ~~~~~~~~~~~ 15 | 16 | Report bugs at https://github.com/audiolion/django-behaviors/issues. 17 | 18 | If you are reporting a bug, please include: 19 | 20 | * Your operating system name and version. 21 | * Any details about your local setup that might be helpful in troubleshooting. 22 | * Detailed steps to reproduce the bug. 23 | 24 | Fix Bugs 25 | ~~~~~~~~ 26 | 27 | Look through the GitHub issues for bugs. Anything tagged with "bug" 28 | is open to whoever wants to implement it. 29 | 30 | Implement Features 31 | ~~~~~~~~~~~~~~~~~~ 32 | 33 | Look through the GitHub issues for features. Anything tagged with "feature" 34 | is open to whoever wants to implement it. 35 | 36 | Write Documentation 37 | ~~~~~~~~~~~~~~~~~~~ 38 | 39 | Django Behaviors could always use more documentation, whether as part of the 40 | official Django Behaviors docs, in docstrings, or even on the web in blog posts, 41 | articles, and such. 42 | 43 | Submit Feedback 44 | ~~~~~~~~~~~~~~~ 45 | 46 | The best way to send feedback is to file an issue at https://github.com/audiolion/django-behaviors/issues. 47 | 48 | If you are proposing a feature: 49 | 50 | * Explain in detail how it would work. 51 | * Keep the scope as narrow as possible, to make it easier to implement. 52 | * Remember that this is a volunteer-driven project, and that contributions 53 | are welcome :) 54 | 55 | Get Started! 56 | ------------ 57 | 58 | Ready to contribute? Here's how to set up `django-behaviors` for local development. 59 | 60 | 1. Fork the `django-behaviors` repo on GitHub. 61 | 2. Clone your fork locally:: 62 | 63 | $ git clone git@github.com:your_name_here/django-behaviors.git 64 | 65 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 66 | 67 | $ mkvirtualenv django-behaviors 68 | $ cd django-behaviors/ 69 | $ python setup.py develop 70 | 71 | 4. Create a branch for local development:: 72 | 73 | $ git checkout -b name-of-your-bugfix-or-feature 74 | 75 | Now you can make your changes locally. 76 | 77 | 5. When you're done making changes, check that your changes pass flake8 and the 78 | tests, including testing other Python versions with tox:: 79 | 80 | $ flake8 behaviors tests 81 | $ python setup.py test 82 | $ tox 83 | 84 | To get flake8 and tox, just pip install them into your virtualenv. 85 | 86 | 6. Commit your changes and push your branch to GitHub:: 87 | 88 | $ git add . 89 | $ git commit -m "Your detailed description of your changes." 90 | $ git push origin name-of-your-bugfix-or-feature 91 | 92 | 7. Submit a pull request through the GitHub website. 93 | 94 | Pull Request Guidelines 95 | ----------------------- 96 | 97 | Before you submit a pull request, check that it meets these guidelines: 98 | 99 | 1. The pull request should include tests. 100 | 2. If the pull request adds functionality, the docs should be updated. Put 101 | your new functionality into a function with a docstring, and add the 102 | feature to the list in README.rst. 103 | 3. The pull request should work for Python 2.6, 2.7, and 3.3, and for PyPy. Check 104 | https://travis-ci.org/audiolion/django-behaviors/pull_requests 105 | and make sure that the tests pass for all supported Python versions. 106 | 107 | Tips 108 | ---- 109 | 110 | To run a subset of tests:: 111 | 112 | $ python -m unittest tests.test_behaviors 113 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | History 4 | ======= 5 | 6 | 0.5.1 (2020-09-19) 7 | ------------------ 8 | 9 | * Feature: ``Slugged`` behavior now supports non-unique slugs (thanks @vonmaster) 10 | 11 | 0.5.0 (2019-07-01) 12 | ------------------ 13 | 14 | * Drop Django 2.0 support and Python 3.4 support as they have reached EOL 15 | * Add Django 2.1, 2.2 and Python 3.7 support 16 | * Feature: Add db_index to ``Timestamped`` behavior's ``modified`` field (thanks @kazqvaizer) 17 | 18 | 0.4.1 (2018-05-17) 19 | ------------------ 20 | 21 | * Feature: Add ``StoreDeleted`` behavior to soft delete models (thanks @abekroenem) 22 | 23 | 0.4.0 (2018-01-16) 24 | ------------------ 25 | 26 | * Update setup.py classifiers 27 | 28 | 0.3.1 (2018-01-04) 29 | ------------------ 30 | 31 | * Add Django 2.0 and Python 3.6 Support 32 | 33 | 0.3.0 (2017-03-11) 34 | ------------------ 35 | 36 | * Add ``Slugged`` behavior adding a slug to models 37 | * Update documentation 38 | 39 | 0.2.0 (2017-02-14) 40 | ------------------ 41 | 42 | * Add ``Released`` behavior for a release date on models 43 | * Update documentation 44 | 45 | 0.1.7 (2017-02-14) 46 | ------------------ 47 | 48 | * Remove an unused import 49 | * Integrate with Lintly 50 | 51 | 0.1.6 (2017-02-14) 52 | ------------------ 53 | 54 | * Drop python3.3 support for Django 1.8 because 1.8 no longer supports it 55 | 56 | 0.1.5 (2017-02-14) 57 | ------------------ 58 | 59 | * Fix import error for py2.7 builds 60 | 61 | 0.1.4 (2017-02-14) 62 | ------------------ 63 | 64 | * Fix Syntax Error 65 | 66 | 0.1.3 (2017-02-14) 67 | ------------------ 68 | 69 | * Fixed Circular Import 70 | 71 | 0.1.2 (2017-02-13) 72 | ------------------ 73 | 74 | * Travis CI Fixes 75 | 76 | 0.1.1 (2017-02-13) 77 | ------------------ 78 | 79 | * First release on PyPI 80 | * Flake8 adherence fixes 81 | 82 | 0.1.0 (2017-02-13) 83 | ------------------ 84 | 85 | * First push of project 86 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2017, Ryan Castner 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | 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 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | recursive-include behaviors *.html *.png *.gif *js *.css *jpg *jpeg *svg *py 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | define BROWSER_PYSCRIPT 4 | import os, webbrowser, sys 5 | try: 6 | from urllib import pathname2url 7 | except: 8 | from urllib.request import pathname2url 9 | 10 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 11 | endef 12 | export BROWSER_PYSCRIPT 13 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 14 | 15 | help: 16 | @perl -nle'print $& if m{^[a-zA-Z_-]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-25s\033[0m %s\n", $$1, $$2}' 17 | 18 | clean: clean-build clean-pyc 19 | 20 | clean-build: ## remove build artifacts 21 | rm -fr build/ 22 | rm -fr dist/ 23 | rm -fr *.egg-info 24 | 25 | clean-pyc: ## remove Python file artifacts 26 | find . -name '*.pyc' -exec rm -f {} + 27 | find . -name '*.pyo' -exec rm -f {} + 28 | find . -name '*~' -exec rm -f {} + 29 | 30 | lint: ## check style with flake8 31 | flake8 behaviors tests 32 | 33 | test: ## run tests quickly with the default Python 34 | python runtests.py tests 35 | 36 | test-all: ## run tests on every Python version with tox 37 | tox 38 | 39 | coverage: ## check code coverage quickly with the default Python 40 | coverage run --source behaviors runtests.py tests 41 | coverage report -m 42 | coverage html 43 | open htmlcov/index.html 44 | 45 | docs: ## generate Sphinx HTML documentation, including API docs 46 | rm -f docs/django-behaviors.rst 47 | rm -f docs/modules.rst 48 | sphinx-apidoc -o docs/ behaviors 49 | $(MAKE) -C docs clean 50 | $(MAKE) -C docs html 51 | $(BROWSER) docs/_build/html/index.html 52 | 53 | release: clean ## package and upload a release 54 | python setup.py sdist upload 55 | python setup.py bdist_wheel upload 56 | 57 | sdist: clean ## package 58 | python setup.py sdist 59 | ls -l dist 60 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============================= 2 | Django Behaviors 3 | ============================= 4 | 5 | .. image:: https://badge.fury.io/py/django-behaviors.svg 6 | :target: https://badge.fury.io/py/django-behaviors 7 | 8 | .. image:: https://travis-ci.org/audiolion/django-behaviors.svg?branch=master 9 | :target: https://travis-ci.org/audiolion/django-behaviors 10 | 11 | .. image:: https://codecov.io/gh/audiolion/django-behaviors/branch/master/graph/badge.svg 12 | :target: https://codecov.io/gh/audiolion/django-behaviors 13 | 14 | 15 | Common behaviors for Django Models, e.g. Timestamps, Publishing, Authoring/Editing and more. 16 | 17 | Inspired by Kevin Stone's `Django Model Behaviors`_. 18 | 19 | Documentation 20 | ============= 21 | 22 | Quickstart 23 | ---------- 24 | 25 | Install Django Behaviors:: 26 | 27 | pip install django-behaviors 28 | # Or, if you are going to use the Slugged behaviour 29 | pip install django-behaviors[slugged] 30 | 31 | Add it to your `INSTALLED_APPS`: 32 | 33 | .. code-block:: python 34 | 35 | INSTALLED_APPS = ( 36 | ... 37 | 'behaviors.apps.BehaviorsConfig', 38 | ... 39 | ) 40 | 41 | Features 42 | -------- 43 | 44 | ``behaviors`` makes it easy to integrate common behaviors into your django models: 45 | 46 | - **Documented**, **tested**, and **easy to use** 47 | - **Timestamped** to add ``created`` and ``modified`` attributes to your models 48 | - **StoreDeleted** to add ``deleted`` attribute to your models, avoiding the record to be deleted and allow to restore it 49 | - **Authored** to add an ``author`` to your models 50 | - **Editored** to add an ``editor`` to your models 51 | - **Published** to add a ``publication_status`` (draft or published) to your models 52 | - **Released** to add a ``release_date`` to your models 53 | - **Slugged** to add a ``slug`` to your models (thanks @apirobot) (ensure you have `awesome-slugify` installed, see above) 54 | - Easily compose together multiple ``behaviors`` to get desired functionality (e.g. ``Authored`` and ``Editored``) 55 | - Custom ``QuerySet`` methods added as managers to your models to utilize the added fields 56 | - Easily compose together multiple ``queryset`` or ``manager`` to get desired functionality 57 | 58 | Table of Contents 59 | ----------------- 60 | 61 | - `Behaviors`_ 62 | - `Timestamped`_ 63 | - `StoreDeleted`_ 64 | - `Authored`_ 65 | - `Editored`_ 66 | - `Published`_ 67 | - `Released`_ 68 | - `Slugged`_ 69 | - `Mixing in with Custom Managers`_ 70 | - `Mixing Multiple Behaviors`_ 71 | 72 | Behaviors 73 | --------- 74 | 75 | Timestamped Behavior 76 | `````````````````````` 77 | 78 | The model adds a ``created`` and ``modified`` field to your model. 79 | 80 | .. code-block:: python 81 | 82 | class Timestamped(models.Model): 83 | """ 84 | An abstract behavior representing timestamping a model with``created`` and 85 | ``modified`` fields. 86 | """ 87 | created = models.DateTimeField(auto_now_add=True, db_index=True) 88 | modified = models.DateTimeField(null=True, blank=True, db_index=True) 89 | 90 | class Meta: 91 | abstract = True 92 | 93 | @property 94 | def changed(self): 95 | return True if self.modified else False 96 | 97 | def save(self, *args, **kwargs): 98 | if self.pk: 99 | self.modified = timezone.now() 100 | return super(Timestamped, self).save(*args, **kwargs) 101 | 102 | ``created`` is set on the next save and is set to the current UTC time. 103 | ``modified`` is set when the object already exists and is set to the current UTC time. 104 | 105 | ``MyModel.changed`` returns a boolean representing if the object has been updated after created (the ``modified`` field has been set). 106 | 107 | Here is an example of using the model, note you do not need to add ``models.Model`` because ``Timestamped`` already inherits it. 108 | 109 | .. code-block:: python 110 | 111 | # models.py 112 | from behaviors.behaviors import Authored, Editored, Timestamped, Published 113 | 114 | 115 | class MyModel(Timestamped): 116 | name = models.CharField(max_length=100) 117 | 118 | 119 | >>> m = MyModel.objects.create(name='dj') 120 | >>> m.created 121 | '2017-02-14 17:20:19.835517+00:00' 122 | >>> m.modified 123 | None 124 | >>> m.changed 125 | False 126 | >>> m.save() 127 | >>> m.modified 128 | '2017-02-14 17:20:46.836395+00:00' 129 | >>> m.changed 130 | True 131 | 132 | StoreDeleted Behavior 133 | `````````````````````` 134 | 135 | The model add a ``deleted`` field to your model and prevent record to be deleted and allow to restore it 136 | 137 | .. code-block:: python 138 | 139 | class StoreDeleted(models.Model): 140 | """ 141 | An abstract behavior representing store deleted a model with``deleted`` field, 142 | avoiding the model object to be deleted and allowing you to restore it. 143 | """ 144 | deleted = models.DateTimeField(null=True, blank=True) 145 | 146 | objects = StoreDeletedQuerySet.as_manager() 147 | 148 | class Meta: 149 | abstract = True 150 | 151 | @property 152 | def is_deleted(self): 153 | return self.deleted != None 154 | 155 | def delete(self, *args, **kwargs): 156 | if not self.pk: 157 | raise ObjectDoesNotExist('Object must be created before it can be deleted') 158 | self.deleted = timezone.now() 159 | return super(StoreDeleted, self).save(*args, **kwargs) 160 | 161 | def restore(self, *args, **kwargs): 162 | if not self.pk: 163 | raise ObjectDoesNotExist('Object must be created before it can be restored') 164 | self.deleted = None 165 | return super(StoreDeleted, self).save(*args, **kwargs) 166 | 167 | ``deleted`` is set when ``delete()`` method is called, with current UTC time. 168 | 169 | Here is an example of using the model, note you do not need to add ``models.Model`` because ``StoreDeleted`` already inherits it. 170 | 171 | .. code-block:: python 172 | 173 | # models.py 174 | from behaviors.behaviors import StoreDeleted 175 | 176 | 177 | class GreatModel(StoreDeleted): 178 | name = models.CharField(max_length=100) 179 | 180 | # Deleting model 181 | >>> gm = GreatModel.objects.create(name='Xtra') 182 | >>> gm.deleted 183 | None 184 | >>> gm.delete() 185 | >>> gm.deleted 186 | '2018-05-14 08:35:41.197661+00:00' 187 | 188 | # Restoring model 189 | >>> gm = GreatModel.objects.deleted(name='Xtra') 190 | >>> gm.deleted 191 | '2018-05-14 08:35:41.197661+00:00' 192 | >>> gm.restore() 193 | >>> gm.deleted 194 | None 195 | 196 | 197 | Authored Behavior 198 | `````````````````` 199 | 200 | The authored model adds an ``author`` attribute that is a foreign key to the ``settings.AUTH_USER_MODEL`` and adds manager methods through ``objects`` and ``authors``. 201 | 202 | .. code-block:: python 203 | 204 | class Authored(models.Model): 205 | """ 206 | An abstract behavior representing adding an author to a model based on the 207 | AUTH_USER_MODEL setting. 208 | """ 209 | author = models.ForeignKey( 210 | settings.AUTH_USER_MODEL, 211 | related_name="%(app_label)s_%(class)s_author") 212 | 213 | objects = AuthoredQuerySet.as_manager() 214 | authors = AuthoredQuerySet.as_manager() 215 | 216 | class Meta: 217 | abstract = True 218 | 219 | Here is an example of using the behavior and its ``authored_by()`` manager method: 220 | 221 | .. code-block:: python 222 | 223 | # models.py 224 | from behaviors.behaviors import Authored 225 | 226 | 227 | class MyModel(Authored): 228 | name = models.CharField(max_length=100) 229 | 230 | >>> m = MyModel.objects.create(author=User.objects.get(pk=2), name='tj') 231 | >>> m.author 232 | 233 | >>> queryset = MyModel.objects.authored_by(User.objects.get(pk=2)) 234 | >>> queryset.count() 235 | 1 236 | 237 | The author is a required field and must be provided on initial ``POST`` requests that create an object. 238 | 239 | A custom ``models.ModelForm`` is provided to automatically add the ``author`` 240 | on object creation: 241 | 242 | .. code-block:: python 243 | 244 | # forms.py 245 | from behaviors.forms import AuthoredModelForm 246 | from .models import MyModel 247 | 248 | 249 | class MyModelForm(AuthoredModelForm): 250 | class Meta: 251 | model = MyModel 252 | fields = ['name'] 253 | 254 | # views.py 255 | from django.views.generic.edit import CreateView 256 | from .forms import MyModelForm 257 | from .models import MyModel 258 | 259 | 260 | class MyModelCreateView(CreateView): 261 | model = MyModel 262 | form = MyModelForm 263 | 264 | # add request to form kwargs 265 | def get_form_kwargs(self): 266 | kwargs = super(MyModelCreateView, self).get_form_kwargs() 267 | kwargs['request'] = self.request 268 | return kwargs 269 | 270 | Now when the object is created the ``author`` will be added on the call 271 | to ``form.save()``. 272 | 273 | If you are using functional views or another view type you simply need 274 | to make sure you pass the request object along with the form. 275 | 276 | .. code-block:: python 277 | 278 | # views.py 279 | 280 | class MyModelView(View): 281 | template_name = "myapp/mymodel_form.html" 282 | 283 | def get(self, request, *args, **kwargs): 284 | context = { 285 | 'form': MyModelForm(), 286 | } 287 | return render(request, self.template_name, context=context) 288 | 289 | def post(self, request, *args, **kwargs): 290 | # pass in request object to the request keyword argument 291 | form = MyModelForm(self.request.POST, request=request) 292 | if form.is_valid(): 293 | form.save() 294 | return reverse(..) 295 | context = { 296 | 'form': form, 297 | } 298 | return render(request, self.template_name, context=context) 299 | 300 | If for some reason you don't want to mixin the ``AuthoredModelForm`` with your existing 301 | form you can just add the user like so: 302 | 303 | .. code-block:: python 304 | 305 | # ... 306 | if form.is_valid() 307 | obj = form.save(commit=False) 308 | obj.author = request.user 309 | obj.save() 310 | return reverse(..) 311 | # ... 312 | 313 | But it isn't recommended, the ``AuthoredModelForm`` is tested and doesn't reassign the 314 | author on every save. 315 | 316 | The ``related_name`` is set so that it will never create conflicts. Given the above example if you wanted to do a reverse foreign key lookup from the User model and ``MyModel`` was part of the ``blogs`` app it could be done like so: 317 | 318 | .. code-block:: python 319 | 320 | >>> user = User.objects.get(pk=2) 321 | >>> user.blogs_mymodel_author.all() 322 | [] 323 | 324 | That would give a list of all ``MyModel`` objects that ``user`` has ``authored``. 325 | 326 | Authored QuerySet 327 | .................. 328 | 329 | The ``Authored`` behavior attaches a custom model manager to the default ``objects`` 330 | and to the ``authors`` variables on the model it is mixed into. If you haven't overrode 331 | the ``objects`` variable with a custom manager then you can use that, otherwise the 332 | ``authors`` variable is a fallback. 333 | 334 | To get all ``MyModel`` instances authored by people whose name starts with 'Jo' 335 | 336 | .. code-block:: python 337 | 338 | # case is insensitive so 'joe' or 'Joe' matches 339 | >>> MyModel.objects.authored_by('Jo') 340 | [, , ...] 341 | 342 | # or use the authors manager variable 343 | >>> MyModel.authors.authored_by('Jo') 344 | [, , ...] 345 | 346 | See `Mixing in with Custom Managers`_ for details on how 347 | to mix in this behavior with a custom manager you have that overrides the ``objects`` 348 | default manager. 349 | 350 | 351 | Editored Behavior 352 | `````````````````` 353 | 354 | The editored model adds an ``editor`` attribute that is a foreign key to the ``settings.AUTH_USER_MODEL`` and adds manager methods through ``objects`` and ``editors`` variables. 355 | 356 | 357 | .. code-block:: python 358 | 359 | class Editored(models.Model): 360 | """ 361 | An abstract behavior representing adding an editor to a model based on the 362 | AUTH_USER_MODEL setting. 363 | """ 364 | editor = models.ForeignKey( 365 | settings.AUTH_USER_MODEL, 366 | related_name="%(app_label)s_%(class)s_editor", 367 | blank=True, null=True) 368 | 369 | objects = EditoredQuerySet.as_manager() 370 | editors = EditoredQuerySet.as_manager() 371 | 372 | class Meta: 373 | abstract = True 374 | 375 | The ``Editored`` model is similar to the ``Authored`` model except the foreign key is **not required**. Here is an example of its usage: 376 | 377 | .. code-block:: python 378 | 379 | # models.py 380 | from behaviors.behaviors import Editored 381 | 382 | 383 | class MyModel(Editored): 384 | name = models.CharField(max_length=100) 385 | 386 | >>> m = MyModel.objects.create(name='pj') 387 | >>> m.editor 388 | None 389 | >>> m.editor = User.objects.all()[0] 390 | >>> m.save() 391 | >>> queryset = MyModel.objects.edited_by(User.objects.all()[0]) 392 | >>> queryset.count() 393 | 1 394 | 395 | By default the ``editor`` is blank and null, if a ``request`` object is supplied to the form it will assign a new editor and erase the previous editor (or the null editor). 396 | 397 | Instead of using the ``AuthoredModelForm`` use the ``EditoredModelForm`` as a mixin to 398 | your form. 399 | 400 | .. code-block:: python 401 | 402 | # forms.py 403 | from behaviors.forms import EditoredModelForm 404 | from .models import MyModel 405 | 406 | 407 | class MyModelForm(EditoredModelForm): 408 | class Meta: 409 | model = MyModel 410 | fields = ['name'] 411 | 412 | # views.py 413 | from django.views.generic.edit import CreateView, UpdateView 414 | from .forms import MyModelForm 415 | from .models import MyModel 416 | 417 | 418 | MyModelRequestFormMixin(object): 419 | # add request to form kwargs 420 | def get_form_kwargs(self): 421 | kwargs = super(MyModelCreateView, self).get_form_kwargs() 422 | kwargs['request'] = self.request 423 | return kwargs 424 | 425 | 426 | class MyModelCreateView(MyModelRequestFormMixin, CreateView): 427 | model = MyModel 428 | form = MyModelForm 429 | 430 | 431 | class MyModelUpdateView(MyModelRequestFormMixin, UpdateView): 432 | model = MyModel 433 | form = MyModelForm 434 | 435 | 436 | Now when the object is created or updated the ``editor`` will be updated 437 | on the call to ``form.save()``. 438 | 439 | If you are using functional views or another view type you simply need 440 | to make sure you pass the request object along with the form. 441 | 442 | .. code-block:: python 443 | 444 | # views.py 445 | 446 | class MyModelView(View): 447 | template_name = "myapp/mymodel_form.html" 448 | 449 | def get(self, request, *args, **kwargs): 450 | context = { 451 | 'form': MyModelForm(), 452 | } 453 | return render(request, self.template_name, context=context) 454 | 455 | def post(self, request, *args, **kwargs): 456 | # pass in request object to the request keyword argument 457 | form = MyModelForm(self.request.POST, request=request) 458 | if form.is_valid(): 459 | form.save() 460 | return reverse(..) 461 | context = { 462 | 'form': form, 463 | } 464 | return render(request, self.template_name, context=context) 465 | 466 | If for some reason you don't want to mixin the ``EditoredModelForm`` with your existing 467 | form you can just add the user like so: 468 | 469 | .. code-block:: python 470 | 471 | ... 472 | if form.is_valid() 473 | obj = form.save(commit=False) 474 | obj.editor = request.user 475 | obj.save() 476 | return reverse(..) 477 | ... 478 | 479 | But it isn't recommended, the ``EditoredModelForm`` is tested and doesn't cause errors 480 | if request.user is invalid. 481 | 482 | The ``related_name`` is set so that it will never create conflicts. Given the above example if you wanted to do a reverse foreign key lookup from the User model and ``MyModel`` was part of the ``blogs`` app it could be done like so: 483 | 484 | .. code-block:: python 485 | 486 | >>> user = User.objects.get(pk=2) 487 | >>> user.blogs_mymodel_editor.all() 488 | [] 489 | 490 | That would give a list of all ``MyModel`` objects that ``user`` is an ``editor``. 491 | 492 | Editored QuerySet 493 | .................. 494 | 495 | The ``Editored`` behavior attaches a custom model manager to the default ``objects`` 496 | and to the ``editors`` variables on the model it is mixed into. If you haven't overrode 497 | the ``objects`` variable with a custom manager then you can use that, otherwise the 498 | ``editors`` variable is a fallback. 499 | 500 | To get all ``MyModel`` instances edited by people whose name starts with 'Jo' 501 | 502 | .. code-block:: python 503 | 504 | # case is insensitive so 'joe' or 'Joe' matches 505 | >>> MyModel.objects.edited_by('Jo') 506 | [, , ...] 507 | 508 | # or use the editors manager variable 509 | >>> MyModel.editors.edited_by('Jo') 510 | [, , ...] 511 | 512 | See `Mixing in with Custom Managers`_ for details on how 513 | to mix in this behavior with a custom manager you have that overrides the ``objects`` 514 | default manager. 515 | 516 | Published Behavior 517 | ```````````````````` 518 | 519 | The ``Published`` behavior adds a field ``publication_status`` to your model. The status 520 | has two states: 'Draft' or 'Published'. 521 | 522 | .. code-block:: python 523 | 524 | class Published(models.Model): 525 | """ 526 | An abstract behavior representing adding a publication status. A 527 | ``publication_status`` is set on the model with Draft or Published 528 | options. 529 | """ 530 | DRAFT = 'd' 531 | PUBLISHED = 'p' 532 | 533 | PUBLICATION_STATUS_CHOICES = ( 534 | (DRAFT, 'Draft'), 535 | (PUBLISHED, 'Published'), 536 | ) 537 | 538 | publication_status = models.CharField( 539 | "Publication Status", max_length=1, 540 | choices=PUBLICATION_STATUS_CHOICES, default=DRAFT) 541 | 542 | class Meta: 543 | abstract = True 544 | 545 | objects = PublishedQuerySet.as_manager() 546 | publications = PublishedQuerySet.as_manager() 547 | 548 | @property 549 | def draft(self): 550 | return self.publication_status == self.DRAFT 551 | 552 | @property 553 | def published(self): 554 | return self.publication_status == self.PUBLISHED 555 | 556 | The class offers two properties ``draft`` and ``published`` to know object state. The ``DRAFT`` and ``PUBLISHED`` class constants will be available from the class the ``Published`` behavior is mixed into. There is also a custom manager attached to ``objects`` and ``publications`` variables to get ``published()`` or ``draft()`` querysets. 557 | 558 | .. code-block:: python 559 | 560 | # models.py 561 | from behaviors.behaviors import Published 562 | 563 | 564 | class MyModel(Published): 565 | name = models.CharField(max_length=100) 566 | 567 | >>> m = MyModel.objects.create(name='cj') 568 | >>> m.publication_status 569 | u'd' 570 | >>> m.draft 571 | True 572 | >>> m.published 573 | False 574 | >>> m.get_publication_status_display() 575 | u'Draft' 576 | >>> MyModel.objects.published().count() 577 | 0 578 | >>> MyModel.objects.draft().count() 579 | 1 580 | >>> m.publication_status = MyModel.PUBLISHED 581 | >>> m.save() 582 | >>> m.publication_status 583 | u'p' 584 | >>> m.draft 585 | False 586 | >>> m.published 587 | True 588 | >>> m.get_publication_status_display() 589 | u'Published' 590 | >>> MyModel.objects.published().count() 591 | 1 592 | >>> MyModel.PUBLISHED 593 | u'p' 594 | >>> MyModel.PUBLISHED == m.publication_status 595 | True 596 | 597 | The ``publication_status`` field defaults to ``Published.DRAFT`` when you make new 598 | models unless you supply the ``Published.PUBLISHED`` attribute to the ``publication_status`` 599 | field. 600 | 601 | .. code-block:: python 602 | 603 | MyModel.objects.create(name='Jim-bob Cooter', publication_status=MyModel.PUBLISHED) 604 | 605 | Published QuerySet 606 | ................... 607 | 608 | The ``Published`` behavior attaches to the default ``objects`` variable and 609 | the ``publications`` variable as a fallback if ``objects`` is overrode. 610 | 611 | .. code-block:: python 612 | 613 | # returns all MyModel.PUBLISHED 614 | MyModel.objects.published() 615 | MyModel.publications.published() 616 | 617 | # returns all MyModel.DRAFT 618 | MyModel.objects.draft() 619 | MyModel.publications.draft() 620 | 621 | 622 | Released Behavior 623 | `````````````````` 624 | 625 | The ``Released`` behavior adds a field ``release_date`` to your model. The field 626 | is **not_required**. The release date can be set with the ``release_on(datetime)`` method. 627 | 628 | .. code-block:: python 629 | 630 | class Released(models.Model): 631 | """ 632 | An abstract behavior representing a release_date for a model to 633 | indicate when it should be listed publically. 634 | """ 635 | release_date = models.DateTimeField(null=True, blank=True) 636 | 637 | class Meta: 638 | abstract = True 639 | 640 | objects = ReleasedQuerySet.as_manager() 641 | releases = ReleasedQuerySet.as_manager() 642 | 643 | def release_on(self, date=None): 644 | if not date: 645 | date = timezone.now() 646 | self.release_date = date 647 | self.save() 648 | 649 | @property 650 | def released(self): 651 | return self.release_date and self.release_date < timezone.now() 652 | 653 | There is a ``released`` property added which determines if the object has been released. There is a custom manager attached to ``objects`` and ``releases`` variables to filter querysets on their release date. 654 | 655 | Here is an example of using the behavior: 656 | 657 | .. code-block:: python 658 | 659 | # models.py 660 | from django.utils import timezone 661 | from datetime import timedelta 662 | from behaviors.behaviors import Released 663 | 664 | 665 | class MyModel(Released): 666 | name = models.CharField(max_length=100) 667 | 668 | >>> m = MyModel.objects.create(name='rj') 669 | >>> m.release_date 670 | None 671 | >>> MyModel.objects.no_release_date().count() 672 | 1 673 | >>> m.release_on() 674 | >>> MyModel.objects.no_release_date().count() 675 | 0 676 | >>> MyModel.objects.released().count() 677 | 1 678 | >>> m.release_on(timezone.now() + timedelta(weeks=1)) 679 | >>> MyModel.objects.not_released().count() 680 | 1 681 | >>> MyModel.objects.released().count() 682 | 0 683 | 684 | The ``release_on`` method defaults to the current time so that the object is immediately 685 | released. You can also provide a date to the method to release on a certain date. ``release_on()`` just serves as a wrapper to setting and saving the date. 686 | 687 | You can always provide a ``release_date`` on object creation: 688 | 689 | .. code-block:: python 690 | 691 | MyModel.objects.create(name='Jim-bob Cooter', release_date=timezone.now()) 692 | 693 | 694 | Released QuerySet 695 | ................... 696 | 697 | The ``Released`` behavior attaches to the default ``objects`` variable and 698 | the ``releases`` variable as a fallback if ``objects`` is overrode. 699 | 700 | .. code-block:: python 701 | 702 | # returns all not released MyModel objects 703 | MyModel.objects.not_released() 704 | MyModel.releases.not_released() 705 | 706 | # returns all released MyModel objects 707 | MyModel.objects.released() 708 | MyModel.releases.released() 709 | 710 | # returns all null release date MyModel objects 711 | MyModel.objects.no_release_date() 712 | MyModel.releases.no_release_date() 713 | 714 | Slugged Behavior 715 | `````````````````` 716 | 717 | The ``Slugged`` behavior allows you to easily add a ``slug`` field to your model. The slug is generated on the first model creation or the next model save and is based on the ``slug_source`` attribute. 718 | 719 | **The** ``slug_source`` **property has no set default, you must add it to your model for the behavior to work.** 720 | 721 | .. code-block:: python 722 | 723 | class Slugged(models.Model): 724 | """ 725 | An abstract behavior representing adding a slug (by default, unique) to 726 | a model based on the slug_source property. 727 | """ 728 | slug = models.SlugField( 729 | max_length=255, 730 | unique=BehaviorsConfig.are_slug_unique(), 731 | blank=True) 732 | 733 | class Meta: 734 | abstract = True 735 | 736 | def save(self, *args, **kwargs): 737 | if not self.slug: 738 | self.slug = self.generate_unique_slug() \ 739 | if BehaviorsConfig.are_slug_unique() else self.get_slug() 740 | super(Slugged, self).save(*args, **kwargs) 741 | 742 | def get_slug(self): 743 | return slugify(getattr(self, "slug_source"), to_lower=True) 744 | 745 | def is_unique_slug(self, slug): 746 | qs = self.__class__.objects.filter(slug=slug) 747 | return not qs.exists() 748 | 749 | def generate_unique_slug(self): 750 | slug = self.get_slug() 751 | new_slug = slug 752 | 753 | iteration = 1 754 | while not self.is_unique_slug(new_slug): 755 | new_slug = "%s-%d" % (slug, iteration) 756 | iteration += 1 757 | 758 | return new_slug 759 | 760 | The ``slug`` uses the awesome-slugify package which will preserve unicode 761 | character slugs. By default, the ``slug`` must be unique and is guaranteed to 762 | be unique by the class appending a number ``-[0-9+]`` to the end of the slug 763 | if it is not unique. The ``unique`` field type `adds an index`_ to the ``slug`` field. 764 | 765 | Add the ``slug_source`` property to your class when mixing in the behavior. 766 | 767 | To allow non-unique slugs, add ``UNIQUE_SLUG_BEHAVIOR = False`` to your project's settings. 768 | 769 | .. code-block:: python 770 | 771 | # models.py 772 | from behaviors.behaviors import Slugged 773 | 774 | 775 | class MyModel(Slugged): 776 | name = models.CharField(max_length=100) 777 | 778 | # slug_source is required for the slug to be set 779 | @property 780 | def slug_source(self): 781 | return "prepended-text-for-fun-{}".format(self.name) 782 | 783 | # you can now use the slug for your get_absolute_url() method 784 | def get_absolute_url(self): 785 | return reverse('myapp:mymodel_detail', args=[self.slug]) 786 | 787 | >>> m = MyModel.objects.create(name='aj') 788 | >>> m.slug 789 | 'prepended-text-for-fun-aj' 790 | >>> m2 = MyModel.objects.create(name='aj') 791 | >>> m.slug 792 | 'prepended-text-for-fun-aj-1' 793 | >>> m.get_absolute_url() 794 | '/myapp/prepended-text-for-fun-aj/detail' 795 | 796 | Your ``slug_source`` attribute can be a mix of any of the model data available at the time of save, generally it is some ``name`` type of field. You could also hash the primary key and/or some other data as a ``slug_source``. 797 | By default, the ``slug`` is unique so it can be used to define the ``get_absolute_url()`` method on your model. 798 | 799 | Thanks to @apirobot for sending the PR for the ``Slugged`` behavior. 800 | 801 | Mixing in with Custom Managers 802 | ------------------------------ 803 | 804 | If you have a custom manager on your model already: 805 | 806 | .. code-block:: python 807 | 808 | # models.py 809 | from behaviors.behaviors import Authored, Editored, Published, Timestamped 810 | 811 | from django.db import models 812 | 813 | 814 | class MyModelCustomManager(models.Manager): 815 | 816 | def get_queryset(self): 817 | return super(MyModelCustomManager).get_queryset(self) 818 | 819 | def custom_manager_method(self): 820 | return self.get_queryset().filter(name='Jim-bob') 821 | 822 | class MyModel(Authored): 823 | name = models.CharField(max_length=100) 824 | 825 | # MyModel.objects.authored_by(..) won't work 826 | # MyModel.authors.authored_by(..) still will 827 | objects = MyModelCustomManager() 828 | 829 | Simply add ``AuthoredManager`` from ``behaviors.managers`` as a mixin to 830 | ``MyModelCustomManager`` so they can share the ``objects`` variable. 831 | 832 | .. code-block:: python 833 | 834 | # models.py 835 | from behaviors.behaviors import Authored, Editored, Published, Timestamped 836 | from behaviors.managers import AuthoredManager, EditoredManager, PublishedManager 837 | 838 | from django.db import models 839 | 840 | 841 | class MyModelCustomManager(AuthoredManager, models.Manager): 842 | 843 | def get_queryset(self): 844 | return super(MyModelCustomManager).get_queryset(self) 845 | 846 | def custom_manager_method(self): 847 | return self.get_queryset().filter(name='Jim-bob') 848 | 849 | class MyModel(Authored): 850 | name = models.CharField(max_length=100) 851 | 852 | # MyModel.objects.authored_by(..) now works 853 | objects = MyModelCustomManager() 854 | 855 | Similarly if you are using a custom QuerySet and calling its ``as_manager()`` 856 | method to attach it to ``objects`` you can import from ``behaviors.querysets`` 857 | and mix it in. 858 | 859 | .. code-block:: python 860 | 861 | # models.py 862 | from behaviors.behaviors import Authored, Editored, Published, Timestamped 863 | from behaviors.querysets import AuthoredQuerySet, EditoredQuerySet, PublishedQuerySet 864 | 865 | from django.db import models 866 | 867 | 868 | class MyModelCustomQuerySet(AuthoredQuerySet, models.QuerySet): 869 | 870 | def custom_queryset_method(self): 871 | return self.filter(name='Jim-bob') 872 | 873 | class MyModel(Authored): 874 | name = models.CharField(max_length=100) 875 | 876 | # MyModel.objects.authored_by(..) works 877 | objects = MyModelCustomQuerySet.as_manager() 878 | 879 | 880 | Mixing in Multiple Behaviors 881 | ---------------------------- 882 | 883 | Many times you will want multiple behaviors on a model. You can simply mix in 884 | multiple behaviors and, if you'd like to have all their custom ``QuerySet`` 885 | methods work on ``objects``, provide a custom manager with all the mixins. 886 | 887 | .. code-block:: python 888 | 889 | # models.py 890 | from behaviors.behaviors import Authored, Editored, Published, Timestamped 891 | from behaviors.querysets import AuthoredQuerySet, EditoredQuerySet, PublishedQuerySet 892 | 893 | from django.db import models 894 | 895 | 896 | class MyModelQuerySet(AuthoredQuerySet, EditoredQuerySet, PublishedQuerySet): 897 | pass 898 | 899 | class MyModel(Authored, Editored, Published, Timestamped): 900 | name = models.CharField(max_length=100) 901 | 902 | # MyModel.objects.authored_by(..) works 903 | # MyModel.objects.edited_by(..) works 904 | # MyModel.objects.published() works 905 | # MyModel.objects.draft() works 906 | objects = MyModelQuerySet.as_manager() 907 | 908 | # you can also chain queryset methods 909 | >>> u = User.objects.all()[0] 910 | >>> u2 = User.objects.all()[1] 911 | >>> m = MyModel.objects.create(author=u, editor=u2) 912 | >>> MyModel.objects.published().authored_by(u).count() 913 | 1 914 | 915 | 916 | Running Tests 917 | ------------- 918 | 919 | Does the code actually work? 920 | 921 | :: 922 | 923 | source /bin/activate 924 | (myenv) $ pip install tox 925 | (myenv) $ tox 926 | 927 | Credits 928 | ------- 929 | 930 | Tools used in rendering this package: 931 | 932 | * Cookiecutter_ 933 | * `cookiecutter-djangopackage`_ 934 | 935 | .. _Cookiecutter: https://github.com/audreyr/cookiecutter 936 | .. _`cookiecutter-djangopackage`: https://github.com/pydanny/cookiecutter-djangopackage 937 | 938 | .. _`Timestamped`: #timestamped-behavior 939 | .. _`StoreDeleted`: #storedeleted-behavior 940 | .. _`Authored`: #authored-behavior 941 | .. _`Editored`: #editored-behavior 942 | .. _`Published`: #published-behavior 943 | .. _`Released`: #released-behavior 944 | .. _`Slugged`: #slugged-behavior 945 | .. _`settings.AUTH_USER_MODEL`: https://docs.djangoproject.com/en/1.10/ref/settings/#std:setting-AUTH_USER_MODEL 946 | .. _`Mixing in with Custom Managers`: #mixing-in-with-custom-managers 947 | .. _`Mixing Multiple Behaviors`: #mixing-in-multiple-behaviors 948 | .. _`Django Model Behaviors`: http://blog.kevinastone.com/django-model-behaviors.html 949 | .. _`adds an index`: https://docs.djangoproject.com/en/dev/ref/models/fields/#unique 950 | -------------------------------------------------------------------------------- /behaviors/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.5.1' 2 | 3 | default_app_config = 'behaviors.apps.BehaviorsConfig' 4 | -------------------------------------------------------------------------------- /behaviors/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from django.apps import AppConfig 3 | from django.conf import settings 4 | 5 | 6 | class BehaviorsConfig(AppConfig): 7 | name = 'behaviors' 8 | 9 | @classmethod 10 | def are_slug_unique(cls): 11 | # By default, the Slugged behavior will generate unique slugs. 12 | # You can disable this constraint in your project's settings module. 13 | return getattr(settings, "UNIQUE_SLUG_BEHAVIOR", True) 14 | -------------------------------------------------------------------------------- /behaviors/behaviors.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.conf import settings 4 | from django.db import models 5 | from django.utils import timezone 6 | from django.core.exceptions import ObjectDoesNotExist 7 | 8 | try: 9 | from slugify import slugify 10 | except ImportError: 11 | from django.utils.text import slugify 12 | 13 | from .apps import BehaviorsConfig 14 | from .querysets import (AuthoredQuerySet, EditoredQuerySet, 15 | PublishedQuerySet, ReleasedQuerySet, 16 | StoreDeletedQuerySet) 17 | 18 | 19 | class Authored(models.Model): 20 | """ 21 | An abstract behavior representing adding an author to a model based on the 22 | AUTH_USER_MODEL setting. 23 | """ 24 | author = models.ForeignKey( 25 | settings.AUTH_USER_MODEL, 26 | on_delete=models.CASCADE, 27 | related_name="%(app_label)s_%(class)s_author") 28 | 29 | objects = AuthoredQuerySet.as_manager() 30 | authors = AuthoredQuerySet.as_manager() 31 | 32 | class Meta: 33 | abstract = True 34 | 35 | 36 | class Editored(models.Model): 37 | """ 38 | An abstract behavior representing adding an editor to a model based on the 39 | AUTH_USER_MODEL setting. 40 | """ 41 | editor = models.ForeignKey( 42 | settings.AUTH_USER_MODEL, 43 | on_delete=models.SET_NULL, 44 | related_name="%(app_label)s_%(class)s_editor", 45 | blank=True, null=True) 46 | 47 | objects = EditoredQuerySet.as_manager() 48 | editors = EditoredQuerySet.as_manager() 49 | 50 | class Meta: 51 | abstract = True 52 | 53 | 54 | class Published(models.Model): 55 | """ 56 | An abstract behavior representing adding a publication status. A 57 | ``publication_status`` is set on the model with Draft or Published 58 | options. 59 | """ 60 | DRAFT = 'd' 61 | PUBLISHED = 'p' 62 | 63 | PUBLICATION_STATUS_CHOICES = ( 64 | (DRAFT, 'Draft'), 65 | (PUBLISHED, 'Published'), 66 | ) 67 | 68 | publication_status = models.CharField( 69 | "Publication Status", max_length=1, 70 | choices=PUBLICATION_STATUS_CHOICES, default=DRAFT) 71 | 72 | class Meta: 73 | abstract = True 74 | 75 | objects = PublishedQuerySet.as_manager() 76 | publications = PublishedQuerySet.as_manager() 77 | 78 | @property 79 | def draft(self): 80 | return self.publication_status == self.DRAFT 81 | 82 | @property 83 | def published(self): 84 | return self.publication_status == self.PUBLISHED 85 | 86 | 87 | class Released(models.Model): 88 | """ 89 | An abstract behavior representing a release_date for a model to 90 | indicate when it should be listed publically. 91 | """ 92 | release_date = models.DateTimeField(null=True, blank=True) 93 | 94 | class Meta: 95 | abstract = True 96 | 97 | objects = ReleasedQuerySet.as_manager() 98 | releases = ReleasedQuerySet.as_manager() 99 | 100 | def release_on(self, date=None): 101 | if not date: 102 | date = timezone.now() 103 | self.release_date = date 104 | self.save() 105 | 106 | @property 107 | def released(self): 108 | return self.release_date and self.release_date < timezone.now() 109 | 110 | 111 | class Slugged(models.Model): 112 | """ 113 | An abstract behavior representing adding a slug (by default, unique) to 114 | a model based on the slug_source property. 115 | """ 116 | slug = models.SlugField( 117 | max_length=255, 118 | unique=BehaviorsConfig.are_slug_unique(), 119 | blank=True) 120 | 121 | class Meta: 122 | abstract = True 123 | 124 | def save(self, *args, **kwargs): 125 | if not self.slug: 126 | self.slug = self.generate_unique_slug() \ 127 | if BehaviorsConfig.are_slug_unique() else self.get_slug() 128 | super(Slugged, self).save(*args, **kwargs) 129 | 130 | def get_slug(self): 131 | try: 132 | return slugify(getattr(self, "slug_source"), to_lower=True) 133 | except TypeError: 134 | # django.utils.text.slugify fallback 135 | return slugify(getattr(self, "slug_source")) 136 | 137 | def is_unique_slug(self, slug): 138 | qs = self.__class__.objects.filter(slug=slug) 139 | return not qs.exists() 140 | 141 | def generate_unique_slug(self): 142 | slug = self.get_slug() 143 | new_slug = slug 144 | 145 | iteration = 1 146 | while not self.is_unique_slug(new_slug): 147 | new_slug = "%s-%d" % (slug, iteration) 148 | iteration += 1 149 | 150 | return new_slug 151 | 152 | 153 | class Timestamped(models.Model): 154 | """ 155 | An abstract behavior representing timestamping a model with``created`` and 156 | ``modified`` fields. 157 | """ 158 | created = models.DateTimeField(auto_now_add=True, db_index=True) 159 | modified = models.DateTimeField(null=True, blank=True, db_index=True) 160 | 161 | class Meta: 162 | abstract = True 163 | 164 | @property 165 | def changed(self): 166 | return True if self.modified else False 167 | 168 | def save(self, *args, **kwargs): 169 | if self.pk: 170 | self.modified = timezone.now() 171 | return super(Timestamped, self).save(*args, **kwargs) 172 | 173 | 174 | class StoreDeleted(models.Model): 175 | """ 176 | An abstract behavior representing store deleted a model with``deleted`` field, 177 | avoiding the model object to be deleted and allowing you to restore it. 178 | """ 179 | deleted = models.DateTimeField(null=True, blank=True) 180 | 181 | objects = StoreDeletedQuerySet.as_manager() 182 | 183 | class Meta: 184 | abstract = True 185 | 186 | @property 187 | def is_deleted(self): 188 | return self.deleted is not None 189 | 190 | def delete(self, *args, **kwargs): 191 | if not self.pk: 192 | raise ObjectDoesNotExist( 193 | 'Object must be created before it can be deleted') 194 | self.deleted = timezone.now() 195 | return super(StoreDeleted, self).save(*args, **kwargs) 196 | 197 | def restore(self, *args, **kwargs): 198 | if not self.pk: 199 | raise ObjectDoesNotExist( 200 | 'Object must be created before it can be restored') 201 | self.deleted = None 202 | return super(StoreDeleted, self).save(*args, **kwargs) 203 | -------------------------------------------------------------------------------- /behaviors/compat.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | 4 | def is_authenticated(user): 5 | """ 6 | Return whether or not a User is authenticated. 7 | Function provides compatibility following deprecation of method call to 8 | is_authenticated() in Django 2.0. 9 | """ 10 | 11 | if django.VERSION < (1, 10): 12 | return user.is_authenticated() 13 | else: 14 | return user.is_authenticated 15 | -------------------------------------------------------------------------------- /behaviors/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from .compat import is_authenticated 4 | 5 | 6 | class AuthoredModelForm(forms.ModelForm): 7 | class Meta: 8 | fields = [] 9 | 10 | def __init__(self, request=None, *args, **kwargs): 11 | self.request = request 12 | super(AuthoredModelForm, self).__init__(*args, **kwargs) 13 | 14 | def save(self, commit=True): 15 | obj = super(AuthoredModelForm, self).save(commit=False) 16 | 17 | if self.request is not None and is_authenticated(self.request.user): 18 | if not obj.pk: 19 | obj.author = self.request.user 20 | 21 | if commit: 22 | obj.save() 23 | return obj 24 | 25 | 26 | class EditoredModelForm(forms.ModelForm): 27 | class Meta: 28 | fields = [] 29 | 30 | def __init__(self, request=None, *args, **kwargs): 31 | self.request = request 32 | super(EditoredModelForm, self).__init__(*args, **kwargs) 33 | 34 | def save(self, commit=True): 35 | obj = super(EditoredModelForm, self).save(commit=False) 36 | 37 | if self.request is not None and is_authenticated(self.request.user): 38 | obj.editor = self.request.user 39 | 40 | if commit: 41 | obj.save() 42 | return obj 43 | -------------------------------------------------------------------------------- /behaviors/managers.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.db import models 4 | 5 | from .querysets import (AuthoredQuerySet, EditoredQuerySet, 6 | PublishedQuerySet, ReleasedQuerySet, 7 | StoreDeletedQuerySet) 8 | 9 | 10 | class AuthoredManager(models.Manager): 11 | 12 | def get_queryset(self): 13 | return AuthoredQuerySet(self.model, using=self._db) 14 | 15 | def authored_by(self, author): 16 | return self.get_queryset().authored_by(author) 17 | 18 | 19 | class EditoredManager(models.Manager): 20 | 21 | def get_queryset(self): 22 | return EditoredQuerySet(self.model, using=self._db) 23 | 24 | def edited_by(self, editor): 25 | return self.get_queryset().edited_by(editor) 26 | 27 | 28 | class PublishedManager(models.Manager): 29 | 30 | def get_queryset(self): 31 | return PublishedQuerySet(self.model, using=self._db) 32 | 33 | def draft(self): 34 | return self.get_queryset().draft() 35 | 36 | def published(self): 37 | return self.get_queryset().published() 38 | 39 | 40 | class ReleasedManager(models.Manager): 41 | 42 | def get_queryset(self): 43 | return ReleasedQuerySet(self.model, using=self._db) 44 | 45 | def released(self): 46 | return self.get_queryset().released() 47 | 48 | def not_released(self): 49 | return self.get_queryset().not_released() 50 | 51 | def no_release_date(self): 52 | return self.get_queryset().no_release_date() 53 | 54 | 55 | class StoreDeletedManager(models.Manager): 56 | 57 | def _get_base_queryset(self): 58 | return StoreDeletedQuerySet(self.model, using=self._db) 59 | 60 | def get_queryset(self): 61 | return self._get_base_queryset().get_queryset() 62 | 63 | def deleted(self): 64 | return self._get_base_queryset().deleted() 65 | 66 | def not_deleted(self): 67 | return self._get_base_queryset().not_deleted() 68 | 69 | def allow_deleted(self): 70 | return self._get_base_queryset().allow_deleted() 71 | -------------------------------------------------------------------------------- /behaviors/querysets.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.db import models 4 | from django.utils import timezone 5 | 6 | 7 | class AuthoredQuerySet(models.QuerySet): 8 | 9 | def authored_by(self, author): 10 | return self.filter(author__username__istartswith=author) 11 | 12 | 13 | class EditoredQuerySet(models.QuerySet): 14 | 15 | def edited_by(self, editor): 16 | return self.filter(editor__username__istartswith=editor) 17 | 18 | 19 | class PublishedQuerySet(models.QuerySet): 20 | 21 | def draft(self): 22 | return self.filter(publication_status='d') 23 | 24 | def published(self): 25 | return self.filter(publication_status='p') 26 | 27 | 28 | class ReleasedQuerySet(models.QuerySet): 29 | 30 | def released(self): 31 | return self.filter(~models.Q(release_date=None)).filter( 32 | release_date__lte=timezone.now()) 33 | 34 | def not_released(self): 35 | return self.filter(~models.Q(release_date=None)).filter( 36 | release_date__gt=timezone.now()) 37 | 38 | def no_release_date(self): 39 | return self.filter(models.Q(release_date=None)) 40 | 41 | 42 | class StoreDeletedQuerySet(models.QuerySet): 43 | 44 | def get_queryset(self): 45 | return self.not_deleted() 46 | 47 | def deleted(self): 48 | return self.exclude(deleted__isnull=True) 49 | 50 | def not_deleted(self): 51 | return self.exclude(deleted__isnull=False) 52 | 53 | def allow_deleted(self): 54 | return self 55 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/complexity.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/complexity.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/complexity" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/complexity" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # complexity documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Jul 9 22:26:36 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | cwd = os.getcwd() 22 | parent = os.path.dirname(cwd) 23 | sys.path.append(parent) 24 | 25 | import behaviors 26 | 27 | # -- General configuration ----------------------------------------------------- 28 | 29 | # If your documentation needs a minimal Sphinx version, state it here. 30 | #needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be extensions 33 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 34 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ['_templates'] 38 | 39 | # The suffix of source filenames. 40 | source_suffix = '.rst' 41 | 42 | # The encoding of source files. 43 | #source_encoding = 'utf-8-sig' 44 | 45 | # The master toctree document. 46 | master_doc = 'index' 47 | 48 | # General information about the project. 49 | project = u'Django Behaviors' 50 | copyright = u'2017, Ryan Castner' 51 | 52 | # The version info for the project you're documenting, acts as replacement for 53 | # |version| and |release|, also used in various other places throughout the 54 | # built documents. 55 | # 56 | # The short X.Y version. 57 | version = behaviors.__version__ 58 | # The full version, including alpha/beta/rc tags. 59 | release = behaviors.__version__ 60 | 61 | # The language for content autogenerated by Sphinx. Refer to documentation 62 | # for a list of supported languages. 63 | #language = None 64 | 65 | # There are two options for replacing |today|: either, you set today to some 66 | # non-false value, then it is used: 67 | #today = '' 68 | # Else, today_fmt is used as the format for a strftime call. 69 | #today_fmt = '%B %d, %Y' 70 | 71 | # List of patterns, relative to source directory, that match files and 72 | # directories to ignore when looking for source files. 73 | exclude_patterns = ['_build'] 74 | 75 | # The reST default role (used for this markup: `text`) to use for all documents. 76 | #default_role = None 77 | 78 | # If true, '()' will be appended to :func: etc. cross-reference text. 79 | #add_function_parentheses = True 80 | 81 | # If true, the current module name will be prepended to all description 82 | # unit titles (such as .. function::). 83 | #add_module_names = True 84 | 85 | # If true, sectionauthor and moduleauthor directives will be shown in the 86 | # output. They are ignored by default. 87 | #show_authors = False 88 | 89 | # The name of the Pygments (syntax highlighting) style to use. 90 | pygments_style = 'sphinx' 91 | 92 | # A list of ignored prefixes for module index sorting. 93 | #modindex_common_prefix = [] 94 | 95 | # If true, keep warnings as "system message" paragraphs in the built documents. 96 | #keep_warnings = False 97 | 98 | 99 | # -- Options for HTML output --------------------------------------------------- 100 | 101 | # The theme to use for HTML and HTML Help pages. See the documentation for 102 | # a list of builtin themes. 103 | html_theme = 'default' 104 | 105 | # Theme options are theme-specific and customize the look and feel of a theme 106 | # further. For a list of options available for each theme, see the 107 | # documentation. 108 | #html_theme_options = {} 109 | 110 | # Add any paths that contain custom themes here, relative to this directory. 111 | #html_theme_path = [] 112 | 113 | # The name for this set of Sphinx documents. If None, it defaults to 114 | # " v documentation". 115 | #html_title = None 116 | 117 | # A shorter title for the navigation bar. Default is the same as html_title. 118 | #html_short_title = None 119 | 120 | # The name of an image file (relative to this directory) to place at the top 121 | # of the sidebar. 122 | #html_logo = None 123 | 124 | # The name of an image file (within the static path) to use as favicon of the 125 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 126 | # pixels large. 127 | #html_favicon = None 128 | 129 | # Add any paths that contain custom static files (such as style sheets) here, 130 | # relative to this directory. They are copied after the builtin static files, 131 | # so a file named "default.css" will overwrite the builtin "default.css". 132 | html_static_path = ['_static'] 133 | 134 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 135 | # using the given strftime format. 136 | #html_last_updated_fmt = '%b %d, %Y' 137 | 138 | # If true, SmartyPants will be used to convert quotes and dashes to 139 | # typographically correct entities. 140 | #html_use_smartypants = True 141 | 142 | # Custom sidebar templates, maps document names to template names. 143 | #html_sidebars = {} 144 | 145 | # Additional templates that should be rendered to pages, maps page names to 146 | # template names. 147 | #html_additional_pages = {} 148 | 149 | # If false, no module index is generated. 150 | #html_domain_indices = True 151 | 152 | # If false, no index is generated. 153 | #html_use_index = True 154 | 155 | # If true, the index is split into individual pages for each letter. 156 | #html_split_index = False 157 | 158 | # If true, links to the reST sources are added to the pages. 159 | #html_show_sourcelink = True 160 | 161 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 162 | #html_show_sphinx = True 163 | 164 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 165 | #html_show_copyright = True 166 | 167 | # If true, an OpenSearch description file will be output, and all pages will 168 | # contain a tag referring to it. The value of this option must be the 169 | # base URL from which the finished HTML is served. 170 | #html_use_opensearch = '' 171 | 172 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 173 | #html_file_suffix = None 174 | 175 | # Output file base name for HTML help builder. 176 | htmlhelp_basename = 'django-behaviorsdoc' 177 | 178 | 179 | # -- Options for LaTeX output -------------------------------------------------- 180 | 181 | latex_elements = { 182 | # The paper size ('letterpaper' or 'a4paper'). 183 | #'papersize': 'letterpaper', 184 | 185 | # The font size ('10pt', '11pt' or '12pt'). 186 | #'pointsize': '10pt', 187 | 188 | # Additional stuff for the LaTeX preamble. 189 | #'preamble': '', 190 | } 191 | 192 | # Grouping the document tree into LaTeX files. List of tuples 193 | # (source start file, target name, title, author, documentclass [howto/manual]). 194 | latex_documents = [ 195 | ('index', 'django-behaviors.tex', u'Django Behaviors Documentation', 196 | u'Ryan Castner', 'manual'), 197 | ] 198 | 199 | # The name of an image file (relative to this directory) to place at the top of 200 | # the title page. 201 | #latex_logo = None 202 | 203 | # For "manual" documents, if this is true, then toplevel headings are parts, 204 | # not chapters. 205 | #latex_use_parts = False 206 | 207 | # If true, show page references after internal links. 208 | #latex_show_pagerefs = False 209 | 210 | # If true, show URL addresses after external links. 211 | #latex_show_urls = False 212 | 213 | # Documents to append as an appendix to all manuals. 214 | #latex_appendices = [] 215 | 216 | # If false, no module index is generated. 217 | #latex_domain_indices = True 218 | 219 | 220 | # -- Options for manual page output -------------------------------------------- 221 | 222 | # One entry per manual page. List of tuples 223 | # (source start file, name, description, authors, manual section). 224 | man_pages = [ 225 | ('index', 'django-behaviors', u'Django Behaviors Documentation', 226 | [u'Ryan Castner'], 1) 227 | ] 228 | 229 | # If true, show URL addresses after external links. 230 | #man_show_urls = False 231 | 232 | 233 | # -- Options for Texinfo output ------------------------------------------------ 234 | 235 | # Grouping the document tree into Texinfo files. List of tuples 236 | # (source start file, target name, title, author, 237 | # dir menu entry, description, category) 238 | texinfo_documents = [ 239 | ('index', 'django-behaviors', u'Django Behaviors Documentation', 240 | u'Ryan Castner', 'django-behaviors', 'One line description of project.', 241 | 'Miscellaneous'), 242 | ] 243 | 244 | # Documents to append as an appendix to all manuals. 245 | #texinfo_appendices = [] 246 | 247 | # If false, no module index is generated. 248 | #texinfo_domain_indices = True 249 | 250 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 251 | #texinfo_show_urls = 'footnote' 252 | 253 | # If true, do not generate a @detailmenu in the "Top" node's menu. 254 | #texinfo_no_detailmenu = False 255 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. complexity documentation master file, created by 2 | sphinx-quickstart on Tue Jul 9 22:26:36 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Django Behaviors's documentation! 7 | ================================================================= 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | readme 15 | installation 16 | usage 17 | contributing 18 | authors 19 | history 20 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | At the command line:: 6 | 7 | $ easy_install django-behaviors 8 | 9 | Or, if you have virtualenvwrapper installed:: 10 | 11 | $ mkvirtualenv django-behaviors 12 | $ pip install django-behaviors 13 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\complexity.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\complexity.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | To use Django Behaviors in a project, add it to your `INSTALLED_APPS`: 6 | 7 | .. code-block:: python 8 | 9 | INSTALLED_APPS = ( 10 | ... 11 | 'behaviors.apps.BehaviorsConfig', 12 | ... 13 | ) 14 | 15 | Add Django Behaviors's URL patterns: 16 | 17 | .. code-block:: python 18 | 19 | from behaviors import urls as behaviors_urls 20 | 21 | 22 | urlpatterns = [ 23 | ... 24 | url(r'^', include(behaviors_urls)), 25 | ... 26 | ] 27 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import unicode_literals, absolute_import 4 | 5 | import os 6 | import sys 7 | 8 | if __name__ == "__main__": 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | awesome-slugify==1.6.5 2 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | bumpversion==0.5.3 4 | wheel==0.29.0 5 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | coverage==4.3.4 2 | mock>=1.0.1 3 | flake8>=2.1.0 4 | tox>=1.7.0 5 | codecov>=2.0.0 6 | django-test-plus==1.0.21 7 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 3 | from __future__ import unicode_literals, absolute_import 4 | 5 | import os 6 | import sys 7 | 8 | import django 9 | from django.conf import settings 10 | from django.test.utils import get_runner 11 | 12 | 13 | def run_tests(*test_args): 14 | if not test_args: 15 | test_args = ['tests'] 16 | 17 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 18 | django.setup() 19 | TestRunner = get_runner(settings) 20 | test_runner = TestRunner() 21 | failures = test_runner.run_tests(test_args) 22 | sys.exit(bool(failures)) 23 | 24 | 25 | if __name__ == '__main__': 26 | run_tests(*sys.argv[1:]) 27 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.5.1 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | 8 | [bumpversion:file:behaviors/__init__.py] 9 | 10 | [wheel] 11 | universal = 1 12 | 13 | [flake8] 14 | ignore = D203 15 | exclude = 16 | behaviors/migrations, 17 | .git, 18 | .tox, 19 | docs/conf.py, 20 | build, 21 | dist 22 | max-line-length = 119 23 | 24 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import sys 5 | 6 | try: 7 | from setuptools import setup 8 | except ImportError: 9 | from distutils.core import setup 10 | 11 | 12 | version = '0.5.1' 13 | 14 | 15 | if sys.argv[-1] == 'publish': 16 | try: 17 | import wheel 18 | print("Wheel version: ", wheel.__version__) 19 | except ImportError: 20 | print('Wheel library missing. Please run "pip install wheel"') 21 | sys.exit() 22 | os.system('python setup.py sdist upload') 23 | os.system('python setup.py bdist_wheel upload') 24 | sys.exit() 25 | 26 | if sys.argv[-1] == 'tag': 27 | print("Tagging the version on git:") 28 | os.system("git tag -a %s -m 'version %s'" % (version, version)) 29 | os.system("git push --tags") 30 | sys.exit() 31 | 32 | readme = open('README.rst').read() 33 | history = open('HISTORY.rst').read().replace('.. :changelog:', '') 34 | 35 | setup( 36 | name='django-behaviors', 37 | version=version, 38 | description="""Common behaviors for Django Models, e.g. Timestamps, Publishing, Authoring/Editing and more.""", 39 | long_description=readme + '\n\n' + history, 40 | long_description_content_type='text/x-rst', 41 | author='Ryan Castner', 42 | author_email='castner.rr@gmail.com', 43 | url='https://github.com/audiolion/django-behaviors', 44 | packages=[ 45 | 'behaviors', 46 | ], 47 | include_package_data=True, 48 | extras={ 49 | "slugged": "awesome-slugify>=1.6.5", 50 | }, 51 | license="MIT", 52 | zip_safe=False, 53 | keywords='django-behaviors', 54 | classifiers=[ 55 | 'Development Status :: 4 - Beta', 56 | 'Framework :: Django', 57 | 'Framework :: Django :: 1.8', 58 | 'Framework :: Django :: 1.11', 59 | 'Framework :: Django :: 2.0', 60 | 'Intended Audience :: Developers', 61 | 'License :: OSI Approved :: BSD License', 62 | 'Natural Language :: English', 63 | 'Programming Language :: Python :: 2', 64 | 'Programming Language :: Python :: 2.7', 65 | 'Programming Language :: Python :: 3', 66 | 'Programming Language :: Python :: 3.4', 67 | 'Programming Language :: Python :: 3.5', 68 | 'Programming Language :: Python :: 3.6', 69 | ], 70 | ) 71 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/audiolion/django-behaviors/b41f8a2ec1f7040e9efa6746a8b87d9164085b5c/tests/__init__.py -------------------------------------------------------------------------------- /tests/forms.py: -------------------------------------------------------------------------------- 1 | from behaviors.forms import AuthoredModelForm, EditoredModelForm 2 | 3 | from .models import AuthoredMock, EditoredMock 4 | 5 | 6 | class AuthoredModelFormMock(AuthoredModelForm): 7 | class Meta: 8 | model = AuthoredMock 9 | fields = [] 10 | 11 | 12 | class EditoredModelFormMock(EditoredModelForm): 13 | class Meta: 14 | model = EditoredMock 15 | fields = [] 16 | -------------------------------------------------------------------------------- /tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2019-08-11 11:01 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='MixinObjectsQuerySet', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('publication_status', models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='d', max_length=1, verbose_name='Publication Status')), 22 | ], 23 | options={ 24 | 'abstract': False, 25 | }, 26 | ), 27 | migrations.CreateModel( 28 | name='OverrideObjectsQuerySet', 29 | fields=[ 30 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 31 | ('publication_status', models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='d', max_length=1, verbose_name='Publication Status')), 32 | ], 33 | options={ 34 | 'abstract': False, 35 | }, 36 | ), 37 | migrations.CreateModel( 38 | name='PublishedMock', 39 | fields=[ 40 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 41 | ('publication_status', models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='d', max_length=1, verbose_name='Publication Status')), 42 | ], 43 | options={ 44 | 'abstract': False, 45 | }, 46 | ), 47 | migrations.CreateModel( 48 | name='PublishedMockManager', 49 | fields=[ 50 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 51 | ('publication_status', models.CharField(choices=[('d', 'Draft'), ('p', 'Published')], default='d', max_length=1, verbose_name='Publication Status')), 52 | ], 53 | options={ 54 | 'abstract': False, 55 | }, 56 | ), 57 | migrations.CreateModel( 58 | name='ReleasedMock', 59 | fields=[ 60 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 61 | ('release_date', models.DateTimeField(blank=True, null=True)), 62 | ], 63 | options={ 64 | 'abstract': False, 65 | }, 66 | ), 67 | migrations.CreateModel( 68 | name='ReleasedMockManager', 69 | fields=[ 70 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 71 | ('release_date', models.DateTimeField(blank=True, null=True)), 72 | ], 73 | options={ 74 | 'abstract': False, 75 | }, 76 | ), 77 | migrations.CreateModel( 78 | name='SluggedMock', 79 | fields=[ 80 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 81 | ('slug', models.SlugField(blank=True, max_length=255, unique=True)), 82 | ('title', models.CharField(max_length=255)), 83 | ], 84 | options={ 85 | 'abstract': False, 86 | }, 87 | ), 88 | migrations.CreateModel( 89 | name='NonUniqueSluggedMock', 90 | fields=[ 91 | ('id', models.AutoField(auto_created=True, primary_key=True, 92 | serialize=False, verbose_name='ID')), 93 | ('slug', models.SlugField(blank=True, max_length=255, unique=False)), 94 | ('title', models.CharField(max_length=255)), 95 | ], 96 | options={ 97 | 'abstract': False, 98 | }, 99 | ), 100 | migrations.CreateModel( 101 | name='StoreDeletedMock', 102 | fields=[ 103 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 104 | ('deleted', models.DateTimeField(blank=True, null=True)), 105 | ], 106 | options={ 107 | 'abstract': False, 108 | }, 109 | ), 110 | migrations.CreateModel( 111 | name='TimestampedMock', 112 | fields=[ 113 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 114 | ('created', models.DateTimeField(auto_now_add=True, db_index=True)), 115 | ('modified', models.DateTimeField(blank=True, db_index=True, null=True)), 116 | ], 117 | options={ 118 | 'abstract': False, 119 | }, 120 | ), 121 | migrations.CreateModel( 122 | name='OverrideObjectsManager', 123 | fields=[ 124 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 125 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_overrideobjectsmanager_author', to=settings.AUTH_USER_MODEL)), 126 | ], 127 | options={ 128 | 'abstract': False, 129 | }, 130 | ), 131 | migrations.CreateModel( 132 | name='MixinObjectsManager', 133 | fields=[ 134 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 135 | ('editor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tests_mixinobjectsmanager_editor', to=settings.AUTH_USER_MODEL)), 136 | ], 137 | options={ 138 | 'abstract': False, 139 | }, 140 | ), 141 | migrations.CreateModel( 142 | name='EditoredMockManager', 143 | fields=[ 144 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 145 | ('editor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tests_editoredmockmanager_editor', to=settings.AUTH_USER_MODEL)), 146 | ], 147 | options={ 148 | 'abstract': False, 149 | }, 150 | ), 151 | migrations.CreateModel( 152 | name='EditoredMock', 153 | fields=[ 154 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 155 | ('editor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tests_editoredmock_editor', to=settings.AUTH_USER_MODEL)), 156 | ], 157 | options={ 158 | 'abstract': False, 159 | }, 160 | ), 161 | migrations.CreateModel( 162 | name='AuthoredMockManager', 163 | fields=[ 164 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 165 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_authoredmockmanager_author', to=settings.AUTH_USER_MODEL)), 166 | ], 167 | options={ 168 | 'abstract': False, 169 | }, 170 | ), 171 | migrations.CreateModel( 172 | name='AuthoredMock', 173 | fields=[ 174 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 175 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_authoredmock_author', to=settings.AUTH_USER_MODEL)), 176 | ], 177 | options={ 178 | 'abstract': False, 179 | }, 180 | ), 181 | ] 182 | -------------------------------------------------------------------------------- /tests/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/audiolion/django-behaviors/b41f8a2ec1f7040e9efa6746a8b87d9164085b5c/tests/migrations/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from behaviors.behaviors import (Authored, Editored, Published, Released, 4 | Slugged, Timestamped, StoreDeleted) 5 | from behaviors.managers import (AuthoredManager, EditoredManager, 6 | PublishedManager, ReleasedManager, 7 | StoreDeletedManager) 8 | from behaviors.querysets import PublishedQuerySet 9 | 10 | 11 | class AuthoredMock(Authored): 12 | pass 13 | 14 | 15 | class EditoredMock(Editored): 16 | pass 17 | 18 | 19 | class PublishedMock(Published): 20 | pass 21 | 22 | 23 | class SluggedMock(Slugged): 24 | title = models.CharField(max_length=255) 25 | 26 | @property 27 | def slug_source(self): 28 | return self.title 29 | 30 | 31 | class NonUniqueSluggedMock(Slugged): 32 | title = models.CharField(max_length=255) 33 | 34 | @property 35 | def slug_source(self): 36 | return self.title 37 | 38 | 39 | class TimestampedMock(Timestamped): 40 | pass 41 | 42 | 43 | class ReleasedMock(Released): 44 | pass 45 | 46 | 47 | class AuthoredMockManager(Authored): 48 | objects = AuthoredManager() 49 | 50 | 51 | class EditoredMockManager(Editored): 52 | objects = EditoredManager() 53 | 54 | 55 | class PublishedMockManager(Published): 56 | objects = PublishedManager() 57 | 58 | 59 | class ReleasedMockManager(Released): 60 | objects = ReleasedManager() 61 | 62 | 63 | class OverrideManager(models.Manager): 64 | 65 | def get_queryset(self): 66 | return super(OverrideManager, self).get_queryset() 67 | 68 | def custom_filter(self): 69 | return self.get_queryset().filter(author__username__istartswith='u') 70 | 71 | 72 | class OverrideObjectsManager(Authored): 73 | objects = OverrideManager() 74 | 75 | 76 | class MixinManager(EditoredManager, models.Manager): 77 | 78 | def get_queryset(self): 79 | return super(MixinManager, self).get_queryset() 80 | 81 | def custom_filter(self): 82 | return self.get_queryset().filter(editor__username__istartswith='u') 83 | 84 | 85 | class MixinObjectsManager(Editored): 86 | objects = MixinManager() 87 | 88 | 89 | class OverrideQuerySet(models.QuerySet): 90 | 91 | def custom_filter(self): 92 | return self.filter(publication_status=Published.DRAFT) 93 | 94 | 95 | class OverrideObjectsQuerySet(Published): 96 | objects = OverrideQuerySet.as_manager() 97 | 98 | 99 | class MixinQuerySet(PublishedQuerySet, models.QuerySet): 100 | 101 | def custom_filter(self): 102 | return self.filter(publication_status=Published.DRAFT) 103 | 104 | 105 | class MixinObjectsQuerySet(Published): 106 | objects = MixinQuerySet.as_manager() 107 | 108 | 109 | class StoreDeletedMock(StoreDeleted): 110 | objects = StoreDeletedManager() 111 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import unicode_literals, absolute_import 3 | 4 | import django 5 | 6 | DEBUG = True 7 | USE_TZ = True 8 | 9 | # SECURITY WARNING: keep the secret key used in production secret! 10 | SECRET_KEY = "ssssssssssssssssssssssssssssssssssssssssssssssssss" 11 | 12 | DATABASES = { 13 | "default": { 14 | "ENGINE": "django.db.backends.sqlite3", 15 | "NAME": ":memory:", 16 | }, 17 | } 18 | 19 | ROOT_URLCONF = "tests.urls" 20 | 21 | INSTALLED_APPS = [ 22 | "django.contrib.auth", 23 | "django.contrib.contenttypes", 24 | "django.contrib.sites", 25 | "django.contrib.sessions", 26 | "behaviors.apps.BehaviorsConfig", 27 | "tests", 28 | ] 29 | 30 | SITE_ID = 1 31 | 32 | if django.VERSION >= (1, 10): 33 | MIDDLEWARE = () 34 | else: 35 | MIDDLEWARE_CLASSES = () 36 | 37 | UNIQUE_SLUG_BEHAVIOR = True 38 | -------------------------------------------------------------------------------- /tests/templates/authoredmock_form.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 | 5 |
6 | {% csrf_token %} 7 | 8 | {{ form.as_table }} 9 |
10 | 11 |
12 | 13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /tests/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | {% block content %}{% endblock content %} 11 | 12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/test_behaviors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | test_django-behaviors 6 | ------------ 7 | 8 | Tests for `django-behaviors` behaviors module. 9 | """ 10 | from django.contrib.auth import get_user_model 11 | from django.test import override_settings 12 | from django.utils import timezone 13 | from django.core.exceptions import ObjectDoesNotExist 14 | 15 | from test_plus.test import TestCase 16 | 17 | from datetime import timedelta 18 | 19 | from .models import (AuthoredMock, EditoredMock, PublishedMock, 20 | ReleasedMock, SluggedMock, NonUniqueSluggedMock, 21 | TimestampedMock, StoreDeletedMock) 22 | 23 | 24 | class TestAuthored(TestCase): 25 | 26 | @classmethod 27 | def setUpTestData(cls): 28 | User = get_user_model() 29 | cls.author = User.objects.create( 30 | username='u1', email='u1@example.com', password='password') 31 | cls.mock = AuthoredMock.objects.create(author=cls.author) 32 | 33 | def setUp(self): 34 | self.author.refresh_from_db() 35 | self.mock.refresh_from_db() 36 | 37 | def test_author_field_label(self): 38 | field_label = self.mock._meta.get_field('author').verbose_name 39 | self.assertEqual(field_label, 'author') 40 | 41 | def test_author_exists(self): 42 | self.assertIsNotNone(self.mock.author) 43 | self.assertEqual(self.mock.author, self.author) 44 | 45 | def test_author_related_name(self): 46 | related_name = self.mock._meta.get_field('author').related_query_name() 47 | self.assertEqual(related_name, 'tests_authoredmock_author') 48 | 49 | 50 | class TestEditored(TestCase): 51 | 52 | @classmethod 53 | def setUpTestData(cls): 54 | User = get_user_model() 55 | cls.editor = User.objects.create( 56 | username='u1', email='u1@example.com', password='password') 57 | cls.mock = EditoredMock.objects.create() 58 | 59 | def setUp(self): 60 | self.editor.refresh_from_db() 61 | self.mock.refresh_from_db() 62 | 63 | def test_editor_field_label(self): 64 | field_label = self.mock._meta.get_field('editor').verbose_name 65 | self.assertEqual(field_label, 'editor') 66 | 67 | def test_editor_doesnt_exist(self): 68 | self.assertIsNone(self.mock.editor) 69 | 70 | def test_editor_exists(self): 71 | self.mock.editor = self.editor 72 | self.mock.save() 73 | self.assertIsNotNone(self.mock.editor) 74 | self.assertEqual(self.mock.editor, self.editor) 75 | 76 | def test_editor_related_name(self): 77 | related_name = self.mock._meta.get_field('editor').related_query_name() 78 | self.assertEqual(related_name, 'tests_editoredmock_editor') 79 | 80 | 81 | class TestPublished(TestCase): 82 | 83 | @classmethod 84 | def setUpTestData(cls): 85 | cls.mock = PublishedMock.objects.create() 86 | 87 | def setUp(self): 88 | self.mock.refresh_from_db() 89 | 90 | def test_draft_true_by_default(self): 91 | self.assertTrue(self.mock.draft) 92 | 93 | def test_published_property(self): 94 | self.mock.publication_status = PublishedMock.PUBLISHED 95 | self.mock.save() 96 | self.assertTrue(self.mock.published) 97 | 98 | 99 | class TestReleased(TestCase): 100 | 101 | @classmethod 102 | def setUpTestData(cls): 103 | cls.mock = ReleasedMock.objects.create() 104 | cls.mock2 = ReleasedMock.objects.create(release_date=timezone.now()) 105 | 106 | def setUp(self): 107 | self.mock.refresh_from_db() 108 | self.mock2.refresh_from_db() 109 | 110 | def test_nullable_release_date(self): 111 | self.assertIsNone(self.mock.release_date) 112 | 113 | def test_nulled_release_date_released_is_false(self): 114 | self.assertFalse(self.mock.released) 115 | 116 | def test_future_release_date_released_is_false(self): 117 | week_in_advance = timezone.now() + timedelta(weeks=1) 118 | self.mock.release_date = week_in_advance 119 | self.mock.save() 120 | self.assertFalse(self.mock.released) 121 | 122 | def test_past_release_date_released_is_true(self): 123 | self.mock.release_date = timezone.now() 124 | self.mock.save() 125 | self.assertTrue(self.mock.released) 126 | 127 | def test_release_on_no_date_provided(self): 128 | self.mock.release_on() 129 | self.assertTrue(self.mock.released) 130 | 131 | def test_release_on_future_date_provided(self): 132 | week_in_advance = timezone.now() + timedelta(weeks=1) 133 | self.mock.release_on(week_in_advance) 134 | self.assertFalse(self.mock.released) 135 | 136 | def test_release_on_past_date_provided(self): 137 | self.mock.release_on(timezone.now()) 138 | self.assertTrue(self.mock.released) 139 | 140 | 141 | class TestSlugged(TestCase): 142 | 143 | @classmethod 144 | def setUpTestData(cls): 145 | cls.mock = SluggedMock.objects.create(title="Slugged Title") 146 | cls.mock2 = SluggedMock.objects.create(title="Slugged TITLE") 147 | cls.mock3 = SluggedMock.objects.create(title="SLUGGED Title") 148 | 149 | def setUp(self): 150 | self.mock.refresh_from_db() 151 | self.mock2.refresh_from_db() 152 | self.mock3.refresh_from_db() 153 | 154 | def test_title_field_slugged(self): 155 | self.assertEqual(self.mock.slug, "slugged-title") 156 | 157 | def test_generate_unique_slug(self): 158 | self.assertEqual(self.mock.slug, "slugged-title") 159 | self.assertEqual(self.mock2.slug, "slugged-title-1") 160 | self.assertEqual(self.mock3.slug, "slugged-title-2") 161 | 162 | 163 | @override_settings(UNIQUE_SLUG_BEHAVIOR=False) 164 | class TestNonUniqueSlugged(TestCase): 165 | 166 | @classmethod 167 | def setUpTestData(cls): 168 | cls.mock = NonUniqueSluggedMock.objects.create(title="Slugged Title") 169 | cls.mock2 = NonUniqueSluggedMock.objects.create(title="Slugged TITLE") 170 | cls.mock3 = NonUniqueSluggedMock.objects.create(title="SLUGGED Title") 171 | 172 | def setUp(self): 173 | self.mock.refresh_from_db() 174 | self.mock2.refresh_from_db() 175 | self.mock3.refresh_from_db() 176 | 177 | def test_generate_non_unique_slug(self): 178 | self.assertEqual(self.mock.slug, "slugged-title") 179 | self.assertEqual(self.mock2.slug, "slugged-title") 180 | self.assertEqual(self.mock3.slug, "slugged-title") 181 | 182 | 183 | class TestTimestamped(TestCase): 184 | 185 | @classmethod 186 | def setUpTestData(cls): 187 | cls.mock = TimestampedMock.objects.create() 188 | 189 | def setUp(self): 190 | self.mock.refresh_from_db() 191 | 192 | def test_timestamp_changed_initially_false(self): 193 | self.assertFalse(self.mock.changed) 194 | 195 | def test_timestamp_changed_after_save(self): 196 | self.mock.save() 197 | self.assertTrue(self.mock.changed) 198 | 199 | 200 | class TestStoreDeleted(TestCase): 201 | 202 | @classmethod 203 | def setUpTestData(cls): 204 | cls.mock_to_delete = StoreDeletedMock.objects.create() 205 | cls.mock_to_restore = StoreDeletedMock.objects.create() 206 | 207 | def setUp(self): 208 | self.mock_to_delete.refresh_from_db() 209 | 210 | def test_delete_model(self): 211 | self.mock_to_delete.delete() 212 | self.assertIsNotNone(self.mock_to_delete.deleted) 213 | 214 | def test_restore_model(self): 215 | self.mock_to_restore.delete() 216 | self.mock_to_restore.restore() 217 | self.assertIsNone(self.mock_to_restore.deleted) 218 | 219 | def test_delete_not_created_object_raises_exception(self): 220 | mock = StoreDeletedMock() 221 | self.assertIsNone(mock.pk) 222 | with self.assertRaises(ObjectDoesNotExist) as raises_context: 223 | mock.delete() 224 | self.assertIsNotNone(raises_context) 225 | 226 | def test_restore_not_created_object_raises_exception(self): 227 | mock = StoreDeletedMock() 228 | self.assertIsNone(mock.pk) 229 | with self.assertRaises(ObjectDoesNotExist) as raises_context: 230 | mock.restore() 231 | self.assertIsNotNone(raises_context) 232 | 233 | def test_is_deleted_property_returns_true_when_delete_object(self): 234 | self.mock_to_delete.delete() 235 | self.assertTrue(self.mock_to_delete.is_deleted) 236 | 237 | def test_is_deleted_property_returns_false_when_restore_object(self): 238 | self.mock_to_restore.delete() 239 | self.mock_to_restore.restore() 240 | self.assertFalse(self.mock_to_restore.is_deleted) 241 | 242 | def test_is_deleted_property_returns_false_when_create_object(self): 243 | mock = StoreDeletedMock() 244 | mock.save() 245 | self.assertFalse(mock.is_deleted) 246 | -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | test_django-behaviors 6 | ------------ 7 | 8 | Tests for `django-behaviors` forms module. 9 | """ 10 | from django.contrib.auth import get_user_model 11 | 12 | from django.test import TransactionTestCase 13 | from django.test.client import RequestFactory 14 | 15 | 16 | from .forms import AuthoredModelFormMock, EditoredModelFormMock 17 | from .models import AuthoredMock, EditoredMock 18 | 19 | 20 | class TestAuthoredModelForm(TransactionTestCase): 21 | 22 | def setUp(self): 23 | User = get_user_model() 24 | self.author = User.objects.create( 25 | username='u1', email='u1@test.com', password='password') 26 | self.requests = RequestFactory() 27 | 28 | def test_author_added_to_form_on_save(self): 29 | self.assertEqual(AuthoredMock.objects.all().count(), 0) 30 | request = self.requests.get('/') 31 | request.user = self.author 32 | form = AuthoredModelFormMock(data={}, request=request) 33 | self.assertTrue(form.is_valid()) 34 | form.save() 35 | self.assertEqual(AuthoredMock.objects.all().count(), 1) 36 | obj = AuthoredMock.objects.get(id=1) 37 | self.assertIsNotNone(obj.author) 38 | self.assertEqual(obj.author, self.author) 39 | 40 | def test_integrity_error_when_no_author_and_no_request_provided(self): 41 | from django.db.utils import IntegrityError 42 | self.assertEqual(AuthoredMock.objects.all().count(), 0) 43 | form = AuthoredModelFormMock(data={}, request=None) 44 | self.assertTrue(form.is_valid()) 45 | with self.assertRaises(IntegrityError): 46 | form.save() 47 | 48 | def test_form_updates_without_request_passed(self): 49 | request = self.requests.get('/') 50 | request.user = self.author 51 | form = AuthoredModelFormMock(data={}, request=request) 52 | form.save() 53 | obj = AuthoredMock.objects.all()[0] 54 | form = AuthoredModelFormMock(instance=obj, data={}) 55 | form.save() 56 | obj.refresh_from_db() 57 | self.assertIsNotNone(obj.author) 58 | self.assertEqual(obj.author, self.author) 59 | 60 | def test_save_form_second_time_doesnt_change_author(self): 61 | User = get_user_model() 62 | not_author = User.objects.create( 63 | username='u2', email='u2@test.com', password='password') 64 | request = self.requests.get('/') 65 | request.user = self.author 66 | form = AuthoredModelFormMock(data={}, request=request) 67 | form.save() 68 | obj = AuthoredMock.objects.all()[0] 69 | request.user = not_author 70 | form = AuthoredModelFormMock(instance=obj, data={}, request=request) 71 | form.save() 72 | obj.refresh_from_db() 73 | self.assertEqual(obj.author, self.author) 74 | 75 | def test_commit_false_doesnt_save_and_assigns_author(self): 76 | request = self.requests.get('/') 77 | request.user = self.author 78 | form = AuthoredModelFormMock(data={}, request=request) 79 | if form.is_valid(): 80 | obj = form.save(commit=False) 81 | self.assertEqual(obj.author, self.author) 82 | self.assertEqual(AuthoredMock.objects.all().count(), 0) 83 | 84 | 85 | class TestEditoredModelForm(TransactionTestCase): 86 | 87 | def setUp(self): 88 | User = get_user_model() 89 | self.editor = User.objects.create( 90 | username='u1', email='u1@test.com', password='password') 91 | self.requests = RequestFactory() 92 | 93 | def test_editor_added_to_form_on_save(self): 94 | self.assertEqual(EditoredMock.objects.all().count(), 0) 95 | request = self.requests.get('/') 96 | request.user = self.editor 97 | form = EditoredModelFormMock(data={}, request=request) 98 | self.assertTrue(form.is_valid()) 99 | form.save() 100 | self.assertEqual(EditoredMock.objects.all().count(), 1) 101 | obj = EditoredMock.objects.get(id=1) 102 | self.assertIsNotNone(obj.editor) 103 | self.assertEqual(obj.editor, self.editor) 104 | 105 | def test_no_integrity_error_when_no_editor_provided(self): 106 | self.assertEqual(EditoredMock.objects.all().count(), 0) 107 | form = EditoredModelFormMock(data={}, request=None) 108 | self.assertTrue(form.is_valid()) 109 | form.save() 110 | obj = EditoredMock.objects.all()[0] 111 | self.assertIsNone(obj.editor) 112 | 113 | def test_form_updates_without_request_passed(self): 114 | request = self.requests.get('/') 115 | request.user = self.editor 116 | form = EditoredModelFormMock(data={}, request=request) 117 | form.save() 118 | obj = EditoredMock.objects.all()[0] 119 | form = EditoredModelFormMock(instance=obj, data={}) 120 | form.save() 121 | obj.refresh_from_db() 122 | self.assertIsNotNone(obj.editor) 123 | self.assertEqual(obj.editor, self.editor) 124 | 125 | def test_save_form_second_time_changes_editor(self): 126 | User = get_user_model() 127 | new_editor = User.objects.create( 128 | username='u2', email='u2@test.com', password='password') 129 | request = self.requests.get('/') 130 | request.user = self.editor 131 | form = EditoredModelFormMock(data={}, request=request) 132 | form.save() 133 | obj = EditoredMock.objects.all()[0] 134 | request.user = new_editor 135 | form = EditoredModelFormMock(instance=obj, data={}, request=request) 136 | form.save() 137 | obj.refresh_from_db() 138 | self.assertEqual(obj.editor, new_editor) 139 | 140 | def test_commit_false_doesnt_save_and_assigns_editor(self): 141 | request = self.requests.get('/') 142 | request.user = self.editor 143 | form = EditoredModelFormMock(data={}, request=request) 144 | if form.is_valid(): 145 | obj = form.save(commit=False) 146 | self.assertEqual(obj.editor, self.editor) 147 | self.assertEqual(EditoredMock.objects.all().count(), 0) 148 | -------------------------------------------------------------------------------- /tests/test_managers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | test_django-behaviors 6 | ------------ 7 | 8 | Tests for `django-behaviors` managers module. 9 | """ 10 | from django.contrib.auth import get_user_model 11 | from django.utils import timezone 12 | 13 | from test_plus.test import TestCase 14 | 15 | from datetime import timedelta 16 | 17 | from behaviors.querysets import (AuthoredQuerySet, EditoredQuerySet, 18 | PublishedQuerySet, ReleasedQuerySet) 19 | 20 | from .models import (AuthoredMockManager, EditoredMockManager, 21 | PublishedMockManager, ReleasedMockManager) 22 | 23 | 24 | class TestAuthoredMockManager(TestCase): 25 | 26 | @classmethod 27 | def setUpTestData(cls): 28 | User = get_user_model() 29 | cls.author = User.objects.create( 30 | username='u1', email='u1@test.com', password='password') 31 | cls.author2 = User.objects.create( 32 | username='u2', email='u2@test.com', password='password') 33 | for i in range(0, 10): 34 | if i == 9: 35 | AuthoredMockManager.objects.create(author=cls.author2) 36 | else: 37 | AuthoredMockManager.objects.create(author=cls.author) 38 | 39 | def setUp(self): 40 | self.author.refresh_from_db() 41 | self.author2.refresh_from_db() 42 | 43 | def test_manager_get_queryset_returns_authored_queryset(self): 44 | queryset = AuthoredMockManager.objects.get_queryset() 45 | self.assertTrue(type(queryset) is AuthoredQuerySet) 46 | 47 | def test_authored_by_manager_method(self): 48 | queryset = AuthoredMockManager.objects.authored_by('u') 49 | self.assertEquals(queryset.count(), 10) 50 | 51 | 52 | class TestEditoredMockManager(TestCase): 53 | 54 | @classmethod 55 | def setUpTestData(cls): 56 | User = get_user_model() 57 | cls.editor = User.objects.create( 58 | username='u1', email='u1@test.com', password='password') 59 | cls.editor2 = User.objects.create( 60 | username='u2', email='u2@test.com', password='password') 61 | for i in range(0, 10): 62 | if i == 9: 63 | EditoredMockManager.objects.create(editor=cls.editor2) 64 | else: 65 | EditoredMockManager.objects.create(editor=cls.editor) 66 | 67 | def setUp(self): 68 | self.editor.refresh_from_db() 69 | self.editor2.refresh_from_db() 70 | 71 | def test_manager_get_queryset_returns_edited_queryset(self): 72 | queryset = EditoredMockManager.objects.get_queryset() 73 | self.assertTrue(type(queryset) is EditoredQuerySet) 74 | 75 | def test_edited_by_manager_method(self): 76 | queryset = EditoredMockManager.objects.edited_by('u') 77 | self.assertEquals(queryset.count(), 10) 78 | 79 | 80 | class TestPublishedMockManager(TestCase): 81 | 82 | @classmethod 83 | def setUpTestData(cls): 84 | for i in range(0, 10): 85 | if i % 2 == 0: 86 | PublishedMockManager.objects.create() 87 | else: 88 | PublishedMockManager.objects.create( 89 | publication_status=PublishedMockManager.PUBLISHED) 90 | 91 | def test_manager_get_queryset_returns_published_queryset(self): 92 | queryset = PublishedMockManager.objects.get_queryset() 93 | self.assertTrue(type(queryset) is PublishedQuerySet) 94 | 95 | def test_draft_manager_method(self): 96 | queryset = PublishedMockManager.objects.draft() 97 | self.assertEquals(queryset.count(), 5) 98 | 99 | def test_published_manager_method(self): 100 | queryset = PublishedMockManager.objects.published() 101 | self.assertEquals(queryset.count(), 5) 102 | 103 | 104 | class TestReleasedMockManager(TestCase): 105 | 106 | @classmethod 107 | def setUpTestData(cls): 108 | cls.past_date = timezone.now() - timedelta(weeks=1) 109 | cls.future_date = timezone.now() + timedelta(weeks=1) 110 | for i in range(0, 10): 111 | if i % 2 == 0: 112 | ReleasedMockManager.objects.create(release_date=cls.past_date) 113 | elif i == 1: 114 | ReleasedMockManager.objects.create() 115 | else: 116 | ReleasedMockManager.objects.create(release_date=cls.future_date) 117 | 118 | def test_manager_get_queryset_returns_released_queryset(self): 119 | queryset = ReleasedMockManager.objects.get_queryset() 120 | self.assertTrue(type(queryset) is ReleasedQuerySet) 121 | 122 | def test_released_manager_method(self): 123 | queryset = ReleasedMockManager.objects.released() 124 | self.assertEquals(queryset.count(), 5) 125 | 126 | def test_not_released_manager_method(self): 127 | queryset = ReleasedMockManager.objects.not_released() 128 | self.assertEquals(queryset.count(), 4) 129 | 130 | def test_no_release_date_manager_method(self): 131 | queryset = ReleasedMockManager.objects.no_release_date() 132 | self.assertEquals(queryset.count(), 1) 133 | -------------------------------------------------------------------------------- /tests/test_mixins_overrides.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | test_django-behaviors 6 | ------------ 7 | 8 | Tests for `django-behaviors` mixins and managers expected behaviors 9 | for queryset and manager methods. 10 | """ 11 | from django.contrib.auth import get_user_model 12 | 13 | from test_plus.test import TestCase 14 | 15 | from .models import (OverrideObjectsManager, MixinObjectsManager, 16 | OverrideObjectsQuerySet, MixinObjectsQuerySet) 17 | 18 | 19 | class TestOverrideObjectsManager(TestCase): 20 | 21 | @classmethod 22 | def setUpTestData(cls): 23 | User = get_user_model() 24 | cls.author = User.objects.create( 25 | username='u1', email='u1@test.com', password='password') 26 | cls.author2 = User.objects.create( 27 | username='NotStartWithU', email='x@test.com', password='password') 28 | for i in range(0, 10): 29 | if i == 9: 30 | OverrideObjectsManager.objects.create(author=cls.author2) 31 | else: 32 | OverrideObjectsManager.objects.create(author=cls.author) 33 | 34 | def setUp(self): 35 | self.author.refresh_from_db() 36 | self.author2.refresh_from_db() 37 | 38 | def test_objects_authored_by_doesnt_work(self): 39 | with self.assertRaises(AttributeError): 40 | OverrideObjectsManager.objects.authored_by(self.author.username) 41 | 42 | def test_authors_authored_by_work(self): 43 | queryset = OverrideObjectsManager.authors.authored_by(self.author.username) 44 | self.assertEquals(queryset.count(), 9) 45 | 46 | def test_custom_filter_works(self): 47 | queryset = OverrideObjectsManager.objects.custom_filter() 48 | self.assertEquals(queryset.count(), 9) 49 | 50 | 51 | class TestMixinObjectsManager(TestCase): 52 | 53 | @classmethod 54 | def setUpTestData(cls): 55 | User = get_user_model() 56 | cls.editor = User.objects.create( 57 | username='u1', email='u1@test.com', password='password') 58 | cls.editor2 = User.objects.create( 59 | username='NotStartWithU', email='x@test.com', password='password') 60 | for i in range(0, 10): 61 | if i == 9: 62 | MixinObjectsManager.objects.create(editor=cls.editor2) 63 | else: 64 | MixinObjectsManager.objects.create(editor=cls.editor) 65 | 66 | def setUp(self): 67 | self.editor.refresh_from_db() 68 | self.editor2.refresh_from_db() 69 | 70 | def test_objects_edited_by_works(self): 71 | queryset = MixinObjectsManager.objects.edited_by(self.editor.username) 72 | self.assertEquals(queryset.count(), 9) 73 | 74 | def test_editors_edited_by_works(self): 75 | queryset = MixinObjectsManager.editors.edited_by(self.editor.username) 76 | self.assertEquals(queryset.count(), 9) 77 | 78 | def test_custom_filter_works(self): 79 | queryset = MixinObjectsManager.objects.custom_filter() 80 | self.assertEquals(queryset.count(), 9) 81 | 82 | 83 | class TestOverrideObjectsQuerySet(TestCase): 84 | 85 | @classmethod 86 | def setUpTestData(cls): 87 | for i in range(0, 10): 88 | if i % 2 == 0: 89 | OverrideObjectsQuerySet.objects.create( 90 | publication_status=OverrideObjectsQuerySet.DRAFT) 91 | else: 92 | OverrideObjectsQuerySet.objects.create( 93 | publication_status=OverrideObjectsQuerySet.PUBLISHED) 94 | 95 | def test_objects_draft_published_doesnt_work(self): 96 | with self.assertRaises(AttributeError): 97 | OverrideObjectsQuerySet.objects.draft() 98 | with self.assertRaises(AttributeError): 99 | OverrideObjectsQuerySet.objects.published() 100 | 101 | def test_publications_draft_published_by_work(self): 102 | queryset = OverrideObjectsQuerySet.publications.draft() 103 | self.assertEquals(queryset.count(), 5) 104 | queryset = OverrideObjectsQuerySet.publications.published() 105 | self.assertEquals(queryset.count(), 5) 106 | 107 | def test_custom_filter_works(self): 108 | queryset = OverrideObjectsQuerySet.objects.custom_filter() 109 | self.assertEquals(queryset.count(), 5) 110 | 111 | 112 | class TestMixinObjectsQuerySet(TestCase): 113 | 114 | @classmethod 115 | def setUpTestData(cls): 116 | for i in range(0, 10): 117 | if i % 2 == 0: 118 | MixinObjectsQuerySet.objects.create( 119 | publication_status=MixinObjectsQuerySet.DRAFT) 120 | else: 121 | MixinObjectsQuerySet.objects.create( 122 | publication_status=MixinObjectsQuerySet.PUBLISHED) 123 | 124 | def test_objects_draft_published_works(self): 125 | queryset = MixinObjectsQuerySet.objects.draft() 126 | self.assertEquals(queryset.count(), 5) 127 | queryset = MixinObjectsQuerySet.objects.published() 128 | self.assertEquals(queryset.count(), 5) 129 | 130 | def test_publications_draft_published_works(self): 131 | queryset = MixinObjectsQuerySet.publications.draft() 132 | self.assertEquals(queryset.count(), 5) 133 | queryset = MixinObjectsQuerySet.publications.published() 134 | self.assertEquals(queryset.count(), 5) 135 | 136 | def test_custom_filter_works(self): 137 | queryset = MixinObjectsQuerySet.objects.custom_filter() 138 | self.assertEquals(queryset.count(), 5) 139 | -------------------------------------------------------------------------------- /tests/test_querysets.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | test_django-behaviors 6 | ------------ 7 | 8 | Tests for `django-behaviors` querysets module. 9 | """ 10 | from django.contrib.auth import get_user_model 11 | from django.utils import timezone 12 | 13 | from test_plus.test import TestCase 14 | 15 | from datetime import timedelta 16 | 17 | from .models import AuthoredMock, EditoredMock, PublishedMock, ReleasedMock, StoreDeletedMock 18 | 19 | 20 | class TestAuthoredQuerySet(TestCase): 21 | 22 | @classmethod 23 | def setUpTestData(cls): 24 | User = get_user_model() 25 | cls.author = User.objects.create( 26 | username='u1', email='u1@test.com', password='password') 27 | cls.author2 = User.objects.create( 28 | username='u2', email='u2@test.com', password='password') 29 | for i in range(0, 10): 30 | if i == 9: 31 | AuthoredMock.objects.create(author=cls.author2) 32 | else: 33 | AuthoredMock.objects.create(author=cls.author) 34 | 35 | def setUp(self): 36 | self.author.refresh_from_db() 37 | self.author2.refresh_from_db() 38 | 39 | def test_objects_all_not_affected(self): 40 | queryset = AuthoredMock.objects.all() 41 | self.assertIsNotNone(queryset) 42 | self.assertEqual(AuthoredMock.objects.all().count(), 10) 43 | 44 | def test_objects_authored_by(self): 45 | queryset = AuthoredMock.objects.authored_by(self.author) 46 | self.assertIsNotNone(queryset) 47 | self.assertEqual(queryset.count(), 9) 48 | for record in queryset: 49 | self.assertEqual(record.author, self.author) 50 | queryset = AuthoredMock.objects.authored_by(self.author2) 51 | self.assertIsNotNone(queryset) 52 | self.assertEqual(queryset.count(), 1) 53 | for record in queryset: 54 | self.assertEqual(record.author, self.author2) 55 | 56 | def test_objects_authored_by_no_results(self): 57 | queryset = AuthoredMock.objects.authored_by('Nobody') 58 | self.assertEqual(queryset.count(), 0) 59 | 60 | def test_authors_all_not_affected(self): 61 | queryset = AuthoredMock.authors.all() 62 | self.assertIsNotNone(queryset) 63 | self.assertEqual(AuthoredMock.authors.all().count(), 10) 64 | 65 | def test_authors_authored_by(self): 66 | queryset = AuthoredMock.authors.authored_by(self.author) 67 | self.assertIsNotNone(queryset) 68 | self.assertEqual(queryset.count(), 9) 69 | for record in queryset: 70 | self.assertEqual(record.author, self.author) 71 | queryset = AuthoredMock.authors.authored_by(self.author2) 72 | self.assertIsNotNone(queryset) 73 | self.assertEqual(queryset.count(), 1) 74 | for record in queryset: 75 | self.assertEqual(record.author, self.author2) 76 | 77 | def test_authors_authored_by_no_results(self): 78 | queryset = AuthoredMock.authors.authored_by('Nobody') 79 | self.assertEqual(queryset.count(), 0) 80 | 81 | 82 | class TestEditoredQuerySet(TestCase): 83 | 84 | @classmethod 85 | def setUpTestData(cls): 86 | User = get_user_model() 87 | cls.editor = User.objects.create( 88 | username='u1', email='u1@test.com', password='password') 89 | cls.editor2 = User.objects.create( 90 | username='u2', email='u2@test.com', password='password') 91 | for i in range(0, 10): 92 | if i == 0: 93 | EditoredMock.objects.create() 94 | elif i == 9: 95 | EditoredMock.objects.create(editor=cls.editor2) 96 | else: 97 | EditoredMock.objects.create(editor=cls.editor) 98 | 99 | def setUp(self): 100 | self.editor.refresh_from_db() 101 | self.editor2.refresh_from_db() 102 | 103 | def test_empty_editor_returns_all(self): 104 | queryset = EditoredMock.objects.edited_by('') 105 | self.assertEqual(queryset.count(), 9) 106 | 107 | def test_objects_all_not_affected(self): 108 | queryset = EditoredMock.objects.all() 109 | self.assertIsNotNone(queryset) 110 | self.assertEqual(EditoredMock.objects.all().count(), 10) 111 | 112 | def test_objects_editored_by(self): 113 | queryset = EditoredMock.objects.edited_by(self.editor) 114 | self.assertIsNotNone(queryset) 115 | self.assertEqual(queryset.count(), 8) 116 | for record in queryset: 117 | self.assertEqual(record.editor, self.editor) 118 | queryset = EditoredMock.objects.edited_by(self.editor2) 119 | self.assertIsNotNone(queryset) 120 | self.assertEqual(queryset.count(), 1) 121 | for record in queryset: 122 | self.assertEqual(record.editor, self.editor2) 123 | 124 | def test_editors_editored_by_no_results(self): 125 | queryset = EditoredMock.editors.edited_by('Nobody') 126 | self.assertEqual(queryset.count(), 0) 127 | 128 | def test_editors_editored_by(self): 129 | queryset = EditoredMock.editors.edited_by(self.editor) 130 | self.assertIsNotNone(queryset) 131 | self.assertEqual(queryset.count(), 8) 132 | for record in queryset: 133 | self.assertEqual(record.editor, self.editor) 134 | queryset = EditoredMock.editors.edited_by(self.editor2) 135 | self.assertIsNotNone(queryset) 136 | self.assertEqual(queryset.count(), 1) 137 | for record in queryset: 138 | self.assertEqual(record.editor, self.editor2) 139 | 140 | 141 | class TestPublishedQuerySet(TestCase): 142 | 143 | @classmethod 144 | def setUpTestData(cls): 145 | for i in range(0, 10): 146 | if i % 2 == 0: 147 | PublishedMock.objects.create() 148 | else: 149 | PublishedMock.objects.create(publication_status=PublishedMock.PUBLISHED) 150 | 151 | def test_objects_all_not_affected(self): 152 | queryset = PublishedMock.objects.all() 153 | self.assertIsNotNone(queryset) 154 | self.assertEqual(queryset.count(), 10) 155 | 156 | def test_manager_draft(self): 157 | queryset = PublishedMock.objects.draft() 158 | self.assertIsNotNone(queryset) 159 | self.assertEqual(queryset.count(), 5) 160 | for record in queryset: 161 | self.assertEqual(record.publication_status, PublishedMock.DRAFT) 162 | 163 | def test_manager_published(self): 164 | queryset = PublishedMock.objects.published() 165 | self.assertIsNotNone(queryset) 166 | self.assertEqual(queryset.count(), 5) 167 | for record in queryset: 168 | self.assertEqual( 169 | record.publication_status, PublishedMock.PUBLISHED) 170 | 171 | 172 | class TestReleasedQuerySet(TestCase): 173 | 174 | @classmethod 175 | def setUpTestData(cls): 176 | cls.past_date = timezone.now() - timedelta(weeks=1) 177 | cls.future_date = timezone.now() + timedelta(weeks=1) 178 | for i in range(0, 10): 179 | if i % 2 == 0: 180 | ReleasedMock.objects.create(release_date=cls.past_date) 181 | elif i == 1: 182 | ReleasedMock.objects.create() 183 | else: 184 | ReleasedMock.objects.create(release_date=cls.future_date) 185 | 186 | def test_objects_all_not_affected(self): 187 | queryset = ReleasedMock.objects.all() 188 | self.assertIsNotNone(queryset) 189 | self.assertEqual(queryset.count(), 10) 190 | 191 | def test_manager_released(self): 192 | queryset = ReleasedMock.objects.released() 193 | self.assertIsNotNone(queryset) 194 | self.assertEqual(queryset.count(), 5) 195 | for record in queryset: 196 | self.assertEqual(record.release_date, self.past_date) 197 | 198 | def test_manager_not_released(self): 199 | queryset = ReleasedMock.objects.not_released() 200 | self.assertIsNotNone(queryset) 201 | self.assertEqual(queryset.count(), 4) 202 | for record in queryset: 203 | self.assertEqual(record.release_date, self.future_date) 204 | 205 | def test_manager_no_release_date(self): 206 | queryset = ReleasedMock.objects.no_release_date() 207 | self.assertIsNotNone(queryset) 208 | self.assertEqual(queryset.count(), 1) 209 | for record in queryset: 210 | self.assertIsNone(record.release_date) 211 | 212 | 213 | class TestStoreDeletedQuerySet(TestCase): 214 | 215 | @classmethod 216 | def setUpTestData(cls): 217 | for i in range(0, 10): 218 | if i % 2 == 0: 219 | StoreDeletedMock.objects.create() 220 | else: 221 | StoreDeletedMock.objects.create().delete() 222 | 223 | def test_object_all_returns_only_not_deleted_models(self): 224 | queryset = StoreDeletedMock.objects.all() 225 | self.assertIsNotNone(queryset) 226 | self.assertEqual(queryset.count(), 5) 227 | for record in queryset: 228 | self.assertIsNone(record.deleted) 229 | 230 | def test_deleted_returns_only_deleted_models(self): 231 | queryset = StoreDeletedMock.objects.deleted() 232 | self.assertIsNotNone(queryset) 233 | self.assertEqual(queryset.count(), 5) 234 | for record in queryset: 235 | self.assertIsNotNone(record.deleted) 236 | self.assertEqual(record.deleted.date(), timezone.now().date()) 237 | 238 | def test_not_deleted_returns_only_not_deleted_models(self): 239 | queryset = StoreDeletedMock.objects.not_deleted() 240 | self.assertIsNotNone(queryset) 241 | self.assertEqual(queryset.count(), 5) 242 | for record in queryset: 243 | self.assertIsNone(record.deleted) 244 | 245 | def test_allow_deleted_returns_all_models(self): 246 | queryset = StoreDeletedMock.objects.allow_deleted() 247 | self.assertIsNotNone(queryset) 248 | self.assertEqual(queryset.count(), 10) 249 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from __future__ import unicode_literals, absolute_import 3 | 4 | from django.conf.urls import url 5 | 6 | from . import views 7 | 8 | 9 | urlpatterns = [ 10 | url(r'authored$', views.AuthoredMockCreateView.as_view(), name='authored'), 11 | ] 12 | -------------------------------------------------------------------------------- /tests/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic.edit import CreateView, UpdateView 2 | 3 | from .models import AuthoredMock, EditoredMock 4 | from .forms import AuthoredModelFormMock, EditoredModelFormMock 5 | 6 | 7 | class FormKwargsRequestMixin(object): 8 | 9 | def get_form_kwargs(self): 10 | kwargs = super(EditoredMockUpdateView, self).get_form_kwargs(self) 11 | kwargs['request'] = self.request 12 | return kwargs 13 | 14 | 15 | class AuthoredMockCreateView(FormKwargsRequestMixin, CreateView): 16 | model = AuthoredMock 17 | form = AuthoredModelFormMock 18 | 19 | 20 | class EditoredMockUpdateView(FormKwargsRequestMixin, UpdateView): 21 | model = EditoredMock 22 | form = EditoredModelFormMock 23 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | {py27,py35,py36,py37}-django-18-{slugify,noslugify} 4 | {py27,py35,py36,py37}-django-111-{slugify,noslugify} 5 | {py35,py36,py37}-django-21-{slugify,noslugify} 6 | {py35,py36,py37}-django-22-{slugify,noslugify} 7 | 8 | [testenv] 9 | setenv = 10 | PYTHONPATH = {toxinidir}:{toxinidir}/behaviors 11 | commands = coverage run --source behaviors runtests.py 12 | deps = 13 | django-18: Django>=1.8,<1.9 14 | django-111: Django>=1.11,<2.0 15 | django-21: Django>=2.1,<2.2 16 | django-22: Django>=2.2,<2.3 17 | slugify: -r{toxinidir}/requirements.txt 18 | -r{toxinidir}/requirements_test.txt 19 | basepython = 20 | py37: python3.7 21 | py36: python3.6 22 | py35: python3.5 23 | py27: python2.7 24 | --------------------------------------------------------------------------------