changeset 215:8c1832b9d815

Implement #84; additional checks on spammers; implement stranger status.
author Brian Neal <bgneal@gmail.com>
date Sat, 29 May 2010 04:51:28 +0000
parents 28988cce138b
children fe900598f81c
files gpp/antispam/__init__.py gpp/antispam/models.py gpp/antispam/urls.py gpp/antispam/utils.py gpp/antispam/views.py gpp/bio/admin.py gpp/bio/models.py gpp/comments/views.py gpp/core/middleware.py gpp/forums/spam.py gpp/forums/urls.py gpp/forums/views.py gpp/settings.py gpp/templates/antispam/suspended.html gpp/templates/comments/comment.html gpp/templates/forums/display_post.html gpp/templates/forums/spammer.html gpp/templates/forums/stranger.html gpp/urls.py media/css/base.css media/icons/tick.png
diffstat 21 files changed, 288 insertions(+), 25 deletions(-) [+]
line wrap: on
line diff
--- a/gpp/antispam/__init__.py	Fri May 14 02:19:48 2010 +0000
+++ b/gpp/antispam/__init__.py	Sat May 29 04:51:28 2010 +0000
@@ -1,1 +1,4 @@
 SPAM_PHRASE_KEY = "antispam.spam_phrases"
+BUSTED_MESSAGE = ("Your post has tripped our spam filter. Your account has "
+        "been suspended pending a review of your post. If this was a mistake "
+        "then we apologize; your account will be restored shortly.")
--- a/gpp/antispam/models.py	Fri May 14 02:19:48 2010 +0000
+++ b/gpp/antispam/models.py	Sat May 29 04:51:28 2010 +0000
@@ -19,4 +19,5 @@
 
     def save(self, *args, **kwargs):
         cache.delete(SPAM_PHRASE_KEY)
+        self.phrase = self.phrase.lower()
         super(SpamPhrase, self).save(*args, **kwargs)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/antispam/urls.py	Sat May 29 04:51:28 2010 +0000
@@ -0,0 +1,6 @@
+"""URLs for the antispam application."""
+from django.conf.urls.defaults import *
+
+urlpatterns = patterns('antispam.views',
+    url(r'^suspended/$', 'suspended', name='antispam-suspended'),
+)
--- a/gpp/antispam/utils.py	Fri May 14 02:19:48 2010 +0000
+++ b/gpp/antispam/utils.py	Sat May 29 04:51:28 2010 +0000
@@ -1,8 +1,14 @@
 """Antispam utility functions other apps can use."""
+import datetime
+import logging
+import textwrap
+
 from django.core.cache import cache
 
 from antispam import SPAM_PHRASE_KEY
 from antispam.models import SpamPhrase
+from core.functions import email_admins
+from bio.models import STA_SUSPENDED
 
 
 def contains_spam(s):
@@ -10,6 +16,7 @@
     phrases and False otherwise.
     """
     phrases = _get_spam_phrases()
+    s = s.lower()
     for spam in phrases:
         if spam in s:
             return True
@@ -17,6 +24,45 @@
     return False
 
 
+def spam_check(request, content):
+    """This function checks the supplied content for spam if the user from the
+    supplied request is a stranger (new to the site). If spam is found, the
+    function makes a log entry, emails the admins, suspends the user's account
+    and returns True. If spam is not found, False is returned.
+    It is assumed that request.user is an authenticated user and thus has a
+    user profile.
+    """
+    user = request.user
+    if user.get_profile().is_stranger() and contains_spam(content):
+
+        ip = request.META.get('REMOTE_ADDR', "unknown")
+
+        msg = textwrap.dedent("""\
+            SPAM FILTER TRIPPED by %s
+            PATH: %s
+            IP: %s
+            Message:
+            %s
+            """ % (user.username, request.path, ip, content))
+
+        logging.info(msg)
+        email_admins("SPAM FILTER TRIPPED BY %s" % user.username, msg)
+        suspend_user(user)
+        return True
+
+    return False
+
+
+def suspend_user(user):
+    """This function marks the user as suspended."""
+    user.is_active = False
+    user.save()
+    profile = user.get_profile()
+    profile.status = STA_SUSPENDED
+    profile.status_date = datetime.datetime.now()
+    profile.save()
+    
+
 def _get_spam_phrases():
     """This function returns the current list of spam phrase strings.
     The strings are cached to avoid hitting the database.
