Просмотр исходного кода

- Improved paginator in threads
- Posts labels
- Jump to Post
- Stub of threads moderation

Ralfp 12 лет назад
Родитель
Сommit
91be75ddc1

+ 10 - 0
misago/readstracker/trackers.py

@@ -35,6 +35,16 @@ class ThreadsTracker(object):
             self.record = Record(user=user,forum=forum,cleared=self.cutoff)
         self.threads = self.record.get_threads()
     
+    def get_read_date(self, thread):
+        if not self.user.is_authenticated():
+            return timezone.now()
+        try:
+            if self.threads[thread.pk] > self.cutoff:
+                return self.threads[thread.pk]
+        except KeyError:
+            pass
+        return self.cutoff
+        
     def is_read(self, thread):
         if not self.user.is_authenticated():
             return True

+ 82 - 8
misago/threads/acl.py

@@ -1,5 +1,7 @@
-from django.utils.translation import ugettext_lazy as _
 from django import forms
+from django.db import models
+from django.db.models import Q
+from django.utils.translation import ugettext_lazy as _
 from misago.acl.builder import BaseACL
 from misago.acl.utils import ACLError403, ACLError404
 from misago.forms import YesNoSwitch
@@ -80,7 +82,7 @@ def make_forum_form(request, role, form):
                         (
                          ('can_write_posts', {'label': _("Can write posts")}),
                          ('can_edit_own_posts', {'label': _("Can edit own posts")}),
-                         ('can_edit_own_posts', {'label': _("Can soft-delete own posts")}),
+                         ('can_soft_delete_own_posts', {'label': _("Can soft-delete own posts")}),
                         ),
                        ),)
     form.layout.append((
@@ -117,7 +119,7 @@ def make_forum_form(request, role, form):
                          ('can_edit_labels', {'label': _("Can edit thread labels")}),
                          ('can_see_changelog', {'label': _("Can see edits history")}),
                          ('can_make_annoucements', {'label': _("Can make annoucements")}),
-                         ('can_pin_threads', {'label': _("Can pin threads")}),
+                         ('can_pin_threads', {'label': _("Can make threads sticky")}),
                          ('can_edit_threads_posts', {'label': _("Can edit threads and posts")}),
                          ('can_move_threads_posts', {'label': _("Can move, merge and split threads and posts")}),
                          ('can_close_threads', {'label': _("Can close threads")}),
@@ -131,14 +133,46 @@ def make_forum_form(request, role, form):
 
 
 class ThreadsACL(BaseACL):
-    def allow_thread_view(self, thread):
+    def get_role(self, forum):
+        try:
+            return self.acl[forum.pk]
+        except KeyError:
+            return {}
+    
+    def allow_thread_view(self, user, thread):
         try:
             forum_role = self.acl[thread.forum.pk]
             if forum_role['can_read_threads'] == 0:
                 raise ACLError403(_("You don't have permission to read threads in this forum."))
+            if thread.moderated and not (forum_role['can_approve'] or (user.is_authenticated() and user == thread.start_poster)):
+                raise ACLError404()
         except KeyError:
             raise ACLError403(_("You don't have permission to read threads in this forum."))
-        
+    
+    def filter_threads(self, request, forum, queryset):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_approve']:
+                if request.user.is_authenticated():
+                    queryset = queryset.filter(Q(moderated=0) | Q(start_poster=request.user))
+                else:
+                    queryset = queryset.filter(moderated=0)
+        except KeyError:
+            return False
+        return queryset
+    
+    def filter_posts(self, request, thread, queryset):
+        try:
+            forum_role = self.acl[thread.forum.pk]
+            if not forum_role['can_approve']:
+                if request.user.is_authenticated():
+                    queryset = queryset.filter(Q(moderated=0) | Q(user=request.user))
+                else:
+                    queryset = queryset.filter(moderated=0)
+        except KeyError:
+            return False
+        return queryset
+    
     def can_start_threads(self, forum):
         try:
             forum_role = self.acl[forum.pk]
@@ -176,12 +210,52 @@ class ThreadsACL(BaseACL):
             forum_role = self.acl[thread.forum.pk]
             if forum_role['can_write_posts'] == 0:
                 raise ACLError403(_("You don't have permission to write replies in this forum."))
-            if thread.closed and forum_role['can_close_threads'] == 0:
-                raise ACLError403(_("You can't write replies in closed threads."))
+            if forum_role['can_close_threads'] == 0:
+                if forum.closed:
+                    raise ACLError403(_("You can't write replies in closed forums."))
+                if thread.closed:
+                    raise ACLError403(_("You can't write replies in closed threads."))
         except KeyError:
             raise ACLError403(_("You don't have permission to write replies in this forum."))
+    
+    def can_approve(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_approve']
+        except KeyError:
+            return False
+        
+    def can_mod_threads(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return (
+                    forum_role['can_approve']
+                    or forum_role['can_make_annoucements']
+                    or forum_role['can_pin_threads']
+                    or forum_role['can_move_threads_posts']
+                    or forum_role['can_close_threads']
+                    or forum_role['can_delete_threads']
+                    )
+        except KeyError:
+            return False
+        
+    def can_mod_posts(self, thread):
+        try:
+            forum_role = self.acl[thread.forum.pk]
+            return (
+                    forum_role['can_edit_threads_posts']
+                    or forum_role['can_move_threads_posts']
+                    or forum_role['can_close_threads']
+                    or forum_role['can_delete_threads']
+                    or forum_role['can_delete_posts']
+                    )
+        except KeyError:
+            return False
+        
+    def can_mod_thread(self, thread):
+        pass
+
 
- 
 def build_forums(acl, perms, forums, forum_roles):
     acl.threads = ThreadsACL()
     for forum in forums:

+ 5 - 2
misago/threads/models.py

@@ -13,6 +13,9 @@ class Thread(models.Model):
     name = models.CharField(max_length=255)
     slug = models.SlugField(max_length=255)
     replies = models.PositiveIntegerField(default=0)
+    replies_reported = models.PositiveIntegerField(default=0)
+    replies_moderated = models.PositiveIntegerField(default=0)
+    replies_deleted = models.PositiveIntegerField(default=0)
     score = models.PositiveIntegerField(default=30,db_index=True)
     upvotes = models.PositiveIntegerField(default=0)
     downvotes = models.PositiveIntegerField(default=0)
@@ -29,7 +32,7 @@ class Thread(models.Model):
     last_poster_slug = models.SlugField(max_length=255,null=True,blank=True)
     last_poster_style = models.CharField(max_length=255,null=True,blank=True)
     moderated = models.BooleanField(default=False,db_index=True)
-    hidden = models.BooleanField(default=False,db_index=True)
+    deleted = models.BooleanField(default=False,db_index=True)
     closed = models.BooleanField(default=False)
     
     objects = ThreadManager()
@@ -66,7 +69,7 @@ class Post(models.Model):
     edit_user_slug = models.SlugField(max_length=255,null=True,blank=True)
     reported = models.BooleanField(default=False)
     moderated = models.BooleanField(default=False,db_index=True)
-    hidden = models.BooleanField(default=False,db_index=True)
+    deleted = models.BooleanField(default=False,db_index=True)
     protected = models.BooleanField(default=False)
     
     objects = PostManager()

+ 4 - 0
misago/threads/urls.py

@@ -5,6 +5,10 @@ urlpatterns = patterns('misago.threads.views',
     url(r'^forum/(?P<slug>(\w|-)+)-(?P<forum>\d+)/(?P<page>\d+)/$', 'ThreadsView', name="forum"),
     url(r'^forum/(?P<slug>(\w|-)+)-(?P<forum>\d+)/new/$', 'PostingView', name="thread_new", kwargs={'mode': 'new_thread'}),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/$', 'ThreadView', name="thread"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/last/$', 'LastReplyView', name="thread_last"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/new/$', 'NewReplyView', name="thread_new"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/moderated/$', 'FirstModeratedView', name="thread_moderated"),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/reported/$', 'FirstReportedView', name="thread_reported"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<page>\d+)/$', 'ThreadView', name="thread"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/reply/$', 'PostingView', name="thread_reply", kwargs={'mode': 'new_post'}),
 )

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

@@ -1,3 +1,4 @@
 from misago.threads.views.list import *
+from misago.threads.views.jumps import *
 from misago.threads.views.thread import *
 from misago.threads.views.posting import *

+ 77 - 0
misago/threads/views/jumps.py

@@ -0,0 +1,77 @@
+from django.core.urlresolvers import reverse
+from django.shortcuts import redirect
+from django.template import RequestContext
+from misago.acl.utils import ACLError403, ACLError404
+from misago.forums.models import Forum
+from misago.readstracker.trackers import ThreadsTracker
+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
+
+class JumpView(BaseView):
+    def fetch_thread(self, thread):
+        self.thread = Thread.objects.get(pk=thread)
+        self.forum = self.thread.forum
+        self.request.acl.forums.allow_forum_view(self.forum)
+        self.request.acl.threads.allow_thread_view(self.request.user, self.thread)
+        
+    def redirect(self, post):
+        pagination = make_pagination(0, self.request.acl.threads.filter_posts(self.request, self.thread, self.thread.post_set.filter(date__lt=post.date)).count() + 1, self.request.settings.posts_per_page)
+        if pagination['total'] > 1:
+            return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug, 'page': pagination['total']}) + ('#post-%s' % post.pk))
+        return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}) + ('#post-%s' % post.pk))
+        
+    def make_jump(self):
+        raise NotImplementedError('JumpView cannot be called directly.')
+        
+    def __call__(self, request, slug=None, thread=None):
+        self.request = request
+        try:
+            self.fetch_thread(thread)
+            return self.make_jump()
+        except Thread.DoesNotExist:
+            return error404(self.request)
+        except ACLError403 as e:
+            return error403(request, e.message)
+        except ACLError404 as e:
+            return error404(request, e.message)
+        
+
+class LastReplyView(JumpView):
+    def make_jump(self):
+        return self.redirect(self.thread.post_set.order_by('-id')[:1][0])
+
+
+class NewReplyView(JumpView):
+    def make_jump(self):
+        if not self.request.user.is_authenticated():
+            return self.redirect(self.thread.post_set.order_by('-id')[:1][0])
+        tracker = ThreadsTracker(self.request.user, self.forum)
+        read_date = tracker.get_read_date(self.thread)
+        post = self.thread.post_set.filter(date__gt=read_date).order_by('id')[:1]
+        if not post:
+            return self.redirect(self.thread.post_set.order_by('-id')[:1][0])
+        return self.redirect(post[0])
+
+
+class FirstModeratedView(JumpView):
+    def make_jump(self):
+        if not self.request.acl.threads.can_approve(self.forum):
+            raise ACLError404()
+        try:
+            return self.redirect(
+                self.thread.post_set.get(moderated=True))
+        except Post.DoesNotExist:
+            return error404(self.request)
+
+
+class FirstReportedView(JumpView):
+    def make_jump(self):
+        if not self.request.acl.threads.can_mod_posts(self.forum):
+            raise ACLError404()
+        try:
+            return self.redirect(
+                self.thread.post_set.get(reported=True))
+        except Post.DoesNotExist:
+            return error404(self.request)

