Browse Source

Misc bugfixes, advancements in post moderation.

Ralfp 12 years ago
parent
commit
13dfb2c490

+ 3 - 3
misago/forums/models.py

@@ -158,8 +158,8 @@ class Forum(MPTTModel):
         pass
         pass
     
     
     def sync(self):
     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.threads = self.thread_set.filter(moderated=False).filter(deleted=False).count()
+        self.posts = self.post_set.filter(moderated=False).filter(deleted=False).count()
         self.last_poster = None
         self.last_poster = None
         self.last_poster_name = None
         self.last_poster_name = None
         self.last_poster_slug = None
         self.last_poster_slug = None
@@ -169,7 +169,7 @@ class Forum(MPTTModel):
         self.last_thread_name = None
         self.last_thread_name = None
         self.last_thread_slug = None
         self.last_thread_slug = None
         try:
         try:
-            last_thread = self.thread_set.filter(moderated=0).filter(deleted=0).order_by('-last').all()[1:][0]
+            last_thread = self.thread_set.filter(moderated=False).filter(deleted=False).order_by('-last').all()[0:][0]
             self.last_poster_name = last_thread.last_poster_name
             self.last_poster_name = last_thread.last_poster_name
             self.last_poster_slug = last_thread.last_poster_slug
             self.last_poster_slug = last_thread.last_poster_slug
             self.last_poster_style = last_thread.last_poster_style
             self.last_poster_style = last_thread.last_poster_style

+ 98 - 1
misago/threads/acl.py

@@ -154,6 +154,8 @@ class ThreadsACL(BaseACL):
                 raise ACLError404()
                 raise ACLError404()
             if thread.moderated and not (forum_role['can_approve'] or (user.is_authenticated() and user == thread.start_poster)):
             if thread.moderated and not (forum_role['can_approve'] or (user.is_authenticated() and user == thread.start_poster)):
                 raise ACLError404()
                 raise ACLError404()
+            if thread.deleted and not forum_role['can_delete_threads']:
+                raise ACLError404()
         except KeyError:
         except KeyError:
             raise ACLError403(_("You don't have permission to read threads in this forum."))
             raise ACLError403(_("You don't have permission to read threads in this forum."))
     
     
@@ -181,6 +183,8 @@ class ThreadsACL(BaseACL):
                     queryset = queryset.filter(moderated=0)
                     queryset = queryset.filter(moderated=0)
             if forum_role['can_read_threads'] == 1:
             if forum_role['can_read_threads'] == 1:
                 queryset = queryset.filter(Q(weight=2) | Q(start_poster_id=request.user.id))
                 queryset = queryset.filter(Q(weight=2) | Q(start_poster_id=request.user.id))
+            if not forum_role['can_delete_threads']:
+                queryset = queryset.filter(deleted=False)
         except KeyError:
         except KeyError:
             return False
             return False
         return queryset
         return queryset
@@ -234,6 +238,7 @@ class ThreadsACL(BaseACL):
     def allow_thread_edit(self, user, forum, thread, post):
     def allow_thread_edit(self, user, forum, thread, post):
         try:
         try:
             forum_role = self.acl[thread.forum_id]
             forum_role = self.acl[thread.forum_id]
+            self.allow_deleted_post_view(forum)
             if not forum_role['can_close_threads']:
             if not forum_role['can_close_threads']:
                 if forum.closed:
                 if forum.closed:
                     raise ACLError403(_("You can't edit threads in closed forums."))
                     raise ACLError403(_("You can't edit threads in closed forums."))
@@ -289,6 +294,7 @@ class ThreadsACL(BaseACL):
     def allow_reply_edit(self, user, forum, thread, post):
     def allow_reply_edit(self, user, forum, thread, post):
         try:
         try:
             forum_role = self.acl[thread.forum_id]
             forum_role = self.acl[thread.forum_id]
+            self.allow_deleted_post_view(forum)
             if not forum_role['can_close_threads']:
             if not forum_role['can_close_threads']:
                 if forum.closed:
                 if forum.closed:
                     raise ACLError403(_("You can't edit replies in closed forums."))
                     raise ACLError403(_("You can't edit replies in closed forums."))
@@ -314,6 +320,7 @@ class ThreadsACL(BaseACL):
     def allow_changelog_view(self, user, forum, post):
     def allow_changelog_view(self, user, forum, post):
         try:
         try:
             forum_role = self.acl[forum.pk]
             forum_role = self.acl[forum.pk]
+            self.allow_deleted_post_view(forum)
             if not (forum_role['can_see_changelog'] or user.pk == post.user_id):
             if not (forum_role['can_see_changelog'] or user.pk == post.user_id):
                 raise ACLError403(_("You don't have permission to see history of changes made to this post."))
                 raise ACLError403(_("You don't have permission to see history of changes made to this post."))
         except KeyError:
         except KeyError:
@@ -376,11 +383,101 @@ class ThreadsACL(BaseACL):
         
         
     def can_protect(self, forum):
     def can_protect(self, forum):
         try:
         try:
-            forum_role = self.acl[thread.forum.pk]
+            forum_role = self.acl[forum.pk]
             return forum_role['can_protect_posts']
             return forum_role['can_protect_posts']
         except KeyError:
         except KeyError:
             return False
             return False
 
 
+    def can_delete_thread(self, user, forum, thread, post):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_close_threads'] and (forum.closed or thread.closed):
+                return False
+            if post.protected and not forum_role['can_protect_posts']:
+                return False
+            if forum_role['can_delete_threads']:
+                return forum_role['can_delete_threads']
+            if thread.start_poster_id == user.pk and forum_role['can_soft_delete_own_threads']:
+                return 1
+            return False
+        except KeyError:
+            return False
+        
+    def allow_delete_thread(self, user, forum, thread, post, delete=False):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_close_threads']:
+                if forum.closed:
+                    raise ACLError403(_("You don't have permission to delete threads in closed forum."))
+                if thread.closed:
+                    raise ACLError403(_("This thread is closed, you cannot delete it."))
+            if post.protected and not forum_role['can_protect_posts']:
+                raise ACLError403(_("This post is protected, you cannot delete it."))
+            if delete and forum_role['can_delete_threads'] < 2:
+                raise ACLError403(_("You cannot hard delete this thread."))
+            if not (forum_role['can_delete_threads'] or (thread.start_poster_id == user.pk and forum_role['can_soft_delete_own_threads'])):
+                raise ACLError403(_("You don't have permission to delete this thread."))
+            if thread.deleted and not delete:
+                raise ACLError403(_("This thread is already deleted."))
+        except KeyError:
+            raise ACLError403(_("You don't have permission to delete this thread."))
+
+    def can_delete_post(self, user, forum, thread, post):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_close_threads'] and (forum.closed or thread.closed):
+                return False
+            if post.protected and not forum_role['can_protect_posts']:
+                return False
+            if forum_role['can_delete_posts']:
+                return forum_role['can_delete_posts']
+            if post.user_id == user.pk and not post.protected and forum_role['can_soft_delete_own_posts']:
+                return 1
+            return False
+        except KeyError:
+            return False
+
+    def allow_delete_post(self, user, forum, thread, post, delete=False):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_close_threads']:
+                if forum.closed:
+                    raise ACLError403(_("You don't have permission to delete posts in closed forum."))
+                if thread.closed:
+                    raise ACLError403(_("This thread is closed, you cannot delete its posts."))
+            if post.protected and not forum_role['can_protect_posts']:
+                raise ACLError403(_("This post is protected, you cannot delete it."))
+            if delete and forum_role['can_delete_posts'] < 2:
+                raise ACLError403(_("You cannot hard delete this post."))
+            if not (forum_role['can_delete_posts'] or (post.user_id == user.pk and forum_role['can_soft_delete_own_posts'])):
+                raise ACLError403(_("You don't have permission to delete this post."))
+            if post.deleted and not delete:
+                raise ACLError403(_("This post is already deleted."))
+        except KeyError:
+            raise ACLError403(_("You don't have permission to delete this post."))
+    
+    def can_see_deleted_threads(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_delete_threads']
+        except KeyError:
+            raise false
+        
+    def can_see_deleted_posts(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            return forum_role['can_delete_posts']
+        except KeyError:
+            raise false
+        
+    def allow_deleted_post_view(self, forum):
+        try:
+            forum_role = self.acl[forum.pk]
+            if not forum_role['can_delete_posts']:
+                raise ACLError404()
+        except KeyError:
+            raise ACLError404()
+
 
 
 def build_forums(acl, perms, forums, forum_roles):
 def build_forums(acl, perms, forums, forum_roles):
     acl.threads = ThreadsACL()
     acl.threads = ThreadsACL()

+ 4 - 0
misago/threads/forms.py

@@ -71,6 +71,10 @@ class PostForm(Form, ThreadNameMixin):
         
         
         
         
 
 
+class SplitThreadForm(Form, ThreadNameMixin):
+    pass
+
+
 class QuickReplyForm(Form):
 class QuickReplyForm(Form):
     post = forms.CharField(widget=forms.Textarea)
     post = forms.CharField(widget=forms.Textarea)
 
 

+ 11 - 6
misago/threads/models.py

@@ -46,6 +46,13 @@ class Thread(models.Model):
         return self.start
         return self.start
     
     
     def sync(self):
     def sync(self):
+        # Counters
+        self.replies = self.post_set.filter(moderated=False).filter(deleted=False).count() - 1
+        if self.replies < 0:
+            self.replies = 0
+        self.replies_reported = self.post_set.filter(reported=True).count()
+        self.replies_moderated = self.post_set.filter(moderated=True).count()
+        self.replies_deleted = self.post_set.filter(deleted=True).count()
         # First post
         # First post
         start_post = self.post_set.order_by('merge', 'id')[0:][0]
         start_post = self.post_set.order_by('merge', 'id')[0:][0]
         self.start = start_post.date
         self.start = start_post.date
@@ -57,7 +64,10 @@ class Thread(models.Model):
         self.upvotes = start_post.upvotes
         self.upvotes = start_post.upvotes
         self.downvotes = start_post.downvotes
         self.downvotes = start_post.downvotes
         # Last post
         # Last post
-        last_post = self.post_set.order_by('-merge', '-id').filter(moderated=False).filter(deleted=False)[0:][0]
+        if self.replies > 0:
+            last_post = self.post_set.order_by('-merge', '-id').filter(moderated=False).filter(deleted=False)[0:][0]
+        else:
+            last_post = start_post
         self.last = last_post.date
         self.last = last_post.date
         self.last_post = last_post
         self.last_post = last_post
         self.last_poster = last_post.user
         self.last_poster = last_post.user
@@ -68,11 +78,6 @@ class Thread(models.Model):
         self.moderated = start_post.moderated
         self.moderated = start_post.moderated
         self.deleted = start_post.deleted
         self.deleted = start_post.deleted
         self.merges = last_post.merge
         self.merges = last_post.merge
-        # Counters
-        self.replies = self.post_set.filter(moderated=False).filter(deleted=False).count() - 1
-        self.replies_reported = self.post_set.filter(reported=True).count()
-        self.replies_moderated = self.post_set.filter(moderated=True).count()
-        self.replies_deleted = self.post_set.filter(deleted=True).count()
     
     
 
 
 class PostManager(models.Manager):
 class PostManager(models.Manager):

+ 4 - 0
misago/threads/urls.py

@@ -15,6 +15,10 @@ urlpatterns = patterns('misago.threads.views',
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<quote>\d+)/reply/$', 'PostingView', name="thread_reply", kwargs={'mode': 'new_post'}),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<quote>\d+)/reply/$', 'PostingView', name="thread_reply", kwargs={'mode': 'new_post'}),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/edit/$', 'PostingView', name="thread_edit", kwargs={'mode': 'edit_thread'}),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/edit/$', 'PostingView', name="thread_edit", kwargs={'mode': 'edit_thread'}),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/edit/$', 'PostingView', name="post_edit", kwargs={'mode': 'edit_post'}),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/edit/$', 'PostingView', name="post_edit", kwargs={'mode': 'edit_post'}),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/delete/$', 'DeleteView', name="thread_delete", kwargs={'mode': 'delete_thread'}),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/hide/$', 'DeleteView', name="thread_hide", kwargs={'mode': 'hide_thread'}),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/delete/$', 'DeleteView', name="post_delete", kwargs={'mode': 'delete_post'}),
+    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/hide/$', 'DeleteView', name="post_hide", kwargs={'mode': 'hide_post'}),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/$', 'ChangelogView', name="changelog"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/$', 'ChangelogView', name="changelog"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/$', 'ChangelogDiffView', name="changelog_diff"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/$', 'ChangelogDiffView', name="changelog_diff"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/revert/$', 'ChangelogRevertView', name="changelog_revert"),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/revert/$', 'ChangelogRevertView', name="changelog_revert"),

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

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

+ 11 - 1
misago/threads/views/base.py

@@ -1,4 +1,14 @@
+from django.core.urlresolvers import reverse
+from django.shortcuts import redirect
+from misago.utils import make_pagination
+
 class BaseView(object):
 class BaseView(object):
     def __new__(cls, request, **kwargs):
     def __new__(cls, request, **kwargs):
         obj = super(BaseView, cls).__new__(cls)
         obj = super(BaseView, cls).__new__(cls)
-        return obj(request, **kwargs)
+        return obj(request, **kwargs)
+    
+    def redirect_to_post(self, post):
+        pagination = make_pagination(0, self.request.acl.threads.filter_posts(self.request, self.thread, self.thread.post_set).filter(id__lte=self.post.pk).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))

+ 105 - 0
misago/threads/views/delete.py

@@ -0,0 +1,105 @@
+from django.core.urlresolvers import reverse
+from django.shortcuts import redirect
+from django.template import RequestContext
+from django.utils import timezone
+from django.utils.translation import ugettext as _
+from misago.acl.utils import ACLError403, ACLError404
+from misago.forums.models import Forum
+from misago.messages import Message
+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 DeleteView(BaseView):
+    def fetch_thread(self, kwargs):
+        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)
+        if self.mode in ['tread_delete', 'hide_thread']:
+            self.request.acl.threads.allow_delete_thread(
+                                                         self.request.user,
+                                                         self.forum,
+                                                         self.thread,
+                                                         self.thread.start_post,
+                                                         self.mode == 'delete_thread')
+            # Assert we are not user trying to delete thread with replies
+            acl = self.request.acl.threads.get_role(self.thread.forum_id)
+            if not acl['can_delete_threads']:
+                if self.thread.post_set.exclude(user_id=self.request.user.id).count() > 0:
+                    raise ACLError403(_("Somebody has already replied to this thread. You cannot delete it."))
+            
+    def fetch_post(self, kwargs):
+        self.post = self.thread.post_set.get(pk=kwargs['post'])
+        if self.post.pk == self.thread.start_post_id:
+            raise Post.DoesNotExist()
+        self.request.acl.threads.allow_post_view(self.request.user, self.thread, self.post)
+        self.request.acl.threads.allow_delete_post(
+                                                   self.request.user,
+                                                   self.forum,
+                                                   self.thread,
+                                                   self.post,
+                                                   self.mode == 'delete_post')
+        acl = self.request.acl.threads.get_role(self.thread.forum_id)
+        if not acl['can_delete_posts'] and self.thread.post_set.filter(id__gt=self.post.pk).count() > 0:
+            raise ACLError403(_("Somebody has already replied to this post, you cannot delete it."))
+        
+    def __call__(self, request, **kwargs):
+        self.request = request
+        self.mode = kwargs['mode']
+        try:
+            if not request.user.is_authenticated():
+                raise ACLError403(_("Guest, you have to sign-in in order to be able to delete replies."))
+            self.fetch_thread(kwargs)
+            if self.mode in ['hide_post', 'delete_post']:
+                self.fetch_post(kwargs)
+        except (Thread.DoesNotExist, Post.DoesNotExist):
+            return error404(self.request)
+        except ACLError403 as e:
+            return error403(request, e.message)
+        except ACLError404 as e:
+            return error404(request, e.message)
+        
+        if self.mode == 'delete_thread':
+            self.thread.delete()
+            self.forum.sync()
+            self.forum.save(force_update=True)
+            request.messages.set_flash(Message(_('Thread "%(thread)s" has been deleted.') % {'thread': self.thread.name}), 'success', 'threads')
+            return redirect(reverse('forum', kwargs={'forum': self.thread.forum.pk, 'slug': self.thread.forum.slug}))
+        
+        if self.mode == 'hide_thread':
+            self.thread.start_post.deleted = True
+            self.thread.start_post.save(force_update=True)
+            self.thread.last_post.set_checkpoint(request, 'deleted')
+            self.thread.sync()
+            self.thread.save(force_update=True)
+            self.forum.sync()
+            self.forum.save(force_update=True)
+            request.messages.set_flash(Message(_('Thread "%(thread)s" has been deleted.') % {'thread': self.thread.name}), 'success', 'threads')
+            if request.acl.threads.can_see_deleted_threads(self.thread.forum):
+                return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}))
+            return redirect(reverse('forum', kwargs={'forum': self.thread.forum.pk, 'slug': self.thread.forum.slug}))
+        
+        if self.mode == 'delete_post':
+            self.post.delete()
+            self.thread.sync()
+            self.thread.save(force_update=True)
+            self.forum.sync()
+            self.forum.save(force_update=True)
+            request.messages.set_flash(Message(_("Selected Reply has been deleted.")), 'success', 'threads')
+            return redirect(reverse('thread', kwargs={'thread': self.thread.pk, 'slug': self.thread.slug}))
+            
+        if self.mode == 'hide_post':
+            self.post.deleted = True
+            self.post.edit_date = timezone.now()
+            self.post.edit_user = request.user
+            self.post.edit_user_name = request.user.username
+            self.post.edit_user_slug = request.user.username_slug
+            self.post.save(force_update=True)
+            self.thread.sync()
+            self.thread.save(force_update=True)
+            self.forum.sync()
+            self.forum.save(force_update=True)
+            request.messages.set_flash(Message(_("Selected Reply has been deleted.")), 'success', 'threads_%s' % self.post.pk)
+            return self.redirect_to_post(self.post)

+ 3 - 3
misago/threads/views/posting.py

@@ -51,7 +51,7 @@ class PostingView(BaseView):
         if self.mode == 'edit_thread':
         if self.mode == 'edit_thread':
             self.request.acl.threads.allow_thread_edit(self.request.user, self.proxy, self.thread, self.post)
             self.request.acl.threads.allow_thread_edit(self.request.user, self.proxy, self.thread, self.post)
         if self.mode == 'edit_post':
         if self.mode == 'edit_post':
-            self.request.acl.threads.allow_post_edit(self.request.user, self.proxy, self.thread, self.post)     
+            self.request.acl.threads.allow_reply_edit(self.request.user, self.proxy, self.thread, self.post)     
         
         
     def get_form(self, bound=False):
     def get_form(self, bound=False):
         initial = {}
         initial = {}
@@ -104,7 +104,7 @@ class PostingView(BaseView):
                     old_name = self.thread.name
                     old_name = self.thread.name
                     old_post = self.post.post
                     old_post = self.post.post
                     # If there is no change, throw user back
                     # If there is no change, throw user back
-                    changed_name = old_name != form.cleaned_data['thread_name']
+                    changed_name = (old_name != form.cleaned_data['thread_name']) if self.mode == 'edit_thread' else False
                     changed_post = old_post != form.cleaned_data['post']
                     changed_post = old_post != form.cleaned_data['post']
                     changed_anything = changed_name or changed_post
                     changed_anything = changed_name or changed_post
                 
                 
