Rajeesh Nair avatar Rajeesh Nair committed abac1b1

Django-monitor: Source directory name changed as django_monitor.

* Renamed the source directory as ``django_monitor``. We know that
it is unusual to rename the source root directory like this. It
may break many existing things but the name change is essential
for us to make it available over pypi etc. So we follow, the zen
of python: ``Now is better than never``.

Comments (0)

Files changed (47)

 * Download the latest version from the bitbucket repo:
   http://bitbucket.org/rajeesh/django-monitor.
 
-* Copy the ``monitor`` directory to some place in your python path.
+* Copy the ``django_monitor`` directory to some place in your python path.
 
-* Add to your django project by including 'monitor' in settings.INSTALLED_APPS.
+* Add 'django_monitor' to your project's ``settings.INSTALLED_APPS``.
 
 Features
 =========
 
   **Example**: ::
 
-    import monitor
+    import django_monitor
     from django.db import models
     class MyModel(models.Model):
         pass
 
-    monitor.nq(MyModel)
+    django_monitor.nq(MyModel)
 
 * Inherit ``MonitorAdmin``, not ``ModelAdmin`` for moderated models. ::
 
     # in your admin.py
-    from monitor.admin import MonitorAdmin
+    from django_monitor.admin import MonitorAdmin
     class MyAdmin(MonitorAdmin):
         pass
 

django_monitor/__init__.py

+__author__ = "Rajeesh Nair"
+__version__ = "0.1.5a"
+__copyright__ = "Copyright (c) 2011 Rajeesh"
+__license__ = "BSD"
+
+from django.dispatch import Signal
+from django.db.models import signals
+from django.db.models.loading import get_model
+
+from django_monitor.util import (
+    create_moderate_perms, add_fields, save_handler, delete_handler
+)
+
+_queue = {}
+
+def model_from_queue(model):
+    """ Returns the model dict if model is enqueued, else None."""
+    return _queue.get(model, None)
+
+def queued_models():
+    """ Return the models enqueued for moderation"""
+    return _queue.keys()
+
+def get_monitor_entry(obj):
+    """
+    Returns the monitor_entry for the given object.
+    Deprecated.
+    No one except the given object need access to the monitor_entry.
+    """
+    model_dict = model_from_queue(obj.__class__)
+    return getattr(obj, model_dict['monitor_name']) if model_dict else None
+
+def nq(
+    model, rel_fields = [], can_delete_approved = True,
+    manager_name = 'objects', status_name = 'status',
+    monitor_name = 'monitor_entry', base_manager = None
+):
+    """ Register(enqueue) the model for moderation."""
+    if not model_from_queue(model):
+        signals.post_save.connect(save_handler, sender = model)
+        signals.pre_delete.connect(delete_handler, sender = model)
+        registered_model = get_model(
+            model._meta.app_label, model._meta.object_name, False
+        )
+        add_fields(
+            registered_model, manager_name, status_name,
+            monitor_name, base_manager
+        )
+        _queue[model] = {
+            'rel_fields': rel_fields,
+            'can_delete_approved': can_delete_approved,
+            'manager_name': manager_name,
+            'status_name': status_name,
+            'monitor_name': monitor_name
+        }
+
+post_moderation = Signal(providing_args = ["instance"])
+
+signals.post_syncdb.connect(
+    create_moderate_perms,
+    dispatch_uid = "django-monitor.create_moderate_perms"
+)
+

django_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 django_monitor.util import moderate_rel_objects
+from django_monitor import model_from_queue
+from django_monitor.conf import (
+    STATUS_DICT, PENDING_STATUS, APPROVED_STATUS, CHALLENGED_STATUS
+)
+from django_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 model_from_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 == PENDING_STATUS and
+            not modeladmin.has_change_permission(request)
+        ) or 
+        (status != PENDING_STATUS 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 = STATUS_DICT[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, APPROVED_STATUS)
+    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, CHALLENGED_STATUS)
+    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, PENDING_STATUS)
+    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"
+)
+

django_monitor/admin.py

