Browse Source

Merge pull request #2 from RJacksonm1/subforums

Subforums
sh4nks 11 years ago
parent
commit
4f6640447f

+ 22 - 22
flaskbb/admin/forms.py

@@ -21,7 +21,7 @@ from wtforms.ext.sqlalchemy.fields import (QuerySelectField,
 
 from flaskbb.helpers import SelectDateWidget
 from flaskbb.extensions import db
-from flaskbb.forum.models import Category, Forum
+from flaskbb.forum.models import Forum
 from flaskbb.user.models import User, Group
 
 USERNAME_RE = r'^[\w.+-]+$'
@@ -29,8 +29,8 @@ is_username = regexp(USERNAME_RE,
                      message=("You can only use letters, numbers or dashes"))
 
 
-def selectable_categories():
-    return Category.query.order_by(Category.id)
+def selectable_forums():
+    return Forum.query.order_by(Forum.id)
 
 
 def select_primary_group():
@@ -217,6 +217,8 @@ class AddGroupForm(GroupForm):
 
 
 class ForumForm(Form):
+    _id = None
+
     title = TextField("Forum Title", validators=[
         Required(message="Forum title required")])
 
@@ -226,28 +228,26 @@ class ForumForm(Form):
     position = IntegerField("Position", validators=[
         Required(message="Forum position required")])
 
-    category = QuerySelectField("Category",
-                                query_factory=selectable_categories,
-                                get_label="title")
+    parent = QuerySelectField("Parent",
+                              query_factory=selectable_forums,
+                              get_label="title",
+                              description="This field is not saved if this forum is a category (see \"Is a category?\" field below).")
+
+    is_category = BooleanField("Is a category?", description="Categories are root-level parents for forums.  They can not contain topics.")
+    locked = BooleanField("Locked?", description="Disable new posts and topics in this forum.")
+
+    def validate_parent(self, field):
+        if field.data.id == self._id:
+            raise ValidationError("A forum cannot be it's own parent!")
 
     def save(self):
         forum = Forum(title=self.title.data,
                       description=self.description.data,
-                      position=self.position.data,
-                      category_id=self.category.data.id)
-        return forum.save()
-
-
-class CategoryForm(Form):
-    title = TextField("Category Title", validators=[
-        Required(message="Category title required")])
-
-    description = TextAreaField("Description", validators=[
-        Optional()])
+                      position=self.position.data)
 
-    position = IntegerField("Position", validators=[
-        Required(message="Forum position required")])
+        if self.is_category.data:
+            forum.is_category = True
+        else:
+            forum.parent_id = self.parent.data.id
 
-    def save(self):
-        category = Category(**self.data)
-        return category.save()
+        return forum.save()

+ 10 - 54
flaskbb/admin/views.py

@@ -8,9 +8,9 @@ from flaskbb import __version__ as flaskbb_version
 from flaskbb.decorators import admin_required
 from flaskbb.extensions import db
 from flaskbb.user.models import User, Group
-from flaskbb.forum.models import Post, Topic, Forum, Category
+from flaskbb.forum.models import Post, Topic, Forum
 from flaskbb.admin.forms import (AddUserForm, EditUserForm, AddGroupForm,
-                                 EditGroupForm, ForumForm, CategoryForm)
+                                 EditGroupForm, ForumForm)
 
 
 admin = Blueprint("admin", __name__)
@@ -54,15 +54,6 @@ def groups():
     return render_template("admin/groups.html", groups=groups)
 
 
-@admin.route("/categories")
-@admin_required
-def categories():
-    page = request.args.get("page", 1, type=int)
-    categories = Category.query.\
-        paginate(page, current_app.config['USERS_PER_PAGE'], False)
-    return render_template("admin/categories.html", categories=categories)
-
-
 @admin.route("/forums")
 @admin_required
 def forums():
@@ -197,11 +188,15 @@ def edit_forum(forum_id):
     forum = Forum.query.filter_by(id=forum_id).first()
 
     form = ForumForm()
+    form._id = forum.id  # Used for validation only.
+
     if form.validate_on_submit():
         forum.title = form.title.data
         forum.description = form.description.data
         forum.position = form.position.data
-        forum.category_id = form.category.data.id
+        forum.parent_id = form.parent.data.id
+        forum.is_category = form.is_category.data
+        forum.locked = form.locked.data
         forum.save()
 
         flash("Forum successfully edited.", "success")
@@ -210,7 +205,9 @@ def edit_forum(forum_id):
         form.title.data = forum.title
         form.description.data = forum.description
         form.position.data = forum.position
-        form.category.data = forum.category
+        form.parent.data = forum.parent
+        form.is_category.data = forum.is_category
+        form.locked.data = forum.locked
         #form.moderators.data = forum.moderators
 
     return render_template("admin/edit_forum.html", form=form)
@@ -236,44 +233,3 @@ def add_forum():
         return redirect(url_for("admin.forums"))
 
     return render_template("admin/edit_forum.html", form=form)
-
-
-@admin.route("/categories/<int:category_id>/edit", methods=["GET", "POST"])
-@admin_required
-def edit_category(category_id):
-    category = Category.query.filter_by(id=category_id).first()
-
-    form = CategoryForm()
-    if form.validate_on_submit():
-        form.populate_obj(category)
-        category.save()
-        flash("Category successfully edited.", "success")
-        return redirect(url_for("admin.edit_category", category_id=category.id))
-    else:
-        form.title.data = category.title
-        form.description.data = category.description
-        form.position.data = category.position
-
-    return render_template("admin/edit_category.html", form=form)
-
-
-@admin.route("/categories/<int:category_id>/delete")
-@admin_required
-def delete_category(category_id):
-    category = Category.query.filter_by(id=category_id).first()
-    category.delete()
-    flash("Category successfully deleted.", "success")
-    return redirect(url_for("admin.categories"))
-
-
-@admin.route("/categories/add", methods=["GET", "POST"])
-@admin_required
-def add_category():
-    form = CategoryForm()
-
-    if form.validate_on_submit():
-        form.save()
-        flash("Category successfully added.", "success")
-        return redirect(url_for("admin.categories"))
-
-    return render_template("admin/edit_category.html", form=form)

+ 11 - 0
flaskbb/forum/helpers.py

@@ -0,0 +1,11 @@
+def get_forum_ids(forum):
+    """
+    Returns a list of forum ids for the passed `forum` object and its child hierarchy.
+    """
+    forum_ids = [forum.id]
+    if forum.children:
+        for child in forum.children:
+            forum_ids.extend(
+                get_forum_ids(child)
+            )
+    return forum_ids

+ 246 - 95
flaskbb/forum/models.py

@@ -8,10 +8,12 @@
     :copyright: (c) 2013 by the FlaskBB Team.
     :license: BSD, see LICENSE for more details.
 """
+import sys
 from datetime import datetime
 
-from flaskbb.extensions import db
+from flaskbb.extensions import db, cache
 from flaskbb.helpers import DenormalizedText
+from helpers import get_forum_ids
 
 
 class Post(db.Model):
@@ -26,6 +28,14 @@ class Post(db.Model):
     date_created = db.Column(db.DateTime, default=datetime.utcnow())
     date_modified = db.Column(db.DateTime)
 
+    # Methods
+    def __repr__(self):
+        """
+        Set to a unique key specific to the object in the database.
+        Required for cache.memoize() to work across requests.
+        """
+        return "<{} {})>".format(self.__class__.__name__, self.id)
+
     def save(self, user=None, topic=None):
         # update/edit the post
         if self.id:
@@ -43,14 +53,10 @@ class Post(db.Model):
             db.session.add(self)
             db.session.commit()
 
-            # Now lets update the last post id
-            topic.last_post_id = self.id
-            topic.forum.last_post_id = self.id
-
-            # Update the post counts
-            user.post_count += 1
-            topic.post_count += 1
-            topic.forum.post_count += 1
+            # Invalidate relevant caches
+            user.invalidate_cache()
+            topic.invalidate_cache()
+            topic.forum.invalidate_cache()
 
             # And commit it!
             db.session.add(topic)
@@ -63,17 +69,10 @@ class Post(db.Model):
             self.topic.delete()
             return self
 
-        # Delete the last post
-        if self.topic.last_post_id == self.id:
-            # Now the second last post will be the last post
-            self.topic.last_post_id = self.topic.second_last_post
-            self.topic.forum.last_post_id = self.topic.second_last_post
-            db.session.commit()
-
-        # Update the post counts
-        self.user.post_count -= 1
-        self.topic.post_count -= 1
-        self.topic.forum.post_count -= 1
+        # Invalidate relevant caches
+        self.user.invalidate_cache()
+        self.topic.invalidate_cache()
+        self.topic.forum.invalidate_cache()
 
         # Is there a better way to do this?
         db.session.delete(self)
@@ -93,29 +92,39 @@ class Topic(db.Model):
     locked = db.Column(db.Boolean, default=False)
     important = db.Column(db.Boolean, default=False)
     views = db.Column(db.Integer, default=0)
-    post_count = db.Column(db.Integer, default=0)
-
-    # One-to-one (uselist=False) relationship between first_post and topic
-    first_post_id = db.Column(db.Integer, db.ForeignKey("posts.id",
-                                                        ondelete="CASCADE"))
-    first_post = db.relationship("Post", backref="first_post", uselist=False,
-                                 foreign_keys=[first_post_id])
-
-    # One-to-one
-    last_post_id = db.Column(db.Integer, db.ForeignKey("posts.id",
-                                                       ondelete="CASCADE",
-                                                       onupdate="CASCADE"))
-    last_post = db.relationship("Post", backref="last_post", uselist=False,
-                                foreign_keys=[last_post_id])
 
     # One-to-many
     posts = db.relationship("Post", backref="topic", lazy="joined",
                             primaryjoin="Post.topic_id == Topic.id",
                             cascade="all, delete-orphan", post_update=True)
 
-    def __init__(self, title=None):
-        if title:
-            self.title = title
+    # Properties
+    @property
+    def post_count(self):
+        """
+        Property interface for get_post_count method.
+
+        Method seperate for easy invalidation of cache.
+        """
+        return self.get_post_count()
+
+    @property
+    def first_post(self):
+        """
+        Property interface for get_first_post method.
+
+        Method seperate for easy invalidation of cache.
+        """
+        return self.get_first_post()
+
+    @property
+    def last_post(self):
+        """
+        Property interface for get_last_post method.
+
+        Method seperate for easy invalidation of cache.
+        """
+        return self.get_last_post()
 
     @property
     def second_last_post(self):
@@ -124,6 +133,18 @@ class Topic(db.Model):
         """
         return self.posts[-2].id
 
+    # Methods
+    def __init__(self, title=None):
+        if title:
+            self.title = title
+
+    def __repr__(self):
+        """
+        Set to a unique key specific to the object in the database.
+        Required for cache.memoize() to work across requests.
+        """
+        return "<{} {})>".format(self.__class__.__name__, self.id)
+
     def save(self, user=None, forum=None, post=None):
         # Updates the topic - Because the thread title (by intention)
         # isn't change able, so we are just going to update the post content
@@ -136,9 +157,6 @@ class Topic(db.Model):
         self.forum_id = forum.id
         self.user_id = user.id
 
-        # Update the topic count
-        forum.topic_count += 1
-
         # Insert and commit the topic
         db.session.add(self)
         db.session.commit()
@@ -146,47 +164,80 @@ class Topic(db.Model):
         # Create the topic post
         post.save(user, self)
 
-        # Update the first post id
-        self.first_post_id = post.id
+        # Invalidate relevant caches
+        self.invalidate_cache()
+        self.forum.invalidate_cache()
+
         db.session.commit()
 
         return self
 
     def delete(self, users=None):
-        topic = Topic.query.filter_by(forum_id=self.forum_id).\
-            order_by(Topic.last_post_id.desc())
-
-        if topic and topic[0].id == self.id:
-            try:
-                self.forum.last_post_id = topic[1].last_post_id
-            # Catch an IndexError when you delete the last topic in the forum
-            except IndexError:
-                self.forum.last_post_id = 0
+        # Invalidate relevant caches
+        self.invalidate_cache()
+        self.forum.invalidate_cache()
 
-        # These things needs to be stored in a variable before they are deleted
-        forum = self.forum
+        # Invalidate user post counts
+        if users:
+            for user in users:
+                user.invalidate_cache()
 
         # Delete the topic
         db.session.delete(self)
         db.session.commit()
 
-        # Update the post counts
-        if users:
-            # If someone knows a better method for this,
-            # feel free to improve it :)
-            for user in users:
-                user.post_count = Post.query.filter_by(user_id=user.id).count()
-                db.session.commit()
-        forum.topic_count = Topic.query.filter_by(
-            forum_id=self.forum_id).count()
+        return self
 
-        forum.post_count = Post.query.filter(
-            Post.topic_id == Topic.id,
-            Topic.forum_id == self.forum_id).count()
+    @cache.memoize(timeout=sys.maxint)
+    def get_post_count(self):
+        """
+        Returns the amount of posts within the current topic.
+        """
+        return Post.query.\
+            filter(Post.topic_id == self.id).\
+            count()
 
-        db.session.commit()
+    @cache.memoize(timeout=sys.maxint)
+    def get_first_post(self):
+        """
+        Returns the first post within the current topic.
+        """
 
-        return self
+        post = Post.query.\
+            filter(Post.topic_id == self.id).\
+            order_by(Post.date_created.asc()).\
+            first()
+
+        # Load the topic and user before we cache
+        post.topic
+        post.user
+
+        return post
+
+    @cache.memoize(timeout=sys.maxint)
+    def get_last_post(self):
+        """
+        Returns the latest post within the current topic.
+        """
+
+        post = Post.query.\
+            filter(Post.topic_id == self.id).\
+            order_by(Post.date_created.desc()).\
+            first()
+
+        # Load the topic and user before we cache
+        post.topic
+        post.user
+
+        return post
+
+    def invalidate_cache(self):
+        """
+        Invalidates this objects cached metadata.
+        """
+        cache.delete_memoized(self.get_post_count, self)
+        cache.delete_memoized(self.get_first_post, self)
+        cache.delete_memoized(self.get_last_post, self)
 
 
 class Forum(db.Model):
@@ -196,21 +247,51 @@ class Forum(db.Model):
     title = db.Column(db.String)
     description = db.Column(db.String)
     position = db.Column(db.Integer, default=0)
-    category_id = db.Column(db.Integer, db.ForeignKey("categories.id",
-                                                      use_alter=True,
-                                                      name="fk_category_id"))
-    post_count = db.Column(db.Integer, default=0)
-    topic_count = db.Column(db.Integer, default=0)
-
-    # One-to-one
-    last_post_id = db.Column(db.Integer, db.ForeignKey("posts.id"))
-    last_post = db.relationship("Post", backref="last_post_forum",
-                                uselist=False, foreign_keys=[last_post_id])
+    is_category = db.Column(db.Boolean, default=False)
+    parent_id = db.Column(db.Integer, db.ForeignKey("forums.id"))
+    locked = db.Column(db.Boolean, default=False)
 
     # One-to-many
     topics = db.relationship("Topic", backref="forum", lazy="joined")
+    children = db.relationship("Forum", backref=db.backref("parent", remote_side=[id]))
+
+    moderators = db.Column(DenormalizedText)  # TODO: No forum_moderators column?
+
+    # Properties
+    @property
+    def post_count(self):
+        """
+        Property interface for get_post_count method.
+
+        Method seperate for easy invalidation of cache.
+        """
+        return self.get_post_count()
+
+    @property
+    def topic_count(self):
+        """
+        Property interface for get_topic_count method.
 
-    moderators = db.Column(DenormalizedText)
+        Method seperate for easy invalidation of cache.
+        """
+        return self.get_topic_count()
+
+    @property
+    def last_post(self):
+        """
+        Property interface for get_last_post method.
+
+        Method seperate for easy invalidation of cache.
+        """
+        return self.get_last_post()
+
+    # Methods
+    def __repr__(self):
+        """
+        Set to a unique key specific to the object in the database.
+        Required for cache.memoize() to work across requests.
+        """
+        return "<{} {})>".format(self.__class__.__name__, self.id)
 
     def add_moderator(self, user_id):
         self.moderators.add(user_id)