@@ -191,7 +191,7 @@ class PostingView(BaseView):
                                                 reason=form.cleaned_data['edit_reason'],
                                                 reason=form.cleaned_data['edit_reason'],
                                                 size=len(self.post.post),
                                                 size=len(self.post.post),
                                                 change=len(self.post.post) - len(old_post),
                                                 change=len(self.post.post) - len(old_post),
-                                                thread_name_old=old_name if form.cleaned_data['thread_name'] != old_name else None,
+                                                thread_name_old=old_name if self.mode == 'edit_thread' and form.cleaned_data['thread_name'] != old_name else None,
                                                 thread_name_new=self.thread.name if form.cleaned_data['thread_name'] != old_name else None,
                                                 thread_name_new=self.thread.name if form.cleaned_data['thread_name'] != old_name else None,
                                                 post_content=old_post,
                                                 post_content=old_post,
                                                 )
                                                 )

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

@@ -7,9 +7,10 @@ from django.utils.translation import ugettext as _
 from misago.acl.utils import ACLError403, ACLError404
 from misago.acl.utils import ACLError403, ACLError404
 from misago.forms import Form, FormLayout, FormFields
 from misago.forms import Form, FormLayout, FormFields
 from misago.forums.models import Forum
 from misago.forums.models import Forum
+from misago.markdown import post_markdown
 from misago.messages import Message
 from misago.messages import Message
 from misago.readstracker.trackers import ThreadsTracker
 from misago.readstracker.trackers import ThreadsTracker
-from misago.threads.forms import MoveThreadsForm, QuickReplyForm
+from misago.threads.forms import MoveThreadsForm, SplitThreadForm, QuickReplyForm
 from misago.threads.models import Thread, Post
 from misago.threads.models import Thread, Post
 from misago.threads.views.base import BaseView
 from misago.threads.views.base import BaseView
 from misago.views import error403, error404
 from misago.views import error403, error404
@@ -111,6 +112,81 @@ class ThreadView(BaseView):
         else:
         else:
             self.posts_form = self.posts_form(request=self.request)
             self.posts_form = self.posts_form(request=self.request)
             
             
+    def post_action_accept(self, ids):
+        accepted = 0
+        for post in self.posts:
+            if post.pk in ids and post.moderated:
+                accepted += 1
+        if accepted:
+            self.thread.post_set.filter(id__in=ids).update(moderated=False)
+            self.thread.sync()
+            self.thread.save(force_update=True)
+            self.request.messages.set_flash(Message(_('Selected posts have been accepted and made visible to other members.')), 'success', 'threads')
+            
+    def post_action_protect(self, ids):
+        protected = 0
+        for post in self.posts:
+            if post.pk in ids and not post.protected:
+                protected += 1
+        if protected:
+            self.thread.post_set.filter(id__in=ids).update(protected=True)
+            self.request.messages.set_flash(Message(_('Selected posts have been protected from edition.')), 'success', 'threads')            
+            
+    def post_action_merge(self, ids):
+        users = []
+        posts = []
+        for post in self.posts:
+            if post.pk in ids:
+                posts.append(post)
+                if not post.user_id in users:
+                    users.append(post.user_id)
+                if len(users) > 1:
+                    raise forms.ValidationError(_("You cannot merge replies made by different members!"))
+        if len(posts) < 2:
+            raise forms.ValidationError(_("You have to select two or more posts you want to merge."))
+        new_post = posts[0]
+        for post in posts[1:]:
+            new_post.post = '%s\n- - -\n%s' % (new_post.post, post.post)
+            post.change_set.update(post=new_post)
+            post.checkpoint_set.update(post=new_post)
+            post.delete()
+        new_post.post_preparsed = post_markdown(self.request, new_post.post)
+        new_post.save(force_update=True)
+        self.thread.sync()
+        self.thread.save(force_update=True)
+        self.forum.sync()
+        self.forum.save(force_update=True)
+        self.request.messages.set_flash(Message(_('Selected posts have been merged into one message.')), 'success', 'threads')
+                    
+    def post_action_split(self, ids):
+        message = None
+        if self.request.POST.get('do') == 'split':
+            form = SplitThreadForm(self.request.POST,request=self.request)
+            if form.is_valid():
+                return None
+            message = Message(form.non_field_errors()[0], 'error')
+        else:
+            form = SplitThreadForm(request=self.request)
+        return self.request.theme.render_to_response('threads/split.html',
+                                                     {
+                                                      'message': message,
+                                                      'forum': self.forum,
+                                                      'parents': self.parents,
+                                                      'thread': self.thread,
+                                                      'posts': ids,
+                                                      'form': FormLayout(form),
+                                                      },
+                                                     context_instance=RequestContext(self.request));
+        
+    def post_action_unprotect(self, ids):
+        unprotected = 0
+        for post in self.posts:
+            if post.pk in ids and post.protected:
+                unprotected += 1
+        if unprotected:
+            self.thread.post_set.filter(id__in=ids).update(protected=False)
+            self.request.messages.set_flash(Message(_('Protection from editions has been removed from selected posts.')), 'success', 'threads')
+                
     def get_thread_actions(self):
     def get_thread_actions(self):
         acl = self.request.acl.threads.get_role(self.thread.forum_id)
         acl = self.request.acl.threads.get_role(self.thread.forum_id)
         actions = []
         actions = []
@@ -306,6 +382,7 @@ class ThreadView(BaseView):
         try:
         try:
             self.fetch_thread(thread)
             self.fetch_thread(thread)
             self.fetch_posts(page)
             self.fetch_posts(page)
+            self.message = request.messages.get_message('threads')
             self.make_thread_form()
             self.make_thread_form()
             if self.thread_form:
             if self.thread_form:
                 response = self.handle_thread_form()
                 response = self.handle_thread_form()
@@ -326,7 +403,7 @@ class ThreadView(BaseView):
         self.forum.closed = self.proxy.closed
         self.forum.closed = self.proxy.closed
         return request.theme.render_to_response('threads/thread.html',
         return request.theme.render_to_response('threads/thread.html',
                                                 {
                                                 {
-                                                 'message': request.messages.get_message('threads'),
+                                                 'message': self.message,
                                                  'forum': self.forum,
                                                  'forum': self.forum,
                                                  'parents': self.parents,
                                                  'parents': self.parents,
                                                  'thread': self.thread,
                                                  'thread': self.thread,

+ 3 - 0
static/sora/css/sora.css

@@ -1009,6 +1009,9 @@ td.lead-cell{color:#555555;font-weight:bold;}
 .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 .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 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;}
 .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;}
+.posts-list .well-post-deleted{opacity:0.5;filter:alpha(opacity=50);padding:6px 0px;}.posts-list .well-post-deleted .post-author{bottom:2px;}.posts-list .well-post-deleted .post-author .post-bit .lead{padding:0px;}
+.posts-list .well-post-deleted .post-content{position:relative;top:8px;}.posts-list .well-post-deleted .post-content a{font-weight:bold;}
+.posts-list .well-post-deleted .post-extra{position:relative;top:7px;}
 .mod-actions{overflow:auto;border:none;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;margin-top:0px;}
 .mod-actions{overflow:auto;border:none;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;margin-top:0px;}
 .quick-reply{margin-top:12px;overflow:auto;}.quick-reply .avatar-big,.quick-reply .arrow{float:left;}
 .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 .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;}

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

@@ -337,6 +337,35 @@
       height: 4px;
       height: 4px;
     }
     }
   }
   }
+  
+  .well-post-deleted {
+    .opacity(50);
+    padding: 6px 0px;
+    
+    .post-author {
+      bottom: 2px;
+      
+      .post-bit {        
+        .lead {
+          padding: 0px;
+        }
+      }
+    }
+    
+    .post-content {
+      position: relative;
+      top: 8px;
+      
+      a {
+        font-weight: bold;
+      }
+    }
+    
+    .post-extra {
+      position: relative;
+      top: 7px;      
+    }
+  }
 }
 }
 
 
 // Thread mod actions
 // Thread mod actions

+ 1 - 0
templates/sora/threads/split.html

@@ -0,0 +1 @@
+SPLIT THREAD!

+ 47 - 3
templates/sora/threads/thread.html

@@ -45,6 +45,29 @@
 <div class="posts-list">
 <div class="posts-list">
   {% for post in posts %}
   {% for post in posts %}
   {% if post.message %}{{ macros.draw_message(post.message) }}{% endif %}
   {% if post.message %}{{ macros.draw_message(post.message) }}{% endif %}
+  {% if post.deleted and not acl.threads.can_see_deleted_posts(forum) %}
+  <div id="post-{{ post.pk }}" class="well well-post well-post-deleted{% if post.user and post.user.rank and post.user.rank.style %} {{ post.user.rank.style }}{% endif %}">
+    <div class="post-author">
+      <div class="post-bit">
+        {% if post.user_id %}
+        <a href="{% url 'user' user=post.user.pk, username=post.user.username_slug %}" class="lead">{{ post.user.username }}</a>
+        {% else %}
+        <span class="lead">{{ post.user_name }}</span>
+        {% endif %}
+      </div>
+    </div>
+    <div class="post-extra">
+      <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>
+    </div>
+    <div class="post-content">
+      {% trans user=edit_user(post), date=post.edit_date|reltimesince|low %}{{ user }} has deleted this reply {{ date }}{% endtrans %}
+    </div>
+  </div>
+  {% else %}
   <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 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">
     <div class="post-author">
       <img src="{% if post.user_id %}{{ post.user.get_avatar(80) }}{% else %}{{ macros.avatar_guest(80) }}{% endif %}" alt="" class="avatar-normal">
       <img src="{% if post.user_id %}{{ post.user.get_avatar(80) }}{% else %}{{ macros.avatar_guest(80) }}{% endif %}" alt="" class="avatar-normal">
@@ -121,11 +144,19 @@
       {% endif %}
       {% endif %}
       {% if user.is_authenticated() %}
       {% if user.is_authenticated() %}
       <ul class="nav nav-pills pull-right">
       <ul class="nav nav-pills pull-right">
-        <li class="tooltip-top" title="{% trans %}Delete this thread for good{% endtrans %}"><button class="btn danger"><i class="icon-remove"></i></button></li>
-        <li><button class="btn danger"><i class="icon-trash"></i> {% trans %}Delete{% endtrans %}</button></li>
         {% if 1 == 2 %}
         {% if 1 == 2 %}
         <li><a href="#"><i class="icon-info-sign"></i> {% trans %}Info{% endtrans %}</a></li>
         <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>
+        {% endif %}
+        {% if post.pk == thread.start_post_id %}
+        {% if acl.threads.can_delete_thread(user, forum, thread, post) == 2 -%}
+            <li class="tooltip-top" title="{% trans %}Delete this thread for good{% endtrans %}"><form action="{% url 'thread_delete' thread=thread.pk, slug=thread.slug %}" method="post"><button type="submit" class="btn danger"><i class="icon-remove"></i></button><input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}"></form></li>{% endif %}
+        {% if not post.deleted and acl.threads.can_delete_thread(user, forum, thread, post) -%}
+            <li><form action="{% url 'thread_hide' thread=thread.pk, slug=thread.slug %}" method="post"><button type="submit" class="btn danger"><i class="icon-trash"></i> {% trans %}Delete{% endtrans %}</button><input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}"></form></li>{% endif %}
+        {% elif post.pk != thread.start_post_id and acl.threads.can_delete_post(user, forum, thread, post) %}
+        {% if acl.threads.can_delete_post(user, forum, thread, post) == 2 -%}
+            <li class="tooltip-top" title="{% trans %}Delete this reply for good{% endtrans %}"><form action="{% url 'post_delete' thread=thread.pk, slug=thread.slug, post=post.pk %}" method="post"><button type="submit" class="btn danger"><i class="icon-remove"></i></button><input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}"></form></li>{% endif %}
+        {% if not post.deleted and acl.threads.can_delete_post(user, forum, thread, post) -%}
+            <li><form action="{% url 'post_hide' thread=thread.pk, slug=thread.slug, post=post.pk %}" method="post"><button type="submit" class="btn danger"><i class="icon-trash"></i> {% trans %}Delete{% endtrans %}</button><input type="hidden" name="{{ csrf_id }}" value="{{ csrf_token }}"></form></li>{% endif %}
         {% endif %}
         {% endif %}
         {% if acl.threads.can_edit_thread(user, forum, thread, post) and thread.start_post_id == post.pk -%}
         {% if acl.threads.can_edit_thread(user, forum, thread, post) and thread.start_post_id == post.pk -%}
         <li><a href="{% url 'thread_edit' thread=thread.pk, slug=thread.slug %}"><i class="icon-edit"></i> {% trans %}Edit{% endtrans %}</a></li>
         <li><a href="{% url 'thread_edit' thread=thread.pk, slug=thread.slug %}"><i class="icon-edit"></i> {% trans %}Edit{% endtrans %}</a></li>
@@ -137,6 +168,7 @@
       {% endif %}
       {% endif %}
     </div>
     </div>
   </div>
   </div>
+  {% endif %}
   {% if post.checkpoint_set.all() %}
   {% if post.checkpoint_set.all() %}
   <div class="post-checkpoints">
   <div class="post-checkpoints">
     {% for checkpoint in post.checkpoint_set.all() %}
     {% for checkpoint in post.checkpoint_set.all() %}
@@ -227,7 +259,19 @@
 {% endmacro %}
 {% endmacro %}
 
 
 {% macro checkpoint_user(checkpoint) -%}
 {% macro checkpoint_user(checkpoint) -%}
+{%- if checkpoint.user_id -%}
 {{ ('<a href="' ~ 'user'|url(user=checkpoint.user_id, username=checkpoint.user_slug) ~ '">')|safe ~ (checkpoint.user_name) ~ ("</a>")|safe }}
 {{ ('<a href="' ~ 'user'|url(user=checkpoint.user_id, username=checkpoint.user_slug) ~ '">')|safe ~ (checkpoint.user_name) ~ ("</a>")|safe }}
+{%- else -%}
+<strong>{{ checkpoint.user_name }}</strong>
+{%- endif -%}
+{%- endmacro %}
+
+{% macro edit_user(post) -%}
+{%- if post.edit_user_id -%}
+{{ ('<a href="' ~ 'user'|url(user=post.edit_user_id, username=post.edit_user_slug) ~ '">')|safe ~ (post.edit_user_name) ~ ("</a>")|safe }}
+{%- else -%}
+<strong>{{ post.edit_user_name }}</strong>
+{%- endif -%}
 {%- endmacro %}
 {%- endmacro %}
 
 
 {% block javascripts -%}
 {% block javascripts -%}