+
+from django.contrib.contenttypes.models import ContentType
+from django.contrib import admin
+from django.contrib.admin.filterspecs import FilterSpec
+from django.shortcuts import render_to_response
+from django.utils.functional import update_wrapper
+from django.template import RequestContext
+from django.utils.safestring import mark_safe
+
+from django_monitor.actions import (
+    approve_selected, challenge_selected, reset_to_pending
+)
+from django_monitor.filter import MonitorFilter
+from django_monitor import model_from_queue, queued_models
+from django_monitor.conf import (
+    PENDING_STATUS, CHALLENGED_STATUS, APPROVED_STATUS,
+    PENDING_DESCR, CHALLENGED_DESCR
+)
+from django_monitor.models import MonitorEntry
+
+# Our objective is to place the custom monitor-filter on top
+FilterSpec.filter_specs.insert(
+    0, (lambda f: getattr(f, 'monitor_filter', False), MonitorFilter)
+)
+
+
+class MEAdmin(admin.ModelAdmin):
+    """
+    A special admin-class for aggregating moderation summary, not to let users
+    add/edit/delete MonitorEntry objects directly. MonitorEntry works from
+    behind the curtain. This admin class is to provide a single stop for users
+    to get notified about pending/challenged model objects.
+    """
+    change_list_template = 'admin/django_monitor/monitorentry/change_list.html'
+
+    def get_urls(self):
+        """ The only url allowed is that for changelist_view. """
+        from django.conf.urls.defaults import patterns, url
+
+        def wrap(view):
+            def wrapper(*args, **kwargs):
+                return self.admin_site.admin_view(view)(*args, **kwargs)
+            return update_wrapper(wrapper, view)
+
+        info = self.model._meta.app_label, self.model._meta.module_name
+
+        urlpatterns = patterns('',
+            url(r'^$',
+                wrap(self.changelist_view),
+                name = '%s_%s_changelist' % info
+            ),
+        )
+        return urlpatterns
+
+    def has_add_permission(self, request, obj = None):
+        """ Returns False so that no add button is displayed in admin index"""
+        return False
+
+    def has_change_permission(self, request, obj = None):
+        """
+        Users will be lead to the moderation summary page when they click on
+        the link for changelist, which has a url like,
+        ``/admin/django_monitor/monitorentry/``. The admin site index page will show
+        the link to user, only if they have change_permission. So lets grant
+        that perm to all admin-users.
+        """
+        if obj is None and request.user.is_active and request.user.is_staff:
+            return True
+        return super(MEAdmin, self).has_change_permission(request, obj)
+
+    def changelist_view(self, request, extra_context = None):
+        """
+        The 'change list' admin view is overridden to return a page showing the
+        moderation summary aggregated for each model.
+        """
+        query_set = self.queryset(request)
+        model_list = []
+        for model in queued_models():
+            c_type = ContentType.objects.get_for_model(model)
+            q_set = query_set.filter(content_type = c_type)
+            ip_count = q_set.filter(status = PENDING_STATUS).count()
+            ch_count = q_set.filter(status = CHALLENGED_STATUS).count() 
+            app_label = model._meta.app_label
+            if ip_count or ch_count:
+                model_list.append({
+                    'model_name': model._meta.verbose_name,
+                    'app_name': app_label.title(),
+                    'pending': ip_count, 'challenged': ch_count,
+                    'admin_url': mark_safe(
+                        '/admin/%s/%s/' % (app_label, model.__name__.lower())
+                    ),
+                })
+        model_list.sort(key = lambda x: (x['app_name'], x['model_name']))
+        return render_to_response(
+            self.change_list_template,
+            {
+                'model_list': model_list,
+                'ip_status': PENDING_STATUS, 'ip_descr': PENDING_DESCR,
+                'ch_status': CHALLENGED_STATUS, 'ch_descr': CHALLENGED_DESCR
+            },
+            context_instance = RequestContext(request)
+        )
+
+admin.site.register(MonitorEntry, MEAdmin)
+
+class MonitorAdmin(admin.ModelAdmin):
+    """ModelAdmin for monitored models should inherit this."""
+
+    # Which fields are to be made readonly after approval.
+    protected_fields = ()
+
+    def __init__(self, model, admin_site):
+        """ Overridden to add a custom filter to list_filter """
+        super(MonitorAdmin, self).__init__(model, admin_site)
+        self.list_filter = ['id'] + list(self.list_filter)
+        self.list_display = (
+            list(self.list_display) + ['get_monitor_status_display']
+        )
+
+    def queryset(self, request):
+        """
+        Django does not allow using non-fields in list_filter. (As of 1.3).
+        Using params not mentioned in list_filter will raise error in changelist.
+        We want to enable status based filtering (status is not a db_field).
+        We will check the request.GET here and if there's a `status` in it,
+        Remove that and filter the qs by status.
+        """
+        qs = super(MonitorAdmin, self).queryset(request)
+        status = request.GET.get('status', None)
+        # status is not among list_filter entries. So its presence will raise
+        # IncorrectLookupParameters when django tries to build-up changelist.
+        # We no longer need that param after leaving here. So let's remove it.
+        if status:
+            get_dict = request.GET.copy()
+            del get_dict['status']
+            request.GET = get_dict
+        # ChangeList will use this custom queryset. So we've done it!
+        if status and status == PENDING_STATUS:
+            qs = qs.pending()
+        elif status and status == CHALLENGED_STATUS:
+            qs = qs.challenged()
+        elif status and status == APPROVED_STATUS:
+            qs = qs.approved()
+        return qs
+
+    def is_monitored(self):
+        """Returns whether the underlying model is monitored or not."""
+        return bool(model_from_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.is_approved
+        ):
+            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)
+
+    def has_delete_permission(self, request, obj = None):
+        """
+        If ``can_delete_approved`` is set to False in moderation queue and
+        the given object is approved, this will return False. Otherwise,
+        this behaves the same way as the parent class method does.
+        """
+        model = model_from_queue(self.model)
+        if (
+            model and (not model['can_delete_approved']) and
+            obj is not None and obj.is_approved
+        ):
+            return False
+        return super(MonitorAdmin, self).has_delete_permission(request, obj)
+

django_monitor/conf.py

+"""
+All status labels defined here.
+"""
+PENDING_STATUS = 'IP'
+APPROVED_STATUS = 'AP'
+CHALLENGED_STATUS = 'CH'
+
+PENDING_DESCR = "In Pending"
+APPROVED_DESCR = "Approved"
+CHALLENGED_DESCR = "Challenged"
+
+STATUS_DICT = {
+    PENDING_STATUS: PENDING_DESCR,
+    APPROVED_STATUS: APPROVED_DESCR,
+    CHALLENGED_STATUS: CHALLENGED_DESCR
+}
+

django_monitor/filter.py

+from django.contrib.admin.filterspecs import ChoicesFilterSpec
+from django.utils.encoding import smart_unicode
+from django.utils.translation import ugettext_lazy as _
+from django_monitor.conf import STATUS_DICT
+
+class MonitorFilter(ChoicesFilterSpec):
+    """
+    A custom filterspec to enable filter by monitor-status.
+    Django development version has changes in store to break this!
+    """
+    def __init__(
+        self, f, request, params, model, model_admin, field_path = None
+    ):
+        ChoicesFilterSpec.__init__(
+            self, f, request, params, model, model_admin, field_path
+        )
+        self.lookup_kwarg = 'status'
+        self.lookup_val = request.GET.get(self.lookup_kwarg)
+        self.lookup_choices = STATUS_DICT.keys()
+        
+    def choices(self, cl):
+        yield {
+            'selected': self.lookup_val is None,
+            'query_string': cl.get_query_string({}, [self.lookup_kwarg]),
+            'display': _('All')
+        }
+        for val in self.lookup_choices:
+            yield {
+                'selected': smart_unicode(val) == self.lookup_val,
+                'query_string': cl.get_query_string({self.lookup_kwarg: val}),
+                'display': STATUS_DICT[val]
+            }
+
+    def title(self):
+        """ The title displayed above the filter"""
+        return _("Moderation status")
+

django_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)

django_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
+
+from django_monitor.conf import (
+    STATUS_DICT, PENDING_STATUS, APPROVED_STATUS, CHALLENGED_STATUS
+)
+STATUS_CHOICES = STATUS_DICT.items()
+
+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 = 'django_monitor'
+        verbose_name = 'moderation Queue'
+        verbose_name_plural = 'moderation Queue'
+
+    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 = ''):
+        from django_monitor import post_moderation
+        self.status = status
+        self.status_by = user
+        self.status_date = datetime.datetime.now()
+        self.notes = notes
+        self.save()
+        # post_moderation signal will be generated now with the associated
+        # object as the ``instance`` and its model as the ``sender``.
+        sender_model = self.content_type.model_class()
+        instance = self.content_object
+        post_moderation.send(sender = sender_model, instance = instance)
+
+    def approve(self, user = None, notes = ''):
+        """Deprecated. Approve the object"""
+        self._moderate(APPROVED_STATUS, user, notes)
+
+    def challenge(self, user = None, notes = ''):
+        """Deprectaed. Challenge the object """
+        self._moderate(CHALLENGED_STATUS, user, notes)
+
+    def reset_to_pending(self, user = None, notes = ''):
+        """Deprecated. Reset status from Challenged to pending"""
+        self._moderate(PENDING_STATUS, user, notes)
+
+    def moderate(self, status, user = None, notes = ''):
+        """
+        Why a separate public method?
+        To use when you're not sure about the status given
+        """
+        if status in STATUS_DICT.keys():
+            self._moderate(status, user, notes)
+
+    def is_approved(self):
+        """ Deprecated"""
+        return self.status == APPROVED_STATUS
+
+    def is_pending(self):
+        """ Deprecated."""
+        return self.status == PENDING_STATUS
+
+    def is_challenged(self):
+        """ Deprecated."""
+        return self.status == CHALLENGED_STATUS
+
+MONITOR_TABLE = MonitorEntry._meta.db_table
+

django_monitor/templates/admin/django_monitor/monitorentry/change_list.html

+{% extends "admin/base_site.html" %}
+{% load adminmedia admin_list i18n %}
+{% load url from future %}
+{% block extrastyle %}
+  {{ block.super }}
+  <link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/changelists.css" />
+  {{ media.css }}
+    <style>
+      #changelist table thead th:first-child {width: inherit}
+    </style>
+{% endblock %}
+
+{% block extrahead %}
+{{ block.super }}
+{{ media.js }}
+{% endblock %}
+
+{% block bodyclass %}change-list{% endblock %}
+
+  {% block breadcrumbs %}
+    <div class="breadcrumbs">
+      <a href="../../">
+        {% trans "Home" %}
+      </a>
+       &rsaquo;
+       <a href="../">
+         Monitor
+      </a>
+      &rsaquo;
+      Moderation Queue
+    </div>
+  {% endblock %}
+
+{% block coltype %}flex{% endblock %}
+
+{% block content %}
+  <div id="content-main">
+    <div>
+      {% block result_list %}
+      <table width = "100%" class="module" id="changelist">
+      <caption>Moderation Queue</caption>
+      <thead>
+      <tr>
+            <th>App</th><th>Model</th>
+            <th>{{ ip_descr }}</th><th>{{ ch_descr }}</th></tr>
+      </thead>
+      {% for model in model_list %}
+      <tr class="{% cycle row1,row2 %}">
+          <td>{{ model.app_name }}</td>
+          <td>{{ model.model_name|capfirst }}</td>
+          <td>{% if model.pending %}
+                  <a href="{{ model.admin_url }}?status={{ ip_status }}">{{ model.pending }}</a>
+              {% else %}
+                  &nbsp;
+              {% endif %}
+          </td>
+          <td>{% if model.challenged %}
+                  <a href="{{ model.admin_url }}?status={{ ch_status }}">{{ model.challenged }}</a>
+              {% else %}
+                  &nbsp;
+              {% endif %}
+          </td>
+      </tr>
+      {% empty %}
+          <tr class="row1"><th span="row">No pending/challenged objects in queue.</th>
+          <th>&nbsp;</th><th>&nbsp;</th><th>&nbsp;</th>
+          </tr>
+      {% endfor %}
+      </table>
+      {% endblock %}
+    </div>
+  </div>
+{% endblock %}
+

django_monitor/tests/__init__.py

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

django_monitor/tests/apps/__init__.py

Empty file added.

Add a comment to this file

django_monitor/tests/apps/testapp/__init__.py

Empty file added.

django_monitor/tests/apps/testapp/admin.py

+from django.contrib import admin
+
+from django_monitor.tests.apps.testapp.models import (
+    Author, Book, EBook, Supplement, Publisher
+)
+from django_monitor.admin import MonitorAdmin
+
+class AuthorAdmin(MonitorAdmin):
+    """ Monitored model. So the admin inherited from MonitorAdmin."""
+    list_display = ('__unicode__',)
+
+class SuppInline(admin.TabularInline):
+    model = Supplement
+    fk_name = 'book'
+    extra = 2
+
+class BookAdmin(MonitorAdmin):
+    inlines = [SuppInline,]
+
+class EBookAdmin(MonitorAdmin):
+    inlines = [SuppInline,]
+
+class PubAdmin(admin.ModelAdmin):
+    """ Model not monitored. Use the built-in admin"""
+    pass
+
+admin.site.register(Author, AuthorAdmin)
+admin.site.register(Book, BookAdmin)
+admin.site.register(EBook, EBookAdmin)
+admin.site.register(Publisher, PubAdmin)
+

django_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
+        }
+    }
+]
+

django_monitor/tests/apps/testapp/models.py

+from django.db import models
+from django.contrib import admin
+import django_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
+
+django_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 WebPub(Publisher):
+    """ To check something with subclassed models """
+    pass
+
+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'
+
+    def __unicode__(self):
+        return self.name
+
+django_monitor.nq(Book, ['supplements', ])
+
+class EBook(Book):
+    """ Subclassing a moderated model """
+    pass
+
+django_monitor.nq(EBook, ['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')
+
+    def __unicode__(self):
+        return 'Supplement %s to %s' % (self.serial_num, self.book)
+
+django_monitor.nq(Supplement)
+

django_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 django_monitor.conf import (
+    PENDING_STATUS, CHALLENGED_STATUS, APPROVED_STATUS
+)
+from django_monitor.tests.utils.testsettingsmanager import SettingsTestCase
+from django_monitor.tests.apps.testapp.models import (
+    Author, Book, EBook, 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 = 'django_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 = 'django_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')
+        ch_auth_perm = get_perm(Author, 'change_author')
+        mod_auth_perm = get_perm(Author, 'moderate_author')
+        add_bk_perm = get_perm(Book, 'add_book')
+        ch_bk_perm = get_perm(Book, 'change_book')
+        mod_bk_perm = get_perm(Book, 'moderate_book')
+        add_ebk_perm = get_perm(EBook, 'add_ebook')
+        ch_ebk_perm = get_perm(EBook, 'change_ebook')
+        mod_ebk_perm = get_perm(EBook, 'moderate_ebook')
+        add_sup_perm = get_perm(Supplement, 'add_supplement')
+        mod_sup_perm = get_perm(Supplement, 'moderate_supplement')
+
+        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_ebk_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_ebk_perm, add_sup_perm,
+            mod_auth_perm, mod_bk_perm, mod_ebk_perm, mod_sup_perm,
+            ch_auth_perm, ch_bk_perm, ch_ebk_perm
+        ]
+        self.moderator.is_staff = True
+        self.moderator.save()
+
+    def tearDown(self):
+        self.adder.delete()
+        self.moderator.delete()
+
+    def test_1_check_additional_fields(self):
+        """
+        monitor puts some additional attrs to each moderated class.
+        Let's check for their existence.
+        """
+        import django_monitor
+        qd_book = django_monitor.model_from_queue(Book)
+        monitor_name = qd_book['monitor_name']
+        status_name = qd_book['status_name']
+        self.assertEquals(hasattr(Book, monitor_name), True) 
+        self.assertEquals(hasattr(Book, 'monitor_status'), True)
+        self.assertEquals(hasattr(Book, status_name), True)
+        self.assertEquals(hasattr(Book, 'get_monitor_status_display'), True)
+        self.assertEquals(hasattr(Book, 'moderate'), True)
+        self.assertEquals(hasattr(Book, 'approve'), True)
+        self.assertEquals(hasattr(Book, 'challenge'), True)
+        self.assertEquals(hasattr(Book, 'reset_to_pending'), True)
+        self.assertEquals(hasattr(Book, 'is_approved'), True)
+        self.assertEquals(hasattr(Book, 'is_challenged'), True)
+        self.assertEquals(hasattr(Book, 'is_pending'), True)
+        # monitor has changed the default manager, ``objects`` too.
+        self.assertEquals(
+            str(Book.objects)[:34], '<django_monitor.util.CustomManager'
+        )
+
+    def test_2_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).is_pending, True)
+        self.assertEquals(Author.objects.get(pk=2).is_pending, True)
+        # Adding 1 book instance with 2 supplements...
+        url = '/admin/testapp/book/add/'
+        data = {
+            'publisher': 1, 'isbn': '159059725', 'name': 'Definitive', 
+            'authors': [1, 2], 'pages': 447,
+            'supplements-TOTAL_FORMS': 2, 'supplements-INITIAL_FORMS': 0,
+            'supplements-0-serial_num': 1, 'supplements-1-serial_num': 2,
+        }
+        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).is_pending, True)
+        # 2 Supplement instances added. In Pending (IP)
+        self.assertEquals(Supplement.objects.count(), 2)
+        self.assertEquals(Supplement.objects.get(pk=1).is_pending, True)
+        self.assertEquals(Supplement.objects.get(pk=2).is_pending, True)
+
+        # 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).is_approved, True)
+        # 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).is_approved, True)
+        # 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).is_approved, False)
+        self.assertEquals(Author.objects.get(pk=2).is_challenged, True)
+        # 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).is_approved, True)
+        self.assertEquals(Supplement.objects.get(pk=1).is_approved, True)
+        self.assertEquals(Supplement.objects.get(pk=2).is_approved, True)
+
+        # 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).is_pending, True)
+
+    def test_3_moderate_parents_from_shell(self):
+        """
+        Moderate instance of a sub-class model. Parents also must be moderated.
+        Run from the shell.
+        """
+        pub1 = Publisher.objects.create(
+            name = 'test_pub', num_awards = 3
+        )
+        auth1 = Author.objects.create(
+           name = 'test_auth', age = 34
+        )
+        eb1 = EBook.objects.create(
+            isbn='123456789', name='test_ebook', pages=300, publisher = pub1
+        )
+        eb1.authors = [auth1]
+        eb1.save()
+        # The parent instance book_ptr is available
+        book_ptr = getattr(eb1, 'book_ptr', None)
+        self.assertEquals(book_ptr is None, False)
+        # Both are in pending now
+        self.assertEquals(eb1.is_pending, True)
+        self.assertEquals(book_ptr.is_pending, True)
+        # Approve eb1.
+        eb1.approve()
+        # Load again from db. Else, cached value may be used.
+        eb1 = EBook.objects.get(pk = 1)
+        self.assertEquals(eb1.is_approved, True)
+        self.assertEquals(eb1.book_ptr.is_approved, True)
+
+    def test_4_moderate_parents_from_browser(self):
+        """
+        Moderate instance of a sub-class model. Parents also must be monitored.
+        User makes requests from a browser.
+        """
+        # The adder starts it as usual...
+        logged_in = self.client.login(username = 'adder', password = 'adder')
+        self.assertEquals(logged_in, True)
+
+        pub1 = Publisher.objects.create(
+            name = 'test_pub', num_awards = 3
+        )
+        auth1 = Author.objects.create(
+           name = 'test_auth', age = 34
+        )
+        url = '/admin/testapp/ebook/add/'
+        data = {
+            'publisher': 1, 'isbn': '159059725', 'name': 'Definitive', 
+            'authors': [1,], 'pages': 447,
+            'supplements-TOTAL_FORMS': 0, 'supplements-INITIAL_FORMS': 0,
+        }
+        response = self.client.post(url, data, follow = True)
+        self.assertEquals(response.status_code, 200)
+        # 1 EBook instance added. In pending (IP)
+        self.assertEquals(EBook.objects.count(), 1)
+        self.assertEquals(EBook.objects.get(pk=1).is_pending, True)
+        # 1 Book instance also added as parent. In Pending
+        self.assertEquals(Book.objects.count(), 1)
+        self.assertEquals(Book.objects.get(pk=1).is_pending, True)
+        # The Book instance is pointed by book_ptr in EBook instance
+        eb1 = EBook.objects.get(pk=1)
+        self.assertEquals(getattr(eb1, 'book_ptr', None) is None, False)
+
+        # Now moderator try moderation by http_request
+        self.client.logout()
+        logged_in = self.client.login(username = 'moder', password = 'moder')
+        self.assertEquals(logged_in, True)
+        url = '/admin/testapp/ebook/'
+        data = {'action': 'approve_selected', 'index': 0, '_selected_action': 1}
+        response = self.client.post(url, data, follow = True)
+        self.assertEquals(response.status_code, 200)
+        eb1 = EBook.objects.get(pk=1)
+        self.assertEquals(eb1.is_approved, True)
+        self.assertEquals(eb1.book_ptr.is_approved, True)
+
+    def test_5_auto_delete_monitor_entries(self):
+        """
+        When an instance of a moderated model is deleted, the monitor entries
+        corresponding to the instance and all of its parent instances also
+        should be deleted.
+        """
+        from django_monitor.models import MonitorEntry
+
+        self.assertEquals(MonitorEntry.objects.count(), 0)
+        ebook_ct = ContentType.objects.get_for_model(EBook)
+        book_ct = ContentType.objects.get_for_model(Book)
+
+        pub1 = Publisher.objects.create(
+            name = 'test_pub', num_awards = 3
+        )
+        auth1 = Author.objects.create(
+           name = 'test_auth', age = 34
+        )
+        eb1 = EBook.objects.create(
+            isbn='123456789', name='test_ebook', pages=300, publisher = pub1
+        )
+        eb1.authors = [auth1]
+        eb1.save()
+
+        # 1 monitor_entry each for auth1, eb1 & its parent, eb1.book_ptr.
+        self.assertEquals(MonitorEntry.objects.count(), 3)
+        ebook_mes = MonitorEntry.objects.filter(content_type = ebook_ct)
+        book_mes = MonitorEntry.objects.filter(content_type = book_ct)
+        self.assertEquals(ebook_mes.count(), 1)
+        self.assertEquals(book_mes.count(), 1)
+
+        eb1.delete()
+        # MonitorEntry object for auth1 remains. Others removed.
+        self.assertEquals(MonitorEntry.objects.count(), 1)
+        ebook_mes = MonitorEntry.objects.filter(content_type = ebook_ct)
+        book_mes = MonitorEntry.objects.filter(content_type = book_ct)
+        self.assertEquals(ebook_mes.count(), 0)
+        self.assertEquals(book_mes.count(), 0) 
+

django_monitor/tests/settings.py

+from django_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',
+    'django_monitor',
+    'django_monitor.tests.apps.testapp',
+)
+
+SERIALIZATION_MODULES = {}
+
+ROOT_URLCONF = 'django_monitor.tests.urls'
+
+settings = get_only_settings_locals(locals().copy())

django_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

django_monitor/tests/utils/__init__.py

Empty file added.

django_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)

django_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

django_monitor/util.py

+
+from datetime import datetime
+
+from django.contrib.contenttypes.models import ContentType
+from django.db.models import Manager
+
+from django_monitor.middleware import get_current_user
+from django_monitor.models import MonitorEntry, MONITOR_TABLE
+from django_monitor.conf import (
+    STATUS_DICT, PENDING_STATUS, APPROVED_STATUS, CHALLENGED_STATUS
+)
+
+def create_moderate_perms(app, created_models, verbosity, **kwargs):
+    """ This will create moderate permissions for all registered models"""
+    from django.contrib.auth.models import Permission
+
+    from django_monitor import queued_models
+
+    for model in queued_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
+
+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 __getattr__(self, attr):
+            """ Try to get the rest of attributes from queryset """
+            try:
+                return getattr(self, attr)
+            except AttributeError:
+                return getattr(self.get_query_set(), attr)
+
+    def _get_monitor_status(self):
+        """
+        Accessor for monitor_status.
+        To be added to the model as a property, ``monitor_status``.
+        """
+        if not hasattr(self, '_status'):
+            return getattr(self, monitor_name).status
+        return self._status
+
+    def _get_monitor_entry(self):
+        """ accessor for monitor_entry that caches the object """
+        if not hasattr(self, '_monitor_entry'):
+            self._monitor_entry = MonitorEntry.objects.get_for_instance(self)
+        return self._monitor_entry
+
+    def _get_status_display(self):
+        """ to display the moderation status in verbose """
+        return STATUS_DICT[self.monitor_status]
+    _get_status_display.short_description = status_name
+
+    def moderate(self, status, user = None, notes = ''):
+        """ developers may use this to moderate objects """
+        import django_monitor
+        getattr(self, monitor_name).moderate(status, user, notes)
+        # Auto-Moderate parents also
+        monitored_parents = filter(
+            lambda x: django_monitor.model_from_queue(x),
+            self._meta.parents.keys()
+        )
+        for parent in monitored_parents:
+            parent_ct = ContentType.objects.get_for_model(parent)
+            parent_pk_field = self._meta.get_ancestor_link(parent)
+            parent_pk = getattr(self, parent_pk_field.attname)
+            me = MonitorEntry.objects.get(
+                content_type = parent_ct, object_id = parent_pk
+            )
+            me.moderate(status, user)
+
+    def approve(self, user = None, notes = ''):
+        """ Approve the object & its parents."""
+        self.moderate(APPROVED_STATUS, user, notes)
+
+    def challenge(self, user = None, notes = ''):
+        """Challenge"""
+        self.moderate(CHALLENGED_STATUS, user, notes)
+
+    def reset_to_pending(self, user = None, notes = ''):
+        """Reset"""
+        self.moderate(PENDING_STATUS, user, notes)
+
+    def is_approved(self):
+        return self.monitor_status == APPROVED_STATUS
+
+    def is_pending(self):
+        return self.monitor_status == PENDING_STATUS
+
+    def is_challenged(self):
+        return self.monitor_status == CHALLENGED_STATUS
+
+    # 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('monitor_status', property(_get_monitor_status)) 
+    cls.add_to_class(status_name, lambda self: self.monitor_status)
+    cls.add_to_class(
+        'get_monitor_status_display', _get_status_display
+    )
+    cls.add_to_class('moderate', moderate)
+    cls.add_to_class('approve', approve)
+    cls.add_to_class('challenge', challenge)
+    cls.add_to_class('reset_to_pending', reset_to_pending)
+    cls.add_to_class('is_approved', property(is_approved))
+    cls.add_to_class('is_challenged', property(is_challenged))
+    cls.add_to_class('is_pending', property(is_pending))
+    # We have a custom filter defined in django_monitor.filter to enable
+    # filtering of model objects by their moderation status.
+    # But `status` is not a real field and Django does not support filters
+    # on non-fields as of now. Our way out is to attach the filter to some
+    # other field which the developer may never include in ``list_filter``.
+
+    # Used ``pk`` before but subclassed models raise FieldDoesNotExist here.
+    # So let's use ``id``. Latest Django dev-version has undergone changes to
+    # allow non-fields. So this hack must be for a short period of time.
+    cls._meta.get_field('id').monitor_filter = True
+
+    # Copy manager to default_class
+    cls._default_manager = manager
+
+def save_handler(sender, instance, **kwargs):
+    """
+    The following things are done after creating an object in moderated class:
+    1. Creates monitor entries for object and its parents.
+    2. Auto-approves object, its parents & specified related objects if user 
+       has ``moderate`` permission. Otherwise, they are put in pending.
+    """
+    import django_monitor
+    # 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
+
+    # Create corresponding monitor entry
+    if kwargs.get('created', None):
+        MonitorEntry.objects.create(
+            status = status, status_by = user,
+            content_object = instance,
+            timestamp = datetime.now()
+        )
+
+        # Create one monitor_entry per moderated parent.
+        monitored_parents = filter(
+            lambda x: django_monitor.model_from_queue(x),
+            instance._meta.parents.keys()
+        )
+        for parent in monitored_parents:
+            parent_ct = ContentType.objects.get_for_model(parent)
+            parent_pk_field = instance._meta.get_ancestor_link(parent)
+            parent_pk = getattr(instance, parent_pk_field.attname)
+            try:
+                me = MonitorEntry.objects.get(
+                    content_type = parent_ct, object_id = parent_pk
+                )
+            except MonitorEntry.DoesNotExist:
+                me = MonitorEntry(
+                    content_type = parent_ct, object_id = parent_pk,
+                )
+            me.moderate(status, user)
+
+        # Moderate related objects too... 
+        model = django_monitor.model_from_queue(instance.__class__)
+        if model:
+            for rel_name in model['rel_fields']:
+                rel_obj = getattr(instance, rel_name, None)
+                if rel_obj:
+                    moderate_rel_objects(rel_obj, status, user)
+
+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.
+    """
+    from django_monitor import model_from_queue
+    # 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:
+            obj.moderate(status, user)
+            model = model_from_queue(qset.model)
+            if model:
+                for rel_name in model['rel_fields']:
+                    rel_obj = getattr(obj, rel_name, None)
+                    if rel_obj:
+                        moderate_rel_objects(rel_obj, status, user)
+    else:
+        given.moderate(status, user)
+        model = model_from_queue(given.__class__)
+        if model:
+            for rel_name in model['rel_fields']:
+                rel_obj = getattr(given, rel_name, None)
+                if rel_obj:
+                    moderate_rel_objects(rel_obj, status, user)
+
+def delete_handler(sender, instance, **kwargs):
+    """ When an instance is deleted, delete corresponding monitor_entries too"""
+    from django_monitor import model_from_queue
+    if model_from_queue(sender):
+        me = MonitorEntry.objects.get_for_instance(instance)
+        if me:
+            me.delete()
+        # Delete monitor_entries of parents too
+        monitored_parents = filter(
+            lambda x: model_from_queue(x),
+            instance._meta.parents.keys()
+        )
+        for parent in monitored_parents:
+            parent_ct = ContentType.objects.get_for_model(parent)
+            parent_pk_field = instance._meta.get_ancestor_link(parent)
+            parent_pk = getattr(instance, parent_pk_field.attname)
+            try:
+                me = MonitorEntry.objects.get(
+                    content_type = parent_ct, object_id = parent_pk
+                )
+                me.delete()
+            except MonitorEntry.DoesNotExist:
+                pass

docs/source/dev_howto.rst

 Registration (Enqueue)
 ======================
 
-Register the model for moderation using ``monitor.nq``.
+Register the model for moderation using ``django_monitor.nq``.
 
 **Example** ::
 
-    import monitor
+    import django_monitor
     # Your model here
-    monitor.nq(YOUR_MODEL)
+    django_monitor.nq(YOUR_MODEL)
 
 The full signature is... ::
 
-    monitor.nq(
-        model, [rel_fields=[], can_delete_approved=True, manager_name='objects',
-        status_name='status', monitor_name='monitor_entry', base_manager=None]
+    django_monitor.nq(
+        model, [rel_fields = [], can_delete_approved = True,
+        manager_name = 'objects', status_name = 'status',
+        monitor_name = 'monitor_entry', base_manager = None]
     )
 
 ``model`` is the only required argument. Other optional arguments follow:
 ::
 
     # in your admin.py
-    from monitor.admin import MonitorAdmin
+    from django_monitor.admin import MonitorAdmin
     class YourModelAdmin(MonitorAdmin):
         pass
 
         name = models.CharField(max_length = 100)
         book = models.ForeignKey(Book, related_name = 'supplements')
 
-    monitor.nq(Book, rel_fields = ['supplements'])
-    monitor.nq(Supplement)
+    django_monitor.nq(Book, rel_fields = ['supplements'])
+    django_monitor.nq(Supplement)
 
 Remember that both models should be put in moderation queue.
 
     class YourModelAdmin(MonitorAdmin):
         protected_fields = ['field1', 'field2']
 
-``can_delete_approved`` is an optional parameter you pass to ``monitor.nq``.
-Its default value is ``True`` which allows users to delete all objects. If this
-is set to ``False``, admin-user can not delete an object once it is approved.
-Deleting either un-moderated or pending/challenged objects can be done as usual.
-You still can delete approved objects by code or from the django-shell.
+``can_delete_approved`` is an optional parameter you pass to 
+``django_monitor.nq``. Its default value is ``True`` which allows users to
+delete all objects. If this is set to ``False``, admin-user can not delete
+an object once it is approved. Deleting either un-moderated or pending or
+challenged objects can be done as usual. You still can delete approved
+objects by code or from the django-shell.
 
 Creation of objects by code
 ============================
    ``user`` is an optional parameter in all those methods described below.
    Please pass the current user to the methods in all possible cases.
    ``request.user`` can be used for this whenever ``request`` is available.
-   Otherwise, you can use ``get_current_user`` from ``monitor.middleware``.
+   Otherwise, use the function, ``django_monitor.middleware.get_current_user``.
 
 #. approve:
     ::
 If you want to perform something after an object is moderated, you can make use
 of the ``post_moderation`` signal as in the below **example**: ::
 
-    from monitor import post_moderation
+    from django_monitor import post_moderation
 
     # handler_func: function to handle your post moderation activities.
     def handler_func(sender, instance, **kwargs):

monitor/__init__.py

-__author__ = "Rajeesh Nair"
-__version__ = "0.1.5a"
-__copyright__ = "Copyright (c) 2011 Rajeesh"
-__license__ = "BSD"
-
-from django.dispatch import Signal
-from django.db.models import signals
-from django.db.models.loading import get_model
-
-from monitor.util import (
-    create_moderate_perms, add_fields, save_handler, delete_handler
-)
-
-_queue = {}
-
-def model_from_queue(model):
-    """ Returns the model dict if model is enqueued, else None."""
-    return _queue.get(model, None)
-
-def queued_models():
-    """ Return the models enqueued for moderation"""
-    return _queue.keys()
-
-def get_monitor_entry(obj):
-    """
-    Returns the monitor_entry for the given object.
-    Deprecated.
-    No one except the given object need access to the monitor_entry.
-    """
-    model_dict = model_from_queue(obj.__class__)
-    return getattr(obj, model_dict['monitor_name']) if model_dict else None
-
-def nq(
-    model, rel_fields = [], can_delete_approved = True,
-    manager_name = 'objects', status_name = 'status',
-    monitor_name = 'monitor_entry', base_manager = None
-):
-    """ Register(enqueue) the model for moderation."""
-    if not model_from_queue(model):
-        signals.post_save.connect(save_handler, sender = model)
-        signals.pre_delete.connect(delete_handler, sender = model)
-        registered_model = get_model(
-            model._meta.app_label, model._meta.object_name, False
-        )
-        add_fields(
-            registered_model, manager_name, status_name,
-            monitor_name, base_manager
-        )
-        _queue[model] = {
-            'rel_fields': rel_fields,
-            'can_delete_approved': can_delete_approved,
-            'manager_name': manager_name,
-            'status_name': status_name,
-            'monitor_name': monitor_name
-        }
-
-post_moderation = Signal(providing_args = ["instance"])
-
-signals.post_syncdb.connect(
-    create_moderate_perms,
-    dispatch_uid = "django-monitor.create_moderate_perms"
-)
-

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 model_from_queue
-from monitor.conf import (
-    STATUS_DICT, PENDING_STATUS, APPROVED_STATUS, CHALLENGED_STATUS
-)
-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 model_from_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 == PENDING_STATUS and
-            not modeladmin.has_change_permission(request)
-        ) or 
-        (status != PENDING_STATUS 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 = STATUS_DICT[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, APPROVED_STATUS)
-    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, CHALLENGED_STATUS)
-    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, PENDING_STATUS)
-    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"
-)
-

monitor/admin.py

-
-from django.contrib.contenttypes.models import ContentType
-from django.contrib import admin
-from django.contrib.admin.filterspecs import FilterSpec
-from django.shortcuts import render_to_response
-from django.utils.functional import update_wrapper
-from django.template import RequestContext
-from django.utils.safestring import mark_safe
-
-from monitor.actions import (
-    approve_selected, challenge_selected, reset_to_pending
-)
-from monitor.filter import MonitorFilter
-from monitor import model_from_queue, queued_models
-from monitor.conf import (
-    PENDING_STATUS, CHALLENGED_STATUS, APPROVED_STATUS,
-    PENDING_DESCR, CHALLENGED_DESCR
-)
-from monitor.models import MonitorEntry
-
-# Our objective is to place the custom monitor-filter on top
-FilterSpec.filter_specs.insert(
-    0, (lambda f: getattr(f, 'monitor_filter', False), MonitorFilter)
-)
-
-
-class MEAdmin(admin.ModelAdmin):
-    """
-    A special admin-class for aggregating moderation summary, not to let users
-    add/edit/delete MonitorEntry objects directly. MonitorEntry works from
-    behind the curtain. This admin class is to provide a single stop for users
-    to get notified about pending/challenged model objects.
-    """
-    change_list_template = 'admin/monitor/monitorentry/change_list.html'
-
-    def get_urls(self):
-        """ The only url allowed is that for changelist_view. """
-        from django.conf.urls.defaults import patterns, url
-
-        def wrap(view):
-            def wrapper(*args, **kwargs):
-                return self.admin_site.admin_view(view)(*args, **kwargs)
-            return update_wrapper(wrapper, view)
-
-        info = self.model._meta.app_label, self.model._meta.module_name
-
-        urlpatterns = patterns('',
-            url(r'^$',
-                wrap(self.changelist_view),
-                name = '%s_%s_changelist' % info
-            ),
-        )
-        return urlpatterns
-
-    def has_add_permission(self, request, obj = None):
-        """ Returns False so that no add button is displayed in admin index"""
-        return False
-
-    def has_change_permission(self, request, obj = None):
-        """
-        Users will be lead to the moderation summary page when they click on
-        the link for changelist, which has a url like,
-        ``/admin/monitor/monitorentry/``. The admin site index page will show
-        the link to user, only if they have change_permission. So lets grant
-        that perm to all admin-users.
-        """
-        if obj is None and request.user.is_active and request.user.is_staff:
-            return True
-        return super(MEAdmin, self).has_change_permission(request, obj)
-
-    def changelist_view(self, request, extra_context = None):
-        """
-        The 'change list' admin view is overridden to return a page showing the
-        moderation summary aggregated for each model.
-        """
-        query_set = self.queryset(request)
-        model_list = []
-        for model in queued_models():
-            c_type = ContentType.objects.get_for_model(model)
-            q_set = query_set.filter(content_type = c_type)
-            ip_count = q_set.filter(status = PENDING_STATUS).count()
-            ch_count = q_set.filter(status = CHALLENGED_STATUS).count() 
-            app_label = model._meta.app_label
-            if ip_count or ch_count:
-                model_list.append({
-                    'model_name': model._meta.verbose_name,
-                    'app_name': app_label.title(),
-                    'pending': ip_count, 'challenged': ch_count,
-                    'admin_url': mark_safe(
-                        '/admin/%s/%s/' % (app_label, model.__name__.lower())
-                    ),
-                })
-        model_list.sort(key = lambda x: (x['app_name'], x['model_name']))
-        return render_to_response(
-            self.change_list_template,
-            {
-                'model_list': model_list,
-                'ip_status': PENDING_STATUS, 'ip_descr': PENDING_DESCR,
-                'ch_status': CHALLENGED_STATUS, 'ch_descr': CHALLENGED_DESCR
-            },
-            context_instance = RequestContext(request)
-        )
-
-admin.site.register(MonitorEntry, MEAdmin)
-
-class MonitorAdmin(admin.ModelAdmin):
-    """ModelAdmin for monitored models should inherit this."""
-
-    # Which fields are to be made readonly after approval.
-    protected_fields = ()
-
-    def __init__(self, model, admin_site):
-        """ Overridden to add a custom filter to list_filter """
-        super(MonitorAdmin, self).__init__(model, admin_site)
-        self.list_filter = ['id'] + list(self.list_filter)
-        self.list_display = (
-            list(self.list_display) + ['get_monitor_status_display']
-        )
-
-    def queryset(self, request):
-        """
-        Django does not allow using non-fields in list_filter. (As of 1.3).
-        Using params not mentioned in list_filter will raise error in changelist.
-        We want to enable status based filtering (status is not a db_field).
-        We will check the request.GET here and if there's a `status` in it,
-        Remove that and filter the qs by status.
-        """
-        qs = super(MonitorAdmin, self).queryset(request)
-        status = request.GET.get('status', None)
-        # status is not among list_filter entries. So its presence will raise
-        # IncorrectLookupParameters when django tries to build-up changelist.
-        # We no longer need that param after leaving here. So let's remove it.
-        if status:
-            get_dict = request.GET.copy()
-            del get_dict['status']
-            request.GET = get_dict
-        # ChangeList will use this custom queryset. So we've done it!
-        if status and status == PENDING_STATUS:
-            qs = qs.pending()
-        elif status and status == CHALLENGED_STATUS:
-            qs = qs.challenged()
-        elif status and status == APPROVED_STATUS:
-            qs = qs.approved()
-        return qs
-
-    def is_monitored(self):
-        """Returns whether the underlying model is monitored or not."""
-        return bool(model_from_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.is_approved
-        ):
-            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)