Browse Source

#410: label/unlabel threads

Rafał Pitoń 10 years ago
parent
commit
df4dc5015e

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

@@ -126,9 +126,13 @@ def create_default_forums_roles(apps, schema_editor):
                 'can_hide_threads': 2,
                 'can_hide_replies': 2,
                 'can_protect_posts': 1,
-                'can_change_threads_labels': 1,
+                'can_move_posts': 1,
+                'can_merge_posts': 1,
+                'can_change_threads_labels': 2,
                 'can_change_threads_weight': 2,
                 'can_close_threads': 1,
+                'can_move_threads': 1,
+                'can_merge_threads': 1,
                 'can_review_moderated_content': 1,
                 'can_report_content': 1,
                 'can_see_reports': 1,

+ 1 - 0
misago/static/misago/css/misago/threadslists.less

@@ -64,6 +64,7 @@
 
               .label {
                 border-radius: @border-radius-small;
+                margin-left: @line-height-computed / 3;
                 position: relative;
                 bottom: 1px;
 

+ 7 - 7
misago/templates/misago/threads/base.html

@@ -83,13 +83,6 @@
               {% endif %}
 
               <ul class="list-unstyled thread-flags">
-                {% if thread.label %}
-                <li>
-                  <span class="label label-solo label-{{ thread.label.css_class|default:"default" }}">
-                    {{ thread.label.name }}
-                  </span>
-                </li>
-                {% endif %}
                 {% if thread.has_reported_posts %}
                 <li class="tooltip-top" title="{% trans "Reported posts" %}">
                   <span class="fa fa-exclamation-triangle fa-fw fa-lg"></span>
@@ -120,6 +113,13 @@
                   <span class="fa fa-eye-slash fa-fw fa-lg"></span>
                 </li>
                 {% endif %}
+                {% if thread.label %}
+                <li>
+                  <span class="label label-solo label-{{ thread.label.css_class|default:"default" }}">
+                    {{ thread.label.name }}
+                  </span>
+                </li>
+                {% endif %}
               </ul>
               {% endblock thread-stats %}
             </div>

+ 18 - 14
misago/threads/events.py

@@ -10,26 +10,30 @@ __all__ = ['record_event', 'add_events_to_posts']
 
 
 LINK_TEMPLATE = '<a href="%s" class="event-%s">%s</a>'
+NAME_TEMPLATE = '<strong class="event-%s">%s</strong>'
 
 
 def format_message(message, links):
     if links:
         formats = {}
         for name, value in links.items():
-            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
+            if isinstance(value, basestring):
+                formats[name] = NAME_TEMPLATE % (escape(name), escape(value))
+            else:
+                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

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

@@ -5,6 +5,41 @@ from misago.threads.events import record_event
 
 
 @atomic
+def label_thread(user, thread, label):
+    if not thread.label_id or thread.label_id != label.pk:
+        if thread.label_id:
+            message = _("%(user)s changed thread label to %(label)s.")
+        else:
+            message = _("%(user)s set thread label to %(label)s.")
+
+        record_event(user, thread, "tag", message, {
+            'user': user,
+            'label': label.name
+        })
+
+        thread.label = label
+
+        thread.save(update_fields=['has_events', 'label'])
+        return True
+    else:
+        return False
+
+
+@atomic
+def unlabel_thread(user, thread):
+    if thread.label_id:
+        thread.label = None
+
+        message = _("%(user)s removed thread label.")
+        record_event(user, thread, "tag", message, {'user': user})
+
+        thread.save(update_fields=['has_events', 'label'])
+        return True
+    else:
+        return False
+
+
+@atomic
 def announce_thread(user, thread):
     if thread.weight < 2:
         thread.weight = 2

+ 101 - 0
misago/threads/tests/test_forumthreads_view.py

@@ -417,6 +417,12 @@ class ForumThreadsViewTests(AuthenticatedUserTestCase):
         self.link = self.forum.get_absolute_url()
         self.forum.delete_content()
 
+        Label.objects.clear_cache()
+
+    def tearDown(self):
+        super(ForumThreadsViewTests, self).tearDown()
+        Label.objects.clear_cache()
+
     def override_acl(self, new_acl):
         forums_acl = self.user.acl
         if new_acl['can_see']:
@@ -665,6 +671,101 @@ class ForumThreadsViewTests(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertIn(anon_title, response.content)
 
+    def test_change_threads_labels(self):
+        """moderation allows for changing threads labels"""
+        threads = [testutils.post_thread(self.forum) for t in xrange(10)]
+
+        test_acl = {
+            'can_see': 1,
+            'can_browse': 1,
+            'can_see_all_threads': 1,
+            'can_change_threads_labels': 2
+        }
+
+        labels = [
+            Label(name='Label A', slug='label-a'),
+            Label(name='Label B', slug='label-b'),
+        ]
+        for label in labels:
+            label.save()
+            label.forums.add(self.forum)
+
+        self.override_acl(test_acl)
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("Remove labels", response.content)
+
+        # label threads with invalid label
+        self.override_acl(test_acl)
+        response = self.client.post(self.link, data={
+            'action': 'label:mehssiah', 'thread': [t.pk for t in threads]
+        })
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("Requested action is invalid.", response.content)
+
+        # label threads
+        self.override_acl(test_acl)
+        response = self.client.post(self.link, data={
+            'action': 'label:%s' % labels[0].slug,
+            'thread': [t.pk for t in threads]
+        })
+        self.assertEqual(response.status_code, 302)
+
+        self.override_acl(test_acl)
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("10 threads were labeled", response.content)
+
+        # label labeled threads
+        self.override_acl(test_acl)
+        response = self.client.post(self.link, data={
+            'action': 'label:%s' % labels[0].slug,
+            'thread': [t.pk for t in threads]
+        })
+        self.assertEqual(response.status_code, 302)
+
+        self.override_acl(test_acl)
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("No threads were labeled.", response.content)
+
+        # relabel labeled threads
+        self.override_acl(test_acl)
+        response = self.client.post(self.link, data={
+            'action': 'label:%s' % labels[1].slug,
+            'thread': [t.pk for t in threads]
+        })
+        self.assertEqual(response.status_code, 302)
+
+        self.override_acl(test_acl)
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("10 threads were labeled", response.content)
+
+        # remove labels from threads
+        self.override_acl(test_acl)
+        response = self.client.post(self.link, data={
+            'action': 'unlabel', 'thread': [t.pk for t in threads]
+        })
+        self.assertEqual(response.status_code, 302)
+
+        self.override_acl(test_acl)
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("10 threads labels were removed", response.content)
+
+        # remove labels from unlabeled threads
+        self.override_acl(test_acl)
+        response = self.client.post(self.link, data={
+            'action': 'unlabel', 'thread': [t.pk for t in threads]
+        })
+        self.assertEqual(response.status_code, 302)
+
+        self.override_acl(test_acl)
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("No threads were unlabeled.", response.content)
+
     def test_moderate_threads_weight(self):
         """moderation allows for changing threads weight"""
         test_acl = {

+ 39 - 1
misago/threads/tests/test_threads_moderation.py

@@ -2,7 +2,7 @@ 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
+from misago.threads.models import Label, Thread, Post, Event
 
 
 class ThreadsModerationTests(AuthenticatedUserTestCase):
@@ -11,10 +11,48 @@ class ThreadsModerationTests(AuthenticatedUserTestCase):
 
         self.forum = Forum.objects.all_forums().filter(role="forum")[:1][0]
         self.thread = testutils.post_thread(self.forum)
+        Label.objects.clear_cache()
+
+    def tearDown(self):
+        super(ThreadsModerationTests, self).tearDown()
+        Label.objects.clear_cache()
 
     def reload_thread(self):
         self.thread = Thread.objects.get(pk=self.thread.pk)
 
+    def test_label_thread(self):
+        """label_thread makes thread announcement"""
+        label = Label.objects.create(name="Label", slug="label")
+
+        self.assertIsNone(self.thread.label)
+        self.assertTrue(moderation.label_thread(self.user, self.thread, label))
+
+        self.reload_thread()
+        self.assertEqual(self.thread.label, label)
+
+        self.assertTrue(self.thread.has_events)
+        event = self.thread.event_set.last()
+
+        self.assertEqual(event.icon, "tag")
+        self.assertIn("set thread label to", event.message)
+
+    def test_unlabel_thread(self):
+        """unlabel_thread removes thread label"""
+        label = Label.objects.create(name="Label", slug="label")
+        self.assertTrue(moderation.label_thread(self.user, self.thread, label))
+
+        self.reload_thread()
+        self.assertTrue(moderation.unlabel_thread(self.user, self.thread))
+
+        self.reload_thread()
+        self.assertIsNone(self.thread.label)
+
+        self.assertTrue(self.thread.has_events)
+        event = self.thread.event_set.last()
+
+        self.assertEqual(event.icon, "tag")
+        self.assertIn("removed thread label.", event.message)
+
     def test_announce_thread(self):
         """announce_thread makes thread announcement"""
         self.assertEqual(self.thread.weight, 0)

+ 15 - 3
misago/threads/tests/test_threadslist_view.py

@@ -45,7 +45,7 @@ class ActionsTests(AuthenticatedUserTestCase):
         actions = MockActions(user=self.user)
 
         actions.available_actions = [{
-            'action': 'test:',
+            'action': 'test:1234',
             'name': "Test action"
         }]
 
@@ -79,7 +79,7 @@ class ActionsTests(AuthenticatedUserTestCase):
             ))
 
         actions.available_actions = [{
-            'action': 'test:',
+            'action': 'test:123',
             'name': "Test action"
         }]
 
@@ -89,6 +89,18 @@ class ActionsTests(AuthenticatedUserTestCase):
                 POST={'action': 'test'},
             ))
 
+        with self.assertRaises(ModerationError):
+            resolution = actions.resolve_action(MockRequest(
+                user=self.user,
+                POST={'action': 'test:'},
+            ))
+
+        with self.assertRaises(ModerationError):
+            resolution = actions.resolve_action(MockRequest(
+                user=self.user,
+                POST={'action': 'test:321'},
+            ))
+
     def test_clean_selection(self):
         """clean_selection clears valid input"""
         actions = MockActions(user=self.user)
@@ -107,7 +119,7 @@ class ActionsTests(AuthenticatedUserTestCase):
         """get_list returns list of available actions"""
         actions = MockActions(user=self.user)
         actions.available_actions = [{
-            'action': 'test:',
+            'action': 'test:123',
             'name': "Test action"
         }]
         self.assertEqual(actions.get_list(), actions.available_actions)

+ 56 - 0
misago/threads/views/generic/forum.py

@@ -23,6 +23,21 @@ class ForumActions(Actions):
 
         actions = []
 
+        if self.forum.acl['can_change_threads_labels'] == 2:
+            for label in self.forum.labels:
+                actions.append({
+                    'action': 'label:%s' % label.slug,
+                    'icon': 'tag',
+                    'name': _('Label as "%(label)s"') % {'label': label.name}
+                })
+
+            if self.forum.labels:
+                actions.append({
+                    'action': 'unlabel',
+                    'icon': 'times-circle',
+                    'name': _("Remove labels")
+                })
+
         if self.forum.acl['can_change_threads_weight'] == 2:
             actions.append({
                 'action': 'announce',
@@ -72,6 +87,47 @@ class ForumActions(Actions):
 
         return actions
 
+    def action_label(self, request, threads, label_slug):
+        for label in self.forum.labels:
+            if label.slug == label_slug:
+                break
+        else:
+            raise ModerationError(_("Requested action is invalid."))
+
+        changed_threads = 0
+        for thread in threads:
+            if moderation.label_thread(request.user, thread, label):
+                changed_threads += 1
+
+        if changed_threads:
+            message = ungettext(
+                '%(changed)d thread was labeled "%(label)s".',
+                '%(changed)d threads were labeled "%(label)s".',
+            changed_threads)
+            messages.success(request, message % {
+                'changed': changed_threads,
+                'label': label.name
+            })
+        else:
+            message = ("No threads were labeled.")
+            messages.info(request, message)
+
+    def action_unlabel(self, request, threads):
+        changed_threads = 0
+        for thread in threads:
+            if moderation.unlabel_thread(request.user, thread):
+                changed_threads += 1
+
+        if changed_threads:
+            message = ungettext(
+                '%(changed)d thread label was remoded.',
+                '%(changed)d threads labels were removed.',
+            changed_threads)
+            messages.success(request, message % {'changed': changed_threads})
+        else:
+            message = ("No threads were unlabeled.")
+            messages.info(request, message)
+
     def action_announce(self, request, threads):
         changed_threads = 0
         for thread in threads:

+ 7 - 8
misago/threads/views/generic/threads.py

@@ -31,17 +31,16 @@ class Actions(object):
 
     def resolve_action(self, request):
         action_name = request.POST.get('action')
-        if ':' in action_name:
-            action_bits = action_name.split(':')
-            action_name = '%s:' % action_bits[0]
-            action_arg = action_bits[1]
-        else:
-            action_arg = None
 
         for action in self.available_actions:
             if action['action'] == action_name:
-                if action_name[-1] == ':':
-                    action_name = action_name[:-1]
+                if ':' in action_name:
+                    action_bits = action_name.split(':')
+                    action_name = action_bits[0]
+                    action_arg = action_bits[1]
+                else:
+                    action_arg = None
+
                 action_callable = 'action_%s' % action_name
                 return getattr(self, action_callable), action_arg
         else: