ACTUALLY order threads in a forum by last-post time.
[zzz-spline-forum.git] / splinext / forum / controllers / forum.py
1 import datetime
2 import logging
3 import math
4
5 from pylons import cache, config, request, response, session, tmpl_context as c, url
6 from pylons.controllers.util import abort, redirect
7 from pylons.decorators.secure import authenticate_form
8 from routes import request_config
9 from sqlalchemy.orm import aliased, contains_eager, joinedload
10 from sqlalchemy.orm.exc import NoResultFound
11 from sqlalchemy.sql import func
12 import wtforms
13 from wtforms import fields
14
15 from spline.model import meta
16 from spline.lib import helpers as h
17 from spline.lib.base import BaseController, render
18 import spline.lib.markdown
19 from splinext.forum import model as forum_model
20
21 log = logging.getLogger(__name__)
22
23
24 def forum_activity_score(forum):
25 """Returns a number representing how active a forum is, based on the past
26 week.
27
28 The calculation is arbitrary, but 0 is supposed to mean "dead" and 1 is
29 supposed to mean "healthy".
30 """
31 cutoff = datetime.datetime.now() - datetime.timedelta(days=7)
32
33 post_count = meta.Session.query(forum_model.Post) \
34 .join(forum_model.Post.thread) \
35 .filter(forum_model.Thread.forum == forum) \
36 .filter(forum_model.Post.posted_time >= cutoff) \
37 .count()
38
39 # Avoid domain errors!
40 if not post_count:
41 return 0.0
42
43 # The log is to scale 0 posts to 0.0, and 168 posts to 1.0.
44 # The square is really just to take the edge off the log curve; it
45 # accelerates to 1 very quickly, then slows considerably after that.
46 # Squaring helps both of these problems.
47 score = (math.log(post_count) / math.log(168)) ** 2
48
49 # TODO more threads and more new threads should boost the score slightly
50
51 return score
52
53 def get_forum_activity():
54 """Returns a hash mapping forum ids to their level of 'activity'."""
55 forums_q = meta.Session.query(forum_model.Forum)
56
57 activity = {}
58 for forum in forums_q:
59 activity[forum.id] = forum_activity_score(forum)
60
61 return activity
62
63 def get_forum_volume():
64 """Returns a hash mapping forum ids to the percentage of all posts that
65 reside in that forum.
66 """
67 # Do a complicated-ass subquery to get a list of forums and postcounts
68 volume_q = meta.Session.query(
69 forum_model.Forum.id.label('forum_id'),
70 func.count(forum_model.Post.id).label('post_count'),
71 ) \
72 .outerjoin(forum_model.Thread) \
73 .outerjoin(forum_model.Post) \
74 .group_by(forum_model.Forum.id)
75
76 # Stick this into a hash, and count the number of total posts
77 total_posts = 0
78 volume = {}
79 for forum_id, post_count in volume_q:
80 post_count = float(post_count or 0)
81 volume[forum_id] = post_count
82 total_posts += post_count
83
84 # Divide, to get a percentage
85 if total_posts:
86 for forum_id, post_count in volume.iteritems():
87 volume[forum_id] /= total_posts
88
89 return volume
90
91
92 class WritePostForm(wtforms.Form):
93 content = fields.TextAreaField('Content')
94
95 class WriteThreadForm(WritePostForm):
96 subject = fields.TextField('Subject')
97
98 class ForumController(BaseController):
99
100 def forums(self):
101 c.forums = meta.Session.query(forum_model.Forum) \
102 .order_by(forum_model.Forum.id.asc()) \
103 .all()
104
105 # Get some forum stats. Cache them because they're a bit expensive to
106 # compute. Expire after an hour.
107 # XXX when there are admin controls, they'll need to nuke this cache
108 # when messing with the forum list
109 forum_cache = cache.get_cache('spline-forum', expiretime=3600)
110 c.forum_activity = forum_cache.get_value(
111 key='forum_activity', createfunc=get_forum_activity)
112 c.forum_volume = forum_cache.get_value(
113 key='forum_volume', createfunc=get_forum_volume)
114
115 c.max_volume = max(c.forum_volume.itervalues()) or 1
116
117 # Need to know the last post for each forum, in realtime
118 c.last_post = {}
119 last_post_subq = meta.Session.query(
120 forum_model.Forum.id.label('forum_id'),
121 func.max(forum_model.Post.posted_time).label('posted_time'),
122 ) \
123 .outerjoin(forum_model.Thread) \
124 .outerjoin(forum_model.Post) \
125 .group_by(forum_model.Forum.id) \
126 .subquery()
127 last_post_q = meta.Session.query(
128 forum_model.Post,
129 last_post_subq.c.forum_id,
130 ) \
131 .join((
132 last_post_subq,
133 forum_model.Post.posted_time == last_post_subq.c.posted_time,
134 )) \
135 .options(
136 joinedload('thread'),
137 joinedload('author'),
138 )
139 for post, forum_id in last_post_q:
140 c.last_post[forum_id] = post
141
142 return render('/forum/forums.mako')
143
144 def threads(self, forum_id):
145 c.forum = meta.Session.query(forum_model.Forum).get(forum_id)
146 if not c.forum:
147 abort(404)
148
149 c.write_thread_form = WriteThreadForm()
150
151 # nb: This will never show post-less threads. Oh well!
152 last_post = aliased(forum_model.Post)
153 threads_q = c.forum.threads \
154 .join((last_post, forum_model.Thread.last_post)) \
155 .order_by(last_post.posted_time.desc()) \
156 .options(
157 contains_eager(forum_model.Thread.last_post, alias=last_post),
158 joinedload('last_post.author'),
159 )
160 c.num_threads = threads_q.count()
161 try:
162 c.skip = int(request.params.get('skip', 0))
163 except ValueError:
164 abort(404)
165 c.per_page = 89
166 c.threads = threads_q.offset(c.skip).limit(c.per_page)
167
168 return render('/forum/threads.mako')
169
170 def posts(self, forum_id, thread_id):
171 try:
172 c.thread = meta.Session.query(forum_model.Thread) \
173 .filter_by(id=thread_id, forum_id=forum_id).one()
174 except NoResultFound:
175 abort(404)
176
177 c.write_post_form = WritePostForm()
178
179 posts_q = c.thread.posts \
180 .order_by(forum_model.Post.position.asc()) \
181 .options(joinedload('author'))
182 c.num_posts = c.thread.post_count
183 try:
184 c.skip = int(request.params.get('skip', 0))
185 except ValueError:
186 abort(404)
187 c.per_page = 89
188 c.posts = posts_q.offset(c.skip).limit(c.per_page)
189
190 return render('/forum/posts.mako')
191
192
193 def write_thread(self, forum_id):
194 """Provides a form for posting a new thread."""
195 if not c.user.can('forum:create-thread'):
196 abort(403)
197
198 try:
199 c.forum = meta.Session.query(forum_model.Forum) \
200 .filter_by(id=forum_id).one()
201 except NoResultFound:
202 abort(404)
203
204 c.write_thread_form = WriteThreadForm(request.params)
205 return render('/forum/write_thread.mako')
206
207 @authenticate_form
208 def write_thread_commit(self, forum_id):
209 """Posts a new thread."""
210 if not c.user.can('forum:create-thread'):
211 abort(403)
212
213 try:
214 c.forum = meta.Session.query(forum_model.Forum) \
215 .filter_by(id=forum_id).one()
216 except NoResultFound:
217 abort(404)
218
219 c.write_thread_form = WriteThreadForm(request.params)
220
221 # Reshow the form on failure
222 if not c.write_thread_form.validate():
223 return render('/forum/write_thread.mako')
224
225 # Otherwise, add the post.
226 c.forum = meta.Session.query(forum_model.Forum) \
227 .with_lockmode('update') \
228 .get(c.forum.id)
229
230 thread = forum_model.Thread(
231 forum_id = c.forum.id,
232 subject = c.write_thread_form.subject.data,
233 post_count = 1,
234 )
235 source = c.write_thread_form.content.data
236 post = forum_model.Post(
237 position = 1,
238 author_user_id = c.user.id,
239 raw_content = source,
240 content = spline.lib.markdown.translate(source),
241 )
242
243 thread.posts.append(post)
244 c.forum.threads.append(thread)
245
246 meta.Session.commit()
247
248 # Redirect to the new thread
249 h.flash("Contribution to the collective knowledge of the species successfully recorded.")
250 redirect(
251 url(controller='forum', action='posts',
252 forum_id=forum_id, thread_id=thread.id),
253 code=303,
254 )
255
256 def write(self, forum_id, thread_id):
257 """Provides a form for posting to a thread."""
258 if not c.user.can('forum:create-post'):
259 abort(403)
260
261 try:
262 c.thread = meta.Session.query(forum_model.Thread) \
263 .filter_by(id=thread_id, forum_id=forum_id).one()
264 except NoResultFound:
265 abort(404)
266
267 c.write_post_form = WritePostForm(request.params)
268 return render('/forum/write.mako')
269
270 @authenticate_form
271 def write_commit(self, forum_id, thread_id):
272 """Post to a thread."""
273 if not c.user.can('forum:create-post'):
274 abort(403)
275
276 try:
277 c.thread = meta.Session.query(forum_model.Thread) \
278 .filter_by(id=thread_id, forum_id=forum_id).one()
279 except NoResultFound:
280 abort(404)
281
282 c.write_post_form = WritePostForm(request.params)
283
284 # Reshow the form on failure
285 if not c.write_post_form.validate():
286 return render('/forum/write.mako')
287
288 # Otherwise, add the post.
289 c.thread = meta.Session.query(forum_model.Thread) \
290 .with_lockmode('update') \
291 .get(c.thread.id)
292
293 source = c.write_post_form.content.data
294 post = forum_model.Post(
295 position = c.thread.post_count + 1,
296 author_user_id = c.user.id,
297 raw_content = source,
298 content = spline.lib.markdown.translate(source),
299 )
300
301 c.thread.posts.append(post)
302 c.thread.post_count += 1
303
304 meta.Session.commit()
305
306 # Redirect to the thread
307 # XXX probably to the post instead; anchor? depends on paging scheme
308 h.flash('Your uniqueness has been added to our own.')
309 redirect(
310 url(controller='forum', action='posts',
311 forum_id=forum_id, thread_id=thread_id),
312 code=303,
313 )