Package: django-behaviors
Common behaviors for Django Models, e.g. Timestamps, Publishing, Authoring/Editing and more.Inspired by Kevin Stone’sDjango Model Behaviors.DocumentationQuickstartInstall Django Behaviors:pip install django-behaviors
# Or, if you are going to use the Slugged behaviour
pip install django-behaviors[slugged]Add it to yourINSTALLED_APPS:INSTALLED_APPS=(...'behaviors.apps.BehaviorsConfig',...)Featuresbehaviorsmakes it easy to integrate common behaviors into your django models:Documented,tested, andeasy to useTimestampedto addcreatedandmodifiedattributes to your modelsStoreDeletedto adddeletedattribute to your models, avoiding the record to be deleted and allow to restore itAuthoredto add anauthorto your modelsEditoredto add aneditorto your modelsPublishedto add apublication_status(draft or published) to your modelsReleasedto add arelease_dateto your modelsSluggedto add aslugto your models (thanks @apirobot) (ensure you haveawesome-slugifyinstalled, see above)Easily compose together multiplebehaviorsto get desired functionality (e.g.AuthoredandEditored)CustomQuerySetmethods added as managers to your models to utilize the added fieldsEasily compose together multiplequerysetormanagerto get desired functionalityTable of ContentsBehaviorsTimestampedStoreDeletedAuthoredEditoredPublishedReleasedSluggedMixing in with Custom ManagersMixing Multiple BehaviorsBehaviorsTimestamped BehaviorThe model adds acreatedandmodifiedfield to your model.classTimestamped(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)classMeta:abstract=True@propertydefchanged(self):returnTrueifself.modifiedelseFalsedefsave(self,*args,**kwargs):ifself.pk:self.modified=timezone.now()returnsuper(Timestamped,self).save(*args,**kwargs)createdis set on the next save and is set to the current UTC time.modifiedis set when the object already exists and is set to the current UTC time.MyModel.changedreturns a boolean representing if the object has been updated after created (themodifiedfield has been set).Here is an example of using the model, note you do not need to addmodels.ModelbecauseTimestampedalready inherits it.# models.pyfrombehaviors.behaviorsimportAuthored,Editored,Timestamped,PublishedclassMyModel(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.modifiedNone>>>m.changedFalse>>>m.save()>>>m.modified'2017-02-14 17:20:46.836395+00:00'>>>m.changedTrueStoreDeleted BehaviorThe model add adeletedfield to your model and prevent record to be deleted and allow to restore itclassStoreDeleted(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()classMeta:abstract=True@propertydefis_deleted(self):returnself.deleted!=Nonedefdelete(self,*args,**kwargs):ifnotself.pk:raiseObjectDoesNotExist('Object must be created before it can be deleted')self.deleted=timezone.now()returnsuper(StoreDeleted,self).save(*args,**kwargs)defrestore(self,*args,**kwargs):ifnotself.pk:raiseObjectDoesNotExist('Object must be created before it can be restored')self.deleted=Nonereturnsuper(StoreDeleted,self).save(*args,**kwargs)deletedis set whendelete()method is called, with current UTC time.Here is an example of using the model, note you do not need to addmodels.ModelbecauseStoreDeletedalready inherits it.# models.pyfrombehaviors.behaviorsimportStoreDeletedclassGreatModel(StoreDeleted):name=models.CharField(max_length=100)# Deleting model>>>gm=GreatModel.objects.create(name='Xtra')>>>gm.deletedNone>>>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.deletedNoneAuthored BehaviorThe authored model adds anauthorattribute that is a foreign key to thesettings.AUTH_USER_MODELand adds manager methods throughobjectsandauthors.classAuthored(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()classMeta:abstract=TrueHere is an example of using the behavior and itsauthored_by()manager method:# models.pyfrombehaviors.behaviorsimportAuthoredclassMyModel(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()1The author is a required field and must be provided on initialPOSTrequests that create an object.A custommodels.ModelFormis provided to automatically add theauthoron object creation:# forms.pyfrombehaviors.formsimportAuthoredModelFormfrom.modelsimportMyModelclassMyModelForm(AuthoredModelForm):classMeta:model=MyModelfields=['name']# views.pyfromdjango.views.generic.editimportCreateViewfrom.formsimportMyModelFormfrom.modelsimportMyModelclassMyModelCreateView(CreateView):model=MyModelform=MyModelForm# add request to form kwargsdefget_form_kwargs(self):kwargs=super(MyModelCreateView,self).get_form_kwargs()kwargs['request']=self.requestreturnkwargsNow when the object is created theauthorwill be added on the call
toform.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.pyclassMyModelView(View):template_name="myapp/mymodel_form.html"defget(self,request,*args,**kwargs):context={'form':MyModelForm(),}returnrender(request,self.template_name,context=context)defpost(self,request,*args,**kwargs):# pass in request object to the request keyword argumentform=MyModelForm(self.request.POST,request=request)ifform.is_valid():form.save()returnreverse(..)context={'form':form,}returnrender(request,self.template_name,context=context)If for some reason you don’t want to mixin theAuthoredModelFormwith your existing
form you can just add the user like so:# ...ifform.is_valid()obj=form.save(commit=False)obj.author=request.userobj.save()returnreverse(..)# ...But it isn’t recommended, theAuthoredModelFormis tested and doesn’t reassign the
author on every save.Therelated_nameis 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 andMyModelwas part of theblogsapp it could be done like so:>>>user=User.objects.get(pk=2)>>>user.blogs_mymodel_author.all()[<MyModel:...>]That would give a list of allMyModelobjects thatuserhasauthored.Authored QuerySetTheAuthoredbehavior attaches a custom model manager to the defaultobjectsand to theauthorsvariables on the model it is mixed into. If you haven’t overrode
theobjectsvariable with a custom manager then you can use that, otherwise theauthorsvariable is a fallback.To get allMyModelinstances 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:...>,...]SeeMixing in with Custom Managersfor details on how
to mix in this behavior with a custom manager you have that overrides theobjectsdefault manager.Editored BehaviorThe editored model adds aneditorattribute that is a foreign key to thesettings.AUTH_USER_MODELand adds manager methods throughobjectsandeditorsvariables.classEditored(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()classMeta:abstract=TrueTheEditoredmodel is similar to theAuthoredmodel except the foreign key isnot required. Here is an example of its usage:# models.pyfrombehaviors.behaviorsimportEditoredclassMyModel(Editored):name=models.CharField(max_length=100)>>>m=MyModel.objects.create(name='pj')>>>m.editorNone>>>m.editor=User.objects.all()[0]>>>m.save()>>>queryset=MyModel.objects.edited_by(User.objects.all()[0])>>>queryset.count()1By default theeditoris blank and null, if arequestobject is supplied to the form it will assign a new editor and erase the previous editor (or the null editor).Instead of using theAuthoredModelFormuse theEditoredModelFormas a mixin to
your form.# forms.pyfrombehaviors.formsimportEditoredModelFormfrom.modelsimportMyModelclassMyModelForm(EditoredModelForm):classMeta:model=MyModelfields=['name']# views.pyfromdjango.views.generic.editimportCreateView,UpdateViewfrom.formsimportMyModelFormfrom.modelsimportMyModelMyModelRequestFormMixin(object):# add request to form kwargsdefget_form_kwargs(self):kwargs=super(MyModelCreateView,self).get_form_kwargs()kwargs['request']=self.requestreturnkwargsclassMyModelCreateView(MyModelRequestFormMixin,CreateView):model=MyModelform=MyModelFormclassMyModelUpdateView(MyModelRequestFormMixin,UpdateView):model=MyModelform=MyModelFormNow when the object is created or updated theeditorwill be updated
on the call toform.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.pyclassMyModelView(View):template_name="myapp/mymodel_form.html"defget(self,request,*args,**kwargs):context={'form':MyModelForm(),}returnrender(request,self.template_name,context=context)defpost(self,request,*args,**kwargs):# pass in request object to the request keyword argumentform=MyModelForm(self.request.POST,request=request)ifform.is_valid():form.save()returnreverse(..)context={'form':form,}returnrender(request,self.template_name,context=context)If for some reason you don’t want to mixin theEditoredModelFormwith your existing
form you can just add the user like so:...ifform.is_valid()obj=form.save(commit=False)obj.editor=request.userobj.save()returnreverse(..)...But it isn’t recommended, theEditoredModelFormis tested and doesn’t cause errors
if request.user is invalid.Therelated_nameis 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 andMyModelwas part of theblogsapp it could be done like so:>>>user=User.objects.get(pk=2)>>>user.blogs_mymodel_editor.all()[<MyModel:...>]That would give a list of allMyModelobjects thatuseris aneditor.Editored QuerySetTheEditoredbehavior attaches a custom model manager to the defaultobjectsand to theeditorsvariables on the model it is mixed into. If you haven’t overrode
theobjectsvariable with a custom manager then you can use that, otherwise theeditorsvariable is a fallback.To get allMyModelinstances 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:...>,...]SeeMixing in with Custom Managersfor details on how
to mix in this behavior with a custom manager you have that overrides theobjectsdefault manager.Published BehaviorThePublishedbehavior adds a fieldpublication_statusto your model. The status
has two states: ‘Draft’ or ‘Published’.classPublished(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)classMeta:abstract=Trueobjects=PublishedQuerySet.as_manager()publications=PublishedQuerySet.as_manager()@propertydefdraft(self):returnself.publication_status==self.DRAFT@propertydefpublished(self):returnself.publication_status==self.PUBLISHEDThe class offers two propertiesdraftandpublishedto know object state. TheDRAFTandPUBLISHEDclass constants will be available from the class thePublishedbehavior is mixed into. There is also a custom manager attached toobjectsandpublicationsvariables to getpublished()ordraft()querysets.# models.pyfrombehaviors.behaviorsimportPublishedclassMyModel(Published):name=models.CharField(max_length=100)>>>m=MyModel.objects.create(name='cj')>>>m.publication_statusu'd'>>>m.draftTrue>>>m.publishedFalse>>>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_statusu'p'>>>m.draftFalse>>>m.publishedTrue>>>m.get_publication_status_display()u'Published'>>>MyModel.objects.published().count()1>>>MyModel.PUBLISHEDu'p'>>>MyModel.PUBLISHED==m.publication_statusTrueThepublication_statusfield defaults toPublished.DRAFTwhen you make new
models unless you supply thePublished.PUBLISHEDattribute to thepublication_statusfield.MyModel.objects.create(name='Jim-bob Cooter',publication_status=MyModel.PUBLISHED)Published QuerySetThePublishedbehavior attaches to the defaultobjectsvariable and
thepublicationsvariable as a fallback ifobjectsis overrode.# returns all MyModel.PUBLISHEDMyModel.objects.published()MyModel.publications.published()# returns all MyModel.DRAFTMyModel.objects.draft()MyModel.publications.draft()Released BehaviorTheReleasedbehavior adds a fieldrelease_dateto your model. The field
isnot_required. The release date can be set with therelease_on(datetime)method.classReleased(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)classMeta:abstract=Trueobjects=ReleasedQuerySet.as_manager()releases=ReleasedQuerySet.as_manager()defrelease_on(self,date=None):ifnotdate:date=timezone.now()self.release_date=dateself.save()@propertydefreleased(self):returnself.release_dateandself.release_date<timezone.now()There is areleasedproperty added which determines if the object has been released. There is a custom manager attached toobjectsandreleasesvariables to filter querysets on their release date.Here is an example of using the behavior:# models.pyfromdjango.utilsimporttimezonefromdatetimeimporttimedeltafrombehaviors.behaviorsimportReleasedclassMyModel(Released):name=models.CharField(max_length=100)>>>m=MyModel.objects.create(name='rj')>>>m.release_dateNone>>>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()0Therelease_onmethod 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 arelease_dateon object creation:MyModel.objects.create(name='Jim-bob Cooter',release_date=timezone.now())Released QuerySetTheReleasedbehavior attaches to the defaultobjectsvariable and
thereleasesvariable as a fallback ifobjectsis overrode.# returns all not released MyModel objectsMyModel.objects.not_released()MyModel.releases.not_released()# returns all released MyModel objectsMyModel.objects.released()MyModel.releases.released()# returns all null release date MyModel objectsMyModel.objects.no_release_date()MyModel.releases.no_release_date()Slugged BehaviorTheSluggedbehavior allows you to easily add aslugfield to your model. The slug is generated on the first model creation or the next model save and is based on theslug_sourceattribute.Theslug_sourceproperty has no set default, you must add it to your model for the behavior to work.classSlugged(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)classMeta:abstract=Truedefsave(self,*args,**kwargs):ifnotself.slug:self.slug=self.generate_unique_slug()\ifBehaviorsConfig.are_slug_unique()elseself.get_slug()super(Slugged,self).save(*args,**kwargs)defget_slug(self):returnslugify(getattr(self,"slug_source"),to_lower=True)defis_unique_slug(self,slug):qs=self.__class__.objects.filter(slug=slug)returnnotqs.exists()defgenerate_unique_slug(self):slug=self.get_slug()new_slug=slugiteration=1whilenotself.is_unique_slug(new_slug):new_slug="%s-%d"%(slug,iteration)iteration+=1returnnew_slugThesluguses the awesome-slugify package which will preserve unicode
character slugs. By default, theslugmust 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. Theuniquefield typeadds an indexto theslugfield.Add theslug_sourceproperty to your class when mixing in the behavior.To allow non-unique slugs, addUNIQUE_SLUG_BEHAVIOR = Falseto your project’s settings.# models.pyfrombehaviors.behaviorsimportSluggedclassMyModel(Slugged):name=models.CharField(max_length=100)# slug_source is required for the slug to be set@propertydefslug_source(self):return"prepended-text-for-fun-{}".format(self.name)# you can now use the slug for your get_absolute_url() methoddefget_absolute_url(self):returnreverse('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'Yourslug_sourceattribute can be a mix of any of the model data available at the time of save, generally it is somenametype of field. You could also hash the primary key and/or some other data as aslug_source.
By default, theslugis unique so it can be used to define theget_absolute_url()method on your model.Thanks to @apirobot for sending the PR for theSluggedbehavior.Mixing in with Custom ManagersIf you have a custom manager on your model already:# models.pyfrombehaviors.behaviorsimportAuthored,Editored,Published,Timestampedfromdjango.dbimportmodelsclassMyModelCustomManager(models.Manager):defget_queryset(self):returnsuper(MyModelCustomManager).get_queryset(self)defcustom_manager_method(self):returnself.get_queryset().filter(name='Jim-bob')classMyModel(Authored):name=models.CharField(max_length=100)# MyModel.objects.authored_by(..) won't work# MyModel.authors.authored_by(..) still willobjects=MyModelCustomManager()Simply addAuthoredManagerfrombehaviors.managersas a mixin toMyModelCustomManagerso they can share theobjectsvariable.# models.pyfrombehaviors.behaviorsimportAuthored,Editored,Published,Timestampedfrombehaviors.managersimportAuthoredManager,EditoredManager,PublishedManagerfromdjango.dbimportmodelsclassMyModelCustomManager(AuthoredManager,models.Manager):defget_queryset(self):returnsuper(MyModelCustomManager).get_queryset(self)defcustom_manager_method(self):returnself.get_queryset().filter(name='Jim-bob')classMyModel(Authored):name=models.CharField(max_length=100)# MyModel.objects.authored_by(..) now worksobjects=MyModelCustomManager()Similarly if you are using a custom QuerySet and calling itsas_manager()method to attach it toobjectsyou can import frombehaviors.querysetsand mix it in.# models.pyfrombehaviors.behaviorsimportAuthored,Editored,Published,Timestampedfrombehaviors.querysetsimportAuthoredQuerySet,EditoredQuerySet,PublishedQuerySetfromdjango.dbimportmodelsclassMyModelCustomQuerySet(AuthoredQuerySet,models.QuerySet):defcustom_queryset_method(self):returnself.filter(name='Jim-bob')classMyModel(Authored):name=models.CharField(max_length=100)# MyModel.objects.authored_by(..) worksobjects=MyModelCustomQuerySet.as_manager()Mixing in Multiple BehaviorsMany 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 customQuerySetmethods work onobjects, provide a custom manager with all the mixins.# models.pyfrombehaviors.behaviorsimportAuthored,Editored,Published,Timestampedfrombehaviors.querysetsimportAuthoredQuerySet,EditoredQuerySet,PublishedQuerySetfromdjango.dbimportmodelsclassMyModelQuerySet(AuthoredQuerySet,EditoredQuerySet,PublishedQuerySet):passclassMyModel(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() worksobjects=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()1Running TestsDoes the code actually work?source <YOURVIRTUALENV>/bin/activate
(myenv) $ pip install tox
(myenv) $ toxCreditsTools used in rendering this package:Cookiecuttercookiecutter-djangopackageHistory0.5.1 (2020-09-19)Feature:Sluggedbehavior now supports non-unique slugs (thanks @vonmaster)0.5.0 (2019-07-01)Drop Django 2.0 support and Python 3.4 support as they have reached EOLAdd Django 2.1, 2.2 and Python 3.7 supportFeature: Add db_index toTimestampedbehavior’smodifiedfield (thanks @kazqvaizer)0.4.1 (2018-05-17)Feature: AddStoreDeletedbehavior to soft delete models (thanks @abekroenem)0.4.0 (2018-01-16)Update setup.py classifiers0.3.1 (2018-01-04)Add Django 2.0 and Python 3.6 Support0.3.0 (2017-03-11)AddSluggedbehavior adding a slug to modelsUpdate documentation0.2.0 (2017-02-14)AddReleasedbehavior for a release date on modelsUpdate documentation0.1.7 (2017-02-14)Remove an unused importIntegrate with Lintly0.1.6 (2017-02-14)Drop python3.3 support for Django 1.8 because 1.8 no longer supports it0.1.5 (2017-02-14)Fix import error for py2.7 builds0.1.4 (2017-02-14)Fix Syntax Error0.1.3 (2017-02-14)Fixed Circular Import0.1.2 (2017-02-13)Travis CI Fixes0.1.1 (2017-02-13)First release on PyPIFlake8 adherence fixes0.1.0 (2017-02-13)First push of project