bgneal@75: """
bgneal@75: Models for the forums application.
bgneal@75: """
bgneal@102: import datetime
bgneal@102: 
bgneal@75: from django.db import models
bgneal@100: from django.db.models import Q
bgneal@75: from django.contrib.auth.models import User, Group
bgneal@387: from django.core.cache import cache
bgneal@128: 
bgneal@128: from core.markup import site_markup
bgneal@285: from oembed.models import Oembed
bgneal@75: 
bgneal@75: 
bgneal@75: class Category(models.Model):
bgneal@100:     """
bgneal@100:     Forums belong to a category, whose access may be assigned to groups.
bgneal@100:     """
bgneal@75:     name = models.CharField(max_length=80)
bgneal@75:     slug = models.SlugField(max_length=80)
bgneal@75:     position = models.IntegerField(blank=True, default=0)
bgneal@75:     groups = models.ManyToManyField(Group, blank=True, null=True,
bgneal@75:         help_text="If groups are assigned to this category, only members" \
bgneal@75:                     " of those groups can view this category.")
bgneal@75: 
bgneal@75:     class Meta:
bgneal@75:         ordering = ('position', )
bgneal@75:         verbose_name_plural = 'Categories'
bgneal@75: 
bgneal@75:     def __unicode__(self):
bgneal@75:         return self.name
bgneal@75: 
bgneal@100: 
bgneal@100: class ForumManager(models.Manager):
bgneal@100:     """
bgneal@100:     The manager for the Forum model. Provides a centralized place to
bgneal@100:     put commonly used and useful queries.
bgneal@100:     """
bgneal@100: 
bgneal@100:     def forums_for_user(self, user):
bgneal@100:         """
bgneal@100:         Returns a queryset containing the forums that the given user can
bgneal@100:         "see" due to authenticated status, superuser status and group membership.
bgneal@100:         """
bgneal@167:         qs = self._for_user(user)
bgneal@167:         return qs.select_related('category', 'last_post', 'last_post__user')
bgneal@167: 
bgneal@167:     def forum_ids_for_user(self, user):
bgneal@167:         """Returns a list of forum IDs that the given user can "see"."""
bgneal@167:         qs = self._for_user(user)
bgneal@167:         return qs.values_list('id', flat=True)
bgneal@167: 
bgneal@170:     def public_forums(self):
bgneal@170:         """Returns a queryset containing the public forums."""
bgneal@170:         return self.filter(category__groups__isnull=True)
bgneal@170: 
bgneal@387:     def public_forum_ids(self):
bgneal@387:         """
bgneal@387:         Returns a list of ids for the public forums; the list is cached for
bgneal@387:         performance.
bgneal@387:         """
bgneal@387:         public_forums = cache.get('public_forum_ids')
bgneal@387:         if public_forums is None:
bgneal@387:             public_forums = list(self.filter(
bgneal@387:                 category__groups__isnull=True).values_list('id', flat=True))
bgneal@387:             cache.set('public_forum_ids', public_forums, 3600)
bgneal@387:         return public_forums
bgneal@387: 
bgneal@167:     def _for_user(self, user):
bgneal@167:         """Common code for the xxx_for_user() methods."""
bgneal@100:         if user.is_superuser:
bgneal@100:             qs = self.all()
bgneal@100:         else:
bgneal@167:             user_groups = user.groups.all() if user.is_authenticated() else []
bgneal@167:             qs = self.filter(Q(category__groups__isnull=True) |
bgneal@100:                     Q(category__groups__in=user_groups))
bgneal@167:         return qs
bgneal@100: 
bgneal@75: 
bgneal@75: class Forum(models.Model):
bgneal@100:     """
bgneal@100:     A forum is a collection of topics.
bgneal@100:     """
bgneal@75:     category = models.ForeignKey(Category, related_name='forums')
bgneal@75:     name = models.CharField(max_length=80)
bgneal@75:     slug = models.SlugField(max_length=80)
bgneal@75:     description = models.TextField(blank=True, default='')
bgneal@75:     position = models.IntegerField(blank=True, default=0)
bgneal@75:     moderators = models.ManyToManyField(Group, blank=True, null=True)
bgneal@75: 
bgneal@75:     # denormalized fields to reduce database hits
bgneal@75:     topic_count = models.IntegerField(blank=True, default=0)
bgneal@75:     post_count = models.IntegerField(blank=True, default=0)
bgneal@75:     last_post = models.OneToOneField('Post', blank=True, null=True,
bgneal@75:         related_name='parent_forum')
bgneal@75: 
bgneal@100:     objects = ForumManager()
bgneal@100: 
bgneal@75:     class Meta:
bgneal@75:         ordering = ('position', )
bgneal@75: 
bgneal@75:     def __unicode__(self):
bgneal@75:         return self.name
bgneal@75: 
bgneal@81:     @models.permalink
bgneal@81:     def get_absolute_url(self):
bgneal@81:         return ('forums-forum_index', [self.slug])
bgneal@81: 
bgneal@75:     def topic_count_update(self):
bgneal@75:         """Call to notify the forum that its topic count has been updated."""
bgneal@75:         self.topic_count = Topic.objects.filter(forum=self).count()
bgneal@75: 
bgneal@75:     def post_count_update(self):
bgneal@75:         """Call to notify the forum that its post count has been updated."""
bgneal@75:         my_posts = Post.objects.filter(topic__forum=self)
bgneal@75:         self.post_count = my_posts.count()
bgneal@75:         if self.post_count > 0:
bgneal@75:             self.last_post = my_posts[self.post_count - 1]
bgneal@75:         else:
bgneal@75:             self.last_post = None
bgneal@75: 
bgneal@112:     def sync(self):
bgneal@112:         """
bgneal@112:         Call to notify the forum that it needs to recompute its
bgneal@112:         denormalized fields.
bgneal@112:         """
bgneal@112:         self.topic_count_update()
bgneal@112:         self.post_count_update()
bgneal@112: 
bgneal@293:     def last_post_pre_delete(self, deleting_topic=False):
bgneal@107:         """
bgneal@107:         Call this function prior to deleting the last post in the forum.
bgneal@107:         A new last post will be found, if one exists.
bgneal@107:         This is to avoid the Django cascading delete issue.
bgneal@293:         If deleting_topic is True, then the whole topic the last post is
bgneal@293:         part of is being deleted, so we can't pick a new last post from that
bgneal@293:         topic.
bgneal@107:         """
bgneal@107:         try:
bgneal@293:             qs = Post.objects.filter(topic__forum=self)
bgneal@293:             if deleting_topic:
bgneal@293:                 qs = qs.exclude(topic=self.last_post.topic)
bgneal@293:             else:
bgneal@293:                 qs = qs.exclude(pk=self.last_post.pk)
bgneal@293: 
bgneal@293:             self.last_post = qs.latest()
bgneal@293: 
bgneal@107:         except Post.DoesNotExist:
bgneal@107:             self.last_post = None
bgneal@107: 
bgneal@113:     def catchup(self, user, flv=None):
bgneal@113:         """
bgneal@113:         Call to mark this forum all caught up for the given user (i.e. mark all topics
bgneal@113:         read for this user).
bgneal@113:         """
bgneal@113:         TopicLastVisit.objects.filter(user=user, topic__forum=self).delete()
bgneal@113:         if flv is None:
bgneal@113:             try:
bgneal@113:                 flv = ForumLastVisit.objects.get(user=user, forum=self)
bgneal@113:             except ForumLastVisit.DoesNotExist:
bgneal@113:                 flv = ForumLastVisit(user=user, forum=self)
bgneal@113: 
bgneal@113:         now = datetime.datetime.now()
bgneal@113:         flv.begin_date = now
bgneal@113:         flv.end_date = now
bgneal@113:         flv.save()
bgneal@113: 
bgneal@75: 
bgneal@75: class Topic(models.Model):
bgneal@100:     """
bgneal@100:     A topic is a thread of discussion, consisting of a series of posts.
bgneal@100:     """
bgneal@75:     forum = models.ForeignKey(Forum, related_name='topics')
bgneal@75:     name = models.CharField(max_length=255)
bgneal@407:     creation_date = models.DateTimeField(db_index=True)
bgneal@75:     user = models.ForeignKey(User)
bgneal@75:     view_count = models.IntegerField(blank=True, default=0)
bgneal@75:     sticky = models.BooleanField(blank=True, default=False)
bgneal@75:     locked = models.BooleanField(blank=True, default=False)
bgneal@181:     subscribers = models.ManyToManyField(User, related_name='subscriptions',
bgneal@386:             verbose_name='subscribers', blank=True)
bgneal@232:     bookmarkers = models.ManyToManyField(User, related_name='favorite_topics',
bgneal@232:             verbose_name='bookmarkers', blank=True)
bgneal@75: 
bgneal@75:     # denormalized fields to reduce database hits
bgneal@75:     post_count = models.IntegerField(blank=True, default=0)
bgneal@393:     update_date = models.DateTimeField(db_index=True)
bgneal@75:     last_post = models.OneToOneField('Post', blank=True, null=True,
bgneal@75:         related_name='parent_topic')
bgneal@75: 
bgneal@75:     class Meta:
bgneal@75:         ordering = ('-sticky', '-update_date', )
bgneal@75: 
bgneal@75:     def __unicode__(self):
bgneal@75:         return self.name
bgneal@75: 
bgneal@82:     @models.permalink
bgneal@82:     def get_absolute_url(self):
bgneal@82:         return ('forums-topic_index', [self.pk])
bgneal@82: 
bgneal@75:     def post_count_update(self):
bgneal@75:         """
bgneal@75:         Call this function to notify the topic instance that its post count
bgneal@75:         has changed.
bgneal@75:         """
bgneal@75:         my_posts = Post.objects.filter(topic=self)
bgneal@75:         self.post_count = my_posts.count()
bgneal@75:         if self.post_count > 0:
bgneal@75:             self.last_post = my_posts[self.post_count - 1]
bgneal@75:             self.update_date = self.last_post.creation_date
bgneal@75:         else:
bgneal@75:             self.last_post = None
bgneal@75:             self.update_date = self.creation_date
bgneal@75: 
bgneal@83:     def reply_count(self):
bgneal@83:         """
bgneal@83:         Returns the number of replies to a topic. The first post
bgneal@83:         doesn't count as a reply.
bgneal@83:         """
bgneal@83:         if self.post_count > 1:
bgneal@83:             return self.post_count - 1
bgneal@83:         return 0
bgneal@83: 
bgneal@102:     def save(self, *args, **kwargs):
bgneal@102:         if not self.id:
bgneal@102:             now = datetime.datetime.now()
bgneal@102:             self.creation_date = now
bgneal@102:             self.update_date = now
bgneal@102: 
bgneal@102:         super(Topic, self).save(*args, **kwargs)
bgneal@102: 
bgneal@107:     def last_post_pre_delete(self):
bgneal@107:         """
bgneal@107:         Call this function prior to deleting the last post in the topic.
bgneal@107:         A new last post will be found, if one exists.
bgneal@107:         This is to avoid the Django cascading delete issue.
bgneal@107:         """
bgneal@107:         try:
bgneal@107:             self.last_post = \
bgneal@107:                 Post.objects.filter(topic=self).exclude(pk=self.last_post.pk).latest()
bgneal@107:         except Post.DoesNotExist:
bgneal@107:             self.last_post = None
bgneal@107: 
bgneal@414:     def search_title(self):
bgneal@426:         if self.post_count == 1:
bgneal@426:             post_text = "(1 post)"
bgneal@426:         else:
bgneal@426:             post_text = "(%d posts)" % self.post_count
bgneal@426: 
bgneal@426:         return u"%s by %s; %s" % (self.name, self.user.username, post_text)
bgneal@414: 
bgneal@414:     def search_summary(self):
bgneal@414:         return u''
bgneal@414: 
bgneal@75: 
bgneal@75: class Post(models.Model):
bgneal@100:     """
bgneal@100:     A post is an instance of a user's single contribution to a topic.
bgneal@100:     """
bgneal@75:     topic = models.ForeignKey(Topic, related_name='posts')
bgneal@75:     user = models.ForeignKey(User, related_name='posts')
bgneal@277:     creation_date = models.DateTimeField(db_index=True)
bgneal@277:     update_date = models.DateTimeField(db_index=True)
bgneal@75:     body = models.TextField()
bgneal@75:     html = models.TextField()
bgneal@83:     user_ip = models.IPAddressField(blank=True, default='', null=True)
bgneal@285:     attachments = models.ManyToManyField(Oembed, through='Attachment')
bgneal@75: 
bgneal@75:     class Meta:
bgneal@97:         ordering = ('creation_date', )
bgneal@107:         get_latest_by = 'creation_date'
bgneal@226:         verbose_name = 'forum post'
bgneal@226:         verbose_name_plural = 'forum posts'
bgneal@75: 
bgneal@91:     @models.permalink
bgneal@91:     def get_absolute_url(self):
bgneal@91:         return ('forums-goto_post', [self.pk])
bgneal@91: 
bgneal@75:     def summary(self):
bgneal@75:         LIMIT = 50
bgneal@75:         if len(self.body) < LIMIT:
bgneal@75:             return self.body
bgneal@75:         return self.body[:LIMIT] + '...'
bgneal@75: 
bgneal@75:     def __unicode__(self):
bgneal@75:         return self.summary()
bgneal@75: 
bgneal@75:     def save(self, *args, **kwargs):
bgneal@277:         if not self.id:
bgneal@277:             self.creation_date = datetime.datetime.now()
bgneal@277:             self.update_date = self.creation_date
bgneal@277: 
bgneal@128:         self.html = site_markup(self.body)
bgneal@75:         super(Post, self).save(*args, **kwargs)
bgneal@75: 
bgneal@75:     def delete(self, *args, **kwargs):
bgneal@75:         first_post_id = self.topic.posts.all()[0].id
bgneal@75:         super(Post, self).delete(*args, **kwargs)
bgneal@75:         if self.id == first_post_id:
bgneal@75:             self.topic.delete()
bgneal@75: 
bgneal@113:     def has_been_edited(self):
bgneal@277:         return self.update_date > self.creation_date
bgneal@115: 
bgneal@115:     def touch(self):
bgneal@115:         """Call this function to indicate the post has been edited."""
bgneal@115:         self.update_date = datetime.datetime.now()
bgneal@113: 
bgneal@222:     def search_title(self):
bgneal@222:         return u"%s by %s" % (self.topic.name, self.user.username)
bgneal@222: 
bgneal@222:     def search_summary(self):
bgneal@222:         return self.body
bgneal@222: 
bgneal@98: 
bgneal@98: class FlaggedPost(models.Model):
bgneal@98:     """This model represents a user flagging a post as inappropriate."""
bgneal@98:     user = models.ForeignKey(User)
bgneal@98:     post = models.ForeignKey(Post)
bgneal@98:     flag_date = models.DateTimeField(auto_now_add=True)
bgneal@98: 
bgneal@98:     def __unicode__(self):
bgneal@98:         return u'Post ID %s flagged by %s' % (self.post.id, self.user.username)
bgneal@98: 
bgneal@98:     class Meta:
bgneal@98:         ordering = ('flag_date', )
bgneal@98: 
bgneal@98:     def get_post_url(self):
bgneal@98:         return '<a href="%s">Post</a>' % self.post.get_absolute_url()
bgneal@98:     get_post_url.allow_tags = True
bgneal@98: 
bgneal@113: 
bgneal@113: class ForumLastVisit(models.Model):
bgneal@113:     """
bgneal@113:     This model records the last time a user visited a forum.
bgneal@113:     It is used to compute if a user has unread topics in a forum.
bgneal@113:     We keep track of a window of time, delimited by begin_date and end_date.
bgneal@113:     Topics updated within this window are tracked, and may have TopicLastVisit
bgneal@113:     objects.
bgneal@113:     Marking a forum as all read sets the begin_date equal to the end_date.
bgneal@113:     """
bgneal@113:     user = models.ForeignKey(User)
bgneal@113:     forum = models.ForeignKey(Forum)
bgneal@113:     begin_date = models.DateTimeField()
bgneal@113:     end_date = models.DateTimeField()
bgneal@113: 
bgneal@113:     class Meta:
bgneal@113:         unique_together = ('user', 'forum')
bgneal@113:         ordering = ('-end_date', )
bgneal@113: 
bgneal@113:     def __unicode__(self):
bgneal@113:         return u'Forum: %d User: %d Date: %s' % (self.forum.id, self.user.id,
bgneal@113:                 self.end_date.strftime('%Y-%m-%d %H:%M:%S'))
bgneal@113: 
bgneal@113:     def is_caught_up(self):
bgneal@113:         return self.begin_date == self.end_date
bgneal@113: 
bgneal@113: 
bgneal@113: class TopicLastVisit(models.Model):
bgneal@113:     """
bgneal@113:     This model records the last time a user read a topic.
bgneal@113:     Objects of this class exist for the window specified in the
bgneal@113:     corresponding ForumLastVisit object.
bgneal@113:     """
bgneal@113:     user = models.ForeignKey(User)
bgneal@113:     topic = models.ForeignKey(Topic)
bgneal@407:     last_visit = models.DateTimeField(db_index=True)
bgneal@113: 
bgneal@113:     class Meta:
bgneal@113:         unique_together = ('user', 'topic')
bgneal@113:         ordering = ('-last_visit', )
bgneal@113: 
bgneal@113:     def __unicode__(self):
bgneal@113:         return u'Topic: %d User: %d Date: %s' % (self.topic.id, self.user.id,
bgneal@113:                 self.last_visit.strftime('%Y-%m-%d %H:%M:%S'))
bgneal@113: 
bgneal@113:     def save(self, *args, **kwargs):
bgneal@445:         if self.last_visit is None:
bgneal@113:             self.touch()
bgneal@113:         super(TopicLastVisit, self).save(*args, **kwargs)
bgneal@293: 
bgneal@113:     def touch(self):
bgneal@113:         self.last_visit = datetime.datetime.now()
bgneal@164: 
bgneal@285: 
bgneal@285: class Attachment(models.Model):
bgneal@285:     """
bgneal@285:     This model is a "through" table for the M2M relationship between forum
bgneal@285:     posts and Oembed objects.
bgneal@285:     """
bgneal@285:     post = models.ForeignKey(Post)
bgneal@285:     embed = models.ForeignKey(Oembed)
bgneal@285:     order = models.IntegerField()
bgneal@285: 
bgneal@285:     class Meta:
bgneal@285:         ordering = ('order', )
bgneal@285: 
bgneal@285:     def __unicode__(self):
bgneal@285:         return u'Post %d, %s' % (self.post.pk, self.embed.title)
bgneal@301: