+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')
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,
+ )