@@ -28,4 +74,3 @@
     phrases = SpamPhrase.objects.values_list('phrase', flat=True)
     cache.set(SPAM_PHRASE_KEY, phrases)
     return phrases
-
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/antispam/views.py	Sat May 29 04:51:28 2010 +0000
@@ -0,0 +1,18 @@
+"""Views for the antispam application."""
+
+from django.shortcuts import render_to_response
+from django.template import RequestContext
+
+import bio.models
+
+
+def suspended(request):
+    """This view checks the user's status for suspension and displays an
+    appropriate message.
+    """
+    is_active = request.user.is_active
+
+    return render_to_response('antispam/suspended.html', {
+        'is_active': is_active,
+        }, 
+        context_instance = RequestContext(request))
--- a/gpp/bio/admin.py	Fri May 14 02:19:48 2010 +0000
+++ b/gpp/bio/admin.py	Sat May 29 04:51:28 2010 +0000
@@ -20,7 +20,8 @@
     search_fields = ('user__username', 'user__first_name', 'user__last_name',
             'user__email')
     exclude = ('profile_html', 'signature_html')
-    list_display = ('__unicode__', 'get_status_display', 'status_date')
+    list_display = ('__unicode__', 'user_is_active', 'get_status_display', 'status_date')
+    readonly_fields = ('status', 'status_date')
     list_filter = ('status', )
     date_hierarchy = 'status_date'
     inlines = (BadgeOwnerInline, )
@@ -30,6 +31,7 @@
         'mark_removed',
         'mark_suspended',
         'mark_spammer',
+        'mark_stranger',
     )
 
     def get_status_display(self, obj):
@@ -44,7 +46,8 @@
         """
         now = datetime.datetime.now()
         for profile in qs:
-            profile.user.is_active = status == bio.models.STA_ACTIVE
+            profile.user.is_active = (status == bio.models.STA_ACTIVE or
+                    status == bio.models.STA_STRANGER)
             profile.user.save()
             profile.status = status
             profile.status_date = now
@@ -94,6 +97,13 @@
             delete_user_posts(profile.user)
     mark_spammer.short_description = "Mark selected users as spammers"
 
+    def mark_stranger(self, request, qs):
+        """
+        Marks users as strangers. Updates their profile status to STA_STRANGER.
+        """
+        self.mark_user_status(request, qs, bio.models.STA_STRANGER)
+    mark_stranger.short_description = "Mark selected users as strangers"
+
 
 class UserProfileFlagAdmin(admin.ModelAdmin):
     list_display = ('__unicode__', 'flag_date', 'get_profile_url')
--- a/gpp/bio/models.py	Fri May 14 02:19:48 2010 +0000
+++ b/gpp/bio/models.py	Sat May 29 04:51:28 2010 +0000
@@ -12,8 +12,17 @@
 
 from core.markup import SiteMarkup
 
-
-(STA_ACTIVE, STA_RESIGNED, STA_REMOVED, STA_SUSPENDED, STA_SPAMMER) = range(5)
+# These are the secondary user status enumeration values. 
+(STA_ACTIVE,        # User is a full member in good standing.
+ STA_RESIGNED,      # User has voluntarily asked to be removed.
+ STA_REMOVED,       # User was removed for bad behavior.
+ STA_SUSPENDED,     # User is temporarily suspended; e.g. a stranger tripped
+                    # the spam filter.
+ STA_SPAMMER,       # User has been removed for spamming.
+ STA_STRANGER,      # New member, isn't fully trusted yet. Their comments and
+                    # forum posts are scanned for spam. They can have their
+                    # accounts deactivated by moderators for spamming.
+ ) = range(6)
 
 USER_STATUS_CHOICES = (
     (STA_ACTIVE, "Active"),
@@ -21,6 +30,7 @@
     (STA_REMOVED, "Removed"),
     (STA_SUSPENDED, "Suspended"),
     (STA_SPAMMER, "Spammer"),
+    (STA_STRANGER, "Stranger")
 )
 
 
@@ -76,7 +86,7 @@
             default='US/Pacific')
     use_24_time = models.BooleanField(default=False)
     forum_post_count = models.IntegerField(default=0)
-    status = models.IntegerField(default=STA_ACTIVE,
+    status = models.IntegerField(default=STA_STRANGER,
             choices=USER_STATUS_CHOICES)
     status_date = models.DateTimeField(auto_now_add=True)
     badges = models.ManyToManyField(Badge, through="BadgeOwnership")
@@ -105,6 +115,18 @@
                 "badge")
         return self._badges
 
+    def is_stranger(self):
+        """Returns True if this user profile status is STA_STRANGER."""
+        return self.status == STA_STRANGER
+
+    def user_is_active(self):
+        """Returns the profile's user is_active status. This function exists
+        for the admin.
+        """
+        return self.user.is_active
+    user_is_active.boolean = True
+    user_is_active.short_description = "Is Active"
+
 
 class UserProfileFlag(models.Model):
     """This model represents a user flagging a profile as inappropriate."""
--- a/gpp/comments/views.py	Fri May 14 02:19:48 2010 +0000
+++ b/gpp/comments/views.py	Sat May 29 04:51:28 2010 +0000
@@ -18,13 +18,16 @@
 from comments.forms import CommentForm
 from comments.models import Comment
 from comments.models import CommentFlag
+import antispam
+import antispam.utils
+
 
 @login_required
 @require_POST
 def post_comment(request):
     """
     This function handles the posting of comments. If successful, returns
-    the comment text as the response. This function is mean't to be the target
+    the comment text as the response. This function is meant to be the target
     of an AJAX post.
     """
     # Look up the object we're trying to comment about
@@ -66,9 +69,13 @@
     if not form.is_valid():
         return HttpResponseBadRequest('Invalid comment; missing parameters?')
 
-    # else, create and save the comment
+    comment = form.get_comment_object(request.user, request.META.get("REMOTE_ADDR", None))
 
-    comment = form.get_comment_object(request.user, request.META.get("REMOTE_ADDR", None))
+    # Check for spam 
+
+    if antispam.utils.spam_check(request, comment.comment):
+        return HttpResponseForbidden(antispam.BUSTED_MESSAGE)
+
     comment.save()
 
     # return the rendered comment
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/core/middleware.py	Sat May 29 04:51:28 2010 +0000
@@ -0,0 +1,16 @@
+"""Common middleware for the entire project."""
+from django.contrib.auth import logout
+
+class InactiveUserMiddleware(object):
+    """
+    This middleware ensures users with is_active set to False get their
+    session destroyed and are treated as logged out.
+    This middleware should come after the 'django.contrib.auth.middleware.
+    AuthenticationMiddleware' in settings.py.
+    Idea taken from: http://djangosnippets.org/snippets/1105/
+    """
+
+    def process_request(self, request):
+        if request.user.is_authenticated() and not request.user.is_active:
+            logout(request)
+
--- a/gpp/forums/spam.py	Fri May 14 02:19:48 2010 +0000
+++ b/gpp/forums/spam.py	Sat May 29 04:51:28 2010 +0000
@@ -2,10 +2,13 @@
 This module contains views for dealing with spam and spammers.
 """
 import datetime
+import logging
+import textwrap
 
 from django.contrib.auth.decorators import login_required
 from django.core.urlresolvers import reverse
 from django.http import HttpResponseRedirect
+from django.http import HttpResponseForbidden
 from django.shortcuts import get_object_or_404
 from django.shortcuts import render_to_response
 from django.template import RequestContext
@@ -39,28 +42,51 @@
     delete_user_posts(user)
 
 
+def promote_stranger(user):
+    """This function upgrades the user from stranger status to a regular user.
+    """
+    profile = user.get_profile()
+    if user.is_active and profile.status == bio.models.STA_STRANGER:
+        profile.status = bio.models.STA_ACTIVE
+        profile.status_date = datetime.datetime.now()
+        profile.save()
+
+
 @login_required
 def spammer(request, post_id):
     """This view allows moderators to deactivate spammer accounts."""
 
     post = get_object_or_404(Post.objects.select_related(), pk=post_id)
+    poster = post.user
+    poster_profile = poster.get_profile()
 
     can_moderate = request.user.is_superuser or (
             request.user in post.topic.forum.moderators.all())
 
-    if request.method == "POST":
-        if can_moderate:
-            deactivate_spammer(post.user)
+    can_deactivate = (poster_profile.status == bio.models.STA_STRANGER and not
+            poster.is_superuser)
 
