Rajeesh Nair avatar Rajeesh Nair committed 7c723ab

Initial commit of django-monitor

Comments (0)

Files changed (24)

+# Ignore all editor-backups & pyc files
+syntax: glob
+
+*~
+*.pyc
+
+Copyright (c) Rajeesh Nair.
+All rights reserved.
+ 
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+ 
+    1. Redistributions of source code must retain the above copyright notice,
+       this list of conditions and the following disclaimer.
+ 
+    2. Redistributions in binary form must reproduce the above copyright
+       notice, this list of conditions and the following disclaimer in the
+       documentation and/or other materials provided with the distribution.
+ 
+    3. Neither the name of the author nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+ 
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+===============
+Django-monitor
+===============
+
+---------------------------------------------------------
+A django-app to enable moderation of model objects
+---------------------------------------------------------
+
+Expected work-flow:
+===================
+
+Registration
+-------------
+A developer can register models for moderation. A register hook will
+be provided to accept the model along with some other arguments.
+
+Permission
+-----------
+During database syncing, a basic permission, ``moderate_model`` will be
+created for each model registered for moderation.
+
+.. note::
+
+The users who have add/edit permissions will be called non-managers and
+those who have moderate permission also (in addition to add/edit) will
+be called managers from now onwards.
+
+Auto-moderation
+----------------
+#. When a non-manager creates an object that belongs to a moderated model,
+   it will have a pending status. (status = ``In Pending``). Each of these
+   objects can later be approved or challenged by a manager.
+
+#. When a manager creates an object that belongs to a moderated model,
+   it will get approved automatically (status = ``Approved``).
+
+Moderation-lists
+----------------
+When a manager logs into the admin-site, he can see two links, ``n Pending``
+and ``n Challenged``, in the rows of all moderated models which have objects
+in the respective status. ``n`` is the number of objects to be moderated.
+On clicking the link, manager can see the change-list of all such objects.
+
+Moderation actions
+-------------------
+Change-list of each moderated model contains three actions, ``approve``,
+``challenge`` and ``reset to pending``. If the manager selects few objects,
+choose the action ``approve`` and press ``Go``, those objects will get
+approved. Similarly, one can challenge some objects too. Once some objects
+get challenged, the non-managers with edit permissions may check them
+again and make required corrections. After that, they can reset the status to
+``In pending`` so that their manager gets to verify them again.
+
+Related moderation
+-------------------
+When a manager moderates some model objects, there may be some other related
+model objects which also can get moderated along with the original ones. The
+developer can specify such related models to be moderated during registration.
+
+Locks
+------
+The developer can protect the approved objects from further modification and
+deletion to maintain data integrity. The fields which are not meant to be
+changed after approval can be specified in the model-admin using
+``protected_fields`` option. The model-admin will club them with readonly_fields
+if the object is approved. Similarly, developer can block deletion also.
+
+

monitor/__init__.py

