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