Django Behaviors
Common behaviors for Django Models, e.g. Timestamps, Publishing, Authoring/Editing and more.
Inspired by Kevin Stone's Django Model Behaviors.
Documentation
Quickstart
Install Django Behaviors:
pip install django-behaviors # Or, if you are going to use the Slugged behaviour pip install django-behaviors[slugged]
Add it to your INSTALLED_APPS:
INSTALLED_APPS = (
...
'behaviors.apps.BehaviorsConfig',
...
)
Features
behaviors
makes it easy to integrate common behaviors into your django models:
- Documented, tested, and easy to use
- Timestamped to add
created
andmodified
attributes to your models - StoreDeleted to add
deleted
attribute to your models, avoiding the record to be deleted and allow to restore it - Authored to add an
author
to your models - Editored to add an
editor
to your models - Published to add a
publication_status
(draft or published) to your models - Released to add a
release_date
to your models - Slugged to add a
slug
to your models (thanks @apirobot) (ensure you have awesome-slugify installed, see above) - Easily compose together multiple
behaviors
to get desired functionality (e.g.Authored
andEditored
) - Custom
QuerySet
methods added as managers to your models to utilize the added fields - Easily compose together multiple
queryset
ormanager
to get desired functionality
Table of Contents
Behaviors
Timestamped Behavior
The model adds a created
and modified
field to your model.
class Timestamped(models.Model):
"""
An abstract behavior representing timestamping a model with``created`` and
``modified`` fields.
"""
created = models.DateTimeField(auto_now_add=True, db_index=True)
modified = models.DateTimeField(null=True, blank=True, db_index=True)
class Meta:
abstract = True
@property
def changed(self):
return True if self.modified else False
def save(self, *args, **kwargs):
if self.pk:
self.modified = timezone.now()
return super(Timestamped, self).save(*args, **kwargs)
created
is set on the next save and is set to the current UTC time.
modified
is set when the object already exists and is set to the current UTC time.
MyModel.changed
returns a boolean representing if the object has been updated after created (the modified
field has been set).
Here is an example of using the model, note you do not need to add models.Model
because Timestamped
already inherits it.
# models.py
from behaviors.behaviors import Authored, Editored, Timestamped, Published
class MyModel(Timestamped):
name = models.CharField(max_length=100)
>>> m = MyModel.objects.create(name='dj')
>>> m.created
'2017-02-14 17:20:19.835517+00:00'
>>> m.modified
None
>>> m.changed
False
>>> m.save()
>>> m.modified
'2017-02-14 17:20:46.836395+00:00'
>>> m.changed
True
StoreDeleted Behavior
The model add a deleted
field to your model and prevent record to be deleted and allow to restore it
class StoreDeleted(models.Model):
"""
An abstract behavior representing store deleted a model with``deleted`` field,
avoiding the model object to be deleted and allowing you to restore it.
"""
deleted = models.DateTimeField(null=True, blank=True)
objects = StoreDeletedQuerySet.as_manager()
class Meta:
abstract = True
@property
def is_deleted(self):
return self.deleted != None
def delete(self, *args, **kwargs):
if not self.pk:
raise ObjectDoesNotExist('Object must be created before it can be deleted')
self.deleted = timezone.now()
return super(StoreDeleted, self).save(*args, **kwargs)
def restore(self, *args, **kwargs):
if not self.pk:
raise ObjectDoesNotExist('Object must be created before it can be restored')
self.deleted = None
return super(StoreDeleted, self).save(*args, **kwargs)
deleted
is set when delete()
method is called, with current UTC time.
Here is an example of using the model, note you do not need to add models.Model
because StoreDeleted
already inherits it.
# models.py
from behaviors.behaviors import StoreDeleted
class GreatModel(StoreDeleted):
name = models.CharField(max_length=100)
# Deleting model
>>> gm = GreatModel.objects.create(name='Xtra')
>>> gm.deleted
None
>>> gm.delete()
>>> gm.deleted
'2018-05-14 08:35:41.197661+00:00'
# Restoring model
>>> gm = GreatModel.objects.deleted(name='Xtra')
>>> gm.deleted
'2018-05-14 08:35:41.197661+00:00'
>>> gm.restore()
>>> gm.deleted
None
Authored Behavior
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
.
class Authored(models.Model):
"""
An abstract behavior representing adding an author to a model based on the
AUTH_USER_MODEL setting.
"""
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
related_name="%(app_label)s_%(class)s_author")
objects = AuthoredQuerySet.as_manager()
authors = AuthoredQuerySet.as_manager()
class Meta:
abstract = True
Here is an example of using the behavior and its authored_by()
manager method:
# models.py
from behaviors.behaviors import Authored
class MyModel(Authored):
name = models.CharField(max_length=100)
>>> m = MyModel.objects.create(author=User.objects.get(pk=2), name='tj')
>>> m.author
<User: ...>
>>> queryset = MyModel.objects.authored_by(User.objects.get(pk=2))
>>> queryset.count()
1
The author is a required field and must be provided on initial POST
requests that create an object.
A custom models.ModelForm
is provided to automatically add the author
on object creation:
# forms.py
from behaviors.forms import AuthoredModelForm
from .models import MyModel
class MyModelForm(AuthoredModelForm):
class Meta:
model = MyModel
fields = ['name']
# views.py
from django.views.generic.edit import CreateView
from .forms import MyModelForm
from .models import MyModel
class MyModelCreateView(CreateView):
model = MyModel
form = MyModelForm
# add request to form kwargs
def get_form_kwargs(self):
kwargs = super(MyModelCreateView, self).get_form_kwargs()
kwargs['request'] = self.request
return kwargs
Now when the object is created the author
will be added on the call
to form.save()
.
If you are using functional views or another view type you simply need to make sure you pass the request object along with the form.
# views.py
class MyModelView(View):
template_name = "myapp/mymodel_form.html"
def get(self, request, *args, **kwargs):
context = {
'form': MyModelForm(),
}
return render(request, self.template_name, context=context)
def post(self, request, *args, **kwargs):
# pass in request object to the request keyword argument
form = MyModelForm(self.request.POST, request=request)
if form.is_valid():
form.save()
return reverse(..)
context = {
'form': form,
}
return render(request, self.template_name, context=context)
If for some reason you don't want to mixin the AuthoredModelForm
with your existing
form you can just add the user like so:
# ...
if form.is_valid()
obj = form.save(commit=False)
obj.author = request.user
obj.save()
return reverse(..)
# ...
But it isn't recommended, the AuthoredModelForm
is tested and doesn't reassign the
author on every save.
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:
>>> user = User.objects.get(pk=2)
>>> user.blogs_mymodel_author.all()
[<MyModel: ...>]
That would give a list of all MyModel
objects that user
has authored
.
Authored QuerySet
The Authored
behavior attaches a custom model manager to the default objects
and to the authors
variables on the model it is mixed into. If you haven't overrode
the objects
variable with a custom manager then you can use that, otherwise the
authors
variable is a fallback.
To get all MyModel
instances authored by people whose name starts with 'Jo'
# case is insensitive so 'joe' or 'Joe' matches
>>> MyModel.objects.authored_by('Jo')
[<MyModel: ...>, <MyModel: ...>, ...]
# or use the authors manager variable
>>> MyModel.authors.authored_by('Jo')
[<MyModel: ...>, <MyModel: ...>, ...]
See Mixing in with Custom Managers for details on how
to mix in this behavior with a custom manager you have that overrides the objects
default manager.
Editored Behavior
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.
class Editored(models.Model):
"""
An abstract behavior representing adding an editor to a model based on the
AUTH_USER_MODEL setting.
"""
editor = models.ForeignKey(
settings.AUTH_USER_MODEL,
related_name="%(app_label)s_%(class)s_editor",
blank=True, null=True)
objects = EditoredQuerySet.as_manager()
editors = EditoredQuerySet.as_manager()
class Meta:
abstract = True
The Editored
model is similar to the Authored
model except the foreign key is not required. Here is an example of its usage:
# models.py
from behaviors.behaviors import Editored
class MyModel(Editored):
name = models.CharField(max_length=100)
>>> m = MyModel.objects.create(name='pj')
>>> m.editor
None
>>> m.editor = User.objects.all()[0]
>>> m.save()
>>> queryset = MyModel.objects.edited_by(User.objects.all()[0])
>>> queryset.count()
1
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).
Instead of using the AuthoredModelForm
use the EditoredModelForm
as a mixin to
your form.
# forms.py
from behaviors.forms import EditoredModelForm
from .models import MyModel
class MyModelForm(EditoredModelForm):
class Meta:
model = MyModel
fields = ['name']
# views.py
from django.views.generic.edit import CreateView, UpdateView
from .forms import MyModelForm
from .models import MyModel
MyModelRequestFormMixin(object):
# add request to form kwargs
def get_form_kwargs(self):
kwargs = super(MyModelCreateView, self).get_form_kwargs()
kwargs['request'] = self.request
return kwargs
class MyModelCreateView(MyModelRequestFormMixin, CreateView):
model = MyModel
form = MyModelForm
class MyModelUpdateView(MyModelRequestFormMixin, UpdateView):
model = MyModel
form = MyModelForm
Now when the object is created or updated the editor
will be updated
on the call to form.save()
.
If you are using functional views or another view type you simply need to make sure you pass the request object along with the form.
# views.py
class MyModelView(View):
template_name = "myapp/mymodel_form.html"
def get(self, request, *args, **kwargs):
context = {
'form': MyModelForm(),
}
return render(request, self.template_name, context=context)
def post(self, request, *args, **kwargs):
# pass in request object to the request keyword argument
form = MyModelForm(self.request.POST, request=request)
if form.is_valid():
form.save()
return reverse(..)
context = {
'form': form,
}
return render(request, self.template_name, context=context)
If for some reason you don't want to mixin the EditoredModelForm
with your existing
form you can just add the user like so:
...
if form.is_valid()
obj = form.save(commit=False)
obj.editor = request.user
obj.save()
return reverse(..)
...
But it isn't recommended, the EditoredModelForm
is tested and doesn't cause errors
if request.user is invalid.
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:
>>> user = User.objects.get(pk=2)
>>> user.blogs_mymodel_editor.all()
[<MyModel: ...>]
That would give a list of all MyModel
objects that user
is an editor
.
Editored QuerySet
The Editored
behavior attaches a custom model manager to the default objects
and to the editors
variables on the model it is mixed into. If you haven't overrode
the objects
variable with a custom manager then you can use that, otherwise the
editors
variable is a fallback.
To get all MyModel
instances edited by people whose name starts with 'Jo'
# case is insensitive so 'joe' or 'Joe' matches
>>> MyModel.objects.edited_by('Jo')
[<MyModel: ...>, <MyModel: ...>, ...]
# or use the editors manager variable
>>> MyModel.editors.edited_by('Jo')
[<MyModel: ...>, <MyModel: ...>, ...]
See Mixing in with Custom Managers for details on how
to mix in this behavior with a custom manager you have that overrides the objects
default manager.
Published Behavior
The Published
behavior adds a field publication_status
to your model. The status
has two states: 'Draft' or 'Published'.
class Published(models.Model):
"""
An abstract behavior representing adding a publication status. A
``publication_status`` is set on the model with Draft or Published
options.
"""
DRAFT = 'd'
PUBLISHED = 'p'
PUBLICATION_STATUS_CHOICES = (
(DRAFT, 'Draft'),
(PUBLISHED, 'Published'),
)
publication_status = models.CharField(
"Publication Status", max_length=1,
choices=PUBLICATION_STATUS_CHOICES, default=DRAFT)
class Meta:
abstract = True
objects = PublishedQuerySet.as_manager()
publications = PublishedQuerySet.as_manager()
@property
def draft(self):
return self.publication_status == self.DRAFT
@property
def published(self):
return self.publication_status == self.PUBLISHED
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.
# models.py
from behaviors.behaviors import Published
class MyModel(Published):
name = models.CharField(max_length=100)
>>> m = MyModel.objects.create(name='cj')
>>> m.publication_status
u'd'
>>> m.draft
True
>>> m.published
False
>>> m.get_publication_status_display()
u'Draft'
>>> MyModel.objects.published().count()
0
>>> MyModel.objects.draft().count()
1
>>> m.publication_status = MyModel.PUBLISHED
>>> m.save()
>>> m.publication_status
u'p'
>>> m.draft
False
>>> m.published
True
>>> m.get_publication_status_display()
u'Published'
>>> MyModel.objects.published().count()
1
>>> MyModel.PUBLISHED
u'p'
>>> MyModel.PUBLISHED == m.publication_status
True
The publication_status
field defaults to Published.DRAFT
when you make new
models unless you supply the Published.PUBLISHED
attribute to the publication_status
field.
MyModel.objects.create(name='Jim-bob Cooter', publication_status=MyModel.PUBLISHED)
Published QuerySet
The Published
behavior attaches to the default objects
variable and
the publications
variable as a fallback if objects
is overrode.
# returns all MyModel.PUBLISHED
MyModel.objects.published()
MyModel.publications.published()
# returns all MyModel.DRAFT
MyModel.objects.draft()
MyModel.publications.draft()
Released Behavior
The Released
behavior adds a field release_date
to your model. The field
is not_required. The release date can be set with the release_on(datetime)
method.
class Released(models.Model):
"""
An abstract behavior representing a release_date for a model to
indicate when it should be listed publically.
"""
release_date = models.DateTimeField(null=True, blank=True)
class Meta:
abstract = True
objects = ReleasedQuerySet.as_manager()
releases = ReleasedQuerySet.as_manager()
def release_on(self, date=None):
if not date:
date = timezone.now()
self.release_date = date
self.save()
@property
def released(self):
return self.release_date and self.release_date < timezone.now()
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.
Here is an example of using the behavior:
# models.py
from django.utils import timezone
from datetime import timedelta
from behaviors.behaviors import Released
class MyModel(Released):
name = models.CharField(max_length=100)
>>> m = MyModel.objects.create(name='rj')
>>> m.release_date
None
>>> MyModel.objects.no_release_date().count()
1
>>> m.release_on()
>>> MyModel.objects.no_release_date().count()
0
>>> MyModel.objects.released().count()
1
>>> m.release_on(timezone.now() + timedelta(weeks=1))
>>> MyModel.objects.not_released().count()
1
>>> MyModel.objects.released().count()
0
The release_on
method defaults to the current time so that the object is immediately
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.
You can always provide a release_date
on object creation:
MyModel.objects.create(name='Jim-bob Cooter', release_date=timezone.now())
Released QuerySet
The Released
behavior attaches to the default objects
variable and
the releases
variable as a fallback if objects
is overrode.
# returns all not released MyModel objects
MyModel.objects.not_released()
MyModel.releases.not_released()
# returns all released MyModel objects
MyModel.objects.released()
MyModel.releases.released()
# returns all null release date MyModel objects
MyModel.objects.no_release_date()
MyModel.releases.no_release_date()
Slugged Behavior
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.
The slug_source
property has no set default, you must add it to your model for the behavior to work.
class Slugged(models.Model):
"""
An abstract behavior representing adding a slug (by default, unique) to
a model based on the slug_source property.
"""
slug = models.SlugField(
max_length=255,
unique=BehaviorsConfig.are_slug_unique(),
blank=True)
class Meta:
abstract = True
def save(self, *args, **kwargs):
if not self.slug:
self.slug = self.generate_unique_slug() \
if BehaviorsConfig.are_slug_unique() else self.get_slug()
super(Slugged, self).save(*args, **kwargs)
def get_slug(self):
return slugify(getattr(self, "slug_source"), to_lower=True)
def is_unique_slug(self, slug):
qs = self.__class__.objects.filter(slug=slug)
return not qs.exists()
def generate_unique_slug(self):
slug = self.get_slug()
new_slug = slug
iteration = 1
while not self.is_unique_slug(new_slug):
new_slug = "%s-%d" % (slug, iteration)
iteration += 1
return new_slug
The slug
uses the awesome-slugify package which will preserve unicode
character slugs. By default, the slug
must be unique and is guaranteed to
be unique by the class appending a number -[0-9+]
to the end of the slug
if it is not unique. The unique
field type adds an index to the slug
field.
Add the slug_source
property to your class when mixing in the behavior.
To allow non-unique slugs, add UNIQUE_SLUG_BEHAVIOR = False
to your project's settings.
# models.py
from behaviors.behaviors import Slugged
class MyModel(Slugged):
name = models.CharField(max_length=100)
# slug_source is required for the slug to be set
@property
def slug_source(self):
return "prepended-text-for-fun-{}".format(self.name)
# you can now use the slug for your get_absolute_url() method
def get_absolute_url(self):
return reverse('myapp:mymodel_detail', args=[self.slug])
>>> m = MyModel.objects.create(name='aj')
>>> m.slug
'prepended-text-for-fun-aj'
>>> m2 = MyModel.objects.create(name='aj')
>>> m.slug
'prepended-text-for-fun-aj-1'
>>> m.get_absolute_url()
'/myapp/prepended-text-for-fun-aj/detail'
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
.
By default, the slug
is unique so it can be used to define the get_absolute_url()
method on your model.
Thanks to @apirobot for sending the PR for the Slugged
behavior.
Mixing in with Custom Managers
If you have a custom manager on your model already:
# models.py
from behaviors.behaviors import Authored, Editored, Published, Timestamped
from django.db import models
class MyModelCustomManager(models.Manager):
def get_queryset(self):
return super(MyModelCustomManager).get_queryset(self)
def custom_manager_method(self):
return self.get_queryset().filter(name='Jim-bob')
class MyModel(Authored):
name = models.CharField(max_length=100)
# MyModel.objects.authored_by(..) won't work
# MyModel.authors.authored_by(..) still will
objects = MyModelCustomManager()
Simply add AuthoredManager
from behaviors.managers
as a mixin to
MyModelCustomManager
so they can share the objects
variable.
# models.py
from behaviors.behaviors import Authored, Editored, Published, Timestamped
from behaviors.managers import AuthoredManager, EditoredManager, PublishedManager
from django.db import models
class MyModelCustomManager(AuthoredManager, models.Manager):
def get_queryset(self):
return super(MyModelCustomManager).get_queryset(self)
def custom_manager_method(self):
return self.get_queryset().filter(name='Jim-bob')
class MyModel(Authored):
name = models.CharField(max_length=100)
# MyModel.objects.authored_by(..) now works
objects = MyModelCustomManager()
Similarly if you are using a custom QuerySet and calling its as_manager()
method to attach it to objects
you can import from behaviors.querysets
and mix it in.
# models.py
from behaviors.behaviors import Authored, Editored, Published, Timestamped
from behaviors.querysets import AuthoredQuerySet, EditoredQuerySet, PublishedQuerySet
from django.db import models
class MyModelCustomQuerySet(AuthoredQuerySet, models.QuerySet):
def custom_queryset_method(self):
return self.filter(name='Jim-bob')
class MyModel(Authored):
name = models.CharField(max_length=100)
# MyModel.objects.authored_by(..) works
objects = MyModelCustomQuerySet.as_manager()
Mixing in Multiple Behaviors
Many times you will want multiple behaviors on a model. You can simply mix in
multiple behaviors and, if you'd like to have all their custom QuerySet
methods work on objects
, provide a custom manager with all the mixins.
# models.py
from behaviors.behaviors import Authored, Editored, Published, Timestamped
from behaviors.querysets import AuthoredQuerySet, EditoredQuerySet, PublishedQuerySet
from django.db import models
class MyModelQuerySet(AuthoredQuerySet, EditoredQuerySet, PublishedQuerySet):
pass
class MyModel(Authored, Editored, Published, Timestamped):
name = models.CharField(max_length=100)
# MyModel.objects.authored_by(..) works
# MyModel.objects.edited_by(..) works
# MyModel.objects.published() works
# MyModel.objects.draft() works
objects = MyModelQuerySet.as_manager()
# you can also chain queryset methods
>>> u = User.objects.all()[0]
>>> u2 = User.objects.all()[1]
>>> m = MyModel.objects.create(author=u, editor=u2)
>>> MyModel.objects.published().authored_by(u).count()
1
Running Tests
Does the code actually work?
source <YOURVIRTUALENV>/bin/activate (myenv) $ pip install tox (myenv) $ tox
Credits
Tools used in rendering this package: