diff options
author | Stefano Mancini <stefano.mancini@devinterface.com> | 2012-10-15 14:11:24 +0200 |
---|---|---|
committer | Stefano Mancini <stefano.mancini@devinterface.com> | 2012-10-15 14:11:24 +0200 |
commit | fae17ca2a09b6dc24103f998719706427254aa76 (patch) | |
tree | fb70e1ce6cbcff71d0324e88091bbf20b0c7e9c3 | |
parent | 14775aaaa677a5c3c1aa1b00f385d60d99d44f02 (diff) | |
parent | d83006dd9239fa77b95f77e361d15ad8289c7877 (diff) | |
download | askbot-fae17ca2a09b6dc24103f998719706427254aa76.tar.gz askbot-fae17ca2a09b6dc24103f998719706427254aa76.tar.bz2 askbot-fae17ca2a09b6dc24103f998719706427254aa76.zip |
Merge branch 'master' of https://github.com/ASKBOT/askbot-devel
28 files changed, 753 insertions, 212 deletions
diff --git a/askbot/auth.py b/askbot/auth.py index c80f5db1..846445b4 100644 --- a/askbot/auth.py +++ b/askbot/auth.py @@ -238,7 +238,7 @@ def onAnswerAcceptCanceled(answer, user, timestamp=None): reputation.save() if answer.author == question.author and user == question.author: - #a symmettric measure for the reputation gaming plug + #a symmettric measure for the reputation gaming plug #as in the onAnswerAccept function #here it protects the user from uwanted reputation loss return @@ -263,7 +263,7 @@ def onUpVoted(vote, post, user, timestamp=None): if post.post_type != 'comment': post.vote_up_count = int(post.vote_up_count) + 1 - post.score = int(post.score) + 1 + post.points = int(post.points) + 1 post.save() if post.post_type == 'comment': @@ -300,7 +300,7 @@ def onUpVotedCanceled(vote, post, user, timestamp=None): if post.vote_up_count < 0: post.vote_up_count = 0 - post.score = int(post.score) - 1 + post.points = int(post.points) - 1 post.save() if post.post_type == 'comment': @@ -333,7 +333,7 @@ def onDownVoted(vote, post, user, timestamp=None): vote.save() post.vote_down_count = int(post.vote_down_count) + 1 - post.score = int(post.score) - 1 + post.points = int(post.points) - 1 post.save() if not (post.wiki or post.is_anonymous): @@ -375,7 +375,7 @@ def onDownVotedCanceled(vote, post, user, timestamp=None): post.vote_down_count = int(post.vote_down_count) - 1 if post.vote_down_count < 0: post.vote_down_count = 0 - post.score = post.score + 1 + post.points = post.points + 1 post.save() if not (post.wiki or post.is_anonymous): diff --git a/askbot/const/__init__.py b/askbot/const/__init__.py index 5f47bb79..977cf0c5 100644 --- a/askbot/const/__init__.py +++ b/askbot/const/__init__.py @@ -299,7 +299,7 @@ POST_STATUS = { 'deleted': _('[deleted]'), 'default_version': _('initial version'), 'retagged': _('retagged'), - 'private': _('[private]') + 'private': _('[private]') } #choices used in email and display filters @@ -361,7 +361,7 @@ DEFAULT_USER_STATUS = 'w' #number of items to show in user views USER_VIEW_DATA_SIZE = 50 -#not really dependency, but external links, which it would +#not really dependency, but external links, which it would #be nice to test for correctness from time to time DEPENDENCY_URLS = { 'akismet': 'https://akismet.com/signup/', @@ -411,8 +411,8 @@ SEARCH_ORDER_BY = ( ('last_activity_at', _('activity ascendant')), ('-answer_count', _('answers descendant')), ('answer_count', _('answers ascendant')), - ('-score', _('votes descendant')), - ('score', _('votes ascendant')), + ('-points', _('votes descendant')), + ('points', _('votes ascendant')), ) DEFAULT_QUESTION_WIDGET_STYLE = """ diff --git a/askbot/deps/livesettings/views.py b/askbot/deps/livesettings/views.py index 918c6602..d12eb602 100644 --- a/askbot/deps/livesettings/views.py +++ b/askbot/deps/livesettings/views.py @@ -96,6 +96,6 @@ def export_as_python(request): pp = pprint.PrettyPrinter(indent=4) pretty = pp.pformat(work) - return render_to_response('askbot.deps.livesettings/text.txt', { 'text' : pretty }, mimetype='text/plain') + return render_to_response('livesettings/text.txt', { 'text' : pretty }, mimetype='text/plain') export_as_python = never_cache(staff_member_required(export_as_python)) diff --git a/askbot/doc/source/changelog.rst b/askbot/doc/source/changelog.rst index d77e11ab..a971d9d6 100644 --- a/askbot/doc/source/changelog.rst +++ b/askbot/doc/source/changelog.rst @@ -3,6 +3,7 @@ Changes in Askbot Development version ------------------- +* Added support of Haystack for search (Adolfo) * Added minimum reputation setting to accept any answer as correct (Evgeny) * Added "VIP" option to groups - if checked, all posts belong to the group and users of that group in the future will be able to moderate those posts. Moderation features for VIP group are in progress (Evgeny) * Added setting `NOTIFICATION_DELAY_TIME` to use with enabled celery daemon (Adolfo) diff --git a/askbot/doc/source/optional-modules.rst b/askbot/doc/source/optional-modules.rst index 3337ef0c..995ed224 100644 --- a/askbot/doc/source/optional-modules.rst +++ b/askbot/doc/source/optional-modules.rst @@ -55,6 +55,22 @@ Finally, add lin .. _embedding-video: +Haystack search +============= +Askbot supports `Haystack <http://haystacksearch.org/>`_, a modular search framework that supports popular search engine backends as +Solr, Elasticsearch, Whoosh and Xapian. + +.. note:: + Haystack support in Askbot is a new feature, + please give us your feedback at ``support@askbot.com`` + regarding the possible improvements. + +To enable: + +* add 'haystack' to INSTALLED_APPS +* add ENABLE_HAYSTACK_SEARCH = True in settings.py +* Configure your search backend according to your setup following `this guide <http://django-haystack.readthedocs.org/en/latest/tutorial.html#modify-your-settings-py>`_ + Embedding video =============== diff --git a/askbot/media/style/style.less b/askbot/media/style/style.less index 607261ce..a7d035ed 100644 --- a/askbot/media/style/style.less +++ b/askbot/media/style/style.less @@ -2695,7 +2695,6 @@ a:hover.medal { } .user-profile-page{ - font-size:13px; color:@info-text-dark; p{ diff --git a/askbot/models/__init__.py b/askbot/models/__init__.py index 83e67bb9..db9674e2 100644 --- a/askbot/models/__init__.py +++ b/askbot/models/__init__.py @@ -93,23 +93,28 @@ def get_users_by_text_query(search_query, users_query_set = None): """Runs text search in user names and profile. For postgres, search also runs against user group names. """ - import askbot - if users_query_set is None: - users_query_set = User.objects.all() - if 'postgresql_psycopg2' in askbot.get_database_engine_name(): - from askbot.search import postgresql - return postgresql.run_full_text_search(users_query_set, search_query) + if getattr(django_settings, 'ENABLE_HAYSTACK_SEARCH', False): + from askbot.search.haystack import AskbotSearchQuerySet + qs = AskbotSearchQuerySet().filter(content=search_query).models(User).get_django_queryset(User) + return qs else: - return users_query_set.filter( - models.Q(username__icontains=search_query) | - models.Q(about__icontains=search_query) - ) - #if askbot.get_database_engine_name().endswith('mysql') \ - # and mysql.supports_full_text_search(): - # return User.objects.filter( - # models.Q(username__search = search_query) | - # models.Q(about__search = search_query) - # ) + import askbot + if users_query_set is None: + users_query_set = User.objects.all() + if 'postgresql_psycopg2' in askbot.get_database_engine_name(): + from askbot.search import postgresql + return postgresql.run_full_text_search(users_query_set, search_query) + else: + return users_query_set.filter( + models.Q(username__icontains=search_query) | + models.Q(about__icontains=search_query) + ) + #if askbot.get_database_engine_name().endswith('mysql') \ + # and mysql.supports_full_text_search(): + # return User.objects.filter( + # models.Q(username__search = search_query) | + # models.Q(about__search = search_query) + # ) User.add_to_class( 'status', @@ -851,7 +856,7 @@ def user_assert_can_delete_question(self, question = None): #if there are answers by other people, #then deny, unless user in admin or moderator answer_count = question.thread.all_answers()\ - .exclude(author=self).exclude(score__lte=0).count() + .exclude(author=self).exclude(points__lte=0).count() if answer_count > 0: if self.is_administrator() or self.is_moderator(): @@ -881,7 +886,7 @@ def user_assert_can_delete_answer(self, answer = None): 'you can delete only your own posts' ) low_rep_error_message = _( - 'Sorry, to deleted other people\' posts, a minimum ' + 'Sorry, to delete other people\'s posts, a minimum ' 'reputation of %(min_rep)s is required' ) % \ {'min_rep': askbot_settings.MIN_REP_TO_DELETE_OTHERS_POSTS} @@ -1725,14 +1730,15 @@ def user_edit_answer( ): if force == False: self.assert_can_edit_answer(answer) + answer.apply_edit( - edited_at = timestamp, - edited_by = self, - text = body_text, - comment = revision_comment, - wiki = wiki, - is_private = is_private, - by_email = by_email + edited_at=timestamp, + edited_by=self, + text=body_text, + comment=revision_comment, + wiki=wiki, + is_private=is_private, + by_email=by_email ) answer.thread.invalidate_cached_data() @@ -2458,7 +2464,7 @@ def _process_vote(user, post, timestamp=None, cancel=False, vote_type=None): if post.post_type == 'question': #denormalize the question post score on the thread - post.thread.score = post.score + post.thread.points = post.points post.thread.save() post.thread.update_summary_html() @@ -2889,7 +2895,6 @@ def format_instant_notification_email( only update_types in const.RESPONSE_ACTIVITY_TYPE_MAP_FOR_TEMPLATES are supported """ - site_url = askbot_settings.APP_URL origin_post = post.get_origin_post() #todo: create a better method to access "sub-urls" in user views diff --git a/askbot/models/badges.py b/askbot/models/badges.py index 61149df3..244c8e2f 100644 --- a/askbot/models/badges.py +++ b/askbot/models/badges.py @@ -43,13 +43,13 @@ class Badge(object): """ def __init__(self, key = '', - name = '', + name = '', level = None, description = None, multiple = False): #key - must be an ASCII only word - self.key = key + self.key = key self.name = name self.level = level self.description = description @@ -114,11 +114,11 @@ class Badge(object): def consider_award(self, actor = None, context_object = None, timestamp = None): - """Normally this method should be reimplemented + """Normally this method should be reimplemented in subclass, but some badges are awarded without checks. Those do no need to override this method - actor - user who committed some action, context_object - + actor - user who committed some action, context_object - the object related to the award situation, e.g. answer """ return self.award(actor, context_object, timestamp) @@ -141,7 +141,7 @@ class Disciplined(Badge): if context_object.author != actor: return False - if context_object.score >= \ + if context_object.points>= \ askbot_settings.DISCIPLINED_BADGE_MIN_UPVOTES: return self.award(actor, context_object, timestamp) @@ -163,7 +163,7 @@ class PeerPressure(Badge): if context_object.author != actor: return False - if context_object.score <= \ + if context_object.points<= \ -1 * askbot_settings.PEER_PRESSURE_BADGE_MIN_DOWNVOTES: return self.award(actor, context_object, timestamp) return False @@ -181,12 +181,12 @@ class Teacher(Badge): multiple = False ) - def consider_award(self, actor = None, + def consider_award(self, actor = None, context_object = None, timestamp = None): if context_object.post_type != 'answer': return False - if context_object.score >= askbot_settings.TEACHER_BADGE_MIN_UPVOTES: + if context_object.points>= askbot_settings.TEACHER_BADGE_MIN_UPVOTES: return self.award(context_object.author, context_object, timestamp) return False @@ -268,7 +268,7 @@ class SelfLearner(Badge): question = context_object.thread._question_post() answer = context_object - if question.author == answer.author and answer.score >= min_upvotes: + if question.author == answer.author and answer.points >= min_upvotes: self.award(context_object.author, context_object, timestamp) class QualityPost(Badge): @@ -294,7 +294,7 @@ class QualityPost(Badge): context_object = None, timestamp = None): if context_object.post_type not in ('answer', 'question'): return False - if context_object.score >= self.min_votes: + if context_object.points >= self.min_votes: return self.award(context_object.author, context_object, timestamp) return False @@ -485,7 +485,7 @@ class VotedAcceptedAnswer(Badge): if context_object.post_type != 'answer': return None answer = context_object - if answer.score >= self.min_votes and answer.accepted(): + if answer.points >= self.min_votes and answer.accepted(): return self.award(answer.author, answer, timestamp) class Enlightened(VotedAcceptedAnswer): @@ -537,7 +537,7 @@ class Necromancer(Badge): delta = datetime.timedelta(askbot_settings.NECROMANCER_BADGE_MIN_DELAY) min_score = askbot_settings.NECROMANCER_BADGE_MIN_UPVOTES if answer.added_at - question.added_at >= delta \ - and answer.score >= min_score: + and answer.points >= min_score: return self.award(answer.author, answer, timestamp) return False @@ -723,7 +723,7 @@ class Enthusiast(Badge): return False class Commentator(Badge): - """Commentator is a bronze badge that is + """Commentator is a bronze badge that is awarded once when user posts a certain number of comments""" def __init__(self): @@ -778,7 +778,7 @@ class Expert(Badge): ) ORIGINAL_DATA = """ - + extra badges from stackexchange * commentator - left n comments (single) * enthusiast, fanatic - visited site n days in a row (s) @@ -894,7 +894,7 @@ award_badges_signal = Signal( #context_object - database object related to the event, e.g. question @auto_now_timestamp -def award_badges(event = None, actor = None, +def award_badges(event = None, actor = None, context_object = None, timestamp = None, **kwargs): """function that is called when signal `award_badges_signal` is sent """ diff --git a/askbot/models/post.py b/askbot/models/post.py index 9984155a..10b3cdc7 100644 --- a/askbot/models/post.py +++ b/askbot/models/post.py @@ -59,6 +59,15 @@ class PostQuerySet(models.query.QuerySet): #todo: we may not need this query set class, #as all methods on this class seem to want to #belong to Thread manager or Query set. + def get_for_user(self, user): + if askbot_settings.GROUPS_ENABLED: + if user is None or user.is_anonymous(): + groups = [get_global_group()] + else: + groups = user.get_groups() + return self.filter(groups__in = groups).distinct() + else: + return self def get_by_text_query(self, search_query): """returns a query set of questions, @@ -156,24 +165,16 @@ class PostManager(BaseQuerySetManager): def get_query_set(self): return PostQuerySet(self.model) - def get_questions(self): - return self.filter(post_type='question') + def get_questions(self, user=None): + questions = self.filter(post_type='question') + return questions.get_for_user(user) - def get_answers(self, user = None): + def get_answers(self, user=None): """returns query set of answer posts, optionally filtered to exclude posts of groups to which user does not belong""" answers = self.filter(post_type='answer') - - if askbot_settings.GROUPS_ENABLED: - if user is None or user.is_anonymous(): - groups = [get_global_group()] - else: - groups = user.get_groups() - answers = answers.filter(groups__in = groups).distinct() - - return answers - + return answers.get_for_user(user) def get_comments(self): return self.filter(post_type='comment') @@ -358,7 +359,7 @@ class Post(models.Model): locked_by = models.ForeignKey(User, null=True, blank=True, related_name='locked_posts') locked_at = models.DateTimeField(null=True, blank=True) - score = models.IntegerField(default=0) + points = models.IntegerField(default=0, db_column='score') vote_up_count = models.IntegerField(default=0) vote_down_count = models.IntegerField(default=0) @@ -389,6 +390,14 @@ class Post(models.Model): app_label = 'askbot' db_table = 'askbot_post' + #property to support legacy themes in case there are. + @property + def score(self): + return int(self.points) + @score.setter + def score(self, number): + if number: + self.points = int(number) def parse_post_text(self): """typically post has a field to store raw source text @@ -1717,9 +1726,10 @@ class Post(models.Model): ##it is important to do this before __apply_edit b/c of signals!!! if self.is_private() != is_private: if is_private: - self.make_private(self.author) + #todo: make private for author or for the editor? + self.thread.make_private(self.author) else: - self.make_public() + self.thread.make_public(recursive=False) self.__apply_edit( edited_at=edited_at, diff --git a/askbot/models/question.py b/askbot/models/question.py index 5878500d..6c45f1eb 100644 --- a/askbot/models/question.py +++ b/askbot/models/question.py @@ -2,7 +2,7 @@ import datetime import operator import re -from django.conf import settings +from django.conf import settings as django_settings from django.db import models from django.contrib.auth.models import User from django.core import cache # import cache, not from cache import cache, to be able to monkey-patch cache.cache in test cases @@ -176,28 +176,33 @@ class ThreadManager(BaseQuerySetManager): """returns a query set of questions, matching the full text query """ - if not qs: - qs = self.all() -# if getattr(settings, 'USE_SPHINX_SEARCH', False): -# matching_questions = Question.sphinx_search.query(search_query) -# question_ids = [q.id for q in matching_questions] -# return qs.filter(posts__post_type='question', posts__deleted=False, posts__self_question_id__in=question_ids) - if askbot.get_database_engine_name().endswith('mysql') \ - and mysql.supports_full_text_search(): - return qs.filter( - models.Q(title__search = search_query) | - models.Q(tagnames__search = search_query) | - models.Q(posts__deleted=False, posts__text__search = search_query) - ) - elif 'postgresql_psycopg2' in askbot.get_database_engine_name(): - from askbot.search import postgresql - return postgresql.run_full_text_search(qs, search_query) + if django_settings.ENABLE_HAYSTACK_SEARCH: + from askbot.search.haystack import AskbotSearchQuerySet + hs_qs = AskbotSearchQuerySet().filter(content=search_query) + return hs_qs.get_django_queryset() else: - return qs.filter( - models.Q(title__icontains=search_query) | - models.Q(tagnames__icontains=search_query) | - models.Q(posts__deleted=False, posts__text__icontains = search_query) - ) + if not qs: + qs = self.all() + # if getattr(settings, 'USE_SPHINX_SEARCH', False): + # matching_questions = Question.sphinx_search.query(search_query) + # question_ids = [q.id for q in matching_questions] + # return qs.filter(posts__post_type='question', posts__deleted=False, posts__self_question_id__in=question_ids) + if askbot.get_database_engine_name().endswith('mysql') \ + and mysql.supports_full_text_search(): + return qs.filter( + models.Q(title__search = search_query) | + models.Q(tagnames__search = search_query) | + models.Q(posts__deleted=False, posts__text__search = search_query) + ) + elif 'postgresql_psycopg2' in askbot.get_database_engine_name(): + from askbot.search import postgresql + return postgresql.run_full_text_search(qs, search_query) + else: + return qs.filter( + models.Q(title__icontains=search_query) | + models.Q(tagnames__icontains=search_query) | + models.Q(posts__deleted=False, posts__text__icontains = search_query) + ) def run_advanced_search(self, request_user, search_state): # TODO: !! review, fix, and write tests for this @@ -211,8 +216,8 @@ class ThreadManager(BaseQuerySetManager): # TODO: add a possibility to see deleted questions qs = self.filter( - posts__post_type='question', - posts__deleted=False + posts__post_type='question', + posts__deleted=False, ) # (***) brings `askbot_post` into the SQL query, see the ordering section below if askbot_settings.ENABLE_CONTENT_MODERATION: @@ -249,7 +254,7 @@ class ThreadManager(BaseQuerySetManager): ) # TODO: unify with search_state.author ? #unified tags - is list of tags taken from the tag selection - #plus any tags added to the query string with #tag or [tag:something] + #plus any tags added to the query string with #tag or [tag:something] #syntax. #run tag search in addition to these unified tags meta_data = {} @@ -271,7 +276,7 @@ class ThreadManager(BaseQuerySetManager): existing_tags.add(tag_record.name) except Tag.DoesNotExist: non_existing_tags.add(tag) - + meta_data['non_existing_tags'] = list(non_existing_tags) tags = existing_tags else: @@ -298,7 +303,7 @@ class ThreadManager(BaseQuerySetManager): elif search_state.scope == 'favorite': favorite_filter = models.Q(favorited_by=request_user) - if 'followit' in settings.INSTALLED_APPS: + if 'followit' in django_settings.INSTALLED_APPS: followed_users = request_user.get_followed_users() favorite_filter |= models.Q(posts__post_type__in=('question', 'answer'), posts__author__in=followed_users) qs = qs.filter(favorite_filter) @@ -370,13 +375,21 @@ class ThreadManager(BaseQuerySetManager): 'activity-asc': 'last_activity_at', 'answers-desc': '-answer_count', 'answers-asc': 'answer_count', - 'votes-desc': '-score', - 'votes-asc': 'score', + 'votes-desc': '-points', + 'votes-asc': 'points', 'relevance-desc': '-relevance', # special Postgresql-specific ordering, 'relevance' quaso-column is added by get_for_query() } + orderby = QUESTION_ORDER_BY_MAP[search_state.sort] - qs = qs.extra(order_by=[orderby]) + + if not ( + getattr(django_settings, 'ENABLE_HAYSTACK_SEARCH', False) \ + and orderby=='-relevance' + ): + #FIXME: this does not produces the very same results as postgres. + qs = qs.extra(order_by=[orderby]) + # HACK: We add 'ordering_key' column as an alias and order by it, because when distict() is used, # qs.extra(order_by=[orderby,]) is lost if only `orderby` column is from askbot_post! @@ -403,7 +416,7 @@ class ThreadManager(BaseQuerySetManager): page_questions = Post.objects.filter( post_type='question', thread__id__in = thread_ids ).only(# pick only the used fields - 'id', 'thread', 'score', 'is_anonymous', + 'id', 'thread', 'points', 'is_anonymous', 'summary', 'post_type', 'deleted' ) page_question_map = {} @@ -514,13 +527,23 @@ class Thread(models.Model): answer_accepted_at = models.DateTimeField(null=True, blank=True) added_at = models.DateTimeField(default = datetime.datetime.now) - score = models.IntegerField(default = 0) + #db_column will be removed later + points = models.IntegerField(default = 0, db_column='score') objects = ThreadManager() - + class Meta: app_label = 'askbot' + #property to support legacy themes in case there are. + @property + def score(self): + return int(self.points) + @score.setter + def score(self, number): + if number: + self.points = int(number) + def _question_post(self, refresh=False): if refresh and hasattr(self, '_question_cache'): delattr(self, '_question_cache') @@ -694,7 +717,7 @@ class Thread(models.Model): def get_answers_by_user(self, user): """regardless - deleted or not""" - return self.posts.filter(post_type = 'answer', author = user) + return self.posts.filter(post_type='answer', author=user, deleted=False) def has_answer_by_user(self, user): #use len to cache the queryset @@ -735,14 +758,26 @@ class Thread(models.Model): if user is None or user.is_anonymous(): return self.posts.get_answers().filter(deleted=False) else: - if user.is_administrator() or user.is_moderator(): - return self.posts.get_answers(user = user) - else: - return self.posts.get_answers(user = user).filter( - models.Q(deleted = False) \ - | models.Q(author = user) \ - | models.Q(deleted_by = user) - ) + return self.posts.get_answers( + user=user + ).filter(deleted=False) + # return self.posts.get_answers(user=user).filter( + # models.Q(deleted=False) \ + # | models.Q(author=user) \ + # | models.Q(deleted_by=user) + # ) + #we used to show deleted answers to admins, + #users who deleted those answers and answer owners + #but later decided to not show deleted answers at all + #because it makes caching the post lists for thread easier + #if user.is_administrator() or user.is_moderator(): + # return self.posts.get_answers(user=user) + #else: + # return self.posts.get_answers(user=user).filter( + # models.Q(deleted=False) \ + # | models.Q(author=user) \ + # | models.Q(deleted_by=user) + # ) def invalidate_cached_thread_content_fragment(self): cache.cache.delete(self.SUMMARY_CACHE_KEY_TPL % self.id) @@ -751,7 +786,7 @@ class Thread(models.Model): return 'thread-data-%s-%s' % (self.id, sort_method) def invalidate_cached_post_data(self): - """needs to be called when anything notable + """needs to be called when anything notable changes in the post data - on votes, adding, deleting, editing content""" #we can call delete_many() here if using Django > 1.2 @@ -798,7 +833,7 @@ class Thread(models.Model): { 'latest':'-added_at', 'oldest':'added_at', - 'votes':'-score' + 'votes':'-points' }[sort_method] ) #1) collect question, answer and comment posts and list of post id's @@ -852,17 +887,18 @@ class Thread(models.Model): #todo: there may be > 1 enquirers published_answer_ids = list() if self.is_moderated() and user != question_post.author: - #if moderated - then author is guaranteed to be the + #if moderated - then author is guaranteed to be the #limited visibility enquirer published_answer_ids = self.posts.get_answers( - user=question_post.author#todo: may be > 1 + user=question_post.author + #todo: may be > 1 user ).filter( deleted=False ).order_by( { 'latest':'-added_at', 'oldest':'added_at', - 'votes':'-score' + 'votes':'-points' }[sort_method] ).values_list('id', flat=True) @@ -870,9 +906,13 @@ class Thread(models.Model): #now put those answers first answer_map = dict([(answer.id, answer) for answer in answers]) for answer_id in published_answer_ids: - answer = answer_map[answer_id] - answers.remove(answer) - answers.insert(0, answer) + #note that answer map may not contain answers publised + #to the question enquirer, because current user may + #not have access to that answer, so we use the .get() method + answer = answer_map.get(answer_id, None) + if answer: + answers.remove(answer) + answers.insert(0, answer) return (question_post, answers, post_to_author, published_answer_ids) @@ -940,8 +980,8 @@ class Thread(models.Model): url = question_post.get_absolute_url() title = thread.get_title(question_post) result.append({'url': url, 'title': title}) - - return result + + return result def get_cached_data(): """similar thread data will expire @@ -986,7 +1026,7 @@ class Thread(models.Model): return False def add_child_posts_to_groups(self, groups): - """adds questions and answers of the thread to + """adds questions and answers of the thread to given groups, comments are taken care of implicitly by the underlying ``Post`` methods """ @@ -1258,7 +1298,7 @@ class Thread(models.Model): return last_updated_at, last_updated_by - def get_summary_html(self, search_state, visitor = None): + def get_summary_html(self, search_state=None, visitor = None): html = self.get_cached_summary_html(visitor) if not html: html = self.update_summary_html(visitor) @@ -1273,6 +1313,9 @@ class Thread(models.Model): re.UNICODE ) + if search_state is None: + search_state = DummySearchState() + while True: match = regex.search(html) if not match: @@ -1293,6 +1336,12 @@ class Thread(models.Model): return cache.cache.get(self.SUMMARY_CACHE_KEY_TPL % self.id) def update_summary_html(self, visitor = None): + #todo: it is quite wrong that visitor is an argument here + #because we do not include any visitor-related info in the cache key + #ideally cache should be shareable between users, so straight up + #using the user id for cache is wrong, we could use group + #memberships, but in that case we'd need to be more careful with + #cache invalidation context = { 'thread': self, #fetch new question post to make sure we're up-to-date diff --git a/askbot/models/repute.py b/askbot/models/repute.py index 33ec3a42..a6e9d7d1 100644 --- a/askbot/models/repute.py +++ b/askbot/models/repute.py @@ -75,14 +75,14 @@ class Vote(models.Model): """ #importing locally because of circular dependency from askbot import auth - score_before = self.voted_post.score + score_before = self.voted_post.points if self.vote > 0: # cancel upvote auth.onUpVotedCanceled(self, self.voted_post, self.user) else: # cancel downvote auth.onDownVotedCanceled(self, self.voted_post, self.user) - score_after = self.voted_post.score + score_after = self.voted_post.points return score_after - score_before @@ -94,7 +94,7 @@ class BadgeData(models.Model): awarded_to = models.ManyToManyField(User, through='Award', related_name='badges') def _get_meta_data(self): - """retrieves badge metadata stored + """retrieves badge metadata stored in a file""" from askbot.models import badges return badges.get_badge(self.slug) @@ -171,9 +171,9 @@ class ReputeManager(models.Manager): tomorrow = today + datetime.timedelta(1) rep_types = (1,-8) sums = self.filter(models.Q(reputation_type__in=rep_types), - user=user, + user=user, reputed_at__range=(today, tomorrow), - ).aggregate(models.Sum('positive'), models.Sum('negative')) + ).aggregate(models.Sum('positive'), models.Sum('negative')) if sums: pos = sums['positive__sum'] neg = sums['negative__sum'] @@ -200,7 +200,7 @@ class Repute(models.Model): #assigned_by_moderator - so that reason can be displayed #in that case Question field will be blank comment = models.CharField(max_length=128, null=True) - + objects = ReputeManager() def __unicode__(self): @@ -214,7 +214,7 @@ class Repute(models.Model): """returns HTML snippet with a link to related question or a text description for a the reason of the reputation change - in the implementation description is returned only + in the implementation description is returned only for Repute.reputation_type == 10 - "assigned by the moderator" part of the purpose of this method is to hide this idiosyncracy @@ -242,7 +242,7 @@ class Repute(models.Model): return '<a href="%(url)s" title="%(link_title)s">%(question_title)s</a>' \ % { - 'url': self.question.get_absolute_url(), + 'url': self.question.get_absolute_url(), 'question_title': escape(self.question.thread.title), 'link_title': escape(link_title) } diff --git a/askbot/models/tag.py b/askbot/models/tag.py index 38555e49..647ea5cf 100644 --- a/askbot/models/tag.py +++ b/askbot/models/tag.py @@ -3,6 +3,7 @@ import logging from django.db import models from django.contrib.auth.models import User from django.utils.translation import ugettext as _ +from django.conf import settings from askbot.models.base import BaseQuerySetManager from askbot import const from askbot.conf import settings as askbot_settings diff --git a/askbot/search/haystack/__init__.py b/askbot/search/haystack/__init__.py new file mode 100644 index 00000000..71f04d00 --- /dev/null +++ b/askbot/search/haystack/__init__.py @@ -0,0 +1,59 @@ +try: + from haystack import indexes, site + from haystack.query import SearchQuerySet + from askbot.models import Post, Thread, User + + + class ThreadIndex(indexes.SearchIndex): + text = indexes.CharField(document=True, use_template=True) + title = indexes.CharField(model_attr='title') + post_text = indexes.CharField(model_attr='posts__text__search') + + def index_queryset(self): + return Thread.objects.filter(posts__deleted=False) + + def prepare(self, obj): + self.prepared_data = super(ThreadIndex, self).prepare(object) + + self.prepared_data['tags'] = [tag.name for tag in objects.tags.all()] + + class PostIndex(indexes.SearchIndex): + text = indexes.CharField(document=True, use_template=True) + post_text = indexes.CharField(model_attr='text') + author = indexes.CharField(model_attr='user') + thread_id = indexes.CharField(model_attr='thread') + + def index_queryset(self): + return Post.objects.filter(deleted=False) + + class UserIndex(indexes.SearchIndex): + text = indexes.CharField(document=True, use_template=True) + + def index_queryset(self): + return User.objects.all() + + site.register(Post, PostIndex) + site.register(Thread, ThreadIndex) + site.register(User, UserIndex) + + class AskbotSearchQuerySet(SearchQuerySet): + + def get_django_queryset(self, model_klass=Thread): + '''dirty hack because models() method from the + SearchQuerySet does not work </3''' + id_list = [] + for r in self: + if r.model_name in ['thread','post'] \ + and model_klass._meta.object_name.lower() == 'thread': + if getattr(r, 'thread_id'): + id_list.append(r.thread_id) + else: + id_list.append(r.pk) + elif r.model_name == model_klass._meta.object_name.lower(): + #FIXME: add a highlight here? + id_list.append(r.pk) + + return model_klass.objects.filter(id__in=set(id_list)) + +except: + pass diff --git a/askbot/setup_templates/settings.py b/askbot/setup_templates/settings.py index 3d24fc5e..94df6f29 100644 --- a/askbot/setup_templates/settings.py +++ b/askbot/setup_templates/settings.py @@ -162,6 +162,7 @@ INSTALLED_APPS = ( 'django.contrib.humanize', 'django.contrib.sitemaps', #'debug_toolbar', + #'haystack', 'askbot', 'askbot.deps.django_authopenid', #'askbot.importers.stackexchange', #se loader @@ -235,6 +236,13 @@ STATICFILES_DIRS = ( RECAPTCHA_USE_SSL = True +#HAYSTACK_SETTINGS +ENABLE_HAYSTACK_SEARCH = False +HAYSTACK_SITECONF = 'askbot.search.haystack' +#more information +#http://django-haystack.readthedocs.org/en/v1.2.7/settings.html +HAYSTACK_SEARCH_ENGINE = 'simple' + TINYMCE_COMPRESSOR = True TINYMCE_SPELLCHECKER = False TINYMCE_JS_ROOT = os.path.join(STATIC_ROOT, 'common/media/js/tinymce/') diff --git a/askbot/setup_templates/settings.py.mustache b/askbot/setup_templates/settings.py.mustache index a800edec..74295513 100644 --- a/askbot/setup_templates/settings.py.mustache +++ b/askbot/setup_templates/settings.py.mustache @@ -161,6 +161,8 @@ INSTALLED_APPS = ( 'django.contrib.humanize', 'django.contrib.sitemaps', #'debug_toolbar', + #Optional, to enable haystack search + #'haystack', 'askbot', 'askbot.deps.django_authopenid', #'askbot.importers.stackexchange', #se loader @@ -236,6 +238,13 @@ STATICFILES_DIRS = ( RECAPTCHA_USE_SSL = True +#HAYSTACK_SETTINGS +ENABLE_HAYSTACK_SEARCH = False +HAYSTACK_SITECONF = 'askbot.search.haystack' +#more information +#http://django-haystack.readthedocs.org/en/v1.2.7/settings.html +HAYSTACK_SEARCH_ENGINE = 'simple' + TINYMCE_COMPRESSOR = True TINYMCE_SPELLCHECKER = False TINYMCE_JS_ROOT = os.path.join(STATIC_ROOT, 'common/media/js/tinymce/') diff --git a/askbot/startup_procedures.py b/askbot/startup_procedures.py index 736397fb..706e19ad 100644 --- a/askbot/startup_procedures.py +++ b/askbot/startup_procedures.py @@ -531,13 +531,27 @@ def test_avatar(): short_message = True ) +def test_haystack(): + if 'haystack' in django_settings.INSTALLED_APPS: + try_import('haystack', 'django-haystack', short_message = True) + if getattr(django_settings, 'ENABLE_HAYSTACK_SEARCH', False): + errors = list() + if not hasattr(django_settings, 'HAYSTACK_SEARCH_ENGINE'): + message = "Please HAYSTACK_SEARCH_ENGINE to an appropriate value, value 'simple' can be used for basic testing" + errors.append(message) + if not hasattr(django_settings, 'HAYSTACK_SITECONF'): + message = 'Please add HAYSTACK_SITECONF = "askbot.search.haystack"' + errors.append(message) + footer = 'Please refer to haystack documentation at http://django-haystack.readthedocs.org/en/v1.2.7/settings.html#haystack-search-engine' + print_errors(errors, footer=footer) + def test_custom_user_profile_tab(): setting_name = 'ASKBOT_CUSTOM_USER_PROFILE_TAB' tab_settings = getattr(django_settings, setting_name, None) if tab_settings: if not isinstance(tab_settings, dict): print "Setting %s must be a dictionary!!!" % setting_name - + name = tab_settings.get('NAME', None) slug = tab_settings.get('SLUG', None) func_name = tab_settings.get('CONTENT_GENERATOR', None) @@ -691,6 +705,7 @@ def run_startup_tests(): test_new_skins() test_longerusername() test_avatar() + test_haystack() settings_tester = SettingsTester({ 'CACHE_MIDDLEWARE_ANONYMOUS_ONLY': { 'value': True, @@ -720,6 +735,10 @@ def run_startup_tests(): 'RECAPTCHA_USE_SSL': { 'value': True, 'message': 'Please add: RECAPTCHA_USE_SSL = True' + }, + 'HAYSTACK_SITECONF': { + 'value': 'askbot.search.haystack', + 'message': 'Please add: HAYSTACK_SITECONF = "askbot.search.haystack"' } }) settings_tester.run() diff --git a/askbot/tests/__init__.py b/askbot/tests/__init__.py index fcef288b..7e7e3c48 100644 --- a/askbot/tests/__init__.py +++ b/askbot/tests/__init__.py @@ -14,9 +14,12 @@ from askbot.tests.markup_test import * from askbot.tests.post_model_tests import * from askbot.tests.thread_model_tests import * from askbot.tests.reply_by_email_tests import * +from askbot.tests.haystack_search_tests import * from askbot.tests.email_parsing_tests import * from askbot.tests.widget_tests import * from askbot.tests.category_tree_tests import CategoryTreeTests +from askbot.tests.question_views_tests import * from askbot.tests.user_model_tests import UserModelTests +from askbot.tests.user_views_tests import * from askbot.tests.utils_tests import * from askbot.tests.view_context_tests import * diff --git a/askbot/tests/__init__.py.orig b/askbot/tests/__init__.py.orig deleted file mode 100644 index 905c90df..00000000 --- a/askbot/tests/__init__.py.orig +++ /dev/null @@ -1,19 +0,0 @@ -from askbot.tests.cache_tests import * -from askbot.tests.email_alert_tests import * -from askbot.tests.on_screen_notification_tests import * -from askbot.tests.page_load_tests import * -from askbot.tests.permission_assertion_tests import * -from askbot.tests.db_api_tests import * -from askbot.tests.skin_tests import * -from askbot.tests.badge_tests import * -from askbot.tests.management_command_tests import * -from askbot.tests.search_state_tests import * -from askbot.tests.form_tests import * -from askbot.tests.follow_tests import * -from askbot.tests.templatefilter_tests import * -from askbot.tests.markup_test import * -from askbot.tests.post_model_tests import * -from askbot.tests.thread_model_tests import * -from askbot.tests.reply_by_email_tests import * -from askbot.tests.category_tree_tests import CategoryTreeTests -from askbot.tests.user_model_tests import UserModelTests diff --git a/askbot/tests/badge_tests.py b/askbot/tests/badge_tests.py index 0ed4b343..c184db6f 100644 --- a/askbot/tests/badge_tests.py +++ b/askbot/tests/badge_tests.py @@ -24,7 +24,7 @@ class BadgeTests(AskbotTestCase): def assert_accepted_answer_badge_works(self, badge_key = None, - min_score = None, + min_points = None, expected_count = 1, previous_count = 0, trigger = None @@ -32,7 +32,7 @@ class BadgeTests(AskbotTestCase): assert(trigger in ('accept_best_answer', 'upvote_answer')) question = self.post_question(user = self.u1) answer = self.post_answer(user = self.u2, question = question) - answer.score = min_score - 1 + answer.points = min_points - 1 answer.save() recipient = answer.author @@ -47,30 +47,30 @@ class BadgeTests(AskbotTestCase): self.u1.upvote(answer) self.assert_have_badge(badge_key, recipient, expected_count) - def assert_upvoted_answer_badge_works(self, + def assert_upvoted_answer_badge_works(self, badge_key = None, - min_score = None, + min_points = None, multiple = False ): """test answer badge where answer author is the recipient where badge award is triggered by upvotes - * min_score - minimum # of upvotes required + * min_points - minimum # of upvotes required * multiple - multiple award or not * badge_key - key on askbot.models.badges.Badge object """ question = self.post_question(user = self.u1) answer = self.post_answer(user = self.u2, question = question) - answer.score = min_score - 1 + answer.points = min_points - 1 answer.save() self.u1.upvote(answer) self.assert_have_badge(badge_key, recipient = self.u2) self.u3.upvote(answer) self.assert_have_badge(badge_key, recipient = self.u2, expected_count = 1) - + #post another question and check that there are no new badges question2 = self.post_question(user = self.u1) answer2 = self.post_answer(user = self.u2, question = question2) - answer2.score = min_score - 1 + answer2.score = min_points - 1 answer2.save() self.u1.upvote(answer2) @@ -85,28 +85,28 @@ class BadgeTests(AskbotTestCase): expected_count = expected_count ) - def assert_upvoted_question_badge_works(self, + def assert_upvoted_question_badge_works(self, badge_key = None, - min_score = None, + min_points = None, multiple = False ): """test question badge where question author is the recipient where badge award is triggered by upvotes - * min_score - minimum # of upvotes required + * min_points - minimum # of upvotes required * multiple - multiple award or not * badge_key - key on askbot.models.badges.Badge object """ question = self.post_question(user = self.u1) - question.score = min_score - 1 + question.points = min_points - 1 question.save() self.u2.upvote(question) self.assert_have_badge(badge_key, recipient = self.u1) self.u3.upvote(question) self.assert_have_badge(badge_key, recipient = self.u1, expected_count = 1) - + #post another question and check that there are no new badges question2 = self.post_question(user = self.u1) - question2.score = min_score - 1 + question2.points = min_points - 1 question2.save() self.u2.upvote(question2) @@ -123,13 +123,13 @@ class BadgeTests(AskbotTestCase): def test_disciplined_badge(self): question = self.post_question(user = self.u1) - question.score = settings.DISCIPLINED_BADGE_MIN_UPVOTES + question.points = settings.DISCIPLINED_BADGE_MIN_UPVOTES question.save() self.u1.delete_question(question) self.assert_have_badge('disciplined', recipient = self.u1) question2 = self.post_question(user = self.u1) - question2.score = settings.DISCIPLINED_BADGE_MIN_UPVOTES + question2.points = settings.DISCIPLINED_BADGE_MIN_UPVOTES question2.save() self.u1.delete_question(question2) self.assert_have_badge('disciplined', recipient = self.u1, expected_count = 2) @@ -137,7 +137,7 @@ class BadgeTests(AskbotTestCase): def test_peer_pressure_badge(self): question = self.post_question(user = self.u1) answer = self.post_answer(user = self.u1, question = question) - answer.score = -1*settings.PEER_PRESSURE_BADGE_MIN_DOWNVOTES + answer.points = -1*settings.PEER_PRESSURE_BADGE_MIN_DOWNVOTES answer.save() self.u1.delete_answer(answer) self.assert_have_badge('peer-pressure', recipient = self.u1) @@ -145,21 +145,21 @@ class BadgeTests(AskbotTestCase): def test_teacher_badge(self): self.assert_upvoted_answer_badge_works( badge_key = 'teacher', - min_score = settings.TEACHER_BADGE_MIN_UPVOTES, + min_points = settings.TEACHER_BADGE_MIN_UPVOTES, multiple = False ) def test_nice_answer_badge(self): self.assert_upvoted_answer_badge_works( badge_key = 'nice-answer', - min_score = settings.NICE_ANSWER_BADGE_MIN_UPVOTES, + min_points = settings.NICE_ANSWER_BADGE_MIN_UPVOTES, multiple = True ) def test_nice_question_badge(self): self.assert_upvoted_question_badge_works( badge_key = 'nice-question', - min_score = settings.NICE_QUESTION_BADGE_MIN_UPVOTES, + min_points = settings.NICE_QUESTION_BADGE_MIN_UPVOTES, multiple = True ) @@ -227,7 +227,7 @@ class BadgeTests(AskbotTestCase): question = self.post_question(user = self.u1) answer = self.post_answer(user = self.u1, question = question) min_votes = settings.SELF_LEARNER_BADGE_MIN_UPVOTES - answer.score = min_votes - 1 + answer.points = min_votes - 1 answer.save() self.u2.upvote(answer) self.assert_have_badge('self-learner', recipient = self.u1) @@ -235,14 +235,14 @@ class BadgeTests(AskbotTestCase): #copy-paste of the first question, except expect second badge question = self.post_question(user = self.u1) answer = self.post_answer(user = self.u1, question = question) - answer.score = min_votes - 1 + answer.points = min_votes - 1 answer.save() self.u2.upvote(answer) self.assert_have_badge('self-learner', recipient = self.u1, expected_count = 2) question = self.post_question(user = self.u2) answer = self.post_answer(user = self.u1, question = question) - answer.score = min_votes - 1 + answer.points = min_votes - 1 answer.save() self.u2.upvote(answer) #no badge when asker != answerer @@ -282,13 +282,13 @@ class BadgeTests(AskbotTestCase): def assert_enlightened_badge_works(self, trigger): self.assert_accepted_answer_badge_works( 'enlightened', - min_score = settings.ENLIGHTENED_BADGE_MIN_UPVOTES, + min_points = settings.ENLIGHTENED_BADGE_MIN_UPVOTES, expected_count = 1, trigger = trigger ) self.assert_accepted_answer_badge_works( 'enlightened', - min_score = settings.ENLIGHTENED_BADGE_MIN_UPVOTES, + min_points = settings.ENLIGHTENED_BADGE_MIN_UPVOTES, expected_count = 1, previous_count = 1, trigger = trigger @@ -297,13 +297,13 @@ class BadgeTests(AskbotTestCase): def assert_guru_badge_works(self, trigger): self.assert_accepted_answer_badge_works( 'guru', - min_score = settings.GURU_BADGE_MIN_UPVOTES, + min_points = settings.GURU_BADGE_MIN_UPVOTES, expected_count = 1, trigger = trigger ) self.assert_accepted_answer_badge_works( 'guru', - min_score = settings.GURU_BADGE_MIN_UPVOTES, + min_points = settings.GURU_BADGE_MIN_UPVOTES, previous_count = 1, expected_count = 2, trigger = trigger @@ -330,8 +330,8 @@ class BadgeTests(AskbotTestCase): user = self.u2, question = question, timestamp = future - ) - answer.score = settings.NECROMANCER_BADGE_MIN_UPVOTES - 1 + ) + answer.points = settings.NECROMANCER_BADGE_MIN_UPVOTES - 1 answer.save() self.assert_have_badge('necromancer', self.u2, expected_count = 0) self.u1.upvote(answer) @@ -457,7 +457,7 @@ class BadgeTests(AskbotTestCase): self.u1.toggle_favorite_question(question) """no gaming""" self.assert_have_badge('stellar-question', self.u1, 0) - + def test_stellar_badge3(self): question = self.post_question(user = self.u1) settings.update('STELLAR_QUESTION_BADGE_MIN_STARS', 2) @@ -480,9 +480,9 @@ class BadgeTests(AskbotTestCase): self.post_comment(user = self.u1, parent_post = question) self.assert_have_badge('commentator', self.u1, 0) - self.post_comment(user = self.u1, parent_post = question) + self.post_comment(user = self.u1, parent_post = question) self.assert_have_badge('commentator', self.u1, 1) - self.post_comment(user = self.u1, parent_post = question) + self.post_comment(user = self.u1, parent_post = question) self.assert_have_badge('commentator', self.u1, 1) def test_taxonomist_badge(self): diff --git a/askbot/tests/db_api_tests.py b/askbot/tests/db_api_tests.py index 5ad26e8a..5477990a 100644 --- a/askbot/tests/db_api_tests.py +++ b/askbot/tests/db_api_tests.py @@ -9,6 +9,7 @@ from django.test.client import Client from django.conf import settings from django.contrib.auth.models import AnonymousUser from django import forms +from askbot import exceptions as askbot_exceptions from askbot.tests.utils import AskbotTestCase from askbot.tests.utils import with_settings from askbot import models @@ -38,6 +39,7 @@ class DBApiTests(AskbotTestCase): user = user, question = question, ) + return self.answer def assert_post_is_deleted(self, post): self.assertTrue(post.deleted == True) @@ -82,6 +84,25 @@ class DBApiTests(AskbotTestCase): ) return self.reload_object(q) + def test_user_cannot_post_two_answers(self): + question = self.post_question(user=self.user) + answer = self.post_answer(question=question, user=self.user) + self.assertRaises( + askbot_exceptions.AnswerAlreadyGiven, + self.post_answer, + question=question, + user=self.user + ) + + def test_user_can_post_answer_after_deleting_one(self): + question = self.post_question(user=self.user) + answer = self.post_answer(question=question, user=self.user) + self.user.delete_answer(answer=answer) + answer2 = self.post_answer(question=question, user=self.user) + answers = question.thread.get_answers(user=self.user) + self.assertEqual(answers.count(), 1) + self.assertEqual(answers[0], answer2) + def test_post_anonymous_question(self): q = self.ask_anonymous_question() self.assertTrue(q.is_anonymous) @@ -173,13 +194,13 @@ class DBApiTests(AskbotTestCase): count = models.Tag.objects.filter(name='one-tag').count() self.assertEquals(count, 0) - + @with_settings(MAX_TAG_LENGTH=200, MAX_TAGS_PER_POST=50) def test_retag_tags_too_long_raises(self): tags = "aoaoesuouooeueooeuoaeuoeou aostoeuoaethoeastn oasoeoa nuhoasut oaeeots aoshootuheotuoehao asaoetoeatuoasu o aoeuethut aoaoe uou uoetu uouuou ao aouosutoeh" question = self.post_question(user=self.user) self.assertRaises( - exceptions.ValidationError, + exceptions.ValidationError, self.user.retag_question, question=question, tags=tags ) @@ -415,10 +436,10 @@ class CommentTests(AskbotTestCase): def test_other_user_can_cancel_upvote(self): self.test_other_user_can_upvote_comment() comment = models.Post.objects.get_comments().get(id = self.comment.id) - self.assertEquals(comment.score, 1) + self.assertEquals(comment.points, 1) self.other_user.upvote(comment, cancel = True) comment = models.Post.objects.get_comments().get(id = self.comment.id) - self.assertEquals(comment.score, 0) + self.assertEquals(comment.points, 0) class GroupTests(AskbotTestCase): def setUp(self): @@ -526,7 +547,7 @@ class GroupTests(AskbotTestCase): #because answer groups always inherit thread groups self.edit_answer(user=self.u1, answer=answer, is_private=True) self.assertEqual(answer.groups.count(), 1) - + #here we have a simple case - the comment to answer was posted #by the answer author!!! #won't work when comment was by someone else diff --git a/askbot/tests/haystack_search_tests.py b/askbot/tests/haystack_search_tests.py new file mode 100644 index 00000000..7a8bfcfd --- /dev/null +++ b/askbot/tests/haystack_search_tests.py @@ -0,0 +1,101 @@ +"""Tests haystack indexes and queries""" +from django.core import exceptions +from django.conf import settings +from django.contrib.auth.models import User +from askbot.tests.utils import AskbotTestCase, skipIf +from askbot import models +import datetime + +class HaystackSearchTests(AskbotTestCase): + """tests methods on User object, + that were added for askbot + """ + def setUp(self): + self._old_value = getattr(settings, 'ENABLE_HAYSTACK_SEARCH', False) + setattr(settings, "ENABLE_HAYSTACK_SEARCH", True) + + self.user = self.create_user(username='gepeto') + self.other_user = self.create_user(username = 'pinocho') + self.other_user.location = 'Managua' + self.other_user.about = "I'm made of wood, gepeto made me" + self.other_user.save() + body_1 = '''Lorem turpis purus? Amet mattis eu et sociis phasellus + montes elementum proin ut urna enim, velit, tincidunt quis ut, + et integer mus? Nunc! Vut sed? Ac tincidunt egestas adipiscing, + magna et pulvinar mid est urna ultricies, turpis tristique nisi, + cum. Urna. Purus elit porttitor nisi porttitor ridiculus tincidunt + amet duis, gepeto''' + #from Baldy of Nome by Esther Birdsall Darling + body_2 = ''' With unseeing eyes and dragging steps, the boy trudged along the snowy + trail, dreading the arrival at Golconda Camp. For there was the House of + Judgment, where all of the unfortunate events of that most unhappy day + would be reviewed sternly, lorem''' + self.question1 = self.post_question( + user=self.user, + body_text=body_1, + title='Test title 1' + ) + self.question2 = self.post_question( + user=self.other_user, + body_text=body_2, + title='Test title 2, Baldy of Nome' + ) + self.answer1 = self.post_answer( + user=self.user, + question = self.question1, + body_text="This is a answer for question 1" + ) + self.answer1 = self.post_answer( + user=self.other_user, + question = self.question2, + body_text="Just a random text to fill the space" + ) + + def tearDown(self): + setattr(settings, "ENABLE_HAYSTACK_SEARCH", self._old_value) + + @skipIf('haystack' not in settings.INSTALLED_APPS, + 'Haystack not setup') + def test_title_search(self): + #title search + title_search_qs = models.Thread.objects.get_for_query('title') + title_search_qs_2 = models.Thread.objects.get_for_query('Nome') + self.assertEquals(title_search_qs.count(), 2) + self.assertEquals(title_search_qs_2.count(), 1) + + @skipIf('haystack' not in settings.INSTALLED_APPS, + 'Haystack not setup') + def test_body_search(self): + + #bodysearch + body_search_qs = models.Thread.objects.get_for_query('Lorem') + self.assertEquals(body_search_qs.count(), 2) + body_search_qs_2 = models.Thread.objects.get_for_query('steps') + self.assertEquals(body_search_qs_2.count(), 1) + + @skipIf('haystack' not in settings.INSTALLED_APPS, + 'Haystack not setup') + def test_user_profile_search(self): + #must return pinocho + user_profile_qs = models.get_users_by_text_query('wood') + self.assertEquals(user_profile_qs.count(), 1) + + #returns both gepeto and pinocho because gepeto nickname + #and gepeto name in pinocho's profile + user_profile_qs = models.get_users_by_text_query('gepeto') + self.assertEquals(user_profile_qs.count(), 2) + + @skipIf('haystack' not in settings.INSTALLED_APPS, + 'Haystack not setup') + def test_get_django_queryset(self): + '''makes a query that can return multiple models and test + get_django_queryset() method from AskbotSearchQuerySet''' + #gepeto is present in profile and in question + from askbot.search.haystack import AskbotSearchQuerySet + qs = AskbotSearchQuerySet().filter(content='gepeto').get_django_queryset(User) + for instance in qs: + self.assertTrue(isinstance(instance, User)) + + qs = AskbotSearchQuerySet().filter(content='gepeto').get_django_queryset(models.Thread) + for instance in qs: + self.assertTrue(isinstance(instance, models.Thread)) diff --git a/askbot/tests/page_load_tests.py b/askbot/tests/page_load_tests.py index 3805d012..4efac0f0 100644 --- a/askbot/tests/page_load_tests.py +++ b/askbot/tests/page_load_tests.py @@ -166,13 +166,15 @@ class PageLoadTestCase(AskbotTestCase): group.save() user = self.create_user('user') user.join_group(group) - self.post_question(user=user, title='alibaba', group_id=group.id) + question = self.post_question(user=user, title='alibaba', group_id=group.id) + #ask for data anonymously - should get nothing query_data = {'query': 'alibaba'} response = self.client.get(reverse('api_get_questions'), query_data) response_data = simplejson.loads(response.content) self.assertEqual(len(response_data), 0) + #log in - should get the question self.client.login(method='force', user_id=user.id) response = self.client.get(reverse('api_get_questions'), query_data) response_data = simplejson.loads(response.content) diff --git a/askbot/tests/post_model_tests.py b/askbot/tests/post_model_tests.py index 1a3a9c49..e61fcd2d 100644 --- a/askbot/tests/post_model_tests.py +++ b/askbot/tests/post_model_tests.py @@ -618,7 +618,7 @@ class ThreadRenderCacheUpdateTests(AskbotTestCase): def test_question_upvote_downvote(self): question = self.post_question() - question.score = 5 + question.points = 5 question.vote_up_count = 7 question.vote_down_count = 2 question.save() @@ -631,7 +631,7 @@ class ThreadRenderCacheUpdateTests(AskbotTestCase): data = simplejson.loads(response.content) self.assertEqual(1, data['success']) - self.assertEqual(6, data['count']) # 6 == question.score(5) + 1 + self.assertEqual(6, data['count']) # 6 == question.points(5) + 1 thread = Thread.objects.get(id=question.thread.id) @@ -647,7 +647,7 @@ class ThreadRenderCacheUpdateTests(AskbotTestCase): data = simplejson.loads(response.content) self.assertEqual(1, data['success']) - self.assertEqual(5, data['count']) # 6 == question.score(6) - 1 + self.assertEqual(5, data['count']) # 6 == question.points(6) - 1 thread = Thread.objects.get(id=question.thread.id) diff --git a/askbot/tests/question_views_tests.py b/askbot/tests/question_views_tests.py new file mode 100644 index 00000000..b1836f9e --- /dev/null +++ b/askbot/tests/question_views_tests.py @@ -0,0 +1,207 @@ +from bs4 import BeautifulSoup +from askbot.conf import settings as askbot_settings +from askbot import const +from askbot.tests.utils import AskbotTestCase +from askbot import models +from askbot.models.tag import get_global_group +from django.core.urlresolvers import reverse + + +class PrivateQuestionViewsTests(AskbotTestCase): + + def setUp(self): + self._backup = askbot_settings.GROUPS_ENABLED + askbot_settings.update('GROUPS_ENABLED', True) + self.user = self.create_user('user') + self.group = models.Group.objects.create( + name='the group', openness=models.Group.OPEN + ) + self.user.join_group(self.group) + self.qdata = { + 'title': 'test question title', + 'text': 'test question text' + } + self.client.login(user_id=self.user.id, method='force') + + def tearDown(self): + askbot_settings.update('GROUPS_ENABLED', self._backup) + + def test_post_private_question(self): + data = self.qdata + data['post_privately'] = 'checked' + response1 = self.client.post(reverse('ask'), data=data) + response2 = self.client.get(response1['location']) + dom = BeautifulSoup(response2.content) + title = dom.find('h1').text + self.assertTrue(const.POST_STATUS['private'] in title) + question = models.Thread.objects.get(id=1) + self.assertEqual(question.title, self.qdata['title']) + self.assertFalse(get_global_group() in set(question.groups.all())) + + #private question is not accessible to unauthorized users + self.client.logout() + response = self.client.get(question._question_post().get_absolute_url()) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.content, '') + #private question link is not shown on the main page + #to unauthorized users + response = self.client.get(reverse('questions')) + self.assertFalse(self.qdata['title'] in response.content) + #private question link is not shown on the poster profile + #to the unauthorized users + response = self.client.get(self.user.get_profile_url()) + self.assertFalse(self.qdata['title'] in response.content) + + def test_publish_private_question(self): + question = self.post_question(user=self.user, is_private=True) + title = question.thread.get_title() + self.assertTrue(const.POST_STATUS['private'] in title) + data = self.qdata + #data['post_privately'] = 'false' + data['select_revision'] = 'false' + data['text'] = 'edited question text' + response1 = self.client.post( + reverse('edit_question', kwargs={'id':question.id}), + data=data + ) + response2 = self.client.get(question.get_absolute_url()) + dom = BeautifulSoup(response2.content) + title = dom.find('h1').text + self.assertTrue(get_global_group() in set(question.groups.all())) + self.assertEqual(title, self.qdata['title']) + + self.client.logout() + response = self.client.get(question.get_absolute_url()) + self.assertTrue('edited question text' in response.content) + + def test_privatize_public_question(self): + question = self.post_question(user=self.user) + title = question.thread.get_title() + self.assertFalse(const.POST_STATUS['private'] in title) + data = self.qdata + data['post_privately'] = 'checked' + data['select_revision'] = 'false' + response1 = self.client.post( + reverse('edit_question', kwargs={'id':question.id}), + data=data + ) + response2 = self.client.get(question.get_absolute_url()) + dom = BeautifulSoup(response2.content) + title = dom.find('h1').text + self.assertFalse(get_global_group() in set(question.groups.all())) + self.assertTrue(const.POST_STATUS['private'] in title) + + def test_private_checkbox_is_on_when_editing_private_question(self): + question = self.post_question(user=self.user, is_private=True) + response = self.client.get( + reverse('edit_question', kwargs={'id':question.id}) + ) + dom = BeautifulSoup(response.content) + checkbox = dom.find( + 'input', attrs={'type': 'checkbox', 'name': 'post_privately'} + ) + self.assertEqual(checkbox['checked'], 'checked') + + def test_private_checkbox_is_off_when_editing_public_question(self): + question = self.post_question(user=self.user) + response = self.client.get( + reverse('edit_question', kwargs={'id':question.id}) + ) + dom = BeautifulSoup(response.content) + checkbox = dom.find( + 'input', attrs={'type': 'checkbox', 'name': 'post_privately'} + ) + self.assertEqual(checkbox.get('checked', False), False) + + +class PrivateAnswerViewsTests(AskbotTestCase): + + def setUp(self): + self._backup = askbot_settings.GROUPS_ENABLED + askbot_settings.update('GROUPS_ENABLED', True) + self.user = self.create_user('user') + group = models.Group.objects.create( + name='the group', openness=models.Group.OPEN + ) + self.user.join_group(group) + self.question = self.post_question(user=self.user) + self.client.login(user_id=self.user.id, method='force') + + def tearDown(self): + askbot_settings.update('GROUPS_ENABLED', self._backup) + + def test_post_private_answer(self): + response1 = self.client.post( + reverse('answer', kwargs={'id': self.question.id}), + data={'text': 'some answer text', 'post_privately': 'checked'} + ) + answer = self.question.thread.get_answers(user=self.user)[0] + self.assertFalse(get_global_group() in set(answer.groups.all())) + self.client.logout() + + user2 = self.create_user('user2') + self.client.login(user_id=user2.id, method='force') + response = self.client.get(self.question.get_absolute_url()) + self.assertFalse('some answer text' in response.content) + + self.client.logout() + response = self.client.get(self.question.get_absolute_url()) + self.assertFalse('some answer text' in response.content) + + + def test_private_checkbox_is_on_when_editing_private_answer(self): + answer = self.post_answer( + question=self.question, user=self.user, is_private=True + ) + response = self.client.get( + reverse('edit_answer', kwargs={'id': answer.id}) + ) + dom = BeautifulSoup(response.content) + checkbox = dom.find( + 'input', attrs={'type': 'checkbox', 'name': 'post_privately'} + ) + self.assertEqual(checkbox['checked'], 'checked') + + def test_privaet_checkbox_is_off_when_editing_public_answer(self): + answer = self.post_answer(question=self.question, user=self.user) + response = self.client.get( + reverse('edit_answer', kwargs={'id': answer.id}) + ) + dom = BeautifulSoup(response.content) + checkbox = dom.find( + 'input', attrs={'type': 'checkbox', 'name': 'post_privately'} + ) + self.assertEqual(checkbox.get('checked', False), False) + + def test_publish_private_answer(self): + answer = self.post_answer( + question=self.question, user=self.user, is_private=True + ) + self.client.post( + reverse('edit_answer', kwargs={'id': answer.id}), + data={'text': 'edited answer text', 'select_revision': 'false'} + ) + answer = self.reload_object(answer) + self.assertTrue(get_global_group() in answer.groups.all()) + self.client.logout() + response = self.client.get(self.question.get_absolute_url()) + self.assertTrue('edited answer text' in response.content) + + + def test_privatize_public_answer(self): + answer = self.post_answer(question=self.question, user=self.user) + self.client.post( + reverse('edit_answer', kwargs={'id': answer.id}), + data={ + 'text': 'edited answer text', + 'post_privately': 'checked', + 'select_revision': 'false' + } + ) + #check that answer is not visible to the "everyone" group + answer = self.reload_object(answer) + self.assertFalse(get_global_group() in answer.groups.all()) + #check that countent is not seen by an anonymous user + self.client.logout() + response = self.client.get(self.question.get_absolute_url()) + self.assertFalse('edited answer text' in response.content) diff --git a/askbot/tests/user_views_tests.py b/askbot/tests/user_views_tests.py new file mode 100644 index 00000000..489cf76a --- /dev/null +++ b/askbot/tests/user_views_tests.py @@ -0,0 +1,39 @@ +from askbot.tests.utils import AskbotTestCase +from askbot.views.users import owner_or_moderator_required +from django.contrib.auth.models import AnonymousUser +from django.core.urlresolvers import reverse +from django.http import HttpResponseRedirect +from mock import Mock +import urllib +import urlparse + +class UserViewsTests(AskbotTestCase): + + def test_owner_or_mod_required_passes_url_parameters(self): + @owner_or_moderator_required + def mock_view(request, user, context): + return None + + request = Mock(spec=('path', 'REQUEST', 'user')) + request.user = AnonymousUser() + request.REQUEST = {'abra': 'cadabra', 'foo': 'bar'} + request.path = '/some/path/' + user = self.create_user('user') + response = mock_view(request, user, {}) + self.assertEqual(isinstance(response, HttpResponseRedirect), True) + + url = response['location'] + parsed_url = urlparse.urlparse(url) + + self.assertEqual(parsed_url.path, reverse('user_signin')) + + next = dict(urlparse.parse_qsl(parsed_url.query))['next'] + next_url = urllib.unquote(next) + parsed_url = urlparse.urlparse(next_url) + + self.assertEqual(parsed_url.path, request.path) + + query = dict(urlparse.parse_qsl(parsed_url.query)) + self.assertEqual(set(query.keys()), set(['foo', 'abra'])) + self.assertEqual(set(query.values()), set(['bar', 'cadabra'])) + self.assertEqual(query['abra'], 'cadabra') diff --git a/askbot/views/commands.py b/askbot/views/commands.py index f02061cd..f7d22f48 100644 --- a/askbot/views/commands.py +++ b/askbot/views/commands.py @@ -169,7 +169,7 @@ def process_vote(user = None, vote_direction = None, post = None): if vote != None: user.assert_can_revoke_old_vote(vote) score_delta = vote.cancel() - response_data['count'] = post.score + score_delta + response_data['count'] = post.points+ score_delta response_data['status'] = 1 #this means "cancel" else: @@ -192,7 +192,7 @@ def process_vote(user = None, vote_direction = None, post = None): else: vote = user.downvote(post = post) - response_data['count'] = post.score + response_data['count'] = post.points response_data['status'] = 0 #this means "not cancel", normal operation response_data['success'] = 1 @@ -842,7 +842,8 @@ def upvote_comment(request): ) else: raise ValueError - return {'score': comment.score} + #FIXME: rename js + return {'score': comment.points} @csrf.csrf_exempt @decorators.ajax_only diff --git a/askbot/views/users.py b/askbot/views/users.py index dbcbda5c..c22fd294 100644 --- a/askbot/views/users.py +++ b/askbot/views/users.py @@ -12,6 +12,7 @@ import functools import datetime import logging import operator +import urllib from django.db.models import Count from django.conf import settings as django_settings @@ -54,7 +55,8 @@ def owner_or_moderator_required(f): elif request.user.is_authenticated() and request.user.can_moderate_user(profile_owner): pass else: - params = '?next=%s' % request.path + next_url = request.path + '?' + urllib.urlencode(request.REQUEST) + params = '?next=%s' % urllib.quote(next_url) return HttpResponseRedirect(url_utils.get_login_url() + params) return f(request, profile_owner, context) return wrapped_func @@ -369,9 +371,15 @@ def user_stats(request, user, context): # # Questions # - questions = user.posts.get_questions().filter(**question_filter).\ - order_by('-score', '-thread__last_activity_at').\ - select_related('thread', 'thread__last_activity_by')[:100] + questions = user.posts.get_questions( + user=request.user + ).filter( + **question_filter + ).order_by( + '-points', '-thread__last_activity_at' + ).select_related( + 'thread', 'thread__last_activity_by' + )[:100] #added this if to avoid another query if questions is less than 100 if len(questions) < 100: @@ -391,7 +399,7 @@ def user_stats(request, user, context): ).select_related( 'thread' ).order_by( - '-score', '-added_at' + '-points', '-added_at' )[:100] top_answer_count = len(top_answers) @@ -724,7 +732,7 @@ def user_responses(request, user, context): and "flags" - moderation items for mods only """ - #0) temporary, till urls are fixed: update context + #0) temporary, till urls are fixed: update context # to contain response counts for all sub-sections context.update(view_context.get_for_inbox(request.user)) @@ -904,7 +912,7 @@ def user_favorites(request, user, context): favorite_threads = user.user_favorite_questions.values_list('thread', flat=True) questions = models.Post.objects.filter(post_type='question', thread__in=favorite_threads)\ .select_related('thread', 'thread__last_activity_by')\ - .order_by('-score', '-thread__last_activity_at')[:const.USER_VIEW_DATA_SIZE] + .order_by('-points', '-thread__last_activity_at')[:const.USER_VIEW_DATA_SIZE] data = { 'active_tab':'users', diff --git a/askbot/views/writers.py b/askbot/views/writers.py index 4024b4b0..db7a24d2 100644 --- a/askbot/views/writers.py +++ b/askbot/views/writers.py @@ -473,7 +473,7 @@ def edit_answer(request, id): if request.POST['select_revision'] == 'true': # user has changed revistion number revision_form = forms.RevisionForm( - answer, + answer, revision, request.POST ) @@ -496,13 +496,13 @@ def edit_answer(request, id): if form.has_changed(): user = form.get_post_user(request.user) user.edit_answer( - answer = answer, - body_text = form.cleaned_data['text'], - revision_comment = form.cleaned_data['summary'], - wiki = form.cleaned_data.get('wiki', answer.wiki), - is_private = form.cleaned_data.get('is_private', False) - #todo: add wiki field to form - ) + answer=answer, + body_text=form.cleaned_data['text'], + revision_comment=form.cleaned_data['summary'], + wiki=form.cleaned_data.get('wiki', answer.wiki), + is_private=form.cleaned_data.get('post_privately', False) + #todo: add wiki field to form + ) return HttpResponseRedirect(answer.get_absolute_url()) else: revision_form = forms.RevisionForm(answer, revision) @@ -618,7 +618,8 @@ def __generate_comments_json(obj, user):#non-view generates json data for the po 'user_id': comment_owner.id, 'is_deletable': is_deletable, 'is_editable': is_editable, - 'score': comment.score, + 'points': comment.points, + 'score': comment.points, #to support js 'upvoted_by_user': getattr(comment, 'upvoted_by_user', False) } json_comments.append(comment_data) @@ -685,7 +686,8 @@ def edit_comment(request): 'user_id': comment_post.author.id, 'is_deletable': is_deletable, 'is_editable': is_editable, - 'score': comment_post.score, + 'score': comment_post.points, #to support unchanged js + 'points': comment_post.points, 'voted': comment_post.is_upvoted_by(request.user), } |