annotate forums/models.py @ 989:2908859c2fe4

Smilies now use relative links. This is for upcoming switch to SSL. Currently we do not need absolute URLs for smilies. If this changes we can add it later.
author Brian Neal <bgneal@gmail.com>
date Thu, 29 Oct 2015 20:54:34 -0500
parents 4619290d171d
children 5ba2508939f7
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@963 314 self.html = kwargs.pop('html', None)
bgneal@963 315 if self.html is None:
bgneal@963 316 self.html = site_markup(self.body)
bgneal@75 317 super(Post, self).save(*args, **kwargs)
bgneal@75 318
bgneal@75 319 def delete(self, *args, **kwargs):
bgneal@75 320 first_post_id = self.topic.posts.all()[0].id
bgneal@75 321 super(Post, self).delete(*args, **kwargs)
bgneal@75 322 if self.id == first_post_id:
bgneal@75 323 self.topic.delete()
bgneal@75 324
bgneal@113 325 def has_been_edited(self):
bgneal@277 326 return self.update_date > self.creation_date
bgneal@115 327
bgneal@115 328 def touch(self):
bgneal@115 329 """Call this function to indicate the post has been edited."""
bgneal@115 330 self.update_date = datetime.datetime.now()
bgneal@113 331
bgneal@222 332 def search_title(self):
bgneal@597 333 return u"%s by %s (%s)" % (self.topic.name, self.user.username,
bgneal@597 334 self.creation_date.strftime('%d-%b-%Y'))
bgneal@222 335
bgneal@222 336 def search_summary(self):
bgneal@222 337 return self.body
bgneal@222 338
bgneal@98 339
bgneal@98 340 class FlaggedPost(models.Model):
bgneal@98 341 """This model represents a user flagging a post as inappropriate."""
bgneal@98 342 user = models.ForeignKey(User)
bgneal@98 343 post = models.ForeignKey(Post)
bgneal@98 344 flag_date = models.DateTimeField(auto_now_add=True)
bgneal@98 345
bgneal@98 346 def __unicode__(self):
bgneal@98 347 return u'Post ID %s flagged by %s' % (self.post.id, self.user.username)
bgneal@98 348
bgneal@98 349 class Meta:
bgneal@98 350 ordering = ('flag_date', )
bgneal@98 351
bgneal@98 352 def get_post_url(self):
bgneal@98 353 return '<a href="%s">Post</a>' % self.post.get_absolute_url()
bgneal@98 354 get_post_url.allow_tags = True
bgneal@98 355
bgneal@113 356
bgneal@113 357 class ForumLastVisit(models.Model):
bgneal@113 358 """
bgneal@113 359 This model records the last time a user visited a forum.
bgneal@113 360 It is used to compute if a user has unread topics in a forum.
bgneal@113 361 We keep track of a window of time, delimited by begin_date and end_date.
bgneal@113 362 Topics updated within this window are tracked, and may have TopicLastVisit
bgneal@113 363 objects.
bgneal@113 364 Marking a forum as all read sets the begin_date equal to the end_date.
bgneal@113 365 """
bgneal@113 366 user = models.ForeignKey(User)
bgneal@113 367 forum = models.ForeignKey(Forum)
bgneal@113 368 begin_date = models.DateTimeField()
bgneal@113 369 end_date = models.DateTimeField()
bgneal@113 370
bgneal@113 371 class Meta:
bgneal@113 372 unique_together = ('user', 'forum')
bgneal@113 373 ordering = ('-end_date', )
bgneal@113 374
bgneal@113 375 def __unicode__(self):
bgneal@113 376 return u'Forum: %d User: %d Date: %s' % (self.forum.id, self.user.id,
bgneal@113 377 self.end_date.strftime('%Y-%m-%d %H:%M:%S'))
bgneal@113 378
bgneal@113 379 def is_caught_up(self):
bgneal@113 380 return self.begin_date == self.end_date
bgneal@113 381
bgneal@113 382
bgneal@113 383 class TopicLastVisit(models.Model):
bgneal@113 384 """
bgneal@113 385 This model records the last time a user read a topic.
bgneal@113 386 Objects of this class exist for the window specified in the
bgneal@113 387 corresponding ForumLastVisit object.
bgneal@113 388 """
bgneal@113 389 user = models.ForeignKey(User)
bgneal@113 390 topic = models.ForeignKey(Topic)
bgneal@407 391 last_visit = models.DateTimeField(db_index=True)
bgneal@113 392
bgneal@113 393 class Meta:
bgneal@113 394 unique_together = ('user', 'topic')
bgneal@113 395 ordering = ('-last_visit', )
bgneal@113 396
bgneal@113 397 def __unicode__(self):
bgneal@113 398 return u'Topic: %d User: %d Date: %s' % (self.topic.id, self.user.id,
bgneal@113 399 self.last_visit.strftime('%Y-%m-%d %H:%M:%S'))
bgneal@113 400
bgneal@113 401 def save(self, *args, **kwargs):
bgneal@445 402 if self.last_visit is None:
bgneal@113 403 self.touch()
bgneal@113 404 super(TopicLastVisit, self).save(*args, **kwargs)
bgneal@293 405
bgneal@113 406 def touch(self):
bgneal@113 407 self.last_visit = datetime.datetime.now()
bgneal@164 408
bgneal@285 409
bgneal@285 410 class Attachment(models.Model):
bgneal@285 411 """
bgneal@285 412 This model is a "through" table for the M2M relationship between forum
bgneal@285 413 posts and Oembed objects.
bgneal@285 414 """
bgneal@285 415 post = models.ForeignKey(Post)
bgneal@285 416 embed = models.ForeignKey(Oembed)
bgneal@285 417 order = models.IntegerField()
bgneal@285 418
bgneal@285 419 class Meta:
bgneal@285 420 ordering = ('order', )
bgneal@285 421
bgneal@285 422 def __unicode__(self):
bgneal@285 423 return u'Post %d, %s' % (self.post.pk, self.embed.title)
bgneal@301 424