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

WIP #410: thread moderation in thread view

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

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

@@ -0,0 +1,22 @@
+{% load i18n %}
+<script lang="JavaScript">
+  $(function() {
+
+    $('#thread-actions .dropdown-menu button').click(function() {
+      if ($(this).data('confirmation') != undefined) {
+        var decision = confirm($(this).data('confirmation'));
+        return decision
+      } else {
+        return true;
+      }
+    });
+
+    var $thread_actions = $('#thread-actions');
+    $('#thread-actions .action-move').click(function() {
+      var action_data = $thread_actions.serialize($thread_actions) + '&thread_action=move';
+      Misago.Modal.post('', action_data);
+      return false;
+    });
+
+  });
+</script>

+ 8 - 0
misago/templates/misago/thread/full_path.html

@@ -0,0 +1,8 @@
+{% for crumb in path %}
+<li>
+  <a href="{{ crumb.get_absolute_url }}">{{ crumb.name }}</a><span class="fa fa-chevron-right"></span>
+</li>
+{% endfor %}
+<li>
+  <a href="{{ thread.get_absolute_url }}">{{ thread.title }}</a>
+</li>

+ 0 - 0
misago/templates/misago/thread/goto_moderated.html


+ 0 - 0
misago/templates/misago/thread/goto_new.html


+ 0 - 0
misago/templates/misago/thread/goto_reported.html


+ 59 - 0
misago/templates/misago/thread/move/full.html

@@ -0,0 +1,59 @@
+{% extends "misago/threads/base.html" %}
+{% load i18n misago_forms %}
+
+
+{% block title %}{% trans "Move 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 "Move thread" %}</h1>
+    </div>
+  </div>
+
+  <div class="container">
+    <form method="POST">
+      {% csrf_token %}
+      <input type="hidden" name="thread_action" value="move">
+
+      <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_forum %}
+
+            </div>
+
+            <div class="form-footer text-center">
+
+              <button class="btn btn-primary" name="submit">{% trans "Move thread" %}</button>
+              <a href="" class="btn btn-default">{% trans "Cancel" %}</a>
+
+            </div>
+          </div>
+
+        </div>
+      </div><!-- /.row -->
+
+    </form>
+  </div>
+
+</div>
+{% endblock content %}

+ 21 - 0
misago/templates/misago/thread/move/modal.html

@@ -0,0 +1,21 @@
+{% load i18n misago_forms %}
+<form method="POST">
+  {% 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>
+    <h4 class="modal-title" id="ajaxModalLabel">
+      <span class="fa fa-arrow-right fa-fw"></span>
+      {% trans "Move thread" %}
+    </h4>
+  </div>
+  <div class="modal-body modal-form">
+
+    {% form_row form.new_forum %}
+
+  </div>
+  <div class="modal-footer text-center">
+    <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>
+</form>

+ 0 - 0
misago/templates/misago/thread/post_actions.html


+ 13 - 1
misago/templates/misago/thread/replies.html

@@ -2,7 +2,7 @@
 {% load humanize i18n misago_avatars misago_stringutils %}
 
 
-{% block title %}{{ thread.title }} | {{ block.super }}{% endblock title %}
+{% block title %}{{ thread.title }}{% if page.number > 1 %} ({% blocktrans with page=page.number %}Page {{ page }}{% endblocktrans %}){% endif %} | {{ block.super }}{% endblock title %}
 
 
 {% block meta-description %}{% if thread.first_post.is_valid %}{{ thread.first_post.short|striplinebreaks }} {% endif %}{% blocktrans trimmed with replies=thread.replies started=thread.started_on|date last_post=thread.last_post_on|date count counter=thread.replies %}
@@ -62,6 +62,10 @@
 
       {% include "misago/thread/pagination.html" %}
 
+      {% if thread_actions %}
+      {% include "misago/thread/thread_actions.html" %}
+      {% endif %}
+
     </div>
 
     <div class="posts-list">
@@ -79,3 +83,11 @@
   </div>
 </div>
 {% endblock %}
+
+
+{% block javascripts %}
+{{ block.super }}
+{% if user.is_authenticated %}
+  {% include "misago/thread/actions_js.html" %}
+{% endif %}
+{% endblock javascripts %}

+ 20 - 0
misago/templates/misago/thread/thread_actions.html

@@ -0,0 +1,20 @@
+{% load i18n %}
+<div class="btn-group pull-right">
+  <form id="thread-actions" method="post">
+    {% csrf_token %}
+    <button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
+      <span class="fa fa-gears"></span>
+      {% trans "Thread moderation" %}
+    </button>
+    <ul class="dropdown-menu scrollable" role="menu">
+      {% for action in thread_actions %}
+      <li>
+        <button type="submit" name="thread_action" value="{{ action.action }}" class="action-{{ action.action }}" {% if action.confirmation %}data-confirmation="{{ action.confirmation }}"{% endif %}>
+          <span class="fa fa-{{ action.icon }} fa-fw"></span>
+          {{ action.name }}
+        </button>
+      </li>
+      {% endfor %}
+    </ul>
+  </form>
+</div>

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

@@ -4,11 +4,11 @@
 </button>
 <div class="btn-group pull-right">
   <form id="threads-actions" action="{{ querystring }}" method="post">
+    {% csrf_token %}
     <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 scrollable" role="menu">
       {% for action in list_actions %}
       <li>

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

@@ -51,3 +51,8 @@ class MoveThreadsForm(forms.Form):
         else:
             raise forms.ValidationError(_("You have to select forum."))
         return data
+
+
+class MoveThreadForm(MoveThreadsForm):
+    new_forum = ForumChoiceField(label=_("Move thread to forum"),
+                                 empty_label=None)

+ 201 - 0
misago/threads/views/generic/_thread.py

@@ -0,0 +1,201 @@
+from django.db.models import Q
+from django.shortcuts import redirect
+from django.utils.translation import ungettext, ugettext_lazy, ugettext as _
+
+from misago.acl import add_acl
+from misago.forums.lists import get_forum_path
+from misago.threads.models import Label
+from misago.readtracker import threadstracker
+from misago.users.online.utils import get_user_state
+
+from misago.threads.events import add_events_to_posts
+from misago.threads.paginator import paginate
+from misago.threads.views.generic.actions import ActionsBase
+from misago.threads.views.generic.base import ViewBase
+
+
+__all__ = ['ThreadActions']
+
+
+class ThreadActions(ActionsBase):
+    query_key = 'thread_action'
+    is_mass_action = False
+
+    def get_available_actions(self, kwargs):
+        self.thread = kwargs['thread']
+        self.forum = self.thread.forum
+
+        actions = []
+
+        if self.forum.acl['can_change_threads_labels'] == 2:
+            self.forum.labels = Label.objects.get_forum_labels(self.forum)
+            for label in self.forum.labels:
+                if label.pk != self.thread.label_id:
+                    name = _('Label as "%(label)s"') % {'label': label.name}
+                    actions.append({
+                        'action': 'label:%s' % label.slug,
+                        'icon': 'tag',
+                        'name': name
+                    })
+
+            if self.forum.labels and self.thread.label_id:
+                actions.append({
+                    'action': 'unlabel',
+                    'icon': 'times-circle',
+                    'name': _("Remove label")
+                })
+
+        if self.forum.acl['can_pin_threads']:
+            if self.thread.is_pinned:
+                actions.append({
+                    'action': 'unpin',
+                    'icon': 'circle',
+                    'name': _("Unpin thread")
+                })
+            else:
+                actions.append({
+                    'action': 'pin',
+                    'icon': 'star',
+                    'name': _("Pin thread")
+                })
+
+        if self.forum.acl['can_review_moderated_content']:
+            if self.thread.is_moderated:
+                actions.append({
+                    'action': 'approve',
+                    'icon': 'check',
+                    'name': _("Approve thread")
+                })
+
+        if self.forum.acl['can_move_threads']:
+            actions.append({
+                'action': 'move',
+                'icon': 'arrow-right',
+                'name': _("Move thread")
+            })
+
+        if self.forum.acl['can_close_threads']:
+            if self.thread.is_closed:
+                actions.append({
+                    'action': 'open',
+                    'icon': 'unlock-alt',
+                    'name': _("Open thread")
+                })
+            else:
+                actions.append({
+                    'action': 'close',
+                    'icon': 'lock',
+                    'name': _("Close thread")
+                })
+
+        if self.forum.acl['can_hide_threads']:
+            if self.thread.is_hidden:
+                actions.append({
+                    'action': 'unhide',
+                    'icon': 'eye',
+                    'name': _("Unhide thread")
+                })
+            else:
+                actions.append({
+                    'action': 'hide',
+                    'icon': 'eye-slash',
+                    'name': _("Hide thread")
+                })
+
+        if self.forum.acl['can_hide_threads'] == 2:
+            actions.append({
+                'action': 'delete',
+                'icon': 'times',
+                'name': _("Delete thread"),
+                'confirmation': _("Are you sure you want to delete this "
+                                  "thread? This action can't be undone.")
+            })
+
+        return actions
+
+
+class PostsActions(ActionsBase):
+    select_items_message = ugettext_lazy(
+        "You have to select at least one post.")
+    is_mass_action = True
+
+    def get_available_actions(self, kwargs):
+        return []
+
+
+class ThreadView(ViewBase):
+    """
+    Basic view for threads
+    """
+    ThreadActions = ThreadActions
+    PostsActions = PostsActions
+    template = 'misago/thread/replies.html'
+
+    def get_posts(self, user, forum, thread, kwargs):
+        queryset = self.get_posts_queryset(user, forum, thread)
+        page = paginate(queryset, kwargs.get('page', 0), 10, 5)
+
+        posts = []
+        for post in page.object_list:
+            add_acl(user, post)
+            if post.poster:
+                poster_state = get_user_state(post.poster, user.acl)
+                post.poster.online_state = poster_state
+            posts.append(post)
+
+        if page.next_page_first_item:
+            add_events_to_posts(
+                user, thread, posts, page.next_page_first_item.posted_on)
+        else:
+            add_events_to_posts(user, thread, posts)
+
+        return page, posts
+
+    def get_posts_queryset(self, user, forum, thread):
+        queryset = thread.post_set.select_related(
+            'poster', 'poster__rank', 'poster__bancache', 'poster__online')
+
+        if user.is_authenticated():
+            if forum.acl['can_review_moderated_content']:
+                visibility_condition = Q(is_moderated=False) | Q(poster=user)
+                queryset = queryset.filter(visibility_condition)
+        else:
+            queryset = queryset.filter(is_moderated=False)
+
+        return queryset.order_by('id')
+
+    def dispatch(self, request, *args, **kwargs):
+        relations = ['forum', 'starter', 'last_poster', 'first_post']
+        thread = self.fetch_thread(request, select_related=relations, **kwargs)
+        forum = thread.forum
+
+        self.check_forum_permissions(request, forum)
+        self.check_thread_permissions(request, thread)
+
+        threadstracker.make_read_aware(request.user, thread)
+
+        thread_actions = self.ThreadActions(user=request.user, thread=thread)
+        posts_actions = self.PostsActions(user=request.user, thread=thread)
+
+        page, posts = self.get_posts(request.user, forum, thread, kwargs)
+        threadstracker.make_posts_read_aware(request.user, thread, posts)
+        threadstracker.read_thread(request.user, thread, posts[-1])
+
+        return self.render(request, {
+            'link_name': thread.get_url(),
+            'links_params': {
+                'thread_id': thread.id, 'thread_slug': thread.slug
+            },
+
+            'forum': forum,
+            'path': get_forum_path(forum),
+
+            'thread': thread,
+            'thread_actions': thread_actions.get_list(),
+
+            'posts': posts,
+            'posts_actions': posts_actions.get_list(),
+
+            'paginator': page.paginator,
+            'page': page,
+        })

+ 112 - 0
misago/threads/views/generic/actions.py

@@ -0,0 +1,112 @@
+from django.contrib import messages
+from django.shortcuts import redirect
+from django.utils.translation import ugettext_lazy as _
+
+from misago.core.exceptions import AjaxError
+
+from misago.threads.moderation import ModerationError
+
+
+__all__ = ['ActionsBase']
+
+
+class ActionsBase(object):
+    query_key = 'action'
+    invalid_action_message = _("Requested action is invalid.")
+
+    def __init__(self, **kwargs):
+        if kwargs.get('user').is_authenticated():
+            self.available_actions = self.get_available_actions(kwargs)
+        else:
+            self.available_actions = []
+        self.selected_ids = []
+
+    def get_available_actions(self, kwargs):
+        raise NotImplementedError("get_available_actions has to return list "
+                                  "of dicts with allowed actions")
+
+    def resolve_action(self, request):
+        action_name = request.POST.get(self.query_key)
+
+        for action in self.available_actions:
+            if action['action'] == action_name:
+                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:
+            raise ModerationError(self.invalid_action_message)
+
+    def clean_selection(self, data):
+        filtered_data = []
+        for pk in data[:50]: # a tiny fail-safe to avoid too big workloads
+            try:
+                filtered_data.append(int(pk))
+            except ValueError:
+                pass
+
+        if not filtered_data:
+            raise ModerationError(self.select_items_message)
+
+        return filtered_data
+
+    def handle_post(self, request, target):
+        try:
+            if self.is_mass_action:
+                return self.handle_mass_action(request, target)
+            else:
+                return self.handle_single_action(request, target)
+        except ModerationError as e:
+            if request.is_ajax():
+                raise AjaxError(e.message, 406)
+            else:
+                messages.error(request, e.message)
+                return False
+
+    def handle_mass_action(self, request, queryset):
+        action, action_arg = self.resolve_action(request)
+        self.selected_ids = self.clean_selection(
+            request.POST.getlist('thread', []))
+
+        filtered_queryset = queryset.filter(pk__in=self.selected_ids)
+        if filtered_queryset.exists():
+            if action_arg:
+                response = action(request, filtered_queryset, action_arg)
+            else:
+                response = action(request, filtered_queryset)
+            if response:
+                return response
+            elif request.is_ajax():
+                raise AjaxError(self.invalid_action_message, 406)
+            else:
+                # prepare default response: page reload
+                return redirect(request.path)
+        else:
+            raise ModerationError(self.select_items_message)
+
+    def handle_single_action(self, request, target):
+        action, action_arg = self.resolve_action(request)
+
+        if action_arg:
+            response = action(request, target, action_arg)
+        else:
+            response = action(request, target)
+
+        if response:
+            return response
+        elif request.is_ajax():
+            raise AjaxError(self.invalid_action_message, 406)
+        else:
+            # prepare default response: page reload
+            return redirect(request.path)
+
+    def get_list(self):
+        return self.available_actions
+
+    def get_selected_ids(self):
+        return self.selected_ids

+ 1 - 1
misago/threads/views/generic/forum/actions.py

@@ -136,7 +136,7 @@ class ForumActions(Actions):
 
         if changed_threads:
             message = ungettext(
-                '%(changed)d thread label was remoded.',
+                '%(changed)d thread label was removed.',
                 '%(changed)d threads labels were removed.',
             changed_threads)
             messages.success(request, message % {'changed': changed_threads})

+ 4 - 0
misago/threads/views/generic/thread/__init__.py

@@ -0,0 +1,4 @@
+# flake8: noqa
+from misago.threads.views.generic.thread.postsactions import PostsActions
+from misago.threads.views.generic.thread.threadactions import ThreadActions
+from misago.threads.views.generic.thread.view import ThreadView

+ 16 - 0
misago/threads/views/generic/thread/postsactions.py

@@ -0,0 +1,16 @@
+from django.utils.translation import ungettext, ugettext_lazy, ugettext as _
+
+from misago.threads import moderation
+from misago.threads.views.generic.actions import ActionsBase
+
+
+__all__ = ['PostsActions']
+
+
+class PostsActions(ActionsBase):
+    select_items_message = ugettext_lazy(
+        "You have to select at least one post.")
+    is_mass_action = True
+
+    def get_available_actions(self, kwargs):
+        return []

+ 195 - 0
misago/threads/views/generic/thread/threadactions.py

@@ -0,0 +1,195 @@
+from django.contrib import messages
+from django.shortcuts import redirect, render
+from django.utils.translation import ugettext as _
+
+from misago.forums.lists import get_forum_path
+
+from misago.threads import moderation
+from misago.threads.forms.moderation import MoveThreadForm
+from misago.threads.models import Label
+from misago.threads.views.generic.actions import ActionsBase
+
+
+__all__ = ['ThreadActions']
+
+
+class ThreadActions(ActionsBase):
+    query_key = 'thread_action'
+    is_mass_action = False
+
+    def get_available_actions(self, kwargs):
+        self.thread = kwargs['thread']
+        self.forum = self.thread.forum
+
+        actions = []
+
+        if self.forum.acl['can_change_threads_labels'] == 2:
+            self.forum.labels = Label.objects.get_forum_labels(self.forum)
+            for label in self.forum.labels:
+                if label.pk != self.thread.label_id:
+                    name = _('Label as "%(label)s"') % {'label': label.name}
+                    actions.append({
+                        'action': 'label:%s' % label.slug,
+                        'icon': 'tag',
+                        'name': name
+                    })
+
+            if self.forum.labels and self.thread.label_id:
+                actions.append({
+                    'action': 'unlabel',
+                    'icon': 'times-circle',
+                    'name': _("Remove label")
+                })
+
+        if self.forum.acl['can_pin_threads']:
+            if self.thread.is_pinned:
+                actions.append({
+                    'action': 'unpin',
+                    'icon': 'circle',
+                    'name': _("Unpin thread")
+                })
+            else:
+                actions.append({
+                    'action': 'pin',
+                    'icon': 'star',
+                    'name': _("Pin thread")
+                })
+
+        if self.forum.acl['can_review_moderated_content']:
+            if self.thread.is_moderated:
+                actions.append({
+                    'action': 'approve',
+                    'icon': 'check',
+                    'name': _("Approve thread")
+                })
+
+        if self.forum.acl['can_move_threads']:
+            actions.append({
+                'action': 'move',
+                'icon': 'arrow-right',
+                'name': _("Move thread")
+            })
+
+        if self.forum.acl['can_close_threads']:
+            if self.thread.is_closed:
+                actions.append({
+                    'action': 'open',
+                    'icon': 'unlock-alt',
+                    'name': _("Open thread")
+                })
+            else:
+                actions.append({
+                    'action': 'close',
+                    'icon': 'lock',
+                    'name': _("Close thread")
+                })
+
+        if self.forum.acl['can_hide_threads']:
+            if self.thread.is_hidden:
+                actions.append({
+                    'action': 'unhide',
+                    'icon': 'eye',
+                    'name': _("Unhide thread")
+                })
+            else:
+                actions.append({
+                    'action': 'hide',
+                    'icon': 'eye-slash',
+                    'name': _("Hide thread")
+                })
+
+        if self.forum.acl['can_hide_threads'] == 2:
+            actions.append({
+                'action': 'delete',
+                'icon': 'times',
+                'name': _("Delete thread"),
+                'confirmation': _("Are you sure you want to delete this "
+                                  "thread? This action can't be undone.")
+            })
+
+        return actions
+
+    def action_label(self, request, thread, label_slug):
+        for label in self.forum.labels:
+            if label.slug == label_slug:
+                break
+        else:
+            raise moderation.ModerationError(self.invalid_action_message)
+
+        moderation.label_thread(request.user, thread, label)
+        message = _('Thread was labeled "%(label)s".')
+        messages.success(request, message % {'label': label.name})
+
+    def action_unlabel(self, request, thread):
+        moderation.unlabel_thread(request.user, thread)
+        messages.success(request, _("Thread label was removed."))
+
+    def action_pin(self, request, thread):
+        moderation.pin_thread(request.user, thread)
+        messages.success(request, _("Thread was pinned."))
+
+    def action_unpin(self, request, thread):
+        moderation.unpin_thread(request.user, thread)
+        messages.success(request, _("Thread was unpinned."))
+
+    move_thread_full_template = 'misago/thread/move/full.html'
+    move_thread_modal_template = 'misago/thread/move/modal.html'
+
+    def action_move(self, request, thread):
+        form = MoveThreadForm(acl=request.user.acl, forum=self.forum)
+
+        if request.method == "POST" and 'submit' in request.POST:
+            form = MoveThreadForm(
+                request.POST, acl=request.user.acl, forum=self.forum)
+            if form.is_valid():
+                new_forum = form.cleaned_data['new_forum']
+
+                with atomic():
+                    moderation.move_thread(request.user, thread, new_forum)
+                    self.forum.synchronize()
+                    self.forum.save()
+                    new_forum.synchronize()
+                    new_forum.save()
+
+                message = _('Thread was moved to "%(forum)s".')
+                messages.success(request, message % {
+                    'forum': new_forum.name
+                })
+
+                return None # trigger thread refresh
+
+        if request.is_ajax():
+            template = self.move_thread_modal_template
+        else:
+            template = self.move_thread_full_template
+
+        return render(request, template, {
+            'form': form,
+            'forum': self.forum,
+            'path': get_forum_path(self.forum),
+            'thread': thread
+        })
+
+    def action_close(self, request, thread):
+        moderation.close_thread(request.user, thread)
+        messages.success(request, _("Thread was closed."))
+
+    def action_open(self, request, thread):
+        moderation.open_thread(request.user, thread)
+        messages.success(request, _("Thread was opened."))
+
+    def action_unhide(self, request, thread):
+        moderation.unhide_thread(request.user, thread)
+        messages.success(request, _("Thread was made visible."))
+
+    def action_hide(self, request, thread):
+        moderation.hide_thread(request.user, thread)
+        messages.success(request, _("Thread was hid."))
+
+    def action_delete(self, request, thread):
+        moderation.delete_thread(request.user, thread)
+
+        message = _('Thread "%(thread)s" was deleted.')
+        messages.success(request, message % {'thread': thread.title})
+
+        return redirect(self.forum.get_absolute_url())

+ 17 - 1
misago/threads/views/generic/thread.py → misago/threads/views/generic/thread/view.py

@@ -1,5 +1,5 @@
 from django.db.models import Q
-from django.shortcuts import redirect
+from django.utils.translation import ungettext, ugettext_lazy, ugettext as _
 
 from misago.acl import add_acl
 from misago.forums.lists import get_forum_path
@@ -9,6 +9,8 @@ from misago.users.online.utils import get_user_state
 from misago.threads.events import add_events_to_posts
 from misago.threads.paginator import paginate
 from misago.threads.views.generic.base import ViewBase
+from misago.threads.views.generic.thread.postsactions import PostsActions
+from misago.threads.views.generic.thread.threadactions import ThreadActions
 
 
 __all__ = ['ThreadView']
@@ -18,6 +20,8 @@ class ThreadView(ViewBase):
     """
     Basic view for threads
     """
+    ThreadActions = ThreadActions
+    PostsActions = PostsActions
     template = 'misago/thread/replies.html'
 
     def get_posts(self, user, forum, thread, kwargs):
@@ -63,6 +67,15 @@ class ThreadView(ViewBase):
 
         threadstracker.make_read_aware(request.user, thread)
 
+        thread_actions = self.ThreadActions(user=request.user, thread=thread)
+        posts_actions = self.PostsActions(user=request.user, thread=thread)
+
+        if request.method == 'POST':
+            if thread_actions.query_key in request.POST:
+                response = thread_actions.handle_post(request, thread)
+                if response:
+                    return response
+
         page, posts = self.get_posts(request.user, forum, thread, kwargs)
         threadstracker.make_posts_read_aware(request.user, thread, posts)
         threadstracker.read_thread(request.user, thread, posts[-1])
@@ -77,7 +90,10 @@ class ThreadView(ViewBase):
             'path': get_forum_path(forum),
 
             'thread': thread,
+            'thread_actions': thread_actions.get_list(),
+
             'posts': posts,
+            'posts_actions': posts_actions.get_list(),
 
             'paginator': page.paginator,
             'page': page,

+ 5 - 82
misago/threads/views/generic/threads/actions.py

@@ -2,94 +2,17 @@ from django.contrib import messages
 from django.shortcuts import redirect
 from django.utils.translation import ungettext, ugettext_lazy, ugettext as _
 
-from misago.core.exceptions import AjaxError
-
 from misago.threads import moderation
-from misago.threads.moderation import ModerationError
+from misago.threads.views.generic.actions import ActionsBase
 
 
 __all__ = ['Actions']
 
 
-class Actions(object):
-    invalid_action_message = ugettext_lazy("Requested action is invalid.")
-    select_threads_message = ugettext_lazy(
+class Actions(ActionsBase):
+    select_items_message = ugettext_lazy(
         "You have to select at least one thread.")
-
-    def __init__(self, **kwargs):
-        if kwargs.get('user').is_authenticated():
-            self.available_actions = self.get_available_actions(kwargs)
-        else:
-            self.available_actions = []
-        self.selected_ids = []
-
-    def get_available_actions(self, kwargs):
-        raise NotImplementedError("get_available_actions has to return list "
-                                  "of dicts with allowed actions")
-
-    def resolve_action(self, request):
-        action_name = request.POST.get('action')
-
-        for action in self.available_actions:
-            if action['action'] == action_name:
-                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:
-            raise ModerationError(self.invalid_action_message)
-
-    def clean_selection(self, data):
-        filtered_data = []
-        for pk in data[:50]: # a tiny fail-safe to avoid too big workloads
-            try:
-                filtered_data.append(int(pk))
-            except ValueError:
-                pass
-
-        if not filtered_data:
-            raise ModerationError(self.select_threads_message)
-
-        return filtered_data
-
-    def handle_post(self, request, queryset):
-        try:
-            action, action_arg = self.resolve_action(request)
-            self.selected_ids = self.clean_selection(
-                request.POST.getlist('thread', []))
-
-            filtered_queryset = queryset.filter(pk__in=self.selected_ids)
-            if filtered_queryset.exists():
-                if action_arg:
-                    response = action(request, filtered_queryset, action_arg)
-                else:
-                    response = action(request, filtered_queryset)
-                if response:
-                    return response
-                elif request.is_ajax():
-                    raise AjaxError(self.invalid_action_message, 406)
-                else:
-                    # prepare default response: page reload
-                    return redirect(request.path)
-            else:
-                raise ModerationError(self.select_threads_message)
-        except ModerationError as e:
-            if request.is_ajax():
-                raise AjaxError(e.message, 406)
-            else:
-                messages.error(request, e.message)
-                return False
-
-    def get_list(self):
-        return self.available_actions
-
-    def get_selected_ids(self):
-        return self.selected_ids
+    is_mass_action = True
 
     def action_approve(self, request, threads):
         changed_threads = 0
@@ -104,5 +27,5 @@ class Actions(object):
             changed_threads)
             messages.success(request, message % {'changed': changed_threads})
         else:
-            message = ("No threads were approved.")
+            message = _("No threads were approved.")
             messages.info(request, message)