annotate gpp/forums/models.py @ 451:345825e6dcae

Working on #220. Can't test locally, so committing in increments.
author Brian Neal <bgneal@gmail.com>
date Thu, 30 Jun 2011 01:57:17 +0000
parents e9f203c5f5bb
children 9d3bd7304050
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 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@426 263 if self.post_count == 1:
bgneal@426 264 post_text = "(1 post)"
bgneal@426 265 else:
bgneal@426 266 post_text = "(%d posts)" % self.post_count
bgneal@426 267
bgneal@426 268 return u"%s by %s; %s" % (self.name, self.user.username, post_text)
bgneal@414 269
bgneal@414 270 def search_summary(self):
bgneal@414 271 return u''
bgneal@414 272
bgneal@75 273
bgneal@75 274 class Post(models.Model):
bgneal@100 275 """
bgneal@100 276 A post is an instance of a user's single contribution to a topic.
bgneal@100 277 """
bgneal@75 278 topic = models.ForeignKey(Topic, related_name='posts')
bgneal@75 279 user = models.ForeignKey(User, related_name='posts')
bgneal@277 280 creation_date = models.DateTimeField(db_index=True)
bgneal@277 281 update_date = models.DateTimeField(db_index=True)
bgneal@75 282 body = models.TextField()
bgneal@75 283 html = models.TextField()
bgneal@83 284 user_ip = models.IPAddressField(blank=True, default='', null=True)
bgneal@285 285 attachments = models.ManyToManyField(Oembed, through='Attachment')
bgneal@75 286
bgneal@75 287 class Meta:
bgneal@97 288 ordering = ('creation_date', )
bgneal@107 289 get_latest_by = 'creation_date'
bgneal@226 290 verbose_name = 'forum post'
bgneal@226 291 verbose_name_plural = 'forum posts'
bgneal@75 292
bgneal@91 293 @models.permalink
bgneal@91 294 def get_absolute_url(self):
bgneal@91 295 return ('forums-goto_post', [self.pk])
bgneal@91 296
bgneal@75 297 def summary(self):
bgneal@75 298 LIMIT = 50
bgneal@75 299 if len(self.body) < LIMIT:
bgneal@75 300 return self.body
bgneal@75 301 return self.body[:LIMIT] + '...'
bgneal@75 302
bgneal@75 303 def __unicode__(self):
bgneal@75 304 return self.summary()
bgneal@75 305
bgneal@75 306 def save(self, *args, **kwargs):
bgneal@277 307 if not self.id:
bgneal@277 308 self.creation_date = datetime.datetime.now()
bgneal@277 309 self.update_date = self.creation_date
bgneal@277 310
bgneal@128 311 self.html = site_markup(self.body)
bgneal@75 312 super(Post, self).save(*args, **kwargs)
bgneal@75 313
bgneal@75 314 def delete(self, *args, **kwargs):
bgneal@75 315 first_post_id = self.topic.posts.all()[0].id
bgneal@75 316 super(Post, self).delete(*args, **kwargs)
bgneal@75 317 if self.id == first_post_id:
bgneal@75 318 self.topic.delete()
bgneal@75 319
bgneal@113 320 def has_been_edited(self):
bgneal@277 321 return self.update_date > self.creation_date
bgneal@115 322
bgneal@115 323 def touch(self):
bgneal@115 324 """Call this function to indicate the post has been edited."""
bgneal@115 325 self.update_date = datetime.datetime.now()
bgneal@113 326
bgneal@222 327 def search_title(self):
bgneal@222 328 return u"%s by %s" % (self.topic.name, self.user.username)
bgneal@222 329
bgneal@222 330 def search_summary(self):
bgneal@222 331 return self.body
bgneal@222 332
bgneal@98 333
bgneal@98 334 class FlaggedPost(models.Model):
bgneal@98 335 """This model represents a user flagging a post as inappropriate."""
bgneal@98 336 user = models.ForeignKey(User)
bgneal@98 337 post = models.ForeignKey(Post)
bgneal@98 338 flag_date = models.DateTimeField(auto_now_add=True)
bgneal@98 339
bgneal@98 340 def __unicode__(self):
bgneal@98 341 return u'Post ID %s flagged by %s' % (self.post.id, self.user.username)
bgneal@98 342
bgneal@98 343 class Meta:
bgneal@98 344 ordering = ('flag_date', )
bgneal@98 345
bgneal@98 346 def get_post_url(self):
bgneal@98 347 return '<a href="%s">Post</a>' % self.post.get_absolute_url()
bgneal@98 348 get_post_url.allow_tags = True
bgneal@98 349
bgneal@113 350
bgneal@113 351 class ForumLastVisit(models.Model):
bgneal@113 352 """
bgneal@113 353 This model records the last time a user visited a forum.
bgneal@113 354 It is used to compute if a user has unread topics in a forum.
bgneal@113 355 We keep track of a window of time, delimited by begin_date and end_date.
bgneal@113 356 Topics updated within this window are tracked, and may have TopicLastVisit
bgneal@113 357 objects.
bgneal@113 358 Marking a forum as all read sets the begin_date equal to the end_date.
bgneal@113 359 """
bgneal@113 360 user = models.ForeignKey(User)
bgneal@113 361 forum = models.ForeignKey(Forum)
bgneal@113 362 begin_date = models.DateTimeField()
bgneal@113 363 end_date = models.DateTimeField()
bgneal@113 364
bgneal@113 365 class Meta:
bgneal@113 366 unique_together = ('user', 'forum')
bgneal@113 367 ordering = ('-end_date', )
bgneal@113 368
bgneal@113 369 def __unicode__(self):
bgneal@113 370 return u'Forum: %d User: %d Date: %s' % (self.forum.id, self.user.id,
bgneal@113 371 self.end_date.strftime('%Y-%m-%d %H:%M:%S'))
bgneal@113 372
bgneal@113 373 def is_caught_up(self):
bgneal@113 374 return self.begin_date == self.end_date
bgneal@113 375
bgneal@113 376
bgneal@113 377 class TopicLastVisit(models.Model):
bgneal@113 378 """
bgneal@113 379 This model records the last time a user read a topic.
bgneal@113 380 Objects of this class exist for the window specified in the
bgneal@113 381 corresponding ForumLastVisit object.
bgneal@113 382 """
bgneal@113 383 user = models.ForeignKey(User)
bgneal@113 384 topic = models.ForeignKey(Topic)
bgneal@407 385 last_visit = models.DateTimeField(db_index=True)
bgneal@113 386
bgneal@113 387 class Meta:
bgneal@113 388 unique_together = ('user', 'topic')
bgneal@113 389 ordering = ('-last_visit', )
bgneal@113 390
bgneal@113 391 def __unicode__(self):
bgneal@113 392 return u'Topic: %d User: %d Date: %s' % (self.topic.id, self.user.id,
bgneal@113 393 self.last_visit.strftime('%Y-%m-%d %H:%M:%S'))
bgneal@113 394
bgneal@113 395 def save(self, *args, **kwargs):
bgneal@445 396 if self.last_visit is None:
bgneal@113 397 self.touch()
bgneal@113 398 super(TopicLastVisit, self).save(*args, **kwargs)
bgneal@293 399
bgneal@113 400 def touch(self):
bgneal@113 401 self.last_visit = datetime.datetime.now()
bgneal@164 402
bgneal@285 403
bgneal@285 404 class Attachment(models.Model):
bgneal@285 405 """
bgneal@285 406 This model is a "through" table for the M2M relationship between forum
bgneal@285 407 posts and Oembed objects.
bgneal@285 408 """
bgneal@285 409 post = models.ForeignKey(Post)
bgneal@285 410 embed = models.ForeignKey(Oembed)
bgneal@285 411 order = models.IntegerField()
bgneal@285 412
bgneal@285 413 class Meta:
bgneal@285 414 ordering = ('order', )
bgneal@285 415
bgneal@285 416 def __unicode__(self):
bgneal@285 417 return u'Post %d, %s' % (self.post.pk, self.embed.title)
bgneal@301 418