+__author__ = "Rajeesh Nair"
+__version__ = "0.1.0a"
+__copyright__ = "Copyright (c) 2011 Rajeesh"
+__license__ = "BSD"
+
+from datetime import datetime
+
+from django.contrib.contenttypes.models import ContentType
+from django.db.models import Manager, signals
+
+from monitor.middleware import get_current_user
+from monitor.models import MonitorEntry
+
+_queue = {}
+
+def is_in_queue(model):
+    """ Whether the given model is put in queue or not."""
+    return _queue.has_key(model)
+
+PENDING_STATUS = 'IP'
+APPROVED_STATUS = 'AP'
+CHALLENGED_STATUS = 'CH'
+
+MONITOR_TABLE = MonitorEntry._meta.db_table
+
+def nq(
+    model, rel_fields = [], manager_name = 'objects',
+    status_name = 'status', monitor_name = 'monitor_entry', base_manager = None
+):
+    """ Register(enqueue) the model for moderation."""
+    if not is_in_queue(model):
+        signals.post_save.connect(save_handler, sender = model)
+        add_fields(model, manager_name, status_name, monitor_name, base_manager)
+        _queue[model] = {'model': model, 'rel_fields': rel_fields}
+
+def dq(model):
+    """ Unregister (dequeue) the registered model."""
+    return _queue.pop(model, None)
+
+def create_moderate_perms(app, created_models, verbosity, **kwargs):
+    """ This will create moderate permissions for all registered models"""
+    from django.contrib.contenttypes.models import ContentType
+    from django.contrib.auth.models import Permission
+    mod_models = _queue.keys()
+    for model in mod_models:
+        ctype = ContentType.objects.get_for_model(model)
+        codename = 'moderate_%s' % model._meta.object_name.lower()
+        name = u'Can moderate %s' % model._meta.verbose_name_raw
+        p, created = Permission.objects.get_or_create(
+            codename = codename,
+            content_type__pk = ctype.id,
+            defaults = {'name': name, 'content_type': ctype}
+        )
+        if created and verbosity >= 2:
+            print "Adding permission '%s'" % p
+
+signals.post_syncdb.connect(
+    create_moderate_perms,
+    dispatch_uid = "django-monitor.create_moderate_perms"
+)
+
+def add_fields(cls, manager_name, status_name, monitor_name, base_manager):
+    """ Add additional fields like status to moderated models"""
+    # Inheriting from old manager
+    if base_manager is None:
+        if hasattr(cls, manager_name):
+            base_manager = getattr(cls, manager_name).__class__
+        else:
+            base_manager = Manager
+    # Queryset inheriting from manager's Queryset
+    base_queryset = base_manager().get_query_set().__class__
+
+    class CustomQuerySet(base_queryset):
+        """ Chainable queryset for checking status """
+       
+        def _by_status(self, field_name, status):
+            """ Filter queryset by given status"""
+            where_clause = '%s = %%s' % (field_name)
+            return self.extra(where = [where_clause], params = [status])
+
+        def approved(self):
+            """ All approved objects"""
+            return self._by_status(status_name, APPROVED_STATUS)
+
+        def exclude_approved(self):
+            """ All not-approved objects"""
+            where_clause = '%s != %%s' % (status_name)
+            return self.extra(
+                where = [where_clause], params = [APPROVED_STATUS]
+            )
+
+        def pending(self):
+            """ All pending objects """
+            return self._by_status(status_name, PENDING_STATUS)
+
+        def challenged(self):
+            """ All challenged objects """
+            return self._by_status(status_name, CHALLENGED_STATUS)
+
+    class CustomManager(base_manager):
+        """ custom manager that adds parameters and uses custom QuerySet """
+
+        # use_for_related_fields is read when the model class is prepared
+        # because CustomManager isn't set on the class at the time
+        # this really has no effect, but is set to True because we are going
+        # to hijack cls._default_manager later
+        use_for_related_fields = True
+
+        # add monitor_id and status_name attributes to the query
+        def get_query_set(self):
+            # parameters to help with generic SQL
+            db_table = self.model._meta.db_table
+            pk_name = self.model._meta.pk.attname
+            content_type = ContentType.objects.get_for_model(self.model).id
+
+            # extra params - status and id of object (for later access)
+            select = {
+                '_monitor_id': '%s.id' % MONITOR_TABLE,
+                '_status': '%s.status' % MONITOR_TABLE,
+            }
+            where = [
+                '%s.content_type_id=%s' % (MONITOR_TABLE, content_type),
+                '%s.object_id=%s.%s' % (MONITOR_TABLE, db_table, pk_name)
+            ]
+            tables = [MONITOR_TABLE]
+
+            # build extra query then copy model/query to a CustomQuerySet
+            q = super(CustomManager, self).get_query_set().extra(
+                select = select, where = where, tables = tables
+            )
+            return CustomQuerySet(self.model, q.query)
+
+    def _get_monitor_entry(self):
+        """ accessor for monitor_entry that caches the object """
+        if not hasattr(self, '_monitor_entry'):
+            self._monitor_entry = Monitor.objects.get(pk = self._monitor_id)
+        return self._monitor_entry
+
+    # Add custom manager & monitor_entry to class
+    manager = CustomManager()
+    cls.add_to_class(manager_name, manager)
+    cls.add_to_class(monitor_name, property(_get_monitor_entry))
+    cls.add_to_class(status_name, property(lambda self: self._status))
+
+    # Copy manager to default_class
+    cls._default_manager = manager
+
+def save_handler(sender, instance, **kwargs):
+    """
+    After saving an object in moderated class, do the following:
+    1. Create a corresponding monitor entry.
+    2. Auto-moderate objects if enough permissions are available.
+    3. Moderate specified related objects too.
+    """
+    # Auto-moderation
+    user = get_current_user()
+    opts = instance.__class__._meta
+    mod_perm = '%s.moderate_%s' % (
+        opts.app_label.lower(), opts.object_name.lower()
+    )
+    if user and user.has_perm(mod_perm):
+        status = APPROVED_STATUS
+    else:
+        status = PENDING_STATUS
+
+    # Corresponding monitor entry
+    if kwargs.get('created', None):
+        me = MonitorEntry(
+            status = status, content_object = instance,
+            timestamp = datetime.now()
+        )
+        me.save()
+

