annotate gpp/forums/models.py @ 114:535d02d1c017

Forums: Implemented unread status for topics and posts.
author Brian Neal <bgneal@gmail.com>
date Sun, 11 Oct 2009 20:27:07 +0000
parents d97ceb95ce02
children 0ce0104c7df3
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@75 9 from django.template.loader import render_to_string
bgneal@75 10
bgneal@75 11
bgneal@113 12 POST_EDIT_DELTA = datetime.timedelta(seconds=3)
bgneal@113 13
bgneal@113 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@100 47
bgneal@100 48 class ForumManager(models.Manager):
bgneal@100 49 """
bgneal@100 50 The manager for the Forum model. Provides a centralized place to
bgneal@100 51 put commonly used and useful queries.
bgneal@100 52 """
bgneal@100 53
bgneal@100 54 def forums_for_user(self, user):
bgneal@100 55 """
bgneal@100 56 Returns a queryset containing the forums that the given user can
bgneal@100 57 "see" due to authenticated status, superuser status and group membership.
bgneal@100 58 """
bgneal@100 59 if user.is_superuser:
bgneal@100 60 qs = self.all()
bgneal@100 61 else:
bgneal@100 62 user_groups = []
bgneal@100 63 if user.is_authenticated():
bgneal@100 64 user_groups = user.groups.all()
bgneal@100 65
bgneal@100 66 qs = self.filter(Q(category__groups__isnull=True) | \
bgneal@100 67 Q(category__groups__in=user_groups))
bgneal@100 68
bgneal@100 69 return qs.select_related('category', 'last_post', 'last_post__user')
bgneal@100 70
bgneal@75 71
bgneal@75 72 class Forum(models.Model):
bgneal@100 73 """
bgneal@100 74 A forum is a collection of topics.
bgneal@100 75 """
bgneal@75 76 category = models.ForeignKey(Category, related_name='forums')
bgneal@75 77 name = models.CharField(max_length=80)
bgneal@75 78 slug = models.SlugField(max_length=80)
bgneal@75 79 description = models.TextField(blank=True, default='')
bgneal@75 80 position = models.IntegerField(blank=True, default=0)
bgneal@75 81 moderators = models.ManyToManyField(Group, blank=True, null=True)
bgneal@75 82
bgneal@75 83 # denormalized fields to reduce database hits
bgneal@75 84 topic_count = models.IntegerField(blank=True, default=0)
bgneal@75 85 post_count = models.IntegerField(blank=True, default=0)
bgneal@75 86 last_post = models.OneToOneField('Post', blank=True, null=True,
bgneal@75 87 related_name='parent_forum')
bgneal@75 88
bgneal@100 89 objects = ForumManager()
bgneal@100 90
bgneal@75 91 class Meta:
bgneal@75 92 ordering = ('position', )
bgneal@75 93
bgneal@75 94 def __unicode__(self):
bgneal@75 95 return self.name
bgneal@75 96
bgneal@81 97 @models.permalink
bgneal@81 98 def get_absolute_url(self):
bgneal@81 99 return ('forums-forum_index', [self.slug])
bgneal@81 100
bgneal@75 101 def topic_count_update(self):
bgneal@75 102 """Call to notify the forum that its topic count has been updated."""
bgneal@75 103 self.topic_count = Topic.objects.filter(forum=self).count()
bgneal@75 104
bgneal@75 105 def post_count_update(self):
bgneal@75 106 """Call to notify the forum that its post count has been updated."""
bgneal@75 107 my_posts = Post.objects.filter(topic__forum=self)
bgneal@75 108 self.post_count = my_posts.count()
bgneal@75 109 if self.post_count > 0:
bgneal@75 110 self.last_post = my_posts[self.post_count - 1]
bgneal@75 111 else:
bgneal@75 112 self.last_post = None
bgneal@75 113
bgneal@112 114 def sync(self):
bgneal@112 115 """
bgneal@112 116 Call to notify the forum that it needs to recompute its
bgneal@112 117 denormalized fields.
bgneal@112 118 """
bgneal@112 119 self.topic_count_update()
bgneal@112 120 self.post_count_update()
bgneal@112 121
bgneal@107 122 def last_post_pre_delete(self):
bgneal@107 123 """
bgneal@107 124 Call this function prior to deleting the last post in the forum.
bgneal@107 125 A new last post will be found, if one exists.
bgneal@107 126 This is to avoid the Django cascading delete issue.
bgneal@107 127 """
bgneal@107 128 try:
bgneal@107 129 self.last_post = \
bgneal@107 130 Post.objects.filter(topic__forum=self).exclude(pk=self.last_post.pk).latest()
bgneal@107 131 except Post.DoesNotExist:
bgneal@107 132 self.last_post = None
bgneal@107 133
bgneal@113 134 def catchup(self, user, flv=None):
bgneal@113 135 """
bgneal@113 136 Call to mark this forum all caught up for the given user (i.e. mark all topics
bgneal@113 137 read for this user).
bgneal@113 138 """
bgneal@113 139 TopicLastVisit.objects.filter(user=user, topic__forum=self).delete()
bgneal@113 140 if flv is None:
bgneal@113 141 try:
bgneal@113 142 flv = ForumLastVisit.objects.get(user=user, forum=self)
bgneal@113 143 except ForumLastVisit.DoesNotExist:
bgneal@113 144 flv = ForumLastVisit(user=user, forum=self)
bgneal@113 145
bgneal@113 146 now = datetime.datetime.now()
bgneal@113 147 flv.begin_date = now
bgneal@113 148 flv.end_date = now
bgneal@113 149 flv.save()
bgneal@113 150
bgneal@75 151
bgneal@75 152 class Topic(models.Model):
bgneal@100 153 """
bgneal@100 154 A topic is a thread of discussion, consisting of a series of posts.
bgneal@100 155 """
bgneal@75 156 forum = models.ForeignKey(Forum, related_name='topics')
bgneal@75 157 name = models.CharField(max_length=255)
bgneal@75 158 creation_date = models.DateTimeField(auto_now_add=True)
bgneal@75 159 user = models.ForeignKey(User)
bgneal@75 160 view_count = models.IntegerField(blank=True, default=0)
bgneal@75 161 sticky = models.BooleanField(blank=True, default=False)
bgneal@75 162 locked = models.BooleanField(blank=True, default=False)
bgneal@75 163
bgneal@75 164 # denormalized fields to reduce database hits
bgneal@75 165 post_count = models.IntegerField(blank=True, default=0)
bgneal@102 166 update_date = models.DateTimeField()
bgneal@75 167 last_post = models.OneToOneField('Post', blank=True, null=True,
bgneal@75 168 related_name='parent_topic')
bgneal@75 169
bgneal@75 170 class Meta:
bgneal@75 171 ordering = ('-sticky', '-update_date', )
bgneal@75 172
bgneal@75 173 def __unicode__(self):
bgneal@75 174 return self.name
bgneal@75 175
bgneal@82 176 @models.permalink
bgneal@82 177 def get_absolute_url(self):
bgneal@82 178 return ('forums-topic_index', [self.pk])
bgneal@82 179
bgneal@75 180 def post_count_update(self):
bgneal@75 181 """
bgneal@75 182 Call this function to notify the topic instance that its post count
bgneal@75 183 has changed.
bgneal@75 184 """
bgneal@75 185 my_posts = Post.objects.filter(topic=self)
bgneal@75 186 self.post_count = my_posts.count()
bgneal@75 187 if self.post_count > 0:
bgneal@75 188 self.last_post = my_posts[self.post_count - 1]
bgneal@75 189 self.update_date = self.last_post.creation_date
bgneal@75 190 else:
bgneal@75 191 self.last_post = None
bgneal@75 192 self.update_date = self.creation_date
bgneal@75 193
bgneal@83 194 def reply_count(self):
bgneal@83 195 """
bgneal@83 196 Returns the number of replies to a topic. The first post
bgneal@83 197 doesn't count as a reply.
bgneal@83 198 """
bgneal@83 199 if self.post_count > 1:
bgneal@83 200 return self.post_count - 1
bgneal@83 201 return 0
bgneal@83 202
bgneal@102 203 def save(self, *args, **kwargs):
bgneal@102 204 if not self.id:
bgneal@102 205 now = datetime.datetime.now()
bgneal@102 206 self.creation_date = now
bgneal@102 207 self.update_date = now
bgneal@102 208
bgneal@102 209 super(Topic, self).save(*args, **kwargs)
bgneal@102 210
bgneal@107 211 def last_post_pre_delete(self):
bgneal@107 212 """
bgneal@107 213 Call this function prior to deleting the last post in the topic.
bgneal@107 214 A new last post will be found, if one exists.
bgneal@107 215 This is to avoid the Django cascading delete issue.
bgneal@107 216 """
bgneal@107 217 try:
bgneal@107 218 self.last_post = \
bgneal@107 219 Post.objects.filter(topic=self).exclude(pk=self.last_post.pk).latest()
bgneal@107 220 except Post.DoesNotExist:
bgneal@107 221 self.last_post = None
bgneal@107 222
bgneal@75 223
bgneal@75 224 class Post(models.Model):
bgneal@100 225 """
bgneal@100 226 A post is an instance of a user's single contribution to a topic.
bgneal@100 227 """
bgneal@75 228 topic = models.ForeignKey(Topic, related_name='posts')
bgneal@75 229 user = models.ForeignKey(User, related_name='posts')
bgneal@75 230 creation_date = models.DateTimeField(auto_now_add=True)
bgneal@75 231 update_date = models.DateTimeField(auto_now=True)
bgneal@75 232 body = models.TextField()
bgneal@75 233 html = models.TextField()
bgneal@83 234 user_ip = models.IPAddressField(blank=True, default='', null=True)
bgneal@75 235
bgneal@75 236 class Meta:
bgneal@97 237 ordering = ('creation_date', )
bgneal@107 238 get_latest_by = 'creation_date'
bgneal@75 239
bgneal@91 240 @models.permalink
bgneal@91 241 def get_absolute_url(self):
bgneal@91 242 return ('forums-goto_post', [self.pk])
bgneal@91 243
bgneal@75 244 def summary(self):
bgneal@75 245 LIMIT = 50
bgneal@75 246 if len(self.body) < LIMIT:
bgneal@75 247 return self.body
bgneal@75 248 return self.body[:LIMIT] + '...'
bgneal@75 249
bgneal@75 250 def __unicode__(self):
bgneal@75 251 return self.summary()
bgneal@75 252
bgneal@75 253 def save(self, *args, **kwargs):
bgneal@75 254 html = render_to_string('forums/post.html', {'data': self.body})
bgneal@75 255 self.html = html.strip()
bgneal@75 256 super(Post, self).save(*args, **kwargs)
bgneal@75 257
bgneal@75 258 def delete(self, *args, **kwargs):
bgneal@75 259 first_post_id = self.topic.posts.all()[0].id
bgneal@75 260 super(Post, self).delete(*args, **kwargs)
bgneal@75 261 if self.id == first_post_id:
bgneal@75 262 self.topic.delete()
bgneal@75 263
bgneal@113 264 def has_been_edited(self):
bgneal@113 265 return (self.update_date - self.creation_date) > POST_EDIT_DELTA
bgneal@113 266
bgneal@98 267
bgneal@98 268 class FlaggedPost(models.Model):
bgneal@98 269 """This model represents a user flagging a post as inappropriate."""
bgneal@98 270 user = models.ForeignKey(User)
bgneal@98 271 post = models.ForeignKey(Post)
bgneal@98 272 flag_date = models.DateTimeField(auto_now_add=True)
bgneal@98 273
bgneal@98 274 def __unicode__(self):
bgneal@98 275 return u'Post ID %s flagged by %s' % (self.post.id, self.user.username)
bgneal@98 276
bgneal@98 277 class Meta:
bgneal@98 278 ordering = ('flag_date', )
bgneal@98 279
bgneal@98 280 def get_post_url(self):
bgneal@98 281 return '<a href="%s">Post</a>' % self.post.get_absolute_url()
bgneal@98 282 get_post_url.allow_tags = True
bgneal@98 283
bgneal@113 284
bgneal@113 285 class ForumLastVisit(models.Model):
bgneal@113 286 """
bgneal@113 287 This model records the last time a user visited a forum.
bgneal@113 288 It is used to compute if a user has unread topics in a forum.
bgneal@113 289 We keep track of a window of time, delimited by begin_date and end_date.
bgneal@113 290 Topics updated within this window are tracked, and may have TopicLastVisit
bgneal@113 291 objects.
bgneal@113 292 Marking a forum as all read sets the begin_date equal to the end_date.
bgneal@113 293 """
bgneal@113 294 user = models.ForeignKey(User)
bgneal@113 295 forum = models.ForeignKey(Forum)
bgneal@113 296 begin_date = models.DateTimeField()
bgneal@113 297 end_date = models.DateTimeField()
bgneal@113 298
bgneal@113 299 class Meta:
bgneal@113 300 unique_together = ('user', 'forum')
bgneal@113 301 ordering = ('-end_date', )
bgneal@113 302
bgneal@113 303 def __unicode__(self):
bgneal@113 304 return u'Forum: %d User: %d Date: %s' % (self.forum.id, self.user.id,
bgneal@113 305 self.end_date.strftime('%Y-%m-%d %H:%M:%S'))
bgneal@113 306
bgneal@113 307 def is_caught_up(self):
bgneal@113 308 return self.begin_date == self.end_date
bgneal@113 309
bgneal@113 310
bgneal@113 311 class TopicLastVisit(models.Model):
bgneal@113 312 """
bgneal@113 313 This model records the last time a user read a topic.
bgneal@113 314 Objects of this class exist for the window specified in the
bgneal@113 315 corresponding ForumLastVisit object.
bgneal@113 316 """
bgneal@113 317 user = models.ForeignKey(User)
bgneal@113 318 topic = models.ForeignKey(Topic)
bgneal@113 319 last_visit = models.DateTimeField()
bgneal@113 320
bgneal@113 321 class Meta:
bgneal@113 322 unique_together = ('user', 'topic')
bgneal@113 323 ordering = ('-last_visit', )
bgneal@113 324
bgneal@113 325 def __unicode__(self):
bgneal@113 326 return u'Topic: %d User: %d Date: %s' % (self.topic.id, self.user.id,
bgneal@113 327 self.last_visit.strftime('%Y-%m-%d %H:%M:%S'))
bgneal@113 328
bgneal@113 329 def save(self, *args, **kwargs):
bgneal@113 330 if self.id is None:
bgneal@113 331 self.touch()
bgneal@113 332 super(TopicLastVisit, self).save(*args, **kwargs)
bgneal@113 333
bgneal@113 334 def touch(self):
bgneal@113 335 self.last_visit = datetime.datetime.now()