Mercurial > public > sg101
diff forums/models.py @ 581:ee87ea74d46b
For Django 1.4, rearranged project structure for new manage.py.
author | Brian Neal <bgneal@gmail.com> |
---|---|
date | Sat, 05 May 2012 17:10:48 -0500 |
parents | gpp/forums/models.py@70722b7d10af |
children | 7d005ebd8deb |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/forums/models.py Sat May 05 17:10:48 2012 -0500 @@ -0,0 +1,420 @@ +""" +Models for the forums application. +""" +import datetime + +from django.db import models +from django.db.models import Q +from django.contrib.auth.models import User, Group +from django.core.cache import cache + +from core.markup import site_markup +from oembed.models import Oembed + + +class Category(models.Model): + """ + Forums belong to a category, whose access may be assigned to groups. + """ + name = models.CharField(max_length=80) + slug = models.SlugField(max_length=80) + position = models.IntegerField(blank=True, default=0) + groups = models.ManyToManyField(Group, blank=True, null=True, + help_text="If groups are assigned to this category, only members" \ + " of those groups can view this category.") + + class Meta: + ordering = ('position', ) + verbose_name_plural = 'Categories' + + def __unicode__(self): + return self.name + + +class ForumManager(models.Manager): + """ + The manager for the Forum model. Provides a centralized place to + put commonly used and useful queries. + """ + + def forums_for_user(self, user): + """ + Returns a queryset containing the forums that the given user can + "see" due to authenticated status, superuser status and group membership. + """ + qs = self._for_user(user) + return qs.select_related('category', 'last_post', 'last_post__user') + + def forum_ids_for_user(self, user): + """Returns a list of forum IDs that the given user can "see".""" + qs = self._for_user(user) + return qs.values_list('id', flat=True) + + def public_forums(self): + """Returns a queryset containing the public forums.""" + return self.filter(category__groups__isnull=True) + + def public_forum_ids(self): + """ + Returns a list of ids for the public forums; the list is cached for + performance. + """ + public_forums = cache.get('public_forum_ids') + if public_forums is None: + public_forums = list(self.filter( + category__groups__isnull=True).values_list('id', flat=True)) + cache.set('public_forum_ids', public_forums, 3600) + return public_forums + + def _for_user(self, user): + """Common code for the xxx_for_user() methods.""" + if user.is_superuser: + qs = self.all() + else: + user_groups = user.groups.all() if user.is_authenticated() else [] + qs = self.filter(Q(category__groups__isnull=True) | + Q(category__groups__in=user_groups)) + return qs + + +class Forum(models.Model): + """ + A forum is a collection of topics. + """ + category = models.ForeignKey(Category, related_name='forums') + name = models.CharField(max_length=80) + slug = models.SlugField(max_length=80) + description = models.TextField(blank=True, default='') + position = models.IntegerField(blank=True, default=0) + moderators = models.ManyToManyField(Group, blank=True, null=True) + + # denormalized fields to reduce database hits + topic_count = models.IntegerField(blank=True, default=0) + post_count = models.IntegerField(blank=True, default=0) + last_post = models.OneToOneField('Post', blank=True, null=True, + related_name='parent_forum') + + objects = ForumManager() + + class Meta: + ordering = ('position', ) + + def __unicode__(self): + return self.name + + @models.permalink + def get_absolute_url(self): + return ('forums-forum_index', [self.slug]) + + def topic_count_update(self): + """Call to notify the forum that its topic count has been updated.""" + self.topic_count = Topic.objects.filter(forum=self).count() + + def post_count_update(self): + """Call to notify the forum that its post count has been updated.""" + my_posts = Post.objects.filter(topic__forum=self) + self.post_count = my_posts.count() + if self.post_count > 0: + self.last_post = my_posts[self.post_count - 1] + else: + self.last_post = None + + def sync(self): + """ + Call to notify the forum that it needs to recompute its + denormalized fields. + """ + self.topic_count_update() + self.post_count_update() + + def last_post_pre_delete(self, deleting_topic=False): + """ + Call this function prior to deleting the last post in the forum. + A new last post will be found, if one exists. + This is to avoid the Django cascading delete issue. + If deleting_topic is True, then the whole topic the last post is + part of is being deleted, so we can't pick a new last post from that + topic. + """ + try: + qs = Post.objects.filter(topic__forum=self) + if deleting_topic: + qs = qs.exclude(topic=self.last_post.topic) + else: + qs = qs.exclude(pk=self.last_post.pk) + + self.last_post = qs.latest() + + except Post.DoesNotExist: + self.last_post = None + + def catchup(self, user, flv=None): + """ + Call to mark this forum all caught up for the given user (i.e. mark all topics + read for this user). + """ + TopicLastVisit.objects.filter(user=user, topic__forum=self).delete() + if flv is None: + try: + flv = ForumLastVisit.objects.get(user=user, forum=self) + except ForumLastVisit.DoesNotExist: + flv = ForumLastVisit(user=user, forum=self) + + now = datetime.datetime.now() + flv.begin_date = now + flv.end_date = now + flv.save() + + +class Topic(models.Model): + """ + A topic is a thread of discussion, consisting of a series of posts. + """ + forum = models.ForeignKey(Forum, related_name='topics') + name = models.CharField(max_length=255) + creation_date = models.DateTimeField(db_index=True) + user = models.ForeignKey(User) + view_count = models.IntegerField(blank=True, default=0) + sticky = models.BooleanField(blank=True, default=False) + locked = models.BooleanField(blank=True, default=False) + subscribers = models.ManyToManyField(User, related_name='subscriptions', + verbose_name='subscribers', blank=True) + bookmarkers = models.ManyToManyField(User, related_name='favorite_topics', + verbose_name='bookmarkers', blank=True) + + # denormalized fields to reduce database hits + post_count = models.IntegerField(blank=True, default=0) + update_date = models.DateTimeField(db_index=True) + last_post = models.OneToOneField('Post', blank=True, null=True, + related_name='parent_topic') + + class Meta: + ordering = ('-sticky', '-update_date', ) + + def __unicode__(self): + return self.name + + @models.permalink + def get_absolute_url(self): + return ('forums-topic_index', [self.pk]) + + @models.permalink + def get_latest_post_url(self): + return ('forums-topic_latest', [self.pk]) + + def post_count_update(self): + """ + Call this function to notify the topic instance that its post count + has changed. + """ + my_posts = Post.objects.filter(topic=self) + self.post_count = my_posts.count() + if self.post_count > 0: + self.last_post = my_posts[self.post_count - 1] + self.update_date = self.last_post.creation_date + else: + self.last_post = None + self.update_date = self.creation_date + + def reply_count(self): + """ + Returns the number of replies to a topic. The first post + doesn't count as a reply. + """ + if self.post_count > 1: + return self.post_count - 1 + return 0 + + def save(self, *args, **kwargs): + if not self.id: + now = datetime.datetime.now() + self.creation_date = now + self.update_date = now + + super(Topic, self).save(*args, **kwargs) + + def last_post_pre_delete(self): + """ + Call this function prior to deleting the last post in the topic. + A new last post will be found, if one exists. + This is to avoid the Django cascading delete issue. + """ + try: + self.last_post = \ + Post.objects.filter(topic=self).exclude(pk=self.last_post.pk).latest() + except Post.DoesNotExist: + self.last_post = None + + def search_title(self): + if self.post_count == 1: + post_text = "(1 post)" + else: + post_text = "(%d posts)" % self.post_count + + return u"%s by %s; %s" % (self.name, self.user.username, post_text) + + def search_summary(self): + return u'' + + def ogp_tags(self): + """ + Returns a dict of Open Graph Protocol meta tags. + + """ + desc = 'Forum topic created by %s on %s.' % ( + self.user.username, + self.creation_date.strftime('%B %d, %Y')) + + return { + 'og:title': self.name, + 'og:type': 'article', + 'og:url': self.get_absolute_url(), + 'og:description': desc, + } + + +class Post(models.Model): + """ + A post is an instance of a user's single contribution to a topic. + """ + topic = models.ForeignKey(Topic, related_name='posts') + user = models.ForeignKey(User, related_name='posts') + creation_date = models.DateTimeField(db_index=True) + update_date = models.DateTimeField(db_index=True) + body = models.TextField() + html = models.TextField() + user_ip = models.IPAddressField(blank=True, default='', null=True) + attachments = models.ManyToManyField(Oembed, through='Attachment') + + class Meta: + ordering = ('creation_date', ) + get_latest_by = 'creation_date' + verbose_name = 'forum post' + verbose_name_plural = 'forum posts' + + @models.permalink + def get_absolute_url(self): + return ('forums-goto_post', [self.pk]) + + def summary(self): + limit = 65 + if len(self.body) < limit: + return self.body + return self.body[:limit] + '...' + + def __unicode__(self): + return self.summary() + + def save(self, *args, **kwargs): + if not self.id: + self.creation_date = datetime.datetime.now() + self.update_date = self.creation_date + + self.html = site_markup(self.body) + super(Post, self).save(*args, **kwargs) + + def delete(self, *args, **kwargs): + first_post_id = self.topic.posts.all()[0].id + super(Post, self).delete(*args, **kwargs) + if self.id == first_post_id: + self.topic.delete() + + def has_been_edited(self): + return self.update_date > self.creation_date + + def touch(self): + """Call this function to indicate the post has been edited.""" + self.update_date = datetime.datetime.now() + + def search_title(self): + return u"%s by %s" % (self.topic.name, self.user.username) + + def search_summary(self): + return self.body + + +class FlaggedPost(models.Model): + """This model represents a user flagging a post as inappropriate.""" + user = models.ForeignKey(User) + post = models.ForeignKey(Post) + flag_date = models.DateTimeField(auto_now_add=True) + + def __unicode__(self): + return u'Post ID %s flagged by %s' % (self.post.id, self.user.username) + + class Meta: + ordering = ('flag_date', ) + + def get_post_url(self): + return '<a href="%s">Post</a>' % self.post.get_absolute_url() + get_post_url.allow_tags = True + + +class ForumLastVisit(models.Model): + """ + This model records the last time a user visited a forum. + It is used to compute if a user has unread topics in a forum. + We keep track of a window of time, delimited by begin_date and end_date. + Topics updated within this window are tracked, and may have TopicLastVisit + objects. + Marking a forum as all read sets the begin_date equal to the end_date. + """ + user = models.ForeignKey(User) + forum = models.ForeignKey(Forum) + begin_date = models.DateTimeField() + end_date = models.DateTimeField() + + class Meta: + unique_together = ('user', 'forum') + ordering = ('-end_date', ) + + def __unicode__(self): + return u'Forum: %d User: %d Date: %s' % (self.forum.id, self.user.id, + self.end_date.strftime('%Y-%m-%d %H:%M:%S')) + + def is_caught_up(self): + return self.begin_date == self.end_date + + +class TopicLastVisit(models.Model): + """ + This model records the last time a user read a topic. + Objects of this class exist for the window specified in the + corresponding ForumLastVisit object. + """ + user = models.ForeignKey(User) + topic = models.ForeignKey(Topic) + last_visit = models.DateTimeField(db_index=True) + + class Meta: + unique_together = ('user', 'topic') + ordering = ('-last_visit', ) + + def __unicode__(self): + return u'Topic: %d User: %d Date: %s' % (self.topic.id, self.user.id, + self.last_visit.strftime('%Y-%m-%d %H:%M:%S')) + + def save(self, *args, **kwargs): + if self.last_visit is None: + self.touch() + super(TopicLastVisit, self).save(*args, **kwargs) + + def touch(self): + self.last_visit = datetime.datetime.now() + + +class Attachment(models.Model): + """ + This model is a "through" table for the M2M relationship between forum + posts and Oembed objects. + """ + post = models.ForeignKey(Post) + embed = models.ForeignKey(Oembed) + order = models.IntegerField() + + class Meta: + ordering = ('order', ) + + def __unicode__(self): + return u'Post %d, %s' % (self.post.pk, self.embed.title) +