Announcing django-moderation

django-moderation is reusable application for Django framework, that allows to moderate any model objects.

Code can be found at http://github.com/dominno/django-moderation

Possible use cases:

  • User creates his profile, profile is not visible on site. It will be visible on site when moderator approves it.
  • User changes his profile, old profile data is visible on site. New data will be visible on site when moderator approves it.

Features:

  • configurable admin integration(data changed in admin can be visible on site when moderator approves it)
  • moderation queue in admin
  • html differences of changes between versions of objects
  • configurable email notifications
  • custom model form that allows to edit changed data of object
  • 100% PEP8 correct code
  • test coverage > 80%

Requirements

python >= 2.4

django >= 1.1

Installation

Download source code from http://github.com/dominno/django-moderation and run installation script:

$> python setup.py install

Configuration

  1. Add to your INSTALLED_APPS in your settings.py:

    moderation

  2. Run command manage.py syncdb

  3. Register Models with moderation

    from django.db import models
    from moderation import moderation
    
    
    class YourModel(models.Model):
        pass
    
    moderation.register(YourModel)
    
  4. Register admin class with your Model

    from django.contrib import admin
    from moderation.admin import ModerationAdmin
    
    
    class YourModelAdmin(ModerationAdmin):
        """Admin settings go here."""
    
    admin.site.register(YourModel, YourModelAdmin)
    

If you want to disable integration of moderation in admin, add admin_intergration_enabled = False to your admin class:

class YourModelAdmin(ModerationAdmin):
    admin_intergration_enabled = False

admin.site.register(YourModel, YourModelAdmin)

How django-moderation works

When you change existing object or create new one, it will not be publicly available until moderator approves it. It will be stored in ModeratedObject model.

your_model = YourModel(description='test')
your_model.save()

YourModel.objects.get(pk=your_model.pk)
Traceback (most recent call last):
DoesNotExist: YourModel matching query does not exist.

When you will approve object, then it will be publicly available.

your_model.moderated_object.approve(moderatated_by=user,
                                   reason='Reason for approve')

YourModel.objects.get(pk=1)
<YourModel: YourModel object>

You can access changed object by calling changed_object on moderated_object:

your_model.moderated_object.changed_object
<YourModel: YourModel object>

This is deserialized version of object that was changed.

Now when you will change an object, old version of it will be available publicly, new version will be saved in moderated_object

your_model.description = 'New description'
your_model.save()

your_model = YourModel.objects.get(pk=1)
your_model.__dict__
{'id': 1, 'description': 'test'}

your_model.moderated_object.changed_object.__dict__
{'id': 1, 'description': 'New description'}

your_model.moderated_object.approve(moderatated_by=user,
                                   reason='Reason for approve')

your_model = YourModel.objects.get(pk=1)
your_model.__dict__
{'id': 1, 'description': 'New description'}

Email notifications

By default when user change object that is under moderation, e-mail notification is send to moderator. It will inform him that object was changed and need to be moderated.

When moderator approves or reject object changes then e-mail notification is send to user that changed this object. It will inform user if his changes were accepted or rejected and inform him why it was rejected or approved.

How to overwrite email notification templates

E-mail notifications use following templates:

  • moderation/notification_subject_moderator.txt
  • moderation/notification_message_moderator.txt
  • moderation/notification_subject_user.txt
  • moderation/notification_message_user.txt

Default context:

content_type - content type object of moderated object

moderated_object - ModeratedObject instance

site - current Site instance

How to pass extra context to email notification templates

If you want to pass extra context to email notification methods you new need to create new class that subclass BaseModerationNotification class.

class CustomModerationNotification(BaseModerationNotification):
    def inform_moderator(self,
                     subject_template='moderation/notification_subject_moderator.txt',
                     message_template='moderation/notification_message_moderator.txt',
                     extra_context=None):
        '''Send notification to moderator'''
        extra_context={'test':'test'}
        super(CustomModerationNotification, self).inform_moderator(subject_template,
                                                                   message_template,
                                                                   extra_context)

    def inform_user(self, user,
                    subject_template='moderation/notification_subject_user.txt',
                    message_template='moderation/notification_message_user.txt',
                    extra_context=None)
        '''Send notification to user when object is approved or rejected'''
        extra_context={'test':'test'}
        super(CustomModerationNotification, self).inform_user(user,
                                                              subject_template,
                                                              message_template,
                                                              extra_context)

Next register it with moderation as notification_class:

moderation.register(YourModel, notification_class=CustomModerationNotification)

Signals

moderation.signals.pre_moderation - signal send before object is approved or rejected

Arguments sent with this signal:

sender - The model class.

instance - Instance of model class that is moderated

status - Moderation status, 0 - rejected, 1 - approved

moderation.signals.post_moderation - signal send after object is approved or rejected

Arguments sent with this signal:

sender - The model class.

instance - Instance of model class that is moderated

status - Moderation status, 0 - rejected, 1 - approved

Forms

When creating ModelForms for models that are under moderation use BaseModeratedObjectForm class as ModelForm class. Thanks to that form will initialized with data from changed_object.

from moderation.forms import BaseModeratedObjectForm


class ModeratedObjectForm(BaseModeratedObjectForm):

    class Meta:
        model = MyModel

Any comments ? Feedback ? Feature requests ?

Komentarze

very interesting
11 March, 2010, 11:20 p.m.:

Nice job!!! thank You for sharing

Chris
12 March, 2010, 7:33 a.m.:

Hi. This looks interesting. On reading the example, I would prefer using a decorator on the Model class instead of 'moderation.register(YourModel)':

@moderated class YourModel(models.Model): pass

PS: BTW, your blog interface is displayed in polish for me, so it was kinda hard to post this comment ;) My firefox language setting is set to English primary, German secondary language...

12 March, 2010, 7:51 a.m.:

Looking into moderation is in my queue, thanks for opening up your code as a reusable app.

Do you have any thoughts how this relates to the publisher and moderation techniques used in django-cms 2? I know that it offeres moderation, too, though I don't need it for the CMS.

Just curious whether you have thought about that, I'll look into it anyway.

Cheers!!!

Patrick Lauber
12 March, 2010, 8:39 a.m.:

How do you handle foreign-keys and m2m? What if the foreign key points to a moderated model as well? Does this get moderated as well?

apollo13
12 March, 2010, 10:01 a.m.:

I see a big problem with this example

your_model = YourModel(description='test')
your_model.save()

YourModel.objects.get(pk=your_model.pk)
Traceback (most recent call last):
DoesNotExist: YourModel matching query does not exist.

Aperrently you are patching the default manager which is more than bad. Eg assume this as YourModel:

class YourModel(models.Model):
name = models.SlugField(unique=True)

a = YourModel()
a.name = "apo"
a.save()

# So far so good
YourModel.objects.get(name="apo") # Fails cause your manager knows it's not moderated, that's fine for now

b = YourModel()
b.name = "apo"
b.save() # Will raise an Integrity error at the Database level, you usually circumvent this by checking if this name already exists, but your manager yields no…
apollo13
12 March, 2010, 10:02 a.m.:

Eeeks, I hope my comment ist still readable, /me would appreciate english translations of your buttons :þ

12 March, 2010, 1:09 p.m.:

@Chris: Idea with decorator looks interesting i will think about it. Thank you

@Klaus Blindert: I have no idea, i didnt know that django cms2 have a moderation queue, i will see how it works. Thank you

@Patrick Lauber: Currently there is no support for foreign-key and m2m relations. I will add it in future.

@apollo13: When you register a model with moderation then new manager class is created that subclass original manager this new manager will filter out any model instances that should not be available publicly. I have not tested this case when there is Integrity error. I will fix this. Thank you for finding the bug.

As for buttons translations I'll fix this, i promise.

12 March, 2010, 4:46 p.m.:

I have added list of Known isuses and a Road map to the documentation on github.

Do you have any features that you would like to have in this app ??

Jon
13 March, 2010, 4:18 p.m.:

Hi Dominik,

Thank you very much for this app. I do have bug reports, and a few questions.

First, bugs: "moderatated_by" everywhere it appears should be spelt "moderated_by", so that English users (and others alike) can use that word without confusion. Also, "object" is sometimes used in a non-keyword context (for object in ...), which I guess is OK, until it breaks something... Unlikely, but "obj" would be more than sufficient.

Secondly, it seems there is no way to permanently exempt "is_staff" or "is_superuser" from moderation? And also on that point it seems like any time you will want to either auto-approve or auto-reject an object based on "request.user" (their Group for instance) you will instead need a long chain of if/elif conditions, and inside those differing pre_moderation/post_moderation signals, no? Seems like a lot of repetition and not very DRY... It is something that should be configurable and handled automatically any time you save/get/filter/etc.

And related to that, I echo the sentiments about possible default manager problems... I haven't fully looked through the code but it seems that it assumes the default manager is what shall be "public" which is not the case for all models. I have my own public manager for some models accessed like "Content.public.filter(...)" which is non-default, so I feel that it may end up displaying content submitted by users which I have moderated.

I do see "manager_name" in moderation.register() kwargs, but what if we have multiple public managers?

class Person(models.Model):
    ...
    gender = models.PositiveSmallIntegerField(choices=GENDER_CHOICES)
    ...
    objects = models.Manager()
    men = GenderManager(gender=0)
    women  = GenderManager(gender=1)

Any of the three managers could be used to display public information, but if we were to register this with django-moderation it would not allow us to use our managers any more. Of course it seems like subclassing ModerationObjectsManager in GenderManager could work, but I am not sure and have not tried this.

So basically the biggest unresolved issue for me is how to go about auto-approving on save() for "is_staff", "is_superuser", and for specific, configurable group sets. Or the converse where we only need to moderate on save() for "is_anonymous == True".

The other issue (multiple managers) I think may work the way I've explained but some clarification would be appreciated...

Jon
13 March, 2010, 4:31 p.m.:

P.S. -

And if pre_moderation/post_moderation signals aren't the best/easiest way of currently handing auto-approval situations, what is? I still feel that some kind of configurable way of handling auto-approval would be optimal.

Say, for instance:

moderation.register(MyModel, auto_approve_for_staff=True, auto_approve_for_groups=[1, 3, 4], auto_reject_for_groups=[8])
# "Registered", "Moderators", "VIP" = 1, 3, 4  for example
# "Banned" = 8 for example

Thank you very much!

Dominik Szopa
14 March, 2010, 8:56 a.m.:

Hello Jon,

Thank you for your comment.

I have fixed typo in "moderatated_by", if you you'll find any more typos please let me know.

I have also fixed object variables names to obj, thank you for suggestion, you are right that some times it could break something when object type is used.

As for auto approve/reject for specific user groups. You are right that using signals will not be DRY. Thank you for this idea. But i think that auto_approve_for_groups and auto_reject_for_groups should be group names not ids, because ids may change if you have multiple installations of project. I will implement this. Do you have other ideas about that ?

As for managers. Yes there is manager_name in kwargs of moderation.register(). By default when you register a model class with moderation, it will not remove manager that was uses under manager_name(objects default). It will create new manager class that subclass manager that was under 'objects'. What if i will add for instance 'manager_names', that could get a list of managers names that will be changed to ModerationObjectsManager class that subclass each of manager name from list given. What do you think about it ? It should fixed the problem.

moderation.register(MyModel, manager_names=['objects', 'men', 'women'])

Thank you very much for your help.

Dominik Szopa
15 March, 2010, 9:08 a.m.:

apollo13: I have fixed formating of your comment and now it's displayed properly. Now i see what you meant. I fought that you meant that integrity error was caused by manager class. I have written test for this case and it's working ok. Now i see that you meant that you can not check manually if object exist in order to create new one. Actually you can. You can use the defualtmanager for this.

YourModel._default_manager.get(name="apo") # this will not raise DoesNotExist.

or you can use try except:

try:
    b = YourModel()
    b.name = "apo"
    b.save()
except IntegrityError:
    # object already exist
    # do your custom action

You can also define in model your custom manager this return objects that are not under moderation:

class YourModel(models.Model):
    name = models.SlugField(unique=True)

    unmoderated = models.Manager()

YourModel.unmoderated.get(name="apo")

What do you think about it ???

Jon
15 March, 2010, 11:03 p.m.:

But i think that auto_approve_for_groups and auto_reject_for_groups should be group names not ids, because ids may change if you have multiple installations of project. I will implement this. Do you have other ideas about that ?

Whatever sounds best to you. I think that group names could likely change as well, though. And if you're registering a specific model on a specific site the group IDs should more or less be set in stone. I understand though with reusable apps it maybe a problem, so you should decide which ever is best...

What if i will add for instance 'manager_names', that could get a list of managers names that will be changed to ModerationObjectsManager class that subclass each of manager name from list given. What do you think about it ? It should fixed the problem.

Again, if that sounds like a good solution to you. I like the idea of "manager_names".

Also, you never specifically addressed the idea of auto_approve_for_staff or auto_approve_for_superusers and also auto_reject_for_anonymous as kwargs for moderation.register(). Do you see the usefulness of also having those? Staff, superusers, and anonymous users don't have specific Group IDs or any other identifiers, so it would seem to be the best way...

Jon
15 March, 2010, 11:05 p.m.:

It stripped my blockquotes where I was replying to you... (using the " button)

"But I think..." is replying to group names vs ids

"Again, if that sounds..." is replying to your paragraph about manager_names

16 March, 2010, 8:01 a.m.:

Hi Jon,

...And if you're registering a specific model on a specific site the group IDs should more or less be set in stone. I understand though with reusable apps it maybe a problem, so you should decide which ever is best

Ok, i will use group names.

The ideas of auto_approve_for_staff, auto_approve_for_superusers, auto_reject_for_anonymous are also very good thank you. Do you have any other ideas that could be useful also here ? I will implement this today or tomorrow.

Right now I'm refactoring a register method. Now the register method will get only two parameters: model class and settings class:

from moderation import GenericModerator


class PersonModerator(GenericModerator):
     manager_names = ['men', 'women']
     auto_approve_for_staff = True
     auto_approve_for_groups = ['moderators']

moderation.register(Person, PersonModerator)

This way register method code is a lot simpler and there is possibility of overwriting notifications sending methods.

It stripped my blockquotes where I was replying to you... (using the " button)

I'm sorry there was no styles for blockquotes, its fixed right now.

ps. you can edit your comment when login using clickpass(google accounts, openid etc) Look at the head of this page.


Comments turned off