From 72c356b993771063e080c7cb47b8fc23a964dc1a Mon Sep 17 00:00:00 2001 From: Evgeny Fadeev Date: Thu, 8 Apr 2010 01:48:56 -0400 Subject: kind of merged question views, still have debugging to to --- django_authopenid/views.py | 18 ++- forum/const.py | 30 +++- forum/forms.py | 73 +++++++++- forum/middleware/view_log.py | 63 +++++++++ forum/models/question.py | 61 +++++--- forum/models/tag.py | 4 +- forum/search/__init__.py | 0 forum/search/state_manager.py | 127 +++++++++++++++++ forum/skins/default/media/style/style.css | 20 ++- forum/skins/default/templates/header.html | 2 + forum/skins/default/templates/questions.html | 126 ++++++++++++++--- forum/views/readers.py | 202 ++++++++------------------- locale/en/LC_MESSAGES/django.mo | Bin 27146 -> 27175 bytes locale/en/LC_MESSAGES/django.po | 2 +- settings.py | 1 + 15 files changed, 537 insertions(+), 192 deletions(-) create mode 100644 forum/middleware/view_log.py create mode 100644 forum/search/__init__.py create mode 100644 forum/search/state_manager.py diff --git a/django_authopenid/views.py b/django_authopenid/views.py index 3c794a0d..e2d8b67c 100755 --- a/django_authopenid/views.py +++ b/django_authopenid/views.py @@ -71,6 +71,7 @@ from forum.utils.forms import get_next_url EXTERNAL_LOGIN_APP = settings.LOAD_EXTERNAL_LOGIN_APP() +#todo: decouple from forum def login(request,user): from django.contrib.auth import login as _login from forum.models import user_logged_in #custom signal @@ -80,14 +81,27 @@ def login(request,user): #1) get old session key session_key = request.session.session_key - #2) login and get new session key + #2) get old search state + search_state = None + if 'search_state' in request.session: + search_state = request.session['search_state'] + + #3) login and get new session key _login(request,user) - #3) send signal with old session key as argument + #4) transfer search_state to new session if found + if search_state: + search_session.set_logged_in() + request.session['search_state'] = search_state + #5) send signal with old session key as argument logging.debug('logged in user %s with session key %s' % (user.username, session_key)) user_logged_in.send(user=user,session_key=session_key,sender=None) +#todo: uncouple this from forum def logout(request): from django.contrib.auth import logout as _logout#for login I've added wrapper below - called login + if 'search_state' in request.session: + request.session['search_state'].set_logged_out() + request.session.modified = True _logout(request) if settings.USE_EXTERNAL_LEGACY_LOGIN == True: EXTERNAL_LOGIN_APP.api.logout(request) diff --git a/forum/const.py b/forum/const.py index 7b78032a..6f6086f0 100755 --- a/forum/const.py +++ b/forum/const.py @@ -32,19 +32,34 @@ TYPE_REPUTATION = ( (-8, 'lose_by_upvote_canceled'), ) +#do not translate these!!! POST_SORT_METHODS = ( - _('newest'), _('oldest'),_('active'), - _('inactive'),_('hot'),_('cold'), - _('popular'),_('unpopular'),_('relevance') + ('latest', _('newest')), + ('oldest', _('oldest')), + ('active', _('active')), + ('inactive', _('inactive')), + ('hottest', _('hottest')), + ('coldest', _('coldest')), + ('mostvoted', _('most voted')), + ('leastvoted', _('least voted')), + ('relevant', _('relevance')), ) #todo: add assertion here that all sort methods are unique #because they are keys to the hash used in implementations of Q.run_advanced_search -DEFAULT_POST_SORT_METHOD = _('newest') -POST_SCOPES = (_('all'),_('unanswered'),_('favorite')) -DEFAULT_POST_SCOPE = _('all') +DEFAULT_POST_SORT_METHOD = 'latest' +POST_SCOPE_LIST = ( + ('all', _('all')), + ('unanswered', _('unanswered')), + ('favorite', _('favorite')), + ) +DEFAULT_POST_SCOPE = 'all' +DEFAULT_QUESTIONS_PAGE_SIZE = 30 +PAGE_SIZES = (10,30,50) -QUESTIONS_PAGE_SIZE = 30 +UNANSWERED_MEANING_LIST = ('NO_ANSWERS','NO_UPVOTED_ANSWERS','NO_ACCEPTED_ANSWERS') +UNANSWERED_MEANING = 'NO_ANSWERS' +assert(UNANSWERED_MEANING in UNANSWERED_MEANING_LIST) #todo: #this probably needs to be language-specific @@ -53,6 +68,7 @@ QUESTIONS_PAGE_SIZE = 30 #correct regexes - plus this must be an anchored regex #to do full string match TAG_REGEX = r'^[a-z0-9\+\.\-]+$' +TAG_SPLIT_REGEX = r'[ ,]+' MAX_TAG_LENGTH = 20 #default 20 chars MAX_TAGS_PER_POST = 5 #no more than five tags diff --git a/forum/forms.py b/forum/forms.py index dbdc4e9f..c32ce6c0 100755 --- a/forum/forms.py +++ b/forum/forms.py @@ -61,7 +61,7 @@ class TagNamesField(forms.CharField): if len(data) < 1: raise forms.ValidationError(_('tags are required')) - split_re = re.compile(r'[ ,]+') + split_re = re.compile(const.TAG_SPLIT_REGEX) tag_strings = split_re.split(data) out_tag_list = [] tag_count = len(tag_strings) @@ -79,7 +79,7 @@ class TagNamesField(forms.CharField): msg = ungettext('each tag must be shorter than %(max_chars)d character',#odd but added for completeness 'each tag must be shorter than %(max_shars)d characters', tag_length) % {'max_chars':tag_length} - raise forms.ValidationError(msg)_ + raise forms.ValidationError(msg) #todo - this needs to come from settings tagname_re = re.compile(const.TAG_REGEX, re.UNICODE) @@ -127,6 +127,75 @@ class ModerateUserForm(forms.ModelForm): model = User fields = ('is_approved',) +class AdvancedSearchForm(forms.Form): + #nothing must be required in this form + #it is used by the main questions view + scope = forms.ChoiceField(choices=const.POST_SCOPE_LIST, required=False) + sort = forms.ChoiceField(choices=const.POST_SORT_METHODS, required=False) + query = forms.CharField(max_length=256, required=False) + reset_tags = forms.BooleanField(required=False) + reset_author = forms.BooleanField(required=False) + reset_query = forms.BooleanField(required=False) + tags = forms.CharField(max_length=256,required=False) + author = forms.IntegerField(required=False) + page_size = forms.ChoiceField(choices=const.PAGE_SIZES, required=False) + page = forms.IntegerField(required=False) + + def clean_tags(self): + if 'tags' in self.cleaned_data: + tags_input = self.cleaned_data['tags'].strip() + split_re = re.compile(TAG_SPLIT_REGEX) + tag_strings = split_re.split(tags_input) + tagname_re = re.compile(const.TAG_REGEX, re.UNICODE) + out = set() + for s in tag_strings: + if tagname_re.search(s): + out.add(s) + if len(out) > 0: + self.cleaned_data['tags'] = out + else: + self.cleaned_data['tags'] = None + return self.cleaned_data['tags'] + + def clean_query(self): + if 'query' in self.cleaned_data: + q = self.cleaned_data['query'].strip() + if q == '': + q = None + self.cleaned_data['query'] = q + return self.cleaned_data['query'] + + def clean_page_size(self): + if 'page_size' in self.cleaned_data: + if self.cleaned_data['page_size'] == '': + self.cleaned_data['page_size'] = None + return self.cleaned_data['page_size'] + + def clean(self): + #todo rewrite + print self.cleaned_data + if self.cleaned_data['scope'] == '': + del self.cleaned_data['scope'] + if self.cleaned_data['tags'] is None: + del self.cleaned_data['tags'] + if self.cleaned_data['sort'] == '': + del self.cleaned_data['sort'] + if self.cleaned_data['query'] == None: + del self.cleaned_data['query'] + if self.cleaned_data['reset_tags'] == False: + del self.cleaned_data['reset_tags'] + if self.cleaned_data['reset_author'] == False: + del self.cleaned_data['reset_author'] + if self.cleaned_data['reset_query'] == False: + del self.cleaned_data['reset_query'] + if self.cleaned_data['author'] is None: + del self.cleaned_data['author'] + if self.cleaned_data['page'] is None: + del self.cleaned_data['page'] + if self.cleaned_data['page_size'] is None: + del self.cleaned_data['page_size'] + return self.cleaned_data + class NotARobotForm(forms.Form): recaptcha = ReCaptchaField() diff --git a/forum/middleware/view_log.py b/forum/middleware/view_log.py new file mode 100644 index 00000000..6f59568a --- /dev/null +++ b/forum/middleware/view_log.py @@ -0,0 +1,63 @@ +import logging +from django.conf import settings +from forum.views.readers import questions as questions_view +from forum.views.commands import vote as vote_view +from django.views.static import serve as django_serve_view + +class ViewLog(object): + """must be modified only in this middlware + however, can be read anywhere else + """ + def __init__(self): + self.views = [] + self.depth = 3 #todo maybe move this to const.py + def set_current(self, view_name): + thi + + def get_previous(self, num): + if num > self.depth - 1: + raise Exception("view log depth exceeded") + elif num < 0: + raise Exception("num must be positive"); + elif num <= len(self.views) - 1: + return self.views[num] + else: + return None + + def set_current(self, view_name): + self.views.insert(0, view_name) + if len(self.views) > self.depth: + self.views.pop() + + def __str__(self): + return str(self.views) + ' depth=%d' % self.depth + +class ViewLogMiddleware(object): + def process_view(self, request, view_func, view_args, view_kwargs): + if view_func == questions_view: + view_str = 'questions' + elif view_func in (django_serve_view, vote_view): + return + elif settings.DEBUG == True: + #todo: dependency! + from debug_toolbar.views import debug_media_view + if view_func == debug_media_view: + return + else: + view_str = view_func.__name__ + else: + view_str = view_func.__name__ + + if request.user.is_authenticated(): + user_name = request.user.username + else: + user_name = request.META['REMOTE_ADDR'] + logging.debug('user %s, view %s' % (request.user.username, view_str)) + + if 'view_log' in request.session: + view_log = request.session['view_log'] + else: + view_log = ViewLog() + + view_log.set_current(view_str) + request.session['view_log'] = view_log diff --git a/forum/models/question.py b/forum/models/question.py index 71da0396..98b50490 100755 --- a/forum/models/question.py +++ b/forum/models/question.py @@ -1,24 +1,31 @@ -from base import * +from base import * #todo maybe remove * from tag import Tag +#todo: make uniform import for consts from forum.const import CONST +from forum import const from forum.utils.html import sanitize_html from markdown2 import Markdown from django.utils.html import strip_tags import datetime +from django.conf import settings +from django.utils.datastructures import SortedDict +from forum.models.tag import MarkedTag + markdowner = Markdown(html4tags=True) from forum.utils.lists import LazyList +#todo: too bad keys are duplicated see const sort methods QUESTION_ORDER_BY_MAP = { - _('newest'): '-added_at', - _('oldest'): 'added_at', - _('active'): '-last_activity_at', - _('inactive'): 'last_activity_at', - _('hot'): '-answer_count', - _('cold'): 'answer_count' - _('popular'): '-score', - _('unpopular'): 'score', - _('relevance'):None #this is a special case + 'latest': '-added_at', + 'oldest': 'added_at', + 'active': '-last_activity_at', + 'inactive': 'last_activity_at', + 'hottest': '-answer_count', + 'coldest': 'answer_count', + 'mostvoted': '-score', + 'leastvoted': 'score', + 'relevant': None #this is a special case } class QuestionManager(models.Manager): @@ -54,7 +61,7 @@ class QuestionManager(models.Manager): def run_advanced_search( self, request_user = None, - scope_selector = scope_selector,#unanswered/all/favorite (for logged in) + scope_selector = const.DEFAULT_POST_SCOPE,#unanswered/all/favorite (for logged in) search_query = None, tag_selector = None, author_selector = None,#???question or answer author or just contributor @@ -65,16 +72,32 @@ class QuestionManager(models.Manager): a relvant filter will be silently dropped """ + qs = self.filter(deleted=False)#todo - add a possibility to see deleted questions + #return metadata meta_data = {} if tag_selector: qs = qs.filter(tags__name__in = tag_selector) + if search_query: + qs = qs.filter(deleted=False).extra( + where=['title like %s'], + params=['%' + search_query + '%'] + ) + if scope_selector: + if scope_selector == 'unanswered': + if const.UNANSWERED_MEANING == 'NO_ANSWERS': + qs = qs.filter(answer_count=0)#todo: expand for different meanings of this + elif const.UNANSWERED_MEANING == 'NO_ACCEPTED_ANSWERS': + qs = qs.filter(answer_accepted=False) + elif const.UNANSWERED_MEANING == 'NO_UPVOTED_ANSWERS': + raise NotImplementedError() + else: + raise Exception('UNANSWERED_MEANING setting is wrong') + elif scope_selector == 'favorite': + qs = qs.filter(favorited_by = request_user) - if unanswered: - qs = qs.exclude(answer_accepted=True) - #user contributed questions & answers if author_selector: try: @@ -126,9 +149,13 @@ class QuestionManager(models.Manager): meta_data['interesting_tag_names'] = pt.filter(reason='good').values_list('tag__name', flat=True) meta_data['ignored_tag_names'] = pt.filter(reason='bad').values_list('tag__name', flat=True) - #todo: fix orderby here - qs = qs.select_related(depth=1).order_by(orderby) - return qs, meta_data + qs = qs.select_related(depth=1) + #todo: fix orderby here + orderby = QUESTION_ORDER_BY_MAP[sort_method] + if orderby: + #relevance will be ignored here + qs = qs.order_by(orderby) + return qs, meta_data def update_tags(self, question, tagnames, user): """ diff --git a/forum/models/tag.py b/forum/models/tag.py index 8d26d6f4..e13baf9b 100755 --- a/forum/models/tag.py +++ b/forum/models/tag.py @@ -49,6 +49,8 @@ class TagManager(models.Manager): def get_tags_by_questions(self, questions): question_ids = [] + if len(questions) == 0: + return [] for question in questions: question_ids.append(question.id) @@ -82,4 +84,4 @@ class MarkedTag(models.Model): reason = models.CharField(max_length=16, choices=TAG_MARK_REASONS) class Meta: - app_label = 'forum' \ No newline at end of file + app_label = 'forum' diff --git a/forum/search/__init__.py b/forum/search/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/forum/search/state_manager.py b/forum/search/state_manager.py new file mode 100644 index 00000000..b36c5e53 --- /dev/null +++ b/forum/search/state_manager.py @@ -0,0 +1,127 @@ +#search state manager object +#that lives in the session and takes care of the state +#persistece during the search session +from forum import const +import logging + +class SearchState(object): + def __init__(self): + self.scope= const.DEFAULT_POST_SCOPE + self.query = None + self.tags = None + self.author = None + self.sort = const.DEFAULT_POST_SORT_METHOD + self.page_size = const.DEFAULT_QUESTIONS_PAGE_SIZE + self.page = 1 + self.logged_in = False + logging.debug('new search state initialized') + print 'new search state' + + def __str__(self): + out = 'scope=%s\n' % self.scope + out += 'query=%s\n' % self.query + if self.tags: + out += 'tags=%s\n' % ','.join(self.tags) + out += 'author=%s\n' % self.author + out += 'sort=%s\n' % self.sort + out += 'page_size=%d\n' % self.page_size + out += 'page=%d\n' % self.page + out += 'logged_in=%s\n' % str(self.logged_in) + return out + + def set_logged_out(self): + if self.scope == 'favorite': + self.scope = None + self.logged_in = False + + def set_logged_in(self): + self.logged_in = True + + def reset(self): + #re-initialize, but keep login state + is_logged_in = self.logged_in + self.__init__() + self.logged_in = is_logged_in + + def update_value(self, key, store): + if key in store: + old_value = getattr(self, key) + new_value = store[key] + if new_value != old_value: + setattr(self, key, new_value) + self.reset_page() + + def update_from_user_input(self,input): + #todo: this function will probably not + #fit the case of multiple parameters entered at the same tiem + print ','.join(input.keys()) + if 'page' in input: + print input['page'] + self.page = input['page'] + #special case - on page flip no other input is accepted + return + + if 'page_size' in input: + self.update_value('page_size',input) + self.reset_page()#todo may be smarter here - start with ~same q + #same as with page - return right away + return + + if 'scope' in input: + if input['scope'] == 'favorite' and self.logged_in == False: + self.reset_scope() + else: + self.update_value('scope',input) + + if 'tags' in input: + if self.tags: + old_tags = self.tags.copy() + self.tags.union(input['tags']) + if self.tags != old_tags: + self.reset_page() + else: + self.tags = input['tags'] + + #all resets just return + if 'reset_tags' in input: + if self.tags: + self.tags = None + self.reset_page() + return + + #todo: handle case of deleting tags one-by-one + if 'reset_author' in input: + if self.author: + self.author = None + self.reset_page() + return + + if 'reset_query' in input: + if self.query: + self.query = None + self.reset_page() + if self.sort == 'relevant': + self.reset_sort() + return + + self.update_value('author',input) + + if 'query' in input: + self.update_value('query',input) + self.sort = 'relevant' + + + if 'sort' in input: + if input['sort'] == 'relevant' and self.query is None: + self.reset_sort() + else: + self.update_value('sort',input) + + def reset_page(self): + self.page = 1 + + def reset_sort(self): + self.sort = const.DEFAULT_POST_SORT_METHOD + + def reset_scope(self): + self.scope = const.DEFAULT_POST_SCOPE diff --git a/forum/skins/default/media/style/style.css b/forum/skins/default/media/style/style.css index 93c979ce..8315839d 100755 --- a/forum/skins/default/media/style/style.css +++ b/forum/skins/default/media/style/style.css @@ -866,7 +866,16 @@ a:hover.medal { height: 20px; } -.tabsA a.on, .tabsA a:hover, .tabsB a.on, .tabsB a:hover { +.tabsC { + background-color: #FFF; + float: left; + position: relative; + display: block; + font-weight: bold; + height: 20px; +} + +.tabsA a.on, .tabsA a:hover, .tabsB a.on, .tabsB a:hover , .tabsC a.on, tabsC a:hover { background: #fff; color: #a40000; border-top: 1px solid #babdb6; @@ -879,7 +888,7 @@ a:hover.medal { padding: 0px 11px 0px 11px; } -.tabsA a { +.tabsA a, .tabsC a{ background: #f9f7eb; border-top: 1px solid #eeeeec; border-left: 1px solid #eeeeec; @@ -895,6 +904,12 @@ a:hover.medal { text-decoration: none; } +.tabsA .label, .tabsC .label { + float: left; + font-weight: bold; + margin: 8px 4px 0 4px; +} + .tabsB a { background: #eee; border: 1px solid #eee; @@ -920,7 +935,6 @@ a:hover.medal { } .headQuestions { - float: left; height: 23px; line-height: 23px; margin: 5px 0 0 5px; diff --git a/forum/skins/default/templates/header.html b/forum/skins/default/templates/header.html index 099bfb85..aaf19874 100644 --- a/forum/skins/default/templates/header.html +++ b/forum/skins/default/templates/header.html @@ -31,7 +31,9 @@ {% trans "books" %} {% endif %} {% trans "badges" %} + {% comment %} {% trans "unanswered questions" %} + {% endcomment %}
{% trans "ask a question" %}
diff --git a/forum/skins/default/templates/questions.html b/forum/skins/default/templates/questions.html index 366727d1..0a7c0c96 100644 --- a/forum/skins/default/templates/questions.html +++ b/forum/skins/default/templates/questions.html @@ -10,9 +10,11 @@