+ 84 - 7
misago/threads/views/list.py

@@ -3,14 +3,17 @@ from django.shortcuts import redirect
 from django.template import RequestContext
 from django.utils.translation import ugettext as _
 from misago.acl.utils import ACLError403, ACLError404
+from misago.forms import FormLayout, FormFields
 from misago.forums.models import Forum
+from misago.messages import Message
 from misago.readstracker.trackers import ForumsTracker, ThreadsTracker
 from misago.threads.models import Thread, Post
 from misago.threads.views.base import BaseView
+from misago.threads.views.mixins import ThreadsFormMixin
 from misago.views import error403, error404
 from misago.utils import make_pagination
 
-class ThreadsView(BaseView):
+class ThreadsView(BaseView, ThreadsFormMixin):
     def fetch_forum(self, forum):
         self.forum = Forum.objects.get(pk=forum, type='forum')
         self.request.acl.forums.allow_forum_view(self.forum)
@@ -20,33 +23,107 @@ class ThreadsView(BaseView):
         self.tracker = ThreadsTracker(self.request.user, self.forum)
                 
     def fetch_threads(self, page):
-        self.count = Thread.objects.filter(forum=self.forum).count()
-        self.threads = Thread.objects.filter(forum=self.forum).order_by('-weight', '-last').all()
+        self.count = self.request.acl.threads.filter_threads(self.request, self.forum, Thread.objects.filter(forum=self.forum)).count()
+        self.threads = self.request.acl.threads.filter_threads(self.request, self.forum, Thread.objects.filter(forum=self.forum)).order_by('-weight', '-last')
         self.pagination = make_pagination(page, self.count, self.request.settings.threads_per_page)
         if self.request.settings.threads_per_page < self.count:
             self.threads = self.threads[self.pagination['start']:self.pagination['stop']]
         for thread in self.threads:
             thread.is_read = self.tracker.is_read(thread)
     
+    def get_thread_actions(self):
+        acl = self.request.acl.threads.get_role(self.forum)
+        actions = []
+        try:
+            if acl['can_approve']:
+               actions.append(('accept', _('Accept threads')))
+            if acl['can_make_annoucements']:
+               actions.append(('annouce', _('Change to annoucements')))
+            if acl['can_pin_threads']:
+               actions.append(('sticky', _('Change to sticky threads')))
+            if acl['can_make_annoucements'] or acl['can_pin_threads']:
+               actions.append(('normal', _('Change to standard thread')))
+            if acl['can_move_threads_posts']:
+               actions.append(('move', _('Move threads')))
+               actions.append(('merge', _('Merge threads')))
+            if acl['can_close_threads']:
+               actions.append(('open', _('Open threads')))
+               actions.append(('close', _('Close threads')))
+            if acl['can_delete_threads']:
+               actions.append(('undelete', _('Undelete threads')))
+            if acl['can_delete_threads']:
+               actions.append(('soft', _('Soft delete threads')))
+            if acl['can_delete_threads'] == 2:
+               actions.append(('hard', _('Hard delete threads')))
+        except KeyError:
+            pass
+        return actions
+    
+    def action_accept(self, ids, threads):
+        accepted = 0
+        users = []
+        for thread in self.threads.prefetch_related('last_post', 'last_post__user').all():
+            if thread.pk in ids and thread.moderated:
+                accepted += 1
+                # Sync thread and post
+                thread.moderated = False
+                thread.replies_moderated -= 1
+                thread.save(force_update=True)
+                thread.last_post.moderated = False
+                thread.last_post.save(force_update=True)
+                # Sync user
+                if thread.last_post.user:
+                    thread.last_post.user.threads += 1
+                    thread.last_post.user.posts += 1
+                    users.append(thread.last_post.user)
+                # Sync forum
+                self.forum.threads += 1
+                self.forum.threads_delta += 1
+                self.forum.posts += 1
+                self.forum.posts_delta += 1
+                if not self.forum.last_thread_date or self.forum.last_thread_date < thread.last:
+                    self.forum.last_thread = thread
+                    self.forum.last_thread_name = thread.name
+                    self.forum.last_thread_slug = thread.slug
+                    self.forum.last_thread_date = thread.last
+                    self.forum.last_poster = thread.last_poster
+                    self.forum.last_poster_name = thread.last_poster_name
+                    self.forum.last_poster_slug = thread.last_poster_slug
+                    self.forum.last_poster_style = thread.last_poster_style
+        if accepted:
+            self.request.monitor['threads'] = int(self.request.monitor['threads']) + accepted
+            self.request.monitor['posts'] = int(self.request.monitor['posts']) + accepted
+            self.forum.save(force_update=True)
+            for user in users:
+                user.save(force_update=True)
+            self.request.messages.set_flash(Message(_('Selected threads have been marked as reviewed and made visible to other members.')), 'success', 'threads')
+    
     def __call__(self, request, slug=None, forum=None, page=0):
         self.request = request
         self.pagination = None
         self.parents = None
+        self.message = request.messages.get_message('threads')
         try:
             self.fetch_forum(forum)
             self.fetch_threads(page)
+            self.make_form()
+            if self.form:
+                response = self.handle_form()
+                if response:
+                    return response
         except Forum.DoesNotExist:
-            return error404(self.request)
+            return error404(request)
         except ACLError403 as e:
-            return error403(args[0], e.message)
+            return error403(request, e.message)
         except ACLError404 as e:
-            return error404(args[0], e.message)
+            return error404(request, e.message)
         return request.theme.render_to_response('threads/list.html',
                                                 {
-                                                 'message': request.messages.get_message('threads'),
+                                                 'message': self.message,
                                                  'forum': self.forum,
                                                  'parents': self.parents,
                                                  'count': self.count,
+                                                 'list_form': FormFields(self.form).fields if self.form else None,
                                                  'threads': self.threads,
                                                  'pagination': self.pagination,
                                                  },

+ 49 - 0
misago/threads/views/mixins.py

@@ -0,0 +1,49 @@
+from django import forms
+from django.shortcuts import redirect
+from django.utils.translation import ugettext as _
+from misago.forms import Form
+from misago.messages import Message
+
+class ThreadsFormMixin(object):
+    def make_form(self):
+        self.form = None
+        list_choices = self.get_thread_actions();
+        if (not self.request.user.is_authenticated()
+            or not list_choices):
+            return
+        
+        form_fields = {}
+        form_fields['list_action'] = forms.ChoiceField(choices=list_choices)
+        list_choices = []
+        for item in self.threads:
+            list_choices.append((item.pk, None))
+        if not list_choices:
+            return
+        form_fields['list_items'] = forms.MultipleChoiceField(choices=list_choices,widget=forms.CheckboxSelectMultiple)
+        self.form = type('ThreadsViewForm', (Form,), form_fields)
+    
+    def handle_form(self):
+        if self.request.method == 'POST':
+            self.form = self.form(self.request.POST, request=self.request)
+            if self.form.is_valid():
+                checked_items = []
+                checked_ids = []
+                for thread in self.threads:
+                    if str(thread.pk) in self.form.cleaned_data['list_items']:
+                        checked_ids.append(thread.pk)
+                        checked_items.append(thread)
+                if checked_items:
+                    form_action = getattr(self, 'action_' + self.form.cleaned_data['list_action'])
+                    response = form_action(checked_ids, checked_items)
+                    if response:
+                        return response
+                    return redirect(self.request.path)
+                else:
+                    self.message = Message(_("You have to select at least one thread."), 'error')
+            else:
+                if 'list_action' in self.form.errors:
+                    self.message = Message(_("Action requested is incorrect."), 'error')
+                else:
+                    self.message = Message(form.non_field_errors()[0], 'error')
+        else:
+            self.form = self.form(request=self.request)

+ 56 - 28
misago/threads/views/posting.py

@@ -12,7 +12,7 @@ from misago.threads.forms import PostForm
 from misago.threads.models import Thread, Post
 from misago.threads.views.base import BaseView
 from misago.views import error403, error404
-from misago.utils import slugify
+from misago.utils import make_pagination, slugify
 
 class PostingView(BaseView):
     def fetch_target(self, kwargs):
@@ -31,6 +31,7 @@ class PostingView(BaseView):
         self.thread = Thread.objects.get(pk=kwargs['thread'])
         self.forum = self.thread.forum
         self.request.acl.forums.allow_forum_view(self.forum)
+        self.request.acl.threads.allow_thread_view(self.request.user, self.thread)
         self.request.acl.threads.allow_reply(self.thread)
         self.parents = self.forum.get_ancestors(include_self=True).filter(level__gt=1)
         
@@ -64,15 +65,24 @@ class PostingView(BaseView):
             form = self.get_form(True)
             if form.is_valid():
                 now = timezone.now()
+                moderation = False
+                if not request.acl.threads.acl[self.forum.pk]['can_approve']:
+                    if self.mode == 'new_thread' and request.acl.threads.acl[self.forum.pk]['can_start_threads'] == 1:
+                        moderation = True
+                    if self.mode in ['new_post', 'new_post_quick'] and request.acl.threads.acl[self.forum.pk]['can_write_posts'] == 1:
+                        moderation = True 
                 # Get or create new thread
-                if self.mode in ['new_thread', 'edit_thread']:
+                if self.mode in ['new_thread']:
                     thread = Thread.objects.create(
                                                    forum=self.forum,
                                                    name=form.cleaned_data['thread_name'],
                                                    slug=slugify(form.cleaned_data['thread_name']),
                                                    start=now,
                                                    last=now,
+                                                   moderated=moderation,
                                                    )
+                    if moderation:
+                        thread.replies_moderated += 1
                 else:
                     thread = self.thread
                 
@@ -86,7 +96,8 @@ class PostingView(BaseView):
                                            agent=request.META.get('HTTP_USER_AGENT'),
                                            post=form.cleaned_data['post'],
                                            post_preparsed=post_markdown(request, form.cleaned_data['post']),
-                                           date=now
+                                           date=now,
+                                           moderated=moderation,
                                            )
                 
                 if self.mode == 'new_thread':
@@ -96,7 +107,7 @@ 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
-                    
+                
                 thread.last = now
                 thread.last_post = post
                 thread.last_poster = request.user
@@ -105,41 +116,58 @@ class PostingView(BaseView):
                 if request.user.rank and request.user.rank.style:
                     thread.last_poster_style = request.user.rank.style
                 if self.mode in ['new_post', 'new_post_quick']:
-                    thread.replies += 1
+                    if moderation:
+                        thread.replies_moderated += 1
+                    else:
+                        thread.replies += 1
+                    thread.score += 5
                 thread.save(force_update=True)
                 
-                # Update forum
-                if self.mode == 'new_thread':
-                    self.forum.threads += 1
-                    self.forum.threads_delta += 1
-                    
-                if self.mode in ['new_post', 'new_post_quick']:
-                    self.forum.posts += 1
-                    self.forum.posts_delta += 1
-                    
-                self.forum.last_thread = thread
-                self.forum.last_thread_name = thread.name
-                self.forum.last_thread_slug = thread.slug
-                self.forum.last_thread_date = thread.last
-                self.forum.last_poster = thread.last_poster
-                self.forum.last_poster_name = thread.last_poster_name
-                self.forum.last_poster_slug = thread.last_poster_slug
-                self.forum.last_poster_style = thread.last_poster_style
-                self.forum.save(force_update=True)
+                # Update forum and monitor
+                if not moderation:
+                    if self.mode == 'new_thread':
+                        self.request.monitor['threads'] = int(self.request.monitor['threads']) + 1
+                        self.forum.threads += 1
+                        self.forum.threads_delta += 1
+                        
+                    if self.mode in ['new_thread', 'new_post', 'new_post_quick']:
+                        self.request.monitor['posts'] = int(self.request.monitor['posts']) + 1
+                        self.forum.posts += 1
+                        self.forum.posts_delta += 1
+                        
+                    self.forum.last_thread = thread
+                    self.forum.last_thread_name = thread.name
+                    self.forum.last_thread_slug = thread.slug
+                    self.forum.last_thread_date = thread.last
+                    self.forum.last_poster = thread.last_poster
+                    self.forum.last_poster_name = thread.last_poster_name
+                    self.forum.last_poster_slug = thread.last_poster_slug
+                    self.forum.last_poster_style = thread.last_poster_style
+                    self.forum.save(force_update=True)
                 
                 # Update user
-                if self.mode == 'new_thread':
-                    request.user.threads += 1
-                request.user.posts += 1
+                if not moderation:
+                    if self.mode == 'new_thread':
+                        request.user.threads += 1
+                    request.user.posts += 1
                 request.user.last_post = thread.last
                 request.user.save(force_update=True)
                 
                 if self.mode == 'new_thread':
-                    request.messages.set_flash(Message(_("New thread has been posted.")), 'success', 'threads')
+                    if moderation:
+                        request.messages.set_flash(Message(_("New thread has been posted. It will be hidden from other members until moderator reviews it.")), 'success', 'threads')
+                    else:
+                        request.messages.set_flash(Message(_("New thread has been posted.")), 'success', 'threads')
                     return redirect(reverse('thread', kwargs={'thread': thread.pk, 'slug': thread.slug}) + ('#post-%s' % post.pk))
                 
                 if self.mode in ['new_post', 'new_post_quick']:
-                    request.messages.set_flash(Message(_("Your reply has been posted.")), 'success', 'threads')
+                    if moderation:
+                        request.messages.set_flash(Message(_("Your reply has been posted. It will be hidden from other members until moderator reviews it.")), 'success', 'threads_%s' % post.pk)
+                    else:
+                        request.messages.set_flash(Message(_("Your reply has been posted.")), 'success', 'threads_%s' % post.pk)
+                    pagination = make_pagination(0, self.request.acl.threads.filter_posts(self.request, self.thread, self.thread.post_set).count(), self.request.settings.posts_per_page)
+                    if pagination['total'] > 1:
+                        return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug, 'page': pagination['total']}) + ('#post-%s' % post.pk))
                     return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}) + ('#post-%s' % post.pk))
             message = Message(form.non_field_errors()[0], 'error')
         else:

+ 12 - 7
misago/threads/views/thread.py

@@ -17,21 +17,25 @@ class ThreadView(BaseView):
         self.thread = Thread.objects.get(pk=thread)
         self.forum = self.thread.forum
         self.request.acl.forums.allow_forum_view(self.forum)
-        self.request.acl.threads.allow_thread_view(self.thread)
+        self.request.acl.threads.allow_thread_view(self.request.user, self.thread)
         self.parents = self.forum.get_ancestors(include_self=True).filter(level__gt=1)
         self.tracker = ThreadsTracker(self.request.user, self.forum)
     
     def fetch_posts(self, page):
-        self.count = Post.objects.filter(thread=self.thread).count()
-        self.posts = Post.objects.filter(thread=self.thread).order_by('pk').all().prefetch_related('user', 'user__rank')
+        self.count = self.request.acl.threads.filter_posts(self.request, self.thread, Post.objects.filter(thread=self.thread)).count()
+        self.posts = self.request.acl.threads.filter_posts(self.request, self.thread, Post.objects.filter(thread=self.thread)).order_by('pk').prefetch_related('user', 'user__rank')
         self.pagination = make_pagination(page, self.count, self.request.settings.posts_per_page)
-        if self.request.settings.threads_per_page < self.count:
+        if self.request.settings.posts_per_page < self.count:
             self.posts = self.posts[self.pagination['start']:self.pagination['stop']]
+        self.read_date = self.tracker.get_read_date(self.thread) 
+        for post in self.posts:
+            post.message = self.request.messages.get_message('threads_%s' % post.pk)
+            post.is_read = post.date <= self.read_date
         last_post = self.posts[len(self.posts) - 1]
         if not self.tracker.is_read(self.thread):
             self.tracker.set_read(self.thread, last_post)
             self.tracker.sync()
-        
+     
     def __call__(self, request, slug=None, thread=None, page=0):
         self.request = request
         self.pagination = None
@@ -42,15 +46,16 @@ class ThreadView(BaseView):
         except Thread.DoesNotExist:
             return error404(self.request)
         except ACLError403 as e:
-            return error403(args[0], e.message)
+            return error403(request, e.message)
         except ACLError404 as e:
