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@529: @models.permalink bgneal@529: def get_latest_post_url(self): bgneal@529: return ('forums-topic_latest', [self.pk]) bgneal@529: 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@598: post_text = "1 post" bgneal@426: else: bgneal@598: post_text = "%d posts" % self.post_count bgneal@426: bgneal@598: return u"%s by %s; %s (%s)" % (self.name, self.user.username, post_text, bgneal@598: self.creation_date.strftime('%d-%b-%Y')) bgneal@414: bgneal@414: def search_summary(self): bgneal@414: return u'' bgneal@414: bgneal@556: def ogp_tags(self): bgneal@556: """ bgneal@556: Returns a dict of Open Graph Protocol meta tags. bgneal@556: bgneal@556: """ bgneal@556: desc = 'Forum topic created by %s on %s.' % ( bgneal@556: self.user.username, bgneal@556: self.creation_date.strftime('%B %d, %Y')) bgneal@556: bgneal@556: return { bgneal@556: 'og:title': self.name, bgneal@556: 'og:type': 'article', bgneal@556: 'og:url': self.get_absolute_url(), bgneal@556: 'og:description': desc, bgneal@556: } bgneal@556: 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@552: limit = 65 bgneal@552: if len(self.body) < limit: bgneal@75: return self.body bgneal@552: 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@597: return u"%s by %s (%s)" % (self.topic.name, self.user.username, bgneal@597: self.creation_date.strftime('%d-%b-%Y')) 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 'Post' % 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: