Browse Source

Post voting system

Ralfp 12 years ago
parent
commit
e593531526

+ 4 - 1
misago/forumroles/fixtures.py

@@ -17,7 +17,7 @@ def load_fixtures():
                           'can_soft_delete_own_posts': True,
                           'can_upvote_posts': True,
                           'can_downvote_posts': True,
-                          'can_see_posts_scores': '2',
+                          'can_see_posts_scores': 2,
                           'can_see_votes': True,
                           'can_make_polls': True,
                           'can_vote_in_polls': True,
@@ -55,6 +55,7 @@ def load_fixtures():
                           'can_soft_delete_own_posts': True,
                           'can_upvote_posts': True,
                           'can_downvote_posts': True,
+                          'can_see_posts_scores': 1,
                           'can_make_polls': True,
                           'can_vote_in_polls': True,
                           'can_upload_attachments': True,
@@ -77,6 +78,7 @@ def load_fixtures():
                           'can_soft_delete_own_posts': True,
                           'can_upvote_posts': True,
                           'can_downvote_posts': True,
+                          'can_see_posts_scores': 1,
                           'can_make_polls': True,
                           'can_vote_in_polls': True,
                           'can_download_attachments': True,
@@ -90,6 +92,7 @@ def load_fixtures():
                           'can_see_forum_contents': True,
                           'can_read_threads': '2',
                           'can_download_attachments': True,
+                          'can_see_posts_scores': 1,
                           })
     role.save(force_insert=True)
 

+ 0 - 1
misago/forums/views.py

@@ -278,7 +278,6 @@ class Edit(FormWidget):
             target.prune_last = form.cleaned_data['prune_last']
 
         if form.cleaned_data['parent'].pk != target.parent.pk:
-            print 'MOVE FORUM!'
             target.move_to(form.cleaned_data['parent'], 'last-child')
             self.request.monitor['acl_version'] = int(self.request.monitor['acl_version']) + 1
 

+ 0 - 1
misago/newsfeed/views.py

@@ -7,7 +7,6 @@ def newsfeed(request):
     follows = []
     for user in request.user.follows.iterator():
         follows.append(user.pk)
-    print follows
     queryset = []
     if follows:
         queryset = Post.objects.filter(forum_id__in=request.acl.threads.get_readable_forums(request.acl))

+ 81 - 0
misago/ranks/fixtures.py

@@ -1,8 +1,85 @@
 from misago.ranks.models import Rank
+from misago.settings.fixtures import load_settings_fixture, update_settings_fixture
 from misago.utils import ugettext_lazy as _
 from misago.utils import get_msgid
 
+
+settings_fixtures = (
+    # Users Ranking Settings
+    ('ranking', {
+        'name': _("Members Ranking"),
+        'description': _("Those settings control mechanisms of members activity ranking which allows you to gamificate your forum."),
+        'settings': (
+            ('ranking_inflation', {
+                'value':        5,
+                'type':         "integer",
+                'input':        "text",
+                'extra':        {'min': 0, 'max': 99},
+                'separator':    _("Basic Ranking Settings"),
+                'name':         _("Ranking Inflation"),
+                'description':  _("Enter size of ranking scores inflation in percent. Scores inflation is important mechanism that allows ranking self-regulation, punishing inactivity and requiring users to remain active in order to remain high in ranking."),
+            }),
+            ('ranking_positions_visible', {
+                'value':        True,
+                'type':         "boolean",
+                'input':        "yesno",
+                'name':         _("Dont Keep Users Scores Secret"),
+                'description':  _("Changing this to yes will cause forum to display user position in ranking on his profile page."),
+            }),
+            ('ranking_scores_visible', {
+                'value':        True,
+                'type':         "boolean",
+                'input':        "yesno",
+                'name':         _("Dont Keep Users Scores Secret"),
+                'description':  _("Changing this to yes will cause forum to display user score on his profile page."),
+            }),
+            ('score_reward_new_thread', {
+                'value':        50,
+                'type':         "integer",
+                'input':        "text",
+                'separator':    _("Posting Rewards"),
+                'name':         _("New Thread Reward"),
+                'description':  _("Score user will receive (or lose) whenever he posts new thread."),
+            }),
+            ('score_reward_new_post', {
+                'value':        100,
+                'type':         "integer",
+                'input':        "text",
+                'name':         _("New Reply Reward"),
+                'description':  _("Score user will receive (or lose) whenever he posts new reply in thread."),
+            }),
+            ('score_reward_new_post_cooldown', {
+                'value':        180,
+                'type':         "integer",
+                'input':        "text",
+                'extra':        {'min': 0},
+                'name':         _("Reward Cooldown"),
+                'description':  _("Minimal time (in seconds) that has to pass between postings for new message to receive karma vote. This is useful to combat flood."),
+            }),
+            ('score_reward_karma_positive', {
+                'value':        20,
+                'type':         "integer",
+                'input':        "text",
+                'extra':        {'min': 0},
+                'separator':    _("Karma System"),
+                'name':         _("Upvote Reward"),
+                'description':  _("Score user will receive every time his post receives upvote."),
+            }),
+            ('score_reward_karma_negative', {
+                'value':        10,
+                'type':         "integer",
+                'input':        "text",
+                'extra':        {'min': 0},
+                'name':         _("Downvote Punishment"),
+                'description':  _("Score user will lose every time his post receives downvote."),
+            }),
+        ),
+    }),
+)
+
+
 def load_fixtures():
+    load_settings_fixture(settings_fixtures)
     Rank.objects.create(
                         name=_("Forum Team").message,
                         name_slug='forum-team',
@@ -46,3 +123,7 @@ def load_fixtures():
                         criteria="10%",
                         as_tab=True,
                         )
+
+
+def update_fixtures():
+    update_settings_fixture(settings_fixtures)

+ 4 - 1
misago/ranks/management/commands/updateranking.py

@@ -1,5 +1,6 @@
 from django.core.management.base import BaseCommand, CommandError
 from django.db.models import F
+from misago.settings.settings import Settings
 from misago.ranks.models import Rank
 from misago.users.models import User
 
@@ -29,6 +30,8 @@ class Command(BaseCommand):
                 defaulted_ranks = True
 
         # Inflate scores
-        User.objects.all().update(score=F('score') * 0.95) # TODO: Ranking system SETTINGS!
+        settings = Settings()
+        inflation = float(100 - settings['ranking_inflation']) / 100
+        User.objects.all().update(score=F('score') * inflation, ranking=0)
 
         self.stdout.write('Users ranking for has been updated.\n')

+ 1 - 0
misago/ranks/models.py

@@ -1,3 +1,4 @@
+import math
 from django.conf import settings
 from django.db import models, connection, transaction
 from django.utils.translation import ugettext_lazy as _

+ 54 - 2
misago/threads/acl.py

@@ -464,14 +464,14 @@ class ThreadsACL(BaseACL):
             forum_role = self.acl[forum.pk]
             return forum_role['can_delete_threads']
         except KeyError:
-            raise false
+            return False
 
     def can_see_deleted_posts(self, forum):
         try:
             forum_role = self.acl[forum.pk]
             return forum_role['can_delete_posts']
         except KeyError:
-            raise false
+            return False
 
     def allow_deleted_post_view(self, forum):
         try:
@@ -480,6 +480,58 @@ class ThreadsACL(BaseACL):
                 raise ACLError404()
         except KeyError:
             raise ACLError404()
+        
+    def can_upvote_posts(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_upvote_posts']
+        except KeyError:
+            return False
+        
+    def can_downvote_posts(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_downvote_posts']
+        except KeyError:
+            return False
+        
+    def can_see_post_score(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_see_posts_scores']
+        except KeyError:
+            return False
+        
+    def can_see_post_votes(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_see_votes']
+        except KeyError:
+            return False
+
+    def allow_post_upvote(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_upvote_posts']:
+                raise ACLError403(_("You cannot upvote posts in this forum."))
+        except KeyError:
+            raise ACLError403(_("You cannot upvote posts in this forum."))
+
+    def allow_post_downvote(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_downvote_posts']:
+                raise ACLError403(_("You cannot downvote posts in this forum."))
+        except KeyError:
+            raise ACLError403(_("You cannot downvote posts in this forum."))
+        
+    def allow_post_votes_view(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_see_votes']:
+                raise ACLError403(_("You don't have permission to see who voted on this post."))
+        except KeyError:
+            raise ACLError403(_("You don't have permission to see who voted on this post."))
 
 
 def build_forums(acl, perms, forums, forum_roles):

+ 3 - 0
misago/threads/urls.py

@@ -25,6 +25,9 @@ urlpatterns = patterns('misago.threads.views',
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/delete/$', 'DeleteView', name="post_delete", kwargs={'mode': 'delete_post'}),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/hide/$', 'DeleteView', name="post_hide", kwargs={'mode': 'hide_post'}),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/info/$', 'DetailsView', name="post_info"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/upvote/$', 'UpvotePostView', name="post_upvote"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/downvote/$', 'DownvotePostView', name="post_downvote"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/votes/$', 'KarmaVotesView', name="post_votes"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/$', 'ChangelogView', name="changelog"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/$', 'ChangelogDiffView', name="changelog_diff"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/revert/$', 'ChangelogRevertView', name="changelog_revert"),

+ 1 - 0
misago/threads/views/__init__.py

@@ -2,6 +2,7 @@ from misago.threads.views.list import *
 from misago.threads.views.jumps import *
 from misago.threads.views.thread import *
 from misago.threads.views.delete import *
+from misago.threads.views.karmas import *
 from misago.threads.views.posting import *
 from misago.threads.views.details import *
 from misago.threads.views.changelog import *

+ 0 - 1
misago/threads/views/details.py

@@ -4,7 +4,6 @@ from misago.forums.models import Forum
 from misago.threads.models import Thread, Post
 from misago.threads.views.base import BaseView
 from misago.views import error403, error404
-from misago.utils import make_pagination, slugify
 
 class DetailsView(BaseView):
     def fetch_target(self, kwargs):

+ 85 - 3
misago/threads/views/jumps.py

@@ -9,7 +9,7 @@ from misago.csrf.decorators import check_csrf
 from misago.forums.models import Forum
 from misago.messages import Message
 from misago.readstracker.trackers import ThreadsTracker
-from misago.threads.models import Thread, Post
+from misago.threads.models import Thread, Post, Karma
 from misago.threads.views.base import BaseView
 from misago.views import error403, error404
 from misago.utils import make_pagination
@@ -110,10 +110,10 @@ class ShowHiddenRepliesView(JumpView):
 class WatchThreadView(JumpView):
     def get_retreat(self):
         return redirect(self.request.POST.get('retreat', reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug})))
-    
+
     def update_watcher(self, request, watcher):
         request.messages.set_flash(Message(_('This thread has been added to your watched threads list.')), 'success', 'threads')
-    
+
     def make_jump(self):
         @block_guest
         @check_csrf
@@ -158,3 +158,85 @@ class UnwatchEmailThreadView(WatchThreadView):
     def update_watcher(self, request, watcher):
         watcher.email = False
         request.messages.set_flash(Message(_('You will no longer receive e-mails with notifications when somebody replies to this thread.')), 'success', 'threads')
+
+
+class UpvotePostView(JumpView):        
+    def make_jump(self):
+        @block_guest
+        @check_csrf
+        def view(request):
+            if self.post.user_id == request.user.id:
+                return error404(request)
+            self.check_acl(request)
+            try:
+                vote = Karma.objects.get(user=request.user, post=self.post)
+                if self.thread.start_post_id == self.post.pk:
+                    if vote.score > 0:
+                        self.thread.upvotes -= 1
+                    else:
+                        self.thread.downvotes -= 1
+                if vote.score > 0:
+                    self.post.upvotes -= 1
+                    request.user.karma_given_p -= 1
+                    if self.post.user_id:
+                        self.post.user.karma_p -= 1
+                else:
+                    self.post.downvotes -= 1
+                    request.user.karma_given_n -= 1
+                    if self.post.user_id:
+                        self.post.user.karma_n -= 1
+            except Karma.DoesNotExist:
+                vote = Karma()
+            vote.forum = self.forum
+            vote.thread = self.thread
+            vote.post = self.post
+            vote.user = request.user
+            vote.user_name = request.user.username
+            vote.user_slug = request.user.username_slug
+            vote.date = timezone.now()
+            vote.ip = request.session.get_ip(request)
+            vote.agent = request.META.get('HTTP_USER_AGENT')
+            self.make_vote(request, vote)
+            request.messages.set_flash(Message(_('Your vote has been saved.')), 'success', 'threads_%s' % self.post.pk)
+            if vote.pk:
+                vote.save(force_update=True)
+            else:
+                vote.save(force_insert=True)
+            if self.thread.start_post_id == self.post.pk:
+                if vote.score > 0:
+                    self.thread.upvotes += 1
+                else:
+                    self.thread.downvotes += 1
+                self.thread.save(force_update=True)
+            if vote.score > 0:
+                self.post.upvotes += 1
+                request.user.karma_given_p += 1
+                if self.post.user_id:
+                    self.post.user.karma_p += 1
+                    self.post.user.score += request.settings['score_reward_karma_positive']
+            else:
+                self.post.downvotes += 1
+                request.user.karma_given_n += 1
+                if self.post.user_id:
+                    self.post.user.karma_n += 1
+                    self.post.user.score -= request.settings['score_reward_karma_negative']
+            self.post.save(force_update=True)
+            request.user.save(force_update=True)
+            if self.post.user_id:
+                self.post.user.save(force_update=True)
+            return self.redirect(self.post)
+        return view(self.request)
+    
+    def check_acl(self, request):
+        request.acl.threads.allow_post_upvote(self.forum)
+    
+    def make_vote(self, request, vote):
+        vote.score = 1
+
+
+class DownvotePostView(UpvotePostView):
+    def check_acl(self, request):
+        request.acl.threads.allow_post_downvote(self.forum)
+    
+    def make_vote(self, request, vote):
+        vote.score = -1

+ 43 - 0
misago/threads/views/karmas.py

@@ -0,0 +1,43 @@
+from django.template import RequestContext
+from misago.acl.utils import ACLError403, ACLError404
+from misago.forums.models import Forum
+from misago.threads.models import Thread, Post
+from misago.threads.views.base import BaseView
+from misago.views import error403, error404
+
+class KarmaVotesView(BaseView):
+    def fetch_target(self, kwargs):
+        self.thread = Thread.objects.get(pk=kwargs['thread'])
+        self.forum = self.thread.forum
+        self.proxy = Forum.objects.parents_aware_forum(self.forum)
+        self.request.acl.forums.allow_forum_view(self.forum)
+        self.request.acl.threads.allow_thread_view(self.request.user, self.thread)
+        self.parents = Forum.objects.forum_parents(self.forum.pk, True)
+        self.post = Post.objects.select_related('user').get(pk=kwargs['post'], thread=self.thread.pk)
+        self.post.thread = self.thread
+        self.request.acl.threads.allow_post_view(self.request.user, self.thread, self.post)
+        self.request.acl.threads.allow_post_votes_view(self.forum)
+
+    def __call__(self, request, **kwargs):
+        self.request = request
+        self.forum = None
+        self.thread = None
+        self.post = None
+        try:
+            self.fetch_target(kwargs)
+        except (Forum.DoesNotExist, Thread.DoesNotExist, Post.DoesNotExist):
+            return error404(self.request)
+        except ACLError403 as e:
+            return error403(request, e.message)
+        except ACLError404 as e:
+            return error404(request, e.message)
+        return request.theme.render_to_response('threads/karmas.html',
+                                                {
+                                                 'forum': self.forum,
+                                                 'parents': self.parents,
+                                                 'thread': self.thread,
+                                                 'post': self.post,
+                                                 'upvotes': self.post.karma_set.filter(score=1),
+                                                 'downvotes': self.post.karma_set.filter(score=-1),
+                                                 },
+                                                context_instance=RequestContext(request))

+ 11 - 0
misago/threads/views/posting.py

@@ -1,3 +1,4 @@
+from datetime import timedelta
 from django.core.urlresolvers import reverse
 from django.shortcuts import redirect
 from django.template import RequestContext
@@ -168,11 +169,14 @@ class PostingView(BaseView):
                         and self.thread.last_poster_id == request.user.id):
                         # Overtake posting
                         post = self.thread.last_post
+                        post.appended = True
                         post.moderated = moderation
                         post.date = now
                         post.post = '%s\n\n- - -\n**%s**\n%s' % (post.post, _("Added on %(date)s:") % {'date': date(now, 'SHORT_DATETIME_FORMAT')}, form.cleaned_data['post'])
                         md, post.post_preparsed = post_markdown(request, post.post)
                         post.save(force_update=True)
+                        thread.last = now
+                        thread.save(force_update=True)
                         # Ignore rest of posting action
                         request.messages.set_flash(Message(_("Your reply has been added to previous one.")), 'success', 'threads_%s' % post.pk)
                         return self.redirect_to_post(post)
@@ -191,6 +195,7 @@ class PostingView(BaseView):
                                                    date=now,
                                                    moderated=moderation,
                                                    )
+                        post.appended = False
                 elif changed_post:
                     # Change message
                     post = self.post
@@ -231,6 +236,9 @@ class PostingView(BaseView):
                     thread.start_poster_slug = request.user.username_slug
                     if request.user.rank and request.user.rank.style:
                         thread.start_poster_style = request.user.rank.style
+                    # Reward user for posting new thread?
+                    if request.user.last_post < timezone.now() - timedelta(seconds=request.settings['score_reward_new_post_cooldown']):
+                        request.user.score += request.settings['score_reward_new_thread']
 
                 # New post - increase post counters, thread score
                 # Notify quoted post author and close thread if it has hit limit
@@ -252,6 +260,9 @@ class PostingView(BaseView):
                             and thread.replies >= self.request.settings.thread_length):
                             thread.closed = True
                             post.set_checkpoint(self.request, 'limit')
+                    # Reward user for posting new post?
+                    if not post.appended and request.user.last_post < timezone.now() - timedelta(seconds=request.settings['score_reward_new_post_cooldown']):
+                        request.user.score += request.settings['score_reward_new_post']
 
                 # Update last poster data
                 if not moderation and self.mode not in ['edit_thread', 'edit_post']:

+ 8 - 2
misago/threads/views/thread.py

@@ -13,7 +13,7 @@ from misago.markdown import post_markdown
 from misago.messages import Message
 from misago.readstracker.trackers import ThreadsTracker
 from misago.threads.forms import MoveThreadsForm, SplitThreadForm, MovePostsForm, QuickReplyForm
-from misago.threads.models import Thread, Post, Change, Checkpoint
+from misago.threads.models import Thread, Post, Karma, Change, Checkpoint
 from misago.threads.views.base import BaseView
 from misago.views import error403, error404
 from misago.utils import make_pagination, slugify
@@ -47,10 +47,13 @@ class ThreadView(BaseView):
         self.read_date = self.tracker.get_read_date(self.thread)
         ignored_users = []
         if self.request.user.is_authenticated():
-            ignored_users = self.request.user.ignored_users()        
+            ignored_users = self.request.user.ignored_users()
+        posts_dict = {}
         for post in self.posts:
+            posts_dict[post.pk] = post
             post.message = self.request.messages.get_message('threads_%s' % post.pk)
             post.is_read = post.date <= self.read_date
+            post.karma_vote = None
             post.ignored = self.thread.start_post_id != post.pk and not self.thread.pk in self.request.session.get('unignore_threads', []) and post.user_id in ignored_users
             if post.ignored:
                 self.ignored = True
@@ -61,6 +64,9 @@ class ThreadView(BaseView):
         if self.watcher and last_post.date > self.watcher.last_read:
             self.watcher.last_read = timezone.now()
             self.watcher.save(force_update=True)
+        if self.request.user.is_authenticated():
+            for karma in Karma.objects.filter(post_id__in=posts_dict.keys()).filter(user=self.request.user):
+                posts_dict[karma.post_id].karma_vote = karma
 
     def get_post_actions(self):
         acl = self.request.acl.threads.get_role(self.thread.forum_id)

+ 2 - 0
misago/users/fixtures.py

@@ -1,4 +1,6 @@
 from misago.monitor.fixtures import load_monitor_fixture
+from misago.utils import ugettext_lazy as _
+from misago.utils import get_msgid
 
 monitor_fixtures = {
                   'users': 0,

+ 7 - 0
misago/users/models.py

@@ -148,6 +148,7 @@ class User(models.Model):
     following = models.PositiveIntegerField(default=0)
     followers = models.PositiveIntegerField(default=0)
     score = models.IntegerField(default=0, db_index=True)
+    ranking = models.PositiveIntegerField(default=0)
     rank = models.ForeignKey('ranks.Rank', null=True, blank=True, on_delete=models.SET_NULL)
     last_sync = models.DateTimeField(null=True, blank=True)
     follows = models.ManyToManyField('self', related_name='follows_set', symmetrical=False)
@@ -433,6 +434,12 @@ class User(models.Model):
             image_size = settings.AVATAR_SIZES[0]
         return 'http://www.gravatar.com/avatar/%s?s=%s' % (hashlib.md5(self.email).hexdigest(), image_size)
 
+    def get_ranking(self):
+        if not self.ranking:
+            self.ranking = User.objects.filter(score__gt=self.score).count() + 1
+            self.save(force_update=True)
+        return self.ranking
+
     def get_title(self):
         if self.title:
             return self.title

+ 13 - 1
static/sora/css/sora.css

@@ -860,7 +860,7 @@ td.lead-cell{color:#555555;font-weight:bold;}
 .table-users .avatar{-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;width:42px;height:42px;}
 .table-users .info-popover{background:#e3e3e3;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;padding:2px;padding-top:0px;}.table-users .info-popover i{margin:0px;}
 .table-users .info-popover:hover{background:#7d7d7d;}.table-users .info-popover:hover i{background-image:url("../img/glyphicons-halflings-white.png");}
-.table-users.list-tiny a:link,.table-users.list-tiny a:active,.table-users.list-tiny a:visited,.table-users.list-tiny a:hover{font-size:100%;}
+.table-users.list-tiny a:link,.table-users.list-tiny a:active,.table-users.list-tiny a:visited,.table-users.list-tiny a:hover{font-size:100%;font-weight:bold;}
 .table-users.list-tiny .avatar{width:22px;height:22px;}
 .table-users.list-tiny i{position:relative;top:2px;}
 .btn{background:#dcdcdc;border:1px solid #dcdcdc;*border:0;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;padding:4px 10px;color:#6f6f6f;font-weight:bold;text-shadow:none;}.btn:hover,.btn:active{background:#e1e1e1;border:1px solid #e1e1e1;*border:0;box-shadow:none;}
@@ -1008,6 +1008,17 @@ td.lead-cell{color:#555555;font-weight:bold;}
 .posts-list .well-post .post-extra .post-checkbox{float:right;margin-left:4px;}
 .posts-list .well-post .post-extra .label{margin-left:8px;margin-bottom:8px;padding:4px 5px;font-size:100%;}
 .posts-list .well-post .post-extra .label-purple{background-color:#7a43b6;}
+.posts-list .well-post .post-extra .post-rating{clear:both;padding:12px 0px;margin:0px;}.posts-list .well-post .post-extra .post-rating li{margin:0px;padding:0px 4px;}.posts-list .well-post .post-extra .post-rating li form{margin:0px;padding:0px;}
+.posts-list .well-post .post-extra .post-rating li .label{background-color:#999999;margin:0px;position:relative;top:1px;padding:3px 6px;padding-left:7px;}.posts-list .well-post .post-extra .post-rating li .label.label-success{background-color:#46a546;}
+.posts-list .well-post .post-extra .post-rating li .label.label-important{background-color:#9d261d;}
+.posts-list .well-post .post-extra .post-rating li .label:hover{background-color:#999999;}.posts-list .well-post .post-extra .post-rating li .label:hover.label-success{background-color:#46a546;}
+.posts-list .well-post .post-extra .post-rating li .label:hover.label-important{background-color:#9d261d;}
+.posts-list .well-post .post-extra .post-rating li button{padding:1px 4px;margin:0px;opacity:0.4;filter:alpha(opacity=40);}.posts-list .well-post .post-extra .post-rating li button:hover i{background-image:url("../img/glyphicons-halflings-white.png");}
+.posts-list .well-post .post-extra .post-rating li button:hover.btn-upvote{background-color:#46a546;}
+.posts-list .well-post .post-extra .post-rating li button:hover.btn-downvote{background-color:#9d261d;}
+.posts-list .well-post .post-extra .post-rating li.active button i{background-image:url("../img/glyphicons-halflings-white.png");}
+.posts-list .well-post .post-extra .post-rating li.active button.btn-upvote{background-color:#46a546;}
+.posts-list .well-post .post-extra .post-rating li.active button.btn-downvote{background-color:#9d261d;}
 .posts-list .well-post .post-nav{clear:both;margin-left:286px;overflow:auto;padding:8px 16px;padding-bottom:0px;margin-bottom:-8px;}.posts-list .well-post .post-nav .changelog{float:left;opacity:0.5;filter:alpha(opacity=50);color:#999999;}
 .posts-list .well-post .post-nav ul{margin:0px;padding:0px;}
 .posts-list .well-post .post-nav .nav-pills li{opacity:0.4;filter:alpha(opacity=40);}.posts-list .well-post .post-nav .nav-pills li form{margin:0px;}
@@ -1016,6 +1027,7 @@ td.lead-cell{color:#555555;font-weight:bold;}
 .posts-list .well-post .post-nav .nav-pills li a,.posts-list .well-post .post-nav .nav-pills li button{background-color:#c9c9c9;border:none;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;margin:0px;color:#ffffff;font-weight:bold;}.posts-list .well-post .post-nav .nav-pills li a:hover,.posts-list .well-post .post-nav .nav-pills li button:hover{background-color:#1ab2ff;}.posts-list .well-post .post-nav .nav-pills li a:hover.danger,.posts-list .well-post .post-nav .nav-pills li button:hover.danger{background-color:#d83a2e;}
 .posts-list .well-post .post-nav .nav-pills li i{background-image:url("../img/glyphicons-halflings-white.png");}
 .posts-list .well-post:hover .changelog,.posts-list .well-post:hover .nav-pills li{opacity:1;filter:alpha(opacity=100);}
+.posts-list .well-post:hover .post-rating li button{opacity:1;filter:alpha(opacity=100);}
 .posts-list .post-checkpoints{padding-top:4px;}.posts-list .post-checkpoints:last-child{margin-bottom:-24px;}
 .posts-list .post-checkpoints .checkpoint{margin:0px;color:#999999;text-align:center;}.posts-list .post-checkpoints .checkpoint span{background-color:#fcfcfc;display:inline-block;padding:4px 12px;position:relative;bottom:16px;}
 .posts-list .post-checkpoints a{color:#333333;font-weight:bold;}

+ 89 - 0
static/sora/css/sora/threads.less

@@ -215,6 +215,87 @@
       .label-purple {
         background-color: @purple;
       }
+      
+      .post-rating {
+        clear: both;
+        padding: 12px 0px;
+        margin: 0px;
+        
+        li {
+          margin: 0px;
+          padding: 0px 4px;
+          
+          form {
+            margin: 0px;
+            padding: 0px;
+          }
+          
+          .label {
+            background-color: @grayLight;
+            margin: 0px;
+            position: relative;
+            top: 1px;
+            padding: 3px 6px;
+            padding-left: 7px;
+            
+            &.label-success {
+              background-color: @green;
+            }
+            
+            &.label-important {
+              background-color: @red;
+            }
+            
+            &:hover {
+              background-color: @grayLight;
+              
+              &.label-success {
+                background-color: @green;
+              }
+              
+              &.label-important {
+                background-color: @red;
+              }
+            }
+          }
+          
+          button {
+            padding: 1px 4px;
+            margin: 0px;
+            .opacity(40);
+            
+            &:hover {
+              i {
+                background-image: url("@{iconWhiteSpritePath}");
+              }
+              
+              &.btn-upvote {
+                background-color: @green;
+              }
+              
+              &.btn-downvote {
+                background-color: @red;
+              }
+            }
+          }
+          
+          &.active {
+            button {
+              i {
+                background-image: url("@{iconWhiteSpritePath}");
+              }
+              
+              &.btn-upvote {
+                background-color: @green;
+              }
+              
+              &.btn-downvote {
+                background-color: @red;
+              }
+            }
+          }
+        }
+      }
     }
     
     .post-nav {
@@ -280,6 +361,14 @@
       .changelog, .nav-pills li {
         .opacity(100);
       }
+        
+      .post-rating {
+        li {
+          button {
+            .opacity(100);
+          }
+        }
+      }
     }
   }
   

+ 1 - 0
static/sora/css/sora/users-lists.less

@@ -39,6 +39,7 @@
     a:link, a:active,
     a:visited, a:hover {
       font-size: 100%;
+      font-weight: bold;
     }
     
     .avatar {

+ 13 - 0
templates/sora/profiles/details.html

@@ -109,6 +109,19 @@
           	{% if profile.rank %}{{ _(profile.rank.name) }}{% else %}<em>{% trans %}Not Ranked{% endtrans %}</em>{% endif %}
           </td>
         </tr>
+        {% if settings.ranking_positions_visible or settings.ranking_scores_visible %}
+        <tr>
+          <td class="span2">
+             <strong>{% if settings.ranking_positions_visible %}{% trans %}Ranking Position{% endtrans %}{% else %}{% trans %}Score{% endtrans %}{% endif %}</strong>
+          </td>
+          <td class="span4">
+            {% if settings.ranking_positions_visible %}
+            #{{ profile.get_ranking()|intcomma }}{% if settings.ranking_scores_visible %} <span class="muted">{{ profile.score|intcomma }}</span>{% endif %}
+            {% else %}{{ profile.score|intcomma }}
+            {% endif %}
+          </td>
+        </tr>
+        {% endif %}
         <tr>
           <td class="span2">
           	 <strong>{% trans %}Karma Received{% endtrans %}</strong>

+ 99 - 0
templates/sora/threads/karmas.html

@@ -0,0 +1,99 @@
+{% extends "sora/layout.html" %}
+{% load i18n %}
+{% load url from future %}
+{% import "sora/macros.html" as macros with context %}
+
+{% block title %}{{ macros.page_title(title=(_("Post #%(post)s Votes") % {'post': post.pk}),parent=thread.name) }}{% endblock %}
+
+{% block breadcrumb %}{{ super() }} <span class="divider">/</span></li>
+{% for parent in parents %}
+<li><a href="{{ parent.type|url(forum=parent.pk, slug=parent.slug) }}">{{ parent.name }}</a> <span class="divider">/</span></li>
+{% endfor %}
+<li><a href="{% url 'thread' thread=thread.pk, slug=thread.slug %}">{{ thread.name }}</a> <span class="divider">/</span></li>
+<li class="active">{% trans post=post.pk %}Post #{{ post }} Votes{% endtrans %}
+{%- endblock %}
+
+{% block content %}
+<div class="page-header">
+  <ul class="breadcrumb">
+    {{ self.breadcrumb() }}</li>
+  </ul>
+  <h1>{% trans post=post.pk %}Post #{{ post }} Votes{% endtrans %} <small>{{ thread.name }}</small></h1>
+  <ul class="unstyled thread-info">
+    <li><i class="icon-time"></i> <a href="{% url 'thread_find' thread=thread.pk, slug=thread.slug, post=post.pk %}">{{ post.date|reltimesince }}</a></li>
+    <li><i class="icon-user"></i> {% if post.user %}<a href="{% url 'user' user=post.user.pk, username=post.user.username_slug %}">{{ post.user.username }}</a>{% else %}{{ post.user_name }}{% endif %}</li>
+    <li><i class="icon-thumbs-up"></i> {{ post.upvotes - post.downvotes }}</li>
+  </ul>
+</div>
+
+<h2>{% trans %}Upvotes{% endtrans %} <small>{% trans count=upvotes|length, votes=upvotes|length|intcomma -%}
+    One upvote
+    {%- pluralize -%}
+    {{ votes }} upvotes
+    {%- endtrans %}</small></h2>
+{% if upvotes %}
+<table class="table table-striped table-users list-tiny">
+  <thead>
+    <tr>
+      <th colspan="4">{% trans %}Votes List{% endtrans %}</th>
+    </tr>
+  </thead>
+  <tbody>
+    {% for row in upvotes|batch(4, '') %}
+    <tr>
+      {% for vote in row %}
+      <td class="span3">
+        {% if vote %}
+        {{ vote_details(vote) }}
+        {% else %}
+        &nbsp;
+        {% endif %}
+      </td>
+      {% endfor %}
+    </tr>
+    {% endfor %}
+  </tbody>
+</table>
+{% else %}
+<p class="lead">{% trans %}Nobody has upvoted this post.{% endtrans %}</p>
+{% endif %}
+
+<hr>
+
+<h2>{% trans %}Downvotes{% endtrans %} <small>{% trans count=downvotes|length, votes=downvotes|length|intcomma -%}
+    One downvote
+    {%- pluralize -%}
+    {{ votes }} downvotes
+    {%- endtrans %}</small></h2>
+{% if downvotes %}
+<table class="table table-striped table-users list-tiny">
+  <thead>
+    <tr>
+      <th colspan="4">{% trans %}Votes List{% endtrans %}</th>
+    </tr>
+  </thead>
+  <tbody>
+    {% for row in downvotes|batch(4, '') %}
+    <tr>
+      {% for vote in row %}
+      <td class="span3">
+        {% if vote %}
+        {{ vote_details(vote) }}
+        {% else %}
+        &nbsp;
+        {% endif %}
+      </td>
+      {% endfor %}
+    </tr>
+    {% endfor %}
+  </tbody>
+</table>
+{% else %}
+<p class="lead">{% trans %}Nobody has downvoted this post.{% endtrans %}</p>
+{% endif %}
+{% endblock %}
+
+{% macro vote_details(vote) %}
+<div>{% if vote.user_id %}<a href="{% url 'user' user=vote.user_id, username=vote.user_slug %}">{{ vote.user_name }}</a>{% else %}<em><strong>{{ vote.user_name }}</strong></em>{% endif %}</div>
+<div class="muted{% if acl.users.can_see_users_trails() %} tooltip-top{% endif %}"{% if acl.users.can_see_users_trails() %} title="{{ vote.agent }}"{% endif %}>{{ vote.date|reldate }}{% if acl.users.can_see_users_trails() %}, {{ vote.ip }}{% endif %}</div>
+{% endmacro %}

+ 14 - 6
templates/sora/threads/thread.html

@@ -59,6 +59,7 @@
 
 <div class="posts-list">
   {% for post in posts %}
+  <div id="post-{{ post.pk }}"></div>
   {% if post.message %}{{ macros.draw_message(post.message) }}{% endif %}
   {% if post.deleted and not acl.threads.can_see_deleted_posts(forum) %}
   <div id="post-{{ post.pk }}" class="well well-post well-post-deleted{% if post.user and post.user.rank and post.user.rank.style %} {{ post.user.rank.style }}{% endif %}">
@@ -83,7 +84,7 @@
     </div>
   </div>
   {% elif post.ignored %}
-  <div id="post-{{ post.pk }}" class="well well-post well-post-deleted{% if post.user and post.user.rank and post.user.rank.style %} {{ post.user.rank.style }}{% endif %}">
+  <div class="well well-post well-post-deleted{% if post.user and post.user.rank and post.user.rank.style %} {{ post.user.rank.style }}{% endif %}">
     <div class="post-author">
       <div class="post-bit">
         <span class="lead">{% trans %}Hidden Reply{% endtrans %}</span>
@@ -149,6 +150,17 @@
         {% trans %}Protected{% endtrans %}
       </span>
       {% endif %}
+      {% if acl.threads.can_see_post_score(forum) %}
+      <ul class="nav nav-pills post-rating pull-right">
+        {% if user.is_authenticated() and user.pk != post.user_id and acl.threads.can_upvote_posts(forum) %}
+        <li{% if post.karma_vote and post.karma_vote.score > 0 %} class="active"{% endif %}><form action="{% url 'post_upvote' thread=thread.pk, slug=thread.slug, post=post.pk %}" method="post"><button type="submit" class="btn btn-upvote"{% if post.karma_vote and post.karma_vote.score > 0 %} disabled="disabled"{% endif %}><i class="icon-thumbs-up"></i></button><input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}"></form></li>
+        {% endif %}
+        <li><{% if acl.threads.can_see_post_votes(forum) %}a href="{% url 'post_votes' thread=thread.pk, slug=thread.slug, post=post.pk %}"{% else %}span{% endif %} class="label{% if (post.upvotes - post.downvotes) > 0 %} label-success{% elif (post.upvotes - post.downvotes) < 0 %} label-important{% endif %}{% if acl.threads.can_see_post_score(forum) == 2 %} tooltip-left{% endif %}"{% if acl.threads.can_see_post_score(forum) == 2 %} title="+{{ post.upvotes }} / -{{ post.downvotes }}"{% endif %}>{% if (post.upvotes - post.downvotes) > 0 %}+{% endif %}{{ post.upvotes - post.downvotes }}</{% if acl.threads.can_see_post_votes(forum) %}a{% else %}span{% endif %}></li>
+        {% if user.is_authenticated() and user.pk != post.user_id and acl.threads.can_downvote_posts(forum) %}
+        <li{% if post.karma_vote and post.karma_vote.score < 0 %} class="active"{% endif %}><form action="{% url 'post_downvote' thread=thread.pk, slug=thread.slug, post=post.pk %}" method="post"><button type="submit" class="btn btn-downvote"{% if post.karma_vote and post.karma_vote.score < 0 %} disabled="disabled"{% endif %}><i class="icon-thumbs-down"></i></button><input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}"></form></li>
+        {% endif %}
+      </ul>
+      {% endif %}
     </div>
     <div class="post-content">
       <div class="markdown">
@@ -156,7 +168,6 @@
       </div>
       {% if post.user.signature %}
       <div class="post-foot">
-        {# <p class='lead'><a href="{% url 'user' user=post.user.pk, username=post.user.username_slug %}" class="lead">{{ post.user.username }}</a>, {{ post.date|reltimesince|low }}</p> #}
         {% if post.user.signature %}
         <div class="signature">
           <div class="markdown">
@@ -167,7 +178,7 @@
       </div>
       {% endif %}
     </div>
-    <div class="post-nav">
+    <div class="post-nav">     
       {% if post.edits %}
       {% if acl.threads.can_see_changelog(user, forum, post) %}
       <a href="{% url 'changelog' thread=thread.pk, slug=thread.slug, post=post.pk %}" class="changelog tooltip-bottom" title="{% trans %}Show changelog{% endtrans %}">{% trans count=post.edits %}One edit{% pluralize %}{{ count }} edits{% endtrans %}</a>
@@ -177,9 +188,6 @@
       {% endif %}
       {% if user.is_authenticated() %}
       <ul class="nav nav-pills pull-right">
-        {% if 1 == 2 %}
-        <li><a href="#"><i class="icon-info-sign"></i> {% trans %}Info{% endtrans %}</a></li>
-        {% endif %}
         {% if post.pk == thread.start_post_id %}
         {% if acl.threads.can_delete_thread(user, forum, thread, post) == 2 -%}
             <li class="tooltip-top" title="{% trans %}Delete this thread for good{% endtrans %}"><form action="{% url 'thread_delete' thread=thread.pk, slug=thread.slug %}" class="prompt-delete-thread" method="post"><button type="submit" class="btn danger"><i class="icon-remove"></i></button><input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}"></form></li>{% endif %}