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

- Global Annoucements, Private Discussions and Reports forums.
- Thread-list level moderation tools (without move/merge)
- Thread state changes are tracked

Ralfp 12 лет назад
Родитель
Сommit
948ac8b66e

+ 2 - 0
misago/admin/widgets.py

@@ -396,8 +396,10 @@ class ListWidget(BaseWidget):
                                                  'table_form': FormFields(table_form).fields if table_form else None,
                                                  'items': items,
                                                  'items_total': items_total,
+                                                 'items_shown': len(items),
                                                 }),
                                                 context_instance=RequestContext(request));
+
                                                 
 class FormWidget(BaseWidget):
     """

+ 9 - 13
misago/forums/fixtures.py

@@ -1,18 +1,14 @@
 from misago.monitor.fixtures import load_monitor_fixture
 from misago.forums.models import Forum
 
-monitor_fixtures = {
-                  'threads': 0,
-                  'posts': 0,
-                  }
-
-
 def load_fixtures():
-    load_monitor_fixture(monitor_fixtures)
+    anno = Forum(token='annoucements', name='annoucements', slug='annoucements', type='forum').insert_at(target=None,save=True)
+    Forum(token='private', name='private', slug='private', type='forum').insert_at(target=None,save=True)
+    Forum(token='reports', name='reports', slug='reports', type='forum').insert_at(target=None,save=True)
+    Forum(token='root', name='root', slug='root').insert_at(target=None,save=True)
     
-    root_forum = Forum(
-                       token='root',
-                       name='root',
-                       slug='root',
-                       )
-    Forum.objects.insert_node(root_forum,target=None,save=True)
+    load_monitor_fixture({
+                          'threads': 0,
+                          'posts': 0,
+                          'anno': anno.pk
+                          })

+ 25 - 0
misago/forums/models.py

@@ -108,5 +108,30 @@ class Forum(MPTTModel):
     def move_content(self, target):
         pass
     
+    def sync(self):
+        self.threads = self.thread_set.filter(moderated=0).filter(deleted=0).count()
+        self.posts = self.post_set.filter(moderated=0).filter(deleted=0).count()
+        self.last_poster = None
+        self.last_poster_name = None
+        self.last_poster_slug = None
+        self.last_poster_style = None
+        self.last_thread = None
+        self.last_thread_date = None
+        self.last_thread_name = None
+        self.last_thread_slug = None
+        try:
+            last_thread = self.thread_set.filter(moderated=0).filter(deleted=0).order_by('-last').all()[1:][0]
+            self.last_poster_name = last_thread.last_poster_name
+            self.last_poster_slug = last_thread.last_poster_slug
+            self.last_poster_style = last_thread.last_poster_style
+            if last_thread.last_poster:
+                self.last_poster = last_thread.last_poster
+            self.last_thread = last_thread
+            self.last_thread_date = last_thread.start
+            self.last_thread_name = last_thread.name
+            self.last_thread_slug = last_thread.slug
+        except (IndexError, AttributeError):
+            pass
+    
     def prune(self):
         pass

+ 12 - 1
misago/roles/views.py

@@ -131,7 +131,18 @@ class Forums(ListWidget):
         return Forum.objects.get(token='root').get_descendants()
     
     def sort_items(self, page_items, sorting_method):
-        return page_items.order_by('lft')
+        final_items = []
+        for forum in Forum.objects.filter(token__in=['annoucements', 'reports', 'private']).order_by('token'):
+            if forum.token == 'annoucements':
+                forum.name = _("Global Annoucements")
+            if forum.token == 'reports':
+                forum.name = _("Reports")
+            if forum.token == 'private':
+                forum.name = _("Private Discussions")
+            final_items.append(forum)
+        for forum in page_items.order_by('lft').all():
+            final_items.append(forum)
+        return final_items
 
     def add_template_variables(self, variables):
         variables['target'] = _(self.role.name)

+ 8 - 6
misago/threads/acl.py

@@ -45,8 +45,11 @@ def make_forum_form(request, role, form):
     form.base_fields['can_approve'] = forms.BooleanField(widget=YesNoSwitch,initial=False,required=False)
     form.base_fields['can_edit_labels'] = forms.BooleanField(widget=YesNoSwitch,initial=False,required=False)
     form.base_fields['can_see_changelog'] = forms.BooleanField(widget=YesNoSwitch,initial=False,required=False)
-    form.base_fields['can_make_annoucements'] = forms.BooleanField(widget=YesNoSwitch,initial=False,required=False)
-    form.base_fields['can_pin_threads'] = forms.BooleanField(widget=YesNoSwitch,initial=False,required=False)
+    form.base_fields['can_pin_threads'] = forms.ChoiceField(choices=(
+                                                                     ('0', _("No")),
+                                                                     ('1', _("Yes, to stickies")),
+                                                                     ('2', _("Yes, to annoucements")),
+                                                                     ))
     form.base_fields['can_edit_threads_posts'] = forms.BooleanField(widget=YesNoSwitch,initial=False,required=False)
     form.base_fields['can_move_threads_posts'] = forms.BooleanField(widget=YesNoSwitch,initial=False,required=False)
     form.base_fields['can_close_threads'] = forms.BooleanField(widget=YesNoSwitch,initial=False,required=False)
@@ -119,7 +122,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 make threads sticky")}),
+                         ('can_pin_threads', {'label': _("Can change threads weight")}),
                          ('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")}),
@@ -211,7 +214,7 @@ class ThreadsACL(BaseACL):
             if forum_role['can_write_posts'] == 0:
                 raise ACLError403(_("You don't have permission to write replies in this forum."))
             if forum_role['can_close_threads'] == 0:
-                if forum.closed:
+                if thread.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."))
@@ -230,7 +233,6 @@ class ThreadsACL(BaseACL):
             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']
@@ -283,7 +285,7 @@ def build_forums(acl, perms, forums, forum_roles):
                      'can_edit_labels': False,
                      'can_see_changelog': False,
                      'can_make_annoucements': False,
-                     'can_pin_threads': False,
+                     'can_pin_threads': 0,
                      'can_edit_threads_posts': False,
                      'can_move_threads_posts': False,
                      'can_close_threads': False,

+ 24 - 1
misago/threads/forms.py

@@ -1,6 +1,7 @@
 from django import forms
-from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import ungettext, ugettext_lazy as _
 from misago.forms import Form
+from misago.utils import slugify
 
 class PostForm(Form):
     thread_name = forms.CharField(max_length=255)
@@ -24,6 +25,28 @@ class PostForm(Form):
         if self.mode not in ['edit_thread', 'new_thread']:
             del self.fields['thread_name']
             del self.layout[0][1][0]
+            
+    def clean_thread_name(self):
+        data = self.cleaned_data['thread_name']
+        slug = slugify(data)
+        if len(slug) < self.request.settings['thread_name_min']:
+            raise forms.ValidationError(ungettext(
+                                                  "Thread name must contain at least one alpha-numeric character.",
+                                                  "Thread name must contain at least %(count)d alpha-numeric characters.",
+                                                  self.request.settings['thread_name_min']
+                                                  ) % {'count': self.request.settings['thread_name_min']})
+        return data
+            
+    def clean_post(self):
+        data = self.cleaned_data['post']
+        if len(data) < self.request.settings['post_length_min']:
+            raise forms.ValidationError(ungettext(
+                                                  "Post content cannot be empty.",
+                                                  "Post content cannot be shorter than %(count)d characters.",
+                                                  self.request.settings['post_length_min']
+                                                  ) % {'count': self.request.settings['post_length_min']})
+        return data
+        
         
 
 class QuickReplyForm(Form):

+ 47 - 2
misago/threads/models.py

@@ -1,4 +1,5 @@
 from django.db import models
+from django.utils import timezone
 from django.utils.translation import ugettext_lazy as _
 
 class ThreadManager(models.Manager):
@@ -49,7 +50,7 @@ class PostManager(models.Manager):
     
 
 class Post(models.Model):
-    forum = models.ForeignKey('forums.Forum',related_name='+')
+    forum = models.ForeignKey('forums.Forum')
     thread = models.ForeignKey(Thread)
     user = models.ForeignKey('users.User',null=True,blank=True)
     user_name = models.CharField(max_length=255)
@@ -77,4 +78,48 @@ class Post(models.Model):
     statistics_name = _('New Posts')
     
     def get_date(self):
-        return self.date
+        return self.date
+    
+    def set_checkpoint(self, request, action):
+        if request.user.is_authenticated():
+            self.checkpoint_set.create(
+                                       forum=self.forum,
+                                       thread=self.thread,
+                                       post=self,
+                                       action=action,
+                                       user=request.user,
+                                       user_name=request.user.username,
+                                       user_slug=request.user.username_slug,
+                                       date=timezone.now(),
+                                       ip=request.session.get_ip(request),
+                                       agent=request.META.get('HTTP_USER_AGENT'),
+                                       )
+
+
+class Change(models.Model):
+    forum = models.ForeignKey('forums.Forum')
+    thread = models.ForeignKey(Thread)
+    post = models.ForeignKey(Post)
+    user = models.ForeignKey('users.User',null=True,blank=True)
+    user_name = models.CharField(max_length=255)
+    user_slug = models.CharField(max_length=255)
+    date = models.DateTimeField()
+    ip = models.GenericIPAddressField()
+    agent = models.CharField(max_length=255)
+    change = models.IntegerField(default=0)
+    thread_name = models.CharField(max_length=255)
+    post_content = models.TextField()
+
+
+class Checkpoint(models.Model):
+    forum = models.ForeignKey('forums.Forum')
+    thread = models.ForeignKey(Thread)
+    post = models.ForeignKey(Post)
+    action = models.CharField(max_length=255)
+    user = models.ForeignKey('users.User',null=True,blank=True)
+    user_name = models.CharField(max_length=255)
+    user_slug = models.CharField(max_length=255)
+    date = models.DateTimeField()
+    ip = models.GenericIPAddressField()
+    agent = models.CharField(max_length=255)
+    

+ 123 - 15
misago/threads/views/list.py

@@ -1,4 +1,5 @@
 from django.core.urlresolvers import reverse
+from django.db.models import Q
 from django.shortcuts import redirect
 from django.template import RequestContext
 from django.utils.translation import ugettext as _
@@ -23,9 +24,13 @@ class ThreadsView(BaseView, ThreadsFormMixin):
         self.tracker = ThreadsTracker(self.request.user, self.forum)
                 
     def fetch_threads(self, page):
-        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.count = self.request.acl.threads.filter_threads(self.request, self.forum, Thread.objects.filter(forum=self.forum).filter(weight__lt=2)).count()
         self.pagination = make_pagination(page, self.count, self.request.settings.threads_per_page)
+        self.threads = []
+        for thread in Thread.objects.filter(Q(forum=self.request.monitor['anno']) | (Q(forum=self.forum) & Q(weight=2))):
+            self.threads.append(thread)
+        for thread in self.request.acl.threads.filter_threads(self.request, self.forum, Thread.objects.filter(forum=self.forum).filter(weight__lt=2)).order_by('-weight', '-last'):
+            self.threads.append(thread)
         if self.request.settings.threads_per_page < self.count:
             self.threads = self.threads[self.pagination['start']:self.pagination['stop']]
         for thread in self.threads:
@@ -37,11 +42,11 @@ class ThreadsView(BaseView, ThreadsFormMixin):
         try:
             if acl['can_approve']:
                actions.append(('accept', _('Accept threads')))
-            if acl['can_make_annoucements']:
+            if acl['can_pin_threads'] == 2:
                actions.append(('annouce', _('Change to annoucements')))
-            if acl['can_pin_threads']:
+            if acl['can_pin_threads'] > 0:
                actions.append(('sticky', _('Change to sticky threads')))
-            if acl['can_make_annoucements'] or acl['can_pin_threads']:
+            if acl['can_pin_threads'] > 0:
                actions.append(('normal', _('Change to standard thread')))
             if acl['can_move_threads_posts']:
                actions.append(('move', _('Move threads')))
@@ -59,28 +64,31 @@ class ThreadsView(BaseView, ThreadsFormMixin):
             pass
         return actions
     
-    def action_accept(self, ids, threads):
+    def action_accept(self, ids):
         accepted = 0
+        posts = 0
         users = []
-        for thread in self.threads.prefetch_related('last_post', 'last_post__user').all():
+        for thread in self.threads:
             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)
+                thread.start_post.moderated = False
+                thread.start_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)
+                    thread.start_post.user.threads += 1
+                    thread.start_post.user.posts += 1
+                    users.append(thread.start_post.user)
+                    thread.last_post.set_checkpoint(self.request, 'accepted')
                 # Sync forum
                 self.forum.threads += 1
                 self.forum.threads_delta += 1
-                self.forum.posts += 1
-                self.forum.posts_delta += 1
+                self.forum.posts += thread.replies + 1
+                posts += thread.replies + 1
+                self.forum.posts_delta += thread.replies + 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
@@ -92,12 +100,112 @@ class ThreadsView(BaseView, ThreadsFormMixin):
                     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.request.monitor['posts'] = posts
             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 action_annouce(self, ids):
+        acl = self.request.acl.threads.get_role(self.forum)
+        annouced = []
+        for thread in self.threads:
+            if thread.pk in ids and thread.weight < 2:
+                annouced.append(thread.pk)
+        if annouced:
+            Thread.objects.filter(id__in=annouced).update(weight=2)
+            self.request.messages.set_flash(Message(_('Selected threads have been turned into annoucements.')), 'success', 'threads')
+    
+    def action_sticky(self, ids):
+        sticky = []
+        for thread in self.threads:
+            if thread.pk in ids and thread.weight != 1 and (acl['can_pin_threads'] == 2 or thread.weight < 2):
+                sticky.append(thread.pk)
+        if sticky:
+            Thread.objects.filter(id__in=sticky).update(weight=1)
+            self.request.messages.set_flash(Message(_('Selected threads have been sticked to the top of list.')), 'success', 'threads')
+    
+    def action_normal(self, ids):
+        normalised = []
+        for thread in self.threads:
+            if thread.pk in ids and thread.weight > 0:
+                normalised.append(thread.pk)
+        if normalised:
+            Thread.objects.filter(id__in=normalised).update(weight=0)
+            self.request.messages.set_flash(Message(_('Selected threads weight has been removed.')), 'success', 'threads')
+        
+    def action_open(self, ids):
+        opened = []
+        for thread in self.threads:
+            if thread.pk in ids and thread.closed:
+                opened.append(thread.pk)
+                thread.last_post.set_checkpoint(self.request, 'opened')
+        if opened:
+            Thread.objects.filter(id__in=opened).update(closed=False)
+            self.request.messages.set_flash(Message(_('Selected threads have been opened.')), 'success', 'threads')
+    
+    def action_close(self, ids):
+        closed = []
+        for thread in self.threads:
+            if thread.pk in ids and not thread.closed:
+                closed.append(thread.pk)
+                thread.last_post.set_checkpoint(self.request, 'closed')
+        if closed:
+            Thread.objects.filter(id__in=closed).update(closed=True)
+            self.request.messages.set_flash(Message(_('Selected threads have been closed.')), 'success', 'threads')
+    
+    def action_undelete(self, ids):
+        undeleted = []
+        posts = 0
+        for thread in self.threads:
+            if thread.pk in ids and thread.deleted:
+                undeleted.append(thread.pk)
+                posts += thread.replies + 1
+                thread.start_post.deleted = False
+                thread.start_post.save(force_update=True)
+                thread.last_post.set_checkpoint(self.request, 'undeleted')
+        if undeleted:
+            self.request.monitor['threads'] = int(self.request.monitor['threads']) + len(undeleted)
+            self.request.monitor['posts'] = int(self.request.monitor['posts']) + posts
+            self.forum.threads += len(undeleted)
+            self.forum.posts += posts
+            self.forum.save(force_update=True)
+            Thread.objects.filter(id__in=undeleted).update(deleted=False)
+            self.request.messages.set_flash(Message(_('Selected threads have been undeleted.')), 'success', 'threads')
+    
+    def action_soft(self, ids):
+        deleted = []
+        posts = 0
+        for thread in self.threads:
+            if thread.pk in ids and not thread.deleted:
+                deleted.append(thread.pk)
+                posts += thread.replies + 1
+                thread.start_post.deleted = True
+                thread.start_post.save(force_update=True)
+                thread.last_post.set_checkpoint(self.request, 'deleted')
+        if deleted:
+            self.request.monitor['threads'] = int(self.request.monitor['threads']) - len(deleted)
+            self.request.monitor['posts'] = int(self.request.monitor['posts']) - posts
+            self.forum.sync()
+            self.forum.save(force_update=True)
+            Thread.objects.filter(id__in=deleted).update(deleted=True)
+            self.request.messages.set_flash(Message(_('Selected threads have been softly deleted.')), 'success', 'threads')            
+    
+    def action_hard(self, ids):
+        deleted = []
+        posts = 0
+        for thread in self.threads:
+            if thread.pk in ids:
+                deleted.append(thread.pk)
+                posts += thread.replies + 1
+                thread.delete()
+        if deleted:
+            self.request.monitor['threads'] = int(self.request.monitor['threads']) - len(deleted)
+            self.request.monitor['posts'] = int(self.request.monitor['posts']) - posts
+            self.forum.sync()
+            self.forum.save(force_update=True)
+            self.request.messages.set_flash(Message(_('Selected threads have been deleted.')), 'success', 'threads')
+    
     def __call__(self, request, slug=None, forum=None, page=0):
         self.request = request
         self.pagination = None

+ 19 - 6
misago/threads/views/mixins.py

@@ -3,6 +3,7 @@ from django.shortcuts import redirect
 from django.utils.translation import ugettext as _
 from misago.forms import Form
 from misago.messages import Message
+from misago.threads.models import Post
 
 class ThreadsFormMixin(object):
     def make_form(self):
@@ -16,7 +17,8 @@ class ThreadsFormMixin(object):
         form_fields['list_action'] = forms.ChoiceField(choices=list_choices)
         list_choices = []
         for item in self.threads:
-            list_choices.append((item.pk, None))
+            if item.forum_id == self.forum.pk:
+                list_choices.append((item.pk, None))
         if not list_choices:
             return
         form_fields['list_items'] = forms.MultipleChoiceField(choices=list_choices,widget=forms.CheckboxSelectMultiple)
@@ -27,14 +29,25 @@ class ThreadsFormMixin(object):
             self.form = self.form(self.request.POST, request=self.request)
             if self.form.is_valid():
                 checked_items = []
-                checked_ids = []
+                posts = []
                 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 str(thread.pk) in self.form.cleaned_data['list_items'] and thread.forum_id == self.forum.pk:
+                        posts.append(thread.start_post_id)
+                        if thread.start_post_id != thread.last_post_id:
+                            posts.append(thread.last_post_id)
+                        checked_items.append(thread.pk)
                 if checked_items:
+                    if posts:
+                        for post in Post.objects.filter(id__in=posts).prefetch_related('user'):
+                            for thread in self.threads:
+                                if thread.start_post_id == post.pk:
+                                    thread.start_post = post
+                                if thread.last_post_id == post.pk:
+                                    thread.last_post = post
+                                if thread.start_post_id == post.pk or thread.last_post_id == post.pk:
+                                    break
                     form_action = getattr(self, 'action_' + self.form.cleaned_data['list_action'])
-                    response = form_action(checked_ids, checked_items)
+                    response = form_action(checked_items)
                     if response:
                         return response
                     return redirect(self.request.path)

+ 9 - 8
misago/threads/views/posting.py

@@ -108,19 +108,20 @@ class PostingView(BaseView):
                     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
-                thread.last_poster_name = request.user.username
-                thread.last_poster_slug = request.user.username_slug
-                if request.user.rank and request.user.rank.style:
-                    thread.last_poster_style = request.user.rank.style
+                if not moderation:
+                    thread.last = now
+                    thread.last_post = post
+                    thread.last_poster = request.user
+                    thread.last_poster_name = request.user.username
+                    thread.last_poster_slug = request.user.username_slug
+                    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']:
                     if moderation:
                         thread.replies_moderated += 1
                     else:
                         thread.replies += 1
-                    thread.score += 5
+                        thread.score += 5
                 thread.save(force_update=True)
                 
                 # Update forum and monitor

+ 1 - 1
misago/threads/views/thread.py

@@ -23,7 +23,7 @@ class ThreadView(BaseView):
     
     def fetch_posts(self, page):
         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.posts = self.request.acl.threads.filter_posts(self.request, self.thread, Post.objects.filter(thread=self.thread)).order_by('pk').prefetch_related('checkpoint_set', 'user', 'user__rank')
         self.pagination = make_pagination(page, self.count, self.request.settings.posts_per_page)
         if self.request.settings.posts_per_page < self.count:
             self.posts = self.posts[self.pagination['start']:self.pagination['stop']]

+ 15 - 2
static/sora/css/sora.css

@@ -976,14 +976,27 @@ td.lead-cell{color:#555555;font-weight:bold;}
 .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-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{margin-left:286px;padding:0px 16px;}.posts-list .well-post .post-content .post-perma{margin-left:8px;color:#999999;}
+.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);}
+.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 ul{margin:0px;padding:0px;}
+.posts-list .well-post .post-nav .nav-pills li{opacity:0.1;filter:alpha(opacity=10);}.posts-list .well-post .post-nav .nav-pills li a{padding:6px 7px;}
+.posts-list .well-post .post-nav .nav-pills li button{padding:3px 7px;}
+.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 .nav-pills li{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;}
+.posts-list .post-checkpoints hr{background-color:#999999;background-image:-webkit-gradient(linear, 0 0, 100% 100%, color-stop(0.25, rgba(255, 255, 255, 0.2)), color-stop(0.25, transparent), color-stop(0.5, transparent), color-stop(0.5, rgba(255, 255, 255, 0.2)), color-stop(0.75, rgba(255, 255, 255, 0.2)), color-stop(0.75, transparent), to(transparent));background-image:-webkit-linear-gradient(-45deg, rgba(255, 255, 255, 0.2) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.2) 50%, rgba(255, 255, 255, 0.2) 75%, transparent 75%, transparent);background-image:-moz-linear-gradient(-45deg, rgba(255, 255, 255, 0.2) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.2) 50%, rgba(255, 255, 255, 0.2) 75%, transparent 75%, transparent);background-image:-ms-linear-gradient(-45deg, rgba(255, 255, 255, 0.2) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.2) 50%, rgba(255, 255, 255, 0.2) 75%, transparent 75%, transparent);background-image:-o-linear-gradient(-45deg, rgba(255, 255, 255, 0.2) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.2) 50%, rgba(255, 255, 255, 0.2) 75%, transparent 75%, transparent);background-image:linear-gradient(-45deg, rgba(255, 255, 255, 0.2) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.2) 50%, rgba(255, 255, 255, 0.2) 75%, transparent 75%, transparent);-webkit-background-size:10px 10px;-moz-background-size:10px 10px;background-size:10px 10px;border:none;margin:0px;margin-top:8px;height:4px;}
 .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;}
 .quick-reply .editor{margin-left:142px;}
-.editor{background-color:#e3e3e3;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;}.editor .editor-input{padding:8px;}.editor .editor-input div{margin-right:14px;}
+.editor{background-color:#e3e3e3;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;}.editor .editor-error{padding:12px 16px;padding-bottom:0px;}.editor .editor-error .help-block{color:#9d261d;}
+.editor .editor-error .help-block:last-child{margin-bottom:0px;}
+.editor .editor-input{padding:8px;}.editor .editor-input div{margin-right:14px;}
 .editor .editor-input textarea{margin:0px;width:100%;font-family:Monaco,Menlo,Consolas,"Courier New",monospace;}
 .editor .editor-actions{border-top:2px solid #c9c9c9;overflow:auto;padding:8px;}
 .markdown{margin-bottom:-12px;}

+ 13 - 0
static/sora/css/sora/editor.less

@@ -4,6 +4,19 @@
   background-color: darken(@bodyBackground, 10%);
   .border-radius(3px);
   
+  .editor-error {
+    padding: 12px 16px;
+    padding-bottom: 0px;
+    
+    .help-block {
+      color: @red;
+    }
+    
+    .help-block:last-child {
+      margin-bottom: 0px;
+    }
+  }
+  
   .editor-input {
     padding: 8px;
     

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

@@ -146,6 +146,12 @@
       margin-left: 286px;
       padding: 0px 16px;
       
+      .post-perma {
+        margin-left: 8px;
+        
+        color: @grayLight;
+      }
+      
       .label {
         margin-left: 8px;
         padding: 4px 5px;
@@ -180,6 +186,119 @@
         }        
       }
     }
+    
+    .post-nav {
+      clear: both;
+      margin-left: 286px;
+      overflow: auto;
+      padding: 8px 16px;
+      padding-bottom: 0px;
+      margin-bottom: -8px;
+      
+      ul {
+        margin: 0px;
+        padding: 0px;
+      }
+      
+      .nav-pills li {
+        .opacity(10);
+        
+        a {
+          padding: 6px 7px;
+        }
+        
+        button {
+          padding: 3px 7px;
+        }
+        
+        a, button {
+          background-color: darken(@bodyBackground, 20%);
+          border: none;
+          .border-radius(3px);
+          margin: 0px;
+          
+          color: @white;
+          font-weight: bold;
+          
+          &:hover {
+            background-color: lighten(@linkColor, 15%);
+            
+            &.danger {
+              background-color: lighten(@red, 15%);
+            }
+          }
+        }
+      
+        i {
+          background-image: url("@{iconWhiteSpritePath}");
+        }
+      }
+    }
+    
+    &:hover {
+      .nav-pills li {
+        .opacity(100);
+      }
+    }
+  }
+  
+  .post-checkpoints {
+    padding-top: 4px;
+    
+    &:last-child {
+      margin-bottom: -24px;
+    }
+    
+    .checkpoint {
+      margin: 0px;
+      
+      color: @grayLight;
+      text-align: center;
+      
+      span {
+        background-color: @bodyBackground;
+        display: inline-block;
+        padding: 4px 12px;
+        position: relative;
+        bottom: 16px;
+      }
+    }
+    
+    a {
+      color: @textColor;
+      font-weight: bold;
+    }
+    
+    hr {
+      background-color: @grayLight;
+      background-image: -webkit-gradient(linear, 0 0, 100% 100%,
+                  color-stop(.25, rgba(255, 255, 255, .2)), color-stop(.25, transparent),
+                  color-stop(.5, transparent), color-stop(.5, rgba(255, 255, 255, .2)),
+                  color-stop(.75, rgba(255, 255, 255, .2)), color-stop(.75, transparent),
+                  to(transparent));
+      background-image: -webkit-linear-gradient(-45deg, rgba(255, 255, 255, .2) 25%, transparent 25%,
+                transparent 50%, rgba(255, 255, 255, .2) 50%, rgba(255, 255, 255, .2) 75%,
+                transparent 75%, transparent);
+      background-image: -moz-linear-gradient(-45deg, rgba(255, 255, 255, .2) 25%, transparent 25%,
+                transparent 50%, rgba(255, 255, 255, .2) 50%, rgba(255, 255, 255, .2) 75%,
+                transparent 75%, transparent);
+      background-image: -ms-linear-gradient(-45deg, rgba(255, 255, 255, .2) 25%, transparent 25%,
+                transparent 50%, rgba(255, 255, 255, .2) 50%, rgba(255, 255, 255, .2) 75%,
+                transparent 75%, transparent);
+      background-image: -o-linear-gradient(-45deg, rgba(255, 255, 255, .2) 25%, transparent 25%,
+                transparent 50%, rgba(255, 255, 255, .2) 50%, rgba(255, 255, 255, .2) 75%,
+                transparent 75%, transparent);
+      background-image: linear-gradient(-45deg, rgba(255, 255, 255, .2) 25%, transparent 25%,
+                transparent 50%, rgba(255, 255, 255, .2) 50%, rgba(255, 255, 255, .2) 75%,
+                transparent 75%, transparent);
+      -webkit-background-size: 10px 10px;
+      -moz-background-size: 10px 10px;
+      background-size: 10px 10px;
+      border: none;
+      margin: 0px;
+      margin-top: 8px;
+      height: 4px;
+    }
   }
 }
 

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

@@ -66,7 +66,7 @@
   <div class="table-count pull-left">{%- trans current_page=pagination['page'], pages=pagination['total'] -%}
   Page {{ current_page }} of {{ pages }}
   {%- endtrans -%}</div>{% else %}
-  <div class="table-count pull-left">{% trans count=items_total, total=items_total|intcomma, shown=items.count()|intcomma -%}Showing all items
+  <div class="table-count pull-left">{% trans count=items_total, total=items_total|intcomma, shown=items_shown|intcomma -%}Showing one item
 {%- pluralize -%}
 Showing {{ shown }} of {{ total }} items
 {%- endtrans %}</div>{% endif %}

+ 7 - 0
templates/sora/editor.html

@@ -1,5 +1,12 @@
 {% macro editor(field, submit_button, placeholder=None, rows=4) %}
 <div class="editor">
+  {% if field.errors %}
+  <div class="editor-error">
+    {% for error in field.errors %}
+    <p class="help-block" style="font-weight: bold;">{{ error }}</p>
+    {% endfor %}
+  </div>
+  {% endif %}
   <div class="editor-input">
     <div>
       <textarea name="{{ field.html_name }}" id="{{ field.html_id }}" rows="{{ rows }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}>{% if field.has_value %}{{ field.value }}{% endif %}</textarea>

+ 9 - 6
templates/sora/threads/list.html

@@ -55,14 +55,17 @@
     <tr>
       <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> {% if not thread.is_read -%}
+        {% if thread.weight > 0 %}<strong>
+        {% if thread.weight == 2 %}
+        <i class="icon-fire"></i> {% trans %}Annoucement:{% endtrans %}
+        {%- else -%}
+        <i class="icon-asterisk"></i> {% trans %}Sticky:{% endtrans %}
+        {%- endif %}</strong> {% endif %}<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 user.is_crawler() %}{% 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 %}
+        {%- endif %}{% 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 %}
@@ -72,7 +75,7 @@
       <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>
+      <td class="check-cell">{% if thread.forum_id == forum.pk %}<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>{% else %}&nbsp;{% endif %}</td>
       {% endif %}
     </tr>
     {% else %}
@@ -130,7 +133,7 @@
           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 %}");
+          var decision = confirm("{% trans %}Are you sure you want to delete selected threads? This action is not reversible!{% endtrans %}");
           return decision;
         }
         return true;

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

@@ -20,6 +20,7 @@
     {{ self.breadcrumb() }}</li>
   </ul>
   <h1>{% trans %}Post New Thread{% endtrans %}</h1>
+  {% if thread %}
   <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>
@@ -30,6 +31,7 @@
       {% trans %}No replies{% endtrans %}
     {%- endif %}</li>
   </ul>
+  {%- endif %}
 </div>
 {% if message %}{{ macros.draw_message(message, 'alert-form') }}{% endif %}
 <form action="{% url 'thread_new' forum=forum.pk, slug=forum.slug %}" method="post">

+ 41 - 0
templates/sora/threads/thread.html

@@ -54,6 +54,11 @@
       </div>
     </div>
     <div class="post-content">
+      <a href="{% if pagination['page'] > 1 -%}
+      {% url 'thread' thread=thread.pk, slug=thread.slug, page=pagination['page'] %}
+      {%- else -%}
+      {% url 'thread' thread=thread.pk, slug=thread.slug %}
+      {%- endif %}#post-{{ post.pk }}" class="post-perma pull-right tooltip-left" title="{% trans %}Direct link to this post{% endtrans %}">#{{ pagination['start'] + loop.index }}</a>
       {% if not post.is_read %}
       <span class="label label-warning pull-right">
         {% trans %}New{% endtrans %}
@@ -90,7 +95,39 @@
       </div>
       {% endif %}
     </div>
+    <div class="post-nav">
+      <ul class="nav nav-pills pull-right">
+        <li><a href="#"><i class="icon-info-sign"></i> {% trans %}Info{% endtrans %}</a></li>
+        <li><button class="btn danger"><i class="icon-remove"></i> {% trans %}Delete{% endtrans %}</button></li>
+        <li><a href="#"><i class="icon-edit"></i> {% trans %}Edit{% endtrans %}</a></li>
+        <li><a href="#"><i class="icon-comment"></i> {% trans %}Reply{% endtrans %}</a></li>
+      </ul>
+    </div>
+  </div>
+  {% if post.checkpoint_set.all() %}
+  <div class="post-checkpoints">
+    {% for checkpoint in post.checkpoint_set.all() %}
+    <div class="checkpoint">
+      <hr>
+      <span>
+        {%- if checkpoint.action == 'limit' -%}
+        <i class="icon-lock"></i> {% trans  %}This thread has reached post limit and has been closed.{% endtrans %}
+        {%- elif checkpoint.action == 'accepted' -%}
+        <i class="icon-ok"></i> {% trans user=checkpoint_user(checkpoint), date=checkpoint.date|reltimesince|low %}{{ user }} accepted this thread {{ date }}{% endtrans %}
+        {%- elif checkpoint.action == 'closed' -%}
+        <i class="icon-lock"></i> {% trans user=checkpoint_user(checkpoint), date=checkpoint.date|reltimesince|low %}{{ user }} closed this thread {{ date }}{% endtrans %}
+        {%- elif checkpoint.action == 'opened' -%}
+        <i class="icon-lock"></i> {% trans user=checkpoint_user(checkpoint), date=checkpoint.date|reltimesince|low %}{{ user }} opened this thread {{ date }}{% endtrans %}
+        {%- elif checkpoint.action == 'deleted' -%}
+        <i class="icon-trash"></i> {% trans user=checkpoint_user(checkpoint), date=checkpoint.date|reltimesince|low %}{{ user }} deleted this thread {{ date }}{% endtrans %}
+        {%- elif checkpoint.action == 'undeleted' -%}
+        <i class="icon-trash"></i> {% trans user=checkpoint_user(checkpoint), date=checkpoint.date|reltimesince|low %}{{ user }} restored this thread {{ date }}{% endtrans %}
+        {%- endif -%}
+      </span>
+    </div>
+    {% endfor %}
   </div>
+  {% endif %}
   {% endfor %}
 </div>
 
@@ -134,3 +171,7 @@
     {% endif %}
   </ul>
 {% endmacro %}
+
+{% macro checkpoint_user(checkpoint) -%}
+{{ ('<a href="' ~ 'user'|url(user=checkpoint.user_id, username=checkpoint.user_slug) ~ '">')|safe ~ (checkpoint.user_name) ~ ("</a>")|safe }}
+{%- endmacro %}

+ 1 - 1
templates/sora/userbar.html

@@ -2,9 +2,9 @@
   <div class="navbar-inner">
     <div class="container">
       <ul class="nav">{% if user.is_authenticated() %}
+        <li><a href="#" title="{% trans %}Active Reports{% endtrans %}" class="tooltip-bottom"><i class="icon-warning-sign"></i><span class="stat">5</span></a></li>
         <li><a href="#" title="{% trans %}Your Notifications{% endtrans %}" class="tooltip-bottom"><i class="icon-fire"></i><span class="stat att">13</span></a></li>
         <li><a href="#" title="{% trans %}Private messages{% endtrans %}" class="tooltip-bottom"><i class="icon-inbox"></i><span class="stat">2</span></a></li>
-        <li><a href="#" title="{% trans %}Reported posts{% endtrans %}" class="tooltip-bottom"><i class="icon-bell"></i><span class="stat">5</span></a></li>
         <li><a href="#" title="{% trans %}People you are following{% endtrans %}" class="tooltip-bottom"><i class="icon-heart"></i></a></li>
         <li><a href="#" title="{% trans %}Threads you are watching{% endtrans %}" class="tooltip-bottom"><i class="icon-bookmark"></i></a></li>{% endif %}
         <li><a href="#" title="{% trans %}Today Posts{% endtrans %}" class="tooltip-bottom"><i class="icon-star"></i></a></li>