Browse Source

#410: merge threads

Rafał Pitoń 10 years ago
parent
commit
26f3e719e5

+ 92 - 0
misago/templates/misago/threads/merge.html

@@ -0,0 +1,92 @@
+{% extends "misago/threads/base.html" %}
+{% load i18n misago_forms %}
+
+
+{% block title %}{% trans "Merge threads" %} | {{ 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">
+        {% for crumb in path|slice:":-1" %}
+        <li>
+          <a href="{{ crumb.get_absolute_url }}">{{ crumb.name }}</a><span class="fa fa-chevron-right"></span>
+        </li>
+        {% endfor %}
+        <li>
+          <a href="{{ forum.get_absolute_url }}">{{ forum.name }}</a>
+        </li>
+      </ol>
+      {% endif %}
+
+      <h1>{% trans "Merge threads" %}</h1>
+    </div>
+  </div>
+
+  <div class="container">
+    <form method="POST">
+      {% csrf_token %}
+      <input type="hidden" name="action" value="merge">
+      {% for thread in threads %}
+      <input type="hidden" name="thread" value="{{ thread.pk }}">
+      {% endfor %}
+
+      <div class="row">
+        <div class="col-md-8 col-md-offset-2">
+
+          <div class="form-panel">
+
+            <div class="form-header">
+              <h2>
+                {% blocktrans trimmed with forum=forum.name %}
+                  Merge threads in {{ forum }}
+                {% endblocktrans %}
+              </h2>
+            </div>
+
+            {% include "misago/form_errors.html" %}
+            <div class="form-body no-fieldsets">
+
+              <div class="form-group">
+                <label class="control-label">{% trans "Threads that will be merged:" %}</label>
+                <div class="form-control-static">
+                  <ul class="list-unstyled">
+                    {% for thread in threads %}
+                    <li>
+                      {% if thread.is_announcement %}
+                      <span class="fa fa-star-o fa-fw"></span>
+                      {% elif thread.is_pinned %}
+                      <span class="fa fa-bookmark-o fa-fw"></span>
+                      {% else %}
+                      <span class="fa fa-circle-o fa-fw"></span>
+                      {% endif %}
+                      <a href="{{ thread.get_absolute_url }}" class="item-title">{{ thread }}</a>
+                    </li>
+                    {% endfor %}
+                  </ul>
+                </div>
+              </div>
+
+              {% form_row form.merged_thread_title %}
+
+            </div>
+
+            <div class="form-footer text-center">
+
+              <button class="btn btn-primary" name="submit">{% trans "Merge threads" %}</button>
+              <a href="" class="btn btn-default">{% trans "Cancel" %}</a>
+
+            </div>
+          </div>
+
+        </div>
+      </div><!-- /.row -->
+
+    </form>
+  </div>
+
+</div>
+{% endblock content %}

+ 18 - 1
misago/threads/forms/moderation.py

@@ -3,6 +3,24 @@ from django.utils.translation import ugettext_lazy as _
 from misago.core import forms
 from misago.core import forms
 from misago.forums.forms import ForumChoiceField
 from misago.forums.forms import ForumChoiceField
 
 
+from misago.threads.validators import validate_title
+
+
+class MergeThreadsForm(forms.Form):
+    merged_thread_title = forms.CharField(label=_("Merged thread title"),
+                                          required=False)
+
+    def clean(self):
+        data = super(MergeThreadsForm, self).clean()
+
+        merged_thread_title = data.get('merged_thread_title')
+        if merged_thread_title:
+            validate_title(merged_thread_title)
+        else:
+            message = _("You have to enter merged thread title.")
+            raise forms.ValidationError(message)
+        return data
+
 
 
 class MoveThreadsForm(forms.Form):
 class MoveThreadsForm(forms.Form):
     new_forum = ForumChoiceField(label=_("Move threads to forum"),
     new_forum = ForumChoiceField(label=_("Move threads to forum"),
@@ -33,4 +51,3 @@ class MoveThreadsForm(forms.Form):
         else:
         else:
             raise forms.ValidationError(_("You have to select forum."))
             raise forms.ValidationError(_("You have to select forum."))
         return data
         return data
-

+ 3 - 31
misago/threads/forms/posting.py

@@ -2,9 +2,10 @@ from django.utils.translation import ugettext_lazy as _, ungettext
 
 
 from misago.conf import settings
 from misago.conf import settings
 from misago.core import forms
 from misago.core import forms
-from misago.core.validators import validate_sluggable
 from misago.markup import common_flavour
 from misago.markup import common_flavour
 
 
+from misago.threads.validators import validate_title
+
 
 
 class ReplyForm(forms.Form):
 class ReplyForm(forms.Form):
     is_main = True
     is_main = True
@@ -64,35 +65,6 @@ class ThreadForm(ReplyForm):
         self.thread_instance = thread
         self.thread_instance = thread
         super(ThreadForm, self).__init__(*args, **kwargs)
         super(ThreadForm, self).__init__(*args, **kwargs)
 
 
-    def validate_title(self, title):
-        title_len = len(title)
-
-        if not title_len:
-            raise forms.ValidationError(_("Enter thread title."))
-
-        if title_len < settings.thread_title_length_min:
-            message = ungettext(
-                "Thread title should be at least %(limit)s character long.",
-                "Thread title should be at least %(limit)s characters long.",
-                settings.thread_title_length_min)
-            message = message % {'limit': settings.thread_title_length_min}
-            raise forms.ValidationError(message)
-
-        if title_len > settings.thread_title_length_max:
-            message = ungettext(
-                "Thread title can't be longer than %(limit)s character.",
-                "Thread title can't be longer than %(limit)s characters.",
-                settings.thread_title_length_max,)
-            message = message % {'limit': settings.thread_title_length_max}
-            raise forms.ValidationError(message)
-
-        error_not_sluggable = _("Thread title should contain "
-                                "alpha-numeric characters.")
-        error_slug_too_long = _("Thread title is too long.")
-        slug_validator = validate_sluggable(error_not_sluggable,
-                                            error_slug_too_long)
-        slug_validator(title)
-
     def validate_data(self, data):
     def validate_data(self, data):
         errors = []
         errors = []
 
 
@@ -100,7 +72,7 @@ class ThreadForm(ReplyForm):
             raise forms.ValidationError(_("Enter thread title and message."))
             raise forms.ValidationError(_("Enter thread title and message."))
 
 
         try:
         try:
-            self.validate_title(data.get('title', ''))
+            validate_title(data.get('title', ''))
         except forms.ValidationError as e:
         except forms.ValidationError as e:
             errors.append(e)
             errors.append(e)
 
 

+ 6 - 0
misago/threads/moderation/threads.py

@@ -101,6 +101,12 @@ def move_thread(user, thread, new_forum):
 
 
 @atomic
 @atomic
 def merge_thread(user, thread, other_thread):
 def merge_thread(user, thread, other_thread):
+    message = _("%(user)s merged in %(thread)s.")
+    record_event(user, thread, "arrow-right", message, {
+        'user': user,
+        'thread': other_thread.title
+    })
+
     thread.merge(other_thread)
     thread.merge(other_thread)
     thread.synchronize()
     thread.synchronize()
     thread.save()
     thread.save()

+ 93 - 2
misago/threads/tests/test_forumthreads_view.py

@@ -186,6 +186,28 @@ class ActionsTests(ForumViewHelperTestCase):
             },
             },
         ])
         ])
 
 
+    def test_merge_action(self):
+        """ForumActions initializes list with merge threads action"""
+        self.override_acl({
+            'can_merge_threads': 0,
+        })
+
+        actions = ForumActions(user=self.user, forum=self.forum)
+        self.assertEqual(actions.available_actions, [])
+
+        self.override_acl({
+            'can_merge_threads': 1,
+        })
+
+        actions = ForumActions(user=self.user, forum=self.forum)
+        self.assertEqual(actions.available_actions, [
+            {
+                'action': 'merge',
+                'icon': 'reply-all',
+                'name': _("Merge threads")
+            },
+        ])
+
     def test_close_open_actions(self):
     def test_close_open_actions(self):
         """ForumActions initializes list with close and open actions"""
         """ForumActions initializes list with close and open actions"""
         self.override_acl({
         self.override_acl({
@@ -1006,7 +1028,7 @@ class ForumThreadsViewTests(AuthenticatedUserTestCase):
         self.assertIn("1 thread was approved.", response.content)
         self.assertIn("1 thread was approved.", response.content)
 
 
     def test_move_threads(self):
     def test_move_threads(self):
-        """moderation allows for aproving moderated threads"""
+        """moderation allows for moving threads"""
         new_forum = Forum(name="New Forum",
         new_forum = Forum(name="New Forum",
                           slug="new-forum",
                           slug="new-forum",
                           role="forum")
                           role="forum")
@@ -1026,7 +1048,7 @@ class ForumThreadsViewTests(AuthenticatedUserTestCase):
 
 
         threads = [testutils.post_thread(self.forum) for t in xrange(10)]
         threads = [testutils.post_thread(self.forum) for t in xrange(10)]
 
 
-        # see move threads forum
+        # see move threads form
         self.override_acl(test_acl)
         self.override_acl(test_acl)
         response = self.client.post(self.link, data={
         response = self.client.post(self.link, data={
             'action': 'move', 'thread': [t.pk for t in threads[:5]]
             'action': 'move', 'thread': [t.pk for t in threads[:5]]
@@ -1096,6 +1118,75 @@ class ForumThreadsViewTests(AuthenticatedUserTestCase):
         for thread in self.forum.thread_set.all():
         for thread in self.forum.thread_set.all():
             self.assertIn(thread, threads[5:])
             self.assertIn(thread, threads[5:])
 
 
+    def test_merge_threads(self):
+        """moderation allows for merging threads"""
+        test_acl = {
+            'can_see': 1,
+            'can_browse': 1,
+            'can_see_all_threads': 1,
+            'can_merge_threads': 1
+        }
+
+        self.override_acl(test_acl)
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("Merge threads", response.content)
+
+        threads = [testutils.post_thread(self.forum) for t in xrange(10)]
+
+        # see merge threads form
+        self.override_acl(test_acl)
+        response = self.client.post(self.link, data={
+            'action': 'merge', 'thread': [t.pk for t in threads[:5]]
+        })
+        self.assertEqual(response.status_code, 200)
+
+        # submit form with empty title
+        self.override_acl(test_acl)
+        response = self.client.post(self.link, data={
+            'action': 'merge',
+            'thread': [t.pk for t in threads[:5]],
+            'submit': ''
+        })
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("You have to enter merged thread title.",
+                      response.content)
+
+        # submit form with one thread selected
+        self.override_acl(test_acl)
+        response = self.client.post(self.link, data={
+            'action': 'merge',
+            'thread': [threads[0].pk],
+            'submit': ''
+        })
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("You have to select at least two threads to merge.",
+                      response.content)
+
+        # submit form with valid title
+        self.override_acl(test_acl)
+        response = self.client.post(self.link, data={
+            'action': 'merge',
+            'thread': [t.pk for t in threads[:5]],
+            'merged_thread_title': 'Merged thread',
+            'submit': ''
+        })
+        self.assertEqual(response.status_code, 302)
+
+        # see if merged thread is there
+        self.override_acl(test_acl)
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("Merged thread", response.content)
+
+        # assert that merged threads are gone
+        for thread in threads[:5]:
+            self.assertNotIn(thread.get_absolute_url(), response.content)
+
+        # assert that non-merged threads were untouched
+        for thread in threads[5:]:
+            self.assertIn(thread.get_absolute_url(), response.content)
+
     def test_close_open_threads(self):
     def test_close_open_threads(self):
         """moderation allows for closing and opening threads"""
         """moderation allows for closing and opening threads"""
         test_acl = {
         test_acl = {

+ 36 - 0
misago/threads/validators.py

@@ -0,0 +1,36 @@
+from django.core.exceptions import ValidationError
+from django.utils.translation import ugettext_lazy as _, ungettext
+
+from misago.conf import settings
+from misago.core.validators import validate_sluggable
+
+
+def validate_title(title):
+    title_len = len(title)
+
+    if not title_len:
+        raise forms.ValidationError(_("Enter thread title."))
+
+    if title_len < settings.thread_title_length_min:
+        message = ungettext(
+            "Thread title should be at least %(limit)s character long.",
+            "Thread title should be at least %(limit)s characters long.",
+            settings.thread_title_length_min)
+        message = message % {'limit': settings.thread_title_length_min}
+        raise forms.ValidationError(message)
+
+    if title_len > settings.thread_title_length_max:
+        message = ungettext(
+            "Thread title can't be longer than %(limit)s character.",
+            "Thread title can't be longer than %(limit)s characters.",
+            settings.thread_title_length_max,)
+        message = message % {'limit': settings.thread_title_length_max}
+        raise forms.ValidationError(message)
+
+    error_not_sluggable = _("Thread title should contain "
+                            "alpha-numeric characters.")
+    error_slug_too_long = _("Thread title is too long.")
+    slug_validator = validate_sluggable(error_not_sluggable,
+                                        error_slug_too_long)
+    slug_validator(title)
+    return title

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

@@ -1,12 +1,14 @@
 from django.contrib import messages
 from django.contrib import messages
 from django.db.transaction import atomic
 from django.db.transaction import atomic
 from django.shortcuts import render
 from django.shortcuts import render
+from django.utils import timezone
 from django.utils.translation import ugettext_lazy, ugettext as _, ungettext
 from django.utils.translation import ugettext_lazy, ugettext as _, ungettext
 
 
 from misago.forums.lists import get_forum_path
 from misago.forums.lists import get_forum_path
 
 
 from misago.threads import moderation
 from misago.threads import moderation
-from misago.threads.forms.moderation import MoveThreadsForm
+from misago.threads.forms.moderation import MergeThreadsForm, MoveThreadsForm
+from misago.threads.models import Thread
 from misago.threads.views.generic.threads import Actions
 from misago.threads.views.generic.threads import Actions
 
 
 
 
@@ -112,7 +114,7 @@ class ForumActions(Actions):
             if label.slug == label_slug:
             if label.slug == label_slug:
                 break
                 break
         else:
         else:
-            raise ModerationError(_("Requested action is invalid."))
+            raise moderation.ModerationError(_("Requested action is invalid."))
 
 
         changed_threads = 0
         changed_threads = 0
         for thread in threads:
         for thread in threads:
@@ -234,6 +236,61 @@ class ForumActions(Actions):
             'threads': threads
             'threads': threads
         })
         })
 
 
+    merge_threads_template = 'misago/threads/merge.html'
+
+    def action_merge(self, request, threads):
+        if len(threads) == 1:
+            message = _("You have to select at least two threads to merge.")
+            raise moderation.ModerationError(message)
+
+        form = MergeThreadsForm()
+
+        if request.method == "POST" and '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.weight = max(t.weight for t in threads)
+                    merged_thread.forum = self.forum
+                    merged_thread.set_title(
+                        form.cleaned_data['merged_thread_title'])
+                    merged_thread.starter_name = "-"
+                    merged_thread.starter_slug = "-"
+                    merged_thread.last_poster_name = "-"
+                    merged_thread.last_poster_slug = "-"
+                    merged_thread.started_on = timezone.now()
+                    merged_thread.last_post_on = timezone.now()
+                    merged_thread.save()
+
+                    for thread in threads:
+                        moderation.merge_thread(
+                            request.user, merged_thread, thread)
+
+                with atomic():
+                    self.forum.synchronize()
+                    self.forum.save()
+
+                changed_threads = len(threads)
+                message = ungettext(
+                    '%(changed)d thread was merged into %(thread)s.',
+                    '%(changed)d threads were merged into %(thread)s.',
+                changed_threads)
+                messages.success(request, message % {
+                    'changed': changed_threads,
+                    'thread': merged_thread.title
+                })
+
+                return None # trigger threads list refresh
+
+        return render(request, self.merge_threads_template, {
+            'form': form,
+            'forum': self.forum,
+            'path': get_forum_path(self.forum),
+            'threads': threads
+        })
+
     def action_close(self, request, threads):
     def action_close(self, request, threads):
         changed_threads = 0
         changed_threads = 0
         for thread in threads:
         for thread in threads: