bgneal@509
|
1 """
|
bgneal@509
|
2 This module maintains the latest posts datastore. The latest posts are often
|
bgneal@509
|
3 needed by RSS feeds, "latest posts" template tags, etc. This module listens for
|
bgneal@509
|
4 the post_content_update signal, then bundles the post up and stores it by forum
|
bgneal@509
|
5 ID in Redis. We also maintain a combined forums list. This allows quick
|
bgneal@509
|
6 retrieval of the latest posts and avoids some slow SQL queries.
|
bgneal@509
|
7
|
bgneal@522
|
8 We also do things like send topic notification emails, auto-favorite, and
|
bgneal@522
|
9 auto-subscribe functions here rather than bog the user down in the request /
|
bgneal@522
|
10 response cycle.
|
bgneal@522
|
11
|
bgneal@509
|
12 """
|
bgneal@509
|
13 import datetime
|
bgneal@522
|
14 import logging
|
bgneal@509
|
15 import time
|
bgneal@509
|
16
|
bgneal@509
|
17 from django.dispatch import receiver
|
bgneal@509
|
18 from django.utils import simplejson
|
bgneal@594
|
19 from django.template.loader import render_to_string
|
bgneal@523
|
20 import redis
|
bgneal@509
|
21
|
bgneal@522
|
22 from forums.signals import post_content_update, topic_content_update
|
bgneal@594
|
23 from forums.models import Forum, Topic, Post, Attachment
|
bgneal@522
|
24 from forums.views.subscriptions import notify_topic_subscribers
|
bgneal@522
|
25 from forums.tools import auto_favorite, auto_subscribe
|
bgneal@509
|
26 from core.services import get_redis_connection
|
bgneal@509
|
27
|
bgneal@509
|
28 # This constant controls how many latest posts per forum we store
|
bgneal@509
|
29 MAX_POSTS = 50
|
bgneal@509
|
30
|
bgneal@522
|
31 # This controls how many updated topics we track
|
bgneal@522
|
32 MAX_UPDATED_TOPICS = 50
|
bgneal@522
|
33
|
bgneal@522
|
34 # Redis key names:
|
bgneal@522
|
35 POST_COUNT_KEY = "forums:public_post_count"
|
bgneal@522
|
36 TOPIC_COUNT_KEY = "forums:public_topic_count"
|
bgneal@522
|
37 UPDATED_TOPICS_SET_KEY = "forums:updated_topics:set"
|
bgneal@522
|
38 UPDATED_TOPIC_KEY = "forums:updated_topics:%s"
|
bgneal@522
|
39
|
bgneal@522
|
40 logger = logging.getLogger(__name__)
|
bgneal@522
|
41
|
bgneal@509
|
42
|
bgneal@509
|
43 @receiver(post_content_update, dispatch_uid='forums.latest_posts')
|
bgneal@509
|
44 def on_post_update(sender, **kwargs):
|
bgneal@509
|
45 """
|
bgneal@509
|
46 This function is our signal handler, called when a post has been updated.
|
bgneal@509
|
47 We only care about newly created posts, and ignore updates.
|
bgneal@509
|
48
|
bgneal@522
|
49 We kick off a Celery task to perform work outside of the request/response
|
bgneal@522
|
50 cycle.
|
bgneal@509
|
51
|
bgneal@509
|
52 """
|
bgneal@509
|
53 # ignore non-new posts
|
bgneal@509
|
54 if not kwargs['created']:
|
bgneal@509
|
55 return
|
bgneal@509
|
56
|
bgneal@522
|
57 # Kick off a Celery task to process this new post
|
bgneal@522
|
58 forums.tasks.new_post_task.delay(sender.id)
|
bgneal@522
|
59
|
bgneal@522
|
60
|
bgneal@522
|
61 def process_new_post(post_id):
|
bgneal@522
|
62 """
|
bgneal@522
|
63 This function is run on a Celery task. It performs all new-post processing.
|
bgneal@522
|
64
|
bgneal@522
|
65 """
|
bgneal@522
|
66 try:
|
bgneal@522
|
67 post = Post.objects.select_related().get(pk=post_id)
|
bgneal@522
|
68 except Post.DoesNotExist:
|
bgneal@522
|
69 logger.warning("process_new_post: post %d does not exist", post_id)
|
bgneal@509
|
70 return
|
bgneal@509
|
71
|
bgneal@522
|
72 # selectively process posts from non-public forums
|
bgneal@522
|
73 public_forums = Forum.objects.public_forum_ids()
|
bgneal@522
|
74
|
bgneal@522
|
75 if post.topic.forum.id in public_forums:
|
bgneal@523
|
76 conn = get_redis_connection()
|
bgneal@523
|
77 _update_post_feeds(conn, post)
|
bgneal@523
|
78 _update_post_count(conn, public_forums)
|
bgneal@523
|
79 _update_latest_topics(conn, post)
|
bgneal@522
|
80
|
bgneal@522
|
81 # send out any email notifications
|
bgneal@522
|
82 notify_topic_subscribers(post, defer=False)
|
bgneal@522
|
83
|
bgneal@522
|
84 # perform any auto-favorite and auto-subscribe actions for the new post
|
bgneal@522
|
85 auto_favorite(post)
|
bgneal@522
|
86 auto_subscribe(post)
|
bgneal@522
|
87
|
bgneal@522
|
88
|
bgneal@523
|
89 def _update_post_feeds(conn, post):
|
bgneal@522
|
90 """
|
bgneal@522
|
91 Updates the forum feeds we keep in Redis so that our RSS feeds are quick.
|
bgneal@522
|
92
|
bgneal@522
|
93 """
|
bgneal@594
|
94 # get any attachments for the post
|
bgneal@594
|
95
|
bgneal@594
|
96 attachments = Attachment.objects.filter(post=post).select_related(
|
bgneal@594
|
97 'embed').order_by('order')
|
bgneal@594
|
98 embeds = [item.embed for item in attachments]
|
bgneal@594
|
99 if len(embeds) == 0:
|
bgneal@594
|
100 content = post.html
|
bgneal@594
|
101 else:
|
bgneal@594
|
102 content = render_to_string('forums/post_rss.html', {
|
bgneal@594
|
103 'post': post,
|
bgneal@594
|
104 'embeds': embeds,
|
bgneal@594
|
105 })
|
bgneal@594
|
106
|
bgneal@509
|
107 # serialize post attributes
|
bgneal@509
|
108 post_content = {
|
bgneal@522
|
109 'id': post.id,
|
bgneal@522
|
110 'title': post.topic.name,
|
bgneal@594
|
111 'content': content,
|
bgneal@522
|
112 'author': post.user.username,
|
bgneal@522
|
113 'pubdate': int(time.mktime(post.creation_date.timetuple())),
|
bgneal@522
|
114 'forum_name': post.topic.forum.name,
|
bgneal@522
|
115 'url': post.get_absolute_url()
|
bgneal@509
|
116 }
|
bgneal@509
|
117
|
bgneal@509
|
118 s = simplejson.dumps(post_content)
|
bgneal@509
|
119
|
bgneal@509
|
120 # store in Redis
|
bgneal@509
|
121
|
bgneal@523
|
122 pipeline = conn.pipeline()
|
bgneal@509
|
123
|
bgneal@522
|
124 key = 'forums:latest:%d' % post.topic.forum.id
|
bgneal@509
|
125
|
bgneal@509
|
126 pipeline.lpush(key, s)
|
bgneal@509
|
127 pipeline.ltrim(key, 0, MAX_POSTS - 1)
|
bgneal@509
|
128
|
bgneal@509
|
129 # store in the combined feed; yes this wastes some memory storing it twice,
|
bgneal@509
|
130 # but it makes things much easier
|
bgneal@509
|
131
|
bgneal@509
|
132 key = 'forums:latest:*'
|
bgneal@509
|
133
|
bgneal@509
|
134 pipeline.lpush(key, s)
|
bgneal@509
|
135 pipeline.ltrim(key, 0, MAX_POSTS - 1)
|
bgneal@509
|
136
|
bgneal@509
|
137 pipeline.execute()
|
bgneal@509
|
138
|
bgneal@509
|
139
|
bgneal@523
|
140 def _update_post_count(conn, public_forums):
|
bgneal@522
|
141 """
|
bgneal@522
|
142 Updates the post count we cache in Redis. Doing a COUNT(*) on the post table
|
bgneal@522
|
143 can be expensive in MySQL InnoDB.
|
bgneal@522
|
144
|
bgneal@522
|
145 """
|
bgneal@523
|
146 result = conn.incr(POST_COUNT_KEY)
|
bgneal@522
|
147 if result == 1:
|
bgneal@522
|
148 # it is likely redis got trashed, so re-compute the correct value
|
bgneal@522
|
149
|
bgneal@522
|
150 count = Post.objects.filter(topic__forum__in=public_forums).count()
|
bgneal@523
|
151 conn.set(POST_COUNT_KEY, count)
|
bgneal@522
|
152
|
bgneal@522
|
153
|
bgneal@523
|
154 def _update_latest_topics(conn, post):
|
bgneal@522
|
155 """
|
bgneal@522
|
156 Updates the "latest topics with new posts" list we cache in Redis for speed.
|
bgneal@522
|
157 There is a template tag and forum view that uses this information.
|
bgneal@522
|
158
|
bgneal@522
|
159 """
|
bgneal@522
|
160 # serialize topic attributes
|
bgneal@522
|
161 topic_id = post.topic.id
|
bgneal@522
|
162 topic_score = int(time.mktime(post.creation_date.timetuple()))
|
bgneal@522
|
163
|
bgneal@522
|
164 topic_content = {
|
bgneal@522
|
165 'title': post.topic.name,
|
bgneal@522
|
166 'author': post.user.username,
|
bgneal@522
|
167 'date': topic_score,
|
bgneal@529
|
168 'url': post.topic.get_latest_post_url()
|
bgneal@522
|
169 }
|
bgneal@522
|
170 json = simplejson.dumps(topic_content)
|
bgneal@522
|
171 key = UPDATED_TOPIC_KEY % topic_id
|
bgneal@522
|
172
|
bgneal@523
|
173 pipeline = conn.pipeline()
|
bgneal@522
|
174 pipeline.set(key, json)
|
bgneal@522
|
175 pipeline.zadd(UPDATED_TOPICS_SET_KEY, topic_score, topic_id)
|
bgneal@522
|
176 pipeline.zcard(UPDATED_TOPICS_SET_KEY)
|
bgneal@522
|
177 results = pipeline.execute()
|
bgneal@522
|
178
|
bgneal@522
|
179 # delete topics beyond our maximum count
|
bgneal@522
|
180 num_topics = results[-1]
|
bgneal@522
|
181 num_to_del = num_topics - MAX_UPDATED_TOPICS
|
bgneal@522
|
182 if num_to_del > 0:
|
bgneal@522
|
183 # get the IDs of the topics we need to delete first
|
bgneal@522
|
184 start = 0
|
bgneal@522
|
185 stop = num_to_del - 1 # Redis indices are inclusive
|
bgneal@523
|
186 old_ids = conn.zrange(UPDATED_TOPICS_SET_KEY, start, stop)
|
bgneal@522
|
187
|
bgneal@522
|
188 keys = [UPDATED_TOPIC_KEY % n for n in old_ids]
|
bgneal@523
|
189 conn.delete(*keys)
|
bgneal@522
|
190
|
bgneal@522
|
191 # now delete the oldest num_to_del topics
|
bgneal@523
|
192 conn.zremrangebyrank(UPDATED_TOPICS_SET_KEY, start, stop)
|
bgneal@522
|
193
|
bgneal@522
|
194
|
bgneal@509
|
195 def get_latest_posts(num_posts=MAX_POSTS, forum_id=None):
|
bgneal@509
|
196 """
|
bgneal@509
|
197 This function retrieves num_posts latest posts for the forum with the given
|
bgneal@509
|
198 forum_id. If forum_id is None, the posts are retrieved from the combined
|
bgneal@509
|
199 forums datastore. A list of dictionaries is returned. Each dictionary
|
bgneal@509
|
200 contains information about a post.
|
bgneal@509
|
201
|
bgneal@509
|
202 """
|
bgneal@509
|
203 key = 'forums:latest:%d' % forum_id if forum_id else 'forums:latest:*'
|
bgneal@509
|
204
|
bgneal@509
|
205 num_posts = max(0, min(MAX_POSTS, num_posts))
|
bgneal@509
|
206
|
bgneal@509
|
207 if num_posts == 0:
|
bgneal@509
|
208 return []
|
bgneal@509
|
209
|
bgneal@523
|
210 conn = get_redis_connection()
|
bgneal@523
|
211 raw_posts = conn.lrange(key, 0, num_posts - 1)
|
bgneal@509
|
212
|
bgneal@509
|
213 posts = []
|
bgneal@509
|
214 for raw_post in raw_posts:
|
bgneal@509
|
215 post = simplejson.loads(raw_post)
|
bgneal@509
|
216
|
bgneal@509
|
217 # fix up the pubdate; turn it back into a datetime object
|
bgneal@509
|
218 post['pubdate'] = datetime.datetime.fromtimestamp(post['pubdate'])
|
bgneal@509
|
219
|
bgneal@509
|
220 posts.append(post)
|
bgneal@509
|
221
|
bgneal@509
|
222 return posts
|
bgneal@522
|
223
|
bgneal@522
|
224
|
bgneal@522
|
225 @receiver(topic_content_update, dispatch_uid='forums.latest_posts')
|
bgneal@522
|
226 def on_topic_update(sender, **kwargs):
|
bgneal@522
|
227 """
|
bgneal@522
|
228 This function is our signal handler, called when a topic has been updated.
|
bgneal@522
|
229 We only care about newly created topics, and ignore updates.
|
bgneal@522
|
230
|
bgneal@522
|
231 We kick off a Celery task to perform work outside of the request/response
|
bgneal@522
|
232 cycle.
|
bgneal@522
|
233
|
bgneal@522
|
234 """
|
bgneal@522
|
235 # ignore non-new topics
|
bgneal@522
|
236 if not kwargs['created']:
|
bgneal@522
|
237 return
|
bgneal@522
|
238
|
bgneal@522
|
239 # Kick off a Celery task to process this new post
|
bgneal@522
|
240 forums.tasks.new_topic_task.delay(sender.id)
|
bgneal@522
|
241
|
bgneal@522
|
242
|
bgneal@522
|
243 def process_new_topic(topic_id):
|
bgneal@522
|
244 """
|
bgneal@522
|
245 This function contains new topic processing. Currently we only update the
|
bgneal@522
|
246 topic count statistic.
|
bgneal@522
|
247
|
bgneal@522
|
248 """
|
bgneal@522
|
249 try:
|
bgneal@522
|
250 topic = Topic.objects.select_related().get(pk=topic_id)
|
bgneal@522
|
251 except Topic.DoesNotExist:
|
bgneal@522
|
252 logger.warning("process_new_topic: topic %d does not exist", topic_id)
|
bgneal@522
|
253 return
|
bgneal@522
|
254
|
bgneal@522
|
255 # selectively process topics from non-public forums
|
bgneal@522
|
256 public_forums = Forum.objects.public_forum_ids()
|
bgneal@522
|
257
|
bgneal@522
|
258 if topic.forum.id not in public_forums:
|
bgneal@522
|
259 return
|
bgneal@522
|
260
|
bgneal@522
|
261 # update the topic count statistic
|
bgneal@523
|
262 conn = get_redis_connection()
|
bgneal@522
|
263
|
bgneal@523
|
264 result = conn.incr(TOPIC_COUNT_KEY)
|
bgneal@522
|
265 if result == 1:
|
bgneal@522
|
266 # it is likely redis got trashed, so re-compute the correct value
|
bgneal@522
|
267
|
bgneal@522
|
268 count = Topic.objects.filter(forum__in=public_forums).count()
|
bgneal@523
|
269 conn.set(TOPIC_COUNT_KEY, count)
|
bgneal@522
|
270
|
bgneal@522
|
271
|
bgneal@522
|
272 def get_stats():
|
bgneal@522
|
273 """
|
bgneal@522
|
274 This function returns the topic and post count statistics as a tuple, in
|
bgneal@522
|
275 that order. If a statistic is not available, its position in the tuple will
|
bgneal@522
|
276 be None.
|
bgneal@522
|
277
|
bgneal@522
|
278 """
|
bgneal@522
|
279 try:
|
bgneal@523
|
280 conn = get_redis_connection()
|
bgneal@523
|
281 result = conn.mget(TOPIC_COUNT_KEY, POST_COUNT_KEY)
|
bgneal@522
|
282 except redis.RedisError, e:
|
bgneal@522
|
283 logger.error(e)
|
bgneal@522
|
284 return (None, None)
|
bgneal@522
|
285
|
bgneal@522
|
286 topic_count = int(result[0]) if result[0] else None
|
bgneal@522
|
287 post_count = int(result[1]) if result[1] else None
|
bgneal@522
|
288
|
bgneal@522
|
289 return (topic_count, post_count)
|
bgneal@522
|
290
|
bgneal@522
|
291
|
bgneal@522
|
292 def get_latest_topic_ids(num):
|
bgneal@522
|
293 """
|
bgneal@522
|
294 Return a list of topic ids from the latest topics that have posts. The ids
|
bgneal@522
|
295 will be sorted from newest to oldest.
|
bgneal@522
|
296
|
bgneal@522
|
297 """
|
bgneal@522
|
298 try:
|
bgneal@523
|
299 conn = get_redis_connection()
|
bgneal@523
|
300 result = conn.zrevrange(UPDATED_TOPICS_SET_KEY, 0, num - 1)
|
bgneal@522
|
301 except redis.RedisError, e:
|
bgneal@522
|
302 logger.error(e)
|
bgneal@522
|
303 return []
|
bgneal@522
|
304
|
bgneal@522
|
305 return [int(n) for n in result]
|
bgneal@522
|
306
|
bgneal@522
|
307
|
bgneal@522
|
308 def get_latest_topics(num):
|
bgneal@522
|
309 """
|
bgneal@522
|
310 Return a list of dictionaries with information about the latest topics that
|
bgneal@522
|
311 have updated posts. The topics are sorted from newest to oldest.
|
bgneal@522
|
312
|
bgneal@522
|
313 """
|
bgneal@522
|
314 try:
|
bgneal@523
|
315 conn = get_redis_connection()
|
bgneal@523
|
316 result = conn.zrevrange(UPDATED_TOPICS_SET_KEY, 0, num - 1)
|
bgneal@522
|
317
|
bgneal@522
|
318 topic_keys = [UPDATED_TOPIC_KEY % n for n in result]
|
bgneal@524
|
319 json_list = conn.mget(topic_keys) if topic_keys else []
|
bgneal@522
|
320
|
bgneal@522
|
321 except redis.RedisError, e:
|
bgneal@522
|
322 logger.error(e)
|
bgneal@522
|
323 return []
|
bgneal@522
|
324
|
bgneal@522
|
325 topics = []
|
bgneal@522
|
326 for s in json_list:
|
bgneal@522
|
327 item = simplejson.loads(s)
|
bgneal@522
|
328 item['date'] = datetime.datetime.fromtimestamp(item['date'])
|
bgneal@522
|
329 topics.append(item)
|
bgneal@522
|
330
|
bgneal@522
|
331 return topics
|
bgneal@522
|
332
|
bgneal@522
|
333
|
bgneal@522
|
334 def notify_topic_delete(topic):
|
bgneal@522
|
335 """
|
bgneal@522
|
336 This function should be called when a topic is deleted. It will remove the
|
bgneal@522
|
337 topic from the updated topics set, if present, and delete any info we have
|
bgneal@522
|
338 about the topic.
|
bgneal@522
|
339
|
bgneal@522
|
340 Note we don't do anything like this for posts. Since they just populate RSS
|
bgneal@522
|
341 feeds we'll let them 404. The updated topic list is seen in a prominent
|
bgneal@522
|
342 template tag however, so it is a bit more important to get that cleaned up.
|
bgneal@522
|
343
|
bgneal@522
|
344 """
|
bgneal@522
|
345 try:
|
bgneal@523
|
346 conn = get_redis_connection()
|
bgneal@523
|
347 pipeline = conn.pipeline()
|
bgneal@522
|
348 pipeline.zrem(UPDATED_TOPICS_SET_KEY, topic.id)
|
bgneal@522
|
349 pipeline.delete(UPDATED_TOPIC_KEY % topic.id)
|
bgneal@522
|
350 pipeline.execute()
|
bgneal@522
|
351 except redis.RedisError, e:
|
bgneal@522
|
352 logger.error(e)
|
bgneal@522
|
353
|
bgneal@522
|
354
|
bgneal@522
|
355 # Down here to avoid a circular import
|
bgneal@522
|
356 import forums.tasks
|