monitor/actions.py

+
+from django.core.exceptions import PermissionDenied
+from django.contrib.admin.util import model_ngettext
+from django.utils.translation import ugettext_lazy, ugettext as _
+
+from monitor.util import moderate_rel_objects
+from monitor import is_in_queue
+from monitor.models import MonitorEntry
+
+def moderate_selected(modeladmin, request, queryset, status):
+    """
+    Generic action to moderate selected objects plus all related objects.
+    """
+    opts = modeladmin.model._meta
+    
+    # If moderation is disabled..
+    if not is_in_queue(modeladmin.model):
+        return 0
+
+    # Check that the user has required permission for the actual model
+    # To reset to pending status, change_perm is enough. For all else,
+    # user need to have moderate_perm.
+    if (
+        (status == 'IP' and not modeladmin.has_change_permission(request)) or 
+        (status != 'IP' and not modeladmin.has_moderate_permission(request))
+    ):
+        raise PermissionDenied
+
+    # Approved objects can not further be moderated.
+    queryset = queryset.exclude_approved()
+
+    # After moderating objects in queryset, moderate related objects also
+    q_count = queryset.count()
+
+    # We want to use the status display rather than abbreviations in logs.
+    status_display = {
+        'IP': 'Pending', 
+        'CH': 'Challenged',
+        'AP': 'Approved'
+    }[status]
+
+    if q_count:
+        #for obj in queryset:
+            #message = 'Changed status from %s to %s.' % (
+            #    obj.get_status_display(), status_display
+            #)
+            #modeladmin.log_moderation(request, obj, message)
+            #me = MonitorEntry.objects.get_for_instance(obj)
+        moderate_rel_objects(queryset, status, request.user)
+    return q_count
+        
+def approve_selected(modeladmin, request, queryset):
+    """ Default action to approve selected objects """
+    ap_count = moderate_selected(modeladmin, request, queryset, 'AP')
+    if ap_count:
+        modeladmin.message_user(
+            request,
+            _("Successfully approved %(count)d %(items)s.") % {
+                "count": ap_count,
+                "items": model_ngettext(modeladmin.opts, ap_count)
+            }
+        )
+    # Return None to display the change list page again.
+    return None
+approve_selected.short_description = ugettext_lazy(
+    "Approve selected %(verbose_name_plural)s"
+)
+
+def challenge_selected(modeladmin, request, queryset):
+    """ Default action to challenge selected objects """
+    ch_count = moderate_selected(modeladmin, request, queryset, 'CH')
+    if ch_count:
+        modeladmin.message_user(
+            request,
+            _("Successfully challenged %(count)d %(items)s.") % {
+                "count": ch_count,
+                "items": model_ngettext(modeladmin.opts, ch_count)
+            }
+        )
+    # Return None to display the change list page again.
+    return None
+challenge_selected.short_description = ugettext_lazy(
+    "Challenge selected %(verbose_name_plural)s"
+)
+
+def reset_to_pending(modeladmin, request, queryset):
+    """ Default action to reset selected object's status to pending """
+    ip_count = moderate_selected(modeladmin, request, queryset, 'IP')
+    if ip_count:
+        modeladmin.message_user(
+            request,
+            _("Successfully reset status of %(count)d %(items)s.") % {
+                'count': ip_count,
+                'items': model_ngettext(modeladmin.opts, ip_count)
+            }
+        )
+    return None
+reset_to_pending.short_description = ugettext_lazy(
+    "Reset selected %(verbose_name_plural)s to pending"
+)
+
+
+from django.contrib import admin
+
+from monitor.actions import approve_selected, challenge_selected
+from monitor.actions import reset_to_pending
+from monitor import APPROVED_STATUS
+
+class MonitorAdmin(admin.ModelAdmin):
+    """Use this for monitored models."""
+
+    # Which fields are to be made readonly after approval.
+    protected_fields = ()
+
+    def is_monitored(self):
+        """Returns whether the underlying model is monitored or not."""
+        from monitor import is_in_queue
+        return is_in_queue(self.model)
+
+    def get_readonly_fields(self, request, obj = None):
+        """ Overridden to include protected_fields as well."""
+        if self.is_monitored() and obj is not None and obj.status == APPROVED_STATUS:
+            return self.readonly_fields + self.protected_fields
+        return self.readonly_fields
+
+    def get_actions(self, request):
+        """ For monitored models, we need 3 more actions."""
+        actions = super(MonitorAdmin, self).get_actions(request)
+        mod_perm = '%s.moderate_%s' % (
+            self.opts.app_label.lower(), self.opts.object_name.lower()
+        )
+        change_perm = mod_perm.replace('moderate', 'change')
+        if request.user.has_perm(mod_perm):
+            descr = getattr(
+                approve_selected, 'short_description', 'approve selected'
+            )
+            actions.update({
+                'approve_selected': (approve_selected, 'approve_selected', descr)
+            })
+            descr = getattr(
+                challenge_selected, 'short_description', 'challenge selected'
+            )
+            actions.update({
+                'challenge_selected': (challenge_selected, 'challenge_selected', descr)
+            })
+        if request.user.has_perm(change_perm):
+            descr = getattr(
+                reset_to_pending, 'short_description', 'reset to pending'
+            )
+            actions.update({
+                'reset_to_pending': (reset_to_pending, 'reset_to_pending', descr)
+            })
+        return actions
+
+    def has_moderate_permission(self, request):
+        """
+        Returns true if the given request has permission to moderate objects
+        of the model corresponding to this model admin.
+        """
+        mod_perm = '%s.moderate_%s' % (
+            self.opts.app_label.lower(), self.opts.object_name.lower()
+        )
+        return request.user.has_perm(mod_perm)
+

monitor/middleware.py

+try:
+    from threading import local
+except ImportError:
+    from django.utils._threading_local import local
+
+_thread_locals = local()
+
+def get_current_user():
+    return getattr(_thread_locals, 'monitor_user', None)
+
+class MonitorMiddleware(object):
+    def process_request(self, request):
+        _thread_locals.monitor_user = getattr(request, 'user', None)

monitor/models.py

+from django.db import models
+from django.contrib.auth.models import User
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.contenttypes import generic
+import datetime
+
+STATUS_CHOICES = [
+    ('IP', "In Pending"),
+    ('AP', "Approved"),
+    ('CH', "Challenged")
+]
+
+class MonitorEntryManager(models.Manager):
+    """ Custom Manager for MonitorEntry"""
+
+    def get_for_instance(self, obj):
+        ct = ContentType.objects.get_for_model(obj.__class__)
+        try:
+            mo = MonitorEntry.objects.get(content_type = ct, object_id = obj.pk)
+            return mo
+        except MonitorEntry.DoesNotExist:
+            pass
+
+class MonitorEntry(models.Model):
+    """ Each Entry will monitor the status of one moderated model object"""
+    objects = MonitorEntryManager()
+    
+    timestamp = models.DateTimeField(
+        auto_now_add = True, blank = True, null = True
+    )
+    status = models.CharField(max_length = 2, choices = STATUS_CHOICES)
+    status_by = models.ForeignKey(User, blank = True, null = True)
+    status_date = models.DateTimeField(blank = True, null = True)
+    notes = models.CharField(max_length = 100, blank = True)
+
+    content_type = models.ForeignKey(ContentType)
+    object_id = models.PositiveIntegerField()
+    content_object = generic.GenericForeignKey('content_type', 'object_id')
+
+    class Meta:
+        app_label = 'monitor'
+
+    def __unicode__(self):
+        return "[%s] %s" % (self.get_status_display(), self.content_object)
+
+    def get_absolute_url(self):
+        if hasattr(self.content_object, "get_absolute_url"):
+            return self.content_object.get_absolute_url()
+
+    def _moderate(self, status, user, notes = None):
+        self.status = status
+        self.status_by = user
+        self.status_date = datetime.datetime.now()
+        self.notes = notes
+        self.save()
+
+    def approve(self, user, notes = ''):
+        """ Approve the object"""
+        self._moderate('AP', user, notes)
+
+    def challenge(self, user, notes = ''):
+        """ Challenge the object """
+        self._moderate('CH', user, notes)
+
+    def moderate(self, status, user, notes = ''):
+        """
+        Why a separate public method?
+        To use when you're not sure about the status used
+        """
+        self._moderate(status, user, notes)
+

monitor/tests/__init__.py

+
+from .apps.testapp.tests import *
+
Add a comment to this file

monitor/tests/apps/__init__.py

Empty file added.

Add a comment to this file

monitor/tests/apps/testapp/__init__.py

Empty file added.

monitor/tests/apps/testapp/admin.py

+from django.contrib import admin
+
+from monitor.tests.apps.testapp.models import Author, Book, Supplement
+from monitor.admin import MonitorAdmin
+
+class AuthorAdmin(MonitorAdmin):
+    pass
+
+class BookAdmin(MonitorAdmin):
+    pass
+
+class SupAdmin(MonitorAdmin):
+    pass
+
+admin.site.register(Author, AuthorAdmin)
+admin.site.register(Book, BookAdmin)
+admin.site.register(Supplement, SupAdmin)
+

monitor/tests/apps/testapp/fixtures/test_monitor.json

+[
+    {
+        "pk": 1,
+        "model": "testapp.publisher",
+        "fields": {
+            "name": "Apress",
+            "num_awards": 3
+        }
+    },
+    {
+        "pk": 2,
+        "model": "testapp.publisher",
+        "fields": {
+            "name": "Sams",
+            "num_awards": 1
+        }
+    },
+    {
+        "pk": 3,
+        "model": "testapp.publisher",
+        "fields": {
+            "name": "Prentice Hall",
+            "num_awards": 7
+        }
+    },
+    {
+        "pk": 4,
+        "model": "testapp.publisher",
+        "fields": {
+            "name": "Morgan Kaufmann",
+            "num_awards": 9
+        }
+    },
+    {
+        "pk": 5,
+        "model": "testapp.publisher",
+        "fields": {
+            "name": "Jonno's House of Books",
+            "num_awards": 0
+        }
+    }
+]
+

monitor/tests/apps/testapp/models.py

+from django.db import models
+from django.contrib import admin
+import monitor
+
+class Author(models.Model):
+    """ Moderated model """
+    name = models.CharField(max_length = 100)
+    age = models.IntegerField()
+
+    class Meta:
+        app_label = 'testapp'
+
+    def __unicode__(self):
+        return self.name
+
+monitor.nq(Author)
+
+class Publisher(models.Model):
+    """ Not moderated model """
+    name = models.CharField(max_length = 255)
+    num_awards = models.IntegerField()
+
+    class Meta:
+        app_label = 'testapp'
+
+    def __unicode__(self):
+        return self.name
+
+class Book(models.Model):
+    """ Moderated model with related objects """
+    isbn = models.CharField(max_length = 9)
+    name = models.CharField(max_length = 255)
+    pages = models.IntegerField()
+    authors = models.ManyToManyField(Author)
+    publisher = models.ForeignKey(Publisher)
+    
+    class Meta:
+        app_label = 'testapp'
+
+monitor.nq(Book, ['supplements', ])
+
+class Supplement(models.Model):
+    """ Objects of this model get moderated along with Book"""
+    serial_num = models.IntegerField()
+    book = models.ForeignKey(Book, related_name = 'supplements')
+
+monitor.nq(Supplement)
+

monitor/tests/apps/testapp/tests.py

