bgneal@75
|
1 """
|
bgneal@75
|
2 Models for the forums application.
|
bgneal@75
|
3 """
|
bgneal@102
|
4 import datetime
|
bgneal@102
|
5
|
bgneal@75
|
6 from django.db import models
|
bgneal@100
|
7 from django.db.models import Q
|
bgneal@75
|
8 from django.contrib.auth.models import User, Group
|
bgneal@387
|
9 from django.core.cache import cache
|
bgneal@128
|
10
|
bgneal@128
|
11 from core.markup import site_markup
|
bgneal@285
|
12 from oembed.models import Oembed
|
bgneal@75
|
13
|
bgneal@75
|
14
|
bgneal@75
|
15 class Category(models.Model):
|
bgneal@100
|
16 """
|
bgneal@100
|
17 Forums belong to a category, whose access may be assigned to groups.
|
bgneal@100
|
18 """
|
bgneal@75
|
19 name = models.CharField(max_length=80)
|
bgneal@75
|
20 slug = models.SlugField(max_length=80)
|
bgneal@75
|
21 position = models.IntegerField(blank=True, default=0)
|
bgneal@75
|
22 groups = models.ManyToManyField(Group, blank=True, null=True,
|
bgneal@75
|
23 help_text="If groups are assigned to this category, only members" \
|
bgneal@75
|
24 " of those groups can view this category.")
|
bgneal@75
|
25
|
bgneal@75
|
26 class Meta:
|
bgneal@75
|
27 ordering = ('position', )
|
bgneal@75
|
28 verbose_name_plural = 'Categories'
|
bgneal@75
|
29
|
bgneal@75
|
30 def __unicode__(self):
|
bgneal@75
|
31 return self.name
|
bgneal@75
|
32
|
bgneal@100
|
33 def can_access(self, user):
|
bgneal@100
|
34 """
|
bgneal@100
|
35 Checks to see if the given user has permission to access
|
bgneal@100
|
36 this category.
|
bgneal@100
|
37 If this category has no groups assigned to it, return true.
|
bgneal@100
|
38 Else, return true if the user belongs to a group that has been
|
bgneal@100
|
39 assigned to this category, and false otherwise.
|
bgneal@100
|
40 """
|
bgneal@100
|
41 if self.groups.count() == 0:
|
bgneal@100
|
42 return True
|
bgneal@100
|
43 if user.is_authenticated():
|
bgneal@100
|
44 return self.groups.filter(user__pk=user.id).count() > 0
|
bgneal@100
|
45 return False
|
bgneal@100
|
46
|
bgneal@170
|
47 def is_public(self):
|
bgneal@170
|
48 """Returns true if this is a public category, viewable by all."""
|
bgneal@170
|
49 return self.groups.count() == 0
|
bgneal@170
|
50
|
bgneal@100
|
51
|
bgneal@100
|
52 class ForumManager(models.Manager):
|
bgneal@100
|
53 """
|
bgneal@100
|
54 The manager for the Forum model. Provides a centralized place to
|
bgneal@100
|
55 put commonly used and useful queries.
|
bgneal@100
|
56 """
|
bgneal@100
|
57
|
bgneal@100
|
58 def forums_for_user(self, user):
|
bgneal@100
|
59 """
|
bgneal@100
|
60 Returns a queryset containing the forums that the given user can
|
bgneal@100
|
61 "see" due to authenticated status, superuser status and group membership.
|
bgneal@100
|
62 """
|
bgneal@167
|
63 qs = self._for_user(user)
|
bgneal@167
|
64 return qs.select_related('category', 'last_post', 'last_post__user')
|
bgneal@167
|
65
|
bgneal@167
|
66 def forum_ids_for_user(self, user):
|
bgneal@167
|
67 """Returns a list of forum IDs that the given user can "see"."""
|
bgneal@167
|
68 qs = self._for_user(user)
|
bgneal@167
|
69 return qs.values_list('id', flat=True)
|
bgneal@167
|
70
|
bgneal@170
|
71 def public_forums(self):
|
bgneal@170
|
72 """Returns a queryset containing the public forums."""
|
bgneal@170
|
73 return self.filter(category__groups__isnull=True)
|
bgneal@170
|
74
|
bgneal@387
|
75 def public_forum_ids(self):
|
bgneal@387
|
76 """
|
bgneal@387
|
77 Returns a list of ids for the public forums; the list is cached for
|
bgneal@387
|
78 performance.
|
bgneal@387
|
79 """
|
bgneal@387
|
80 public_forums = cache.get('public_forum_ids')
|
bgneal@387
|
81 if public_forums is None:
|
bgneal@387
|
82 public_forums = list(self.filter(
|
bgneal@387
|
83 category__groups__isnull=True).values_list('id', flat=True))
|
bgneal@387
|
84 cache.set('public_forum_ids', public_forums, 3600)
|
bgneal@387
|
85 return public_forums
|
bgneal@387
|
86
|
bgneal@167
|
87 def _for_user(self, user):
|
bgneal@167
|
88 """Common code for the xxx_for_user() methods."""
|
bgneal@100
|
89 if user.is_superuser:
|
bgneal@100
|
90 qs = self.all()
|
bgneal@100
|
91 else:
|
bgneal@167
|
92 user_groups = user.groups.all() if user.is_authenticated() else []
|
bgneal@167
|
93 qs = self.filter(Q(category__groups__isnull=True) |
|
bgneal@100
|
94 Q(category__groups__in=user_groups))
|
bgneal@167
|
95 return qs
|
bgneal@100
|
96
|
bgneal@75
|
97
|
bgneal@75
|
98 class Forum(models.Model):
|
bgneal@100
|
99 """
|
bgneal@100
|
100 A forum is a collection of topics.
|
bgneal@100
|
101 """
|
bgneal@75
|
102 category = models.ForeignKey(Category, related_name='forums')
|
bgneal@75
|
103 name = models.CharField(max_length=80)
|
bgneal@75
|
104 slug = models.SlugField(max_length=80)
|
bgneal@75
|
105 description = models.TextField(blank=True, default='')
|
bgneal@75
|
106 position = models.IntegerField(blank=True, default=0)
|
bgneal@75
|
107 moderators = models.ManyToManyField(Group, blank=True, null=True)
|
bgneal@75
|
108
|
bgneal@75
|
109 # denormalized fields to reduce database hits
|
bgneal@75
|
110 topic_count = models.IntegerField(blank=True, default=0)
|
bgneal@75
|
111 post_count = models.IntegerField(blank=True, default=0)
|
bgneal@75
|
112 last_post = models.OneToOneField('Post', blank=True, null=True,
|
bgneal@75
|
113 related_name='parent_forum')
|
bgneal@75
|
114
|
bgneal@100
|
115 objects = ForumManager()
|
bgneal@100
|
116
|
bgneal@75
|
117 class Meta:
|
bgneal@75
|
118 ordering = ('position', )
|
bgneal@75
|
119
|
bgneal@75
|
120 def __unicode__(self):
|
bgneal@75
|
121 return self.name
|
bgneal@75
|
122
|
bgneal@81
|
123 @models.permalink
|
bgneal@81
|
124 def get_absolute_url(self):
|
bgneal@81
|
125 return ('forums-forum_index', [self.slug])
|
bgneal@81
|
126
|
bgneal@75
|
127 def topic_count_update(self):
|
bgneal@75
|
128 """Call to notify the forum that its topic count has been updated."""
|
bgneal@75
|
129 self.topic_count = Topic.objects.filter(forum=self).count()
|
bgneal@75
|
130
|
bgneal@75
|
131 def post_count_update(self):
|
bgneal@75
|
132 """Call to notify the forum that its post count has been updated."""
|
bgneal@75
|
133 my_posts = Post.objects.filter(topic__forum=self)
|
bgneal@75
|
134 self.post_count = my_posts.count()
|
bgneal@75
|
135 if self.post_count > 0:
|
bgneal@75
|
136 self.last_post = my_posts[self.post_count - 1]
|
bgneal@75
|
137 else:
|
bgneal@75
|
138 self.last_post = None
|
bgneal@75
|
139
|
bgneal@112
|
140 def sync(self):
|
bgneal@112
|
141 """
|
bgneal@112
|
142 Call to notify the forum that it needs to recompute its
|
bgneal@112
|
143 denormalized fields.
|
bgneal@112
|
144 """
|
bgneal@112
|
145 self.topic_count_update()
|
bgneal@112
|
146 self.post_count_update()
|
bgneal@112
|
147
|
bgneal@293
|
148 def last_post_pre_delete(self, deleting_topic=False):
|
bgneal@107
|
149 """
|
bgneal@107
|
150 Call this function prior to deleting the last post in the forum.
|
bgneal@107
|
151 A new last post will be found, if one exists.
|
bgneal@107
|
152 This is to avoid the Django cascading delete issue.
|
bgneal@293
|
153 If deleting_topic is True, then the whole topic the last post is
|
bgneal@293
|
154 part of is being deleted, so we can't pick a new last post from that
|
bgneal@293
|
155 topic.
|
bgneal@107
|
156 """
|
bgneal@107
|
157 try:
|
bgneal@293
|
158 qs = Post.objects.filter(topic__forum=self)
|
bgneal@293
|
159 if deleting_topic:
|
bgneal@293
|
160 qs = qs.exclude(topic=self.last_post.topic)
|
bgneal@293
|
161 else:
|
bgneal@293
|
162 qs = qs.exclude(pk=self.last_post.pk)
|
bgneal@293
|
163
|
bgneal@293
|
164 self.last_post = qs.latest()
|
bgneal@293
|
165
|
bgneal@107
|
166 except Post.DoesNotExist:
|
bgneal@107
|
167 self.last_post = None
|
bgneal@107
|
168
|
bgneal@113
|
169 def catchup(self, user, flv=None):
|
bgneal@113
|
170 """
|
bgneal@113
|
171 Call to mark this forum all caught up for the given user (i.e. mark all topics
|
bgneal@113
|
172 read for this user).
|
bgneal@113
|
173 """
|
bgneal@113
|
174 TopicLastVisit.objects.filter(user=user, topic__forum=self).delete()
|
bgneal@113
|
175 if flv is None:
|
bgneal@113
|
176 try:
|
bgneal@113
|
177 flv = ForumLastVisit.objects.get(user=user, forum=self)
|
bgneal@113
|
178 except ForumLastVisit.DoesNotExist:
|
bgneal@113
|
179 flv = ForumLastVisit(user=user, forum=self)
|
bgneal@113
|
180
|
bgneal@113
|
181 now = datetime.datetime.now()
|
bgneal@113
|
182 flv.begin_date = now
|
bgneal@113
|
183 flv.end_date = now
|
bgneal@113
|
184 flv.save()
|
bgneal@113
|
185
|
bgneal@75
|
186
|
bgneal@75
|
187 class Topic(models.Model):
|
bgneal@100
|
188 """
|
bgneal@100
|
189 A topic is a thread of discussion, consisting of a series of posts.
|
bgneal@100
|
190 """
|
bgneal@75
|
191 forum = models.ForeignKey(Forum, related_name='topics')
|
bgneal@75
|
192 name = models.CharField(max_length=255)
|
bgneal@407
|
193 creation_date = models.DateTimeField(db_index=True)
|
bgneal@75
|
194 user = models.ForeignKey(User)
|
bgneal@75
|
195 view_count = models.IntegerField(blank=True, default=0)
|
bgneal@75
|
196 sticky = models.BooleanField(blank=True, default=False)
|
bgneal@75
|
197 locked = models.BooleanField(blank=True, default=False)
|
bgneal@181
|
198 subscribers = models.ManyToManyField(User, related_name='subscriptions',
|
bgneal@386
|
199 verbose_name='subscribers', blank=True)
|
bgneal@232
|
200 bookmarkers = models.ManyToManyField(User, related_name='favorite_topics',
|
bgneal@232
|
201 verbose_name='bookmarkers', blank=True)
|
bgneal@75
|
202
|
bgneal@75
|
203 # denormalized fields to reduce database hits
|
bgneal@75
|
204 post_count = models.IntegerField(blank=True, default=0)
|
bgneal@393
|
205 update_date = models.DateTimeField(db_index=True)
|
bgneal@75
|
206 last_post = models.OneToOneField('Post', blank=True, null=True,
|
bgneal@75
|
207 related_name='parent_topic')
|
bgneal@75
|
208
|
bgneal@75
|
209 class Meta:
|
bgneal@75
|
210 ordering = ('-sticky', '-update_date', )
|
bgneal@75
|
211
|
bgneal@75
|
212 def __unicode__(self):
|
bgneal@75
|
213 return self.name
|
bgneal@75
|
214
|
bgneal@82
|
215 @models.permalink
|
bgneal@82
|
216 def get_absolute_url(self):
|
bgneal@82
|
217 return ('forums-topic_index', [self.pk])
|
bgneal@82
|
218
|
bgneal@75
|
219 def post_count_update(self):
|
bgneal@75
|
220 """
|
bgneal@75
|
221 Call this function to notify the topic instance that its post count
|
bgneal@75
|
222 has changed.
|
bgneal@75
|
223 """
|
bgneal@75
|
224 my_posts = Post.objects.filter(topic=self)
|
bgneal@75
|
225 self.post_count = my_posts.count()
|
bgneal@75
|
226 if self.post_count > 0:
|
bgneal@75
|
227 self.last_post = my_posts[self.post_count - 1]
|
bgneal@75
|
228 self.update_date = self.last_post.creation_date
|
bgneal@75
|
229 else:
|
bgneal@75
|
230 self.last_post = None
|
bgneal@75
|
231 self.update_date = self.creation_date
|
bgneal@75
|
232
|
bgneal@83
|
233 def reply_count(self):
|
bgneal@83
|
234 """
|
bgneal@83
|
235 Returns the number of replies to a topic. The first post
|
bgneal@83
|
236 doesn't count as a reply.
|
bgneal@83
|
237 """
|
bgneal@83
|
238 if self.post_count > 1:
|
bgneal@83
|
239 return self.post_count - 1
|
bgneal@83
|
240 return 0
|
bgneal@83
|
241
|
bgneal@102
|
242 def save(self, *args, **kwargs):
|
bgneal@102
|
243 if not self.id:
|
bgneal@102
|
244 now = datetime.datetime.now()
|
bgneal@102
|
245 self.creation_date = now
|
bgneal@102
|
246 self.update_date = now
|
bgneal@102
|
247
|
bgneal@102
|
248 super(Topic, self).save(*args, **kwargs)
|
bgneal@102
|
249
|
bgneal@107
|
250 def last_post_pre_delete(self):
|
bgneal@107
|
251 """
|
bgneal@107
|
252 Call this function prior to deleting the last post in the topic.
|
bgneal@107
|
253 A new last post will be found, if one exists.
|
bgneal@107
|
254 This is to avoid the Django cascading delete issue.
|
bgneal@107
|
255 """
|
bgneal@107
|
256 try:
|
bgneal@107
|
257 self.last_post = \
|
bgneal@107
|
258 Post.objects.filter(topic=self).exclude(pk=self.last_post.pk).latest()
|
bgneal@107
|
259 except Post.DoesNotExist:
|
bgneal@107
|
260 self.last_post = None
|
bgneal@107
|
261
|
bgneal@414
|
262 def search_title(self):
|
bgneal@414
|
263 return u"%s by %s" % (self.name, self.user.username)
|
bgneal@414
|
264
|
bgneal@414
|
265 def search_summary(self):
|
bgneal@414
|
266 return u''
|
bgneal@414
|
267
|
bgneal@75
|
268
|
bgneal@75
|
269 class Post(models.Model):
|
bgneal@100
|
270 """
|
bgneal@100
|
271 A post is an instance of a user's single contribution to a topic.
|
bgneal@100
|
272 """
|
bgneal@75
|
273 topic = models.ForeignKey(Topic, related_name='posts')
|
bgneal@75
|
274 user = models.ForeignKey(User, related_name='posts')
|
bgneal@277
|
275 creation_date = models.DateTimeField(db_index=True)
|
bgneal@277
|
276 update_date = models.DateTimeField(db_index=True)
|
bgneal@75
|
277 body = models.TextField()
|
bgneal@75
|
278 html = models.TextField()
|
bgneal@83
|
279 user_ip = models.IPAddressField(blank=True, default='', null=True)
|
bgneal@285
|
280 attachments = models.ManyToManyField(Oembed, through='Attachment')
|
bgneal@75
|
281
|
bgneal@75
|
282 class Meta:
|
bgneal@97
|
283 ordering = ('creation_date', )
|
bgneal@107
|
284 get_latest_by = 'creation_date'
|
bgneal@226
|
285 verbose_name = 'forum post'
|
bgneal@226
|
286 verbose_name_plural = 'forum posts'
|
bgneal@75
|
287
|
bgneal@91
|
288 @models.permalink
|
bgneal@91
|
289 def get_absolute_url(self):
|
bgneal@91
|
290 return ('forums-goto_post', [self.pk])
|
bgneal@91
|
291
|
bgneal@75
|
292 def summary(self):
|
bgneal@75
|
293 LIMIT = 50
|
bgneal@75
|
294 if len(self.body) < LIMIT:
|
bgneal@75
|
295 return self.body
|
bgneal@75
|
296 return self.body[:LIMIT] + '...'
|
bgneal@75
|
297
|
bgneal@75
|
298 def __unicode__(self):
|
bgneal@75
|
299 return self.summary()
|
bgneal@75
|
300
|
bgneal@75
|
301 def save(self, *args, **kwargs):
|
bgneal@277
|
302 if not self.id:
|
bgneal@277
|
303 self.creation_date = datetime.datetime.now()
|
bgneal@277
|
304 self.update_date = self.creation_date
|
bgneal@277
|
305
|
bgneal@128
|
306 self.html = site_markup(self.body)
|
bgneal@75
|
307 super(Post, self).save(*args, **kwargs)
|
bgneal@75
|
308
|
bgneal@75
|
309 def delete(self, *args, **kwargs):
|
bgneal@75
|
310 first_post_id = self.topic.posts.all()[0].id
|
bgneal@75
|
311 super(Post, self).delete(*args, **kwargs)
|
bgneal@75
|
312 if self.id == first_post_id:
|
bgneal@75
|
313 self.topic.delete()
|
bgneal@75
|
314
|
bgneal@113
|
315 def has_been_edited(self):
|
bgneal@277
|
316 return self.update_date > self.creation_date
|
bgneal@115
|
317
|
bgneal@115
|
318 def touch(self):
|
bgneal@115
|
319 """Call this function to indicate the post has been edited."""
|
bgneal@115
|
320 self.update_date = datetime.datetime.now()
|
bgneal@113
|
321
|
bgneal@222
|
322 def search_title(self):
|
bgneal@222
|
323 return u"%s by %s" % (self.topic.name, self.user.username)
|
bgneal@222
|
324
|
bgneal@222
|
325 def search_summary(self):
|
bgneal@222
|
326 return self.body
|
bgneal@222
|
327
|
bgneal@98
|
328
|
bgneal@98
|
329 class FlaggedPost(models.Model):
|
bgneal@98
|
330 """This model represents a user flagging a post as inappropriate."""
|
bgneal@98
|
331 user = models.ForeignKey(User)
|
bgneal@98
|
332 post = models.ForeignKey(Post)
|
bgneal@98
|
333 flag_date = models.DateTimeField(auto_now_add=True)
|
bgneal@98
|
334
|
bgneal@98
|
335 def __unicode__(self):
|
bgneal@98
|
336 return u'Post ID %s flagged by %s' % (self.post.id, self.user.username)
|
bgneal@98
|
337
|
bgneal@98
|
338 class Meta:
|
bgneal@98
|
339 ordering = ('flag_date', )
|
bgneal@98
|
340
|
bgneal@98
|
341 def get_post_url(self):
|
bgneal@98
|
342 return '<a href="%s">Post</a>' % self.post.get_absolute_url()
|
bgneal@98
|
343 get_post_url.allow_tags = True
|
bgneal@98
|
344
|
bgneal@113
|
345
|
bgneal@113
|
346 class ForumLastVisit(models.Model):
|
bgneal@113
|
347 """
|
bgneal@113
|
348 This model records the last time a user visited a forum.
|
bgneal@113
|
349 It is used to compute if a user has unread topics in a forum.
|
bgneal@113
|
350 We keep track of a window of time, delimited by begin_date and end_date.
|
bgneal@113
|
351 Topics updated within this window are tracked, and may have TopicLastVisit
|
bgneal@113
|
352 objects.
|
bgneal@113
|
353 Marking a forum as all read sets the begin_date equal to the end_date.
|
bgneal@113
|
354 """
|
bgneal@113
|
355 user = models.ForeignKey(User)
|
bgneal@113
|
356 forum = models.ForeignKey(Forum)
|
bgneal@113
|
357 begin_date = models.DateTimeField()
|
bgneal@113
|
358 end_date = models.DateTimeField()
|
bgneal@113
|
359
|
bgneal@113
|
360 class Meta:
|
bgneal@113
|
361 unique_together = ('user', 'forum')
|
bgneal@113
|
362 ordering = ('-end_date', )
|
bgneal@113
|
363
|
bgneal@113
|
364 def __unicode__(self):
|
bgneal@113
|
365 return u'Forum: %d User: %d Date: %s' % (self.forum.id, self.user.id,
|
bgneal@113
|
366 self.end_date.strftime('%Y-%m-%d %H:%M:%S'))
|
bgneal@113
|
367
|
bgneal@113
|
368 def is_caught_up(self):
|
bgneal@113
|
369 return self.begin_date == self.end_date
|
bgneal@113
|
370
|
bgneal@113
|
371
|
bgneal@113
|
372 class TopicLastVisit(models.Model):
|
bgneal@113
|
373 """
|
bgneal@113
|
374 This model records the last time a user read a topic.
|
bgneal@113
|
375 Objects of this class exist for the window specified in the
|
bgneal@113
|
376 corresponding ForumLastVisit object.
|
bgneal@113
|
377 """
|
bgneal@113
|
378 user = models.ForeignKey(User)
|
bgneal@113
|
379 topic = models.ForeignKey(Topic)
|
bgneal@407
|
380 last_visit = models.DateTimeField(db_index=True)
|
bgneal@113
|
381
|
bgneal@113
|
382 class Meta:
|
bgneal@113
|
383 unique_together = ('user', 'topic')
|
bgneal@113
|
384 ordering = ('-last_visit', )
|
bgneal@113
|
385
|
bgneal@113
|
386 def __unicode__(self):
|
bgneal@113
|
387 return u'Topic: %d User: %d Date: %s' % (self.topic.id, self.user.id,
|
bgneal@113
|
388 self.last_visit.strftime('%Y-%m-%d %H:%M:%S'))
|
bgneal@113
|
389
|
bgneal@113
|
390 def save(self, *args, **kwargs):
|
bgneal@116
|
391 if self.id is None:
|
bgneal@113
|
392 self.touch()
|
bgneal@113
|
393 super(TopicLastVisit, self).save(*args, **kwargs)
|
bgneal@293
|
394
|
bgneal@113
|
395 def touch(self):
|
bgneal@113
|
396 self.last_visit = datetime.datetime.now()
|
bgneal@164
|
397
|
bgneal@285
|
398
|
bgneal@285
|
399 class Attachment(models.Model):
|
bgneal@285
|
400 """
|
bgneal@285
|
401 This model is a "through" table for the M2M relationship between forum
|
bgneal@285
|
402 posts and Oembed objects.
|
bgneal@285
|
403 """
|
bgneal@285
|
404 post = models.ForeignKey(Post)
|
bgneal@285
|
405 embed = models.ForeignKey(Oembed)
|
bgneal@285
|
406 order = models.IntegerField()
|
bgneal@285
|
407
|
bgneal@285
|
408 class Meta:
|
bgneal@285
|
409 ordering = ('order', )
|
bgneal@285
|
410
|
bgneal@285
|
411 def __unicode__(self):
|
bgneal@285
|
412 return u'Post %d, %s' % (self.post.pk, self.embed.title)
|
bgneal@301
|
413
|