-            email_admins(SPAMMER_NAILED_SUBJECT % post.user.username, 
-                SPAMMER_NAILED_MSG_BODY % (
-                    request.user.username, post.user.username))
+    if request.method == "POST" and can_moderate and can_deactivate:
+        deactivate_spammer(poster)
 
-            return HttpResponseRedirect(reverse('forums-spammer_nailed', args=[
-                post.user.id]))
+        email_admins(SPAMMER_NAILED_SUBJECT % poster.username, 
+            SPAMMER_NAILED_MSG_BODY % (
+                request.user.username, poster.username))
+
+        logging.info(textwrap.dedent("""\
+            SPAMMER DEACTIVATED: %s nailed %s for spam.
+            IP: %s
+            Message:
+            %s
+            """ % (request.user.username, poster.username, post.user_ip,
+                post.body)))
+
+        return HttpResponseRedirect(reverse('forums-spammer_nailed', args=[
+            poster.id]))
 
     return render_to_response('forums/spammer.html', {
         'can_moderate': can_moderate,
+        'can_deactivate': can_deactivate,
         'post': post,
         },
         context_instance=RequestContext(request))
@@ -81,3 +107,33 @@
         'success': success,
         },
         context_instance=RequestContext(request))
+
+
+@login_required
+def stranger(request, post_id):
+    """This view allows a forum moderator or super user to promote a user from
+    stranger status to regular user.
+    """
+    post = get_object_or_404(Post.objects.select_related(), pk=post_id)
+    poster = post.user
+    poster_profile = poster.get_profile()
+
+    can_moderate = request.user.is_superuser or (
+            request.user in post.topic.forum.moderators.all())
+
+    can_promote = poster_profile.status == bio.models.STA_STRANGER
+
+    if request.method == "POST" and can_moderate and can_promote:
+        promote_stranger(poster)
+
+        logging.info("STRANGER PROMOTED: %s promoted %s." % (
+            request.user.username, poster.username))
+
+        return HttpResponseRedirect(post.get_absolute_url())
+
+    return render_to_response('forums/stranger.html', {
+        'can_moderate': can_moderate,
+        'can_promote': can_promote,
+        'post': post,
+        },
+        context_instance=RequestContext(request))
--- a/gpp/forums/urls.py	Fri May 14 02:19:48 2010 +0000
+++ b/gpp/forums/urls.py	Sat May 29 04:51:28 2010 +0000
@@ -38,4 +38,5 @@
 urlpatterns += patterns('forums.spam',
     url(r'^spammer/(\d+)/$', 'spammer', name='forums-spammer'),
     url(r'^spammer/nailed/(\d+)/$', 'spammer_nailed', name='forums-spammer_nailed'),
+    url(r'^stranger/(\d+)/$', 'stranger', name='forums-stranger'),
 )
--- a/gpp/forums/views.py	Fri May 14 02:19:48 2010 +0000
+++ b/gpp/forums/views.py	Sat May 29 04:51:28 2010 +0000
@@ -31,6 +31,9 @@
         get_post_unread_status, get_unread_topics
 
 from bio.models import UserProfile
+import antispam
+import antispam.utils
+
 #######################################################################
 
 TOPICS_PER_PAGE = 50
@@ -225,6 +228,9 @@
     if request.method == 'POST':
         form = NewTopicForm(request.user, forum, request.POST)
         if form.is_valid():
+            if antispam.utils.spam_check(request, form.cleaned_data['body']):
+                return HttpResponseRedirect(reverse('antispam-suspended'))
+
             topic = form.save(request.META.get("REMOTE_ADDR"))
             _bump_post_count(request.user)
             return HttpResponseRedirect(reverse('forums-new_topic_thanks',
@@ -267,6 +273,8 @@
     if form.is_valid():
         if not _can_post_in_topic(form.topic, request.user):
             return HttpResponseForbidden("You don't have permission to post in this topic.")
+        if antispam.utils.spam_check(request, form.cleaned_data['body']):
+            return HttpResponseForbidden(antispam.BUSTED_MESSAGE)
 
         post = form.save(request.user, request.META.get("REMOTE_ADDR", ""))
         post.unread = True
@@ -342,6 +350,8 @@
     if request.method == "POST":
         form = PostForm(request.POST, instance=post)
         if form.is_valid():
+            if antispam.utils.spam_check(request, form.cleaned_data['body']):
+                return HttpResponseRedirect(reverse('antispam-suspended'))
             post = form.save(commit=False)
             post.touch()
             post.save()
@@ -462,6 +472,8 @@
         if request.method == 'POST':
             form = PostForm(request.POST)
             if form.is_valid():
+                if antispam.utils.spam_check(request, form.cleaned_data['body']):
+                    return HttpResponseRedirect(reverse('antispam-suspended'))
                 post = form.save(commit=False)
                 post.topic = topic
                 post.user = request.user
--- a/gpp/settings.py	Fri May 14 02:19:48 2010 +0000
+++ b/gpp/settings.py	Sat May 29 04:51:28 2010 +0000
@@ -81,8 +81,9 @@
         'django.contrib.messages.middleware.MessageMiddleware',
         'debug_toolbar.middleware.DebugToolbarMiddleware',
         'django.contrib.auth.middleware.AuthenticationMiddleware',
+        'gpp.core.middleware.InactiveUserMiddleware',
+        'gpp.forums.middleware.WhosOnline',
         'django.contrib.flatpages.middleware.FlatpageFallbackMiddleware',
-        'gpp.forums.middleware.WhosOnline',
     )
 else:
     MIDDLEWARE_CLASSES = (
@@ -91,8 +92,9 @@
         'django.contrib.sessions.middleware.SessionMiddleware',
         'django.contrib.messages.middleware.MessageMiddleware',
         'django.contrib.auth.middleware.AuthenticationMiddleware',
+        'gpp.core.middleware.InactiveUserMiddleware',
+        'gpp.forums.middleware.WhosOnline',
         'django.contrib.flatpages.middleware.FlatpageFallbackMiddleware',
-        'gpp.forums.middleware.WhosOnline',
     )
 
 ROOT_URLCONF = 'gpp.urls'
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/templates/antispam/suspended.html	Sat May 29 04:51:28 2010 +0000
@@ -0,0 +1,19 @@
+{% extends 'base.html' %}
+{% block title %}Account Suspension Status{% endblock %}
+{% block content %}
+<h2>Account Suspension Status</h2>
+{% if not is_active %}
+<p class="error">
+Your post has tripped our spam filter. Your account has been suspended pending
+a review of your post. If this was a mistake, we sincerely apologize and promise
+your account will be restored soon. We regret having to take this drastic measure, but
+the high volume of spam we recieve has forced us to take this action. If you have any
+questions, please feel to <a href="{% url contact-form %}">contact us</a>.
+</p>
+{% else %}
+<p class="success">
+Your account has been restored and you should be able to make comments and forum
+posts. We apologize for any inconvenience.
+</p>
+{% endif %}
+{% endblock %}
--- a/gpp/templates/comments/comment.html	Fri May 14 02:19:48 2010 +0000
+++ b/gpp/templates/comments/comment.html	Sat May 29 04:51:28 2010 +0000
@@ -1,6 +1,4 @@
 {% load avatar_tags %}
-{% load markup %}
-{% load smiley_tags %}
 <div class="comment" id="c{{ comment.id }}">
 <div class="comment-list">{{ forloop.counter }}.</div>
 <div class="comment-avatar">
--- a/gpp/templates/forums/display_post.html	Fri May 14 02:19:48 2010 +0000
+++ b/gpp/templates/forums/display_post.html	Sat May 29 04:51:28 2010 +0000
@@ -23,7 +23,7 @@
       {% endif %}
    </td>
    <td class="forum-post-body">
-      <div class="forum-post-info quiet">
+      <div class="forum-post-info quiet{% if post.user_profile.is_stranger %} stranger{% endif %}">
       {% if post.unread %}<img src="{{ MEDIA_URL }}icons/new.png" alt="New" title="New" />{% endif %}
       <a href="{{ post.get_absolute_url }}"><img src="{{ MEDIA_URL }}icons/link.png" alt="Link" title="Link to this post" /></a>
          Posted on {% forum_date post.creation_date user %}
@@ -49,7 +49,11 @@
       {% if can_moderate %}
       <a href="#" class="post-delete" id="dp-{{ post.id }}"
          title="Delete this post"><img src="{{ MEDIA_URL }}icons/cross.png" alt="Delete post" /></a>
-         {% if post.user != user %}
+         {% if post.user != user and post.user_profile.is_stranger %}
+         <br />
+         <span class="quiet">Stranger options:</span>
+         <a href="{% url forums-stranger post.id %}" title="This stranger seems legitimate">
+            <img src="{{ MEDIA_URL }}icons/tick.png" alt="Acquaintance" /></a>
          <a href="{% url forums-spammer post.id %}" title="This is spam">
             <img src="{{ MEDIA_URL }}icons/exclamation.png" alt="Spammer" /></a>
          {% endif %}
--- a/gpp/templates/forums/spammer.html	Fri May 14 02:19:48 2010 +0000
+++ b/gpp/templates/forums/spammer.html	Sat May 29 04:51:28 2010 +0000
@@ -3,7 +3,7 @@
 {% block content %}
 <h2>Deactivate Spammer: {{ post.user.username }}</h2>
 
-{% if can_moderate %}
+{% if can_moderate and can_deactivate %}
 <p>Please confirm that you wish to mark the user
 <a href="{% url bio-view_profile username=post.user.username %}">{{ post.user.username }}</a> as a
 spammer based on <a href="{% url forums-goto_post post.id %}">this post</a>. 
@@ -15,7 +15,12 @@
    <input type="submit" value="Deactivate {{ post.user.username }}" />
 </form>
 {% else %}
-<p>Sorry, but you don't have permission to deactivate spammers in that post's forum.</p>
+   {% if can_moderate %}
+   <p>That user is no longer a stranger, and can't be deactivated like this. Please
+   contact the site admin if that user is now posting spam.</p>
+   {% else %}
+   <p>Sorry, but you don't have permission to deactivate spammers in that post's forum.</p>
+   {% endif %}
 {% endif %}
 <hr />
 <p>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/templates/forums/stranger.html	Sat May 29 04:51:28 2010 +0000
@@ -0,0 +1,31 @@
+{% extends 'base.html' %}
+{% block title %}Promote Stranger: {{ post.user.username }}{% endblock %}
+{% block content %}
+<h2>Promote Stranger: {{ post.user.username }}</h2>
+
+{% if can_moderate and can_promote %}
+<p>All new users are considered &quot;<em>strangers</em>&quot; until approved by a moderator. 
+Strangers have their posts automatically scanned for spam phrases. Moderators can also instantly 
+deactivate stranger accounts if the spam filter does not catch them. If you promote a stranger, 
+these checks (which are somewhat expensive for the webserver) will no longer be performed, 
+and moderators won't be able to deactivate them on the spot. You may wish to wait until the user 
+has posted at least 10 times before making your decision.</p>
+<p>Please confirm that you wish to promote the new user
+<a href="{% url bio-view_profile username=post.user.username %}">{{ post.user.username }}</a> from
+<em>stranger</em> status based on <a href="{% url forums-goto_post post.id %}">this post</a>. 
+</p>
+<form action="." method="post">{% csrf_token %}
+   <input type="submit" value="Yes, {{ post.user.username }} seems legit and is not a stranger" />
+</form>
+{% else %}
+   {% if can_moderate %}
+   <p>That user is no longer a stranger, and can't be promoted again.</p>
+   {% else %}
+   <p>Sorry, but you don't have permission to promote users in that post's forum.</p>
+   {% endif %}
+{% endif %}
+<hr />
+<p>
+<a href="{% url forums-goto_post post.id %}">Return to the post</a>.
+</p>
+{% endblock %}
--- a/gpp/urls.py	Fri May 14 02:19:48 2010 +0000
+++ b/gpp/urls.py	Sat May 29 04:51:28 2010 +0000
@@ -14,6 +14,7 @@
    (r'^admin/doc/', include('django.contrib.admindocs.urls')),
    (r'^admin/', include(admin.site.urls)),
    (r'^accounts/', include('accounts.urls')),
+   (r'^antispam/', include('antispam.urls')),
    (r'^calendar/', include('gcalendar.urls')),
    (r'^comments/', include('comments.urls')),
    (r'^contact/', include('contact.urls')),
--- a/media/css/base.css	Fri May 14 02:19:48 2010 +0000
+++ b/media/css/base.css	Sat May 29 04:51:28 2010 +0000
@@ -365,3 +365,9 @@
 #badge_summary th, #badge_summary td {
    border: 1px solid teal;
 }
+.error a {
+   text-decoration: underline;
+}
+.stranger {
+   background:#FFF6BF;
+}
Binary file media/icons/tick.png has changed