+import re
+from datetime import datetime
+
+from django.test import TestCase
+from django.contrib.auth.models import User, Permission
+from django.contrib.contenttypes.models import ContentType
+
+from monitor.tests.utils.testsettingsmanager import SettingsTestCase
+
+from monitor.tests.apps.testapp.models import Author, Book, Supplement, Publisher
+
+def get_perm(Model, perm):
+    """Return the permission object, for the Model"""
+    ct = ContentType.objects.get_for_model(Model)
+    return Permission.objects.get(content_type = ct, codename = perm)
+
+def moderate_perm_exists(Model):
+    """ Returns whether moderate permission exists for the given model or not."""
+    ct = ContentType.objects.get_for_model(Model)
+    return Permission.objects.filter(
+        content_type = ct,
+        codename = 'moderate_%s' % Model._meta.object_name.lower()
+    ).exists()
+
+class ModPermTest(SettingsTestCase):
+    """ Make sure that moderate permissions are created for required models."""
+    test_settings = 'monitor.tests.settings'
+
+    def test_perms_for_author(self):
+        """ Testing moderate_perm exists for Author..."""
+        self.assertEquals(moderate_perm_exists(Author), True)
+
+    def test_perms_for_book(self):
+        """ Testing moderate_ perm exists for Book """
+        self.assertEquals(moderate_perm_exists(Book), True)
+
+    def test_perms_for_supplement(self):
+        """ Testing moderate_ perm exists for Supplement """
+        self.assertEquals(moderate_perm_exists(Supplement), True)
+
+    def test_perms_for_publisher(self):
+        """ Testing moderate_ perm exists for Publisher """
+        self.assertEquals(moderate_perm_exists(Publisher), False)
+
+class ModTest(SettingsTestCase):
+    """ Testing Moderation facility """
+    fixtures = ['test_monitor.json']
+    test_settings = 'monitor.tests.settings'
+
+    def get_csrf_token(self, url):
+        """ Scrape CSRF token """
+        response = self.client.get(url, follow = True)
+        csrf_regex = r'csrfmiddlewaretoken\'\s+value=\'([^\']+)\''
+        return re.search(csrf_regex, response.content).groups()[0]
+    
+    def setUp(self):
+        """ Two users, adder & moderator. """
+        # Permissions
+        add_auth_perm = get_perm(Author, 'add_author')
+        mod_auth_perm = get_perm(Author, 'moderate_author')
+        add_bk_perm = get_perm(Book, 'add_book')
+        mod_bk_perm = get_perm(Book, 'moderate_book')
+        add_sup_perm = get_perm(Supplement, 'add_supplement')
+        mod_sup_perm = get_perm(Supplement, 'moderate_supplement')
+        ch_auth_perm = get_perm(Author, 'change_author')
+        ch_bk_perm = get_perm(Book, 'change_book')
+
+        self.adder = User.objects.create_user(
+            username = 'adder', email = 'adder@monitor.com',
+            password = 'adder'
+        )
+        self.adder.user_permissions = [
+            add_auth_perm, add_bk_perm, add_sup_perm, ch_auth_perm
+        ]
+        self.adder.is_staff = True
+        self.adder.save()
+        self.moderator = User.objects.create_user(
+            username = 'moder', email = 'moder@monitor.com',
+            password = 'moder'
+        )
+        self.moderator.user_permissions = [
+            add_auth_perm, add_bk_perm, add_sup_perm,
+            mod_auth_perm, mod_bk_perm, mod_sup_perm,
+            ch_auth_perm, ch_bk_perm
+        ]
+        self.moderator.is_staff = True
+        self.moderator.save()
+
+    def tearDown(self):
+        self.adder.delete()
+        self.moderator.delete()
+
+    def test_moderation(self):
+        """ 
+        Adder has permission to add only. All objects he creates are in Pending.
+        Moderator has permissions to add & moderate also. All objects he creates
+        are auto-approved.
+        """
+        # adder logs in. 
+        logged_in = self.client.login(username = 'adder', password='adder')
+        self.assertEquals(logged_in, True)
+        # Make sure that no objects are there 
+        self.assertEquals(Author.objects.count(), 0) 
+        self.assertEquals(Book.objects.count(), 0)
+        self.assertEquals(Supplement.objects.count(), 0)
+        # Adding 2 Author instances...
+        url = '/admin/testapp/author/add/'
+        # Author 1
+        data = {'age': 34, 'name': "Adrian Holovaty"}
+        response = self.client.post(url, data, follow = True)
+        # Author 2
+        data = {'age': 35, 'name': 'Jacob kaplan-Moss'}
+        response = self.client.post(url, data, follow = True)
+        self.assertEquals(response.status_code, 200)
+        # 2 Author instances added. Both are in pending (IP)
+        self.assertEquals(Author.objects.count(), 2)
+        self.assertEquals(Author.objects.get(pk=1).status, 'IP')
+        self.assertEquals(Author.objects.get(pk=2).status, 'IP')
+        # Adding 1 book instance...
+        url = '/admin/testapp/book/add/'
+        data = {
+            'publisher': 1, 'isbn': '159059725', 'name': 'Definitive', 
+            'authors': [1, 2], 'pages': 447
+        }
+        response = self.client.post(url, data, follow = True)
+        self.assertEquals(response.status_code, 200)
+        # 1 Book instance added. In pending (IP)
+        self.assertEquals(Book.objects.count(), 1)
+        self.assertEquals(Book.objects.get(pk=1).status, 'IP')
+        # Adding 2 Supplement instances
+        url = '/admin/testapp/supplement/add/'
+        data = {'serial_num': 1, 'book': 1}
+        response = self.client.post(url, data, follow = True)
+        data = {'serial_num':2, 'book':1}
+        response = self.client.post(url, data, follow = True)
+        # 2 Supplement instances added. In Pending (IP)
+        self.assertEquals(Supplement.objects.count(), 2)
+        self.assertEquals(Supplement.objects.get(pk=1).status, 'IP')
+        self.assertEquals(Supplement.objects.get(pk=2).status, 'IP')
+
+        # Adder logs out
+        self.client.logout()
+    
+        # moderator logs in. 
+        logged_in = self.client.login(username = 'moder', password = 'moder')
+        self.assertEquals(logged_in, True)
+        # Adding one more author instance...
+        url = '/admin/testapp/author/add/'
+        # Author 3
+        data = {'age': 46, 'name': "Stuart Russel"}
+        response = self.client.post(url, data, follow = True)
+        # Author 3 added. Auto-Approved (AP)
+        self.assertEquals(Author.objects.count(), 3)
+        self.assertEquals(Author.objects.get(pk=3).status, 'AP')
+        # Approve Author 1 (created by adder)
+        url = '/admin/testapp/author/'
+        data = {'action': 'approve_selected', 'index': 0, '_selected_action': 1}
+        response = self.client.post(url, data, follow = True)
+        self.assertEquals(Author.objects.get(pk=1).status, 'AP')
+        # Challenge Author 2 (created by adder)
+        data = {'action': 'challenge_selected', 'index': 0, '_selected_action': 2}
+        response = self.client.post(url, data, follow = True)
+        self.assertEquals(Author.objects.get(pk=2).status, 'CH')
+        # Approve Book 1 (created by adder). Supplements also get approved.
+        url = '/admin/testapp/book/'
+        data = {'action': 'approve_selected', 'index': 0, '_selected_action': 1}
+        response = self.client.post(url, data, follow = True)
+        self.assertEquals(Book.objects.get(pk=1).status, 'AP')
+        self.assertEquals(Supplement.objects.get(pk=1).status, 'AP')
+        self.assertEquals(Supplement.objects.get(pk=2).status, 'AP')
+
+        # moderator logs out
+        self.client.logout()
+        # adder logs in again
+        logged_in = self.client.login(username = 'adder', password = 'adder')
+        self.assertEquals(logged_in, True)
+
+        # Edit the challenged Author, author 2.
+        self.failUnlessEqual(Author.objects.get(pk=2).age, 35)
+        url = '/admin/testapp/author/2/'
+        data = {'id': 2, 'age': 53, 'name': 'Stuart Russel'}
+        response = self.client.post(url, data)
+        self.assertRedirects(response, '/admin/testapp/author/', target_status_code = 200)
+        self.failUnlessEqual(Author.objects.get(pk=2).age, 53)
+        # Reset Author 2 back to pending
+        url = '/admin/testapp/author/'
+        data = {'action': 'reset_to_pending', 'index': 0, '_selected_action': 2}
+        response = self.client.post(url, data, follow = True)
+        self.failUnlessEqual(Author.objects.get(pk=2).status, 'IP')
+

