Browse Source

#410: Basic moderation on threads list

Rafał Pitoń 10 years ago
parent
commit
cfb330da13

+ 1 - 0
misago/conf/defaults.py

@@ -64,6 +64,7 @@ PIPELINE_JS = {
             'misago/js/misago-ajax.js',
             'misago/js/misago-uiserver.js',
             'misago/js/misago-notifications.js',
+            'misago/js/misago-threads-lists.js',
         ),
         'output_filename': 'misago.js',
     },

+ 4 - 63
misago/static/misago/css/misago/tables.less

@@ -22,10 +22,12 @@
       background: none;
       border-color: transparent;
       .box-shadow(none);
+      margin-right: @padding-small-horizontal;
       padding: 0px 6px;
+      transition: 0s;
 
-      color: @gray-light;
-      font-size: 24px;
+      color: @text-muted;
+      font-size: 25px;
       line-height: 32px;
 
       &.active, &:active {
@@ -215,67 +217,6 @@
 }
 
 
-//== Row select
-//
-//**
-.table-panel {
-  table.table {
-    tr {
-      &.active {
-        td {
-          background-color: fadeOut(@brand-warning, 95%);
-        }
-      }
-
-      td.row-select {
-        width: 1%;
-
-        label {
-          padding: 0px;
-          width: 24px;
-          height: 24px;
-
-          text-align: center;
-
-          input {
-            width: 12px;
-            height: 12px;
-          }
-
-          &.ninja-label {
-              border: 0;
-              clip: rect(0 0 0 0);
-              height: 1px;
-              margin: -1px;
-              overflow: hidden;
-              padding: 0;
-              position: absolute;
-              width: 1px;
-          }
-        }
-
-        a {
-          display: block;
-          width: 24px;
-          height: 24px;
-
-          span {
-            color: lighten(@gray-light, 5%);
-            font-size: 24px;
-          }
-
-          &.active {
-            span {
-              color: @brand-success;
-            }
-          }
-        }
-      }
-    }
-  }
-}
-
-
 //== Table footer
 //
 //**

+ 26 - 6
misago/static/misago/css/misago/threadslists.less

@@ -14,6 +14,10 @@
         padding-top: @padding-base-vertical + 1px;
         padding-bottom: @padding-base-vertical + 1px;
 
+        &.active {
+          background-color: @table-row-highlight;
+        }
+
 
         // Thread icon
         //
@@ -82,17 +86,11 @@
             display: inline-block;
             margin: 0px;
             margin-right: @font-size-large;
-            .opacity(0.5);
             transition-duration: 0.5s;
             width: @font-size-large * 4.5;
 
             font-size: @font-size-large;
 
-            &:hover, &:focus {
-              .opacity(0.9);
-              transition-duration: 0.05s;
-            }
-
             &>div {
               display: inline;
 
@@ -142,6 +140,28 @@
               color: @state-clicked;
             }
           }
+
+          .thread-check {
+            margin: 0px;
+
+            color: @text-muted;
+            font-size: 25px;
+            text-decoration: none;
+            line-height: 24px;
+
+            cursor: pointer;
+
+
+            &.active {
+              color: @brand-success;
+            }
+
+            input {
+              position: absolute;
+              top: -9999px;
+              left: -9999px;
+            }
+          }
         }
 
 

+ 2 - 0
misago/static/misago/css/misago/variables.less

@@ -298,6 +298,8 @@
 @table-panel-border:                    @panel-default-border;
 @table-panel-shadow:                    @panel-shadow;
 
+@table-row-highlight:                   fadeOut(@brand-warning, 90%);
+
 @table-header-color:                    @gray;
 @table-header-bg:                       darken(@body-bg, 5%);
 @table-header-border:                   darken(@body-bg, 15%);

+ 58 - 0
misago/static/misago/js/misago-threads-lists.js

@@ -0,0 +1,58 @@
+// Mass-aciton for threads
+function threadsMassActions(select_message) {
+  var $form = $('#threads-actions');
+  var $master = $('.master-checkbox');
+  var $threads = $('.table-panel .list-group-item');
+
+  $master.click(function() {
+    if ($threads.filter('.active').length == $threads.length) {
+      $threads.removeClass('active');
+      $threads.find('.thread-check').removeClass('active');
+      $threads.find('.thread-check input').prop("checked", false);
+      $master.removeClass('active');
+    } else {
+      $threads.addClass('active');
+      $threads.find('.thread-check').addClass('active');
+      $threads.find('.thread-check input').prop("checked", true);
+      $master.addClass('active');
+    }
+  });
+
+  $threads.each(function() {
+    var $row = $(this);
+
+    var $checkbox = $(this).find('.thread-check input');
+    if ($checkbox.prop("checked")) {
+      $row.addClass('active');
+      $(this).find('.thread-check').addClass('active');
+    }
+
+    $row.find('.thread-check').on("click", function() {
+      var $row = $(this).parents('.list-group-item');
+      var $checkbox = $(this).find('input');
+
+      $(this).toggleClass('active');
+      if ($(this).hasClass('active')) {
+        $checkbox.prop("checked", true);
+        $row.addClass('active');
+        if ($threads.filter('.active').length == $threads.length) {
+          $master.addClass('active');
+        }
+      } else {
+        $checkbox.prop("checked", false);
+        $row.removeClass('active');
+        $master.removeClass('active');
+      }
+      return false;
+    });
+  });
+
+  $form.submit(function() {
+    if ($threads.filter('.active').length == 0) {
+      alert(select_message);
+      return false;
+    } else {
+      return true;
+    }
+  });
+}

+ 23 - 1
misago/templates/misago/threads/actions.html

@@ -1 +1,23 @@
-{{ list_actions }}
+{% load i18n %}
+<button type="button" class="btn btn-default pull-right master-checkbox">
+  <span class="fa fa-check"></span>
+</button>
+<div class="btn-group pull-right">
+  <form id="threads-actions" action="{{ querystring }}" method="post">
+    <button type="button" class="btn btn-default dropdown-toggle mass-controller" data-toggle="dropdown">
+      <span class="fa fa-gears"></span>
+      {% trans "With selected" %}
+    </button>
+    {% csrf_token %}
+    <ul class="dropdown-menu" role="menu">
+      {% for action in list_actions %}
+      <li>
+        <button type="submit" name="action" value="{{ action.action }}" {% if action.confirmation %}data-confirmation="{{ action.confirmation }}"{% endif %}>
+          <span class="{{ action.icon }}"></span>
+          {{ action.name }}
+        </button>
+      </li>
+      {% endfor %}
+    </ul>
+  </form>
+</div>

+ 6 - 0
misago/templates/misago/threads/actions_js.html

@@ -0,0 +1,6 @@
+{% load i18n %}
+<script lang="JavaScript">
+  $(function() {
+    threadsMassActions("{% trans "You have to select at least one thread." %}");
+  });
+</script>

+ 5 - 0
misago/templates/misago/threads/base.html

@@ -39,6 +39,11 @@
             </div>
             {% block thread-extra %}
             <div class="col-md-4 thread-stats">
+              <a href="#" class="thread-check">
+                <span class="fa fa-check"></span>
+                <input type="checkbox" form="threads-actions" name="thread" value="{{ thread.pk }}"{% if thread.pk in selected_threads %}checked="checked"{% endif %}>
+              </a>
+
               {% block thread-stats %}
               <a href="#" class="last-post">
                 <span class="dynamic time-ago-compact tooltip-top" data-timestamp="{{ thread.last_post_on|date:"c" }}" title="{% blocktrans with last_post=thread.last_post_on|date:"DATETIME_FORMAT" %}Last post from {{ last_post }}{% endblocktrans %}">{{ thread.last_post_on|compact_date|lower }}</span>

+ 6 - 3
misago/templates/misago/threads/forum.html

@@ -53,11 +53,11 @@
     {% include "misago/threads/show.html" %}
   {% endif %}
 
-  {% include "misago/threads/reply_btn.html" %}
-
-  {% if user.is_authenticated %}
+  {% if user.is_authenticated and list_actions %}
     {% include "misago/threads/actions.html" %}
   {% endif %}
+
+  {% include "misago/threads/reply_btn.html" %}
 </div>
 
 {{ block.super }}
@@ -84,4 +84,7 @@
 {% block javascripts %}
 {{ block.super }}
 {% include "misago/forums/js.html" %}
+{% if user.is_authenticated and list_actions %}
+  {% include "misago/threads/actions_js.html" %}
+{% endif %}
 {% endblock javascripts %}

+ 14 - 1
misago/threads/events.py

@@ -16,7 +16,20 @@ def format_message(message, links):
     if links:
         formats = {}
         for name, value in links.items():
-            formats[name] = LINK_TEMPLATE % (escape(value[1]), escape(name), escape(value[0]))
+            try:
+                replaces = (
+                    escape(value.get_absolute_url()),
+                    escape(name),
+                    escape(unicode(value))
+                )
+            except AttributeError:
+                replaces = (
+                    escape(value[1]),
+                    escape(name),
+                    escape(value[0])
+                )
+
+            formats[name] = LINK_TEMPLATE % replaces
         return message % formats
     else:
         return message

+ 61 - 9
misago/threads/moderation/threads.py

@@ -1,39 +1,70 @@
+from django.db.transaction import atomic
+from django.utils.translation import ugettext_lazy, ugettext as _
+
+from misago.threads.events import record_event
+
+
+@atomic
 def announce_thread(user, thread):
     if thread.weight < 2:
         thread.weight = 2
-        thread.save(update_fields=['weight'])
+
+        message = _("%(user)s changed thread to announcement.")
+        record_event(user, thread, "star", message, {'user': user})
+
+        thread.save(update_fields=['has_events', 'weight'])
         return True
     else:
         return False
 
 
+@atomic
 def pin_thread(user, thread):
     if thread.weight < 1:
         thread.weight = 1
-        thread.save(update_fields=['weight'])
+
+        message = _("%(user)s pinned thread.")
+        record_event(user, thread, "bookmark", message, {'user': user})
+
+        thread.save(update_fields=['has_events', 'weight'])
         return True
     else:
         return False
 
 
+@atomic
 def default_thread(user, thread):
     if thread.weight > 0:
+        if thread.is_announcement:
+            message = _("%(user)s withhold announcement.")
+        if thread.is_pinned:
+            message = _("%(user)s unpinned thread.")
+        record_event(user, thread, "circle", message, {'user': user})
+
         thread.weight = 0
-        thread.save(update_fields=['weight'])
+        thread.save(update_fields=['has_events', 'weight'])
         return True
     else:
         return False
 
 
+@atomic
 def move_thread(user, thread, new_forum):
     if thread.forum_id != new_forum.pk:
+        message = _("%(user)s moved thread from %(forum)s.")
+        record_event(user, thread, "arrow-right", message, {
+            'user': user,
+            'forum': thread.forum
+        })
+
         thread.move(new_forum)
-        thread.save(update_fields=['forum'])
+        thread.save(update_fields=['has_events', 'forum'])
         return True
     else:
         return False
 
 
+@atomic
 def merge_thread(user, thread, other_thread):
     thread.merge(other_thread)
     thread.synchornize()
@@ -42,11 +73,15 @@ def merge_thread(user, thread, other_thread):
     return True
 
 
+@atomic
 def approve_thread(user, thread):
     if thread.is_moderated:
+        message = _("%(user)s approved thread.")
+        record_event(user, thread, "check", message, {'user': user})
+
         thread.is_closed = False
         thread.first_post.is_moderated = False
-        thread.first_post.save(update_fields=['is_moderated'])
+        thread.first_post.save(update_fields=['has_events', 'is_moderated'])
         thread.synchornize()
         thread.save()
         return True
@@ -54,30 +89,42 @@ def approve_thread(user, thread):
         return False
 
 
+@atomic
 def open_thread(user, thread):
     if thread.is_closed:
+        message = _("%(user)s opened thread.")
+        record_event(user, thread, "unlock-alt", message, {'user': user})
+
         thread.is_closed = False
-        thread.save(update_fields=['is_closed'])
+        thread.save(update_fields=['has_events', 'is_closed'])
         return True
     else:
         return False
 
 
+@atomic
 def close_thread(user, thread):
     if not thread.is_closed:
+        message = _("%(user)s closed thread.")
+        record_event(user, thread, "lock", message, {'user': user})
+
         thread.is_closed = True
-        thread.save(update_fields=['is_closed'])
+        thread.save(update_fields=['has_events', 'is_closed'])
         return True
     else:
         return False
 
 
+@atomic
 def show_thread(user, thread):
     if thread.is_hidden:
+        message = _("%(user)s made thread visible.")
+        record_event(user, thread, "eye", message, {'user': user})
+
         thread.first_post.is_hidden = False
         thread.first_post.save(update_fields=['is_hidden'])
         thread.is_hidden = False
-        thread.save(update_fields=['is_hidden'])
+        thread.save(update_fields=['has_events', 'is_hidden'])
         thread.synchornize()
         thread.save()
         return True
@@ -85,15 +132,20 @@ def show_thread(user, thread):
         return False
 
 
+@atomic
 def hide_thread(user, thread):
     if not thread.is_hidden:
+        message = _("%(user)s hid thread.")
+        record_event(user, thread, "eye-slash", message, {'user': user})
+
         thread.is_hidden = True
-        thread.save(update_fields=['is_hidden'])
+        thread.save(update_fields=['has_events', 'is_hidden'])
         return True
     else:
         return False
 
 
+@atomic
 def delete_thread(user, thread):
     thread.delete()
     return True

+ 61 - 0
misago/threads/tests/test_threads_moderation.py

@@ -0,0 +1,61 @@
+from misago.acl.testutils import override_acl
+from misago.forums.models import Forum
+from misago.users.testutils import AuthenticatedUserTestCase
+
+from misago.threads import moderation, testutils
+from misago.threads.models import Thread, Post, Event
+
+
+class ThreadsModerationTests(AuthenticatedUserTestCase):
+    def setUp(self):
+        super(ThreadsModerationTests, self).setUp()
+
+        self.forum = Forum.objects.all_forums().filter(role="forum")[:1][0]
+        self.thread = testutils.post_thread(self.forum)
+
+    def reload_thread(self):
+        self.thread = Thread.objects.get(pk=self.thread.pk)
+
+    def test_announce_thread(self):
+        """announce_thread makes thread announcement"""
+        self.assertEqual(self.thread.weight, 0)
+        self.assertTrue(moderation.announce_thread(self.user, self.thread))
+
+        self.reload_thread()
+        self.assertEqual(self.thread.weight, 2)
+
+        self.assertTrue(self.thread.has_events)
+        event = self.thread.event_set.last()
+
+        self.assertEqual(event.icon, "star")
+        self.assertIn("changed thread to announcement.", event.message)
+
+    def test_pin_thread(self):
+        """pin_thread makes thread pinned"""
+        self.assertEqual(self.thread.weight, 0)
+        self.assertTrue(moderation.pin_thread(self.user, self.thread))
+
+        self.reload_thread()
+        self.assertEqual(self.thread.weight, 1)
+
+        self.assertTrue(self.thread.has_events)
+        event = self.thread.event_set.last()
+
+        self.assertEqual(event.icon, "bookmark")
+        self.assertIn("pinned thread.", event.message)
+
+    def test_default_thread(self):
+        """default_thread defaults thread weight"""
+        moderation.pin_thread(self.user, self.thread)
+
+        self.assertEqual(self.thread.weight, 1)
+        self.assertTrue(moderation.default_thread(self.user, self.thread))
+
+        self.reload_thread()
+        self.assertEqual(self.thread.weight, 0)
+
+        self.assertTrue(self.thread.has_events)
+        event = self.thread.event_set.last()
+
+        self.assertIn("unpinned thread.", event.message)
+        self.assertEqual(event.icon, "circle")

+ 79 - 29
misago/threads/views/generic/forum.py

@@ -1,10 +1,12 @@
+from django.contrib import messages
 from django.core.urlresolvers import reverse
 from django.shortcuts import redirect
-from django.utils.translation import ugettext_lazy, ugettext as _
+from django.utils.translation import ugettext_lazy, ugettext as _, ungettext
 
 from misago.core.shortcuts import paginate
 from misago.forums.lists import get_forums_list, get_forum_path
 
+from misago.threads import moderation
 from misago.threads.models import ANNOUNCEMENT, Thread, Label
 from misago.threads.permissions import exclude_invisible_threads
 from misago.threads.views.generic.threads import (Actions, Sorting, Threads,
@@ -14,6 +16,78 @@ from misago.threads.views.generic.threads import (Actions, Sorting, Threads,
 __all__ = ['ForumFiltering', 'ForumThreads', 'ForumView']
 
 
+class ForumActions(Actions):
+    def get_available_actions(self, kwargs):
+        forum = kwargs['forum']
+
+        actions = []
+
+        if forum.acl['can_change_threads_weight'] == 2:
+            actions.append({
+                'action': 'announce',
+                'name': _("Change to announcements")
+            })
+        if forum.acl['can_change_threads_weight']:
+            actions.append({
+                'action': 'pin',
+                'name': _("Change to pinned")
+            })
+            actions.append({
+                'action': 'default',
+                'name': _("Change to default")
+            })
+
+        return actions
+
+    def action_announce(self, request, threads):
+        changed_threads = 0
+        for thread in threads:
+            if moderation.announce_thread(request.user, thread):
+                changed_threads += 1
+
+        if changed_threads:
+            message = ungettext(
+                '%(changed)d thread was changed to announcement.',
+                '%(changed)d threads were changed to announcements.',
+            changed_threads)
+            messages.success(request, message % {'changed': changed_threads})
+        else:
+            message = ("No threads were changed to announcements.")
+            messages.info(request, message)
+
+    def action_pin(self, request, threads):
+        changed_threads = 0
+        for thread in threads:
+            if moderation.pin_thread(request.user, thread):
+                changed_threads += 1
+
+        if changed_threads:
+            message = ungettext(
+                '%(changed)d thread was pinned.',
+                '%(changed)d threads were pinned.',
+            changed_threads)
+            messages.success(request, message % {'changed': changed_threads})
+        else:
+            message = ("No threads were pinned.")
+            messages.info(request, message)
+
+    def action_default(self, request, threads):
+        changed_threads = 0
+        for thread in threads:
+            if moderation.default_thread(request.user, thread):
+                changed_threads += 1
+
+        if changed_threads:
+            message = ungettext(
+                '%(changed)d thread weight was changed to default.',
+                '%(changed)d threads weight was changed to default.',
+            changed_threads)
+            messages.success(request, message % {'changed': changed_threads})
+        else:
+            message = ("No threads weight was changed to default.")
+            messages.info(request, message)
+
+
 class ForumFiltering(object):
     def __init__(self, forum, link_name, link_params):
         self.forum = forum
@@ -219,30 +293,6 @@ class ForumThreads(Threads):
             raise AttributeError(self.error_message)
 
 
-class ForumActions(Actions):
-    def get_available_actions(self, kwargs):
-        forum = kwargs['forum']
-
-        actions = []
-
-        if forum.acl['can_change_threads_weight'] == 2:
-            actions.append({
-                'action': 'announce',
-                'name': _("Change to announcements")
-            })
-        if forum.acl['can_change_threads_weight']:
-            actions.append({
-                'action': 'pin',
-                'name': _("Change to pinned")
-            })
-            actions.append({
-                'action': 'default',
-                'name': _("Change to default")
-            })
-
-        return actions
-
-
 class ForumView(ThreadsView):
     """
     Basic view for forum threads lists
@@ -275,6 +325,10 @@ class ForumView(ThreadsView):
         if cleaned_kwargs != kwargs:
             return redirect('misago:forum', **cleaned_kwargs)
 
+        threads = self.Threads(request.user, forum)
+        sorting.sort(threads)
+        filtering.filter(threads)
+
         actions = self.Actions(user=request.user, forum=forum)
         if request.method == 'POST':
             # see if we can delegate anything to actions manager
@@ -282,10 +336,6 @@ class ForumView(ThreadsView):
             if response:
                 return response
 
-        threads = self.Threads(request.user, forum)
-        sorting.sort(threads)
-        filtering.filter(threads)
-
         return self.render(request, {
             'link_name': self.link_name,
             'links_params': cleaned_kwargs,

+ 6 - 4
misago/threads/views/generic/threads.py

@@ -30,16 +30,18 @@ class Actions(object):
                                   "of dicts with allowed actions")
 
     def resolve_action(self, request):
-        action_name = request.get_list('thread')
+        action_name = request.POST.get('action')
         if ':' in action_name:
-            action_name, action_arg = action_name.split(':')
+            action_bits = action_name.split(':')
+            action_name = action_bits[0]
+            action_arg = action_bits[1]
         else:
             action_arg = None
 
         for action in self.available_actions:
             if action['action'] == action_name:
                 action_callable = 'action_%s' % action_name
-                return getattr(self, action_callable)
+                return getattr(self, action_callable), action_arg
         else:
             raise ModerationError(_("Requested action is invalid."))
 
@@ -60,7 +62,7 @@ class Actions(object):
         try:
             action, action_arg = self.resolve_action(request)
             self.selected_ids = self.clean_selection(
-                request.POST.get_list('thread'))
+                request.POST.getlist('thread', []))
 
             filtered_queryset = queryset.filter(pk__in=self.selected_ids)
             if filtered_queryset.exists():