changeset 204:b4305e18d3af

Resolve ticket #74. Add user badges. Some extra credit was done here: also refactored how pending news, links, and downloads are handled.
author Brian Neal <bgneal@gmail.com>
date Sat, 01 May 2010 21:53:59 +0000
parents 40e5903903e1
children da46e77cd804
files gpp/bio/admin.py gpp/bio/badges.py gpp/bio/models.py gpp/bio/signals.py gpp/bio/views.py gpp/comments/admin.py gpp/core/templatetags/custom_admin_tags.py gpp/downloads/admin.py gpp/downloads/forms.py gpp/downloads/models.py gpp/downloads/views.py gpp/forums/admin.py gpp/gcalendar/admin.py gpp/news/admin.py gpp/news/feeds.py gpp/news/models.py gpp/news/views.py gpp/templates/bio/view_profile.html gpp/templates/core/admin_dashboard.html gpp/templates/forums/display_post.html gpp/templates/news/category_index.html gpp/templates/news/story.html gpp/templates/news/story_summary.html gpp/weblinks/admin.py gpp/weblinks/forms.py gpp/weblinks/models.py gpp/weblinks/views.py media/css/base.css
diffstat 28 files changed, 406 insertions(+), 78 deletions(-) [+]
line wrap: on
line diff
--- a/gpp/bio/admin.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/bio/admin.py	Sat May 01 21:53:59 2010 +0000
@@ -4,13 +4,17 @@
 import datetime
 
 from django.contrib import admin
-from bio.models import UserProfile
-from bio.models import UserProfileFlag
+
 import bio.models
 from comments.models import Comment
 from forums.tools import delete_user_posts
 
 
+class BadgeOwnerInline(admin.TabularInline):
+    model = bio.models.BadgeOwnership
+    extra = 1
+
+
 class UserProfileAdmin(admin.ModelAdmin):
     search_fields = ('user__username', 'user__first_name', 'user__last_name',
             'user__email')
@@ -18,6 +22,7 @@
     list_display = ('__unicode__', 'get_status_display', 'status_date')
     list_filter = ('status', )
     date_hierarchy = 'status_date'
+    inlines = (BadgeOwnerInline, )
     actions = (
         'mark_active', 
         'mark_resigned',
@@ -93,5 +98,11 @@
     list_display = ('__unicode__', 'flag_date', 'get_profile_url')
 
 
-admin.site.register(UserProfile, UserProfileAdmin)
-admin.site.register(UserProfileFlag, UserProfileFlagAdmin)
+class BadgeAdmin(admin.ModelAdmin):
+    list_display = ('name', 'html', 'order', 'numeric_id', 'description')
+    list_editable = ('order', 'numeric_id')
+
+
+admin.site.register(bio.models.UserProfile, UserProfileAdmin)
+admin.site.register(bio.models.UserProfileFlag, UserProfileFlagAdmin)
+admin.site.register(bio.models.Badge, BadgeAdmin)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gpp/bio/badges.py	Sat May 01 21:53:59 2010 +0000
@@ -0,0 +1,35 @@
+"""This module contains user profile badge-related functionality."""
+
+from bio.models import Badge
+from bio.models import BadgeOwnership
+
+
+# Numeric ID's for badges that are awarded for user actions:
+(CONTRIBUTOR_PIN, CALENDAR_PIN, NEWS_PIN, LINK_PIN, DOWNLOAD_PIN,
+        SECURITY_PIN) = range(6)
+
+
+def award_badge(badge_id, user):
+    """This function awards the badge specified by badge_id
+    to the given user. If the user already has the badge,
+    the badge count is incremented by one.
+    """
+    import logging
+    try:
+        badge = Badge.objects.get(numeric_id=badge_id)
+    except Badge.DoesNotExist:
+        logging.error("Can't award badge with numeric_id = %d" % badge_id)
+        return
+
+    profile = user.get_profile()
+
+    # Does the user already have badges of this type?
+    try:
+        bo = BadgeOwnership.objects.get(profile=profile, badge=badge)
+    except BadgeOwnership.DoesNotExist:
+        # No badge of this type, yet
+        bo = BadgeOwnership(profile=profile, badge=badge, count=1)
+    else:
+        # Already have this badge
+        bo.count += 1
+    bo.save()
--- a/gpp/bio/models.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/bio/models.py	Sat May 01 21:53:59 2010 +0000
@@ -23,6 +23,33 @@
     (STA_SPAMMER, "Spammer"),
 )
 
+
+class Badge(models.Model):
+    """This model represents badges that users can earn."""
+    image = models.ImageField(upload_to='badges')
+    name = models.CharField(max_length=64)
+    description = models.TextField(blank=True)
+    order = models.IntegerField()
+    numeric_id = models.IntegerField(db_index=True)
+
+    class Meta:
+        ordering = ('order', )
+
+    def __unicode__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return self.image.url
+
+    def html(self):
+        """Returns a HTML img tag representation of the badge."""
+        if self.image:
+            return u'<img src="%s" alt="%s" title="%s" />' % (
+                    self.get_absolute_url(), self.name, self.name)
+        return u''
+    html.allow_tags = True
+
+
 def avatar_file_path_for_user(username, filename):
     return os.path.join(settings.AVATAR_DIR, 'users', username, filename)
 
@@ -52,6 +79,7 @@
     status = models.IntegerField(default=STA_ACTIVE,
             choices=USER_STATUS_CHOICES)
     status_date = models.DateTimeField(auto_now_add=True)
+    badges = models.ManyToManyField(Badge, through="BadgeOwnership")
 
     def __unicode__(self):
         return self.user.username
@@ -70,6 +98,13 @@
     def get_absolute_url(self):
         return ('bio-view_profile', (), {'username': self.user.username})
 
+    def badge_ownership(self):
+        if hasattr(self, '_badges'):
+            return self._badges
+        self._badges = BadgeOwnership.objects.filter(profile=self).select_related(
+                "badge")
+        return self._badges
+
 
 class UserProfileFlag(models.Model):
     """This model represents a user flagging a profile as inappropriate."""
@@ -87,3 +122,27 @@
     def get_profile_url(self):
         return '<a href="%s">Profile</a>' % self.profile.get_absolute_url()
     get_profile_url.allow_tags = True
+
+
+class BadgeOwnership(models.Model):
+    """This model represents the ownership of badges by users."""
+    profile = models.ForeignKey(UserProfile)
+    badge = models.ForeignKey(Badge)
+    count = models.IntegerField(default=1)
+
+    class Meta:
+        verbose_name_plural = "badge ownership"
+        ordering = ('badge__order', )
+
+    def __unicode__(self):
+        if self.count == 1:
+            return u"%s owns 1 %s" % (self.profile.user.username,
+                    self.badge.name)
+        else:
+            return u"%s owns %d %s badges" % (self.profile.user.username,
+                    self.count, self.badge.name)
+
+    def badge_count_str(self):
+        if self.count == 1:
+            return u"1 %s" % self.badge.name
+        return u"%d %ss" % (self.count, self.badge.name)
--- a/gpp/bio/signals.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/bio/signals.py	Sat May 01 21:53:59 2010 +0000
@@ -3,7 +3,14 @@
 """
 from django.db.models.signals import post_save
 from django.contrib.auth.models import User
+
+import bio.badges
 from bio.models import UserProfile
+from donations.models import Donation
+from weblinks.models import Link
+from downloads.models import Download
+from news.models import Story
+
 
 def on_user_save(sender, **kwargs):
     """
@@ -14,9 +21,51 @@
     created = kwargs['created']
     if created:
         user = kwargs['instance']
-        profile = UserProfile()
+        profile = bio.models.UserProfile()
         profile.user = user
         profile.save()
 
 
+def on_donation_save(sender, **kwargs):
+    """This function is called after a Donation is saved.
+    If the Donation was newly created and not anonymous,
+    award the user a contributor pin.
+    """
+    if kwargs['created']:
+        donation = kwargs['instance']
+        if not donation.is_anonymous and donation.user:
+            bio.badges.award_badge(bio.badges.CONTRIBUTOR_PIN, donation.user)
+
+
+def on_link_save(sender, **kwargs):
+    """This function is called after a Link is saved. If the Link was newly
+    created, award the user a link pin.
+    """
+    if kwargs['created']:
+        link = kwargs['instance']
+        bio.badges.award_badge(bio.badges.LINK_PIN, link.user)
+
+
+def on_download_save(sender, **kwargs):
+    """This function is called after a Download is saved. If the Download was
+    newly created, award the user a download pin.
+    """
+    if kwargs['created']:
+        download = kwargs['instance']
+        bio.badges.award_badge(bio.badges.DOWNLOAD_PIN, download.user)
+
+
+def on_story_save(sender, **kwargs):
+    """This function is called after a Story is saved. If the Story was
+    newly created, award the user a news pin.
+    """
+    if kwargs['created']:
+        story = kwargs['instance']
+        bio.badges.award_badge(bio.badges.NEWS_PIN, story.submitter)
+
+
 post_save.connect(on_user_save, sender=User)
+post_save.connect(on_donation_save, sender=Donation)
+post_save.connect(on_link_save, sender=Link)
+post_save.connect(on_download_save, sender=Download)
+post_save.connect(on_story_save, sender=Story)
--- a/gpp/bio/views.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/bio/views.py	Sat May 01 21:53:59 2010 +0000
@@ -21,6 +21,7 @@
 
 from bio.models import UserProfile
 from bio.models import UserProfileFlag
+from bio.models import BadgeOwnership
 from bio.forms import UploadAvatarForm
 from bio.forms import EditUserForm
 from bio.forms import EditUserProfileForm
@@ -70,12 +71,15 @@
 @login_required
 def my_profile(request):
     profile = request.user.get_profile()
+    badge_collection = BadgeOwnership.objects.filter(
+            profile=profile).select_related("badge")
 
     return render_to_response('bio/view_profile.html', {
         'subject': request.user, 
         'profile': profile, 
         'hide_email': False,
         'this_is_me': True,
+        'badge_collection': badge_collection,
         }, 
         context_instance = RequestContext(request))
 
@@ -89,15 +93,17 @@
         return HttpResponseRedirect(reverse('bio.views.my_profile'))
 
     profile = user.get_profile()
+    hide_email = profile.hide_email
 
-    # work around MySQL's handling of Boolean
-    hide_email = bool(profile.hide_email)
+    badge_collection = BadgeOwnership.objects.filter(
+            profile=profile).select_related("badge")
     
     return render_to_response('bio/view_profile.html', {
         'subject': user, 
         'profile': profile, 
         'hide_email': hide_email,
         'this_is_me': False,
+        'badge_collection': badge_collection,
         }, 
         context_instance = RequestContext(request))
 
--- a/gpp/comments/admin.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/comments/admin.py	Sat May 01 21:53:59 2010 +0000
@@ -4,6 +4,8 @@
 from django.contrib import admin
 from comments.models import Comment
 from comments.models import CommentFlag
+import bio.badges
+
 
 class CommentAdmin(admin.ModelAdmin):
     fieldsets = (
@@ -25,8 +27,21 @@
     search_fields = ('comment', 'user__username', 'ip_address')
     raw_id_fields = ('user', 'content_type')
 
+
 class CommentFlagAdmin(admin.ModelAdmin):
     list_display = ('__unicode__', 'flag_date', 'get_comment_url')
+    actions = ('accept_flags', )
+
+    def accept_flags(self, request, qs):
+        """This admin action awards a security pin to the user who reported
+        the comment and then deletes the flagged comment object.
+        """
+        for flag in qs:
+            bio.badges.award_badge(bio.badges.SECURITY_PIN, flag.user)
+            flag.delete()
+
+    accept_flags.short_description = "Accept selected comment flags"
+
 
 admin.site.register(Comment, CommentAdmin)
 admin.site.register(CommentFlag, CommentFlagAdmin)
--- a/gpp/core/templatetags/custom_admin_tags.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/core/templatetags/custom_admin_tags.py	Sat May 01 21:53:59 2010 +0000
@@ -6,11 +6,11 @@
 
 from bio.models import UserProfileFlag
 from comments.models import CommentFlag
-from downloads.models import Download
+from downloads.models import PendingDownload
 from forums.models import FlaggedPost
 from gcalendar.models import Event
 from news.models import PendingStory
-from weblinks.models import Link, FlaggedLink
+from weblinks.models import PendingLink, FlaggedLink
 from shoutbox.models import ShoutFlag
 
 
@@ -25,14 +25,14 @@
     """
     flagged_profiles = UserProfileFlag.objects.count()
     flagged_comments = CommentFlag.objects.count()
-    new_downloads = Download.objects.filter(is_public=False).count()
+    new_downloads = PendingDownload.objects.count()
     flagged_posts = FlaggedPost.objects.count()
     event_requests = Event.objects.filter(
                 Q(status=Event.NEW) | 
                 Q(status=Event.EDIT_REQ) | 
                 Q(status=Event.DEL_REQ)).count()
     new_stories = PendingStory.objects.count()
-    new_links = Link.objects.filter(is_public=False).count()
+    new_links = PendingLink.objects.count()
     broken_links = FlaggedLink.objects.count()
     flagged_shouts = ShoutFlag.objects.count()
 
--- a/gpp/downloads/admin.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/downloads/admin.py	Sat May 01 21:53:59 2010 +0000
@@ -1,9 +1,12 @@
 """
 This file contains the automatic admin site definitions for the downloads models.
 """
+import datetime
+
 from django.contrib import admin
 from django.conf import settings
 
+from downloads.models import PendingDownload
 from downloads.models import Download
 from downloads.models import Category
 from downloads.models import AllowedExtension
@@ -15,6 +18,39 @@
     readonly_fields = ('count', )
 
 
+class PendingDownloadAdmin(admin.ModelAdmin):
+    exclude = ('html', )
+    list_display = ('title', 'user', 'category', 'date_added', 'ip_address', 'size')
+    ordering = ('date_added', )
+    raw_id_fields = ('user', )
+
+    actions = ('approve_downloads', )
+
+    def approve_downloads(self, request, qs):
+        for pending_dl in qs:
+            dl = Download(
+                    title=pending_dl.title,
+                    category=pending_dl.category,
+                    description=pending_dl.description,
+                    html=pending_dl.html,
+                    file=pending_dl.file,
+                    user=pending_dl.user,
+                    date_added=datetime.datetime.now(),
+                    ip_address=pending_dl.ip_address,
+                    hits=0,
+                    average_score=0.0,
+                    total_votes=0,
+                    is_public=True)
+            dl.save()
+
+            # If we don't do this, the actual file will be deleted when
+            # the pending download is deleted.
+            pending_dl.file = None
+            pending_dl.delete()
+
+    approve_downloads.short_description = "Approve selected downloads"
+
+
 class DownloadAdmin(admin.ModelAdmin):
     exclude = ('html', )
     list_display = ('title', 'user', 'category', 'date_added', 'ip_address',
@@ -33,6 +69,7 @@
     date_hierarchy = 'vote_date'
 
 
+admin.site.register(PendingDownload, PendingDownloadAdmin)
 admin.site.register(Download, DownloadAdmin)
 admin.site.register(Category, CategoryAdmin)
 admin.site.register(AllowedExtension)
--- a/gpp/downloads/forms.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/downloads/forms.py	Sat May 01 21:53:59 2010 +0000
@@ -6,7 +6,7 @@
 from django import forms
 from django.conf import settings
 
-from downloads.models import Download
+from downloads.models import PendingDownload
 from downloads.models import AllowedExtension
 
 
@@ -34,7 +34,7 @@
         raise forms.ValidationError('The file extension "%s" is not allowed.' % ext)
 
     class Meta:
-        model = Download
+        model = PendingDownload
         fields = ('title', 'category', 'description', 'file')
         
     class Media:
--- a/gpp/downloads/models.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/downloads/models.py	Sat May 01 21:53:59 2010 +0000
@@ -45,16 +45,44 @@
                 is_public=True).select_related()
 
 
-class Download(models.Model):
-    """Model to represent a download."""
+class DownloadBase(models.Model):
+    """Abstract model to collect common download fields."""
     title = models.CharField(max_length=128)
     category = models.ForeignKey(Category)
     description = models.TextField()
     html = models.TextField(blank=True)
     file = models.FileField(upload_to=download_path)
     user = models.ForeignKey(User)
-    date_added = models.DateTimeField(auto_now_add=True)
+    date_added = models.DateTimeField()
     ip_address = models.IPAddressField('IP Address')
+
+    class Meta:
+        abstract = True
+
+    def size(self):
+        return filesizeformat(self.file.size)
+
+
+class PendingDownload(DownloadBase):
+    """This model represents pending downloads created by users. These pending
+    downloads must be approved by an admin before they turn into "real"
+    Downloads and are visible on site.
+    """
+    class Meta:
+        ordering = ('date_added', )
+
+    def __unicode__(self):
+        return self.title
+
+    def save(self, *args, **kwargs):
+        if not self.pk:
+            self.date_added = datetime.datetime.now()
+        self.html = site_markup(self.description)
+        super(PendingDownload, self).save(*args, **kwargs)
+
+
+class Download(DownloadBase):
+    """Model to represent a download."""
     hits = models.IntegerField(default=0)
     average_score = models.FloatField(default=0.0)
     total_votes = models.IntegerField(default=0)
@@ -83,9 +111,6 @@
         self.average_score = total_score / self.total_votes
         return self.average_score
 
-    def size(self):
-        return filesizeformat(self.file.size)
-
 
 class AllowedExtensionManager(models.Manager):
     def get_extension_list(self):
--- a/gpp/downloads/views.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/downloads/views.py	Sat May 01 21:53:59 2010 +0000
@@ -161,7 +161,6 @@
             dl = form.save(commit=False)
             dl.user = request.user
             dl.ip_address = request.META.get('REMOTE_ADDR', None)
-            dl.is_public = False
             dl.save()
             email_admins('New download for approval', """Hello,
 
--- a/gpp/forums/admin.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/forums/admin.py	Sat May 01 21:53:59 2010 +0000
@@ -9,6 +9,7 @@
 from forums.models import Post
 from forums.models import FlaggedPost
 from forums.models import TopicLastVisit
+import bio.badges
 
 
 class CategoryAdmin(admin.ModelAdmin):
@@ -47,6 +48,17 @@
 
 class FlaggedPostAdmin(admin.ModelAdmin):
     list_display = ('__unicode__', 'flag_date', 'get_post_url')
+    actions = ('accept_flags', )
+
+    def accept_flags(self, request, qs):
+        """This admin action awards a security pin to the user who reported
+        the post and then deletes the flagged post object.
+        """
+        for flag in qs:
+            bio.badges.award_badge(bio.badges.SECURITY_PIN, flag.user)
+            flag.delete()
+
+    accept_flags.short_description = "Accept selected flagged posts"
 
 
 class TopicLastVisitAdmin(admin.ModelAdmin):
--- a/gpp/gcalendar/admin.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/gcalendar/admin.py	Sat May 01 21:53:59 2010 +0000
@@ -7,6 +7,7 @@
 
 from gcalendar.models import Event
 from gcalendar.admin_views import google_sync
+import bio.badges
 
 
 class EventAdmin(admin.ModelAdmin):
@@ -47,6 +48,9 @@
                 event.save()
                 count += 1
 
+                if event.status == Event.NEW_APRV:
+                    bio.badges.award_badge(bio.badges.CALENDAR_PIN, event.user)
+
         msg = "1 event was" if count == 1 else "%d events were" % count
         msg += " approved."
         self.message_user(request, msg)
--- a/gpp/news/admin.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/news/admin.py	Sat May 01 21:53:59 2010 +0000
@@ -1,32 +1,52 @@
 """
 This file contains the automatic admin site definitions for the News models.
 """
+import datetime
 
 from django.contrib import admin
 from django.conf import settings
+from django.core.cache import cache
 
 from news.models import PendingStory
 from news.models import Story
 from news.models import Category
 
 class PendingStoryAdmin(admin.ModelAdmin):
-   list_display = ('title', 'date_submitted', 'submitter')
-   list_filter = ('date_submitted', )
-   search_fields = ('title', 'short_text', 'long_text')
-   date_hierarchy = 'date_submitted'
+    list_display = ('title', 'date_submitted', 'submitter')
+    list_filter = ('date_submitted', )
+    search_fields = ('title', 'short_text', 'long_text')
+    date_hierarchy = 'date_submitted'
+    actions = ('approve_story', )
 
-   class Media:
-      js = settings.GPP_THIRD_PARTY_JS['tiny_mce']
+    def approve_story(self, request, qs):
+        for pending_story in qs:
+            story = Story(
+                    title=pending_story.title,
+                    submitter=pending_story.submitter,
+                    category=pending_story.category,
+                    short_text=pending_story.short_text,
+                    long_text=pending_story.long_text,
+                    date_submitted=datetime.datetime.now(),
+                    allow_comments=pending_story.allow_comments,
+                    tags=pending_story.tags)
+            story.save()
+            pending_story.delete()
+            cache.delete('home_news')
+
+    approve_story.short_description = "Approve selected pending stories"
+
+    class Media:
+        js = settings.GPP_THIRD_PARTY_JS['tiny_mce']
 
 
 class StoryAdmin(admin.ModelAdmin):
-   list_display = ('title', 'date_published', 'submitter', 'category')
-   list_filter = ('date_published', 'category')
-   search_fields = ('title', 'short_text', 'long_text')
-   date_hierarchy = 'date_published'
+    list_display = ('title', 'date_submitted', 'submitter', 'category')
+    list_filter = ('date_submitted', 'category')
+    search_fields = ('title', 'short_text', 'long_text')
+    date_hierarchy = 'date_submitted'
 
-   class Media:
-      js = settings.GPP_THIRD_PARTY_JS['tiny_mce']
+    class Media:
+        js = settings.GPP_THIRD_PARTY_JS['tiny_mce']
 
 
 admin.site.register(Category)
--- a/gpp/news/feeds.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/news/feeds.py	Sat May 01 21:53:59 2010 +0000
@@ -22,7 +22,7 @@
         return copyright_str()
     
     def items(self):
-        return Story.objects.order_by('-date_published')[:5]
+        return Story.objects.order_by('-date_submitted')[:5]
 
     def item_title(self, item):
         return item.title
@@ -34,7 +34,7 @@
         return get_full_name(item.submitter)
 
     def item_pubdate(self, item):
-        return item.date_published
+        return item.date_submitted
 
     def item_categories(self, item):
         return (item.category.title, )
--- a/gpp/news/models.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/news/models.py	Sat May 01 21:53:59 2010 +0000
@@ -25,32 +25,29 @@
         ordering = ('title', )
 
 
-class PendingStory(models.Model):
-    """Stories submitted by users are held pending admin approval"""
+class StoryBase(models.Model):
+    """Abstract model to collect common fields."""
     title = models.CharField(max_length=255)
     submitter = models.ForeignKey(User)
     category = models.ForeignKey(Category)
     short_text = models.TextField()
     long_text = models.TextField(blank=True)
-    date_submitted = models.DateTimeField(auto_now_add=True, db_index=True)
+    date_submitted = models.DateTimeField(db_index=True)
     allow_comments = models.BooleanField(default=True)
-    approved = models.BooleanField(default=False)
     tags = TagField()
 
+    class Meta:
+        abstract = True
+
+
+class PendingStory(StoryBase):
+    """Stories submitted by users are held pending admin approval"""
+
     def save(self, *args, **kwargs):
-        if self.approved:
-            Story.objects.create(title=self.title,
-                    submitter=self.submitter,
-                    category=self.category,
-                    short_text=self.short_text,
-                    long_text=self.long_text,
-                    allow_comments=self.allow_comments,
-                    date_published=datetime.datetime.now(),
-                    tags=self.tags)
-            self.delete()
-            cache.delete('home_news')
-        else:
-            super(PendingStory, self).save(*args, **kwargs)
+        if not self.pk:
+            self.date_submitted = datetime.datetime.now()
+
+        super(PendingStory, self).save(*args, **kwargs)
 
     def __unicode__(self):
         return self.title
@@ -60,16 +57,8 @@
         verbose_name_plural = 'Pending Stories'
 
 
-class Story(models.Model):
+class Story(StoryBase):
     """Model for news stories"""
-    title = models.CharField(max_length=255)
-    submitter = models.ForeignKey(User)
-    category = models.ForeignKey(Category)
-    short_text = models.TextField()
-    long_text = models.TextField(blank=True)
-    allow_comments = models.BooleanField(default=True)
-    date_published = models.DateTimeField(db_index=True)
-    tags = TagField()
 
     @models.permalink
     def get_absolute_url(self):
@@ -79,13 +68,13 @@
         return self.title
 
     class Meta:
-        ordering = ('-date_published', )
+        ordering = ('-date_submitted', )
         verbose_name_plural = 'Stories'
 
     def can_comment_on(self):
         now = datetime.datetime.now()
-        delta = now - self.date_published
-        return delta.days < 30
+        delta = now - self.date_submitted
+        return self.allow_comments and delta.days < 30
 
     def save(self, *args, **kwargs):
         super(Story, self).save(*args, **kwargs)
--- a/gpp/news/views.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/news/views.py	Sat May 01 21:53:59 2010 +0000
@@ -57,7 +57,7 @@
 #######################################################################
 
 def archive_index(request):
-   dates = Story.objects.dates('date_published', 'month', order='DESC')
+   dates = Story.objects.dates('date_submitted', 'month', order='DESC')
    return render_to_response('news/archive_index.html', {
       'title': 'News Archive',
       'dates': dates, 
@@ -68,7 +68,7 @@
 #######################################################################
 
 def archive(request, year, month, page=1):
-   stories = Story.objects.filter(date_published__year=year, date_published__month=month)
+   stories = Story.objects.filter(date_submitted__year=year, date_submitted__month=month)
    paginator = create_paginator(stories)
    try:
       the_page = paginator.page(int(page))
@@ -144,7 +144,7 @@
    stories = stories.filter(
          Q(title__icontains=query_text) |
          Q(short_text__icontains=query_text) |
-         Q(long_text__icontains=query_text)).order_by('-date_published')
+         Q(long_text__icontains=query_text)).order_by('-date_submitted')
 
    paginator = create_paginator(stories)
    try:
--- a/gpp/templates/bio/view_profile.html	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/templates/bio/view_profile.html	Sat May 01 21:53:59 2010 +0000
@@ -55,6 +55,16 @@
    {% endif %}
    <tr><th>Elsewhere</th><td>{% elsewhere_links subject %}</td></tr>
    <tr><th>Time Zone</th><td>{{ profile.time_zone }}</td></tr>
+   <tr><th>Badges</th><td>
+         {% if badge_collection %}
+         <table id="badge_summary">
+         <tr><th>Badge</th><th>Qty.</th><th>Name</th><th>Description</th></tr>
+         {% for bo in badge_collection %}
+         <tr><td>{{ bo.badge.html|safe }}</td><td>{{ bo.count }}</td><td>{{ bo.badge.name }}</td><td>{{ bo.badge.description }}</td></tr>
+         {% endfor %}
+         </table>
+         {% endif %}
+      </td></tr>
 </table>
 </div>
 {% if this_is_me %}
--- a/gpp/templates/core/admin_dashboard.html	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/templates/core/admin_dashboard.html	Sat May 01 21:53:59 2010 +0000
@@ -20,10 +20,10 @@
 <li><a href="/admin/news/pendingstory/">News</a>: {{ new_stories }}</li>
 {% endif %}
 {% if new_downloads %}
-<li><a href="/admin/downloads/download/">Downloads</a>: {{ new_downloads }}</li>
+<li><a href="/admin/downloads/pendingdownload/">Downloads</a>: {{ new_downloads }}</li>
 {% endif %}
 {% if new_links %}
-<li><a href="/admin/weblinks/link/">New Links</a>: {{ new_links }}</li>
+<li><a href="/admin/weblinks/pendinglink/">New Links</a>: {{ new_links }}</li>
 {% endif %}
 {% if broken_links %}
 <li><a href="/admin/weblinks/flaggedlink/">Broken Links</a>: {{ broken_links }}</li>
--- a/gpp/templates/forums/display_post.html	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/templates/forums/display_post.html	Sat May 01 21:53:59 2010 +0000
@@ -10,6 +10,9 @@
       {% if post.user_profile.location %}
       Location: {{ post.user_profile.location }}<br />
       {% endif %}
+      {% for bo in post.user_profile.badge_ownership %}
+         <img src="{{ bo.badge.image.url }}" alt="{{ bo.badge_count_str }}" title="{{ bo.badge_count_str }}" />
+      {% endfor %}
       {% if user.is_authenticated %}
       <p>
       <a href="{% url messages-compose_to post.user.username %}">
--- a/gpp/templates/news/category_index.html	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/templates/news/category_index.html	Sat May 01 21:53:59 2010 +0000
@@ -20,7 +20,7 @@
       <ul>
       {% for story in story_set %}
          <li><a href="{{ story.get_absolute_url }}">{{ story.title }}</a> 
-            - {{ story.date_published|date:"F d, Y" }}</li>
+            - {{ story.date_submitted|date:"F d, Y" }}</li>
       {% endfor %}
       </ul>
    {% else %}
--- a/gpp/templates/news/story.html	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/templates/news/story.html	Sat May 01 21:53:59 2010 +0000
@@ -15,7 +15,7 @@
 {% block news_content %}
 <h3>{{ story.title }}</h3>
 <div class="news-details">
-   Submitted by {{ story.submitter.username }} on {{ story.date_published|date:"F d, Y" }}.
+   Submitted by {{ story.submitter.username }} on {{ story.date_submitted|date:"F d, Y" }}.
 </div>
 <hr />
 <div class="news-content">
--- a/gpp/templates/news/story_summary.html	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/templates/news/story_summary.html	Sat May 01 21:53:59 2010 +0000
@@ -7,7 +7,7 @@
 <h4><a href="{{ story.get_absolute_url }}">{{ story.title }}</a></h4>
 {% endif %}
 <div class="news-details">
-   Submitted by {{ story.submitter.username }} on {{ story.date_published|date:"F d, Y" }}.
+   Submitted by {{ story.submitter.username }} on {{ story.date_submitted|date:"F d, Y" }}.
 </div>
 <a href="{% url news.views.category category=story.category.id page=1 %}">
 <img src="{{ story.category.icon.url }}" alt="{{ story.category.title }}" title="{{ story.category.title }}" 
--- a/gpp/weblinks/admin.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/weblinks/admin.py	Sat May 01 21:53:59 2010 +0000
@@ -1,7 +1,9 @@
 """This file contains the automatic admin site definitions for the weblinks models"""
+import datetime
 
 from django.contrib import admin
 from weblinks.models import Category
+from weblinks.models import PendingLink
 from weblinks.models import Link
 from weblinks.models import FlaggedLink
 
@@ -11,6 +13,27 @@
     readonly_fields = ('count', )
 
 
+class PendingLinkAdmin(admin.ModelAdmin):
+    list_display = ('title', 'url', 'user', 'category', 'date_added')
+    raw_id_fields = ('user', )
+    actions = ('approve_links', )
+
+    def approve_links(self, request, qs):
+        for pending_link in qs:
+            link = Link(category=pending_link.category,
+                    title=pending_link.title,
+                    url=pending_link.url,
+                    description=pending_link.description,
+                    user=pending_link.user,
+                    date_added=datetime.datetime.now(),
+                    hits=0,
+                    is_public=True)
+            link.save()
+            pending_link.delete()
+
+    approve_links.short_description = "Approve selected links"
+
+
 class LinkAdmin(admin.ModelAdmin):
     list_display = ('title', 'url', 'category', 'date_added', 'hits', 'is_public')
     list_filter = ('date_added', 'is_public', 'category')
@@ -27,5 +50,6 @@
     raw_id_fields = ('user', )
 
 admin.site.register(Category, CategoryAdmin)
+admin.site.register(PendingLink, PendingLinkAdmin)
 admin.site.register(Link, LinkAdmin)
 admin.site.register(FlaggedLink, FlaggedLinkAdmin)
--- a/gpp/weblinks/forms.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/weblinks/forms.py	Sat May 01 21:53:59 2010 +0000
@@ -3,7 +3,7 @@
 """
 
 from django import forms
-from weblinks.models import Link
+from weblinks.models import PendingLink, Link
 
 class SearchForm(forms.Form):
    '''Weblinks search form'''
@@ -26,5 +26,5 @@
       raise forms.ValidationError('That link already exists in our database.')
 
    class Meta:
-      model = Link
-      exclude = ('user', 'date_added', 'hits', 'is_public')
+      model = PendingLink
+      exclude = ('user', 'date_added')
--- a/gpp/weblinks/models.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/weblinks/models.py	Sat May 01 21:53:59 2010 +0000
@@ -1,6 +1,8 @@
 """
 This module contains the models for the weblinks application.
 """
+import datetime
+
 from django.db import models
 from django.contrib import auth
 
@@ -26,14 +28,21 @@
                 is_public=True).select_related()
 
 
-class Link(models.Model):
-    """Model to represent a web link"""
+class LinkBase(models.Model):
+    """Abstract model to aggregate common fields of a web link."""
     category = models.ForeignKey(Category)
     title = models.CharField(max_length=128)
     url = models.URLField(verify_exists=False, db_index=True)
     description = models.TextField(blank=True)
     user = models.ForeignKey(auth.models.User)
-    date_added = models.DateField(auto_now_add=True)
+    date_added = models.DateField()
+
+    class Meta:
+        abstract = True
+
+
+class Link(LinkBase):
+    """Model to represent a web link"""
     hits = models.IntegerField(default=0)
     is_public = models.BooleanField(default=False, db_index=True)
 
@@ -52,6 +61,22 @@
         return ('weblinks-link_detail', [str(self.id)])
 
 
+class PendingLink(LinkBase):
+    """This model represents links that users submit. They must be approved by
+    an admin before they become visible on the site.
+    """
+    class Meta:
+        ordering = ('date_added', )
+
+    def __unicode__(self):
+        return self.title
+
+    def save(self, *args, **kwargs):
+        if not self.pk:
+            self.date_added = datetime.datetime.now()
+        super(PendingLink, self).save(*args, **kwargs)
+
+
 class FlaggedLinkManager(models.Manager):
 
     def create(self, link, user):
--- a/gpp/weblinks/views.py	Wed Apr 28 03:00:31 2010 +0000
+++ b/gpp/weblinks/views.py	Sat May 01 21:53:59 2010 +0000
@@ -73,7 +73,6 @@
       if add_form.is_valid():
          new_link = add_form.save(commit=False)
          new_link.user = request.user
-         new_link.is_public = False
          new_link.save()
          email_admins('New link for approval', """Hello,
 
--- a/media/css/base.css	Wed Apr 28 03:00:31 2010 +0000
+++ b/media/css/base.css	Sat May 01 21:53:59 2010 +0000
@@ -359,3 +359,9 @@
 #forums-post-list dd.even {
    background-color:#e5ecf9;
 }
+#badge_summary {
+   border-collapse:collapse;
+}
+#badge_summary th, #badge_summary td {
+   border: 1px solid teal;
+}