monitor/tests/apps/testapp/views.py

+# Create your views here.

monitor/tests/settings.py

+from monitor.tests.utils.testsettingsmanager import get_only_settings_locals
+
+DATABASE_ENGINE = 'sqlite3'
+
+INSTALLED_APPS = (
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.admin',
+    'django.contrib.sites',
+    'monitor',
+    'monitor.tests.apps.testapp',
+)
+
+SERIALIZATION_MODULES = {}
+
+ROOT_URLCONF = 'monitor.tests.urls'
+
+settings = get_only_settings_locals(locals().copy())

monitor/tests/urls.py

+from django.conf.urls.defaults import patterns, include, handler500
+from django.conf import settings
+from django.contrib import admin
+admin.autodiscover()
+
+handler500
+
+urlpatterns = patterns(
+    '',
+    (r'^admin/', include(admin.site.urls)),
+    (r'^media/(?P<path>.*)$', 'django.views.static.serve',
+         {'document_root': settings.MEDIA_ROOT}),
+)
+
Add a comment to this file

monitor/tests/utils/__init__.py

Empty file added.

monitor/tests/utils/request_factory.py

+"""
+RequestFactory mock class,
+snippet taken from http://www.djangosnippets.org/snippets/963/
+"""
+from django.test import Client
+from django.core.handlers.wsgi import WSGIRequest
+
+
+class RequestFactory(Client):
+    """
+    Class that lets you create mock Request objects for use in testing.
+    
+    Usage:
+    
+    rf = RequestFactory()
+    get_request = rf.get('/hello/')
+    post_request = rf.post('/submit/', {'foo': 'bar'})
+    
+    This class re-uses the django.test.client.Client interface, docs here:
+    http://www.djangoproject.com/documentation/testing/#the-test-client
+    
+    Once you have a request object you can pass it to any view function, 
+    just as if that view had been hooked up using a URLconf.
+    
+    """
+    
+    def request(self, **request):
+        """
+        Similar to parent class, but returns the request object as soon as it
+        has created it.
+        """
+        environ = {
+            'HTTP_COOKIE': self.cookies,
+            'PATH_INFO': '/',
+            'QUERY_STRING': '',
+            'REQUEST_METHOD': 'GET',
+            'SCRIPT_NAME': '',
+            'SERVER_NAME': 'testserver',
+            'SERVER_PORT': 80,
+            'SERVER_PROTOCOL': 'HTTP/1.1',
+        }
+        environ.update(self.defaults)
+        environ.update(request)
+        return WSGIRequest(environ)

monitor/tests/utils/testsettingsmanager.py

+"""
+TestSettingsManager class for making temporary changes to 
+settings for the purposes of a unittest or doctest. 
+It will keep track of the original settings and let 
+easily revert them back when you're done.
+
+Snippet taken from: http://www.djangosnippets.org/snippets/1011/
+"""
+
+from django.conf import settings
+from django.core.management import call_command
+from django.db.models import loading
+from django.test import TestCase
+from django.utils.importlib import import_module
+
+NO_SETTING = ('!', None)
+
+
+class TestSettingsManager(object):
+    """
+    A class which can modify some Django settings temporarily for a
+    test and then revert them to their original values later.
+
+    Automatically handles resyncing the DB if INSTALLED_APPS is
+    modified.
+
+    """
+    
+    def __init__(self):
+        self._original_settings = {}
+
+    def set(self, **kwargs):
+        for k, v in kwargs.iteritems():
+            self._original_settings.setdefault(k, getattr(settings, k,
+                                                          NO_SETTING))
+            setattr(settings, k, v)
+        if 'INSTALLED_APPS' in kwargs:
+            self.syncdb()
+
+    def syncdb(self):
+        loading.cache.loaded = False
+        call_command('syncdb', verbosity=0)
+
+    def revert(self):
+        for k, v in self._original_settings.iteritems():
+            if v == NO_SETTING:
+                try:
+                    delattr(settings, k)
+                except AttributeError:
+                    pass
+            else:
+                setattr(settings, k, v)
+        if 'INSTALLED_APPS' in self._original_settings:
+            self.syncdb()
+        self._original_settings = {}
+
+
+class SettingsTestCase(TestCase):
+    """
+    A subclass of the Django TestCase with a settings_manager
+    attribute which is an instance of TestSettingsManager.
+
+    """
+    test_settings = ''
+    
+    def __init__(self, *args, **kwargs):
+        super(SettingsTestCase, self).__init__(*args, **kwargs)
+        self.settings_manager = TestSettingsManager()
+        
+    def _pre_setup(self):
+        if self.test_settings:
+            settings_module = import_module(self.test_settings)
+            self.settings_manager.set(**settings_module.settings)
+        super(SettingsTestCase, self)._pre_setup()
+    
+    def _post_teardown(self):
+        if self.test_settings:
+            self.settings_manager.revert()
+        
+        super(SettingsTestCase, self)._post_teardown()
+
+
+def get_only_settings_locals(locals):
+    for key in locals.keys():
+        if key.islower():
+            del locals[key]
+    return locals
+
+from monitor import _queue
+from monitor.models import MonitorEntry
+
+def moderate_rel_objects(given, status, user = None):
+    """
+    `given` can either be any model object or a queryset. Moderate given
+    object(s) and all specified related objects.
+    TODO: Permissions must be checked before each iteration.
+    """
+    # Not sure how we can find whether `given` is a queryset or object.
+    # Now assume `given` is a queryset/related_manager if it has 'all'
+    if not given:
+        # given may become None. Stop there.
+        return
+    if hasattr(given, 'all'):
+        qset = given.all()
+        for obj in qset:
+            me = MonitorEntry.objects.get_for_instance(obj)
+            me.moderate(status, user)
+            rel_fields = _queue[qset.model]['rel_fields']
+            for rel_name in rel_fields:
+                moderate_rel_objects(getattr(obj, rel_name), status, user)
+    else:
+        me = MonitorEntry.objects.get_for_instance(given)
+        me.moderate(status, user)
+        rel_fields = _queue[given._meta.model]['rel_fields']
+        for rel_name in rel_fields:
+            moderate_rel_objects(getattr(given, rel_name), status, user)
+
+# Create your views here.
+from setuptools import setup, find_packages
+import os
+
+version = '0.1'
+
+setup(
+    name = 'django-monitor',
+    version = version,
+    description = "Django app to moderate model objects",
+    long_description = open("README.rst").read(),
+    classifiers = [
+        'Development Status :: 3 - Alpha',
+        'Environment :: Web Environment',
+        'Intended Audience :: Developers',
+        'License :: OSI Approved :: BSD License',
+        'Operating System :: OS Independent',
+        'Programming Language :: Python',
+        'Framework :: Django',
+    ],
+    keywords = 'django moderation models',
+    author = 'Rajeesh Nair',
+    author_email = 'rajeeshrnair@gmail.com',
+    url = 'http://bitbucket.org/rajeesh/django-monitor',
+    license = 'BSD',
+    packages = find_packages('monitor'),
+    package_dir = {'': 'monitor'},
+    include_package_data = True,
+    install_requires=[
+        'setuptools',
+    ],
+    zip_safe = True,
+)
+
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.