Browse Source

Added forum moderation tools.
Also added a CRUDMixin and did some code clean up.

sh4nks 9 years ago
parent
commit
e8a2ff62b5

+ 38 - 107
flaskbb/forum/models.py

@@ -13,7 +13,9 @@ from datetime import datetime, timedelta
 from flask import url_for, abort
 
 from flaskbb.extensions import db
-from flaskbb.utils.helpers import slugify, get_categories_and_forums, get_forums
+from flaskbb.utils.helpers import slugify, get_categories_and_forums, \
+    get_forums
+from flaskbb.utils.database import CRUDMixin
 from flaskbb.utils.settings import flaskbb_config
 
 
@@ -36,7 +38,7 @@ topictracker = db.Table(
               nullable=False))
 
 
-class TopicsRead(db.Model):
+class TopicsRead(db.Model, CRUDMixin):
     __tablename__ = "topicsread"
 
     user_id = db.Column(db.Integer, db.ForeignKey("users.id"),
@@ -51,23 +53,8 @@ class TopicsRead(db.Model):
                          primary_key=True)
     last_read = db.Column(db.DateTime, default=datetime.utcnow())
 
-    def __repr__(self):
-        return "<{}>".format(self.__class__.__name__)
-
-    def save(self):
-        """Saves a TopicsRead entry."""
-        db.session.add(self)
-        db.session.commit()
-        return self
-
-    def delete(self):
-        """Deletes a TopicsRead entry."""
-        db.session.delete(self)
-        db.session.commit()
-        return self
 
