annotate forums/models.py @ 1206:02181fa5ac9d modernize tip

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