ACTUALLY order threads in a forum by last-post time.
[zzz-spline-forum.git] / splinext / forum / controllers / forum.py
index 93eaed4..a27dd81 100644 (file)
+import datetime
 import logging
+import math
 
-from pylons import config, request, response, session, tmpl_context as c, url
-from pylons.controllers.util import abort, redirect_to
+from pylons import cache, config, request, response, session, tmpl_context as c, url
+from pylons.controllers.util import abort, redirect
+from pylons.decorators.secure import authenticate_form
 from routes import request_config
+from sqlalchemy.orm import aliased, contains_eager, joinedload
 from sqlalchemy.orm.exc import NoResultFound
+from sqlalchemy.sql import func
+import wtforms
+from wtforms import fields
 
 from spline.model import meta
 from spline.lib import helpers as h
 from spline.lib.base import BaseController, render
+import spline.lib.markdown
 from splinext.forum import model as forum_model
 
 log = logging.getLogger(__name__)
 
+
+def forum_activity_score(forum):
+    """Returns a number representing how active a forum is, based on the past
+    week.
+
+    The calculation is arbitrary, but 0 is supposed to mean "dead" and 1 is
+    supposed to mean "healthy".
+    """
+    cutoff = datetime.datetime.now() - datetime.timedelta(days=7)
+
+    post_count = meta.Session.query(forum_model.Post) \
+        .join(forum_model.Post.thread) \
+        .filter(forum_model.Thread.forum == forum) \
+        .filter(forum_model.Post.posted_time >= cutoff) \
+        .count()
+
+    # Avoid domain errors!
+    if not post_count:
+        return 0.0
+
+    # The log is to scale 0 posts to 0.0, and 168 posts to 1.0.
+    # The square is really just to take the edge off the log curve; it
+    # accelerates to 1 very quickly, then slows considerably after that.
+    # Squaring helps both of these problems.
+    score = (math.log(post_count) / math.log(168)) ** 2
+
+    # TODO more threads and more new threads should boost the score slightly
+
+    return score
+
+def get_forum_activity():
+    """Returns a hash mapping forum ids to their level of 'activity'."""
+    forums_q = meta.Session.query(forum_model.Forum)
+
+    activity = {}
+    for forum in forums_q:
+        activity[forum.id] = forum_activity_score(forum)
+
+    return activity
+
+def get_forum_volume():
+    """Returns a hash mapping forum ids to the percentage of all posts that
+    reside in that forum.
+    """
+    # Do a complicated-ass subquery to get a list of forums and postcounts
+    volume_q = meta.Session.query(
+            forum_model.Forum.id.label('forum_id'),
+            func.count(forum_model.Post.id).label('post_count'),
+        ) \
+        .outerjoin(forum_model.Thread) \
+        .outerjoin(forum_model.Post) \
+        .group_by(forum_model.Forum.id)
+
+    # Stick this into a hash, and count the number of total posts
+    total_posts = 0
+    volume = {}
+    for forum_id, post_count in volume_q:
+        post_count = float(post_count or 0)
+        volume[forum_id] = post_count
+        total_posts += post_count
+
+    # Divide, to get a percentage
+    if total_posts:
+        for forum_id, post_count in volume.iteritems():
+            volume[forum_id] /= total_posts
+
+    return volume
+
+
+class WritePostForm(wtforms.Form):
+    content = fields.TextAreaField('Content')
+
+class WriteThreadForm(WritePostForm):
+    subject = fields.TextField('Subject')
+
 class ForumController(BaseController):
 
     def forums(self):
         c.forums = meta.Session.query(forum_model.Forum) \
-            .order_by(forum_model.Forum.id.asc())
+            .order_by(forum_model.Forum.id.asc()) \
+            .all()
+
+        # Get some forum stats.  Cache them because they're a bit expensive to
+        # compute.  Expire after an hour.
+        # XXX when there are admin controls, they'll need to nuke this cache
+        # when messing with the forum list
+        forum_cache = cache.get_cache('spline-forum', expiretime=3600)
+        c.forum_activity = forum_cache.get_value(
+            key='forum_activity', createfunc=get_forum_activity)
+        c.forum_volume = forum_cache.get_value(
+            key='forum_volume', createfunc=get_forum_volume)
+
+        c.max_volume = max(c.forum_volume.itervalues()) or 1
+
+        # Need to know the last post for each forum, in realtime
+        c.last_post = {}
+        last_post_subq = meta.Session.query(
+                forum_model.Forum.id.label('forum_id'),
+                func.max(forum_model.Post.posted_time).label('posted_time'),
+            ) \
+            .outerjoin(forum_model.Thread) \
+            .outerjoin(forum_model.Post) \
+            .group_by(forum_model.Forum.id) \
+            .subquery()
+        last_post_q = meta.Session.query(
+                forum_model.Post,
+                last_post_subq.c.forum_id,
+            ) \
+            .join((
+                last_post_subq,
+                forum_model.Post.posted_time == last_post_subq.c.posted_time,
+            )) \
+            .options(
+                joinedload('thread'),
+                joinedload('author'),
+            )
+        for post, forum_id in last_post_q:
+            c.last_post[forum_id] = post
+
         return render('/forum/forums.mako')
 
     def threads(self, forum_id):
-        try:
-            c.forum = meta.Session.query(forum_model.Forum).get(forum_id)
-        except NoResultFound:
+        c.forum = meta.Session.query(forum_model.Forum).get(forum_id)
+        if not c.forum:
             abort(404)
 
-        c.threads = c.forum.threads
+        c.write_thread_form = WriteThreadForm()
+
+        # nb: This will never show post-less threads.  Oh well!
+        last_post = aliased(forum_model.Post)
+        threads_q = c.forum.threads \
+            .join((last_post, forum_model.Thread.last_post)) \
+            .order_by(last_post.posted_time.desc()) \
+            .options(
+                contains_eager(forum_model.Thread.last_post, alias=last_post),
+                joinedload('last_post.author'),
+            )
+        c.num_threads = threads_q.count()
+        try:
+            c.skip = int(request.params.get('skip', 0))
+        except ValueError:
+            abort(404)
+        c.per_page = 89
+        c.threads = threads_q.offset(c.skip).limit(c.per_page)
 
         return render('/forum/threads.mako')
 
@@ -36,4 +174,140 @@ class ForumController(BaseController):
         except NoResultFound:
             abort(404)
 
+        c.write_post_form = WritePostForm()
+
+        posts_q = c.thread.posts \
+            .order_by(forum_model.Post.position.asc()) \
+            .options(joinedload('author'))
+        c.num_posts = c.thread.post_count
+        try:
+            c.skip = int(request.params.get('skip', 0))
+        except ValueError:
+            abort(404)
+        c.per_page = 89
+        c.posts = posts_q.offset(c.skip).limit(c.per_page)
+
         return render('/forum/posts.mako')
+
+
+    def write_thread(self, forum_id):
+        """Provides a form for posting a new thread."""
+        if not c.user.can('forum:create-thread'):
+            abort(403)
+
+        try:
+            c.forum = meta.Session.query(forum_model.Forum) \
+                .filter_by(id=forum_id).one()
+        except NoResultFound:
+            abort(404)
+
+        c.write_thread_form = WriteThreadForm(request.params)
+        return render('/forum/write_thread.mako')
+
+    @authenticate_form
+    def write_thread_commit(self, forum_id):
+        """Posts a new thread."""
+        if not c.user.can('forum:create-thread'):
+            abort(403)
+
+        try:
+            c.forum = meta.Session.query(forum_model.Forum) \
+                .filter_by(id=forum_id).one()
+        except NoResultFound:
+            abort(404)
+
+        c.write_thread_form = WriteThreadForm(request.params)
+
+        # Reshow the form on failure
+        if not c.write_thread_form.validate():
+            return render('/forum/write_thread.mako')
+
+        # Otherwise, add the post.
+        c.forum = meta.Session.query(forum_model.Forum) \
+            .with_lockmode('update') \
+            .get(c.forum.id)
+
+        thread = forum_model.Thread(
+            forum_id = c.forum.id,
+            subject = c.write_thread_form.subject.data,
+            post_count = 1,
+        )
+        source = c.write_thread_form.content.data
+        post = forum_model.Post(
+            position = 1,
+            author_user_id = c.user.id,
+            raw_content = source,
+            content = spline.lib.markdown.translate(source),
+        )
+
+        thread.posts.append(post)
+        c.forum.threads.append(thread)
+
+        meta.Session.commit()
+
+        # Redirect to the new thread
+        h.flash("Contribution to the collective knowledge of the species successfully recorded.")
+        redirect(
+            url(controller='forum', action='posts',
+                forum_id=forum_id, thread_id=thread.id),
+            code=303,
+        )
+
+    def write(self, forum_id, thread_id):
+        """Provides a form for posting to a thread."""
+        if not c.user.can('forum:create-post'):
+            abort(403)
+
+        try:
+            c.thread = meta.Session.query(forum_model.Thread) \
+                .filter_by(id=thread_id, forum_id=forum_id).one()
+        except NoResultFound:
+            abort(404)
+
+        c.write_post_form = WritePostForm(request.params)
+        return render('/forum/write.mako')
+
+    @authenticate_form
+    def write_commit(self, forum_id, thread_id):
+        """Post to a thread."""
+        if not c.user.can('forum:create-post'):
+            abort(403)
+
+        try:
+            c.thread = meta.Session.query(forum_model.Thread) \
+                .filter_by(id=thread_id, forum_id=forum_id).one()
+        except NoResultFound:
+            abort(404)
+
+        c.write_post_form = WritePostForm(request.params)
+
+        # Reshow the form on failure
+        if not c.write_post_form.validate():
+            return render('/forum/write.mako')
+
+        # Otherwise, add the post.
+        c.thread = meta.Session.query(forum_model.Thread) \
+            .with_lockmode('update') \
+            .get(c.thread.id)
+
+        source = c.write_post_form.content.data
+        post = forum_model.Post(
+            position = c.thread.post_count + 1,
+            author_user_id = c.user.id,
+            raw_content = source,
+            content = spline.lib.markdown.translate(source),
+        )
+
+        c.thread.posts.append(post)
+        c.thread.post_count += 1
+
+        meta.Session.commit()
+
+        # Redirect to the thread
+        # XXX probably to the post instead; anchor?  depends on paging scheme
+        h.flash('Your uniqueness has been added to our own.')
+        redirect(
+            url(controller='forum', action='posts',
+                forum_id=forum_id, thread_id=thread_id),
+            code=303,
+        )