@@ -228,28 +309,91 @@ class Forum(db.Model):
         db.session.commit()
         return self
 
+    def get_breadcrumbs(self):
+        breadcrumbs = []
+        parent = self.parent
+        while parent is not None:
+            breadcrumbs.append(parent)
+            parent = parent.parent
 
-class Category(db.Model):
-    __tablename__ = "categories"
+        breadcrumbs.reverse()
+        return breadcrumbs
 
-    id = db.Column(db.Integer, primary_key=True)
-    title = db.Column(db.String)
-    description = db.Column(db.String)
-    position = db.Column(db.Integer, default=0)
+    @cache.memoize(timeout=sys.maxint)
+    def get_post_count(self, include_children=True):
+        """
+        Returns the amount of posts within the current forum or it's children.
+        Children can be excluded by setting the second parameter to 'false'.
+        """
 
-    # One-to-many
-    forums = db.relationship("Forum", backref="category", lazy="joined",
-                             primaryjoin="Forum.category_id == Category.id")
+        if include_children:
+            return Post.query.\
+                filter(Post.topic_id == Topic.id). \
+                filter(Topic.forum_id.in_(get_forum_ids(self))). \
+                count()
+        else:
+            return Post.query.\
+                filter(Post.topic_id == Topic.id).\
+                filter(Topic.forum_id == self.id).\
+                count()
+
+    @cache.memoize(timeout=sys.maxint)
+    def get_topic_count(self, include_children=True):
+        """
+        Returns the amount of topics within the current forum or it's children.
+        Children can be excluded by setting the second parameter to 'false'.
+        """
 
-    def save(self):
-        db.session.add(self)
-        db.session.commit()
-        return self
+        if include_children:
+            return Topic.query.\
+                filter(Topic.forum_id.in_(get_forum_ids(self))). \
+                count()
+        else:
+            return Topic.query.\
+                filter(Topic.forum_id == self.id).\
+                count()
+
+    @cache.memoize(timeout=sys.maxint)
+    def get_last_post(self, include_children=True):
+        """
+        Returns the latest post within the current forum or it's children.
+        Children can be excluded by setting the second parameter to 'false'.
+        """
 
-    def delete(self):
-        db.session.delete(self)
-        db.session.commit()
-        return self
+        if include_children:
+            post = Post.query.\
+                filter(Post.topic_id == Topic.id). \
+                filter(Topic.forum_id.in_(get_forum_ids(self))). \
+                order_by(Post.date_created.desc()). \
+                first()
+        else:
+            post = Post.query.\
+                filter(Post.topic_id == Topic.id).\
+                filter(Topic.forum_id == self.id).\
+                order_by(Post.date_created.desc()).\
+                first()
+
+        # Load the topic and user before we cache
+        post.topic
+        post.user
+
+        return post
+
+    def invalidate_cache(self):
+        """
+        Invalidates this objects, and it's parents', cached metadata.
+        """
+        _forum = self
+        while _forum.parent:
+            cache.delete_memoized(self.get_post_count, _forum)
+            cache.delete_memoized(self.get_topic_count, _forum)
+            cache.delete_memoized(self.get_last_post, _forum)
+            _forum = _forum.parent
+
+    # Class methods
+    @classmethod
+    def get_categories(cls):
+        return cls.query.filter(cls.is_category)
 
 
 """
@@ -274,6 +418,13 @@ class Tracking(db.Model):
     topic_id = db.Column(db.Integer, db.ForeignKey("topics.id"))
     last_read = db.Column(db.DateTime, default=datetime.utcnow())
 
+    def __repr__(self):
+        """
+        Set to a unique key specific to the object in the database.
+        Required for cache.memoize() to work across requests.
+        """
+        return "<{} {})>".format(self.__class__.__name__, self.id)
+
     def save(self):
         db.session.add(self)
         db.session.commit()

+ 24 - 13
flaskbb/forum/views.py

@@ -19,7 +19,7 @@ from flask.ext.login import login_required, current_user
 from flaskbb.helpers import (time_diff, perm_post_reply, perm_post_topic,
                              perm_edit_post, perm_delete_topic,
                              perm_delete_post, get_online_users)
-from flaskbb.forum.models import Category, Forum, Topic, Post
+from flaskbb.forum.models import Forum, Topic, Post
 from flaskbb.forum.forms import QuickreplyForm, ReplyForm, NewTopicForm
 from flaskbb.user.models import User
 
@@ -29,7 +29,7 @@ forum = Blueprint("forum", __name__)
 
 @forum.route("/")
 def index():
-    categories = Category.query.all()
+    categories = Forum.get_categories().all()
 
     # Fetch a few stats about the forum
     user_count = User.query.count()
@@ -45,21 +45,14 @@ def index():
                            online_users=len(get_online_users()),
                            online_guests=len(get_online_users(guest=True)))
 
-
-@forum.route("/category/<int:category_id>")
-def view_category(category_id):
-    category = Category.query.filter_by(id=category_id).first()
-
-    return render_template("forum/category.html", category=category)
-
-
 @forum.route("/forum/<int:forum_id>")
 def view_forum(forum_id):
     page = request.args.get('page', 1, type=int)
 
     forum = Forum.query.filter_by(id=forum_id).first()
     topics = Topic.query.filter_by(forum_id=forum.id).\
-        order_by(Topic.last_post_id.desc()).\
+        filter(Post.topic_id == Topic.id).\
+        order_by(Post.id.desc()).\
         paginate(page, current_app.config['TOPICS_PER_PAGE'], False)
 
     return render_template("forum/forum.html", forum=forum, topics=topics)
@@ -79,8 +72,10 @@ def view_topic(topic_id):
 
     form = None
 
-    if not topic.locked and perm_post_reply(user=current_user,
-                                            forum=topic.forum):
+    if not topic.locked \
+        and not topic.forum.locked \
+        and perm_post_reply(user=current_user,
+                            forum=topic.forum):
 
             form = QuickreplyForm()
             if form.validate_on_submit():
@@ -111,6 +106,10 @@ def view_post(post_id):
 def new_topic(forum_id):
     forum = Forum.query.filter_by(id=forum_id).first()
 
+    if forum.locked:
+        flash("This forum is locked; you cannot submit new topics or posts.", "error")
+        return redirect(url_for('forum.view_forum', forum_id=forum.id))
+
     if not perm_post_topic(user=current_user, forum=forum):
         flash("You do not have the permissions to create a new topic.", "error")
         return redirect(url_for('forum.view_forum', forum_id=forum.id))
@@ -146,6 +145,10 @@ def delete_topic(topic_id):
 def new_post(topic_id):
     topic = Topic.query.filter_by(id=topic_id).first()
 
+    if topic.forum.locked:
+        flash("This forum is locked; you cannot submit new topics or posts.", "error")
+        return redirect(url_for('forum.view_forum', forum_id=topic.forum.id))
+
     if topic.locked:
         flash("The topic is locked.", "error")
         return redirect(url_for("forum.view_forum", forum_id=topic.forum_id))
@@ -167,6 +170,14 @@ def new_post(topic_id):
 def edit_post(post_id):
     post = Post.query.filter_by(id=post_id).first()
 
+    if post.topic.forum.locked:
+        flash("This forum is locked; you cannot submit new topics or posts.", "error")
+        return redirect(url_for('forum.view_forum', forum_id=post.topic.forum.id))
+
+    if post.topic.locked:
+        flash("The topic is locked.", "error")
+        return redirect(url_for("forum.view_forum", forum_id=post.topic.forum_id))
+
     if not perm_edit_post(user=current_user, forum=post.topic.forum,
                           post_user_id=post.user_id):
         flash("You do not have the permissions to edit this post", "error")

+ 0 - 1
flaskbb/templates/admin/admin_layout.html

@@ -10,7 +10,6 @@
                 {{ navlink('admin.overview', 'Overview') }}
                 {{ navlink('admin.users', 'Users') }}
                 {{ navlink('admin.groups', 'Groups') }}
-                {{ navlink('admin.categories', 'Categories') }}
                 {{ navlink('admin.forums', 'Forums') }}
             </ul>
         </div><!--/.sidebar -->

+ 0 - 27
flaskbb/templates/admin/categories.html

@@ -1,27 +0,0 @@
-{% extends "admin/admin_layout.html" %}
-{% block admin_content %}
-<legend>Manage Categories | <a href="{{ url_for('admin.add_category') }}">Add Category</a></legend>
-<table class="table table-bordered">
-    <thead>
-        <tr>
-            <th>#</th>
-            <th>Category Name</th>
-            <th>Description</th>
-            <th>Manage</th>
-        </tr>
-    </thead>
-    <tbody>
-        {% for category in categories.items %}
-        <tr>
-            <td>{{ category.id }}</td>
-            <td><a href="#">{{ category.title }}</a></td>
-            <td>{{ category.description }}</td>
-            <td>
-                <a href="{{ url_for('admin.edit_category', category_id = category.id) }}">Edit</a> |
-                <a href="{{ url_for('admin.delete_category', category_id = category.id) }}">Delete</a>
-            </td>
-        </tr>
-        {% endfor %}
-    </tbody>
-</table>
-{% endblock %}

+ 0 - 22
flaskbb/templates/admin/edit_category.html

@@ -1,22 +0,0 @@
-{% set page_title = "Edit Category" %}
-{% set active_forum_nav=True %}
-
-{% extends "admin/admin_layout.html" %}
-{% block admin_content %}
-{% from "macros.html" import horizontal_field %}
-
-<form class="form-horizontal" role="form" method="post">
-    {{ form.hidden_tag() }}
-    <legend class="">Edit Forum</legend>
-        {{ horizontal_field(form.title) }}
-        {{ horizontal_field(form.position) }}
-        {{ horizontal_field(form.description, rows=5, div_class="col-lg-9") }}
-
-        <div class="form-group">
-            <div class="col-lg-offset-0 col-lg-9">
-                <button type="submit" class="btn btn-success">Save</button>
-            </div>
-        </div>
-</form>
-
-{% endblock %}

+ 7 - 3
flaskbb/templates/admin/edit_forum.html

@@ -3,16 +3,20 @@
 
 {% extends "admin/admin_layout.html" %}
 {% block admin_content %}
-{% from "macros.html" import horizontal_field %}
+{% from "macros.html" import horizontal_field, render_boolean_field %}
 
 <form class="form-horizontal" role="form" method="post">
     {{ form.hidden_tag() }}
     <legend class="">Edit Forum</legend>
         {{ horizontal_field(form.title) }}
-        {{ horizontal_field(form.position) }}
-        {{ horizontal_field(form.category) }}
         {{ horizontal_field(form.description, rows=5, div_class="col-lg-9") }}
 
+        {{ horizontal_field(form.parent) }}
+        {{ horizontal_field(form.position) }}
+
+        {{ render_boolean_field(form.is_category) }}
+        {{ render_boolean_field(form.locked) }}
+
         <div class="form-group">
             <div class="col-lg-offset-0 col-lg-9">
                 <button type="submit" class="btn btn-success">Save</button>

+ 0 - 13
flaskbb/templates/forum/category.html

@@ -1,13 +0,0 @@
-{% set page_title = category.title ~ " - Category"%}
-{% set active_forum_nav=True %}
-
-{% extends "layout.html" %}
-{% block content %}
-
-<ol class="breadcrumb">
-    <li><a href="{{ url_for('forum.index') }}">Forum</a></li>
-    <li class="active">{{ category.title }}</li>
-</ol>
-{% include 'forum/category_layout.html' %}
-
-{% endblock %}

+ 16 - 8
flaskbb/templates/forum/category_layout.html

@@ -2,7 +2,7 @@
     <thead class="categoryhead">
         <tr>
             <td colspan="5">
-                <div><strong><a href="{{ url_for('forum.view_category', category_id=category.id) }}">{{ category.title }}</a></strong></div>
+                <div><strong><a href="{{ url_for('forum.view_forum', forum_id=category.id) }}">{{ category.title }}</a></strong></div>
             </td>
         </tr>
     </thead>
@@ -14,7 +14,7 @@
             <td width="200" align="center" style="white-space: nowrap"><strong>Last Post</strong></td>
         </tr>
 
-        {% for forum in category.forums %}
+        {% for forum in category.children %}
         <tr>
             <td align="center" valign="center" width="1">
                 New </br> Posts
@@ -27,19 +27,27 @@
                     {% autoescape false %}
                     {{ forum.description|markup }}
                     {% endautoescape %}
-                    <br />
-                    <!--
-                    <strong>Sub Forums:</strong> <a href="#" title="">Subforum 1</a>, <a href="#" title="">Subforum 2</a>
-                     -->
                 </div>
+                {% if forum.children|length %}
+                <div class="forum-subforums">
+                    <ul class="list-inline">
+                        <li><strong>Subforums:</strong></li>
+                        {% for subforum in forum.children %}
+                        <li>
+                            <a href="{{ url_for('forum.view_forum', forum_id=subforum.id) }}">{{ subforum.title }}</a>
+                        </li>
+                        {% endfor %}
+                    </ul>
+                </div>
+                {% endif %}
             </td>
 
             <td valign="top" align="center" style="white-space: nowrap">{{ forum.topic_count }}</td>
             <td valign="top" align="center" style="white-space: nowrap">{{ forum.post_count }}</td>
 
             <td valign="top" align="right" style="white-space: nowrap">
-                {% if forum.last_post_id %}
-                <a href="{{ url_for('forum.view_post', post_id=forum.last_post_id) }}" title="{{ forum.last_post.topic.title }}">
+                {% if forum.last_post %}
+                <a href="{{ url_for('forum.view_post', post_id=forum.last_post.id) }}" title="{{ forum.last_post.topic.title }}">
                     <strong>{{ forum.last_post.topic.title|crop_title }}</strong>
                 </a>
                 <br />

+ 73 - 2
flaskbb/templates/forum/forum.html

@@ -7,7 +7,9 @@
 
 <ol class="breadcrumb">
     <li><a href="{{ url_for('forum.index') }}">Forum</a></li>
-    <li><a href="{{ url_for('forum.view_category', category_id=forum.category_id) }}">{{ forum.category.title }}</a></li>
+    {% for breadcrumb_item in forum.get_breadcrumbs() %}
+        <li><a href="{{ url_for('forum.view_forum', forum_id=breadcrumb_item.id) }}">{{ breadcrumb_item.title }}</a></li>
+    {% endfor %}
     <li class="active">{{ forum.title }}</li>
 </ol>
 
@@ -15,11 +17,78 @@
     {{ render_pagination(topics, url_for('forum.view_forum', forum_id=forum.id)) }}
 </div> <!-- end span pagination -->
 
-{% if current_user|post_topic(forum) %}
+{% if current_user|post_topic(forum) and not forum.is_category %}
 <div class="pull-right" style="padding-bottom: 10px">
     <a href="{{ url_for('forum.new_topic', forum_id=forum.id) }}" class="btn btn-primary">New Topic</a>
 </div>
 {% endif %}
+{% if forum.children|length %}
+<table class="table table-bordered">
+    <thead>
+        <tr>
+            <th colspan="5">
+                Subforums
+            </th>
+        </tr>
+        <tr>
+            <th colspan="2">Forum</th>
+            <th width="85" align="center" style="white-space: nowrap">Topics</th>
+            <th width="85" align="center" style="white-space: nowrap">Posts</th>
+            <th width="200" align="center" style="white-space: nowrap">Last Post</th>
+        </tr>
+    </thead>
+
+    <tbody>
+        {% for subforum in forum.children %}
+        <tr>
+            <td align="center" valign="center" width="1">
+                New </br> Posts
+            </td>
+
+            <td valign="top">
+                <strong><a href="{{ url_for('forum.view_forum', forum_id=subforum.id) }}">{{ subforum.title }}</a></strong>
+
+                <div class="forum-description">
+                    {% autoescape false %}
+                    {{ subforum.description|markup }}
+                    {% endautoescape %}
+                </div>
+                {% if subforum.children|length %}
+                <div class="forum-subforums">
+                    <ul class="list-inline">
+                        <li><strong>Subforums:</strong></li>
+                        {% for subforum2 in subforum.children %}
+                        <li>
+                            <a href="{{ url_for('forum.view_forum', forum_id=subforum2.id) }}">{{ subforum2.title }}</a>
+                        </li>
+                        {% endfor %}
+                    </ul>
+                </div>
+                {% endif %}
+            </td>
+
+            <td valign="top" align="center" style="white-space: nowrap">{{ subforum.topic_count }}</td>
+            <td valign="top" align="center" style="white-space: nowrap">{{ subforum.post_count }}</td>
+
+            <td valign="top" align="right" style="white-space: nowrap">
+                {% if subforum.last_post %}
+                <a href="{{ url_for('forum.view_post', post_id=subforum.last_post.id) }}" title="{{ subforum.last_post.topic.title }}">
+                    <strong>{{ subforum.last_post.topic.title|crop_title }}</strong>
+                </a>
+                <br />
+                {{ subforum.last_post.date_created|time_since }}<br />
+                by <a href="{{ url_for('user.profile', username=subforum.last_post.user.username) }}">{{ subforum.last_post.user.username }}</a>
+                {% else %}
+                No posts
+                {% endif %}
+            </td>
+        </tr>
+        {% endfor %}
+    </tbody>
+</table>
+{% endif %}
+
+{% if not forum.is_category %}
 <table class="table table-bordered">
     <thead>
         <tr>
@@ -28,6 +97,7 @@
             </th>
         </tr>
     </thead>
+
     <tbody>
         <tr>
             <td colspan="2">Thread</td>
@@ -69,5 +139,6 @@
 
     </tbody>
 </table>
+{% endif %}
 
 {% endblock %}

+ 5 - 2
flaskbb/templates/forum/new_post.html

@@ -7,8 +7,11 @@
 
 <ul class="breadcrumb">
     <li><a href="{{ url_for('forum.index') }}">Forum</a></li>
-    <li><a href="{{ url_for('forum.view_category', category_id=topic.forum.category.id) }}">{{ topic.forum.category.title }}</a></li>
-    <li><a href="{{ url_for('forum.view_forum', forum_id=topic.forum_id) }}">{{ topic.forum.title }}</a></li>
+    {% for breadcrumb_item in topic.forum.get_breadcrumbs() %}
+        <li><a href="{{ url_for('forum.view_forum', forum_id=breadcrumb_item.id) }}">{{ breadcrumb_item.title }}</a></li>
+    {% endfor %}
+
+    <li><a href="{{ url_for('forum.view_forum', forum_id=topic.forum.id) }}">{{ topic.forum.title }}</a></li>
     <li><a href="{{ url_for('forum.view_topic', topic_id=topic.id) }}">{{ topic.title }} </a></li>
     <li class="active">New Post</li>
 </ul>

+ 5 - 2
flaskbb/templates/forum/new_topic.html

@@ -7,8 +7,11 @@
 
 <ul class="breadcrumb">
     <li><a href="{{ url_for('forum.index') }}">Forum</a></li>
-    <li><a href="{{ url_for('forum.view_category', category_id=forum.category_id) }}">{{ forum.category.title }}</a></li>
-    <li><a href="{{ url_for('forum.view_forum', forum_id=forum.id) }}">{{ forum.title }}</a> </li>
+    {% for breadcrumb_item in forum.get_breadcrumbs() %}
+        <li><a href="{{ url_for('forum.view_forum', forum_id=breadcrumb_item.id) }}">{{ breadcrumb_item.title }}</a></li>
+    {% endfor %}
+
+    <li><a href="{{ url_for('forum.view_forum', forum_id=forum.id) }}">{{ forum.title }}</a></li>
     <li class="active">New Topic</li>
 </ul>
 

+ 6 - 3
flaskbb/templates/forum/topic.html

@@ -7,8 +7,11 @@
 
 <ol class="breadcrumb">
     <li><a href="{{ url_for('forum.index') }}">Forum</a></li>
-    <li><a href="{{ url_for('forum.view_category', category_id=topic.forum.category.id) }}">{{ topic.forum.category.title }}</a></li>
-    <li><a href="{{ url_for('forum.view_forum', forum_id=topic.forum_id) }}">{{ topic.forum.title }}</a></li>
+    {% for breadcrumb_item in topic.forum.get_breadcrumbs() %}
+        <li><a href="{{ url_for('forum.view_forum', forum_id=breadcrumb_item.id) }}">{{ breadcrumb_item.title }}</a></li>
+    {% endfor %}
+
+    <li><a href="{{ url_for('forum.view_forum', forum_id=topic.forum.id) }}">{{ topic.forum.title }}</a></li>
     <li class="active">{{ topic.title }}</li>
 </ol>
 
@@ -125,7 +128,7 @@
     </tbody>
 </table>
 
-{% if current_user|post_reply(topic.forum) and not topic.locked %}
+{% if form %}
     {% from "macros.html" import render_field %}
 <form class="form" action="#" method="post">
     {{ form.hidden_tag() }}

+ 64 - 11
flaskbb/user/models.py

@@ -8,6 +8,7 @@
     :copyright: (c) 2013 by the FlaskBB Team.
     :license: BSD, see LICENSE for more details.
 """
+import sys
 from datetime import datetime
 
 from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
@@ -44,6 +45,14 @@ class Group(db.Model):
     posttopic = db.Column(db.Boolean, default=True)
     postreply = db.Column(db.Boolean, default=True)
 
+    # Methods
+    def __repr__(self):
+        """
+        Set to a unique key specific to the object in the database.
+        Required for cache.memoize() to work across requests.
+        """
+        return "<{} {})>".format(self.__class__.__name__, self.id)
+
     def save(self):
         db.session.add(self)
         db.session.commit()
@@ -75,8 +84,6 @@ class User(db.Model, UserMixin):
     posts = db.relationship("Post", backref="user", lazy="dynamic")
     topics = db.relationship("Topic", backref="user", lazy="dynamic")
 
-    post_count = db.Column(db.Integer, default=0)
-
     primary_group_id = db.Column(db.Integer, db.ForeignKey('groups.id'))
 
     primary_group = db.relationship('Group', lazy="joined",
@@ -96,7 +103,31 @@ class User(db.Model, UserMixin):
                         backref=db.backref("topicstracked", lazy="dynamic"),
                         lazy="dynamic")
 
+    # Properties
+    @property
+    def post_count(self):
+        """
+        Property interface for get_post_count method.
+
+        Method seperate for easy invalidation of cache.
+        """
+        return self.get_post_count()
+
+    @property
+    def last_post(self):
+        """
+        Property interface for get_last_post method.
+
+        Method seperate for easy invalidation of cache.
+        """
+        return self.get_last_post()
+
+    # Methods
     def __repr__(self):
+        """
+        Set to a unique key specific to the object in the database.
+        Required for cache.memoize() to work across requests.
+        """
         return "Username: %s" % self.username
 
     def _get_password(self):
@@ -160,20 +191,13 @@ class User(db.Model, UserMixin):
             data = False
         return expired, invalid, data
 
-    @property
-    def last_post(self):
-        """
-        Returns the latest post from the user
-        """
-        return Post.query.filter(Post.user_id == self.id).\
-            order_by(Post.date_created.desc()).first()
-
     def all_topics(self, page):
         """
         Returns a paginated query result with all topics the user has created.
         """
         return Topic.query.filter(Topic.user_id == self.id).\
-            order_by(Topic.last_post_id.desc()).\
+            filter(Post.topic_id == Topic.id).\
+            order_by(Post.id.desc()).\
             paginate(page, current_app.config['TOPICS_PER_PAGE'], False)
 
     def all_posts(self, page):
@@ -275,6 +299,35 @@ class User(db.Model, UserMixin):
         db.session.commit()
         return self
 
+    @cache.memoize(timeout=sys.maxint)
+    def get_post_count(self):
+        """
+        Returns the amount of posts within the current topic.
+        """
+        return Post.query.filter(Post.user_id == self.id).\
+            count()
+
+    @cache.memoize(timeout=sys.maxint)
+    def get_last_post(self):
+        """
+        Returns the latest post from the user
+        """
+        post = Post.query.filter(Post.user_id == self.id).\
+            order_by(Post.date_created.desc()).first()
+
+        # Load the topic and user before we cache
+        post.topic
+        post.user
+
+        return post
+
+    def invalidate_cache(self):
+        """
+        Invalidates this objects cached metadata.
+        """
+        cache.delete_memoized(self.get_post_count, self)
+        cache.delete_memoized(self.get_last_post, self)
+
 
 class Guest(AnonymousUserMixin):
     @cache.memoize(60*5)

+ 10 - 15
manage.py

@@ -20,7 +20,7 @@ from flaskbb import create_app
 from flaskbb.extensions import db
 
 from flaskbb.user.models import User, Group
-from flaskbb.forum.models import Post, Topic, Forum, Category
+from flaskbb.forum.models import Post, Topic, Forum
 
 # Use the development configuration if available
 try:
@@ -179,7 +179,7 @@ def createall():
     # create 2 categories
     for i in range(1, 3):
         category_title = "Test Category %s" % i
-        category = Category(title=category_title,
+        category = Forum(is_category=True, title=category_title,
                             description="Test Description")
         db.session.add(category)
 
@@ -190,11 +190,11 @@ def createall():
 
             forum_title = "Test Forum %s %s" % (j, i)
             forum = Forum(title=forum_title, description="Test Description",
-                          category_id=i)
+                          parent_id=i)
             db.session.add(forum)
 
     # create 1 topic in each forum
-    for k in range(1, 5):
+    for k in [2, 3, 5, 6]:  # Forum ids are not sequential because categories.
         topic = Topic()
         topic.first_post = Post()
 
@@ -208,11 +208,9 @@ def createall():
         db.session.add(topic)
         db.session.commit()
 
-        # Update the post and topic count
-        topic.forum.topic_count += 1
-        topic.forum.post_count += 1
-        topic.post_count += 1
-        topic.first_post.user.post_count += 1
+        # Invalidate relevant caches
+        topic.invalidate_cache()
+        topic.forum.invalidate_cache()
 
         # create 2 additional posts for each topic
         for m in range(1, 3):
@@ -222,12 +220,9 @@ def createall():
             db.session.commit()
 
             # Update the post count
-            post.user.post_count += 1
-            topic.post_count += 1
-            topic.forum.post_count += 1
-
-            topic.last_post_id = post.id
-            topic.forum.last_post_id = post.id
+            post.user.invalidate_cache()
+            topic.invalidate_cache()
+            topic.forum.invalidate_cache()
 
             db.session.commit()