Browse Source

Merge remote-tracking branch 'origin/master' into Improve-Logging

# Conflicts:
#	flaskbb/utils/requirements.py
Alec Nikolas Reiter 7 years ago
parent
commit
fe7fb310a7

+ 1 - 1
MANIFEST.in

@@ -1,4 +1,4 @@
-include LICENSE AUTHORS CHANGES README.md celery_worker.py babel.cfg pytest.ini
+include LICENSE AUTHORS CHANGES README.md NOTICE celery_worker.py babel.cfg pytest.ini
 graft flaskbb
 graft tests
 graft migrations

+ 25 - 0
NOTICE

@@ -0,0 +1,25 @@
+FlaskBB includes code adapted from other Python libaries or resources, including:
+
+sqlalchemy-soft-delete (https://github.com/miguelgrinberg/sqlalchemy-soft-delete) license
+=========================================================================================
+MIT License
+
+Copyright (c) 2016 Miguel Grinberg
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 36 - 46
docs/installation.rst

@@ -19,11 +19,12 @@ use the package manager `pip`_ to install the dependencies for FlaskBB.
 
 Virtualenv Setup
 ~~~~~~~~~~~~~~~~
-
-The easiest way to install `virtualenv`_ and
+**Linux:** The easiest way to install `virtualenv`_ and
 `virtualenvwrapper`_ is, to use the package manager on your system (if you
 are running Linux) to install them.
 
+**Windows:** Take a look at the `flask`_ documentation (then skip ahead to dependencies).
+
 For example, on archlinux you can install them with::
 
     $ sudo pacman -S python2-virtualenvwrapper
@@ -33,7 +34,7 @@ or, on macOS, you can install them with::
     $ sudo pip install virtualenvwrapper
 
 It's sufficient to just install the virtualenvwrapper because it depends on
-virtualenv and the package manager will resolve all the dependncies for you.
+virtualenv and the package manager will resolve all the dependencies for you.
 
 After that, you can create your virtualenv with::
 
@@ -117,36 +118,38 @@ this is `pacman` and for Debian/Ubuntu based systems this is `apt-get`.
 Configuration
 -------------
 
-FlaskBB comes with the ability to generate the configuration file for you.
-Just run::
+Production / I Just Want to Try It
+~~~~~~~~~~
+
+FlaskBB already sets some sane defaults, so you shouldn't have to change much.
+To make this whole process a little bit easier for you, we have created
+a little wizard which will ask you some questions and with the answers
+you provide it will generate a configuration for you. You can of course
+further adjust the generated configuration.
+
+The setup wizard can be started with::
 
     flaskbb makeconfig
 
-and answer its questions. By default it will try to save the configuration
-file with the name ``flaskbb.cfg`` in FlaskBB's root folder.
 
-You can also omit the questions, which will generate a **developemnt**
-configuration by passing the ``-d/--development`` option to it::
+These are the only settings you have to make sure to setup accordingly if
+you want to run FlaskBB in production:
 
-    flaskbb makeconfig -d
+- ``SERVER_NAME = "example.org"``
+- ``PREFERRED_URL_SCHEME = "https"``
+- ``SQLALCHEMY_DATABASE_URI = 'sqlite:///path/to/flaskbb.sqlite'``
+- ``SECRET_KEY = "secret key"``
+- ``WTF_CSRF_SECRET_KEY = "secret key"``
 
-In previous versions, FlaskBB tried to assume which configuration file to use,
-which it will no longer do. Now, by default, it will load a config with
-some sane defaults (i.e. debug off) but thats it. You can either pass an
-import string to a config object or the path to the (python) config file.
+By default it will try to save the configuration file with the name flaskbb.cfg in FlaskBB’s root folder.
 
-For example, if you are using a generated config file it looks something
-like this::
+Finally to get going – fire up FlaskBB!
+::
 
     flaskbb --config flaskbb.cfg run
+    
     [+] Using config from: /path/to/flaskbb/flaskbb.cfg
-     * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
-
-and this is how you do it by using an import string. Be sure that it is
-importable from within FlaskBB::
-
-    flaskbb --config flaskbb.configs.default.DefaultConfig run
-
+    * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
 
 Development
 ~~~~~~~~~~~
@@ -155,6 +158,10 @@ To get started with development you have to generate a development
 configuration first. You can use the CLI for this,
 as explained in `Configuration <#configuration>`_::
 
+    flaskbb makeconfig -d
+
+or
+
     flaskbb makeconfig --development
 
 Now you can either use ``make`` to run the development server::
@@ -165,30 +172,10 @@ or if you like to type a little bit more, the CLI::
 
     flaskbb --config flaskbb.cfg run
 
+You can either pass an import string to the path to the (python) config file you’ve just created, or a default config object. (Most users will follow the example above, which uses the generated file).
+This is how you do it by using an import string. Be sure that it is importable from within FlaskBB:
 
-Production
-~~~~~~~~~~
-
-FlaskBB already sets some sane defaults, so you shouldn't have to change much.
-To make this whole process a little bit easier for you, we have created
-a little wizard which will ask you some questions and with the answers
-you provide it will generate a configuration for you. You can of course
-further adjust the generated configuration.
-
-The setup wizard can be started with::
-
-    flaskbb makeconfig
-
-
-These are the only settings you have to make sure to setup accordingly if
-you want to run FlaskBB in production:
-
-- ``SERVER_NAME = "example.org"``
-- ``PREFERRED_URL_SCHEME = "https"``
-- ``SQLALCHEMY_DATABASE_URI = 'sqlite:///path/to/flaskbb.sqlite'``
-- ``SECRET_KEY = "secret key"``
-- ``WTF_CSRF_SECRET_KEY = "secret key"``
-
+    flaskbb --config flaskbb.configs.default.DefaultConfig run
 
 Redis
 ~~~~~
@@ -256,6 +243,8 @@ Both methods are included in the example configs.
 Installation
 ------------
 
+**Sqlite users:** create a DB file in your project source.
+
 For a guided install, run::
 
     $ make install
@@ -526,3 +515,4 @@ to say
 .. _virtualenv: https://virtualenv.pypa.io/en/latest/installation.html
 .. _virtualenvwrapper: http://virtualenvwrapper.readthedocs.org/en/latest/install.html#basic-installation
 .. _pip: http://www.pip-installer.org/en/latest/installing.html
+.. _flask: http://flask.pocoo.org/docs/0.12/installation/ 

+ 1 - 1
flaskbb/app.py

@@ -253,7 +253,7 @@ def configure_context_processors(app):
         templates.
         """
 
-        return dict(flaskbb_config=flaskbb_config)
+        return dict(flaskbb_config=flaskbb_config, format_date=format_date)
 
 
 def configure_before_handlers(app):

+ 7 - 3
flaskbb/cli/main.py

@@ -453,12 +453,16 @@ def generate_config(development, output, force):
     if os.path.exists(config_path) and not os.path.isfile(config_path):
         config_path = os.path.join(config_path, "flaskbb.cfg")
 
+    # An override to handle database location paths on Windows environments
+    database_path = "sqlite:///" + os.path.join(os.path.dirname(current_app.instance_path), "flaskbb.sqlite")
+    if os.name == 'nt':
+        database_path = database_path.replace("\\", r"\\")
+
     default_conf = {
         "is_debug": True,
         "server_name": "localhost:5000",
         "url_scheme": "http",
-        "database_uri": "sqlite:///" + os.path.join(
-            os.path.dirname(current_app.root_path), "flaskbb.sqlite"),
+        "database_uri": database_path,
         "redis_enabled": False,
         "redis_uri": "redis://localhost:6379",
         "mail_server": "localhost",
@@ -510,7 +514,7 @@ def generate_config(development, output, force):
                 "For more options see the SQLAlchemy docs:\n"
                 "    http://docs.sqlalchemy.org/en/latest/core/engines.html",
                 fg="cyan")
-    default_conf["database_url"] = click.prompt(
+    default_conf["database_uri"] = click.prompt(
         click.style("Database URI", fg="magenta"),
         default=default_conf.get("database_uri"))
 

+ 54 - 10
flaskbb/extensions.py

@@ -8,25 +8,69 @@
     :copyright: (c) 2014 by the FlaskBB Team.
     :license: BSD, see LICENSE for more details.
 """
+from inspect import isclass
+
 from celery import Celery
+from flask_alembic import Alembic
 from flask_allows import Allows
-from flask_sqlalchemy import SQLAlchemy
-from flask_whooshee import Whooshee
-from flask_login import LoginManager
-from flask_mail import Mail
+from flask_babelplus import Babel
 from flask_caching import Cache
 from flask_debugtoolbar import DebugToolbarExtension
+from flask_limiter import Limiter
+from flask_limiter.util import get_remote_address
+from flask_login import LoginManager
+from flask_mail import Mail
+from flask_plugins import PluginManager
 from flask_redis import FlaskRedis
-from flask_alembic import Alembic
+from flask_sqlalchemy import BaseQuery, SQLAlchemy
 from flask_themes2 import Themes
-from flask_plugins import PluginManager
-from flask_babelplus import Babel
+from flask_whooshee import (DELETE_KWD, INSERT_KWD, UPDATE_KWD, Whooshee,
+                            WhoosheeQuery)
 from flask_wtf.csrf import CSRFProtect
-from flask_limiter import Limiter
-from flask_limiter.util import get_remote_address
+from sqlalchemy import event
+from sqlalchemy.orm import Query as SQLAQuery
+
 from flaskbb.exceptions import AuthorizationRequired
 
 
+class FlaskBBWhooshee(Whooshee):
+
+    def register_whoosheer(self, wh):
+        """This will register the given whoosher on `whoosheers`, create the
+        neccessary SQLAlchemy event listeners, replace the `query_class` with
+        our own query class which will provide the search functionality
+        and store the app on the whoosheer, so that we can always work
+        with that.
+        :param wh: The whoosher which should be registered.
+        """
+        self.whoosheers.append(wh)
+        for model in wh.models:
+            event.listen(model, 'after_{0}'.format(INSERT_KWD), self.after_insert)
+            event.listen(model, 'after_{0}'.format(UPDATE_KWD), self.after_update)
+            event.listen(model, 'after_{0}'.format(DELETE_KWD), self.after_delete)
+            query_class = getattr(model, 'query_class', None)
+
+            if query_class is not None and isclass(query_class):
+                # already a subclass, ignore it
+                if issubclass(query_class, self.query):
+                    pass
+
+                # ensure there can be a stable MRO
+                elif query_class not in (BaseQuery, SQLAQuery, WhoosheeQuery):
+                    query_class_name = query_class.__name__
+                    model.query_class = type(
+                        "Whooshee{}".format(query_class_name), (query_class, self.query), {}
+                    )
+                else:
+                    model.query_class = self.query
+            else:
+                model.query_class = self.query
+
+        if self.app:
+            wh.app = self.app
+        return wh
+
+
 # Permissions Manager
 allows = Allows(throws=AuthorizationRequired)
 
@@ -34,7 +78,7 @@ allows = Allows(throws=AuthorizationRequired)
 db = SQLAlchemy()
 
 # Whooshee (Full Text Search)
-whooshee = Whooshee()
+whooshee = FlaskBBWhooshee()
 
 # Login
 login_manager = LoginManager()

+ 12 - 0
flaskbb/fixtures/groups.py

@@ -27,6 +27,8 @@ fixture = OrderedDict((
         'postreply': True,
         'mod_edituser': True,
         'mod_banuser': True,
+        'viewhidden': True,
+        'makehidden': True,
     }),
     ('Super Moderator', {
         'description': 'The Super Moderator Group',
@@ -42,6 +44,8 @@ fixture = OrderedDict((
         'postreply': True,
         'mod_edituser': True,
         'mod_banuser': True,
+        'viewhidden': True,
+        'makehidden': True,
     }),
     ('Moderator', {
         'description': 'The Moderator Group',
@@ -57,6 +61,8 @@ fixture = OrderedDict((
         'postreply': True,
         'mod_edituser': True,
         'mod_banuser': True,
+        'viewhidden': True,
+        'makehidden': False,
     }),
     ('Member', {
         'description': 'The Member Group',
@@ -72,6 +78,8 @@ fixture = OrderedDict((
         'postreply': True,
         'mod_edituser': False,
         'mod_banuser': False,
+        'viewhidden': False,
+        'makehidden': False,
     }),
     ('Banned', {
         'description': 'The Banned Group',
@@ -87,6 +95,8 @@ fixture = OrderedDict((
         'postreply': False,
         'mod_edituser': False,
         'mod_banuser': False,
+        'viewhidden': False,
+        'makehidden': False,
     }),
     ('Guest', {
         'description': 'The Guest Group',
@@ -102,5 +112,7 @@ fixture = OrderedDict((
         'postreply': False,
         'mod_edituser': False,
         'mod_banuser': False,
+        'viewhidden': False,
+        'makehidden': False
     })
 ))

+ 55 - 0
flaskbb/forum/locals.py

@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.forum.locals
+    ~~~~~~~~~~~~~~~~~~~~
+    Thread local helpers for FlaskBB
+
+    :copyright: 2017, the FlaskBB Team
+    :license: BSD, see license for more details
+"""
+
+from flask import _request_ctx_stack, has_request_context, request
+from werkzeug.local import LocalProxy
+
+from .models import Category, Forum, Post, Topic
+
+
+@LocalProxy
+def current_post():
+    return _get_item(Post, 'post_id', 'post')
+
+
+@LocalProxy
+def current_topic():
+    if current_post:
+        return current_post.topic
+    return _get_item(Topic, 'topic_id', 'topic')
+
+
+@LocalProxy
+def current_forum():
+    if current_topic:
+        return current_topic.forum
+    return _get_item(Forum, 'forum_id', 'forum')
+
+
+@LocalProxy
+def current_category():
+    if current_forum:
+        return current_forum.category
+    return _get_item(Category, 'category_id', 'category')
+
+
+def _get_item(model, view_arg, name):
+    if (
+        has_request_context() and
+        not getattr(_request_ctx_stack.top, name, None) and
+        view_arg in request.view_args
+    ):
+        setattr(
+            _request_ctx_stack.top,
+            name,
+            model.query.filter_by(id=request.view_args[view_arg]).first()
+        )
+
+    return getattr(_request_ctx_stack.top, name, None)

+ 248 - 79
flaskbb/forum/models.py

@@ -11,13 +11,14 @@
 from datetime import timedelta
 import logging
 
-from flask import url_for, abort
+from flask import abort, url_for
 from sqlalchemy.orm import aliased
 
 from flaskbb.extensions import db
-from flaskbb.utils.helpers import (slugify, get_categories_and_forums,
-                                   get_forums, time_utcnow, topic_is_unread)
-from flaskbb.utils.database import CRUDMixin, UTCDateTime, make_comparable
+from flaskbb.utils.database import (CRUDMixin, HideableCRUDMixin, UTCDateTime,
+                                    make_comparable)
+from flaskbb.utils.helpers import (get_categories_and_forums, get_forums,
+                                   slugify, time_utcnow, topic_is_unread)
 from flaskbb.utils.settings import flaskbb_config
 
 
@@ -141,7 +142,7 @@ class Report(db.Model, CRUDMixin):
 
 
 @make_comparable
-class Post(db.Model, CRUDMixin):
+class Post(HideableCRUDMixin, db.Model):
     __tablename__ = "posts"
 
     id = db.Column(db.Integer, primary_key=True)
@@ -215,20 +216,21 @@ class Post(db.Model, CRUDMixin):
             self.topic = topic
             self.date_created = created
 
-            topic.last_updated = created
-            topic.last_post = self
+            if not topic.hidden:
+                topic.last_updated = created
+                topic.last_post = self
 
-            # Update the last post info for the forum
-            topic.forum.last_post = self
-            topic.forum.last_post_user = self.user
-            topic.forum.last_post_title = topic.title
-            topic.forum.last_post_username = user.username
-            topic.forum.last_post_created = created
+                # Update the last post info for the forum
+                topic.forum.last_post = self
+                topic.forum.last_post_user = self.user
+                topic.forum.last_post_title = topic.title
+                topic.forum.last_post_username = user.username
+                topic.forum.last_post_created = created
 
-            # Update the post counts
-            user.post_count += 1
-            topic.post_count += 1
-            topic.forum.post_count += 1
+                # Update the post counts
+                user.post_count += 1
+                topic.post_count += 1
+                topic.forum.post_count += 1
 
             # And commit it!
             db.session.add(topic)
@@ -242,26 +244,70 @@ class Post(db.Model, CRUDMixin):
             self.topic.delete()
             return self
 
-        # Delete the last post
+        self._deal_with_last_post()
+        self._update_counts()
+
+        db.session.delete(self)
+        db.session.commit()
+        return self
+
+    def hide(self, user):
+        if self.hidden:
+            return
+
+        if self.topic.first_post == self:
+            self.topic.hide(user)
+            return self
+
+        super(Post, self).hide(user)
+        self._deal_with_last_post()
+        self._update_counts()
+        db.session.commit()
+        return self
+
+    def unhide(self):
+        if not self.hidden:
+            return
+
+        if self.topic.first_post == self:
+            self.topic.unhide()
+            return self
+
+        self._restore_post_to_topic()
+        super(Post, self).unhide()
+        self._update_counts()
+        db.session.commit()
+        return self
+
+    def _deal_with_last_post(self):
         if self.topic.last_post == self:
 
             # update the last post in the forum
             if self.topic.last_post == self.topic.forum.last_post:
                 # We need the second last post in the forum here,
                 # because the last post will be deleted
-                second_last_post = Post.query.\
-                    filter(Post.topic_id == Topic.id,
-                           Topic.forum_id == self.topic.forum.id).\
-                    order_by(Post.id.desc()).limit(2).offset(0).\
-                    all()
-
-                # now lets update the second last post to the last post
-                last_post = second_last_post[1]
-                self.topic.forum.last_post = last_post
-                self.topic.forum.last_post_title = last_post.topic.title
-                self.topic.forum.last_post_user = last_post.user
-                self.topic.forum.last_post_username = last_post.username
-                self.topic.forum.last_post_created = last_post.date_created
+                second_last_post = Post.query.filter(
+                    Post.topic_id == Topic.id,
+                    Topic.forum_id == self.topic.forum.id,
+                    Post.hidden != True,
+                    Post.id != self.id
+                ).order_by(
+                    Post.id.desc()
+                ).limit(1).first()
+
+                if second_last_post:
+                    # now lets update the second last post to the last post
+                    self.topic.forum.last_post = second_last_post
+                    self.topic.forum.last_post_title = second_last_post.topic.title
+                    self.topic.forum.last_post_user = second_last_post.user
+                    self.topic.forum.last_post_username = second_last_post.username
+                    self.topic.forum.last_post_created = second_last_post.date_created
+                else:
+                    self.topic.forum.last_post = None
+                    self.topic.forum.last_post_title = None
+                    self.topic.forum.last_post_user = None
+                    self.topic.forum.last_post_username = None
+                    self.topic.forum.last_post_created = None
 
             # check if there is a second last post in this topic
             if self.topic.second_last_post:
@@ -275,18 +321,67 @@ class Post(db.Model, CRUDMixin):
 
             self.topic.last_updated = self.topic.last_post.date_created
 
+    def _update_counts(self):
+        if self.hidden:
+            clauses = [Post.hidden != True, Post.id != self.id]
+        else:
+            clauses = [db.or_(Post.hidden != True, Post.id == self.id)]
+
+        user_post_clauses = clauses + [
+            Post.user_id == self.user.id,
+            Topic.id == Post.topic_id,
+            Topic.hidden != True,
+        ]
+
         # Update the post counts
-        self.user.post_count -= 1
-        self.topic.post_count -= 1
-        self.topic.forum.post_count -= 1
+        self.user.post_count = Post.query.filter(*user_post_clauses).count()
 
-        db.session.delete(self)
-        db.session.commit()
-        return self
+        if self.topic.hidden:
+            self.topic.post_count = 0
+        else:
+            topic_post_clauses = clauses + [
+                Post.topic_id == self.topic.id,
+            ]
+            self.topic.post_count = Post.query.filter(*topic_post_clauses).count()
+
+        forum_post_clauses = clauses + [
+            Post.topic_id == Topic.id,
+            Topic.forum_id == self.topic.forum.id,
+            Topic.hidden != True,
+        ]
+
+        self.topic.forum.post_count = Post.query.filter(*forum_post_clauses).count()
+
+    def _restore_post_to_topic(self):
+        last_unhidden_post = Post.query.filter(
+            Post.topic_id == self.topic_id,
+            Post.id != self.id,
+            Post.hidden != True,
+        ).limit(1).first()
+
+        # should never be None, but deal with it anyways to be safe
+        if last_unhidden_post and self.date_created > last_unhidden_post.date_created:
+            self.topic.last_post = self
+            self.second_last_post = last_unhidden_post
+
+            # if we're the newest in the topic again, we might be the newest in the forum again
+            # only set if our parent topic isn't hidden
+            if (
+                not self.topic.hidden and
+                (
+                    not self.topic.forum.last_post or
+                    self.date_created > self.topic.forum.last_post.date_created
+                )
+            ):
+                self.topic.forum.last_post = self
+                self.topic.forum.last_post_title = self.topic.title
+                self.topic.forum.last_post_user = self.user
+                self.topic.forum.last_post_username = self.username
+                self.topic.forum.last_post_created = self.date_created
 
 
 @make_comparable
-class Topic(db.Model, CRUDMixin):
+class Topic(HideableCRUDMixin, db.Model):
     __tablename__ = "topics"
 
     id = db.Column(db.Integer, primary_key=True)
@@ -562,53 +657,125 @@ class Topic(db.Model, CRUDMixin):
 
         :param users: A list with user objects
         """
-        # Grab the second last topic in the forum + parents/childs
-        topic = Topic.query.\
-            filter_by(forum_id=self.forum_id).\
-            order_by(Topic.last_post_id.desc()).limit(2).offset(0).all()
+        forum = self.forum
+        db.session.delete(self)
+        self._fix_user_post_counts(users or self.involved_users().all())
+        self._fix_post_counts(forum)
+        db.session.commit()
+        return self
 
-        # do we want to delete the topic with the last post in the forum?
-        if topic and topic[0] == self:
-            try:
+    def hide(self, user, users=None):
+        """Soft deletes a topic from a forum
+        """
+        if self.hidden:
+            return
+
+        self._remove_topic_from_forum()
+        super(Topic, self).hide(user)
+        self._handle_first_post()
+        self._fix_user_post_counts(users or self.involved_users().all())
+        self._fix_post_counts(self.forum)
+        db.session.commit()
+        return self
+
+    def unhide(self, users=None):
+        """Restores a hidden topic to a forum
+        """
+        if not self.hidden:
+            return
+
+        super(Topic, self).unhide()
+        self._handle_first_post()
+        self._restore_topic_to_forum()
+        self._fix_user_post_counts(users or self.involved_users().all())
+        self.forum.recalculate()
+        db.session.commit()
+        return self
+
+    def _remove_topic_from_forum(self):
+        # Grab the second last topic in the forum + parents/childs
+        topics = Topic.query.filter(
+            Topic.forum_id == self.forum_id,
+            Topic.hidden != True
+        ).order_by(
+            Topic.last_post_id.desc()
+        ).limit(2).offset(0).all()
+
+        # do we want to replace the topic with the last post in the forum?
+        if len(topics) > 1:
+            if topics[0] == self:
                 # Now the second last post will be the last post
-                self.forum.last_post = topic[1].last_post
-                self.forum.last_post_title = topic[1].title
-                self.forum.last_post_user = topic[1].user
-                self.forum.last_post_username = topic[1].username
-                self.forum.last_post_created = topic[1].last_updated
-            # Catch an IndexError when you delete the last topic in the forum
-            # There is no second last post
-            except IndexError:
-                self.forum.last_post = None
-                self.forum.last_post_title = None
-                self.forum.last_post_user = None
-                self.forum.last_post_username = None
-                self.forum.last_post_created = None
-
-        # These things needs to be stored in a variable before they are deleted
-        forum = self.forum
+                self.forum.last_post = topics[1].last_post
+                self.forum.last_post_title = topics[1].title
+                self.forum.last_post_user = topics[1].user
+                self.forum.last_post_username = topics[1].username
+                self.forum.last_post_created = topics[1].last_updated
+        else:
+            self.forum.last_post = None
+            self.forum.last_post_title = None
+            self.forum.last_post_user = None
+            self.forum.last_post_username = None
+            self.forum.last_post_created = None
 
         TopicsRead.query.filter_by(topic_id=self.id).delete()
 
-        # Delete the topic
-        db.session.delete(self)
-
+    def _fix_user_post_counts(self, users=None):
         # Update the post counts
         if users:
             for user in users:
-                user.post_count = Post.query.filter_by(user_id=user.id).count()
+                user.post_count = Post.query.filter(
+                    Post.user_id == user.id,
+                    Topic.id == Post.topic_id,
+                    Topic.hidden != True,
+                    Post.hidden != True
+                ).count()
+
+    def _fix_post_counts(self, forum):
+        clauses = [
+            Topic.forum_id == forum.id
+        ]
+        if self.hidden:
+            clauses.extend([
+                Topic.id != self.id,
+                Topic.hidden != True,
+            ])
+        else:
+            clauses.append(db.or_(Topic.id == self.id, Topic.hidden != True))
 
-        forum.topic_count = Topic.query.\
-            filter_by(forum_id=self.forum_id).\
-            count()
+        forum.topic_count = Topic.query.filter(*clauses).count()
 
-        forum.post_count = Post.query.\
-            filter(Post.topic_id == Topic.id,
-                   Topic.forum_id == self.forum_id).\
-            count()
+        post_count = clauses + [
+            Post.topic_id == Topic.id,
+        ]
 
-        db.session.commit()
-        return self
+        if self.hidden:
+            post_count.append(Post.hidden != True)
+        else:
+            post_count.append(db.or_(Post.hidden != True, Post.id == self.first_post.id))
+
+        forum.post_count = Post.query.distinct().filter(*post_count).count()
+
+    def _restore_topic_to_forum(self):
+        if self.forum.last_post is None or self.forum.last_post_created < self.last_updated:
+            self.forum.last_post = self.last_post
+            self.forum.last_post_title = self.title
+            self.forum.last_post_user = self.user
+            self.forum.last_post_username = self.username
+            self.forum.last_post_created = self.last_updated
+
+    def _handle_first_post(self):
+        # have to do this specially because otherwise we start recurisve calls
+        self.first_post.hidden = self.hidden
+        self.first_post.hidden_by = self.hidden_by
+        self.first_post.hidden_at = self.hidden_at
+
+    def involved_users(self):
+        """
+        Returns a query of all users involved in the topic
+        """
+        # todo: Find circular import and break it
+        from flaskbb.user.models import User
+        return User.query.distinct().filter(Post.topic_id == self.id, User.id == Post.user_id)
 
 
 @make_comparable
@@ -803,11 +970,13 @@ class Forum(db.Model, CRUDMixin):
         :param last_post: If set to ``True`` it will also try to update
                           the last post columns in the forum.
         """
-        topic_count = Topic.query.filter_by(forum_id=self.id).count()
-        post_count = Post.query.\
-            filter(Post.topic_id == Topic.id,
-                   Topic.forum_id == self.id).\
-            count()
+        topic_count = Topic.query.filter(Topic.forum_id == self.id, Topic.hidden != True).count()
+        post_count = Post.query.filter(
+            Post.topic_id == Topic.id,
+            Topic.forum_id == self.id,
+            Post.hidden != True,
+            Topic.hidden != True
+        ).count()
         self.topic_count = topic_count
         self.post_count = post_count
 

+ 109 - 1
flaskbb/forum/views.py

@@ -32,7 +32,7 @@ from flaskbb.utils.helpers import (do_topic_action, format_quote,
 from flaskbb.utils.requirements import (CanAccessForum, CanAccessTopic,
                                         CanDeletePost, CanDeleteTopic,
                                         CanEditPost, CanPostReply,
-                                        CanPostTopic,
+                                        CanPostTopic, Has,
                                         IsAtleastModeratorInForum)
 from flaskbb.utils.settings import flaskbb_config
 
@@ -317,6 +317,22 @@ class ManageForum(MethodView):
                 return redirect(mod_forum_url)
 
             new_forum.move_topics_to(tmp_topics)
+
+        # hiding/unhiding
+        elif "hide" in request.form:
+            changed = do_topic_action(
+                topics=tmp_topics, user=real(current_user), action="hide", reverse=False
+            )
+            flash(_("%(count)s topics hidden.", count=changed), "success")
+            return redirect(mod_forum_url)
+
+        elif "unhide" in request.form:
+            changed = do_topic_action(
+                topics=tmp_topics, user=real(current_user), action="unhide", reverse=False
+            )
+            flash(_("%(count)s topics unhidden.", count=changed), "success")
+            return redirect(mod_forum_url)
+
         else:
             flash(_('Unknown action requested'), 'danger')
             return redirect(mod_forum_url)
@@ -671,6 +687,84 @@ class UntrackTopic(MethodView):
         return redirect(topic.url)
 
 
+class HideTopic(MethodView):
+    decorators = [login_required]
+
+    def post(self, topic_id, slug=None):
+        topic = Topic.query.with_hidden().filter_by(id=topic_id).first_or_404()
+        if not Permission(Has('makehidden'), IsAtleastModeratorInForum(forum=topic.forum)):
+            flash(_("You do not have permission to hide this topic"), "danger")
+            return redirect(topic.url)
+        topic.hide(user=current_user)
+        topic.save()
+
+        if Permission(Has('viewhidden')):
+            return redirect(topic.url)
+        return redirect(topic.forum.url)
+
+
+class UnhideTopic(MethodView):
+    decorators = [login_required]
+
+    def post(self, topic_id, slug=None):
+        topic = Topic.query.filter_by(id=topic_id).first_or_404()
+        if not Permission(Has('makehidden'), IsAtleastModeratorInForum(forum=topic.forum)):
+            flash(_("You do not have permission to unhide this topic"), "danger")
+            return redirect(topic.url)
+        topic.unhide()
+        topic.save()
+        return redirect(topic.url)
+
+
+class HidePost(MethodView):
+    decorators = [login_required]
+
+    def post(self, post_id):
+        post = Post.query.filter(Post.id == post_id).first_or_404()
+
+        if not Permission(Has('makehidden'), IsAtleastModeratorInForum(forum=post.topic.forum)):
+            flash(_("You do not have permission to hide this post"), "danger")
+            return redirect(post.topic.url)
+
+        if post.hidden:
+            flash(_("Post is already hidden"), "warning")
+            return redirect(post.topic.url)
+
+        first_post = post.first_post
+
+        post.hide(current_user)
+        post.save()
+
+        if first_post:
+            flash(_("Topic hidden"), "success")
+        else:
+            flash(_("Post hidden"), "success")
+
+        if post.first_post and not Permission(Has("viewhidden")):
+            return redirect(post.topic.forum.url)
+        return redirect(post.topic.url)
+
+
+class UnhidePost(MethodView):
+    decorators = [login_required]
+
+    def post(self, post_id):
+        post = Post.query.filter(Post.id == post_id).first_or_404()
+
+        if not Permission(Has('makehidden'), IsAtleastModeratorInForum(forum=post.topic.forum)):
+            flash(_("You do not have permission to unhide this post"), "danger")
+            return redirect(post.topic.url)
+
+        if not post.hidden:
+            flash(_("Post is already unhidden"), "warning")
+            redirect(post.topic.url)
+
+        post.unhide()
+        post.save()
+        flash(_("Post unhidden"), "success")
+        return redirect(post.topic.url)
+
+
 register_view(
     forum,
     routes=['/category/<int:category_id>', '/category/<int:category_id>-<slug>'],
@@ -760,3 +854,17 @@ register_view(
 register_view(forum, routes=['/topictracker'], view_func=TopicTracker.as_view('topictracker'))
 register_view(forum, routes=['/'], view_func=ForumIndex.as_view('index'))
 register_view(forum, routes=['/who-is-online'], view_func=WhoIsOnline.as_view('who_is_online'))
+register_view(
+    forum,
+    routes=["/topic/<int:topic_id>/hide", "/topic/<int:topic_id>-<slug>/hide"],
+    view_func=HideTopic.as_view('hide_topic')
+)
+register_view(
+    forum,
+    routes=["/topic/<int:topic_id>/unhide", "/topic/<int:topic_id>-<slug>/unhide"],
+    view_func=UnhideTopic.as_view('unhide_topic')
+)
+register_view(forum, routes=["/post/<int:post_id>/hide"], view_func=HidePost.as_view('hide_post'))
+register_view(
+    forum, routes=["/post/<int:post_id>/unhide"], view_func=UnhidePost.as_view('unhide_post')
+)

+ 11 - 0
flaskbb/management/forms.py

@@ -222,6 +222,17 @@ class GroupForm(FlaskForm):
         description=_("Allow moderators to ban other users.")
     )
 
+    viewhidden = BooleanField(
+        _("Can view hidden posts and topics"),
+        description=_("Allows a user to view hidden posts and topics"),
+    )
+
+    makehidden = BooleanField(
+        _("Can hide posts and topics"),
+        description=_("Allows a user to hide posts and topics"),
+    )
+
+
     submit = SubmitField(_("Save"))
 
     def validate_name(self, field):

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

@@ -145,6 +145,17 @@
                             <span class="fa fa-trash-o fa-fw"></span> {% trans %}Delete{% endtrans %}
                         </button>
 
+                        {% if current_user.permissions.get('makehidden') %}
+                        <div class="btn-group" role="group">
+                            <button name="hide" class="btn btn-info">
+                                <span class="fa fa-user-secret fa-fw"></span> {% trans %}Hide{% endtrans %}
+                            </button>
+                            <button name="unhide" class="btn btn-info">
+                                <span class="fa fa-user fa-fw"></span> {% trans %}Unhide{% endtrans %}
+                            </button>
+                        </div>
+                        {% endif %}
+
                     </div>
                 </div>
             </div>

+ 8 - 0
flaskbb/templates/forum/forum.html

@@ -60,6 +60,8 @@
                         <div class="col-md-1 col-sm-2 col-xs-2 topic-status">
                         {% if topic.locked %}
                             <span class="fa fa-lock fa-fw topic-locked"></span>
+                        {% elif topic.hidden %}
+                            <span class="fa fa-user-secret"></span>
                         {% elif topic.important %}
                             {% if topic|topic_is_unread(topicread, current_user, forumsread) %}
                                 <span class="fa fa-star fa-fw topic-starred-unread"></span>
@@ -89,6 +91,12 @@
                                 {{ topic.username }}
                                 {% endif %}
                             </div>
+
+                            {% if topic.hidden %}
+                            <div class="topic-author">
+                                {{ gettext("Hidden on %(when)s  by %(who)s", who=topic.hidden_by.username, when=format_date(topic.hidden_at, '%b %d %Y'))}}
+                            </div>
+                            {% endif %}
                         </div>
                     </div>
                 </div>

+ 41 - 0
flaskbb/templates/forum/topic.html

@@ -14,6 +14,13 @@
     </ol>
 
     {% include theme('forum/topic_controls.html') %}
+    {% if topic.hidden %}
+    <div class="alert alert-warning">
+        <span class="fa fa-user-secret"></span>
+        {{ gettext("This topic is hidden (%(when)s  by %(who)s)", who=topic.hidden_by.username, when=format_date(topic.hidden_at, '%b %d %Y'))}}
+    </div>
+    {% endif %}
+
 
     <div class="panel topic-panel">
         <div class="panel-heading topic-head">
@@ -83,6 +90,12 @@
                     </div>
 
                         <div class="post-content clearfix" id="pid{{ post.id }}">
+                            {% if post.hidden %}
+                            <div class="alert alert-warning">
+                                <span class="fa fa-user-secret"></span>
+                                {{ gettext("This post is hidden (%(when)s  by %(who)s)", who=post.hidden_by.username, when=format_date(post.hidden_at, '%b %d %Y'))}}
+                            </div>
+                            {% endif %}
                         {{ post.content|markup }}
                         <!-- Signature Begin -->
                         {% if flaskbb_config["SIGNATURE_ENABLED"] and post.user_id and user.signature %}
@@ -133,6 +146,34 @@
                                 <a href="{{ url_for('forum.report_post', post_id=post.id) }}" onclick="window.open(this.href, 'wio_window','width=500,height=500'); return false;" class="btn btn-icon icon-report" data-toggle="tooltip" data-placement="top" title="Report this post"></a>
                             {% endif %}
 
+                            {% if current_user.permissions.get('makehidden') %}
+                                {% if post.first_post %}
+                                    {% if topic.hidden %}
+                                    <form class="inline-form" method="post" action="{{ url_for('forum.unhide_topic', topic_id=post.topic.id) }}">
+                                        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
+                                        <button class="btn btn-icon fa fa-user" name="unhide" data-toggle="tooltip" data-placement="top" title="Unhide this topic"></button>
+                                    </form>
+                                    {% else %}
+                                    <form class="inline-form" method="post" action="{{ url_for('forum.hide_topic', topic_id=post.topic.id) }}">
+                                        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
+                                        <button class="btn btn-icon fa fa-user-secret" name="hide" data-toggle="tooltip" data-placement="top" title="Hide this topic"></button>
+                                    </form>
+                                    {% endif %}
+                                {% else %}
+                                    {% if post.hidden %}
+                                    <form class="inline-form" method="post" action="{{ url_for('forum.unhide_post', post_id=post.id) }}">
+                                        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
+                                        <button class="btn btn-icon fa fa-user" name="unhide" data-toggle="tooltip" data-placement="top" title="Unhide this post"></button>
+                                    </form>
+                                    {% else %}
+                                    <form class="inline-form" method="post" action="{{ url_for('forum.hide_post', post_id=post.id) }}">
+                                        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
+                                        <button class="btn btn-icon fa fa-user-secret" name="hide" data-toggle="tooltip" data-placement="top" title="Hide this post"></button>
+                                    </form>
+                                    {% endif %}
+                                {% endif %}
+                            {% endif %}
+
                             </div> <!-- end post-menu -->
                         </div> <!-- end footer -->
 

+ 19 - 0
flaskbb/templates/forum/topic_controls.html

@@ -68,6 +68,25 @@
                     </li>
                     {% endif %}
                 {% endif %}
+                {% if current_user | can_moderate(topic.forum) and current_user.permissions.get('makehidden', False) %}
+                <li>
+                    {% if topic.hidden %}
+                    <form class="inline-form" method="post" action="{{ url_for('forum.unhide_topic', topic_id=topic.id, slug=topic.slug) }}">
+                        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
+                        <button class="btn btn-link">
+                            <span class="fa fa-user fa-fw"></span> {% trans %}Unhide Topic{% endtrans %}
+                        </button>
+                    </form>
+                    {% else %}
+                    <form class="inline-form" method="post" action="{{ url_for('forum.hide_topic', topic_id=topic.id, slug=topic.slug) }}">
+                        <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
+                        <button class="btn btn-link">
+                            <span class="fa fa-user-secret fa-fw"></span> {% trans %}Hide Topic{% endtrans %}
+                        </button>
+                    </form>
+                    {% endif %}
+                </li>
+                {% endif %}
                 </ul>
             </div>
             <!-- end Moderation buttons -->

+ 3 - 0
flaskbb/templates/management/group_form.html

@@ -51,6 +51,9 @@
                         {{ render_boolean_field(form.deletetopic) }}
                         {{ render_boolean_field(form.posttopic) }}
                         {{ render_boolean_field(form.postreply) }}
+                        {{ render_boolean_field(form.makehidden) }}
+                        {{ render_boolean_field(form.viewhidden) }}
+
 
                         {{ render_submit_field(form.submit, div_class="form-group col-sm-5") }}
                     </div>

+ 4 - 2
flaskbb/user/models.py

@@ -60,6 +60,8 @@ class Group(db.Model, CRUDMixin):
     deletetopic = db.Column(db.Boolean, default=False, nullable=False)
     posttopic = db.Column(db.Boolean, default=True, nullable=False)
     postreply = db.Column(db.Boolean, default=True, nullable=False)
+    viewhidden = db.Column(db.Boolean, default=False, nullable=False)
+    makehidden = db.Column(db.Boolean, default=False, nullable=False)
 
     # Methods
     def __repr__(self):
@@ -113,8 +115,8 @@ class User(db.Model, UserMixin, CRUDMixin):
     theme = db.Column(db.String(15), nullable=True)
     language = db.Column(db.String(15), default="en", nullable=True)
 
-    posts = db.relationship("Post", backref="user", lazy="dynamic")
-    topics = db.relationship("Topic", backref="user", lazy="dynamic")
+    posts = db.relationship("Post", backref="user", lazy="dynamic", primaryjoin="User.id == Post.user_id")
+    topics = db.relationship("Topic", backref="user", lazy="dynamic", primaryjoin="User.id == Topic.user_id")
 
     post_count = db.Column(db.Integer, default=0)
 

+ 76 - 0
flaskbb/utils/database.py

@@ -10,6 +10,9 @@
 """
 import logging
 import pytz
+from flask_login import current_user
+from flask_sqlalchemy import BaseQuery
+from sqlalchemy.ext.declarative import declared_attr
 from flaskbb.extensions import db
 
 
@@ -17,6 +20,7 @@ logger = logging.getLogger(__name__)
 
 
 def make_comparable(cls):
+
     def __eq__(self, other):
         return isinstance(other, cls) and self.id == other.id
 
@@ -33,6 +37,7 @@ def make_comparable(cls):
 
 
 class CRUDMixin(object):
+
     def __repr__(self):
         return "<{}>".format(self.__class__.__name__)
 
@@ -74,3 +79,74 @@ class UTCDateTime(db.TypeDecorator):
 
         # other dialects are already non-naive
         return value
+
+
+class HideableQuery(BaseQuery):
+
+    def __new__(cls, *args, **kwargs):
+        inst = super(HideableQuery, cls).__new__(cls)
+        with_hidden = kwargs.pop(
+            '_with_hidden', False
+        ) or (current_user and current_user.permissions.get('viewhidden', False))
+        if args or kwargs:
+            super(HideableQuery, inst).__init__(*args, **kwargs)
+            entity = inst._mapper_zero().class_
+            return inst.filter(entity.hidden != True) if not with_hidden else inst
+        return inst
+
+    def __init__(self, *args, **kwargs):
+        pass
+
+    def with_hidden(self):
+        return self.__class__(
+            db.class_mapper(self._mapper_zero().class_), session=db.session(), _with_hidden=True
+        )
+
+    def _get(self, *args, **kwargs):
+        return super(HideableQuery, self).get(*args, **kwargs)
+
+    def get(self, *args, **kwargs):
+        include_hidden = kwargs.pop('include_hidden', False)
+        obj = self.with_hidden()._get(*args, **kwargs)
+        return obj if obj is not None and (include_hidden or not obj.hidden) else None
+
+
+class HideableMixin(object):
+    query_class = HideableQuery
+
+    hidden = db.Column(db.Boolean, default=False, nullable=True)
+    hidden_at = db.Column(UTCDateTime(timezone=True), nullable=True)
+
+    @declared_attr
+    def hidden_by_id(cls):
+        return db.Column(
+            db.Integer,
+            db.ForeignKey('users.id', name='fk_{}_hidden_by'.format(cls.__name__)),
+            nullable=True
+        )
+
+    @declared_attr
+    def hidden_by(cls):
+        return db.relationship(
+            'User',
+            uselist=False,
+            foreign_keys=[cls.hidden_by_id],
+        )
+
+    def hide(self, user, *args, **kwargs):
+        from flaskbb.utils.helpers import time_utcnow
+
+        self.hidden_by = user
+        self.hidden = True
+        self.hidden_at = time_utcnow()
+        return self
+
+    def unhide(self, *args, **kwargs):
+        self.hidden_by = None
+        self.hidden = False
+        self.hidden_at = None
+        return self
+
+
+class HideableCRUDMixin(HideableMixin, CRUDMixin):
+    pass

+ 33 - 14
flaskbb/utils/helpers.py

@@ -93,11 +93,11 @@ def do_topic_action(topics, user, action, reverse):
                     For example, to unlock a topic, ``reverse`` should be
                     set to ``True``.
     """
-    from flaskbb.utils.requirements import (IsAtleastModeratorInForum,
-                                            CanDeleteTopic)
+    if not topics:
+        return False
 
-    from flaskbb.user.models import User
-    from flaskbb.forum.models import Post
+    from flaskbb.utils.requirements import (IsAtleastModeratorInForum,
+                                            CanDeleteTopic, Has)
 
     if not Permission(IsAtleastModeratorInForum(forum=topics[0].forum)):
         flash(_("You do not have the permissions to execute this "
@@ -105,8 +105,7 @@ def do_topic_action(topics, user, action, reverse):
         return False
 
     modified_topics = 0
-    if action != "delete":
-
+    if action not in {'delete', 'hide', 'unhide'}:
         for topic in topics:
             if getattr(topic, action) and not reverse:
                 continue
@@ -114,17 +113,37 @@ def do_topic_action(topics, user, action, reverse):
             setattr(topic, action, not reverse)
             modified_topics += 1
             topic.save()
+
     elif action == "delete":
+        if not Permission(CanDeleteTopic):
+            flash(_("You do not have the permissions to delete these topics."), "danger")
+            return False
+
         for topic in topics:
-            if not Permission(CanDeleteTopic):
-                flash(_("You do not have the permissions to delete this "
-                        "topic."), "danger")
-                return False
+            modified_topics += 1
+            topic.delete()
 
-            involved_users = User.query.filter(Post.topic_id == topic.id,
-                                               User.id == Post.user_id).all()
+    elif action == 'hide':
+        if not Permission(Has('makehidden')):
+            flash(_("You do not have the permissions to hide these topics."), "danger")
+            return False
+
+        for topic in topics:
+            if topic.hidden:
+                continue
+            modified_topics += 1
+            topic.hide(user)
+
+    elif action == 'unhide':
+        if not Permission(Has('makehidden')):
+            flash(_("You do not have the permissions to unhide these topics."), "danger")
+            return False
+
+        for topic in topics:
+            if not topic.hidden:
+                continue
             modified_topics += 1
-            topic.delete(involved_users)
+            topic.unhide()
 
     return modified_topics
 
@@ -226,7 +245,7 @@ def forum_is_unread(forum, forumsread, user):
         return False
 
     # check if the last post is newer than the tracker length
-    if forum.last_post_created < read_cutoff:
+    if forum.last_post_id is None or forum.last_post_created < read_cutoff:
         return False
 
     # If the user hasn't visited a topic in the forum - therefore,

+ 23 - 71
flaskbb/utils/requirements.py

@@ -8,12 +8,12 @@
     :license: BSD, see LICENSE for more details
 """
 import logging
-from flask_allows import Requirement, Or, And
 
-from flaskbb.exceptions import FlaskBBError
-from flaskbb.forum.models import Post, Topic, Forum
-from flaskbb.user.models import Group
+from flask_allows import And, Or, Requirement
 
+from flaskbb.exceptions import FlaskBBError
+from flaskbb.forum.locals import current_forum, current_post, current_topic
+from flaskbb.forum.models import Forum, Post, Topic
 
 logger = logging.getLogger(__name__)
 
@@ -61,15 +61,9 @@ class IsModeratorInForum(IsAuthed):
         return Forum.query.get(self.forum_id)
 
     def _get_forum_from_request(self, request):
-        view_args = request.view_args
-        if 'post_id' in view_args:
-            return Post.query.get(view_args['post_id']).topic.forum
-        elif 'topic_id' in view_args:
-            return Topic.query.get(view_args['topic_id']).forum
-        elif 'forum_id' in view_args:
-            return Forum.query.get(view_args['forum_id'])
-        else:
-            raise FlaskBBError
+        if not current_forum:
+            raise FlaskBBError('Could not load forum data')
+        return current_forum
 
 
 class IsSameUser(IsAuthed):
@@ -86,11 +80,10 @@ class IsSameUser(IsAuthed):
         return self._get_user_id_from_post(request)
 
     def _get_user_id_from_post(self, request):
-        view_args = request.view_args
-        if 'post_id' in view_args:
-            return Post.query.get(view_args['post_id']).user_id
-        elif 'topic_id' in view_args:
-            return Topic.query.get(view_args['topic_id']).user_id
+        if current_post:
+            return current_post.user_id
+        elif current_topic:
+            return current_topic.user_id
         else:
             raise FlaskBBError
 
@@ -129,22 +122,8 @@ class TopicNotLocked(Requirement):
             return self._get_topic_from_request(request)
 
     def _get_topic_from_request(self, request):
-        view_args = request.view_args
-        if 'post_id' in view_args:
-            return (
-                Topic.query.join(Post, Post.topic_id == Topic.id)
-                .join(Forum, Forum.id == Topic.forum_id)
-                .filter(Post.id == view_args['post_id'])
-                .with_entities(Topic.locked, Forum.locked)
-                .first()
-            )
-        elif 'topic_id' in view_args:
-            return (
-                Topic.query.join(Forum, Forum.id == Topic.forum_id)
-                .filter(Topic.id == view_args['topic_id'])
-                .with_entities(Topic.locked, Forum.locked)
-                .first()
-            )
+        if current_topic:
+            return current_topic.locked, current_forum.locked
         else:
             raise FlaskBBError("How did you get this to happen?")
 
@@ -170,59 +149,32 @@ class ForumNotLocked(Requirement):
             return self._get_forum_from_request(request)
 
     def _get_forum_from_request(self, request):
-        view_args = request.view_args
-
-        # These queries look big and nasty, but they really aren't that bad
-        # Basically, find the forum this post or topic belongs to
-        # with_entities returns a KeyedTuple with only the locked status
-
-        if 'post_id' in view_args:
-            return (
-                Forum.query.join(Topic, Topic.forum_id == Forum.id)
-                .join(Post, Post.topic_id == Topic.id)
-                .filter(Post.id == view_args['post_id'])
-                .with_entities(Forum.locked)
-                .first()
-            )
-
-        elif 'topic_id' in view_args:
-            return (
-                Forum.query.join(Topic, Topic.forum_id == Forum.id)
-                .filter(Topic.id == view_args['topic_id'])
-                .with_entities(Forum.locked)
-                .first()
-            )
-
-        elif 'forum_id' in view_args:
-            return Forum.query.get(view_args['forum_id'])
+        if current_forum:
+            return current_forum
+        raise FlaskBBError
 
 
 class CanAccessForum(Requirement):
     def fulfill(self, user, request):
-        forum_id = request.view_args['forum_id']
-        group_ids = [g.id for g in user.groups]
+        if not current_forum:
+            raise FlaskBBError('Could not load forum data')
 
-        return Forum.query.filter(
-            Forum.id == forum_id,
-            Forum.groups.any(Group.id.in_(group_ids))
-        ).count()
+        return set([g.id for g in current_forum.groups]) & set([g.id for g in user.groups])
 
 
 class CanAccessTopic(Requirement):
     def fulfill(self, user, request):
-        topic_id = request.view_args['topic_id']
-        group_ids = [g.id for g in user.groups]
+        if not current_forum:
+            raise FlaskBBError('Could not load topic data')
 
-        return Forum.query.join(Topic, Topic.forum_id == Forum.id).filter(
-            Topic.id == topic_id,
-            Forum.groups.any(Group.id.in_(group_ids))
-        ).count()
+        return set([g.id for g in current_forum.groups]) & set([g.id for g in user.groups])
 
 
 def IsAtleastModeratorInForum(forum_id=None, forum=None):
     return Or(IsAtleastSuperModerator, IsModeratorInForum(forum_id=forum_id,
                                                           forum=forum))
 
+
 IsMod = And(IsAuthed(), Has('mod'))
 IsSuperMod = And(IsAuthed(), Has('super_mod'))
 IsAdmin = And(IsAuthed(), Has('admin'))

+ 99 - 0
migrations/d0ffadc3ea48_add_hidden_columns.py

@@ -0,0 +1,99 @@
+"""Add hidden columns
+
+Revision ID: d0ffadc3ea48
+Revises:
+Create Date: 2017-09-04 15:19:38.519991
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import flaskbb
+
+
+# revision identifiers, used by Alembic.
+revision = 'd0ffadc3ea48'
+down_revision = '881dd22cab94'
+branch_labels = ()
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table('groups', schema=None) as batch_op:
+        batch_op.add_column(sa.Column('makehidden', sa.Boolean(), nullable=True, default=False))
+        batch_op.add_column(sa.Column('viewhidden', sa.Boolean(), nullable=True, default=False))
+
+    with op.batch_alter_table('groups', schema=None) as batch_op:
+        groups = sa.sql.table(
+            'groups',
+            sa.sql.column('viewhidden'), sa.sql.column('makehidden'),
+            sa.sql.column('admin'), sa.sql.column('super_mod'), sa.sql.column('mod')
+        )
+        batch_op.execute(
+            groups.update().where(
+                sa.or_(
+                    groups.c.admin == True,
+                    groups.c.mod == True,
+                    groups.c.super_mod == True
+                )
+            ).values(viewhidden=True, makehidden=True)
+        )
+        batch_op.execute(
+            groups.update().where(sa.and_(
+                groups.c.admin != True,
+                groups.c.mod != True,
+                groups.c.super_mod != True
+            )).values(viewhidden=False, makehidden=False)
+        )
+
+        batch_op.alter_column('viewhidden', existing_type=sa.Boolean(), nullable=False)
+        batch_op.alter_column('makehidden', existing_type=sa.Boolean(), nullable=False)
+
+    with op.batch_alter_table('posts', schema=None) as batch_op:
+        batch_op.add_column(sa.Column('hidden', sa.Boolean(), nullable=True, default=False))
+        batch_op.add_column(sa.Column('hidden_at', flaskbb.utils.database.UTCDateTime(timezone=True), nullable=True))
+        batch_op.add_column(sa.Column('hidden_by_id', sa.Integer(), nullable=True))
+        batch_op.create_foreign_key('fk_Post_hidden_by', 'users', ['hidden_by_id'], ['id'])
+
+    with op.batch_alter_table('posts', schema=None) as batch_op:
+        posts = sa.sql.table('posts', sa.sql.column('hidden'))
+        batch_op.execute(
+            posts.update().values(hidden=False)
+        )
+        batch_op.alter_column('hidden', existing_type=sa.Boolean(), nullable=False)
+
+    with op.batch_alter_table('topics', schema=None) as batch_op:
+        batch_op.add_column(sa.Column('hidden', sa.Boolean(), nullable=True))
+        batch_op.add_column(sa.Column('hidden_at', flaskbb.utils.database.UTCDateTime(timezone=True), nullable=True))
+        batch_op.add_column(sa.Column('hidden_by_id', sa.Integer(), nullable=True))
+        batch_op.create_foreign_key('fk_Topic_hidden_by', 'users', ['hidden_by_id'], ['id'])
+
+    with op.batch_alter_table('topics', schema=None) as batch_op:
+        topics = sa.sql.table('topics', sa.sql.column('hidden'))
+        batch_op.execute(
+            topics.update().values(hidden=False)
+        )
+        batch_op.alter_column('hidden', existing_type=sa.Boolean(), nullable=False)
+
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table('topics', schema=None) as batch_op:
+        batch_op.drop_constraint('fk_Topic_hidden_by', type_='foreignkey')
+        batch_op.drop_column('hidden_by_id')
+        batch_op.drop_column('hidden_at')
+        batch_op.drop_column('hidden')
+
+    with op.batch_alter_table('posts', schema=None) as batch_op:
+        batch_op.drop_constraint('fk_Post_hidden_by', type_='foreignkey')
+        batch_op.drop_column('hidden_by_id')
+        batch_op.drop_column('hidden_at')
+        batch_op.drop_column('hidden')
+
+    with op.batch_alter_table('groups', schema=None) as batch_op:
+        batch_op.drop_column('viewhidden')
+        batch_op.drop_column('makehidden')
+
+    # ### end Alembic commands ###

+ 6 - 0
tests/fixtures/app.py

@@ -20,6 +20,12 @@ def application():
     ctx.pop()
 
 
+@pytest.yield_fixture()
+def request_context(application):
+    with application.test_request_context():
+        yield
+
+
 @pytest.fixture()
 def default_groups(database):
     """Creates the default groups"""

+ 66 - 3
tests/unit/test_forum_models.py

@@ -517,9 +517,9 @@ def test_post_delete(topic):
     post_last.delete()
 
     # That was a bit trickier..
-    assert topic.post_count == 1
-    assert topic.forum.post_count == 1
-    assert topic.user.post_count == 1
+    assert topic.post_count == 2
+    assert topic.forum.post_count == 2
+    assert topic.user.post_count == 2
     assert topic.first_post_id == topic.last_post_id
 
     assert topic.forum.last_post_id == topic.last_post_id
@@ -574,3 +574,66 @@ def test_topicsread(topic, user):
         filter_by(topic_id=topicsread.topic_id).\
         first()
     assert topicsread is None
+
+
+def test_hiding_post_updates_counts(forum, topic, user):
+    new_post = Post(content='spam')
+    new_post.save(user=user, topic=topic)
+    new_post.hide(user)
+    assert user.post_count == 1
+    assert topic.post_count == 1
+    assert forum.post_count == 1
+    assert topic.last_post != new_post
+    assert forum.last_post != new_post
+    assert new_post.hidden_by == user
+    new_post.unhide()
+    assert topic.post_count == 2
+    assert user.post_count == 2
+    assert forum.post_count == 2
+    assert topic.last_post == new_post
+    assert forum.last_post == new_post
+    assert new_post.hidden_by is None
+
+
+def test_hiding_topic_updates_counts(forum, topic, user):
+    assert forum.post_count == 1
+    topic.hide(user)
+    assert forum.post_count == 0
+    assert topic.hidden_by == user
+    assert forum.last_post is None
+    topic.unhide()
+    assert forum.post_count == 1
+    assert topic.hidden_by is None
+    assert forum.last_post == topic.last_post
+
+
+def test_hiding_first_post_hides_topic(forum, topic, user):
+    assert forum.post_count == 1
+    topic.first_post.hide(user)
+    assert forum.post_count == 0
+    assert topic.hidden_by == user
+    assert forum.last_post is None
+    topic.first_post.unhide()
+    assert forum.post_count == 1
+    assert topic.hidden_by is None
+    assert forum.last_post == topic.last_post
+
+
+def test_retrieving_hidden_posts(topic, user):
+    new_post = Post(content='stuff')
+    new_post.save(user, topic)
+    new_post.hide(user)
+
+    assert Post.query.get(new_post.id) is None
+    assert Post.query.get(new_post.id, include_hidden=True) == new_post
+    assert Post.query.filter(Post.id == new_post.id).first() is None
+    assert Post.query.with_hidden().filter(Post.id == new_post.id).first() == new_post
+
+
+def test_retrieving_hidden_topics(topic, user):
+    topic.hide(user)
+
+    assert Topic.query.get(topic.id) is None
+    assert Topic.query.get(topic.id, include_hidden=True) == topic
+    assert Topic.query.filter(Topic.id == topic.id).first() is None
+    assert Topic.query.with_hidden().filter(Topic.id == topic.id).first() == topic

+ 32 - 0
tests/unit/test_hideable_query.py

@@ -0,0 +1,32 @@
+from flask_login import login_user
+from flaskbb.forum.models import Topic
+
+
+def test_guest_user_cannot_see_hidden_posts(guest, topic, user, request_context):
+    topic.hide(user)
+    login_user(guest)
+    assert Topic.query.filter(Topic.id == topic.id).first() is None
+
+
+def test_regular_user_cannot_see_hidden_posts(topic, user, request_context):
+    topic.hide(user)
+    login_user(user)
+    assert Topic.query.filter(Topic.id == topic.id).first() is None
+
+
+def test_moderator_user_can_see_hidden_posts(topic, moderator_user, request_context):
+    topic.hide(moderator_user)
+    login_user(moderator_user)
+    assert Topic.query.filter(Topic.id == topic.id).first() is not None
+
+
+def test_super_moderator_user_can_see_hidden_posts(topic, super_moderator_user, request_context):
+    topic.hide(super_moderator_user)
+    login_user(super_moderator_user)
+    assert Topic.query.filter(Topic.id == topic.id).first() is not None
+
+
+def test_admin_user_can_see_hidden_posts(topic, admin_user, request_context):
+    topic.hide(admin_user)
+    login_user(admin_user)
+    assert Topic.query.filter(Topic.id == topic.id).first() is not None

+ 1 - 0
tests/unit/test_posts.py

@@ -0,0 +1 @@
+

+ 31 - 18
tests/unit/test_requirements.py

@@ -1,5 +1,18 @@
+import pytest
+from flask import _request_ctx_stack, request
+
 from flaskbb.utils import requirements as r
-from flaskbb.utils.datastructures import SimpleNamespace
+
+
+def push_onto_request_context(**kw):
+    for name, value in kw.items():
+        setattr(_request_ctx_stack.top, name, value)
+
+
+@pytest.yield_fixture
+def request_context(application):
+    with application.test_request_context():
+        yield
 
 
 def test_Fred_IsNotAdmin(Fred):
@@ -42,44 +55,44 @@ def test_Fred_CannotBanUser(Fred):
     assert not r.CanBanUser(Fred, None)
 
 
-def test_CanEditTopic_with_member(user, topic):
-    request = SimpleNamespace(view_args={'topic_id': topic.id})
+def test_CanEditTopic_with_member(user, topic, request_context):
+    push_onto_request_context(topic=topic)
     assert r.CanEditPost(user, request)
 
 
-def test_Fred_cannot_edit_other_members_post(user, Fred, topic):
-    request = SimpleNamespace(view_args={'topic_id': topic.id})
+def test_Fred_cannot_edit_other_members_post(user, Fred, topic, request_context):
+    push_onto_request_context(topic=topic)
     assert not r.CanEditPost(Fred, request)
 
 
-def test_Fred_CannotEditLockedTopic(Fred, topic_locked):
-    request = SimpleNamespace(view_args={'topic_id': topic_locked.id})
+def test_Fred_CannotEditLockedTopic(Fred, topic_locked, request_context):
+    push_onto_request_context(topic=topic_locked)
     assert not r.CanEditPost(Fred, request)
 
 
-def test_Moderator_in_Forum_CanEditLockedTopic(moderator_user, topic_locked):
-    request = SimpleNamespace(view_args={'topic_id': topic_locked.id})
+def test_Moderator_in_Forum_CanEditLockedTopic(moderator_user, topic_locked, request_context):
+    push_onto_request_context(topic=topic_locked)
     assert r.CanEditPost(moderator_user, request)
 
 
-def test_FredIsAMod_but_still_cant_edit_topic_in_locked_forum(
-        Fred, topic_locked, default_groups):
+def test_FredIsAMod_but_still_cant_edit_topic_in_locked_forum(Fred, topic_locked, default_groups, request_context):
 
-    request = SimpleNamespace(view_args={'topic_id': topic_locked.id})
     Fred.primary_group = default_groups[2]
+
+    push_onto_request_context(topic=topic_locked)
     assert not r.CanEditPost(Fred, request)
 
 
-def test_Fred_cannot_reply_to_locked_topic(Fred, topic_locked):
-    request = SimpleNamespace(view_args={'topic_id': topic_locked.id})
+def test_Fred_cannot_reply_to_locked_topic(Fred, topic_locked, request_context):
+    push_onto_request_context(topic=topic_locked)
     assert not r.CanPostReply(Fred, request)
 
 
-def test_Fred_cannot_delete_others_post(Fred, topic):
-    request = SimpleNamespace(view_args={'post_id': topic.first_post.id})
+def test_Fred_cannot_delete_others_post(Fred, topic, request_context):
+    push_onto_request_context(post=topic.first_post)
     assert not r.CanDeletePost(Fred, request)
 
 
-def test_Mod_can_delete_others_post(moderator_user, topic):
-    request = SimpleNamespace(view_args={'post_id': topic.first_post.id})
+def test_Mod_can_delete_others_post(moderator_user, topic, request_context):
+    push_onto_request_context(post=topic.first_post)
     assert r.CanDeletePost(moderator_user, request)