Browse Source

Implemented the 'unread' feature.
We needed more queries with the caching thing than with a bit of denormalization

sh4nks 11 years ago
parent
commit
bc2360fd77

+ 4 - 1
flaskbb/app.py

@@ -30,7 +30,8 @@ from flaskbb.extensions import db, login_manager, mail, cache
 from flaskbb.utils.helpers import (format_date, time_since, crop_title,
                                    can_post_reply, can_post_topic,
                                    can_delete_topic, can_delete_post, is_online,
-                                   can_edit_post, render_markup, mark_online)
+                                   can_edit_post, render_markup, mark_online,
+                                   is_unread)
 
 
 DEFAULT_BLUEPRINTS = (
@@ -137,6 +138,8 @@ def configure_template_filters(app):
     app.jinja_env.filters['time_since'] = time_since
     app.jinja_env.filters['is_online'] = is_online
     app.jinja_env.filters['crop_title'] = crop_title
+    app.jinja_env.filters['is_unread'] = is_unread
+    # Permission filters
     app.jinja_env.filters['edit_post'] = can_edit_post
     app.jinja_env.filters['delete_post'] = can_delete_post
     app.jinja_env.filters['delete_topic'] = can_delete_topic

+ 4 - 0
flaskbb/configs/default.py

@@ -92,3 +92,7 @@ class DefaultConfig(object):
 
     # This is really handy if do not have an redis instance like on windows
     USE_REDIS = True
+
+    # The days for how long the forum should deal with unread topics
+    # 0 - Disable it
+    TRACKER_LENGTH = 0

+ 12 - 6
flaskbb/forum/helpers.py

@@ -27,8 +27,7 @@ def get_child_ids(forum):
 
 def get_parent_ids(forum):
     """
-    Returns a list of forum ids for the passed `forum` object and its
-    parent and child hierarchy.
+    Returns a list of parent forum ids for the passed `forum` object.
     """
     forum_ids = []
     parent = forum.parent
@@ -40,6 +39,10 @@ def get_parent_ids(forum):
 
 
 def get_forum_ids(forum):
+    """
+    Returns a list of forum ids for the passed `forum` object and its
+    parent and child hierarchy.
+    """
     forum_ids = []
     parent = forum.parent
     while parent is not None:
@@ -52,23 +55,26 @@ def get_forum_ids(forum):
     return set(forum_ids)
 
 
-def get_forums(forum_query):
+def get_forums(forum_query, current_user=False):
     """
     Pack all forum objects in a dict
     It looks like this:
       Category      Forum         Subforums
     {<Forum 1)>: {<Forum 2)>: [<Forum 5)>, <Forum 6)>]},
     """
+    if not current_user:
+        forum_query = [(item, None) for item in forum_query]
+
     forums = OrderedDict()
     for category in forum_query:
-        if category.is_category:
+        if category[0].is_category:
             forums[category] = OrderedDict()
 
             for forum in forum_query:
-                if forum.parent_id == category.id:
+                if forum[0].parent_id == category[0].id:
                     forums[category][forum] = []
 
                     for subforum in forum_query:
-                        if subforum.parent_id == forum.id:
+                        if subforum[0].parent_id == forum[0].id:
                             forums[category][forum].append(subforum)
     return forums

+ 182 - 269
flaskbb/forum/models.py

@@ -8,11 +8,11 @@
     :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, cache
-from flaskbb.helpers import DenormalizedText
+from flaskbb.extensions import db
+from flaskbb.utils.types import DenormalizedText
+from flaskbb.utils.query import TopicQuery
 from helpers import get_forum_ids
 
 
@@ -34,7 +34,7 @@ class Post(db.Model):
         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)
+        return "<{} {}>".format(self.__class__.__name__, self.id)
 
     def save(self, user=None, topic=None):
         # update/edit the post
@@ -53,10 +53,22 @@ class Post(db.Model):
             db.session.add(self)
             db.session.commit()
 
-            # Invalidate relevant caches
-            user.invalidate_cache()
-            topic.invalidate_cache()
-            topic.forum.invalidate_cache()
+            # Now lets update the last post id
+            topic.last_post_id = self.id
+            topic.last_updated = datetime.utcnow()
+            topic.forum.last_post_id = self.id
+
+            # Update the post counts
+            user.post_count += 1
+            topic.post_count += 1
+            topic.forum.post_count += 1
+
+            # Update the parent forums
+            parent = topic.forum.parent
+            while parent is not None and not parent.is_category:
+                parent.last_post_id = self.id
+                parent.post_count += 1
+                parent = parent.parent
 
             # And commit it!
             db.session.add(topic)
@@ -69,12 +81,34 @@ class Post(db.Model):
             self.topic.delete()
             return self
 
-        # Invalidate relevant caches
-        self.user.invalidate_cache()
-        self.topic.invalidate_cache()
-        self.topic.forum.invalidate_cache()
+        # 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
+
+            # check if the last_post is also the last post in the forum
+            topic = Topic.query.\
+                filter(Topic.forum_id.in_(get_forum_ids(self.topic.forum))).\
+                order_by(Topic.last_post_id.desc()).first()
+
+            if self.topic.last_post_id == topic.last_post_id:
+                # Update the parent forums
+                forum = self.topic.forum
+                while forum is not None and not forum.is_category:
+                    forum.last_post_id = self.topic.second_last_post
+                    forum = forum.parent
+                db.session.commit()
+
+        # Update the post counts
+        forum = self.topic.forum
+        while forum is not None and not forum.is_category:
+            forum.post_count -= 1
+            forum = forum.parent
+
+        self.user.post_count -= 1
+        self.topic.post_count -= 1
 
-        # Is there a better way to do this?
         db.session.delete(self)
         db.session.commit()
         return self
@@ -83,15 +117,32 @@ class Post(db.Model):
 class Topic(db.Model):
     __tablename__ = "topics"
 
+    query_class = TopicQuery
+
     id = db.Column(db.Integer, primary_key=True)
     forum_id = db.Column(db.Integer, db.ForeignKey("forums.id", use_alter=True,
                                                    name="fk_forum_id"))
     title = db.Column(db.String)
     user_id = db.Column(db.Integer, db.ForeignKey("users.id"))
     date_created = db.Column(db.DateTime, default=datetime.utcnow())
+    last_updated = db.Column(db.DateTime, default=datetime.utcnow())
     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",
@@ -100,33 +151,6 @@ class Topic(db.Model):
 
     # 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):
         """
         Returns the second last post.
@@ -143,7 +167,7 @@ class Topic(db.Model):
         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)
+        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)
@@ -164,31 +188,76 @@ class Topic(db.Model):
         # Create the topic post
         post.save(user, self)
 
-        # Invalidate relevant caches
-        self.invalidate_cache()
-        self.forum.invalidate_cache()
+        # Update the first post id
+        self.first_post_id = post.id
+
+        # Update the topic count
+        forum.topic_count += 1
+
+        # Update the parent forums
+        parent = forum.parent
+        while parent is not None and not parent.is_category:
+            # Update the topic and post count
+            parent.topic_count += 1
+            parent = parent.parent
 
         db.session.commit()
 
         return self
 
     def delete(self, users=None):
-        # Invalidate relevant caches
-        self.invalidate_cache()
-        self.forum.invalidate_cache()
-
-        # Invalidate user post counts
-        if users:
-            for user in users:
-                user.invalidate_cache()
+        # Grab the second last topic in the forum + parents/childs
+        topic = Topic.query.\
+            filter(Topic.forum_id.in_(get_forum_ids(self.forum))).\
+            order_by(Topic.last_post_id.desc()).limit(2).offset(0).all()
+
+        # check if the topic is the most recently one in this forum
+        try:
+            forum = self.forum
+            if self.id == topic[0].id:
+                # Now the second last post will be the last post
+                while forum is not None and not forum.is_category:
+                    forum.last_post_id = topic[1].last_post_id
+                    forum.save()
+                    forum = forum.parent
+            else:
+                forum.last_post_id = topic[1].last_post_id
+        # Catch an IndexError when you delete the last topic in the forum
+        except IndexError:
+            while forum is not None and not forum.is_category:
+                forum.last_post_id = 0
+                forum.save()
+                forum = forum.parent
+
+        # These things needs to be stored in a variable before they are deleted
+        forum = self.forum
 
         # 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()
+
+        while forum is not None and not forum.is_category:
+            forum.topic_count = Topic.query.filter_by(
+                forum_id=self.forum_id).count()
+
+            forum.post_count = Post.query.filter(
+                Post.topic_id == Topic.id,
+                Topic.forum_id == self.forum_id).count()
+
+            forum = forum.parent
+
+        db.session.commit()
         return self
 
-    def update_read(self, user):
+    def update_read(self, user, forum=None):
         """
         Update the topics read status if the user hasn't read the latest
         post.
@@ -214,75 +283,35 @@ class Topic(db.Model):
             topicread.last_read = datetime.utcnow()
             topicread.save()
 
-    def is_unread(self, user):
-        """
-        Returns True if the user hasn't read the topic
-        """
-        if not user.is_authenticated():
-            return True
-
-        topicread = TopicsRead.query.\
-            filter(TopicsRead.user_id == user.id,
-                   TopicsRead.topic_id == self.id).first()
-
-        # If no entry is found, the user hasn't read the topic
-        if not topicread:
-            return True
-        # If the entry is older than the last post, the user hasn't read it
-        if topicread.last_read < self.last_post.date_created:
-            return True
-        return False
-
-    @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()
-
-    @cache.memoize(timeout=sys.maxint)
-    def get_first_post(self):
-        """
-        Returns the first post within the current topic.
-        """
-
-        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()
+        if forum:
+            # fetch the unread posts in the forum
+            unread_count = Topic.query.\
+                outerjoin(TopicsRead,
+                          db.and_(TopicsRead.topic_id == Topic.id,
+                                  TopicsRead.user_id == user.id)).\
+                outerjoin(ForumsRead,
+                          db.and_(ForumsRead.forum_id == Topic.forum_id,
+                                  ForumsRead.user_id == user.id)).\
+                filter(Topic.forum_id == forum.id,
+                       db.or_(TopicsRead.last_read == None,
+                              TopicsRead.last_read < Topic.last_updated)).\
+                count()
+                # Why doesn't this work with when you pass an last_post object?
+                # Line 294 e.q.: TopicsRead.last_read < last_post.date_created
 
-        # Load the topic and user before we cache
-        post.topic
-        post.user
+            # Mark it as read if no unread topics are found
+            if unread_count == 0:
+                forumread = ForumsRead.query.\
+                    filter(ForumsRead.user_id == user.id,
+                           ForumsRead.forum_id == forum.id).first()
 
-        return post
+                # If the user has never visited a topic in this forum
+                # create a new entry
+                if not forumread:
+                    forumread = ForumsRead(user_id=user.id, forum_id=forum.id)
 
-    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)
+                forumread.last_read = datetime.utcnow()
+                forumread.save()
 
 
 class Forum(db.Model):
@@ -296,39 +325,20 @@ class Forum(db.Model):
     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.
+    post_count = db.Column(db.Integer, default=0)
+    topic_count = db.Column(db.Integer, default=0)
 
-        Method seperate for easy invalidation of cache.
-        """
-        return self.get_topic_count()
+    # 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])
 
-    @property
-    def last_post(self):
-        """
-        Property interface for get_last_post method.
+    # One-to-many
+    topics = db.relationship("Topic", backref="forum", lazy="joined")
+    children = db.relationship("Forum",
+                               backref=db.backref("parent", remote_side=[id]))
 
-        Method seperate for easy invalidation of cache.
-        """
-        return self.get_last_post()
+    moderators = db.Column(DenormalizedText)
 
     # Methods
     def __repr__(self):
@@ -336,7 +346,7 @@ class Forum(db.Model):
         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)
+        return "<{} {}>".format(self.__class__.__name__, self.id)
 
     def add_moderator(self, user_id):
         self.moderators.add(user_id)
@@ -364,116 +374,7 @@ class Forum(db.Model):
         breadcrumbs.reverse()
         return breadcrumbs
 
-    def is_unread(self, user, include_children=True):
-        """
-        Returns True if the user hasn't read the topic
-        """
-        if not user.is_authenticated():
-            return True
-
-        if include_children:
-            topicread = TopicsRead.query.\
-                filter(TopicsRead.user_id == user.id). \
-                filter(TopicsRead.topic_id == Topic.id). \
-                filter(Topic.forum_id.in_(get_forum_ids(self))). \
-                order_by(TopicsRead.last_read.desc()). \
-                first()
-        else:
-            topicread = TopicsRead.query.\
-                filter(TopicsRead.user_id == user.id). \
-                filter(TopicsRead.topic_id == Topic.id). \
-                filter(Topic.forum_id == self.id). \
-                order_by(TopicsRead.last_read.desc()). \
-                first()
-
-        # If no entry is found, the user hasn't read the topic
-        if not topicread:
-            return True
-        # If the entry is older than the last post, the user hasn't read it
-        if topicread.last_read < self.last_post.date_created:
-            return True
-        return False
-
-    @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'.
-        """
 
-        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'.
-        """
-
-        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'.
-        """
-
-        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)
-
-
-"""
-This model stores the tracked topics for each user
-"""
 topictracker = db.Table(
     'topictracker',
     db.Column('user_id', db.Integer(), db.ForeignKey('users.id')),
@@ -481,22 +382,34 @@ topictracker = db.Table(
 
 
 class TopicsRead(db.Model):
-    """
-    This model tracks the unread/read posts
-    """
     __tablename__ = "topicsread"
 
-    user_id = db.Column(db.Integer, db.ForeignKey("users.id"), primary_key=True)
+    user_id = db.Column(db.Integer, db.ForeignKey("users.id"),
+                        primary_key=True)
+    #topics = db.Column(MutableDict.as_mutable(JSONEncodedDict))
     topic_id = db.Column(db.Integer, db.ForeignKey("topics.id"),
                          primary_key=True)
     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()
+        return self
+
+    def delete(self):
+        db.session.delete(self)
+        db.session.commit()
+        return self
+
+
+class ForumsRead(db.Model):
+    __tablename__ = "forumsread"
+
+    user_id = db.Column(db.Integer, db.ForeignKey("users.id"),
+                        primary_key=True)
+    forum_id = db.Column(db.Integer, db.ForeignKey("topics.id"),
+                         primary_key=True)
+    last_read = db.Column(db.DateTime, default=datetime.utcnow())
 
     def save(self):
         db.session.add(self)

+ 50 - 14
flaskbb/forum/views.py

@@ -13,13 +13,14 @@ import datetime
 import math
 
 from flask import (Blueprint, render_template, redirect, url_for, current_app,
-                   request, flash)
+                   request, flash, abort)
 from flask.ext.login import login_required, current_user
 
+from flaskbb.extensions import db
 from flaskbb.utils.helpers import (can_post_reply, can_delete_topic,
                                    can_edit_post, can_post_topic,
                                    can_delete_post, get_online_users, time_diff)
-from flaskbb.forum.models import Forum, Topic, Post
+from flaskbb.forum.models import Forum, Topic, Post, ForumsRead, TopicsRead
 from flaskbb.forum.forms import QuickreplyForm, ReplyForm, NewTopicForm
 from flaskbb.forum.helpers import get_forums
 from flaskbb.user.models import User
@@ -30,7 +31,18 @@ forum = Blueprint("forum", __name__)
 
 @forum.route("/")
 def index():
-    categories = Forum.query.all()
+    # Get the categories and forums
+    if current_user.is_authenticated():
+        categories_query = Forum.query.\
+            outerjoin(ForumsRead,
+                      db.and_(ForumsRead.forum_id == Forum.id,
+                              ForumsRead.user_id == current_user.id)).\
+            add_entity(ForumsRead).\
+            all()
+        categories = get_forums(categories_query, current_user=True)
+    else:
+        categories_query = Forum.query.all()
+        categories = get_forums(categories_query, current_user=False)
 
     # Fetch a few stats about the forum
     user_count = User.query.count()
@@ -38,6 +50,7 @@ def index():
     post_count = Post.query.count()
     newest_user = User.query.order_by(User.id.desc()).first()
 
+    # Check if we use redis or not
     if not current_app.config["USE_REDIS"]:
         online_users = User.query.filter(User.lastseen >= time_diff()).count()
         online_guests = None
@@ -46,7 +59,7 @@ def index():
         online_guests = len(get_online_users(guest=True))
 
     return render_template("forum/index.html",
-                           categories=get_forums(categories),
+                           categories=categories,
                            user_count=user_count,
                            topic_count=topic_count,
                            post_count=post_count,
@@ -59,16 +72,40 @@ def index():
 def view_forum(forum_id):
     page = request.args.get('page', 1, type=int)
 
-    forum = Forum.query.filter_by(id=forum_id).first()
-    # TODO: Get all subforums with one query
+    if current_user.is_authenticated():
+        forum = Forum.query.filter(Forum.id == forum_id).first()
+
+        subforums = Forum.query.\
+            filter(Forum.parent_id == forum.id).\
+            outerjoin(ForumsRead,
+                      db.and_(ForumsRead.forum_id == Forum.id,
+                              ForumsRead.user_id == current_user.id)).\
+            add_entity(ForumsRead).\
+            all()
+
+        topics = Topic.query.filter_by(forum_id=forum.id).\
+            filter(Post.topic_id == Topic.id).\
+            outerjoin(TopicsRead,
+                      db.and_(TopicsRead.topic_id == Topic.id,
+                              TopicsRead.user_id == current_user.id)).\
+            add_entity(TopicsRead).\
+            order_by(Post.id.desc()).\
+            paginate(page, current_app.config['TOPICS_PER_PAGE'], True, True)
+    else:
+        forum = Forum.query.filter(Forum.id == forum_id).first()
 
-    topics = Topic.query.filter_by(forum_id=forum.id).\
-        filter(Post.topic_id == Topic.id).\
-        order_by(Post.id.desc()).\
-        paginate(page, current_app.config['TOPICS_PER_PAGE'], False)
+        subforums = Forum.query.filter(Forum.parent_id == forum.id).all()
+        # This isn't really nice imho, but "add_entity" (see above)
+        # makes a list with tuples
+        subforums = [(item, None) for item in subforums]
+
+        topics = Topic.query.filter_by(forum_id=forum.id).\
+            filter(Post.topic_id == Topic.id).\
+            order_by(Post.id.desc()).\
+            paginate(page, current_app.config['TOPICS_PER_PAGE'], True, False)
 
     return render_template("forum/forum.html",
-                           forum=forum, topics=topics)
+                           forum=forum, topics=topics, subforums=subforums)
 
 
 @forum.route("/topic/<int:topic_id>", methods=["POST", "GET"])
@@ -82,8 +119,8 @@ def view_topic(topic_id):
     # Count the topic views
     topic.views += 1
 
-    # Update the topicsread status if he hasn't read it
-    #topic.update_read(current_user)
+    # Update the topicsread status if the user hasn't read it
+    topic.update_read(current_user, topic.forum)
     topic.save()
 
     form = None
@@ -99,7 +136,6 @@ def view_topic(topic_id):
                 return view_post(post.id)
 
     return render_template("forum/topic.html", topic=topic, posts=posts,
-                           per_page=current_app.config['POSTS_PER_PAGE'],
                            last_seen=time_diff(), form=form)
 
 

+ 11 - 12
flaskbb/templates/forum/forum.html

@@ -22,7 +22,7 @@
     <a href="{{ url_for('forum.new_topic', forum_id=forum.id) }}" class="btn btn-primary">New Topic</a>
 </div>
 {% endif %}
-{% if forum.children|length %}
+{% if subforums[0]|length %}
 <table class="table table-bordered">
     <thead>
         <tr>
@@ -39,16 +39,15 @@
     </thead>
 
     <tbody>
-        {% for subforum in forum.children %}
+        {% for subforum, forumread in subforums %}
         <tr>
             <td align="center" valign="center" width="4%">
-            {#
-            {% if subforum.is_unread(current_user) %}
-                New posts
+
+            {% if forumread|is_unread(subforum.last_post) %}
+                Unread
             {% else %}
-                No new posts
+                Read
             {% endif %}
-            #}
             </td>
 
             <td valign="top">
@@ -115,16 +114,16 @@
             <td>Last Post</td>
         </tr>
 
-        {% for topic in topics.items %}
+        {% for topic, topicread in topics.items %}
         <tr>
             <td width="4%">
-            {#
-            {% if topic.is_unread(current_user) %}
-                New
+
+            {% if topicread|is_unread(topic.last_post) %}
+                Unread
             {% else %}
                 Read
             {% endif %}
-            #}
+
             </td>
             <td>
                 <div>

+ 16 - 16
flaskbb/templates/forum/index.html

@@ -10,7 +10,7 @@
     <thead class="categoryhead">
         <tr>
             <td colspan="5">
-                <div><strong><a href="{{ url_for('forum.view_forum', forum_id=category.id) }}">{{ category.title }}</a></strong></div>
+                <div><strong><a href="{{ url_for('forum.view_forum', forum_id=category[0].id) }}">{{ category[0].title }}</a></strong></div>
             </td>
         </tr>
     </thead>
@@ -25,21 +25,21 @@
         {% for forum, subforums in forums.items() %}
         <tr>
             <td align="center" valign="center" width="4%">
-            {#
-            {% if forum.is_unread(current_user) %}
-                New posts
+
+            {% if forum[1]|is_unread(forum[0].last_post) %}
+                Unread
             {% else %}
-                No new posts
+                Read
             {% endif %}
-            #}
+
             </td>
 
             <td valign="top">
-                <strong><a href="{{ url_for('forum.view_forum', forum_id=forum.id) }}">{{ forum.title }}</a></strong>
+                <strong><a href="{{ url_for('forum.view_forum', forum_id=forum[0].id) }}">{{ forum[0].title }}</a></strong>
 
                 <div class="forum-description">
                     {% autoescape false %}
-                    {{ forum.description|markup }}
+                    {{ forum[0].description|markup }}
                     {% endautoescape %}
                 </div>
                 {% if subforums|length %}
@@ -48,7 +48,7 @@
                         <li><strong>Subforums:</strong></li>
                         {% for subforum in subforums %}
                         <li>
-                            <a href="{{ url_for('forum.view_forum', forum_id=subforum.id) }}">{{ subforum.title }}</a>
+                            <a href="{{ url_for('forum.view_forum', forum_id=subforum[0].id) }}">{{ subforum[0].title }}</a>
                         </li>
                         {% endfor %}
                     </ul>
@@ -56,17 +56,17 @@
                 {% 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="center" style="white-space: nowrap">{{ forum[0].topic_count }}</td>
+            <td valign="top" align="center" style="white-space: nowrap">{{ forum[0].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 }}">
-                    <strong>{{ forum.last_post.topic.title|crop_title }}</strong>
+                {% if forum[0].last_post_id %}
+                <a href="{{ url_for('forum.view_post', post_id=forum[0].last_post_id) }}" title="{{ forum[0].last_post.topic.title }}">
+                    <strong>{{ forum[0].last_post.topic.title|crop_title }}</strong>
                 </a>
                 <br />
-                {{ forum.last_post.date_created|time_since }}<br />
-                by <a href="{{ url_for('user.profile', username=forum.last_post.user.username) }}">{{ forum.last_post.user.username }}</a>
+                {{ forum[0].last_post.date_created|time_since }}<br />
+                by <a href="{{ url_for('user.profile', username=forum[0].last_post.user.username) }}">{{ forum[0].last_post.user.username }}</a>
                 {% else %}
                 No posts
                 {% endif %}

+ 9 - 29
flaskbb/user/models.py

@@ -106,6 +106,15 @@ class User(db.Model, UserMixin):
                         backref=db.backref("topicstracked", lazy="dynamic"),
                         lazy="dynamic")
 
+    # Properties
+    @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()
+
     # Methods
     def __repr__(self):
         """
@@ -283,35 +292,6 @@ 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)

+ 16 - 0
flaskbb/utils/helpers.py

@@ -15,6 +15,22 @@ from flask import current_app
 from postmarkup import render_bbcode
 
 from flaskbb.extensions import redis
+from flaskbb.forum.models import ForumsRead, TopicsRead
+
+
+def is_unread(read_object, last_post):
+    if not (isinstance(read_object, ForumsRead) or
+            isinstance(read_object, TopicsRead) or not None):
+        raise TypeError("Must be a ForumsRead or TopicsRead object")
+
+    read_cutoff = datetime.utcnow() - timedelta(
+        days=current_app.config['TRACKER_LENGTH'])
+
+    if read_object is None:
+        return True
+    if read_cutoff < last_post.date_created:
+        return False
+    return read_object.last_read < last_post.date_created
 
 
 def mark_online(user_id, guest=False):

+ 140 - 0
flaskbb/utils/query.py

@@ -0,0 +1,140 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.utils.query
+    ~~~~~~~~~~~~~~~~~~~
+
+    This module holds the query classes that are used by flaskbb
+
+    :copyright: (c) 2013 by the FlaskBB Team.
+    :license: BSD, see LICENSE for more details.
+"""
+from math import ceil
+
+from flask import abort
+from flask.ext.sqlalchemy import BaseQuery
+
+
+class Pagination(object):
+    """Internal helper class returned by :meth:`BaseQuery.paginate`.  You
+    can also construct it from any other SQLAlchemy query object if you are
+    working with other libraries.  Additionally it is possible to pass `None`
+    as query object in which case the :meth:`prev` and :meth:`next` will
+    no longer work.
+    """
+
+    def __init__(self, query, page, per_page, total, items, current_user):
+        #: the unlimited query object that was used to create this
+        #: pagination object.
+        self.query = query
+        #: the current page number (1 indexed)
+        self.page = page
+        #: the number of items to be displayed on a page.
+        self.per_page = per_page
+        #: the total number of items matching the query
+        self.total = total
+        #: the items for the current page
+        if not current_user:
+            self.items = [(item, None) for item in items]
+        else:
+            self.items = items
+
+    @property
+    def pages(self):
+        """The total number of pages"""
+        if self.per_page == 0:
+            pages = 0
+        else:
+            pages = int(ceil(self.total / float(self.per_page)))
+        return pages
+
+    def prev(self, error_out=False):
+        """Returns a :class:`Pagination` object for the previous page."""
+        assert self.query is not None, 'a query object is required ' \
+                                       'for this method to work'
+        return self.query.paginate(self.page - 1, self.per_page, error_out)
+
+    @property
+    def prev_num(self):
+        """Number of the previous page."""
+        return self.page - 1
+
+    @property
+    def has_prev(self):
+        """True if a previous page exists"""
+        return self.page > 1
+
+    def next(self, error_out=False):
+        """Returns a :class:`Pagination` object for the next page."""
+        assert self.query is not None, 'a query object is required ' \
+                                       'for this method to work'
+        return self.query.paginate(self.page + 1, self.per_page, error_out)
+
+    @property
+    def has_next(self):
+        """True if a next page exists."""
+        return self.page < self.pages
+
+    @property
+    def next_num(self):
+        """Number of the next page"""
+        return self.page + 1
+
+    def iter_pages(self, left_edge=2, left_current=2,
+                   right_current=5, right_edge=2):
+        """Iterates over the page numbers in the pagination.  The four
+        parameters control the thresholds how many numbers should be produced
+        from the sides.  Skipped page numbers are represented as `None`.
+        This is how you could render such a pagination in the templates:
+
+        .. sourcecode:: html+jinja
+
+            {% macro render_pagination(pagination, endpoint) %}
+              <div class=pagination>
+              {%- for page in pagination.iter_pages() %}
+                {% if page %}
+                  {% if page != pagination.page %}
+                    <a href="{{ url_for(endpoint, page=page) }}">{{ page }}</a>
+                  {% else %}
+                    <strong>{{ page }}</strong>
+                  {% endif %}
+                {% else %}
+                  <span class=ellipsis>…</span>
+                {% endif %}
+              {%- endfor %}
+              </div>
+            {% endmacro %}
+        """
+        last = 0
+        for num in xrange(1, self.pages + 1):
+            if num <= left_edge or \
+               (num > self.page - left_current - 1 and
+                num < self.page + right_current) or \
+               num > self.pages - right_edge:
+                if last + 1 != num:
+                    yield None
+                yield num
+                last = num
+
+
+class TopicQuery(BaseQuery):
+    def paginate(self, page, per_page=20, error_out=True, current_user=False):
+        """Returns `per_page` items from page `page`.  By default it will
+        abort with 404 if no items were found and the page was larger than
+        1.  This behavor can be disabled by setting `error_out` to `False`.
+
+        Returns an :class:`Pagination` object.
+        """
+        if error_out and page < 1:
+            abort(404)
+        items = self.limit(per_page).offset((page - 1) * per_page).all()
+        if not items and page != 1 and error_out:
+            abort(404)
+
+        # No need to count if we're on the first page and there are fewer
+        # items than we expected.
+        if page == 1 and len(items) < per_page:
+            total = len(items)
+        else:
+            total = self.order_by(None).count()
+
+        return Pagination(self, page, per_page, total, items, current_user)

+ 54 - 0
flaskbb/utils/types.py

@@ -10,6 +10,8 @@
 """
 from sqlalchemy import types
 from sqlalchemy.ext.mutable import Mutable
+import json
+import collections
 
 
 class DenormalizedText(Mutable, types.TypeDecorator):
@@ -47,3 +49,55 @@ class DenormalizedText(Mutable, types.TypeDecorator):
 
     def copy_value(self, value):
         return set(value)
+
+
+# http://docs.sqlalchemy.org/en/rel_0_9/orm/extensions/mutable.html
+class MutableDict(Mutable, dict):
+    @classmethod
+    def coerce(cls, key, value):
+        """
+        Convert plain dictionaries to MutableDict.
+        """
+
+        if not isinstance(value, MutableDict):
+            if isinstance(value, dict):
+                return MutableDict(value)
+
+            # this call will raise ValueError
+            return Mutable.coerce(key, value)
+        else:
+            return value
+
+    def __setitem__(self, key, value):
+        """
+        Detect dictionary set events and emit change events.
+        """
+
+        dict.__setitem__(self, key, value)
+        self.changed()
+
+    def __delitem__(self, key):
+        """
+        Detect dictionary del events and emit change events.
+        """
+
+        dict.__delitem__(self, key)
+        self.changed()
+
+
+class JSONEncodedDict(types.TypeDecorator):
+    """
+    Represents an immutable structure as a json-encoded string.
+    """
+
+    impl = types.VARCHAR
+
+    def process_bind_param(self, value, dialect):
+        if value is not None:
+            value = json.dumps(value)
+        return value
+
+    def process_result_value(self, value, dialect):
+        if value is not None:
+            value = json.loads(value)
+        return value