annotate gpp/forums/models.py @ 348:d1b11096595b

Fix #168; when nailing a spammer, clear their profile text fields. Guard against topics and forums that don't exist when deleting posts in the signal handler. Make the forum stats template tag only display the latest active users.
author Brian Neal <bgneal@gmail.com>
date Wed, 02 Mar 2011 02:18:28 +0000
parents ee451ad46af1
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)