diff options
author | Tomasz Zielinski <tomasz.zielinski@pyconsultant.eu> | 2012-01-09 18:02:13 +0100 |
---|---|---|
committer | Tomasz Zielinski <tomasz.zielinski@pyconsultant.eu> | 2012-01-09 18:02:13 +0100 |
commit | 2bf344dd7bde45de9013ff496ccb7ec31277b781 (patch) | |
tree | 50fa2e1498c8200c6e06f3685dab3c5d216e5733 | |
parent | b332d5d6b9f79491c2412989bc1e75679a9dc685 (diff) | |
download | askbot-2bf344dd7bde45de9013ff496ccb7ec31277b781.tar.gz askbot-2bf344dd7bde45de9013ff496ccb7ec31277b781.tar.bz2 askbot-2bf344dd7bde45de9013ff496ccb7ec31277b781.zip |
Optimized question() view
-rw-r--r-- | askbot/models/content.py | 828 | ||||
-rw-r--r-- | askbot/models/post.py | 78 | ||||
-rw-r--r-- | askbot/models/question.py | 19 | ||||
-rw-r--r-- | askbot/skins/default/templates/macros.html | 4 | ||||
-rw-r--r-- | askbot/skins/default/templates/question.html | 25 | ||||
-rw-r--r-- | askbot/skins/default/templates/question/sidebar.html | 4 | ||||
-rw-r--r-- | askbot/skins/default/templates/user_profile/user_stats.html | 4 | ||||
-rw-r--r-- | askbot/tests/db_api_tests.py | 3 | ||||
-rw-r--r-- | askbot/tests/post_model_tests.py | 34 | ||||
-rw-r--r-- | askbot/views/readers.py | 17 | ||||
-rw-r--r-- | askbot/views/writers.py | 6 |
11 files changed, 128 insertions, 894 deletions
diff --git a/askbot/models/content.py b/askbot/models/content.py deleted file mode 100644 index ca747cc8..00000000 --- a/askbot/models/content.py +++ /dev/null @@ -1,828 +0,0 @@ -import datetime -from django.conf import settings -from django.contrib.auth.models import User -from django.contrib.contenttypes import generic -from django.contrib.contenttypes.models import ContentType -from django.core import urlresolvers -from django.db import models -from django.utils import html as html_utils -from django.utils.datastructures import SortedDict -from django.utils.translation import ugettext as _ -from django.utils.http import urlquote as django_urlquote -from django.core import exceptions as django_exceptions - -from askbot.utils.slug import slugify -from askbot import const -from askbot.models.meta import Comment, Vote -from askbot.models.user import EmailFeedSetting -from askbot.models.tag import Tag, MarkedTag, tags_match_some_wildcard -from askbot.models.post import PostRevision -from askbot.models.base import parse_post_text, parse_and_save_post -from askbot.conf import settings as askbot_settings -from askbot import exceptions - -class Content(models.Model): - """ - Base class for Question and Answer - """ - author = models.ForeignKey(User, related_name='%(class)ss') - added_at = models.DateTimeField(default=datetime.datetime.now) - - deleted = models.BooleanField(default=False) - deleted_at = models.DateTimeField(null=True, blank=True) - deleted_by = models.ForeignKey(User, null=True, blank=True, related_name='deleted_%(class)ss') - - wiki = models.BooleanField(default=False) - wikified_at = models.DateTimeField(null=True, blank=True) - - locked = models.BooleanField(default=False) - locked_by = models.ForeignKey(User, null=True, blank=True, related_name='locked_%(class)ss') - locked_at = models.DateTimeField(null=True, blank=True) - - score = models.IntegerField(default=0) - vote_up_count = models.IntegerField(default=0) - vote_down_count = models.IntegerField(default=0) - - comment_count = models.PositiveIntegerField(default=0) - offensive_flag_count = models.SmallIntegerField(default=0) - - last_edited_at = models.DateTimeField(null=True, blank=True) - last_edited_by = models.ForeignKey(User, null=True, blank=True, related_name='last_edited_%(class)ss') - - html = models.TextField(null=True)#html rendition of the latest revision - text = models.TextField(null=True)#denormalized copy of latest revision - comments = generic.GenericRelation(Comment) - votes = generic.GenericRelation(Vote) - - _use_markdown = True - _escape_html = False #markdow does the escaping - _urlize = False - - class Meta: - abstract = True - app_label = 'askbot' - - parse = parse_post_text - parse_and_save = parse_and_save_post - - def __unicode__(self): - if self.is_question(): - return self.title - elif self.is_answer(): - return self.html - raise NotImplementedError - - def get_absolute_url(self, no_slug = False): - if self.is_answer(): - return u'%(base)s%(slug)s?answer=%(id)d#answer-container-%(id)d' % \ - { - 'base': urlresolvers.reverse('question', args=[self.question.id]), - 'slug': django_urlquote(slugify(self.question.title)), - 'id': self.id - } - elif self.is_question(): - url = urlresolvers.reverse('question', args=[self.id]) - if no_slug == True: - return url - else: - return url + django_urlquote(self.slug) - raise NotImplementedError - - - def is_answer(self): - return self.post_type == 'answer' - - def is_question(self): - return self.post_type == 'question' - - def save(self, *args, **kwargs): - models.Model.save(self, *args, **kwargs) # TODO: figure out how to use super() here - if self.is_answer() and 'postgres' in settings.DATABASE_ENGINE: - #hit the database to trigger update of full text search vector - self.question.save() - - - def get_comments(self, visitor = None): - """returns comments for a post, annotated with - ``upvoted_by_user`` parameter, if visitor is logged in - otherwise, returns query set for all comments to a given post - """ - if visitor.is_anonymous(): - return self.comments.all().order_by('id') - else: - comment_content_type = ContentType.objects.get_for_model(Comment) - #a fancy query to annotate comments with the visitor votes - comments = self.comments.extra( - select = SortedDict([ - ( - 'upvoted_by_user', - 'SELECT COUNT(*) from vote, comment ' - 'WHERE vote.user_id = %s AND ' - 'vote.content_type_id = %s AND ' - 'vote.object_id = comment.id', - ) - ]), - select_params = (visitor.id, comment_content_type.id) - ).order_by('id') - return comments - - #todo: maybe remove this wnen post models are unified - def get_text(self): - return self.text - - def get_snippet(self): - """returns an abbreviated snippet of the content - """ - return html_utils.strip_tags(self.html)[:120] + ' ...' - - def add_comment(self, comment=None, user=None, added_at=None): - if added_at is None: - added_at = datetime.datetime.now() - if None in (comment ,user): - raise Exception('arguments comment and user are required') - - #Comment = models.get_model('askbot','Comment')#todo: forum hardcoded - comment = Comment( - content_object=self, - comment=comment, - user=user, - added_at=added_at - ) - comment.parse_and_save(author = user) - self.comment_count = self.comment_count + 1 - self.save() - - #tried to add this to bump updated question - #in most active list, but it did not work - #becase delayed email updates would be triggered - #for cases where user did not subscribe for them - # - #need to redo the delayed alert sender - # - #origin_post = self.get_origin_post() - #if origin_post == self: - # self.last_activity_at = added_at - # self.last_activity_by = user - #else: - # origin_post.last_activity_at = added_at - # origin_post.last_activity_by = user - # origin_post.save() - - return comment - - def get_global_tag_based_subscribers( - self, - tag_mark_reason = None, - subscription_records = None - ): - """returns a list of users who either follow or "do not ignore" - the given set of tags, depending on the tag_mark_reason - - ``subscription_records`` - query set of ``~askbot.models.EmailFeedSetting`` - this argument is used to reduce number of database queries - """ - if tag_mark_reason == 'good': - email_tag_filter_strategy = const.INCLUDE_INTERESTING - user_set_getter = User.objects.filter - elif tag_mark_reason == 'bad': - email_tag_filter_strategy = const.EXCLUDE_IGNORED - user_set_getter = User.objects.exclude - else: - raise ValueError('Uknown value of tag mark reason %s' % tag_mark_reason) - - #part 1 - find users who follow or not ignore the set of tags - tag_names = self.get_tag_names() - tag_selections = MarkedTag.objects.filter( - tag__name__in = tag_names, - reason = tag_mark_reason - ) - subscribers = set( - user_set_getter( - tag_selections__in = tag_selections - ).filter( - notification_subscriptions__in = subscription_records - ).filter( - email_tag_filter_strategy = email_tag_filter_strategy - ) - ) - - #part 2 - find users who follow or not ignore tags via wildcard selections - #inside there is a potentially time consuming loop - if askbot_settings.USE_WILDCARD_TAGS: - #todo: fix this - #this branch will not scale well - #because we have to loop through the list of users - #in python - if tag_mark_reason == 'good': - empty_wildcard_filter = {'interesting_tags__exact': ''} - wildcard_tags_attribute = 'interesting_tags' - update_subscribers = lambda the_set, item: the_set.add(item) - elif tag_mark_reason == 'bad': - empty_wildcard_filter = {'ignored_tags__exact': ''} - wildcard_tags_attribute = 'ignored_tags' - update_subscribers = lambda the_set, item: the_set.discard(item) - - potential_wildcard_subscribers = User.objects.filter( - notification_subscriptions__in = subscription_records - ).filter( - email_tag_filter_strategy = email_tag_filter_strategy - ).exclude( - **empty_wildcard_filter #need this to limit size of the loop - ) - for potential_subscriber in potential_wildcard_subscribers: - wildcard_tags = getattr( - potential_subscriber, - wildcard_tags_attribute - ).split(' ') - - if tags_match_some_wildcard(tag_names, wildcard_tags): - update_subscribers(subscribers, potential_subscriber) - - return subscribers - - def get_global_instant_notification_subscribers(self): - """returns a set of subscribers to post according to tag filters - both - subscribers who ignore tags or who follow only - specific tags - - this method in turn calls several more specialized - subscriber retrieval functions - todo: retrieval of wildcard tag followers ignorers - won't scale at all - """ - subscriber_set = set() - - global_subscriptions = EmailFeedSetting.objects.filter( - feed_type = 'q_all', - frequency = 'i' - ) - - #segment of users who have tag filter turned off - global_subscribers = User.objects.filter( - email_tag_filter_strategy = const.INCLUDE_ALL - ) - subscriber_set.update(global_subscribers) - - #segment of users who want emails on selected questions only - subscriber_set.update( - self.get_global_tag_based_subscribers( - subscription_records = global_subscriptions, - tag_mark_reason = 'good' - ) - ) - - #segment of users who want to exclude ignored tags - subscriber_set.update( - self.get_global_tag_based_subscribers( - subscription_records = global_subscriptions, - tag_mark_reason = 'bad' - ) - ) - return subscriber_set - - - def get_instant_notification_subscribers( - self, - potential_subscribers = None, - mentioned_users = None, - exclude_list = None, - ): - """get list of users who have subscribed to - receive instant notifications for a given post - this method works for questions and answers - - Arguments: - - * ``potential_subscribers`` is not used here! todo: why? - clean this out - parameter is left for the uniformity of the interface - (Comment method does use it) - normally these methods would determine the list - :meth:`~askbot.models.question.Question.get_response_recipients` - :meth:`~askbot.models.question.Answer.get_response_recipients` - - depending on the type of the post - * ``mentioned_users`` - users, mentioned in the post for the first time - * ``exclude_list`` - users who must be excluded from the subscription - - Users who receive notifications are: - - * of ``mentioned_users`` - those who subscribe for the instant - updates on the @name mentions - * those who follow the parent question - * global subscribers (any personalized tag filters are applied) - * author of the question who subscribe to instant updates - on questions that they asked - * authors or any answers who subsribe to instant updates - on the questions which they answered - """ - #print '------------------' - #print 'in content function' - subscriber_set = set() - #print 'potential subscribers: ', potential_subscribers - - #1) mention subscribers - common to questions and answers - if mentioned_users: - mention_subscribers = EmailFeedSetting.objects.filter_subscribers( - potential_subscribers = mentioned_users, - feed_type = 'm_and_c', - frequency = 'i' - ) - subscriber_set.update(mention_subscribers) - - origin_post = self.get_origin_post() - - #print origin_post - - #2) individually selected - make sure that users - #are individual subscribers to this question - selective_subscribers = origin_post.followed_by.all() - #print 'question followers are ', [s for s in selective_subscribers] - if selective_subscribers: - selective_subscribers = EmailFeedSetting.objects.filter_subscribers( - potential_subscribers = selective_subscribers, - feed_type = 'q_sel', - frequency = 'i' - ) - subscriber_set.update(selective_subscribers) - #print 'selective subscribers: ', selective_subscribers - - #3) whole forum subscribers - global_subscribers = origin_post.get_global_instant_notification_subscribers() - subscriber_set.update(global_subscribers) - - #4) question asked by me (todo: not "edited_by_me" ???) - question_author = origin_post.author - if EmailFeedSetting.objects.filter( - subscriber = question_author, - frequency = 'i', - feed_type = 'q_ask' - ): - subscriber_set.add(question_author) - - #4) questions answered by me -make sure is that people - #are authors of the answers to this question - #todo: replace this with a query set method - answer_authors = set() - for answer in origin_post.answers.all(): - authors = answer.get_author_list() - answer_authors.update(authors) - - if answer_authors: - answer_subscribers = EmailFeedSetting.objects.filter_subscribers( - potential_subscribers = answer_authors, - frequency = 'i', - feed_type = 'q_ans', - ) - subscriber_set.update(answer_subscribers) - #print 'answer subscribers: ', answer_subscribers - - #print 'exclude_list is ', exclude_list - subscriber_set -= set(exclude_list) - - #print 'final subscriber set is ', subscriber_set - return list(subscriber_set) - - def get_latest_revision(self): - return self.revisions.all().order_by('-revised_at')[0] - - def get_latest_revision_number(self): - return self.get_latest_revision().revision - - def get_time_of_last_edit(self): - if self.last_edited_at: - return self.last_edited_at - else: - return self.added_at - - def get_owner(self): - return self.author - - def get_author_list( - self, - include_comments = False, - recursive = False, - exclude_list = None): - - #todo: there may be a better way to do these queries - authors = set() - authors.update([r.author for r in self.revisions.all()]) - if include_comments: - authors.update([c.user for c in self.comments.all()]) - if recursive: - if hasattr(self, 'answers'): - for a in self.answers.exclude(deleted = True): - authors.update(a.get_author_list( include_comments = include_comments ) ) - if exclude_list: - authors -= set(exclude_list) - return list(authors) - - def passes_tag_filter_for_user(self, user): - - question = self.get_origin_post() - if user.email_tag_filter_strategy == const.INCLUDE_INTERESTING: - #at least some of the tags must be marked interesting - return user.has_affinity_to_question( - question, - affinity_type = 'like' - ) - elif user.email_tag_filter_strategy == const.EXCLUDE_IGNORED: - return not user.has_affinity_to_question( - question, - affinity_type = 'dislike' - ) - elif user.email_tag_filter_strategy == const.INCLUDE_ALL: - return True - else: - raise ValueError( - 'unexpected User.email_tag_filter_strategy %s' \ - % user.email_tag_filter_strategy - ) - - def post_get_last_update_info(self):#todo: rename this subroutine - when = self.added_at - who = self.author - if self.last_edited_at and self.last_edited_at > when: - when = self.last_edited_at - who = self.last_edited_by - comments = self.comments.all() - if len(comments) > 0: - for c in comments: - if c.added_at > when: - when = c.added_at - who = c.user - return when, who - - def tagname_meta_generator(self): - return u','.join([unicode(tag) for tag in self.get_tag_names()]) - - def get_origin_post(self): - if self.is_answer(): - return self.question - elif self.is_question(): - return self - raise NotImplementedError - - def _repost_as_question(self, new_title = None): - """posts answer as question, together with all the comments - while preserving time stamps and authors - does not delete the answer itself though - """ - if not self.is_answer(): - raise NotImplementedError - revisions = self.revisions.all().order_by('revised_at') - rev0 = revisions[0] - new_question = rev0.author.post_question( - title = new_title, - body_text = rev0.text, - tags = self.question.tagnames, - wiki = self.question.wiki, - is_anonymous = self.question.is_anonymous, - timestamp = rev0.revised_at - ) - if len(revisions) > 1: - for rev in revisions[1:]: - rev.author.edit_question( - question = new_question, - body_text = rev.text, - revision_comment = rev.summary, - timestamp = rev.revised_at - ) - for comment in self.comments.all(): - comment.content_object = new_question - comment.save() - return new_question - - def swap_with_question(self, new_title = None): - """swaps answer with the question it belongs to and - sets the title of question to ``new_title`` - """ - if not self.is_answer(): - raise NotImplementedError - #1) make new question by using new title, tags of old question - # and the answer body, as well as the authors of all revisions - # and repost all the comments - new_question = self._repost_as_question(new_title = new_title) - - #2) post question (all revisions and comments) as answer - new_answer = self.question.repost_as_answer(question = new_question) - - #3) assign all remaining answers to the new question - self.question.answers.update(question = new_question) - self.question.delete() - self.delete() - return new_question - - - def get_page_number(self, answers = None): - """When question has many answers, answers are - paginated. This function returns number of the page - on which the answer will be shown, using the default - sort order. The result may depend on the visitor.""" - if self.is_question(): - return 1 - elif self.is_answer(): - order_number = 0 - for answer in answers: - if self == answer: - break - order_number += 1 - return int(order_number/const.ANSWERS_PAGE_SIZE) + 1 - raise NotImplementedError - - def get_user_vote(self, user): - if not self.is_answer(): - raise NotImplementedError - - if user.is_anonymous(): - return None - - votes = self.votes.filter(user=user) - if votes and votes.count() > 0: - return votes[0] - else: - return None - - - def _question__assert_is_visible_to(self, user): - """raises QuestionHidden""" - if self.deleted: - message = _( - 'Sorry, this question has been ' - 'deleted and is no longer accessible' - ) - if user.is_anonymous(): - raise exceptions.QuestionHidden(message) - try: - user.assert_can_see_deleted_post(self) - except django_exceptions.PermissionDenied: - raise exceptions.QuestionHidden(message) - - def _answer__assert_is_visible_to(self, user): - """raises QuestionHidden or AnswerHidden""" - try: - self.question.assert_is_visible_to(user) - except exceptions.QuestionHidden: - message = _( - 'Sorry, the answer you are looking for is ' - 'no longer available, because the parent ' - 'question has been removed' - ) - raise exceptions.QuestionHidden(message) - if self.deleted: - message = _( - 'Sorry, this answer has been ' - 'removed and is no longer accessible' - ) - if user.is_anonymous(): - raise exceptions.AnswerHidden(message) - try: - user.assert_can_see_deleted_post(self) - except django_exceptions.PermissionDenied: - raise exceptions.AnswerHidden(message) - - def assert_is_visible_to(self, user): - if self.is_question(): - return self._question__assert_is_visible_to(user) - elif self.is_answer(): - return self._answer__assert_is_visible_to(user) - raise NotImplementedError - - def get_updated_activity_data(self, created = False): - if self.is_answer(): - #todo: simplify this to always return latest revision for the second - #part - if created: - return const.TYPE_ACTIVITY_ANSWER, self - else: - latest_revision = self.get_latest_revision() - return const.TYPE_ACTIVITY_UPDATE_ANSWER, latest_revision - elif self.is_question(): - if created: - return const.TYPE_ACTIVITY_ASK_QUESTION, self - else: - latest_revision = self.get_latest_revision() - return const.TYPE_ACTIVITY_UPDATE_QUESTION, latest_revision - raise NotImplementedError - - def get_tag_names(self): - if self.is_question(): - """Creates a list of Tag names from the ``tagnames`` attribute.""" - return self.tagnames.split(u' ') - elif self.is_answer(): - """return tag names on the question""" - return self.question.get_tag_names() - raise NotImplementedError - - - def _answer__apply_edit(self, edited_at=None, edited_by=None, text=None, comment=None, wiki=False): - - if text is None: - text = self.get_latest_revision().text - if edited_at is None: - edited_at = datetime.datetime.now() - if edited_by is None: - raise Exception('edited_by is required') - - self.last_edited_at = edited_at - self.last_edited_by = edited_by - #self.html is denormalized in save() - self.text = text - #todo: bug wiki has no effect here - - #must add revision before saving the answer - self.add_revision( - author = edited_by, - revised_at = edited_at, - text = text, - comment = comment - ) - - self.parse_and_save(author = edited_by) - - self.question.last_activity_at = edited_at - self.question.last_activity_by = edited_by - self.question.save() - - def _question__apply_edit(self, edited_at=None, edited_by=None, title=None,\ - text=None, comment=None, tags=None, wiki=False, \ - edit_anonymously = False): - - latest_revision = self.get_latest_revision() - #a hack to allow partial edits - important for SE loader - if title is None: - title = self.title - if text is None: - text = latest_revision.text - if tags is None: - tags = latest_revision.tagnames - - if edited_by is None: - raise Exception('parameter edited_by is required') - - if edited_at is None: - edited_at = datetime.datetime.now() - - # Update the Question itself - self.title = title - self.last_edited_at = edited_at - self.last_activity_at = edited_at - self.last_edited_by = edited_by - self.last_activity_by = edited_by - self.tagnames = tags - self.text = text - self.is_anonymous = edit_anonymously - - #wiki is an eternal trap whence there is no exit - if self.wiki == False and wiki == True: - self.wiki = True - - # Update the Question tag associations - if latest_revision.tagnames != tags: - self.update_tags(tagnames = tags, user = edited_by, timestamp = edited_at) - - # Create a new revision - self.add_revision( - author = edited_by, - text = text, - revised_at = edited_at, - is_anonymous = edit_anonymously, - comment = comment, - ) - - self.parse_and_save(author = edited_by) - - def apply_edit(self, *kargs, **kwargs): - if kwargs['text'] == '': - kwargs['text'] = ' '#a hack allowing empty body text in posts - if self.is_answer(): - return self._answer__apply_edit(*kargs, **kwargs) - elif self.is_question(): - return self._question__apply_edit(*kargs, **kwargs) - raise NotImplementedError - - def _answer__add_revision(self, author=None, revised_at=None, text=None, comment=None): - #todo: this may be identical to Question.add_revision - if None in (author, revised_at, text): - raise Exception('arguments author, revised_at and text are required') - rev_no = self.revisions.all().count() + 1 - if comment in (None, ''): - if rev_no == 1: - comment = const.POST_STATUS['default_version'] - else: - comment = 'No.%s Revision' % rev_no - return PostRevision.objects.create_answer_revision( - answer=self, - author=author, - revised_at=revised_at, - text=text, - summary=comment, - revision=rev_no - ) - - def _question__add_revision( - self, - author = None, - is_anonymous = False, - text = None, - comment = None, - revised_at = None - ): - if None in (author, text, comment): - raise Exception('author, text and comment are required arguments') - rev_no = self.revisions.all().count() + 1 - if comment in (None, ''): - if rev_no == 1: - comment = const.POST_STATUS['default_version'] - else: - comment = 'No.%s Revision' % rev_no - - return PostRevision.objects.create_question_revision( - question = self, - revision = rev_no, - title = self.title, - author = author, - is_anonymous = is_anonymous, - revised_at = revised_at, - tagnames = self.tagnames, - summary = comment, - text = text - ) - - def add_revision(self, *kargs, **kwargs): - if self.is_answer(): - return self._answer__add_revision(*kargs, **kwargs) - elif self.is_question(): - return self._question__add_revision(*kargs, **kwargs) - raise NotImplementedError - - def _answer__get_response_receivers(self, exclude_list = None): - """get list of users interested in this response - update based on their participation in the question - activity - - exclude_list is required and normally should contain - author of the updated so that he/she is not notified of - the response - """ - assert(exclude_list is not None) - recipients = set() - recipients.update( - self.get_author_list( - include_comments = True - ) - ) - recipients.update( - self.question.get_author_list( - include_comments = True - ) - ) - for answer in self.question.answers.all(): - recipients.update(answer.get_author_list()) - - recipients -= set(exclude_list) - - return list(recipients) - - def _question__get_response_receivers(self, exclude_list = None): - """returns list of users who might be interested - in the question update based on their participation - in the question activity - - exclude_list is mandatory - it normally should have the - author of the update so the he/she is not notified about the update - """ - assert(exclude_list != None) - recipients = set() - recipients.update( - self.get_author_list( - include_comments = True - ) - ) - #do not include answer commenters here - for a in self.answers.all(): - recipients.update(a.get_author_list()) - - recipients -= set(exclude_list) - return recipients - - def get_response_receivers(self, exclude_list = None): - if self.is_answer(): - return self._answer__get_response_receivers(exclude_list) - elif self.is_question(): - return self._question__get_response_receivers(exclude_list) - raise NotImplementedError - - def get_question_title(self): - if self.is_answer(): - return self.question.title - elif self.is_question(): - if self.closed: - attr = const.POST_STATUS['closed'] - elif self.deleted: - attr = const.POST_STATUS['deleted'] - else: - attr = None - if attr is not None: - return u'%s %s' % (self.title, attr) - else: - return self.title - raise NotImplementedError diff --git a/askbot/models/post.py b/askbot/models/post.py index 531989ab..2956a9da 100644 --- a/askbot/models/post.py +++ b/askbot/models/post.py @@ -1,3 +1,4 @@ +from collections import defaultdict import datetime import operator import cgi @@ -393,6 +394,49 @@ class PostManager(BaseQuerySetManager): return answer + def precache_comments(self, for_posts, visitor): + """ + Fetches comments for given posts, and stores them in post._cached_comments + Additionally, annotates posts with ``upvoted_by_user`` parameter, if visitor is logged in + + """ + qs = Post.objects.get_comments().filter(parent__in=for_posts).select_related('author') + + if visitor.is_anonymous(): + comments = list(qs.order_by('added_at')) + else: + upvoted_by_user = list(qs.filter(votes__user=visitor).distinct()) + not_upvoted_by_user = list(qs.exclude(votes__user=visitor).distinct()) + + for c in upvoted_by_user: + c.upvoted_by_user = 1 # numeric value to maintain compatibility with previous version of this code + + comments = upvoted_by_user + not_upvoted_by_user + comments.sort(key=operator.attrgetter('added_at')) + + post_map = defaultdict(list) + for cm in comments: + post_map[cm.parent_id].append(cm) + for post in for_posts: + post._cached_comments = post_map[post.id] + + # Old Post.get_comment(self, visitor=None) method: + # if visitor.is_anonymous(): + # return self.comments.order_by('added_at') + # else: + # upvoted_by_user = list(self.comments.filter(votes__user=visitor).distinct()) + # not_upvoted_by_user = list(self.comments.exclude(votes__user=visitor).distinct()) + # + # for c in upvoted_by_user: + # c.upvoted_by_user = 1 # numeric value to maintain compatibility with previous version of this code + # + # comments = upvoted_by_user + not_upvoted_by_user + # comments.sort(key=operator.attrgetter('added_at')) + # + # return comments + + + class Post(models.Model): post_type = models.CharField(max_length=255) @@ -616,11 +660,13 @@ class Post(models.Model): def is_comment(self): return self.post_type == 'comment' - def get_absolute_url(self, no_slug = False): + def get_absolute_url(self, no_slug = False, question_post=None): from askbot.utils.slug import slugify if self.is_answer(): + if not question_post: + question_post = self.thread._question_post() return u'%(base)s%(slug)s?answer=%(id)d#answer-container-%(id)d' % { - 'base': urlresolvers.reverse('question', args=[self.thread._question_post().id]), + 'base': urlresolvers.reverse('question', args=[question_post.id]), 'slug': django_urlquote(slugify(self.thread.title)), 'id': self.id } @@ -707,25 +753,6 @@ class Post(models.Model): return slugify(self.thread.title) slug = property(_get_slug) - def get_comments(self, visitor = None): - """returns comments for a post, annotated with - ``upvoted_by_user`` parameter, if visitor is logged in - otherwise, returns query set for all comments to a given post - """ - if visitor.is_anonymous(): - return self.comments.order_by('added_at') - else: - upvoted_by_user = list(self.comments.filter(votes__user=visitor)) - not_upvoted_by_user = list(self.comments.exclude(votes__user=visitor)) - - for c in upvoted_by_user: - c.upvoted_by_user = 1 # numeric value to maintain compatibility with previous version of this code - - comments = upvoted_by_user + not_upvoted_by_user - comments.sort(key=operator.attrgetter('added_at')) - - return comments - def get_snippet(self): """returns an abbreviated snippet of the content """ @@ -1590,14 +1617,9 @@ class Post(models.Model): ##### ##### - def is_answer_accepted(self): - if not self.is_answer(): - raise NotImplementedError - return self == self.thread.accepted_answer - def accepted(self): if self.is_answer(): - return self.thread.accepted_answer == self + return self.thread.accepted_answer_id == self.id raise NotImplementedError def get_page_number(self, answer_posts): @@ -1643,6 +1665,8 @@ class Post(models.Model): parent=self.parent ).exists() is False + def hack_template_marker(self, name): + list(Post.objects.filter(text=name)) class PostRevisionManager(models.Manager): diff --git a/askbot/models/question.py b/askbot/models/question.py index 660e5c4b..b3ce8e68 100644 --- a/askbot/models/question.py +++ b/askbot/models/question.py @@ -416,8 +416,23 @@ class Thread(models.Model): thread.similarity = self.get_similarity(other_thread=thread) similar_threads.sort(key=operator.attrgetter('similarity'), reverse=True) + similar_threads = similar_threads[:10] - return similar_threads[:10] + # Denormalize questions to speed up template rendering + thread_map = dict([(thread.id, thread) for thread in similar_threads]) + questions = Post.objects.get_questions().select_related('thread').filter(thread__in=similar_threads) + for q in questions: + thread_map[q.thread_id].question_denorm = q + + # Postprocess data + similar_threads = [ + { + 'url': thread.question_denorm.get_absolute_url(), + 'title': thread.get_title(thread.question_denorm) + } for thread in similar_threads + ] + + return similar_threads return LazyList(get_data) @@ -557,7 +572,7 @@ class Thread(models.Model): return FavoriteQuestion.objects.filter(thread=self, user=user).exists() def get_last_update_info(self): - posts = self.posts.all() + posts = list(self.posts.select_related('author', 'last_edited_by')) last_updated_at = posts[0].added_at last_updated_by = posts[0].author diff --git a/askbot/skins/default/templates/macros.html b/askbot/skins/default/templates/macros.html index 19eaebde..d80c070a 100644 --- a/askbot/skins/default/templates/macros.html +++ b/askbot/skins/default/templates/macros.html @@ -277,9 +277,9 @@ for the purposes of the AJAX comment editor #} <div class="comments" id="{{widget_id}}"> <div class="content"> {% if show_post == post and show_comment and show_comment_position > max_comments %} - {% set comments = post.get_comments(visitor = user)[:show_comment_position] %} + {% set comments = post._cached_comments[:show_comment_position] %} {% else %} - {% set comments = post.get_comments(visitor = user)[:max_comments] %} + {% set comments = post._cached_comments[:max_comments] %} {% endif %} {% for comment in comments %} {# Warning! Any changes to the comment markup IN THIS `FOR` LOOP must be duplicated in post.js diff --git a/askbot/skins/default/templates/question.html b/askbot/skins/default/templates/question.html index c809b3dc..fab7be10 100644 --- a/askbot/skins/default/templates/question.html +++ b/askbot/skins/default/templates/question.html @@ -1,12 +1,12 @@ {% extends "two_column_body.html" %} <!-- question.html --> -{% block title %}{% spaceless %}{{ thread.get_title() }}{% endspaceless %}{% endblock %} +{% block title %}{% spaceless %}{{ thread.get_title(question) }}{% endspaceless %}{% endblock %} {% block meta_description %} <meta name="description" content="{{question.summary|striptags|escape}}" /> {% endblock %} {% block keywords %}{{thread.tagname_meta_generator()}}{% endblock %} {% block forestyle %} - <link rel="canonical" href="{{settings.APP_URL}}{{thread.get_absolute_url()}}" /> + <link rel="canonical" href="{{settings.APP_URL}}{{question.get_absolute_url()}}" /> <link rel="stylesheet" type="text/css" href="{{'/js/wmd/wmd.css'|media}}" /> {% endblock %} {% block content %} @@ -27,7 +27,7 @@ </div> <div class="question-content"> - <h1><a href="{{ thread.get_absolute_url() }}">{{ thread.get_title() }}</a></h1> + <h1><a href="{{ question.get_absolute_url() }}">{{ thread.get_title(question) }}</a></h1> {# ==== START: question/question_tags.html" #} {{ macros.tag_list_widget( tags = thread.get_tag_names(), @@ -108,6 +108,7 @@ }} {# ==== END: question/question_comments.html ==== #} </div> + </div> <div class="clean"></div> {# ==== END: question/question_card.html ==== #} @@ -120,6 +121,7 @@ </div> {# ==== END: question/closed_question_info.html ==== #} {% endif %} + {% if answers %} {# ==== START: question/answer_tab_bar.html ==== #} <div class="tabBar tabBar-answer"> @@ -134,13 +136,13 @@ <span class="label"> Sort by ยป </span> - <a id="oldest" href="{{ thread.get_absolute_url() }}?sort=oldest#sort-top" + <a id="oldest" href="{{ question.get_absolute_url() }}?sort=oldest#sort-top" title="{% trans %}oldest answers will be shown first{% endtrans %}" ><span>{% trans %}oldest answers{% endtrans %}</span></a> - <a id="latest" href="{{ thread.get_absolute_url() }}?sort=latest#sort-top" + <a id="latest" href="{{ question.get_absolute_url() }}?sort=latest#sort-top" title="{% trans %}newest answers will be shown first{% endtrans %}" ><span>{% trans %}newest answers{% endtrans %}</span></a> - <a id="votes" href="{{ thread.get_absolute_url() }}?sort=votes#sort-top" + <a id="votes" href="{{ question.get_absolute_url() }}?sort=votes#sort-top" title="{% trans %}most voted answers will be shown first{% endtrans %}" ><span>{% trans %}popular answers{% endtrans %}</span></a> </div> @@ -150,6 +152,7 @@ {{ macros.paginator(paginator_context) }} <div class="clean"></div> + {% for answer in answers %} {# ==== START: question/answer_card.html ==== #} <a name="{{ answer.id }}"></a> @@ -164,7 +167,7 @@ {# ==== START: question/answer_vote_buttons.html ==== #} {{ macros.post_vote_buttons(post = answer, visitor_vote = user_answer_votes[answer.id]) }} <img id="answer-img-accept-{{ answer.id }}" class="answer-img-accept" - {% if answer.is_answer_accepted() %} + {% if answer.accepted() %} src="{{'/images/vote-accepted-on.png'|media}}" {% else %} src="{{'/images/vote-accepted.png'|media}}" @@ -196,11 +199,12 @@ {% set pipe=joiner('<span class="sep">|</span>') %} <span class="linksopt">{{ pipe() }} <a class="permant-link" - href="{{ answer.get_absolute_url() }}" + href="{{ answer.get_absolute_url(question_post=question) }}" title="{% trans %}answer permanent link{% endtrans %}"> {% trans %}permanent link{% endtrans %} </a> </span> + {% if request.user|can_edit_post(answer) %}{{ pipe() }} <span class="action-link"><a class="question-edit" href="{% url edit_answer answer.id %}">{% trans %}edit{% endtrans %}</a></span> {% endif %} @@ -234,6 +238,7 @@ <a id="swap-question-with-answer-{{answer.id}}">{% trans %}swap with question{% endtrans %}</a> </span> {% endif %} + {# ==== END: question/answer_controls.html ==== #} </div> {# ==== START: question/answer_comments.html ==== #} @@ -259,7 +264,7 @@ <div class="clean"></div> {% else %} {# ==== START: question/sharing_prompt_phrase.html ==== #} - {% set question_url=settings.APP_URL+thread.get_absolute_url()|urlencode %} + {% set question_url=settings.APP_URL+question.get_absolute_url()|urlencode %} <h2 class="share-question">{% trans %}Know someone who can answer? Share a <a href="{{ question_url }}">link</a> to this question via{% endtrans %} {% if settings.ENABLE_SHARING_TWITTER %}{{ macros.share(site = 'twitter', site_label = 'Twitter') }},{% endif %} {% if settings.ENABLE_SHARING_FACEBOOK %}{{ macros.share(site = 'facebook', site_label = 'Facebook') }},{% endif %} @@ -272,6 +277,7 @@ </h2> {# ==== END: question/sharing_prompt_phrase.html ==== #} {% endif %} + {# ==== START: question/new_answer_form.html ==== #} <form id="fmanswer" @@ -359,6 +365,7 @@ <input type="button" class="submit after-editor" id="fmanswer_button" value="{% trans %}Answer Your Own Question{% endtrans %}"/> {%endif%} {# ==== END: question/content.html ==== #} + {% endblock %} {% block sidebar %} {%include "question/sidebar.html" %} diff --git a/askbot/skins/default/templates/question/sidebar.html b/askbot/skins/default/templates/question/sidebar.html index b10c58f2..c2eb3842 100644 --- a/askbot/skins/default/templates/question/sidebar.html +++ b/askbot/skins/default/templates/question/sidebar.html @@ -62,9 +62,9 @@ <div class="box"> <h2>{% trans %}Related questions{% endtrans %}</h2> <div class="questions-related"> - {% for thread in similar_threads.data() %} + {% for thread_dict in similar_threads.data() %} <p> - <a href="{{ thread.get_absolute_url() }}">{{ thread.get_title() }}</a> + <a href="{{ thread_dict.url }}">{{ thread_dict.title }}</a> </p> {% endfor %} </div> diff --git a/askbot/skins/default/templates/user_profile/user_stats.html b/askbot/skins/default/templates/user_profile/user_stats.html index 06e0e9a3..eb56d47c 100644 --- a/askbot/skins/default/templates/user_profile/user_stats.html +++ b/askbot/skins/default/templates/user_profile/user_stats.html @@ -20,8 +20,8 @@ <div class="answer-summary"> <a title="{{ top_answer.summary|collapse }}" href="{% url question top_answer.thread._question_post().id %}{{ top_answer.thread.title|slugify }}#{{ top_answer.id }}"> - <span class="answer-votes {% if top_answer.is_answer_accepted() %}answered-accepted{% endif %}" - title="{% trans answer_score=top_answer.score %}the answer has been voted for {{ answer_score }} times{% endtrans %} {% if top_answer.is_answer_accepted() %}{% trans %}this answer has been selected as correct{% endtrans %}{%endif%}"> + <span class="answer-votes {% if top_answer.accepted() %}answered-accepted{% endif %}" + title="{% trans answer_score=top_answer.score %}the answer has been voted for {{ answer_score }} times{% endtrans %} {% if top_answer.accepted() %}{% trans %}this answer has been selected as correct{% endtrans %}{%endif%}"> {{ top_answer.score }} </span> </a> diff --git a/askbot/tests/db_api_tests.py b/askbot/tests/db_api_tests.py index 06a40921..1f3d3b9b 100644 --- a/askbot/tests/db_api_tests.py +++ b/askbot/tests/db_api_tests.py @@ -374,7 +374,8 @@ class CommentTests(AskbotTestCase): def test_other_user_can_upvote_comment(self): self.other_user.upvote(self.comment) - comments = self.question.get_comments(visitor = self.other_user) + models.Post.objects.precache_comments(for_posts=[self.question], visitor = self.other_user) + comments = self.question._cached_comments self.assertEquals(len(comments), 1) self.assertEquals(comments[0].upvoted_by_user, True) self.assertEquals(comments[0].is_upvoted_by(self.other_user), True) diff --git a/askbot/tests/post_model_tests.py b/askbot/tests/post_model_tests.py index 03d4023c..e11d2e81 100644 --- a/askbot/tests/post_model_tests.py +++ b/askbot/tests/post_model_tests.py @@ -2,7 +2,7 @@ import datetime from django.core.exceptions import ValidationError from askbot.tests.utils import AskbotTestCase -from askbot.models import PostRevision +from askbot.models import Post, PostRevision class PostModelTests(AskbotTestCase): @@ -115,14 +115,38 @@ class PostModelTests(AskbotTestCase): c2 = q.add_comment(user=self.user, comment='blah blah') c3 = self.post_comment(parent_post=q) - self.assertListEqual([c1, c2, c3], q.get_comments(visitor=self.user)) - self.assertListEqual([c1, c2, c3], q.get_comments(visitor=self.u2)) + Post.objects.precache_comments(for_posts=[q], visitor=self.user) + self.assertListEqual([c1, c2, c3], q._cached_comments) + Post.objects.precache_comments(for_posts=[q], visitor=self.u2) + self.assertListEqual([c1, c2, c3], q._cached_comments) c1.added_at, c3.added_at = c3.added_at, c1.added_at c1.save() c3.save() - self.assertListEqual([c3, c2, c1], q.get_comments(visitor=self.user)) - self.assertListEqual([c3, c2, c1], q.get_comments(visitor=self.u2)) + Post.objects.precache_comments(for_posts=[q], visitor=self.user) + self.assertListEqual([c3, c2, c1], q._cached_comments) + Post.objects.precache_comments(for_posts=[q], visitor=self.u2) + self.assertListEqual([c3, c2, c1], q._cached_comments) + + del self.user + + def test_comment_precaching(self): + self.user = self.u1 + q = self.post_question() + + c1 = self.post_comment(parent_post=q) + c2 = q.add_comment(user=self.user, comment='blah blah') + c3 = self.post_comment(parent_post=q) + + Post.objects.precache_comments(for_posts=[q], visitor=self.user) + self.assertListEqual([c1, c2, c3], q._cached_comments) + + c1.added_at, c3.added_at = c3.added_at, c1.added_at + c1.save() + c3.save() + + Post.objects.precache_comments(for_posts=[q], visitor=self.user) + self.assertListEqual([c3, c2, c1], q._cached_comments) del self.user
\ No newline at end of file diff --git a/askbot/views/readers.py b/askbot/views/readers.py index edcf2436..3ef70472 100644 --- a/askbot/views/readers.py +++ b/askbot/views/readers.py @@ -10,13 +10,10 @@ import datetime import logging import urllib import operator -from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 from django.http import HttpResponseRedirect, HttpResponse, Http404 -from django.conf import settings as django_settings from django.core.paginator import Paginator, EmptyPage, InvalidPage from django.template import Context -from django.utils.http import urlencode from django.utils import simplejson from django.utils.translation import ugettext as _ from django.utils.translation import ungettext @@ -25,7 +22,6 @@ from django.views.decorators import csrf from django.core.urlresolvers import reverse from django.core import exceptions as django_exceptions from django.contrib.humanize.templatetags import humanize -from django.views.decorators.cache import cache_page from django.http import QueryDict import askbot @@ -40,7 +36,6 @@ from askbot.utils import functions from askbot.utils.decorators import anonymous_forbidden, ajax_only, get_only from askbot.search.state_manager import SearchState from askbot.templatetags import extra_tags -from askbot.templatetags import extra_filters import askbot.conf from askbot.conf import settings as askbot_settings from askbot.skins.loaders import render_into_skin, get_template#jinja2 template loading enviroment @@ -478,17 +473,10 @@ def question(request, id):#refactor - long subroutine. display question body, an answers = thread.get_answers(user = request.user) answers = answers.select_related('thread', 'author', 'last_edited_by') answers = answers.order_by({"latest":"-added_at", "oldest":"added_at", "votes":"-score" }[answer_sort_method]) + answers = list(answers) - # TODO: Instead of fetching answer posts, fetch all posts that belong to this thread, - # then manually process them so that `answers` variable is unchanged, and for each answer within it - # answer.get_comments() return a pre-cached list of comment posts - # - # Then, in macros.html, we have to adjust the lines with post.get_comments() calls, like: - # {% set comments = post.get_comments(visitor = user)[:show_comment_position] %} - # + Post.objects.precache_comments(for_posts=[question_post] + answers, visitor=request.user) - answers = list(answers) - if thread.accepted_answer: # Put the accepted answer to front answers.remove(thread.accepted_answer) answers.insert(0, thread.accepted_answer) @@ -524,6 +512,7 @@ def question(request, id):#refactor - long subroutine. display question body, an request.session['question_view_times'] = {} last_seen = request.session['question_view_times'].get(question_post.id, None) + updated_when, updated_who = thread.get_last_update_info() if updated_who != request.user: diff --git a/askbot/views/writers.py b/askbot/views/writers.py index 5ffc67cf..12cc2471 100644 --- a/askbot/views/writers.py +++ b/askbot/views/writers.py @@ -537,7 +537,9 @@ def answer(request, id):#process a new answer def __generate_comments_json(obj, user):#non-view generates json data for the post comments """non-view generates json data for the post comments """ - comments = obj.get_comments(visitor = user) + models.Post.objects.precache_comments(for_posts=[obj], visitor=user) + comments = obj._cached_comments + # {"Id":6,"PostId":38589,"CreationDate":"an hour ago","Text":"hello there!","UserDisplayName":"Jarrod Dixon","UserUrl":"/users/3/jarrod-dixon","DeleteUrl":null} json_comments = [] for comment in comments: @@ -556,7 +558,7 @@ def __generate_comments_json(obj, user):#non-view generates json data for the po is_editable = False - comment_owner = comment.get_owner() + comment_owner = comment.author comment_data = {'id' : comment.id, 'object_id': obj.id, 'comment_age': diff_date(comment.added_at), |