-
-class ForumsRead(db.Model):
+class ForumsRead(db.Model, CRUDMixin):
     __tablename__ = "forumsread"
 
     user_id = db.Column(db.Integer, db.ForeignKey("users.id"),
@@ -79,23 +66,8 @@ class ForumsRead(db.Model):
     last_read = db.Column(db.DateTime, default=datetime.utcnow())
     cleared = db.Column(db.DateTime)
 
-    def __repr__(self):
-        return "<{}>".format(self.__class__.__name__)
-
-    def save(self):
-        """Saves a ForumsRead entry."""
-        db.session.add(self)
-        db.session.commit()
-        return self
-
-    def delete(self):
-        """Deletes a ForumsRead entry."""
-        db.session.delete(self)
-        db.session.commit()
-        return self
-
 
-class Report(db.Model):
+class Report(db.Model, CRUDMixin):
     __tablename__ = "reports"
 
     id = db.Column(db.Integer, primary_key=True)
@@ -119,9 +91,7 @@ class Report(db.Model):
         """Saves a report.
 
         :param post: The post that should be reported
-
         :param user: The user who has reported the post
-
         :param reason: The reason why the user has reported the post
         """
 
@@ -139,14 +109,8 @@ class Report(db.Model):
         db.session.commit()
         return self
 
-    def delete(self):
-        """Deletes a report."""
-        db.session.delete(self)
-        db.session.commit()
-        return self
 
-
-class Post(db.Model):
+class Post(db.Model, CRUDMixin):
     __tablename__ = "posts"
     __searchable__ = ['content', 'username']
 
@@ -187,7 +151,6 @@ class Post(db.Model):
         operation was successful.
 
         :param user: The user who has created the post
-
         :param topic: The topic in which the post was created
         """
         # update/edit the post
@@ -205,7 +168,7 @@ class Post(db.Model):
 
             topic.last_updated = datetime.utcnow()
 
-            # This needs to be done before I update the last_post_id.
+            # This needs to be done before the last_post_id gets updated.
             db.session.add(self)
             db.session.commit()
 
@@ -230,7 +193,7 @@ class Post(db.Model):
             return self
 
     def delete(self):
-        """Deletes a post and returns self"""
+        """Deletes a post and returns self."""
         # This will delete the whole topic
         if self.topic.first_post_id == self.id:
             self.topic.delete()
@@ -274,7 +237,7 @@ class Post(db.Model):
         return self
 
 
-class Topic(db.Model):
+class Topic(db.Model, CRUDMixin):
     __tablename__ = "topics"
     __searchable__ = ['title', 'username']
 
@@ -344,12 +307,9 @@ class Topic(db.Model):
         Also, if the ``TRACKER_LENGTH`` is configured, it will just recognize
         topics that are newer than the ``TRACKER_LENGTH`` (in days) as unread.
 
-        TODO: Couldn't think of a better name for this method - ideas?
-
         :param forumsread: The ForumsRead object is needed because we also
                            need to check if the forum has been cleared
                            sometime ago.
-
         :param topicsread: The topicsread object is used to check if there is
                            a new post in the topic.
         """
@@ -383,9 +343,7 @@ class Topic(db.Model):
         Returns True if the tracker has been updated.
 
         :param user: The user for whom the readstracker should be updated.
-
         :param forum: The forum in which the topic is.
-
         :param forumsread: The forumsread object. It is used to check if there
                            is a new post since the forum has been marked as
                            read.
@@ -432,71 +390,40 @@ class Topic(db.Model):
 
         return updated
 
-    def move(self, forum):
+    def move(self, new_forum):
         """Moves a topic to the given forum.
         Returns True if it could successfully move the topic to forum.
 
-        :param forum: The new forum for the topic
+        :param new_forum: The new forum for the topic
         """
 
         # if the target forum is the current forum, abort
-        if self.forum_id == forum.id:
+        if self.forum_id == new_forum.id:
             return False
 
         old_forum = self.forum
         self.forum.post_count -= self.post_count
         self.forum.topic_count -= 1
-        self.forum_id = forum.id
+        self.forum_id = new_forum.id
 
-        forum.post_count += self.post_count
-        forum.topic_count += 1
+        new_forum.post_count += self.post_count
+        new_forum.topic_count += 1
 
         db.session.commit()
 
-        forum.update_last_post()
+        new_forum.update_last_post()
         old_forum.update_last_post()
 
         TopicsRead.query.filter_by(topic_id=self.id).delete()
 
         return True
 
-    def merge(self, topic):
-        """Merges a topic with another topic
-
-        :param topic: The new topic for the posts in this topic
-        """
-
-        # You can only merge a topic with a differrent topic in the same forum
-        if self.id == topic.id or not self.forum_id == topic.forum_id:
-            return False
-
-        # Update the topic id
-        Post.query.filter_by(topic_id=self.id).\
-            update({Post.topic_id: topic.id})
-
-        # Update the last post
-        if topic.last_post.date_created < self.last_post.date_created:
-            topic.last_post_id = self.last_post_id
-
-        # Increase the post and views count
-        topic.post_count += self.post_count
-        topic.views += self.views
-
-        topic.save()
-
-        # Finally delete the old topic
-        Topic.query.filter_by(id=self.id).delete()
-
-        return True
-
     def save(self, user=None, forum=None, post=None):
         """Saves a topic and returns the topic object. If no parameters are
         given, it will only update the topic.
 
         :param user: The user who has created the topic
-
         :param forum: The forum where the topic is stored
-
         :param post: The post object which is connected to the topic
         """
 
@@ -543,7 +470,7 @@ class Topic(db.Model):
             filter_by(forum_id=self.forum_id).\
             order_by(Topic.last_post_id.desc()).limit(2).offset(0).all()
 
-        # do want to delete the topic with the last post?
+        # do we want to delete the topic with the last post in the forum?
         if topic and topic[0].id == self.id:
             try:
                 # Now the second last post will be the last post
@@ -592,7 +519,7 @@ class Topic(db.Model):
         return self
 
 
-class Forum(db.Model):
+class Forum(db.Model, CRUDMixin):
     __tablename__ = "forums"
     __searchable__ = ['title', 'description']
 
@@ -740,11 +667,16 @@ class Forum(db.Model):
             forumsread.save()
             return True
 
-        # Nothing updated, because there are still more than 0 unread topicsread
+        # Nothing updated, because there are still more than 0 unread
+        # topicsread
         return False
 
     def save(self, moderators=None):
-        """Saves a forum"""
+        """Saves a forum
+
+        :param moderators: If given, it will update the moderators in this
+                           forum with the given iterable of user objects.
+       """
         if moderators is not None:
             for moderator in self.moderators:
                 self.moderators.remove(moderator)
@@ -784,13 +716,23 @@ class Forum(db.Model):
 
         return self
 
+    def move_topics_to(self, topics):
+        """Moves a bunch a topics to the forum. Returns ``True`` if all
+        topics were moved successfully to the forum.
+
+        :param topics: A iterable with topic objects.
+        """
+        status = False
+        for topic in topics:
+            status = topic.move(self)
+        return status
+
     # Classmethods
     @classmethod
     def get_forum(cls, forum_id, user):
         """Returns the forum and forumsread object as a tuple for the user.
 
         :param forum_id: The forum id
-
         :param user: The user object is needed to check if we also need their
                      forumsread object.
         """
@@ -816,11 +758,8 @@ class Forum(db.Model):
         forumsread relation to check if it is read or unread.
 
         :param forum_id: The forum id
-
         :param user: The user object
-
         :param page: The page whom should be loaded
-
         :param per_page: How many topics per page should be shown
         """
         if user.is_authenticated():
@@ -841,7 +780,7 @@ class Forum(db.Model):
         return topics
 
 
-class Category(db.Model):
+class Category(db.Model, CRUDMixin):
     __tablename__ = "categories"
     __searchable__ = ['title', 'description']
 
@@ -875,13 +814,6 @@ class Category(db.Model):
         """
         return "<{} {}>".format(self.__class__.__name__, self.id)
 
-    def save(self):
-        """Saves a category"""
-
-        db.session.add(self)
-        db.session.commit()
-        return self
-
     def delete(self, users=None):
         """Deletes a category. If a list with involved user objects is passed,
         it will also update their post counts
@@ -947,7 +879,6 @@ class Category(db.Model):
             (<Category 1>, [(<Forum 1>, None), (<Forum 2>, None)])
 
         :param category_id: The category id
-
         :param user: The user object is needed to check if we also need their
                      forumsread object.
         """

+ 87 - 110
flaskbb/forum/views.py

@@ -19,7 +19,7 @@ from flask_babelex import gettext as _
 from flaskbb.extensions import db
 from flaskbb.utils.settings import flaskbb_config
 from flaskbb.utils.helpers import (get_online_users, time_diff, format_quote,
-                                   render_template)
+                                   render_template, do_topic_action)
 from flaskbb.utils.permissions import (can_post_reply, can_post_topic,
                                        can_delete_topic, can_delete_post,
                                        can_edit_post, can_moderate)
@@ -125,7 +125,6 @@ def view_topic(topic_id, slug=None):
     topic.update_read(current_user, topic.forum, forumsread)
 
     form = None
-
     if can_post_reply(user=current_user, topic=topic):
         form = QuickreplyForm()
         if form.validate_on_submit():
@@ -178,7 +177,6 @@ def new_topic(forum_id, slug=None):
     )
 
 
-@forum.route("/topic/delete", methods=["POST"])
 @forum.route("/topic/<int:topic_id>/delete", methods=["POST"])
 @forum.route("/topic/<int:topic_id>-<slug>/delete", methods=["POST"])
 @login_required
@@ -196,42 +194,12 @@ def delete_topic(topic_id=None, slug=None):
     return redirect(url_for("forum.view_forum", forum_id=topic.forum_id))
 
 
-@forum.route("/topic/lock", methods=["POST"])
 @forum.route("/topic/<int:topic_id>/lock", methods=["POST"])
 @forum.route("/topic/<int:topic_id>-<slug>/lock", methods=["POST"])
 @login_required
 def lock_topic(topic_id=None, slug=None):
-    if request.is_xhr:
-        ids = request.get_json()["ids"]
-        data = []
-
-        for topic in Topic.query.filter(Topic.id.in_(ids)).all():
-            # skip already locked topics
-            if topic.locked:
-                continue
-
-            # check if the user has the right permissions
-            if not can_moderate(user=current_user, forum=topic.forum):
-                return jsonify(status=550,
-                               message=_("You do not have the permissions to "
-                                         "lock this topic."),
-                               category="danger")
-
-            topic.locked = True
-            topic.save()
-
-            data.append({
-                "id": topic.id,
-                "type": "lock",
-                "reverse": "unlock",
-                "reverse_name": _("Unlock"),
-                "reverse_url": url_for("forum.unlock_topic")
-            })
-
-        return jsonify(message="{} topics locked.".format(len(data)),
-                       category="success", data=data, status=200)
-
     topic = Topic.query.filter_by(id=topic_id).first_or_404()
+
     if not can_moderate(user=current_user, forum=topic.forum):
         flash(_("You do not have the permissions to lock this topic."),
               "danger")
@@ -242,42 +210,12 @@ def lock_topic(topic_id=None, slug=None):
     return redirect(topic.url)
 
 
-@forum.route("/topic/unlock", methods=["POST"])
 @forum.route("/topic/<int:topic_id>/unlock", methods=["POST"])
 @forum.route("/topic/<int:topic_id>-<slug>/unlock", methods=["POST"])
 @login_required
 def unlock_topic(topic_id=None, slug=None):
-    if request.is_xhr:
-        ids = request.get_json()["ids"]
-        data = []
-
-        for topic in Topic.query.filter(Topic.id.in_(ids)).all():
-            # skip already locked topics
-            if not topic.locked:
-                continue
-
-            # check if the user has the right permissions
-            if not can_moderate(user=current_user, forum=topic.forum):
-                return jsonify(status=550,
-                               message=_("You do not have the permissions to "
-                                         "unlock this topic."),
-                               category="danger")
-
-            topic.locked = False
-            topic.save()
-
-            data.append({
-                "id": topic.id,
-                "type": "unlock",
-                "reverse": "lock",
-                "reverse_name": _("Lock"),
-                "reverse_url": url_for("forum.lock_topic")
-            })
-
-        return jsonify(message="{} topics unlocked.".format(len(data)),
-                       category="success", data=data, status=200)
-
     topic = Topic.query.filter_by(id=topic_id).first_or_404()
+
     if not can_moderate(user=current_user, forum=topic.forum):
         flash(_("You do not have the permissions to unlock this topic."),
               "danger")
@@ -288,7 +226,6 @@ def unlock_topic(topic_id=None, slug=None):
     return redirect(topic.url)
 
 
-@forum.route("/topic/highlight", methods=["POST"])
 @forum.route("/topic/<int:topic_id>/highlight", methods=["POST"])
 @forum.route("/topic/<int:topic_id>-<slug>/highlight", methods=["POST"])
 @login_required
@@ -305,7 +242,6 @@ def highlight_topic(topic_id=None, slug=None):
     return redirect(topic.url)
 
 
-@forum.route("/topic/trivialize", methods=["POST"])
 @forum.route("/topic/<int:topic_id>/trivialize", methods=["POST"])
 @forum.route("/topic/<int:topic_id>-<slug>/trivialize", methods=["POST"])
 @login_required
