X-Git-Url: http://git.veekun.com/zzz-spline-forum.git/blobdiff_plain/3e9456eb438e2ce529eb6100170bc88de706eb2e..f36a20d6fd1cb45ef493211c3699c77b68f81ab8:/splinext/forum/controllers/forum.py diff --git a/splinext/forum/controllers/forum.py b/splinext/forum/controllers/forum.py index 93eaed4..a27dd81 100644 --- a/splinext/forum/controllers/forum.py +++ b/splinext/forum/controllers/forum.py @@ -1,31 +1,169 @@ +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, + )