f2eb4760ca2bd962198b0ac05701fef6036ec3cd
[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 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 threads_q = c.forum.threads \
153 .join(forum_model.Thread.last_post) \
154 .order_by(forum_model.Post.posted_time.desc()) \
155 .options(
156 joinedload('last_post'),
157 joinedload('last_post.author'),
158 )
159 c.num_threads = threads_q.count()
160 try:
161 c.skip = int(request.params.get('skip', 0))
162 except ValueError:
163 abort(404)
164 c.per_page = 89
165 c.threads = threads_q.offset(c.skip).limit(c.per_page)
166
167 return render('/forum/threads.mako')
168
169 def posts(self, forum_id, thread_id):
170 try:
171 c.thread = meta.Session.query(forum_model.Thread) \
172 .filter_by(id=thread_id, forum_id=forum_id).one()
173 except NoResultFound:
174 abort(404)
175
176 c.write_post_form = WritePostForm()
177
178 posts_q = c.thread.posts \
179 .order_by(forum_model.Post.position.asc()) \
180 .options(joinedload('author'))
181 c.num_posts = c.thread.post_count
182 try:
183 c.skip = int(request.params.get('skip', 0))
184 except ValueError:
185 abort(404)
186 c.per_page = 89
187 c.posts = posts_q.offset(c.skip).limit(c.per_page)
188
189 return render('/forum/posts.mako')
190
191
192 def write_thread(self, forum_id):
193 """Provides a form for posting a new thread."""
194 if not c.user.can('forum:create-thread'):
195 abort(403)
196
197 try:
198 c.forum = meta.Session.query(forum_model.Forum) \
199 .filter_by(id=forum_id).one()
200 except NoResultFound:
201 abort(404)
202
203 c.write_thread_form = WriteThreadForm(request.params)
204 return render('/forum/write_thread.mako')
205
206 @authenticate_form
207 def write_thread_commit(self, forum_id):
208 """Posts a new thread."""
209 if not c.user.can('forum:create-thread'):
210 abort(403)
211
212 try:
213 c.forum = meta.Session.query(forum_model.Forum) \
214 .filter_by(id=forum_id).one()
215 except NoResultFound:
216 abort(404)
217
218 c.write_thread_form = WriteThreadForm(request.params)
219
220 # Reshow the form on failure
221 if not c.write_thread_form.validate():
222 return render('/forum/write_thread.mako')
223
224 # Otherwise, add the post.
225 c.forum = meta.Session.query(forum_model.Forum) \
226 .with_lockmode('update') \
227 .get(c.forum.id)
228
229 thread = forum_model.Thread(
230 forum_id = c.forum.id,
231 subject = c.write_thread_form.subject.data,
232 post_count = 1,
233 )
234 source = c.write_thread_form.content.data
235 post = forum_model.Post(
236 position = 1,
237 author_user_id = c.user.id,
238 raw_content = source,
239 content = spline.lib.markdown.translate(source),
240 )
241
242 thread.posts.append(post)
243 c.forum.threads.append(thread)
244
245 meta.Session.commit()
246
247 # Redirect to the new thread
248 h.flash("Contribution to the collective knowledge of the species successfully recorded.")
249 redirect(
250 url(controller='forum', action='posts',
251 forum_id=forum_id, thread_id=thread.id),
252 code=303,
253 )
254
255 def write(self, forum_id, thread_id):
256 """Provides a form for posting to a thread."""
257 if not c.user.can('forum:create-post'):
258 abort(403)
259
260 try:
261 c.thread = meta.Session.query(forum_model.Thread) \
262 .filter_by(id=thread_id, forum_id=forum_id).one()
263 except NoResultFound:
264 abort(404)
265
266 c.write_post_form = WritePostForm(request.params)
267 return render('/forum/write.mako')
268
269 @authenticate_form
270 def write_commit(self, forum_id, thread_id):
271 """Post to a thread."""
272 if not c.user.can('forum:create-post'):
273 abort(403)
274
275 try:
276 c.thread = meta.Session.query(forum_model.Thread) \
277 .filter_by(id=thread_id, forum_id=forum_id).one()
278 except NoResultFound:
279 abort(404)
280
281 c.write_post_form = WritePostForm(request.params)
282
283 # Reshow the form on failure
284 if not c.write_post_form.validate():
285 return render('/forum/write.mako')
286
287 # Otherwise, add the post.
288 c.thread = meta.Session.query(forum_model.Thread) \
289 .with_lockmode('update') \
290 .get(c.thread.id)
291
292 source = c.write_post_form.content.data
293 post = forum_model.Post(
294 position = c.thread.post_count + 1,
295 author_user_id = c.user.id,
296 raw_content = source,
297 content = spline.lib.markdown.translate(source),
298 )
299
300 c.thread.posts.append(post)
301 c.thread.post_count += 1
302
303 meta.Session.commit()
304
305 # Redirect to the thread
306 # XXX probably to the post instead; anchor? depends on paging scheme
307 h.flash('Your uniqueness has been added to our own.')
308 redirect(
309 url(controller='forum', action='posts',
310 forum_id=forum_id, thread_id=thread_id),
311 code=303,
312 )