annotate forums/models.py @ 887:9a15f7c27526

Actually save model object upon change. This commit was tested on the comments model. Additional logging added. Added check for Markdown image references. Added TODOs after observing behavior on comments.
author Brian Neal <bgneal@gmail.com>
date Tue, 03 Feb 2015 21:09:44 -0600
parents 2e429e2b7bb6
children 71a671dab55d
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@529 201 @models.permalink
bgneal@529 202 def get_latest_post_url(self):
bgneal@529 203 return ('forums-topic_latest', [self.pk])
bgneal@529 204
bgneal@75 205 def post_count_update(self):
bgneal@75 206 """
bgneal@75 207 Call this function to notify the topic instance that its post count
bgneal@75 208 has changed.
bgneal@75 209 """
bgneal@75 210 my_posts = Post.objects.filter(topic=self)
bgneal@75 211 self.post_count = my_posts.count()
bgneal@75 212 if self.post_count > 0:
bgneal@75 213 self.last_post = my_posts[self.post_count - 1]
bgneal@75 214 self.update_date = self.last_post.creation_date
bgneal@75 215 else:
bgneal@75 216 self.last_post = None
bgneal@75 217 self.update_date = self.creation_date
bgneal@75 218
bgneal@83 219 def reply_count(self):
bgneal@83 220 """
bgneal@83 221 Returns the number of replies to a topic. The first post
bgneal@83 222 doesn't count as a reply.
bgneal@83 223 """
bgneal@83 224 if self.post_count > 1:
bgneal@83 225 return self.post_count - 1
bgneal@83 226 return 0
bgneal@83 227
bgneal@102 228 def save(self, *args, **kwargs):
bgneal@102 229 if not self.id:
bgneal@102 230 now = datetime.datetime.now()
bgneal@102 231 self.creation_date = now
bgneal@102 232 self.update_date = now
bgneal@102 233
bgneal@102 234 super(Topic, self).save(*args, **kwargs)
bgneal@102 235
bgneal@107 236 def last_post_pre_delete(self):
bgneal@107 237 """
bgneal@107 238 Call this function prior to deleting the last post in the topic.
bgneal@107 239 A new last post will be found, if one exists.
bgneal@107 240 This is to avoid the Django cascading delete issue.
bgneal@107 241 """
bgneal@107 242 try:
bgneal@107 243 self.last_post = \
bgneal@107 244 Post.objects.filter(topic=self).exclude(pk=self.last_post.pk).latest()
bgneal@107 245 except Post.DoesNotExist:
bgneal@107 246 self.last_post = None
bgneal@107 247
bgneal@414 248 def search_title(self):
bgneal@426 249 if self.post_count == 1:
bgneal@598 250 post_text = "1 post"
bgneal@426 251 else:
bgneal@598 252 post_text = "%d posts" % self.post_count
bgneal@426 253
bgneal@598 254 return u"%s by %s; %s (%s)" % (self.name, self.user.username, post_text,
bgneal@598 255 self.creation_date.strftime('%d-%b-%Y'))
bgneal@414 256
bgneal@414 257 def search_summary(self):
bgneal@414 258 return u''
bgneal@414 259
bgneal@556 260 def ogp_tags(self):
bgneal@556 261 """
bgneal@556 262 Returns a dict of Open Graph Protocol meta tags.
bgneal@556 263
bgneal@556 264 """
bgneal@556 265 desc = 'Forum topic created by %s on %s.' % (
bgneal@556 266 self.user.username,
bgneal@556 267 self.creation_date.strftime('%B %d, %Y'))
bgneal@556 268
bgneal@556 269 return {
bgneal@556 270 'og:title': self.name,
bgneal@556 271 'og:type': 'article',
bgneal@556 272 'og:url': self.get_absolute_url(),
bgneal@556 273 'og:description': desc,
bgneal@556 274 }
bgneal@556 275
bgneal@75 276
bgneal@75 277 class Post(models.Model):
bgneal@100 278 """
bgneal@100 279 A post is an instance of a user's single contribution to a topic.
bgneal@100 280 """
bgneal@75 281 topic = models.ForeignKey(Topic, related_name='posts')
bgneal@75 282 user = models.ForeignKey(User, related_name='posts')
bgneal@277 283 creation_date = models.DateTimeField(db_index=True)
bgneal@277 284 update_date = models.DateTimeField(db_index=True)
bgneal@75 285 body = models.TextField()
bgneal@75 286 html = models.TextField()
bgneal@83 287 user_ip = models.IPAddressField(blank=True, default='', null=True)
bgneal@285 288 attachments = models.ManyToManyField(Oembed, through='Attachment')
bgneal@75 289
bgneal@75 290 class Meta:
bgneal@97 291 ordering = ('creation_date', )
bgneal@107 292 get_latest_by = 'creation_date'
bgneal@226 293 verbose_name = 'forum post'
bgneal@226 294 verbose_name_plural = 'forum posts'
bgneal@75 295
bgneal@91 296 @models.permalink
bgneal@91 297 def get_absolute_url(self):
bgneal@91 298 return ('forums-goto_post', [self.pk])
bgneal@91 299
bgneal@75 300 def summary(self):
bgneal@552 301 limit = 65
bgneal@552 302 if len(self.body) < limit:
bgneal@75 303 return self.body
bgneal@552 304 return self.body[:limit] + '...'
bgneal@75 305
bgneal@75 306 def __unicode__(self):
bgneal@75 307 return self.summary()
bgneal@75 308
bgneal@75 309 def save(self, *args, **kwargs):
bgneal@277 310 if not self.id:
bgneal@277 311 self.creation_date = datetime.datetime.now()
bgneal@277 312 self.update_date = self.creation_date
bgneal@277 313
bgneal@128 314 self.html = site_markup(self.body)
bgneal@75 315 super(Post, self).save(*args, **kwargs)
bgneal@75 316
bgneal@75 317 def delete(self, *args, **kwargs):
bgneal@75 318 first_post_id = self.topic.posts.all()[0].id
bgneal@75 319 super(Post, self).delete(*args, **kwargs)
bgneal@75 320 if self.id == first_post_id:
bgneal@75 321 self.topic.delete()
bgneal@75 322
bgneal@113 323 def has_been_edited(self):
bgneal@277 324 return self.update_date > self.creation_date
bgneal@115 325
bgneal@115 326 def touch(self):
bgneal@115 327 """Call this function to indicate the post has been edited."""
bgneal@115 328 self.update_date = datetime.datetime.now()
bgneal@113 329
bgneal@222 330 def search_title(self):
bgneal@597 331 return u"%s by %s (%s)" % (self.topic.name, self.user.username,
bgneal@597 332 self.creation_date.strftime('%d-%b-%Y'))
bgneal@222 333
bgneal@222 334 def search_summary(self):
bgneal@222 335 return self.body
bgneal@222 336
bgneal@98 337
bgneal@98 338 class FlaggedPost(models.Model):
bgneal@98 339 """This model represents a user flagging a post as inappropriate."""
bgneal@98 340 user = models.ForeignKey(User)
bgneal@98 341 post = models.ForeignKey(Post)
bgneal@98 342 flag_date = models.DateTimeField(auto_now_add=True)
bgneal@98 343
bgneal@98 344 def __unicode__(self):
bgneal@98 345 return u'Post ID %s flagged by %s' % (self.post.id, self.user.username)
bgneal@98 346
bgneal@98 347 class Meta:
bgneal@98 348 ordering = ('flag_date', )
bgneal@98 349
bgneal@98 350 def get_post_url(self):
bgneal@98 351 return '<a href="%s">Post</a>' % self.post.get_absolute_url()
bgneal@98 352 get_post_url.allow_tags = True
bgneal@98 353
bgneal@113 354
bgneal@113 355 class ForumLastVisit(models.Model):
bgneal@113 356 """
bgneal@113 357 This model records the last time a user visited a forum.
bgneal@113 358 It is used to compute if a user has unread topics in a forum.
bgneal@113 359 We keep track of a window of time, delimited by begin_date and end_date.
bgneal@113 360 Topics updated within this window are tracked, and may have TopicLastVisit
bgneal@113 361 objects.
bgneal@113 362 Marking a forum as all read sets the begin_date equal to the end_date.
bgneal@113 363 """
bgneal@113 364 user = models.ForeignKey(User)
bgneal@113 365 forum = models.ForeignKey(Forum)
bgneal@113 366 begin_date = models.DateTimeField()
bgneal@113 367 end_date = models.DateTimeField()
bgneal@113 368
bgneal@113 369 class Meta:
bgneal@113 370 unique_together = ('user', 'forum')
bgneal@113 371 ordering = ('-end_date', )
bgneal@113 372
bgneal@113 373 def __unicode__(self):
bgneal@113 374 return u'Forum: %d User: %d Date: %s' % (self.forum.id, self.user.id,
bgneal@113 375 self.end_date.strftime('%Y-%m-%d %H:%M:%S'))
bgneal@113 376
bgneal@113 377 def is_caught_up(self):
bgneal@113 378 return self.begin_date == self.end_date
bgneal@113 379
bgneal@113 380
bgneal@113 381 class TopicLastVisit(models.Model):
bgneal@113 382 """
bgneal@113 383 This model records the last time a user read a topic.
bgneal@113 384 Objects of this class exist for the window specified in the
bgneal@113 385 corresponding ForumLastVisit object.
bgneal@113 386 """
bgneal@113 387 user = models.ForeignKey(User)
bgneal@113 388 topic = models.ForeignKey(Topic)
bgneal@407 389 last_visit = models.DateTimeField(db_index=True)
bgneal@113 390
bgneal@113 391 class Meta:
bgneal@113 392 unique_together = ('user', 'topic')
bgneal@113 393 ordering = ('-last_visit', )
bgneal@113 394
bgneal@113 395 def __unicode__(self):
bgneal@113 396 return u'Topic: %d User: %d Date: %s' % (self.topic.id, self.user.id,
bgneal@113 397 self.last_visit.strftime('%Y-%m-%d %H:%M:%S'))
bgneal@113 398
bgneal@113 399 def save(self, *args, **kwargs):
bgneal@445 400 if self.last_visit is None:
bgneal@113 401 self.touch()
bgneal@113 402 super(TopicLastVisit, self).save(*args, **kwargs)
bgneal@293 403
bgneal@113 404 def touch(self):
bgneal@113 405 self.last_visit = datetime.datetime.now()
bgneal@164 406
bgneal@285 407
bgneal@285 408 class Attachment(models.Model):
bgneal@285 409 """
bgneal@285 410 This model is a "through" table for the M2M relationship between forum
bgneal@285 411 posts and Oembed objects.
bgneal@285 412 """
bgneal@285 413 post = models.ForeignKey(Post)
bgneal@285 414 embed = models.ForeignKey(Oembed)
bgneal@285 415 order = models.IntegerField()
bgneal@285 416
bgneal@285 417 class Meta:
bgneal@285 418 ordering = ('order', )
bgneal@285 419
bgneal@285 420 def __unicode__(self):
bgneal@285 421 return u'Post %d, %s' % (self.post.pk, self.embed.title)
bgneal@301 422