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

Move/Merge/Split actions + tweaks in moderation

Rafał Pitoń 10 лет назад
Родитель
Сommit
80ca1d8144

+ 1 - 0
misago/forums/migrations/0003_forums_roles.py

@@ -133,6 +133,7 @@ def create_default_forums_roles(apps, schema_editor):
                 'can_close_threads': 1,
                 'can_move_threads': 1,
                 'can_merge_threads': 1,
+                'can_split_threads': 1,
                 'can_review_moderated_content': 1,
                 'can_report_content': 1,
                 'can_see_reports': 1,

+ 8 - 0
misago/static/misago/css/misago/modals.less

@@ -33,6 +33,14 @@
             float: none;
           }
         }
+
+        &.text-left {
+          text-align: left;
+
+          &>* {
+            float: none;
+          }
+        }
       }
     }
   }

+ 13 - 0
misago/templates/misago/thread/actions_js.html

@@ -18,5 +18,18 @@
       return false;
     });
 
+    var $posts_actions = $('#posts-actions');
+
+    $('#posts-actions .action-move').click(function() {
+      var action_data = $posts_actions.serialize($posts_actions) + '&action=move';
+      Misago.Modal.post('', action_data);
+      return false;
+    });
+
+    $('#posts-actions .action-split').click(function() {
+      var action_data = $posts_actions.serialize($posts_actions) + '&action=split';
+      Misago.Modal.post('', action_data);
+      return false;
+    });
   });
 </script>

+ 1 - 1
misago/templates/misago/thread/move/full.html

@@ -41,7 +41,7 @@
 
             </div>
 
-            <div class="form-footer text-center">
+            <div class="form-footer text-left">
 
               <button class="btn btn-primary" name="submit">{% trans "Move thread" %}</button>
               <a href="" class="btn btn-default">{% trans "Cancel" %}</a>

+ 2 - 2
misago/templates/misago/thread/move/modal.html

@@ -3,7 +3,7 @@
   {% csrf_token %}
   <input type="hidden" name="thread_action" value="move">
   <div class="modal-header">
-    <button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span><span class="sr-only">Close</span></button>
+    <button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span><span class="sr-only">{% trans "Close" %}</span></button>
     <h4 class="modal-title" id="ajaxModalLabel">
       <span class="fa fa-arrow-right fa-fw"></span>
       {% trans "Move thread" %}
@@ -14,7 +14,7 @@
     {% form_row form.new_forum %}
 
   </div>
-  <div class="modal-footer text-center">
+  <div class="modal-footer text-left">
     <button class="btn btn-primary" name="submit">{% trans "Move thread" %}</button>
     <button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Cancel" %}</button>
   </div>

+ 63 - 0
misago/templates/misago/thread/move_posts/full.html

@@ -0,0 +1,63 @@
+{% extends "misago/threads/base.html" %}
+{% load i18n misago_forms %}
+
+
+{% block title %}{% trans "Move posts" %} | {{ thread }} | {{ block.super }}{% endblock title %}
+
+
+{% block content %}
+<div{% if forum.css %} class="page-{{ forum.css_class }}"{% endif %}>
+  <div class="page-header">
+    <div class="container">
+      {% if path %}
+      <ol class="breadcrumb">
+        {% include "misago/thread/full_path.html" %}
+      </ol>
+      {% endif %}
+      <h1>{% trans "Move posts" %}</h1>
+    </div>
+  </div>
+
+  <div class="container">
+    <form method="POST">
+      {% csrf_token %}
+      <input type="hidden" name="action" value="move">
+      {% for post in posts %}
+      <input type="hidden" name="item" value="{{ post.pk }}">
+      {% endfor %}
+
+      <div class="row">
+        <div class="col-md-8 col-md-offset-2">
+
+          <div class="form-panel">
+
+            <div class="form-header">
+              <h2>
+                {{ thread }}
+              </h2>
+            </div>
+
+            {% include "misago/form_errors.html" %}
+            <div class="form-body no-fieldsets">
+
+              {% form_row form.new_thread_url %}
+
+            </div>
+
+            <div class="form-footer text-left">
+
+              <button class="btn btn-primary" name="submit">{% trans "Move posts" %}</button>
+              <button class="btn btn-primary" name="follow">{% trans "Move and follow posts" %}</button>
+              <a href="" class="btn btn-default">{% trans "Cancel" %}</a>
+
+            </div>
+          </div>
+
+        </div>
+      </div><!-- /.row -->
+
+    </form>
+  </div>
+
+</div>
+{% endblock content %}

+ 25 - 0
misago/templates/misago/thread/move_posts/modal.html

@@ -0,0 +1,25 @@
+{% load i18n misago_forms %}
+<form method="POST">
+  {% csrf_token %}
+  <input type="hidden" name="action" value="move">
+  {% for post in posts %}
+  <input type="hidden" name="item" value="{{ post.pk }}">
+  {% endfor %}
+  <div class="modal-header">
+    <button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span><span class="sr-only">{% trans "Close" %}</span></button>
+    <h4 class="modal-title" id="ajaxModalLabel">
+      <span class="fa fa-arrow-right fa-fw"></span>
+      {% trans "Move posts" %}
+    </h4>
+  </div>
+  <div class="modal-body modal-form">
+
+    {% form_row form.new_thread_url %}
+
+  </div>
+  <div class="modal-footer text-left">
+    <button class="btn btn-primary" name="submit">{% trans "Move posts" %}</button>
+    <button class="btn btn-primary" name="follow">{% trans "Move and follow posts" %}</button>
+    <button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Cancel" %}</button>
+  </div>
+</form>

+ 64 - 0
misago/templates/misago/thread/split/full.html

@@ -0,0 +1,64 @@
+{% extends "misago/threads/base.html" %}
+{% load i18n misago_forms %}
+
+
+{% block title %}{% trans "Split thread" %} | {{ thread }} | {{ block.super }}{% endblock title %}
+
+
+{% block content %}
+<div{% if forum.css %} class="page-{{ forum.css_class }}"{% endif %}>
+  <div class="page-header">
+    <div class="container">
+      {% if path %}
+      <ol class="breadcrumb">
+        {% include "misago/thread/full_path.html" %}
+      </ol>
+      {% endif %}
+      <h1>{% trans "Split thread" %}</h1>
+    </div>
+  </div>
+
+  <div class="container">
+    <form method="POST">
+      {% csrf_token %}
+      <input type="hidden" name="action" value="split">
+      {% for post in posts %}
+      <input type="hidden" name="item" value="{{ post.pk }}">
+      {% endfor %}
+
+      <div class="row">
+        <div class="col-md-8 col-md-offset-2">
+
+          <div class="form-panel">
+
+            <div class="form-header">
+              <h2>
+                {{ thread }}
+              </h2>
+            </div>
+
+            {% include "misago/form_errors.html" %}
+            <div class="form-body no-fieldsets">
+
+              {% form_row form.forum %}
+              {% form_row form.thread_title %}
+
+            </div>
+
+            <div class="form-footer text-left">
+
+              <button class="btn btn-primary" name="submit">{% trans "Split thread" %}</button>
+              <button class="btn btn-primary" name="follow">{% trans "Split and see new thread" %}</button>
+              <a href="" class="btn btn-default">{% trans "Cancel" %}</a>
+
+            </div>
+          </div>
+
+        </div>
+      </div><!-- /.row -->
+
+    </form>
+  </div>
+
+</div>
+{% endblock content %}

+ 26 - 0
misago/templates/misago/thread/split/modal.html

@@ -0,0 +1,26 @@
+{% load i18n misago_forms %}
+<form method="POST">
+  {% csrf_token %}
+  <input type="hidden" name="action" value="split">
+  {% for post in posts %}
+  <input type="hidden" name="item" value="{{ post.pk }}">
+  {% endfor %}
+  <div class="modal-header">
+    <button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span><span class="sr-only">{% trans "Close" %}</span></button>
+    <h4 class="modal-title" id="ajaxModalLabel">
+      <span class="fa fa-arrow-right fa-fw"></span>
+      {% trans "Split thread" %}
+    </h4>
+  </div>
+  <div class="modal-body modal-form">
+
+    {% form_row form.forum %}
+    {% form_row form.thread_title %}
+
+  </div>
+  <div class="modal-footer text-left">
+    <button class="btn btn-primary" name="submit">{% trans "Split thread" %}</button>
+    <button class="btn btn-primary" name="follow">{% trans "Split and see new thread" %}</button>
+    <button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Cancel" %}</button>
+  </div>
+</form>

+ 1 - 1
misago/templates/misago/threads/merge/full.html

@@ -54,7 +54,7 @@
 
             </div>
 
-            <div class="form-footer text-center">
+            <div class="form-footer text-left">
 
               <button class="btn btn-primary" name="submit">{% trans "Merge threads" %}</button>
               <a href="" class="btn btn-default">{% trans "Cancel" %}</a>

+ 2 - 2
misago/templates/misago/threads/merge/modal.html

@@ -6,7 +6,7 @@
   <input type="hidden" name="item" value="{{ thread.pk }}">
   {% endfor %}
   <div class="modal-header">
-    <button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span><span class="sr-only">Close</span></button>
+    <button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span><span class="sr-only">{% trans "Close" %}</span></button>
     <h4 class="modal-title" id="ajaxModalLabel">
       <span class="fa fa-reply-all fa-fw"></span>
       {% trans "Merge threads" %}
@@ -17,7 +17,7 @@
     {% include "misago/threads/merge/body.html" %}
 
   </div>
-  <div class="modal-footer text-center">
+  <div class="modal-footer text-left">
     <button class="btn btn-primary" name="submit">{% trans "Merge threads" %}</button>
     <button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Cancel" %}</button>
   </div>

+ 1 - 1
misago/templates/misago/threads/move/full.html

@@ -54,7 +54,7 @@
 
             </div>
 
-            <div class="form-footer text-center">
+            <div class="form-footer text-left">
 
               <button class="btn btn-primary" name="submit">{% trans "Move threads" %}</button>
               <a href="" class="btn btn-default">{% trans "Cancel" %}</a>

+ 2 - 2
misago/templates/misago/threads/move/modal.html

@@ -6,7 +6,7 @@
   <input type="hidden" name="item" value="{{ thread.pk }}">
   {% endfor %}
   <div class="modal-header">
-    <button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span><span class="sr-only">Close</span></button>
+    <button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span><span class="sr-only">{% trans "Close" %}</span></button>
     <h4 class="modal-title" id="ajaxModalLabel">
       <span class="fa fa-arrow-right fa-fw"></span>
       {% trans "Move threads" %}
@@ -17,7 +17,7 @@
     {% include "misago/threads/move/body.html" %}
 
   </div>
-  <div class="modal-footer text-center">
+  <div class="modal-footer text-left">
     <button class="btn btn-primary" name="submit">{% trans "Move threads" %}</button>
     <button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Cancel" %}</button>
   </div>

+ 95 - 0
misago/threads/forms/moderation.py

@@ -1,8 +1,16 @@
+from urlparse import urlparse
+
+from django.core.urlresolvers import resolve
+from django.http import Http404
 from django.utils.translation import ugettext_lazy as _
 
+from misago.acl import add_acl
 from misago.core import forms
 from misago.forums.forms import ForumChoiceField
+from misago.forums.permissions import allow_see_forum, allow_browse_forum
 
+from misago.threads.models import Thread
+from misago.threads.permissions import allow_see_thread
 from misago.threads.validators import validate_title
 
 
@@ -56,3 +64,90 @@ class MoveThreadsForm(forms.Form):
 class MoveThreadForm(MoveThreadsForm):
     new_forum = ForumChoiceField(label=_("Move thread to forum"),
                                  empty_label=None)
+
+
+class MovePostsForm(forms.Form):
+    new_thread_url = forms.CharField(
+        label=_("New thread link"),
+        help_text=_("Paste link to thread you want selected posts moved to."))
+
+    def __init__(self, *args, **kwargs):
+        self.user = kwargs.pop('user')
+        self.thread = kwargs.pop('thread')
+        self.new_thread = None
+
+        super(MovePostsForm, self).__init__(*args, **kwargs)
+
+    def clean(self):
+        data = super(MovePostsForm, self).clean()
+
+        new_thread_url = data.get('new_thread_url')
+        try:
+            if not new_thread_url:
+                raise Http404()
+
+            resolution = resolve(urlparse(new_thread_url).path)
+            if not 'thread_id' in resolution.kwargs:
+                raise Http404()
+
+            queryset = Thread.objects.select_related('forum')
+            self.new_thread = queryset.get(id=resolution.kwargs['thread_id'])
+
+            add_acl(self.user, self.new_thread.forum)
+            add_acl(self.user, self.new_thread)
+
+            allow_see_forum(self.user, self.new_thread.forum)
+            allow_browse_forum(self.user, self.new_thread.forum)
+            allow_see_thread(self.user, self.new_thread)
+
+        except (Http404, Thread.DoesNotExist):
+            message = _("You have to enter valid link to thread.")
+            raise forms.ValidationError(message)
+
+        if self.thread == self.new_thread:
+            message = _("New thread is same as current one.")
+            raise forms.ValidationError(message)
+
+        if self.new_thread.forum.special_role:
+            message = _("You can't move posts to special threads.")
+            raise forms.ValidationError(message)
+
+        return data
+
+
+class SplitThreadForm(forms.Form):
+    forum = ForumChoiceField(label=_("New thread forum"),
+                                 empty_label=None)
+
+    thread_title = forms.CharField(label=_("New thread title"),
+                                   required=False)
+
+    def __init__(self, *args, **kwargs):
+        acl = kwargs.pop('acl')
+
+        super(SplitThreadForm, self).__init__(*args, **kwargs)
+
+        self.fields['forum'].set_acl(acl)
+
+    def clean(self):
+        data = super(SplitThreadForm, self).clean()
+
+        forum = data.get('forum')
+        if forum:
+            if forum.is_category:
+                message = _("You can't start threads in category.")
+                raise forms.ValidationError(message)
+            if forum.is_redirect:
+                message = _("You can't start threads in redirect.")
+                raise forms.ValidationError(message)
+        else:
+            raise forms.ValidationError(_("You have to select forum."))
+
+        thread_title = data.get('thread_title')
+        if thread_title:
+            validate_title(thread_title)
+        else:
+            message = _("You have to enter new thread title.")
+            raise forms.ValidationError(message)
+
+        return data

+ 203 - 7
misago/threads/tests/test_thread_view.py

@@ -314,34 +314,201 @@ class ThreadViewModerationTests(ThreadViewTestCase):
         response = self.client.get(reverse('misago:index'))
         self.assertEqual(response.status_code, 200)
 
-    def test_cant_hide_first_post(self):
-        """op is not deletable/hideable/unhideable"""
+    def test_merge_posts(self):
+        """moderation allows for merging multiple posts into one"""
+        posts = []
+        for p in xrange(4):
+            posts.append(reply_thread(self.thread, poster=self.user))
+        for p in xrange(4):
+            posts.append(reply_thread(self.thread))
+
+        self.thread.synchronize()
+        self.assertEqual(self.thread.replies, 8)
+
         test_acl = {
-            'can_hide_posts': 2
+            'can_merge_posts': 1
         }
 
         self.override_acl(test_acl)
         response = self.client.get(self.thread.get_absolute_url())
         self.assertEqual(response.status_code, 200)
-        self.assertIn("Delete posts", response.content)
+        self.assertIn("Merge posts into one", response.content)
 
         self.override_acl(test_acl)
         response = self.client.post(self.thread.get_absolute_url(), data={
-            'action': 'delete', 'item': [self.thread.first_post_id]
+            'action': 'merge', 'item': [p.pk for p in posts[:1]]
         })
         self.assertEqual(response.status_code, 200)
+        self.assertIn("select at least two posts", response.content)
+
+        thread = Thread.objects.get(pk=self.thread.pk)
+        self.assertEqual(thread.replies, 8)
 
         self.override_acl(test_acl)
         response = self.client.post(self.thread.get_absolute_url(), data={
-            'action': 'hide', 'item': [self.thread.first_post_id]
+            'action': 'merge', 'item': [p.pk for p in posts[3:5]]
         })
         self.assertEqual(response.status_code, 200)
+        self.assertIn("merge posts made by different authors",
+                      response.content)
+
+        thread = Thread.objects.get(pk=self.thread.pk)
+        self.assertEqual(thread.replies, 8)
 
         self.override_acl(test_acl)
         response = self.client.post(self.thread.get_absolute_url(), data={
-            'action': 'unhide', 'item': [self.thread.first_post_id]
+            'action': 'merge', 'item': [p.pk for p in posts[5:7]]
+        })
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("merge posts made by different authors",
+                      response.content)
+
+        thread = Thread.objects.get(pk=self.thread.pk)
+        self.assertEqual(thread.replies, 8)
+
+        self.override_acl(test_acl)
+        response = self.client.post(self.thread.get_absolute_url(), data={
+            'action': 'merge', 'item': [p.pk for p in posts[:4]]
+        })
+        self.assertEqual(response.status_code, 302)
+
+        thread = Thread.objects.get(pk=self.thread.pk)
+        self.assertEqual(thread.replies, 5)
+
+    def test_move_posts(self):
+        """moderation allows for moving posts to other thread"""
+        test_acl = {
+            'can_move_posts': 1
+        }
+
+        self.override_acl(test_acl)
+        response = self.client.get(self.thread.get_absolute_url())
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("Move posts", response.content)
+
+        self.override_acl(test_acl)
+        response = self.client.post(self.thread.get_absolute_url(), data={
+            'action': 'move', 'item': [self.thread.first_post_id]
+        })
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("first post", response.content)
+
+        posts = [reply_thread(self.thread) for t in xrange(4)]
+
+        self.override_acl(test_acl)
+        response = self.client.post(self.thread.get_absolute_url(), data={
+            'action': 'move',
+            'item': [p.pk for p in posts],
+            'new_thread_url': '',
+            'submit': ''
+        })
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('enter valid link to thread', response.content)
+
+        self.override_acl(test_acl)
+        response = self.client.post(self.thread.get_absolute_url(), data={
+            'action': 'move',
+            'item': [p.pk for p in posts],
+            'new_thread_url': self.forum.get_absolute_url(),
+            'submit': ''
+        })
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('enter valid link to thread', response.content)
+
+        self.override_acl(test_acl)
+        response = self.client.post(self.thread.get_absolute_url(), data={
+            'action': 'move',
+            'item': [p.pk for p in posts],
+            'new_thread_url': self.thread.get_absolute_url(),
+            'submit': ''
+        })
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('thread is same as current one', response.content)
+
+        self.override_acl(test_acl)
+        response = self.client.post(self.thread.get_absolute_url(), data={
+            'action': 'move',
+            'item': [p.pk for p in posts]
         })
         self.assertEqual(response.status_code, 200)
+        self.assertIn('Move posts', response.content)
+
+        other_thread = post_thread(self.forum)
+
+        self.override_acl(test_acl)
+        response = self.client.post(self.thread.get_absolute_url(), data={
+            'action': 'move',
+            'item': [p.pk for p in posts[:3]],
+            'new_thread_url': other_thread.get_absolute_url(),
+            'submit': ''
+        })
+        self.assertEqual(response.status_code, 302)
+
+        other_thread = Thread.objects.get(id=other_thread.id)
+        self.assertEqual(other_thread.replies, 3)
+
+        for post in posts[:3]:
+            other_thread.post_set.get(id=post.id)
+
+        self.override_acl(test_acl)
+        response = self.client.post(self.thread.get_absolute_url(), data={
+            'action': 'move',
+            'item': [posts[-1].pk],
+            'new_thread_url': other_thread.get_absolute_url(),
+            'follow': ''
+        })
+        self.assertEqual(response.status_code, 302)
+
+    def test_split_thread(self):
+        """moderation allows for splitting posts into new thread"""
+        test_acl = {
+            'can_split_threads': 1
+        }
+
+        self.override_acl(test_acl)
+        response = self.client.get(self.thread.get_absolute_url())
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("Split posts", response.content)
+
+        self.override_acl(test_acl)
+        response = self.client.post(self.thread.get_absolute_url(), data={
+            'action': 'split', 'item': [self.thread.first_post_id]
+        })
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("first post", response.content)
+
+        posts = [reply_thread(self.thread) for t in xrange(4)]
+
+        self.override_acl(test_acl)
+        response = self.client.post(self.thread.get_absolute_url(), data={
+            'action': 'split',
+            'item': [p.pk for p in posts]
+        })
+        self.assertEqual(response.status_code, 200)
+        self.assertIn('Split thread', response.content)
+
+        self.override_acl(test_acl)
+        response = self.client.post(self.thread.get_absolute_url(), data={
+            'action': 'split',
+            'item': [p.pk for p in posts[:3]],
+            'forum': self.forum.id,
+            'thread_title': 'Split thread',
+            'submit': ''
+        })
+        self.assertEqual(response.status_code, 302)
+
+        new_thread = Thread.objects.get(slug='split-thread')
+        self.assertEqual(new_thread.replies, 2)
+
+        self.override_acl(test_acl)
+        response = self.client.post(self.thread.get_absolute_url(), data={
+            'action': 'split',
+            'item': [posts[-1].pk],
+            'forum': self.forum.id,
+            'thread_title': 'Split thread',
+            'follow': ''
+        })
+        self.assertEqual(response.status_code, 302)
 
     def test_protect_unprotect_posts(self):
         """moderation allows for protecting and unprotecting multiple posts"""
@@ -399,6 +566,35 @@ class ThreadViewModerationTests(ThreadViewTestCase):
         for post in posts_queryset.filter(id__in=[p.pk for p in posts[:2]]):
             self.assertFalse(post.is_protected)
 
+    def test_cant_delete_hide_unhide_first_post(self):
+        """op is not deletable/hideable/unhideable"""
+        test_acl = {
+            'can_hide_posts': 2
+        }
+
+        self.override_acl(test_acl)
+        response = self.client.get(self.thread.get_absolute_url())
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("Delete posts", response.content)
+
+        self.override_acl(test_acl)
+        response = self.client.post(self.thread.get_absolute_url(), data={
+            'action': 'delete', 'item': [self.thread.first_post_id]
+        })
+        self.assertEqual(response.status_code, 200)
+
+        self.override_acl(test_acl)
+        response = self.client.post(self.thread.get_absolute_url(), data={
+            'action': 'hide', 'item': [self.thread.first_post_id]
+        })
+        self.assertEqual(response.status_code, 200)
+
+        self.override_acl(test_acl)
+        response = self.client.post(self.thread.get_absolute_url(), data={
+            'action': 'unhide', 'item': [self.thread.first_post_id]
+        })
+        self.assertEqual(response.status_code, 200)
+
     def test_hide_unhide_posts(self):
         """moderation allows for hiding and unhiding multiple posts"""
         posts = [reply_thread(self.thread) for t in xrange(4)]

+ 2 - 4
misago/threads/views/generic/forum/actions.py

@@ -182,7 +182,7 @@ class ForumActions(Actions):
     def action_move(self, request, threads):
         form = MoveThreadsForm(acl=request.user.acl, forum=self.forum)
 
-        if request.method == "POST" and 'submit' in request.POST:
+        if 'submit' in request.POST:
             form = MoveThreadsForm(
                 request.POST, acl=request.user.acl, forum=self.forum)
             if form.is_valid():
@@ -234,11 +234,9 @@ class ForumActions(Actions):
 
         form = MergeThreadsForm()
 
-        if request.method == "POST" and 'submit' in request.POST:
+        if 'submit' in request.POST:
             form = MergeThreadsForm(request.POST)
             if form.is_valid():
-                thread_title = form.cleaned_data['merged_thread_title']
-
                 with atomic():
                     merged_thread = Thread()
                     merged_thread.forum = self.forum

+ 192 - 8
misago/threads/views/generic/thread/postsactions.py

@@ -1,10 +1,15 @@
 from django.contrib import messages
 from django.db.transaction import atomic
 from django.http import Http404
-from django.shortcuts import redirect
+from django.shortcuts import redirect, render
+from django.utils import timezone
 from django.utils.translation import ungettext, ugettext_lazy, ugettext as _
 
+from misago.forums.lists import get_forum_path
+
 from misago.threads import moderation
+from misago.threads.forms.moderation import MovePostsForm, SplitThreadForm
+from misago.threads.models import Thread
 from misago.threads.paginator import Paginator
 from misago.threads.views.generic.actions import ActionsBase, ReloadAfterDelete
 
@@ -12,14 +17,21 @@ from misago.threads.views.generic.actions import ActionsBase, ReloadAfterDelete
 __all__ = ['PostsActions']
 
 
-def atomic_post_action(f):
+def thread_aware_posts(f):
+    def decorator(self, request, posts):
+        for post in posts:
+            post.thread = self.thread
+
+        return f(self, request, posts)
+    return decorator
+
+
+def changes_thread_state(f):
+    @thread_aware_posts
     def decorator(self, request, posts):
         with atomic():
             self.thread.lock()
 
-            for post in posts:
-                post.thread = self.thread
-
             response = f(self, request, posts)
 
             self.thread.synchronize()
@@ -59,6 +71,27 @@ class PostsActions(ActionsBase):
 
         actions = []
 
+        if self.forum.acl['can_merge_posts']:
+            actions.append({
+                'action': 'merge',
+                'icon': 'compress',
+                'name': _("Merge posts into one")
+            })
+
+        if self.forum.acl['can_move_posts']:
+            actions.append({
+                'action': 'move',
+                'icon': 'arrow-right',
+                'name': _("Move posts to other thread")
+            })
+
+        if self.forum.acl['can_split_threads']:
+            actions.append({
+                'action': 'split',
+                'icon': 'code-fork',
+                'name': _("Split posts to new thread")
+            })
+
         if self.forum.acl['can_protect_posts']:
             actions.append({
                 'action': 'unprotect',
@@ -93,6 +126,157 @@ class PostsActions(ActionsBase):
 
         return actions
 
+    @changes_thread_state
+    def action_merge(self, request, posts):
+        first_post = posts[0]
+
+        changed_posts = len(posts)
+        if changed_posts < 2:
+            message = _("You have to select at least two posts to merge.")
+            raise moderation.ModerationError(message)
+
+        for post in posts:
+            if not post.poster_id or first_post.poster_id != post.poster_id:
+                message = _("You can't merge posts made by different authors.")
+                raise moderation.ModerationError(message)
+
+        for post in posts[1:]:
+            post.merge(first_post)
+            post.delete()
+
+        first_post.save()
+
+        message = ungettext(
+            '%(changed)d post was merged.',
+            '%(changed)d posts were merged.',
+        changed_posts)
+        messages.success(request, message % {'changed': changed_posts})
+
+    move_posts_full_template = 'misago/thread/move_posts/full.html'
+    move_posts_modal_template = 'misago/thread/move_posts/modal.html'
+
+    @changes_thread_state
+    def action_move(self, request, posts):
+        if posts[0].id == self.thread.first_post_id:
+            message = _("You can't move thread's first post.")
+            raise moderation.ModerationError(message)
+
+        form = MovePostsForm(user=request.user, thread=self.thread)
+
+        if 'submit' in request.POST or 'follow' in request.POST:
+            form = MovePostsForm(request.POST,
+                                 user=request.user,
+                                 thread=self.thread)
+            if form.is_valid():
+                for post in posts:
+                    post.move(form.new_thread)
+                    post.save()
+
+                form.new_thread.lock()
+                form.new_thread.synchronize()
+                form.new_thread.save()
+
+                if form.new_thread.forum != self.forum:
+                    form.new_thread.forum.lock()
+                    form.new_thread.forum.synchronize()
+                    form.new_thread.forum.save()
+
+                changed_posts = len(posts)
+                message = ungettext(
+                    '%(changed)d post was moved to "%(thread)s".',
+                    '%(changed)d posts were moved to "%(thread)s".',
+                changed_posts)
+                messages.success(request, message % {
+                    'changed': changed_posts,
+                    'thread': form.new_thread.title
+                })
+
+                if 'follow' in request.POST:
+                    return redirect(form.new_thread.get_absolute_url())
+                else:
+                    return None # trigger thread refresh
+
+        if request.is_ajax():
+            template = self.move_posts_modal_template
+        else:
+            template = self.move_posts_full_template
+
+        return render(request, template, {
+            'form': form,
+            'forum': self.forum,
+            'thread': self.thread,
+            'path': get_forum_path(self.forum),
+
+            'posts': posts
+        })
+
+    split_thread_full_template = 'misago/thread/split/full.html'
+    split_thread_modal_template = 'misago/thread/split/modal.html'
+
+    @changes_thread_state
+    def action_split(self, request, posts):
+        if posts[0].id == self.thread.first_post_id:
+            message = _("You can't split thread's first post.")
+            raise moderation.ModerationError(message)
+
+        form = SplitThreadForm(acl=request.user.acl)
+
+        if 'submit' in request.POST or 'follow' in request.POST:
+            form = SplitThreadForm(request.POST, acl=request.user.acl)
+            if form.is_valid():
+                split_thread = Thread()
+                split_thread.forum = form.cleaned_data['forum']
+                split_thread.set_title(
+                    form.cleaned_data['thread_title'])
+                split_thread.starter_name = "-"
+                split_thread.starter_slug = "-"
+                split_thread.last_poster_name = "-"
+                split_thread.last_poster_slug = "-"
+                split_thread.started_on = timezone.now()
+                split_thread.last_post_on = timezone.now()
+                split_thread.save()
+
+                for post in posts:
+                    post.move(split_thread)
+                    post.save()
+
+                split_thread.synchronize()
+                split_thread.save()
+
+                if split_thread.forum != self.forum:
+                    split_thread.forum.lock()
+                    split_thread.forum.synchronize()
+                    split_thread.forum.save()
+
+                changed_posts = len(posts)
+                message = ungettext(
+                    '%(changed)d post was split to "%(thread)s".',
+                    '%(changed)d posts were split to "%(thread)s".',
+                changed_posts)
+                messages.success(request, message % {
+                    'changed': changed_posts,
+                    'thread': split_thread.title
+                })
+
+                if 'follow' in request.POST:
+                    return redirect(split_thread.get_absolute_url())
+                else:
+                    return None # trigger thread refresh
+
+        if request.is_ajax():
+            template = self.split_thread_modal_template
+        else:
+            template = self.split_thread_full_template
+
+        return render(request, template, {
+            'form': form,
+            'forum': self.forum,
+            'thread': self.thread,
+            'path': get_forum_path(self.forum),
+
+            'posts': posts
+        })
+
     def action_unprotect(self, request, posts):
         changed_posts = 0
         for post in posts:
@@ -125,7 +309,7 @@ class PostsActions(ActionsBase):
             message = _("No posts were made protected.")
             messages.info(request, message)
 
-    @atomic_post_action
+    @changes_thread_state
     def action_unhide(self, request, posts):
         changed_posts = 0
         for post in posts:
@@ -142,7 +326,7 @@ class PostsActions(ActionsBase):
             message = _("No posts were made visible.")
             messages.info(request, message)
 
-    @atomic_post_action
+    @changes_thread_state
     def action_hide(self, request, posts):
         changed_posts = 0
         for post in posts:
@@ -159,7 +343,7 @@ class PostsActions(ActionsBase):
             message = _("No posts were hidden.")
             messages.info(request, message)
 
-    @atomic_post_action
+    @changes_thread_state
     def action_delete(self, request, posts):
         changed_posts = 0
         first_deleted = None

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

@@ -139,7 +139,7 @@ class ThreadActions(ActionsBase):
     def action_move(self, request, thread):
         form = MoveThreadForm(acl=request.user.acl, forum=self.forum)
 
-        if request.method == "POST" and 'submit' in request.POST:
+        if 'submit' in request.POST:
             form = MoveThreadForm(
                 request.POST, acl=request.user.acl, forum=self.forum)
             if form.is_valid():