annotate gpp/forums/models.py @ 505:a5d11471d031

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