annotate gpp/forums/models.py @ 301:ee451ad46af1

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