-            return error404(args[0], e.message)
+            return error404(request, e.message)
         return request.theme.render_to_response('threads/thread.html',
                                                 {
                                                  'message': request.messages.get_message('threads'),
                                                  'forum': self.forum,
                                                  'parents': self.parents,
                                                  'thread': self.thread,
+                                                 'is_read': self.tracker.is_read(self.thread),
                                                  'count': self.count,
                                                  'posts': self.posts,
                                                  'pagination': self.pagination,

+ 4 - 0
misago/utils/__init__.py

@@ -84,6 +84,10 @@ def make_pagination(page, total, max):
             pagination['prev'] = pagination['page'] - 1
         if pagination['page'] < pagination['total']:
             pagination['next'] = pagination['page'] + 1
+
+    # Fix empty pagers
+    if not pagination['total']:
+        pagination['total'] = 1 
             
     # Set stop offset
     pagination['stop'] = pagination['start'] + max

+ 19 - 8
static/sora/css/sora.css

@@ -846,10 +846,7 @@ textarea{resize:vertical;}
 .form-avatar-select .form-button{margin-bottom:4px;}
 .form-avatar-select .form-button:hover img{border:1px solid #0088cc;border:1px solid #0088cc;-webkit-box-shadow:0 1px 3px #0088cc;-moz-box-shadow:0 1px 3px #0088cc;box-shadow:0 1px 3px #0088cc;}
 .form-avatar-select hr{margin-top:16px;}
-.table-footer{background:#e8e8e8;border-top:1px solid #cfcfcf;-webkit-border-radius:0px 0px 3px 3px;-moz-border-radius:0px 0px 3px 3px;border-radius:0px 0px 3px 3px;margin-bottom:0px;padding:0px 8px;position:relative;bottom:20px;}.table-footer .pager{margin:0px 0px;margin-top:9px;padding:0px;margin-right:6px;}.table-footer .pager>li{margin-right:6px;}.table-footer .pager>li>a:link,.table-footer .pager>li>a:active,.table-footer .pager>li>a:visited{background:#e8e8e8;border:none;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;padding:2px 5px;}
-.table-footer .pager>li>a:hover{background-color:#0088cc;}.table-footer .pager>li>a:hover i{background-image:url("../img/glyphicons-halflings-white.png");}
-.table-footer .table-count{padding:11px 0px;color:#555555;}
-.table-footer .form-inline{margin:0px;padding:6px 0px;}
+.table-footer{background:#e8e8e8;border-top:1px solid #cfcfcf;-webkit-border-radius:0px 0px 3px 3px;-moz-border-radius:0px 0px 3px 3px;border-radius:0px 0px 3px 3px;margin-top:-20px;margin-bottom:20px;overflow:auto;padding:0px 12px;}.table-footer .form-inline{margin:0px;padding:6px 0px;}
 td.check-cell,th.check-cell{width:32px;}
 td .checkbox,th .checkbox{margin-bottom:0px;position:relative;bottom:1px;}td .checkbox input,th .checkbox input{position:relative;left:9px;}
 td.lead-cell{color:#555555;font-weight:bold;}
@@ -876,6 +873,7 @@ td.lead-cell{color:#555555;font-weight:bold;}
 .btn.btn-danger{background:#d13327;border:1px solid #d13327;*border:0;background:#d13327;border:1px solid #d13327;*border:0;color:#f7f7f7;text-shadow:0px 1px 0px #bb2d23;}.btn.btn-danger:hover,.btn.btn-danger:active{background:#e05f55;border:1px solid #e05f55;*border:0;}
 .btn.btn-inverse{background:#555555;border:1px solid #555555;*border:0;background:#555555;border:1px solid #555555;*border:0;color:#f7f7f7;text-shadow:0px 1px 0px #484848;}.btn.btn-inverse:hover,.btn.btn-inverse:active{background:#7e7e7e;border:1px solid #7e7e7e;*border:0;}
 .btn.btn-link{background:none;border:none;}.btn.btn-link:hover,.btn.btn-link:active{color:#00aaff;text-decoration:none;}
+.btn.btn-large{font-size:180%;padding:10px 16px;}
 .alerts-global{margin-top:16px;}
 .alert-form{margin:0px;margin-bottom:16px;}.alert-form p{font-weight:normal;}
 .alert-inline{margin:0px;padding:0px;}
@@ -920,7 +918,13 @@ td.lead-cell{color:#555555;font-weight:bold;}
 .nav-pills li.primary a:active,.nav-pills li.primary a:hover{background-color:#0088cc !important;}
 .pager{margin:0px 0px;margin-top:6px;padding:0px;margin-right:6px;}.pager>li{margin-right:6px;}.pager>li>a:link,.pager>li>a:active,.pager>li>a:visited{background:#e8e8e8;border:none;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;padding:2px 5px;color:#333333;font-weight:bold;}
 .pager>li>a:hover{background-color:#0088cc;color:#ffffff;}.pager>li>a:hover i{background-image:url("../img/glyphicons-halflings-white.png");}
-.pager>li.count{color:#808080;}
+.pager>li.unread>a:link,.pager>li.unread>a:active,.pager>li.unread>a:visited{background:#c67605;color:#ffffff;}.pager>li.unread>a:link i,.pager>li.unread>a:active i,.pager>li.unread>a:visited i{background-image:url("../img/glyphicons-halflings-white.png");}
+.pager>li.unread>a:hover{background:#faa937;}
+.pager>li.moderated>a:link,.pager>li.moderated>a:active,.pager>li.moderated>a:visited{background:#613591;color:#ffffff;}.pager>li.moderated>a:link i,.pager>li.moderated>a:active i,.pager>li.moderated>a:visited i{background-image:url("../img/glyphicons-halflings-white.png");}
+.pager>li.moderated>a:hover{background:#9466c6;}
+.pager>li.reported>a:link,.pager>li.reported>a:active,.pager>li.reported>a:visited{background:#9d261d;color:#ffffff;}.pager>li.reported>a:link i,.pager>li.reported>a:active i,.pager>li.reported>a:visited i{background-image:url("../img/glyphicons-halflings-white.png");}
+.pager>li.reported>a:hover{background:#c83025;}
+.pager>li.count{color:#808080;margin-right:24px;}
 .navbar-fixed-top{position:static;}
 .navbar-userbar .navbar-inner{background:none;background-color:#fcfcfc;border-bottom:4px solid #e3e3e3;-webkit-box-shadow:none;-moz-box-shadow:none;box-shadow:none;overflow:auto;}
 .navbar-userbar li a,.navbar-userbar li a:link,.navbar-userbar li a:active,.navbar-userbar li a:visited,.navbar-userbar li button.btn-link{opacity:0.5;filter:alpha(opacity=50);color:#000000;font-weight:bold;}.navbar-userbar li a i,.navbar-userbar li a:link i,.navbar-userbar li a:active i,.navbar-userbar li a:visited i,.navbar-userbar li button.btn-link i{opacity:1;filter:alpha(opacity=100);}
@@ -959,15 +963,22 @@ td.lead-cell{color:#555555;font-weight:bold;}
 .forum-list-side{padding-top:10px;}
 .subforums-list{margin:0px;position:relative;bottom:32px;}.subforums-list .category{margin-bottom:-24px;}
 .threads-list .thread-icon{background-color:#eeeeee;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;padding:5px 6px;}
+.threads-list .jump{-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;padding:3px 4px;margin:-3px 0px;opacity:0.3;filter:alpha(opacity=30);}.threads-list .jump:hover{opacity:1;filter:alpha(opacity=100);}.threads-list .jump:hover.jump-new{background-color:#f89406;}
+.threads-list .jump:hover.jump-last{background-color:#049cdb;}
+.threads-list .jump:hover i{background-image:url("../img/glyphicons-halflings-white.png");}
+.threads-list .thread-stat{text-align:right;}
 .threads-list .thread-closed{background-color:#9d261d;}
 .threads-list .thread-new{background-color:#0088cc;}
+.threads-list .thread-flags{float:right;margin:0px;padding:0px;}.threads-list .thread-flags li{float:right;margin:0px;margin-left:6px;padding:0px;}
 .thread-info{overflow:auto;}.thread-info li{float:left;margin-right:16px;opacity:0.5;filter:alpha(opacity=50);font-weight:bold;}.thread-info li a{color:#333333;}
-.posts-list{margin-top:12px;margin-bottom:20px;}.posts-list .well-post{margin:0px;margin-bottom:16px;overflow:auto;padding:16px 0px;}.posts-list .well-post .post-author{overflow:auto;float:left;width:284px;position:relative;bottom:4px;}.posts-list .well-post .post-author .avatar-normal{float:left;margin:4px 0px;margin-left:16px;width:80px;height:80px;-webkit-box-shadow:0px 0px 4px #999999;-moz-box-shadow:0px 0px 4px #999999;box-shadow:0px 0px 4px #999999;}
+.posts-list{margin-top:12px;margin-bottom:20px;}.posts-list .well-post{margin:0px;margin-bottom:16px;overflow:auto;padding:16px 0px;}.posts-list .well-post .post-author{overflow:auto;float:left;width:286px;position:relative;bottom:4px;}.posts-list .well-post .post-author .avatar-normal{float:left;margin:4px 0px;margin-left:16px;width:80px;height:80px;-webkit-box-shadow:0px 0px 4px #999999;-moz-box-shadow:0px 0px 4px #999999;box-shadow:0px 0px 4px #999999;}
 .posts-list .well-post .post-author .post-bit{float:left;margin-left:12px;padding-top:4px;font-weight:bold;font-size:120%;}.posts-list .well-post .post-author .post-bit p{margin:0px;}
 .posts-list .well-post .post-author .post-bit .lead{font-size:150%;}
 .posts-list .well-post .post-author .post-bit .user-title{color:#555555;}
-.posts-list .well-post .post-author .post-bit .post-date{margin-top:4px;color:#999999;font-weight:normal;}
-.posts-list .well-post .post-content{margin-left:284px;padding:0px 16px;}.posts-list .well-post .post-content .post-foot{margin-top:20px;}.posts-list .well-post .post-content .post-foot .lead{margin:0px;color:#999999;font-size:100%;}.posts-list .well-post .post-content .post-foot .lead a{color:#999999;}
+.posts-list .well-post .post-author .post-bit .post-date{margin-top:4px;color:#999999;font-size:70%;font-weight:normal;}
+.posts-list .well-post .post-content{margin-left:286px;padding:0px 16px;}.posts-list .well-post .post-content .label{margin-left:8px;padding:4px 5px;font-size:100%;}
+.posts-list .well-post .post-content .label-purple{background-color:#7a43b6;}
+.posts-list .well-post .post-content .post-foot{margin-top:20px;}.posts-list .well-post .post-content .post-foot .lead{margin:0px;color:#999999;font-size:100%;}.posts-list .well-post .post-content .post-foot .lead a{color:#999999;}
 .posts-list .well-post .post-content .post-foot .signature{border-top:1px solid #eeeeee;padding-top:12px;}.posts-list .well-post .post-content .post-foot .signature .markdown{opacity:0.7;filter:alpha(opacity=70);}
 .quick-reply{margin-top:12px;overflow:auto;}.quick-reply .avatar-big,.quick-reply .arrow{float:left;}
 .quick-reply .arrow{width:0;height:0;border-top:12px solid transparent;border-bottom:12px solid transparent;border-right:12px solid #e3e3e3;position:relative;top:12px;left:5px;}

+ 5 - 0
static/sora/css/sora/buttons.less

@@ -102,4 +102,9 @@
       text-decoration: none;
     }
   }
+  
+  &.btn-large {
+    font-size: 180%;
+    padding: 10px 16px;
+  }
 }

+ 49 - 0
static/sora/css/sora/navs.less

@@ -195,8 +195,57 @@
       }
     }
     
+    &.unread>a {
+      &:link, &:active, &:visited {
+        background: darken(@orange, 10%);
+        
+        color: @white;
+        
+        i {
+          background-image: url("@{iconWhiteSpritePath}");          
+        }
+      }
+    
+      &:hover {
+        background: lighten(@orange, 10%);
+      }
+    }
+    
+    &.moderated>a {
+      &:link, &:active, &:visited {
+        background: darken(@purple, 10%);
+        
+        color: @white;
+        
+        i {
+          background-image: url("@{iconWhiteSpritePath}");          
+        }
+      }
+    
+      &:hover {
+        background: lighten(@purple, 10%);
+      }
+    }
+    
+    &.reported>a {
+      &:link, &:active, &:visited {
+        background: @red;
+        
+        color: @white;
+        
+        i {
+          background-image: url("@{iconWhiteSpritePath}");          
+        }
+      }
+    
+      &:hover {
+        background: lighten(@red, 10%);
+      }
+    }
+    
     &.count {
       color: lighten(@textColor, 30%);
+      margin-right: 24px;
     }
   }
 }

+ 4 - 38
static/sora/css/sora/tables.less

@@ -4,44 +4,10 @@
   background: darken(@bodyBackground, 8%);
   border-top: 1px solid darken(@bodyBackground, 18%);
   .border-radius(0px 0px 3px 3px);
-  margin-bottom: 0px;
-  padding: 0px 8px;
-  position: relative;
-  bottom: 20px;
-  
-  .pager {
-    margin: 0px 0px;
-    margin-top: 9px;
-    padding: 0px;
-    margin-right: 6px;
-    
-    &>li {
-      margin-right: 6px;
-    
-      &>a {
-        &:link, &:active, &:visited {
-          background: darken(@bodyBackground, 8%);
-          border: none;
-          .border-radius(3px);
-          padding: 2px 5px;
-        }
-      
-        &:hover {
-          background-color: @linkColor;
-          
-          i {
-            background-image: url("@{iconWhiteSpritePath}");          
-          }
-        }
-      }
-    }
-  }
-  
-  .table-count {
-    padding: 11px 0px;
-    
-    color: @gray;
-  }
+  margin-top: -20px;
+  margin-bottom: 20px;
+  overflow: auto;
+  padding: 0px 12px;
   
   .form-inline {
     margin: 0px;

+ 54 - 3
static/sora/css/sora/threads.less

@@ -19,6 +19,33 @@
     padding: 5px 6px;
   }
   
+  .jump { 
+    .border-radius(3px);
+    padding: 3px 4px;
+    margin: -3px 0px;
+    .opacity(30);
+        
+    &:hover {
+      .opacity(100);
+      
+      &.jump-new {
+        background-color: @orange;
+      }
+      
+      &.jump-last {
+        background-color: @blue;
+      }
+      
+      i {
+        background-image: url("@{iconWhiteSpritePath}");
+      }
+    }
+  }
+  
+  .thread-stat {
+    text-align: right;
+  }
+  
   .thread-closed {
     background-color: @red; 
   }
@@ -26,6 +53,19 @@
   .thread-new {
     background-color: @linkColor; 
   }
+  
+  .thread-flags {
+    float: right;
+    margin: 0px;
+    padding: 0px;
+    
+    li {
+      float: right;
+      margin: 0px;
+      margin-left: 6px;
+      padding: 0px;
+    }
+  }
 }
 
 // Thread view
@@ -59,7 +99,7 @@
     .post-author{
       overflow: auto;
       float: left;
-      width: 284px;
+      width: 286px;
       position: relative;
       bottom: 4px;
       
@@ -84,7 +124,7 @@
           margin: 0px;
         }
         
-        .lead {          
+        .lead {
           font-size: 150%;
         }
         
@@ -96,15 +136,26 @@
           margin-top: 4px;
           
           color: @grayLight;
+          font-size: 70%;
           font-weight: normal
         }
       }
     }
 
     .post-content {
-      margin-left: 284px;
+      margin-left: 286px;
       padding: 0px 16px;
       
+      .label {
+        margin-left: 8px;
+        padding: 4px 5px;
+        font-size: 100%;
+      }
+      
+      .label-purple {
+        background-color: @purple;
+      }
+      
       .post-foot {
         margin-top: 20px;
         

+ 3 - 3
templates/admin/admin/list.html

@@ -74,7 +74,7 @@ Showing {{ shown }} of {{ total }} items
   <form id="list_form" class="form-inline pull-right" action="{{ url }}" method="POST">
     <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
     <input type="hidden" name="origin" value="list">
-    {{ form_theme.input_select(list_form.fields['list_action'],width=20) }}
+    {{ form_theme.input_select(list_form.fields['list_action'],width=3) }}
     <button type="submit" class="btn btn-primary">{% trans %}Go{% endtrans %}</button>
   </form>
 {%- endif %}
@@ -117,12 +117,12 @@ Showing {{ shown }} of {{ total }} items
     $(function () {
       $('#list_form').submit(function() {
         if ($('.check-cell[]:checked').length == 0) {
-          alert('{{ action.nothing_checked_message }}');
+          alert("{{ action.nothing_checked_message }}");
           return false;
         }
         {%- for item in action.actions %}{% if item.2 %}
         if ($('#id_list_action').val() == '{{ item.0 }}') {
-          var decision = confirm('{{ item.2 }}');
+          var decision = confirm("{{ item.2 }}");
           return decision;
         }
         {%- endif %}{% endfor %}

+ 57 - 4
templates/sora/threads/list.html

@@ -1,6 +1,7 @@
 {% extends "sora/layout.html" %}
 {% load i18n %}
 {% load url from future %}
+{% import "_forms.html" as form_theme with context %}
 {% import "sora/macros.html" as macros with context %}
 
 {% block title %}{{ macros.page_title(title=forum.name,page=pagination['page']) }}{% endblock %}
@@ -44,25 +45,57 @@
       <th>{% trans %}Author{% endtrans %}</th>
       <th>{% trans %}Replies{% endtrans %}</th>
       <th>{% trans %}Last Poster{% endtrans %}</th>
+      {% if user.is_authenticated() and list_form %}
+      <th class="check-cell"><label class="checkbox"><input type="checkbox" class="checkbox-master"></label></th>
+      {% endif %}
     </tr>
   </thead>
   <tbody>
     {% for thread in threads %}
     <tr>
-      <td><span class="thread-icon{% if not thread.is_read %} {% if thread.closed %}thread-closed{% else %}thread-new{% endif %}{% endif %}"><i class="icon-{% if thread.closed %}remove{% else %}comment{% endif %} icon-white"></i></span></td>
+      <td><span class="thread-icon{% if not thread.is_read %} {% if thread.closed %}thread-closed{% else %}thread-new{% endif %}{% endif %}"><i class="icon-comment icon-white"></i></span></td>
       <td>
-        <a href="{% url 'thread' thread=thread.pk, slug=thread.slug %}">{% if not thread.is_read %}<strong>{{ thread.name }}</strong>{% else %}{{ thread.name }}{% endif %}</a>[LABELS][JUMP] [ICONS]
+        <a href="{% url 'thread' thread=thread.pk, slug=thread.slug %}">{% if not thread.is_read %}<strong>{{ thread.name }}</strong>{% else %}{{ thread.name }}{% endif %}</a> {% if not thread.is_read -%}
+        <a href="{% url 'thread_new' thread=thread.pk, slug=thread.slug %}" class="jump jump-new tooltip-top" title="{% trans %}Jump to first unread post{% endtrans %}"><i class="icon-chevron-right"></i></a>
+        {%- else -%}
+        <a href="{% url 'thread_last' thread=thread.pk, slug=thread.slug %}" class="jump jump-last tooltip-top" title="{% trans %}Jump to last post{% endtrans %}"><i class="icon-chevron-right"></i></a>
+        {%- endif %}
+        <ul class="unstyled thread-flags">
+          {% if thread.weight == 2 %}<li><span class="tooltip-top" title="{% trans %}This thread is annoucement.{% endtrans %}"><i class="icon-fire"></i></span></li>{% endif %}
+          {% if thread.weight == 1 %}<li><span class="tooltip-top" title="{% trans %}This thread is pinned.{% endtrans %}"><i class="icon-star-empty"></i></span></li>{% endif %}
+          {% if thread.closed %}<li><span class="tooltip-top" title="{% trans %}This thread is closed for new replies.{% endtrans %}"><i class="icon-lock"></i></span></li>{% endif %}
+          {% if thread.moderated %}<li><span class="tooltip-top" title="{% trans %}This thread will not be visible to other members until moderator reviews it.{% endtrans %}"><i class="icon-eye-close"></i></span></li>{% endif %}
+          {% if thread.deleted %}<li><span class="tooltip-top" title="{% trans %}This thread has been deleted.{% endtrans %}"><i class="icon-remove"></i></span></li>{% endif %}
+        </ul>
       </td>
       <td class="span2">{% if thread.start_poster_id %}<a href="{% url 'user' user=thread.start_poster_id, username=thread.start_poster_slug %}" class="tooltip-top" title="{{ thread.start|reltimesince }}">{{ thread.start_poster_name }}</a>{% else %}<em class="tooltip-top muted" title="{{ thread.start|reltimesince }}">{{ thread.start_poster_name }}</em>{% endif %}</td>
-      <td class="span1">{{ thread.replies|intcomma }}</td>
+      <td class="span1 thread-stat">{{ thread.replies|intcomma }}</td>
       <td class="span2">{% if thread.last_poster_id %}<a href="{% url 'user' user=thread.last_poster_id, username=thread.last_poster_slug %}" class="tooltip-top" title="{{ thread.last|reltimesince }}">{{ thread.last_poster_name }}</a>{% else %}<em class="tooltip-top muted" title="{{ thread.last|reltimesince }}">{{ thread.last_poster_name }}</em>{% endif %}</td>
+      {% if user.is_authenticated() and list_form %}
+      <td class="check-cell"><label class="checkbox"><input form="threads_form" name="{{ list_form['list_items']['html_name'] }}" type="checkbox" class="checkbox-member" value="{{ thread.pk }}"{% if list_form['list_items']['has_value'] and ('' ~ thread.pk) in list_form['list_items']['value'] %} checked="checked"{% endif %}></label></td>
+      {% endif %}
+    </tr>
+    {% else %}
+    <tr>
+      <td colspan="5" style="text-align: center;">
+        <p class="lead">{% trans %}Looks like there are no threads in this forum.{% endtrans %}</p>
+        {% if user.is_authenticated() and acl.threads.can_start_threads(forum) %}
+        <a href="{% url 'thread_new' forum=forum.pk, slug=forum.slug %}" class="btn btn-primary btn-large">{% trans %}Let's Change This!{% endtrans %}</a></li>
+        {% endif %}
+      </td>
     </tr>
     {% endfor %}
   </tbody>
 </table>
+{% if user.is_authenticated() and list_form %}
 <div class="form-actions table-footer">
-  [MOD ACTIONS]
+  <form id="threads_form" class="form-inline pull-right" action="{% url 'forum' slug=forum.slug, forum=forum.id, page=pagination['page'] %}" method="POST">
+    <input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}">
+    {{ form_theme.input_select(list_form['list_action'],width=3) }}
+    <button type="submit" class="btn btn-primary">{% trans %}Go{% endtrans %}</button>
+  </form>
 </div>
+{% endif %}
 <div class="list-nav last">
   {{ pager() }}
   {% if user.is_authenticated() and acl.threads.can_start_threads(forum) %}
@@ -85,3 +118,23 @@
     </li>
   </ul>
 {% endmacro %}
+
+{% block javascripts -%}
+{{ super() }}
+{%- if user.is_authenticated() and acl.threads.can_start_threads(forum) %}
+  <script type="text/javascript">
+    $(function () {
+      $('#threads_form').submit(function() {
+        if ($('.check-cell[]:checked').length == 0) {
+          alert("{% trans %}You have to select at least one thread.{% endtrans %}");
+          return false;
+        }
+        if ($('#id_list_action').val() == 'hard') {
+          var decision = ("{% trans %}Are you sure you want to delete selected threads? This action is not reversible!{% endtrans %}");
+          return decision;
+        }
+        return true;
+      });
+    });
+  </script>{% endif %}
+{%- endblock %}

+ 10 - 0
templates/sora/threads/posting.html

@@ -20,6 +20,16 @@
     {{ self.breadcrumb() }}</li>
   </ul>
   <h1>{% trans %}Post New Thread{% endtrans %}</h1>
+  <ul class="unstyled thread-info">
+    {% if thread.moderated %}<li><i class="icon-eye-close"></i> {% trans %}Not Reviewed{% endtrans %}</li>{% endif %}
+    <li><i class="icon-time"></i> {{ thread.last|reltimesince }}</li>
+    <li><i class="icon-user"></i> {% if thread.start_poster_id %}<a href="{% url 'user' user=thread.start_poster_id, username=thread.start_poster_slug %}">{{ thread.start_poster_name }}</a>{% else %}{{ thread.start_poster_name }}{% endif %}</li>
+    <li><i class="icon-comment"></i> {% if thread.replies > 0 -%}
+      {% trans count=thread.replies, replies=thread.replies|intcomma %}One reply{% pluralize %}{{ replies }} replies{% endtrans %}
+    {%- else -%}
+      {% trans %}No replies{% endtrans %}
+    {%- endif %}</li>
+  </ul>
 </div>
 {% if message %}{{ macros.draw_message(message, 'alert-form') }}{% endif %}
 <form action="{% url 'thread_new' forum=forum.pk, slug=forum.slug %}" method="post">

+ 33 - 5
templates/sora/threads/thread.html

@@ -20,6 +20,7 @@
   </ul>
   <h1>{{ thread.name }}</h1>
   <ul class="unstyled thread-info">
+    {% if thread.moderated %}<li><i class="icon-eye-close"></i> {% trans %}Not Reviewed{% endtrans %}</li>{% endif %}
     <li><i class="icon-time"></i> {{ thread.last|reltimesince }}</li>
     <li><i class="icon-user"></i> {% if thread.start_poster_id %}<a href="{% url 'user' user=thread.start_poster_id, username=thread.start_poster_slug %}">{{ thread.start_poster_name }}</a>{% else %}{{ thread.start_poster_name }}{% endif %}</li>
     <li><i class="icon-comment"></i> {% if thread.replies > 0 -%}
@@ -42,6 +43,7 @@
 
 <div class="posts-list">
   {% for post in posts %}
+  {% if post.message %}{{ macros.draw_message(post.message) }}{% endif %}
   <div id="post-{{ post.pk }}" class="well well-post{% if post.user and post.user.rank and post.user.rank.style %} {{ post.user.rank.style }}{% endif %}">
     <div class="post-author">
       <img src="{{ post.user.get_avatar() }}" alt="" class="avatar-normal">
@@ -52,6 +54,26 @@
       </div>
     </div>
     <div class="post-content">
+      {% if not post.is_read %}
+      <span class="label label-warning pull-right">
+        {% trans %}New{% endtrans %}
+      </span>
+      {% endif %}
+      {% if post.moderated %}
+      <span class="label label-purple pull-right">
+        {% trans %}Unreviewed{% endtrans %}
+      </span>
+      {% endif %}
+      {% if post.reported %}
+      <span class="label label-important pull-right">
+        {% trans %}Reported{% endtrans %}
+      </span>
+      {% endif %}
+      {% if post.deleted %}
+      <span class="label label-inverse pull-right">
+        {% trans %}Deleted{% endtrans %}
+      </span>
+      {% endif %}
       <div class="markdown">
         {{ post.post_preparsed|safe }}
       </div>
@@ -73,7 +95,7 @@
 </div>
 
 <div class="list-nav last">
-  {{ pager() }}
+  {{ pager(false) }}
   {% if user.is_authenticated() and acl.threads.can_reply(thread) %}
   <ul class="nav nav-pills pull-right">
     <li class="primary"><a href="{% url 'thread_new' forum=forum.pk, slug=forum.slug %}"><i class="icon-plus"></i> {% trans %}Reply{% endtrans %}</a></li>
@@ -94,15 +116,21 @@
 {% endif %}
 {% endblock %}
 
-{% macro pager() %}
+{% macro pager(extra=true) %}
   <ul class="pager pull-left">
-    {%- if pagination['prev'] > 1 %}<li><a href="{% url 'forum' slug=forum.slug, forum=forum.id %}" class="tooltip-top" title="{% trans %}First Page{% endtrans %}"><i class="icon-chevron-left"></i> {% trans %}First{% endtrans %}</a></li>{% endif -%}
-    {%- if pagination['prev'] > 0 %}<li><a href="{%- if pagination['prev'] > 1 %}{% url 'forum' slug=forum.slug, forum=forum.id, page=pagination['prev'] %}{% else %}{% url 'forum' slug=forum.slug, forum=forum.id %}{% endif %}" class="tooltip-top" title="{% trans %}Newest Threads{% endtrans %}"><i class="icon-chevron-left"></i></a></li>{% endif -%}
-    {%- if pagination['next'] > 0 %}<li><a href="{% url 'forum' slug=forum.slug, forum=forum.id, page=pagination['next'] %}" class="tooltip-top" title="{% trans %}Older Threads{% endtrans %}"><i class="icon-chevron-right"></i></a></li>{% endif -%}
+    {%- if pagination['prev'] > 1 %}<li><a href="{% url 'thread' slug=thread.slug, thread=thread.id %}" class="tooltip-top" title="{% trans %}Go to first page{% endtrans %}"><i class="icon-chevron-left"></i> {% trans %}First{% endtrans %}</a></li>{% endif -%}
+    {%- if pagination['prev'] > 0 %}<li><a href="{%- if pagination['prev'] > 1 %}{% url 'thread' slug=thread.slug, thread=thread.id, page=pagination['prev'] %}{% else %}{% url 'thread' slug=thread.slug, thread=thread.id %}{% endif %}" class="tooltip-top" title="{% trans %}Older Posts{% endtrans %}"><i class="icon-chevron-left"></i></a></li>{% endif -%}
+    {%- if pagination['next'] > 0 %}<li><a href="{% url 'thread' slug=thread.slug, thread=thread.id, page=pagination['next'] %}" class="tooltip-top" title="{% trans %}Newest Posts{% endtrans %}"><i class="icon-chevron-right"></i></a></li>{% endif -%}
+    {%- if pagination['next'] > 0 and pagination['next'] < pagination['total'] %}<li><a href="{% url 'thread' slug=thread.slug, thread=thread.id, page=pagination['total'] %}" class="tooltip-top" title="{% trans %}Go to last page{% endtrans %}">{% trans %}Last{% endtrans %} <i class="icon-chevron-right"></i></a></li>{% endif -%}
     <li class="count">
     {%- trans current_page=pagination['page'], pages=pagination['total'] -%}
     Page {{ current_page }} of {{ pages }}
     {%- endtrans -%}
     </li>
+    {% if extra and user.is_authenticated() %}
+    {% if not is_read %}<li class="unread"><a href="{% url 'thread_new' slug=thread.slug, thread=thread.id %}" class="tooltip-top" title="{% trans %}Go to first unread{% endtrans %}"><i class="icon-star"></i> {% trans %}First Unread{% endtrans %}</a></li>{% endif %}
+    {% if thread.replies_moderated > 0 and acl.threads.can_approve(forum) %}<li class="moderated"><a href="{% url 'thread_moderated' slug=thread.slug, thread=thread.id %}" class="tooltip-top" title="{% trans %}Go to first post awaiting review{% endtrans %}"><i class="icon-eye-close"></i> {% trans %}First Unreviewed{% endtrans %}</a></li>{% endif %}
+    {% if thread.replies_reported > 0 and acl.threads.can_mod_posts(thread) %}<li class="reported"><a href="{% url 'thread_reported' slug=thread.slug, thread=thread.id %}" class="tooltip-top" title="{% trans %}Go to first reported post{% endtrans %}"><i class="icon-fire"></i> {% trans %}First Reported{% endtrans %}</a></li>{% endif %}
+    {% endif %}
   </ul>
 {% endmacro %}