@@ -323,57 +259,98 @@ def trivialize_topic(topic_id=None, slug=None):
     return redirect(topic.url)
 
 
-@forum.route("/topic/<int:topic_id>/move/<int:forum_id>", methods=["POST"])
-@forum.route(
-    "/topic/<int:topic_id>-<topic_slug>/move/<int:forum_id>-<forum_slug>",
-    methods=["POST"]
-)
+@forum.route("/forum/<int:forum_id>/edit", methods=["POST", "GET"])
+@forum.route("/forum/<int:forum_id>-<slug>/edit", methods=["POST", "GET"])
 @login_required
-def move_topic(topic_id, forum_id, topic_slug=None, forum_slug=None):
-    forum_instance = Forum.query.filter_by(id=forum_id).first_or_404()
-    topic = Topic.query.filter_by(id=topic_id).first_or_404()
+def manage_forum(forum_id, slug=None):
+    page = request.args.get('page', 1, type=int)
 
-    # TODO: Bulk move
+    forum_instance, forumsread = Forum.get_forum(forum_id=forum_id,
+                                                 user=current_user)
 
-    if not can_moderate(user=current_user, forum=topic.forum):
-        flash(_("You do not have the permissions to move this topic."),
+    # remove the current forum from the select field (move).
+    available_forums = Forum.query.order_by(Forum.position).all()
+    available_forums.remove(forum_instance)
+
+    if not can_moderate(current_user, forum=forum_instance):
+        flash(_("You do not have the permissions to moderate this forum."),
               "danger")
         return redirect(forum_instance.url)
 
-    if not topic.move(forum_instance):
-        flash(_("Could not move the topic to forum %(title)s.",
-                title=forum_instance.title), "danger")
-        return redirect(topic.url)
-
-    flash(_("Topic was moved to forum %(title)s.",
-            title=forum_instance.title), "success")
-    return redirect(topic.url)
-
-
-@forum.route("/topic/<int:old_id>/merge/<int:new_id>", methods=["POST"])
-@forum.route(
-    "/topic/<int:old_id>-<old_slug>/merge/<int:new_id>-<new_slug>",
-    methods=["POST"]
-)
-@login_required
-def merge_topic(old_id, new_id, old_slug=None, new_slug=None):
-    _old_topic = Topic.query.filter_by(id=old_id).first_or_404()
-    _new_topic = Topic.query.filter_by(id=new_id).first_or_404()
+    if forum_instance.external:
+        return redirect(forum_instance.external)
 
-    # TODO: Bulk merge
+    topics = Forum.get_topics(
+        forum_id=forum_instance.id, user=current_user, page=page,
+        per_page=flaskbb_config["TOPICS_PER_PAGE"]
+    )
 
-    # Looks to me that the user should have permissions on both forums, right?
-    if not can_moderate(user=current_user, forum=_old_topic.forum):
-        flash(_("You do not have the permissions to merge this topic."),
-              "danger")
-        return redirect(_old_topic.url)
+    mod_forum_url = url_for("forum.manage_forum", forum_id=forum_instance.id,
+                            slug=forum_instance.slug)
 
-    if not _old_topic.merge(_new_topic):
-        flash(_("Could not merge the topics."), "danger")
-        return redirect(_old_topic.url)
+    # the code is kind of the same here but it somehow still looks cleaner than
+    # doin some magic
+    if request.method == "POST":
+        ids = request.form.getlist("rowid")
+        tmp_topics = Topic.query.filter(Topic.id.in_(ids)).all()
+
+        # locking/unlocking
+        if "lock" in request.form:
+            changed = do_topic_action(topics=tmp_topics, user=current_user,
+                                      action="locked", reverse=False)
+
+            flash(_("%(count)s Topics locked.", count=changed), "success")
+            return redirect(mod_forum_url)
+
+        elif "unlock" in request.form:
+            changed = do_topic_action(topics=tmp_topics, user=current_user,
+                                      action="locked", reverse=True)
+            flash(_("%(count)s Topics unlocked.", count=changed), "success")
+            return redirect(mod_forum_url)
+
+        # highlighting/trivializing
+        elif "highlight" in request.form:
+            changed = do_topic_action(topics=tmp_topics, user=current_user,
+                                      action="important", reverse=False)
+            flash(_("%(count)s Topics highlighted.", count=changed), "success")
+            return redirect(mod_forum_url)
+
+        elif "trivialize" in request.form:
+            changed = do_topic_action(topics=tmp_topics, user=current_user,
+                                      action="important", reverse=True)
+            flash(_("%(count)s Topics trivialized.", count=changed), "success")
+            return redirect(mod_forum_url)
+
+        # deleting
+        elif "delete" in request.form:
+            changed = do_topic_action(topics=tmp_topics, user=current_user,
+                                      action="delete", reverse=False)
+            flash(_("%(count)s Topics deleted.", count=changed), "success")
+            return redirect(mod_forum_url)
+
+        # moving
+        elif "move" in request.form:
+            new_forum_id = request.form.get("forum")
+
+            if not new_forum_id:
+                flash(_("Please choose a new forum for the topics."), "info")
+                return redirect(mod_forum_url)
+
+            new_forum = Forum.query.filter_by(id=new_forum_id).first_or_404()
+            # check the permission in the current forum and in the new forum
+            if not can_moderate(current_user, forum_instance) or \
+                    not can_moderate(current_user, new_forum):
+                flash(_("You do not have the permissions to move this topic."),
+                      "danger")
+                return redirect(mod_forum_url)
+
+            new_forum.move_topics_to(tmp_topics)
+            return redirect(mod_forum_url)
 
-    flash(_("Topics succesfully merged."), "success")
-    return redirect(_new_topic.url)
+    return render_template(
+        "forum/edit_forum.html", forum=forum_instance, topics=topics,
+        available_forums=available_forums, forumsread=forumsread,
+    )
 
 
 @forum.route("/topic/<int:topic_id>/post/new", methods=["POST", "GET"])
@@ -436,7 +413,8 @@ def edit_post(post_id):
     post = Post.query.filter_by(id=post_id).first_or_404()
 
     if not can_edit_post(user=current_user, post=post):
-        flash(_("You do not have the permissions to edit this post."), "danger")
+        flash(_("You do not have the permissions to edit this post."),
+              "danger")
         return redirect(post.topic.url)
 
     form = ReplyForm()
@@ -502,7 +480,6 @@ def raw_post(post_id):
     return format_quote(username=post.username, content=post.content)
 
 
-@forum.route("/markread", methods=["POST"])
 @forum.route("/<int:forum_id>/markread", methods=["POST"])
 @forum.route("/<int:forum_id>-<slug>/markread", methods=["POST"])
 @login_required

+ 3 - 22
flaskbb/management/models.py

@@ -14,9 +14,10 @@ from flask_wtf import Form
 
 from flaskbb._compat import max_integer, text_type, iteritems
 from flaskbb.extensions import db, cache
+from flaskbb.utils.database import CRUDMixin
 
 
-class SettingsGroup(db.Model):
+class SettingsGroup(db.Model, CRUDMixin):
     __tablename__ = "settingsgroup"
 
     key = db.Column(db.String(255), primary_key=True)
@@ -25,18 +26,8 @@ class SettingsGroup(db.Model):
     settings = db.relationship("Setting", lazy="dynamic", backref="group",
                                cascade="all, delete-orphan")
 
-    def save(self):
-        """Saves a settingsgroup."""
-        db.session.add(self)
-        db.session.commit()
 
-    def delete(self):
-        """Deletes a settingsgroup."""
-        db.session.delete(self)
-        db.session.commit()
-
-
-class Setting(db.Model):
+class Setting(db.Model, CRUDMixin):
     __tablename__ = "settings"
 
     key = db.Column(db.String(255), primary_key=True)
@@ -244,13 +235,3 @@ class Setting(db.Model):
     def invalidate_cache(cls):
         """Invalidates this objects cached metadata."""
         cache.delete_memoized(cls.as_dict, cls)
-
-    def save(self):
-        """Saves a setting"""
-        db.session.add(self)
-        db.session.commit()
-
-    def delete(self):
-        """Deletes a setting"""
-        db.session.delete(self)
-        db.session.commit()

+ 3 - 17
flaskbb/message/models.py

@@ -13,9 +13,9 @@ from datetime import datetime
 from sqlalchemy_utils import UUIDType
 
 from flaskbb.extensions import db
+from flaskbb.utils.database import CRUDMixin
 
-
-class Conversation(db.Model):
+class Conversation(db.Model, CRUDMixin):
     __tablename__ = "conversations"
 
     id = db.Column(db.Integer, primary_key=True)
@@ -73,15 +73,8 @@ class Conversation(db.Model):
         db.session.commit()
         return self
 
-    def delete(self):
-        """Deletes a private message"""
-
-        db.session.delete(self)
-        db.session.commit()
-        return self
-
 
-class Message(db.Model):
+class Message(db.Model, CRUDMixin):
     __tablename__ = "messages"
 
     id = db.Column(db.Integer, primary_key=True)
@@ -109,10 +102,3 @@ class Message(db.Model):
         db.session.add(self)
         db.session.commit()
         return self
-
-    def delete(self):
-        """Deletes a private message"""
-
-        db.session.delete(self)
-        db.session.commit()
-        return self

+ 1 - 1
flaskbb/static/css/flaskbb.css

@@ -11,7 +11,7 @@ body {
 }
 
 .forum-moderation, .forum-selectall, .forum-select {
-    display: none;
+
 }
 
 .footer {

+ 0 - 15
flaskbb/static/js/flaskbb.js

@@ -21,11 +21,6 @@ var show_management_search = function() {
     });
 };
 
-var show_moderation_tools = function() {
-
-
-};
-
 var flash_message = function(message) {
     var container = $('#flashed-messages');
 
@@ -109,16 +104,6 @@ var send_data = function(endpoint_url, data) {
 };
 
 $(document).ready(function () {
-    // TODO: Refactor
-
-    $('#toggle-moderation-tools').click(function (event) {
-        event.preventDefault();
-
-        $('.forum-moderation').toggle();
-        $('.forum-selectall').toggle();
-        $('.forum-select').toggle();
-    });
-
     // listen on the action-checkall checkbox to un/check all
     $('.action-checkall').change(function() {
         $('input.action-checkbox').prop('checked', this.checked);

+ 161 - 0
flaskbb/templates/forum/edit_forum.html

@@ -0,0 +1,161 @@
+    {% set page_title = forum.title %}
+    {% set active_forum_nav=True %}
+
+    {% extends theme("layout.html") %}
+    {% block content %}
+    {% from theme('macros.html') import render_pagination, topic_pages %}
+
+    <ol class="breadcrumb">
+        <li><a href="{{ url_for('forum.index') }}">{% trans %}Forum{% endtrans %}</a></li>
+        <li><a href="{{ forum.category.url }}">{{ forum.category.title }}</a></li>
+        <li class="active">{{ forum.title }}</li>
+    </ol>
+
+    <div class="pull-left">
+        {{ render_pagination(topics, forum.url) }}
+    </div> <!-- end span pagination -->
+
+    <form method="post">
+    <div style="display:none;"><input id="csrf_token" name="csrf_token" type="hidden" value="{{ csrf_token() }}"></div>
+
+    <table class="table table-bordered">
+        <thead>
+            <tr>
+                <th colspan="6">
+                    {{ forum.title }}
+                </th>
+            </tr>
+        </thead>
+
+        <tbody>
+            <tr>
+                <td colspan="2">{% trans %}Topic{% endtrans %}</td>
+
+                <td>{% trans %}Posts{% endtrans %}</td>
+
+                <td>{% trans %}Views{% endtrans %}</td>
+
+                <td>{% trans %}Last Post{% endtrans %}</td>
+
+                <td><input type="checkbox" name="rowtoggle" class="action-checkall" title="Select All"/></td>
+            </tr>
+
+            {% for topic, topicread in topics.items %}
+            <tr>
+                <td class="topic-status" width="4%" style="vertical-align: middle; text-align: center;">
+                {% if topic.locked %}
+                    <span class="fa fa-lock" style="font-size: 2em"></span>
+                {% elif topic.important %}
+                    {% if topic|topic_is_unread(topicread, current_user, forumsread) %}
+                        <span class="fa fa-star" style="font-size: 2em"></span>
+                    {% else %}
+                        <span class="fa fa-star-o" style="font-size: 2em"></span>
+                    {% endif %}
+                {% else %}
+                    {% if topic|topic_is_unread(topicread, current_user, forumsread) %}
+                        <span class="fa fa-comment" style="font-size: 2em"></span>
+                    {% else %}
+                        <span class="fa fa-comment-o" style="font-size: 2em"></span>
+                    {% endif %}
+                {% endif %}
+
+                </td>
+                <td>
+                    <div>
+                        <a href="{{ topic.url }}">{{ topic.title }}</a>
+                        <!-- Topic Pagination -->
+                        {{ topic_pages(topic, flaskbb_config["POSTS_PER_PAGE"]) }}
+                        <br />
+                        <small>
+                            {% trans %}by{% endtrans %}
+                            {% if topic.user_id %}
+                             <a href="{{ topic.user.url }}">{{ topic.user.username }}</a>
+                            {% else %}
+                            {{ topic.username }}
+                            {% endif %}
+                        </small>
+                    </div>
+                </td>
+                <td>
+                    {{ topic.post_count }}
+                </td>
+                <td>
+                    {{ topic.views }}
+                </td>
+                <td>
+                    <a href="{{ topic.last_post.url }}">{{ topic.last_post.date_created|time_since }}</a><br />
+
+                    <small>
+                        {% trans %}by{% endtrans %}
+                        {% if topic.last_post.user_id %}
+                        <a href="{{ topic.last_post.user.url }}">{{ topic.last_post.user.username }}</a>
+                        {% else %}
+                        {{ topic.last_post.username }}
+                        {% endif %}
+                    </small>
+                </td>
+                <td><input type="checkbox" name="rowid" class="action-checkbox" value="{{ topic.id }}" title="Select Topic"/></td>
+            </tr>
+            {% else %}
+            <tr>
+                <td colspan="6">
+                    {% trans %}No Topics.{% endtrans %}
+                </td>
+            </tr>
+            {% endfor %}
+
+        </tbody>
+    </table>
+
+    <div class="row">
+        <div class="col-md-2">
+            <div class="pull-left">
+                <a class="btn btn-default" href="{{ forum.url }}">
+                    <span class="fa fa-arrow-left"></span> {% trans %}Back{% endtrans %}
+                </a>
+            </div>
+        </div>
+
+        <div class="col-md-10">
+            <div class="pull-right">
+                <div class="form-group">
+                <div class="btn-group" role="group">
+                    <button name="lock" class="btn btn-warning">
+                        <span class="fa fa-lock"></span> {% trans %}Lock{% endtrans %}
+                    </button>
+                    <button name="unlock" class="btn btn-warning">
+                        <span class="fa fa-unlock"></span> {% trans %}Unlock{% endtrans %}
+                    </button>
+                </div>
+                <div class="btn-group" role="group">
+                    <button name="highlight" class="btn btn-success">
+                        <span class="fa fa-star"></span> {% trans %}Highlight{% endtrans %}
+                    </button>
+                    <button name="trivialize" class="btn btn-success">
+                        <span class="fa fa-star-o"></span> {% trans %}Trivialize{% endtrans %}
+                    </button>
+                </div>
+
+                <button name="delete" class="btn btn-danger">
+                    <span class="fa fa-trash-o"></span> {% trans %}Delete{% endtrans %}
+                </button>
+                </div>
+
+                <div class="form-group row">
+                    <div class="col-sm-5">
+                        <select class="form-control" id="forum" name="forum">
+                            <option selected value="">{% trans %}Move to...{% endtrans %}</option>
+                            {% for forum in available_forums %}
+                                <option value={{forum.id}}>{{ forum.title }}</option>
+                            {% endfor %}
+                        </select>
+                    </div> <!-- end div_class -->
+                    <button name="move" class="btn btn-info">
+                        <span class="fa fa-plane"></span> {% trans %}Move{% endtrans %}
+                    </button>
+                </div> <!-- end form-group -->
+            </div>
+        </div>
+    </div>
+    </form>
+    {% endblock %}

+ 4 - 47
flaskbb/templates/forum/forum.html

@@ -39,7 +39,7 @@
 <table class="table table-bordered">
     <thead>
         <tr>
-            <th colspan="{% if current_user|can_moderate(forum) %}6{% else %}5{% endif %}">
+            <th colspan="5">
                 {{ forum.title }}
             </th>
         </tr>
@@ -54,10 +54,6 @@
             <td>{% trans %}Views{% endtrans %}</td>
 
             <td>{% trans %}Last Post{% endtrans %}</td>
-
-            {% if current_user|can_moderate(forum) %}
-            <td class="forum-selectall"><input type="checkbox" name="rowtoggle" class="action-checkall" title="Select All"/></td>
-            {% endif %}
         </tr>
 
         {% for topic, topicread in topics.items %}
@@ -114,9 +110,6 @@
                     {% endif %}
                 </small>
             </td>
-            {% if current_user|can_moderate(forum) %}
-            <td class="forum-select"><input type="checkbox" name="rowid" class="action-checkbox" value="{{ topic.id }}" title="Select Topic"/></td>
-            {% endif %}
         </tr>
         {% else %}
         <tr>
@@ -129,45 +122,9 @@
     </tbody>
 </table>
 
-{% if current_user|can_moderate(forum) %}
-<button id="toggle-moderation-tools" class="btn btn-default">Toggle Moderation Tools</button>
-
-<div class="forum-moderation pull-right">
-    <div class="btn-group" role="group" aria-label="...">
-        <a class="btn btn-warning" href="javascript:void(0)" onclick="return bulk_actions.execute('{{ url_for('forum.lock_topic') }}', '{% trans %}Are you sure you want to lock these Topics?{% endtrans %}')">
-            <span class="fa fa-lock"></span> {% trans %}Lock{% endtrans %}
-        </a>
-        <a class="btn btn-warning" href="javascript:void(0)" onclick="return bulk_actions.execute('{{ url_for('forum.unlock_topic') }}', '{% trans %}Are you sure you want to lock these Topics?{% endtrans %}')">
-            <span class="fa fa-unlock"></span> {% trans %}Unlock{% endtrans %}
-        </a>
-    </div>
-    <div class="btn-group" role="group" aria-label="...">
-        <a class="btn btn-success" href="javascript:void(0)" onclick="return bulk_actions.execute('{{ url_for('forum.highlight_topic') }}', '{% trans %}Are you sure you want to highlight these Topics?{% endtrans %}')">
-            <span class="fa fa-star"></span> {% trans %}Highlight{% endtrans %}
-        </a>
-        <a class="btn btn-success" href="javascript:void(0)" onclick="return bulk_actions.execute('{{ url_for('forum.trivialize_topic') }}', '{% trans %}Are you sure you want to trivialize these Topics?{% endtrans %}')">
-            <span class="fa fa-star-o"></span> {% trans %}Trivialize{% endtrans %}
-        </a>
-    </div>
-
-    <a class="btn btn-danger" href="javascript:void(0)" onclick="return bulk_actions.execute('{{ url_for('forum.delete_topic') }}', '{% trans %}Are you sure you want to trivialize these Topics?{% endtrans %}')">
-        <span class="fa fa-trash-o"></span> {% trans %}Delete{% endtrans %}
+<div class="pull-left">
+    <a class="btn btn-default" href="{{ url_for('forum.manage_forum', forum_id=forum.id, slug=forum.slug) }}">
+        <span class="fa fa-tasks"></span> {% trans %}Moderation Mode{% endtrans %}
     </a>
-
-    <button class="btn btn-info">
-        <span class="fa fa-plane"></span> {% trans %}Move{% endtrans %}
-    </button>
 </div>
-{% endif %}
-
-{% endblock %}
-
-{% block scripts %}
-    <script>
-    var bulk_actions = new BulkActions();
-
-    $(function () {
-        $('[data-toggle="tooltip"]').tooltip()
-    })
-    </script>
 {% endblock %}

+ 3 - 14
flaskbb/user/models.py

@@ -19,6 +19,7 @@ from flask_login import UserMixin, AnonymousUserMixin
 from flaskbb._compat import max_integer
 from flaskbb.extensions import db, cache
 from flaskbb.utils.settings import flaskbb_config
+from flaskbb.utils.database import CRUDMixin
 from flaskbb.forum.models import (Post, Topic, topictracker, TopicsRead,
                                   ForumsRead)
 from flaskbb.message.models import Conversation
@@ -30,7 +31,7 @@ groups_users = db.Table(
     db.Column('group_id', db.Integer(), db.ForeignKey('groups.id')))
 
 
-class Group(db.Model):
+class Group(db.Model, CRUDMixin):
     __tablename__ = "groups"
 
     id = db.Column(db.Integer, primary_key=True)
@@ -62,20 +63,8 @@ class Group(db.Model):
         """
         return "<{} {}>".format(self.__class__.__name__, self.id)
 
-    def save(self):
-        """Saves a group"""
-        db.session.add(self)
-        db.session.commit()
-        return self
-
-    def delete(self):
-        """Deletes a group"""
-        db.session.delete(self)
-        db.session.commit()
-        return self
-
 
-class User(db.Model, UserMixin):
+class User(db.Model, UserMixin, CRUDMixin):
     __tablename__ = "users"
     __searchable__ = ['username', 'email']
 

+ 28 - 0
flaskbb/utils/database.py

@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.utils.database
+    ~~~~~~~~~~~~~~~~~~~~~~
+
+    Some database helpers such as a CRUD mixin.
+
+    :copyright: (c) 2015 by the FlaskBB Team.
+    :license: BSD, see LICENSE for more details.
+"""
+from flaskbb.extensions import db
+
+
+class CRUDMixin(object):
+    def __repr__(self):
+        return "<{}>".format(self.__class__.__name__)
+
+    def save(self):
+        """Saves the object to the database."""
+        db.session.add(self)
+        db.session.commit()
+        return self
+
+    def delete(self):
+        """Delete the object from the database."""
+        db.session.delete(self)
+        db.session.commit()
+        return self

+ 48 - 2
flaskbb/utils/helpers.py

@@ -17,17 +17,19 @@ from io import BytesIO
 from datetime import datetime, timedelta
 
 import requests
-from flask import session, url_for
+import unidecode
+from flask import session, url_for, flash
 from babel.dates import format_timedelta
+from flask_babelex import lazy_gettext as _
 from flask_themes2 import render_theme_template
 from flask_login import current_user
-import unidecode
 
 from flaskbb._compat import range_method, text_type
 from flaskbb.extensions import redis_store
 from flaskbb.utils.settings import flaskbb_config
 from flaskbb.utils.markup import markdown
 
+
 _punct_re = re.compile(r'[\t !"#$%&\'()*\-/<=>?@\[\\\]^_`{|},.]+')
 
 
@@ -57,6 +59,50 @@ def render_template(template, **context):  # pragma: no cover
     return render_theme_template(theme, template, **context)
 
 
+def do_topic_action(topics, user, action, reverse):
+    """Executes a specific action for topics. Returns a list with the modified
+    topic objects.
+
+    :param topics: A iterable with ``Topic`` objects.
+    :param user: The user object which wants to perform the action.
+    :param action: One of the following actions: locked, important and delete.
+    :param reverse: If the action should be done in a reversed way.
+                    For example, to unlock a topic, ``reverse`` should be
+                    set to ``True``.
+    """
+    from flaskbb.utils.permissions import can_moderate, can_delete_topic
+    from flaskbb.user.models import User
+    from flaskbb.forum.models import Post
+
+    modified_topics = 0
+    if action != "delete":
+        for topic in topics:
+            if not can_moderate(user, topic.forum):
+                flash(_("You do not have the permissions to execute this "
+                        "action."), "danger")
+                return False
+
+            if getattr(topic, action) and not reverse:
+                continue
+
+            setattr(topic, action, not reverse)
+            modified_topics += 1
+            topic.save()
+    elif action == "delete":
+        for topic in topics:
+            if not can_delete_topic(user, topic):
+                flash(_("You do not have the permissions to delete this "
+                        "topic."), "danger")
+                return False
+
+            involved_users = User.query.filter(Post.topic_id == topic.id,
+                                               User.id == Post.user_id).all()
+            modified_topics += 1
+            topic.delete(involved_users)
+
+    return modified_topics
+
+
 def get_categories_and_forums(query_result, user):
     """Returns a list with categories. Every category has a list for all
     their associated forums.