diff options
author | Evgeny Fadeev <evgeny.fadeev@gmail.com> | 2012-06-11 03:20:16 -0400 |
---|---|---|
committer | Evgeny Fadeev <evgeny.fadeev@gmail.com> | 2012-06-11 03:20:16 -0400 |
commit | e50335853baf3238d600b0a04a5261510231474c (patch) | |
tree | 70abbf0ba988ca37ad32ac8546ec54add5c45e32 | |
parent | b13c5a97290c04601b8c069413fef584da018928 (diff) | |
download | askbot-e50335853baf3238d600b0a04a5261510231474c.tar.gz askbot-e50335853baf3238d600b0a04a5261510231474c.tar.bz2 askbot-e50335853baf3238d600b0a04a5261510231474c.zip |
category editor works minus that ability to rename and delete categories is unfinished
-rw-r--r-- | askbot/forms.py | 86 | ||||
-rw-r--r-- | askbot/migrations/0125_save_category_tree_as_json.py (renamed from askbot/migrations/0125_add_email_signature__to__auth_user.py) | 81 | ||||
-rw-r--r-- | askbot/migrations/0126_update_postgres_post_search.py | 29 | ||||
-rw-r--r-- | askbot/skins/common/media/js/post.js | 547 | ||||
-rw-r--r-- | askbot/skins/common/media/js/utils.js | 110 | ||||
-rw-r--r-- | askbot/skins/default/media/style/style.less | 11 | ||||
-rw-r--r-- | askbot/skins/default/templates/meta/category_tree_js.html | 5 | ||||
-rw-r--r-- | askbot/skins/default/templates/meta/html_head_javascript.html | 3 | ||||
-rw-r--r-- | askbot/skins/default/templates/widgets/three_column_category_selector.html | 11 | ||||
-rw-r--r-- | askbot/tests/__init__.py | 1 | ||||
-rw-r--r-- | askbot/tests/category_tree_tests.py | 116 | ||||
-rw-r--r-- | askbot/urls.py | 5 | ||||
-rw-r--r-- | askbot/utils/category_tree.py | 98 | ||||
-rw-r--r-- | askbot/views/commands.py | 39 | ||||
-rw-r--r-- | askbot/views/readers.py | 3 | ||||
-rw-r--r-- | askbot/views/writers.py | 5 |
16 files changed, 951 insertions, 199 deletions
diff --git a/askbot/forms.py b/askbot/forms.py index 23da169a..5e4b5a06 100644 --- a/askbot/forms.py +++ b/askbot/forms.py @@ -237,14 +237,48 @@ class AnswerEditorField(EditorField): self.length_error_template_plural = 'answer must be > %d characters' self.min_length = askbot_settings.MIN_ANSWER_BODY_LENGTH +def clean_tag(tag_name): + """a function that cleans a single tag name""" + tag_length = len(tag_name) + if tag_length > askbot_settings.MAX_TAG_LENGTH: + #singular form is odd in english, but required for pluralization + #in other languages + msg = ungettext_lazy( + #odd but added for completeness + 'each tag must be shorter than %(max_chars)d character', + 'each tag must be shorter than %(max_chars)d characters', + tag_length + ) % {'max_chars':tag_length} + raise forms.ValidationError(msg) + + #todo - this needs to come from settings + tagname_re = re.compile(const.TAG_REGEX, re.UNICODE) + if not tagname_re.search(tag_name): + raise forms.ValidationError( + _(message_keys.TAG_WRONG_CHARS_MESSAGE) + ) + + if askbot_settings.FORCE_LOWERCASE_TAGS: + #a simpler way to handle tags - just lowercase thew all + return tag_name.lower() + else: + try: + from askbot import models + stored_tag = models.Tag.objects.get(name__iexact = tag_name) + return stored_tag.name + except models.Tag.DoesNotExist: + return tag_name + + class TagNamesField(forms.CharField): def __init__(self, *args, **kwargs): super(TagNamesField, self).__init__(*args, **kwargs) self.required = askbot_settings.TAGS_ARE_REQUIRED - self.widget = forms.TextInput(attrs={'size' : 50, 'autocomplete' : 'off'}) + self.widget = forms.TextInput( + attrs={'size' : 50, 'autocomplete' : 'off'} + ) self.max_length = 255 self.label = _('tags') - #self.help_text = _('please use space to separate tags (this enables autocomplete feature)') self.help_text = ungettext_lazy( 'Tags are short keywords, with no spaces within. ' 'Up to %(max_tags)d tag can be used.', @@ -308,51 +342,11 @@ class TagNamesField(forms.CharField): ) % {'tags': get_text_list(models.tag.get_mandatory_tags())} raise forms.ValidationError(msg) - for tag in tag_strings: - tag_length = len(tag) - if tag_length > askbot_settings.MAX_TAG_LENGTH: - #singular form is odd in english, but required for pluralization - #in other languages - msg = ungettext_lazy( - #odd but added for completeness - 'each tag must be shorter than %(max_chars)d character', - 'each tag must be shorter than %(max_chars)d characters', - tag_length - ) % {'max_chars':tag_length} - raise forms.ValidationError(msg) - - #todo - this needs to come from settings - tagname_re = re.compile(const.TAG_REGEX, re.UNICODE) - if not tagname_re.search(tag): - raise forms.ValidationError( - _(message_keys.TAG_WRONG_CHARS_MESSAGE) - ) - #only keep unique tags - if tag not in entered_tags: - entered_tags.append(tag) - - #normalize character case of tags cleaned_entered_tags = list() - if askbot_settings.FORCE_LOWERCASE_TAGS: - #a simpler way to handle tags - just lowercase thew all - for name in entered_tags: - lowercased_name = name.lower() - if lowercased_name not in cleaned_entered_tags: - cleaned_entered_tags.append(lowercased_name) - else: - #make names of tags in the input to agree with the database - for entered_tag in entered_tags: - try: - #looks like we have to load tags one-by one - #because we need tag name cases to be the same - #as those stored in the database - stored_tag = models.Tag.objects.get( - name__iexact = entered_tag - ) - if stored_tag.name not in cleaned_entered_tags: - cleaned_entered_tags.append(stored_tag.name) - except models.Tag.DoesNotExist: - cleaned_entered_tags.append(entered_tag) + for tag in tag_strings: + cleaned_tag = clean_tag(tag) + if cleaned_tag not in cleaned_entered_tags: + cleaned_entered_tags.append(clean_tag(tag)) return u' '.join(cleaned_entered_tags) diff --git a/askbot/migrations/0125_add_email_signature__to__auth_user.py b/askbot/migrations/0125_save_category_tree_as_json.py index be3b9750..b13cd2fe 100644 --- a/askbot/migrations/0125_add_email_signature__to__auth_user.py +++ b/askbot/migrations/0125_save_category_tree_as_json.py @@ -1,21 +1,84 @@ # -*- coding: utf-8 -*- import datetime from south.db import db -from south.v2 import SchemaMigration +from south.v2 import DataMigration +from django.utils import simplejson from django.db import models +from askbot.conf import settings as askbot_settings +def get_subtree(tree, path): + """#this might be simpler, but not tested + clevel = tree + for step in path: + try: + level = clevel[step] + except IndexError: + return False + return clevel + """ + if len(path) == 1: + assert(path[0] == 0) + return tree + else: + import copy + parent_path = copy.copy(path) + leaf_index = parent_path.pop() + branch_index = parent_path[-1] + parent_tree = get_subtree(tree, parent_path) + return parent_tree[branch_index][1] -class Migration(SchemaMigration): +def parse_tree(text): + """parse tree represented as indented text + one item per line, with two spaces per level of indentation + """ + lines = text.split('\n') + import re + in_re = re.compile(r'^([ ]*)') - def forwards(self, orm): + tree = [['dummy', []]] + subtree_path = [0] + clevel = 0 + + for line in lines: + if line.strip() == '': + continue + match = in_re.match(line) + level = len(match.group(1))/2 + 1 + + if level > clevel: + subtree_path.append(0)# + elif level < clevel: + subtree_path = subtree_path[:level+1] + leaf_index = subtree_path.pop() + subtree_path.append(leaf_index + 1) + else: + leaf_index = subtree_path.pop() + subtree_path.append(leaf_index + 1) + + clevel = level try: - # Adding field 'User.interesting_tags' - db.add_column(u'auth_user', 'email_signature', self.gf('django.db.models.fields.TextField')(blank=True, default = ''), keep_default=False) + subtree = get_subtree(tree, subtree_path) except: - pass + return tree + subtree.append([line.strip(), []]) + + return tree + + +class Migration(DataMigration): + + def forwards(self, orm): + """reads category tree saved as string, + translates it to json and saves back""" + old_data = askbot_settings.CATEGORY_TREE + json_data = parse_tree(old_data) + json_string = simplejson.dumps(json_data) + askbot_settings.update('CATEGORY_TREE', json_string) def backwards(self, orm): - db.delete_column('auth_user', 'email_signature') + "Write your backwards methods here." + pass + models = { 'askbot.activity': { @@ -193,7 +256,7 @@ class Migration(SchemaMigration): 'address': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '25'}), 'allowed_from_email': ('django.db.models.fields.EmailField', [], {'max_length': '150'}), 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'post': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'reply_addresses'", 'to': "orm['askbot.Post']"}), + 'post': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'reply_addresses'", 'null': 'True', 'to': "orm['askbot.Post']"}), 'reply_action': ('django.db.models.fields.CharField', [], {'default': "'auto_answer_or_comment'", 'max_length': '32'}), 'response_post': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'edit_addresses'", 'null': 'True', 'to': "orm['askbot.Post']"}), 'used_at': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), @@ -279,6 +342,7 @@ class Migration(SchemaMigration): 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 'email_isvalid': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'email_key': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True'}), + 'email_signature': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 'email_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '1'}), 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 'gold': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}), @@ -318,3 +382,4 @@ class Migration(SchemaMigration): } complete_apps = ['askbot'] + symmetrical = True diff --git a/askbot/migrations/0126_update_postgres_post_search.py b/askbot/migrations/0126_update_postgres_post_search.py deleted file mode 100644 index 156902fe..00000000 --- a/askbot/migrations/0126_update_postgres_post_search.py +++ /dev/null @@ -1,29 +0,0 @@ -# encoding: utf-8 -import askbot -from askbot.search import postgresql -import os -from south.v2 import DataMigration - -class Migration(DataMigration): - """this migration is the same as 22 and 106 - just ran again to update the postgres search setup - """ - - def forwards(self, orm): - "Write your forwards methods here." - - db_engine_name = askbot.get_database_engine_name() - if 'postgresql_psycopg2' in db_engine_name: - script_path = os.path.join( - askbot.get_install_directory(), - 'search', - 'postgresql', - 'thread_and_post_models_05222012.plsql' - ) - postgresql.setup_full_text_search(script_path) - - def backwards(self, orm): - "Write your backwards methods here." - pass - - models = {}#we don't need orm for this migration diff --git a/askbot/skins/common/media/js/post.js b/askbot/skins/common/media/js/post.js index 40773009..aa9795e6 100644 --- a/askbot/skins/common/media/js/post.js +++ b/askbot/skins/common/media/js/post.js @@ -83,6 +83,35 @@ function setupFormValidation(form, validationRules, validationMessages, onSubmit }); } +/** + * generic tag cleaning function, settings + * are from askbot live settings and askbot.const + */ +var cleanTag = function(tag_name, settings) { + var tag_regex = new RegExp(settings['tag_regex']); + if (tag_regex.test(tag_name) === false) { + throw settings['messages']['wrong_chars'] + } + + var max_length = settings['max_tag_length']; + if (tag_name.length > max_length) { + throw interpolate( + ngettext( + 'must be shorter than %(max_chars)s character', + 'must be shorter than %(max_chars)s characters', + max_length + ), + {'max_chars': max_length }, + true + ); + } + if (settings['force_lowercase_tags']) { + return tag_name.toLowerCase(); + } else { + return tag_name; + } +}; + var validateTagLength = function(value){ var tags = getUniqueWords(value); var are_tags_ok = true; @@ -2451,11 +2480,6 @@ TagEditor.prototype.cleanTag = function(tag_name, reject_dupe) { ); } - var tag_regex = new RegExp(this._settings['tag_regex']); - if (tag_regex.test(tag_name) === false) { - throw this._settings['messages']['wrong_chars'] - } - var max_tags = this._settings['max_tags_per_post']; if (this.getSelectedTags().length + 1 > max_tags) {//count current throw interpolate( @@ -2468,23 +2492,8 @@ TagEditor.prototype.cleanTag = function(tag_name, reject_dupe) { ); } - var max_length = this._settings['max_tag_length']; - if (tag_name.length > max_length) { - throw interpolate( - ngettext( - 'must be shorter than %(max_chars)s character', - 'must be shorter than %(max_chars)s characters', - max_length - ), - {'max_chars': max_length }, - true - ); - } - if (this._settings['force_lowercase_tags']) { - return tag_name.toLowerCase(); - } else { - return tag_name; - } + //generic cleaning + return cleanTag(tag_name, this._settings); }; TagEditor.prototype.addTag = function(tag_name) { @@ -2715,14 +2724,295 @@ TagEditor.prototype.decorate = function(element) { }); }; +/** + * @constructor + * Category is a select box item + * that has CategoryEditControls + */ var Category = function() { SelectBoxItem.call(this); - tihs._is_editable = false; + this._state = 'display'; }; inherits(Category, SelectBoxItem); +Category.prototype.getName = function() { + return this.getContent().getContent(); +}; + +Category.prototype.setState = function(state) { + this._state = state; + if ( !this._element ) { + return; + } + this._input_box.val(''); + if (state === 'display') { + this.showContent(); + this.hideEditor(); + this.hideEditControls(); + } else if (state === 'editable') { + this.showContent(); + this.hideEditor(); + this.showEditControls(); + } else if (state === 'edit') { + this._input_box.val(this.getName()); + this.hideContent(); + this.showEditor(); + this.hideEditControls(); + } +}; + +Category.prototype.hideEditControls = function() { + this._delete_button.hide(); + this._edit_button.hide(); + this._element.unbind('mouseenter mouseleave'); +}; + +Category.prototype.showEditControls = function() { + var del = this._delete_button; + var edit = this._edit_button; + this._element.hover( + function(){ + del.show(); + edit.show(); + }, + function(){ + del.hide(); + edit.hide(); + } + ); +}; + +Category.prototype.hideContent = function() { + this.getContent().getElement().hide(); +}; + +Category.prototype.showContent = function() { + this.getContent().getElement().show(); +}; + +Category.prototype.showEditor = function() { + this._input_box.show(); + this._save_button.show(); + this._cancel_button.show(); +}; + +Category.prototype.hideEditor = function() { + this._input_box.hide(); + this._save_button.hide(); + this._cancel_button.hide(); +}; + +Category.prototype.getDeleteHandler = function() { + return function(){ return false; } +}; + +Category.prototype.getSaveHandler = function() { + var me = this; + //here we need old value and new value + return function(){ + return false; + }; +}; + +Category.prototype.addControls = function() { + var input_box = this.makeElement('input'); + this._input_box = input_box; + this._element.append(input_box); + + var save_button = this.makeButton( + gettext('save'), + this.getSaveHandler() + ); + this._save_button = save_button; + this._element.append(save_button); + + var me = this; + var cancel_button = this.makeButton( + 'x', + function(){ + me.setState('editable'); + return false; + } + ); + this._cancel_button = cancel_button; + this._element.append(cancel_button); + + var edit_button = this.makeButton( + gettext('edit'), + function(){ + me.setState('edit'); + return false; + } + ); + this._edit_button = edit_button; + this._element.append(edit_button); + + var delete_button = this.makeButton( + 'x', this.getDeleteHandler() + ); + this._delete_button = delete_button; + this._element.append(delete_button); +}; + +Category.prototype.createDom = function() { + Category.superClass_.createDom.call(this); + this.addControls(); + this.setState('display'); +}; + Category.prototype.decorate = function(element) { - this.superClass_.decorate.call(this, element); + Category.superClass_.decorate.call(this, element); + this.addControls(); + this.setState('display'); +}; + +var CategoryAdder = function() { + WrappedElement.call(this); + this._state = 'disabled';//waitedit + this._tree = undefined;//category tree + this._settings = JSON.parse(askbot['settings']['tag_editor']); +}; +inherits(CategoryAdder, WrappedElement); + +CategoryAdder.prototype.setCategoryTreeObject = function(tree) { + this._tree = tree; +}; + +CategoryAdder.prototype.setLevel = function(level) { + this._level = level; +}; + +CategoryAdder.prototype.setState = function(state) { + this._state = state; + if (!this._element) { + return; + } + if (state === 'wait') { + this._element.show(); + this._input.val(''); + this._input.hide(); + this._save_button.hide(); + this._cancel_button.hide(); + this._trigger.show(); + } else if (state === 'edit') { + this._element.show(); + this._input.show(); + this._input.val(''); + this._input.focus(); + this._save_button.show(); + this._cancel_button.show(); + this._trigger.hide(); + } else if (state === 'disabled') { + this.setState('wait');//a little hack + this._state = 'disabled'; + this._element.hide(); + } +}; + +CategoryAdder.prototype.cleanCategoryName = function(name) { + name = $.trim(name); + if (name === '') { + throw gettext('category name cannot be empty'); + } + if ( this._tree.hasCategory(name) ) { + //throw interpolate( + throw gettext('this category already exists'); + // [this._tree.getDisplayPathByName(name)] + //) + } + return cleanTag(name, this._settings); +}; + +CategoryAdder.prototype.getPath = function() { + var path = this._tree.getCurrentPath(); + if (path.length > this._level + 1) { + return path.slice(0, this._level + 1); + } else { + return path; + } +}; + +CategoryAdder.prototype.startAdding = function() { + try { + var name = this._input.val(); + name = this.cleanCategoryName(name); + } catch (error) { + alert(error); + return; + } + var me = this; + var tree = this._tree; + var current_path = tree.getCurrentPath(); + var adder_path = this.getPath(); + + $.ajax({ + type: 'POST', + dataType: 'json', + data: JSON.stringify({ + path: adder_path, + new_category_name: name + }), + url: askbot['urls']['add_tag_category'], + cache: false, + success: function(data) { + if (data['success']) { + //rebuild category tree based on data + tree.setData(data['tree_data']); + //re-open current branch + tree.openPath(current_path); + me.setState('wait'); + } else { + alert(data['message']); + } + } + }); +}; + +CategoryAdder.prototype.createDom = function() { + this._element = this.makeElement('li'); + //add open adder link + var trigger = this.makeElement('a'); + this._trigger = trigger; + trigger.html(gettext('add category')); + this._element.append(trigger); + //add input box and the add button + var input = this.makeElement('input'); + this._input = input; + input.addClass('add-category'); + this._element.append(input); + //add save category button + var save_button = this.makeElement('button'); + this._save_button = save_button; + save_button.html(gettext('save')); + this._element.append(save_button); + + var cancel_button = this.makeElement('button'); + this._cancel_button = cancel_button; + cancel_button.html('x'); + this._element.append(cancel_button); + + this.setState(this._state); + + var me = this; + setupButtonEventHandlers( + trigger, + function(){ me.setState('edit'); } + ) + setupButtonEventHandlers( + save_button, + function() { + me.startAdding(); + return false;//prevent form submit + } + ); + setupButtonEventHandlers( + cancel_button, + function() { + me.setState('wait'); + return false;//prevent submit + } + ); + //create input box, button and the "activate" link }; /** @@ -2733,45 +3023,166 @@ Category.prototype.decorate = function(element) { var CategorySelectBox = function() { SelectBox.call(this); this._item_class = Category; + this._category_adder = undefined; + this._tree = undefined;//cat tree + this._level = undefined; }; inherits(CategorySelectBox, SelectBox); +CategorySelectBox.prototype.setState = function(state) { + this._state = state; + if (state === 'select') { + this._category_adder.setState('disabled'); + $.each(this._items, function(idx, item){ + item.setState('display'); + }); + } else if (state === 'edit') { + this._category_adder.setState('wait'); + $.each(this._items, function(idx, item){ + item.setState('editable'); + }); + } +}; + +CategorySelectBox.prototype.setCategoryTreeObject = function(tree) { + this._tree = tree; +}; + +CategorySelectBox.prototype.setLevel = function(level) { + this._level = level; +}; + +CategorySelectBox.prototype.appendCategoryAdder = function() { + var adder = new CategoryAdder(); + adder.setLevel(this._level); + adder.setCategoryTreeObject(this._tree); + this._category_adder = adder; + this._element.append(adder.getElement()); +}; + +CategorySelectBox.prototype.createDom = function() { + CategorySelectBox.superClass_.createDom(); + if (askbot['data']['userIsAdmin']) { + this.appendCategoryAdder(); + } +}; + +CategorySelectBox.prototype.decorate = function(element) { + CategorySelectBox.superClass_.decorate.call(this, element); + this.setState(this._state); + if (askbot['data']['userIsAdmin']) { + this.appendCategoryAdder(); + } +}; + +/** + * @constructor + * turns on/off the category editor + */ +var CategoryEditorToggle = function() { + TwoStateToggle.call(this); +}; +inherits(CategoryEditorToggle, TwoStateToggle); + +CategoryEditorToggle.prototype.setCategorySelector = function(sel) { + this._category_selector = sel; +}; + +CategoryEditorToggle.prototype.getCategorySelector = function() { + return this._category_selector; +}; + +CategoryEditorToggle.prototype.decorate = function(element) { + CategoryEditorToggle.superClass_.decorate.call(this, element); +}; + +CategoryEditorToggle.prototype.getDefaultHandler = function() { + var me = this; + return function() { + var editor = me.getCategorySelector(); + if (me.isOn()) { + me.setState('off-state'); + editor.setState('select'); + } else { + me.setState('on-state'); + editor.setState('edit'); + } + }; +}; + var CategorySelector = function() { - WrappedElement.call(this); + Widget.call(this); this._data = null; - this._is_editable = false; this._select_handler = function(){};//dummy default + this._current_path = [0];//path points to selected item in tree +}; +inherits(CategorySelector, Widget); + +/** + * propagates state to the individual selectors + */ +CategorySelector.prototype.setState = function(state) { + this._state = state; + $.each(this._selectors, function(idx, selector){ + selector.setState(state); + }); }; -inherits(CategorySelector, WrappedElement); CategorySelector.prototype.setData = function(data) { this._data = data; }; -CategorySelector.prototype.setEditable = function(is_editable) { - this._is_editable = is_editable; +/** + * clears contents of selector boxes starting from + * the given level, in range 0..2 + */ +CategorySelector.prototype.clearCategoryLevels = function(level) { + for (var i = level; i < 3; i++) { + this._selectors[i].removeAllItems(); + } }; -CategorySelector.prototype.populateCategoryLevel = function(source_path) { - var level = source_path.length - 1; - if (level > 3) { - return; +CategorySelector.prototype.hasCategory = function(category) { + function inData(category, data) { + for (var i = 0; i < data.length; i++) { + //check name of the current node + if (data[i][0] === category) { + return true; + } + //check in the subtree + if (inData(category, data[i][1])) { + return true; + } + } + return false; } + return inData(category, this._data[0][1]); +}; + +CategorySelector.prototype.getLeafItems = function(selection_path) { //traverse the tree down to items pointed to by the path var data = this._data[0]; - for (var i = 1; i < source_path.length; i++) { - data = data[1][source_path[i]]; + for (var i = 1; i < selection_path.length; i++) { + data = data[1][selection_path[i]]; } + return data[1]; +} - var items = data[1]; - - //clear current and above selectors - for (var i = level; i < 3; i++) { - this._selectors[i].removeAllItems(); +/** + * called when a sub-level needs to open + */ +CategorySelector.prototype.populateCategoryLevel = function(source_path) { + var level = source_path.length - 1; + if (level > 3) { + return; } + //clear all items downstream + this.clearCategoryLevels(level); //populate current selector var selector = this._selectors[level]; + var items = this.getLeafItems(source_path); + $.each(items, function(idx, item) { var tag_name = item[0]; selector.addItem(idx, tag_name, '');//first and last are dummy values @@ -2780,11 +3191,15 @@ CategorySelector.prototype.populateCategoryLevel = function(source_path) { } }); + this.setState(this._state);//update state + selector.clearSelection(); }; -CategorySelector.prototype.maybeAddEditButton = function(selector) { - return; +CategorySelector.prototype.openPath = function(path) { + for (var i = 1; i <= path.length; i++) { + this.populateCategoryLevel(path.slice(0, i)); + } }; CategorySelector.prototype.getSelectedPath = function(selected_level) { @@ -2813,15 +3228,33 @@ CategorySelector.prototype.getSelectHandlerInternal = function() { return this._select_handler; }; +CategorySelector.prototype.setCurrentPath = function(path) { + return this._current_path = path; +}; + +CategorySelector.prototype.getCurrentPath = function() { + return this._current_path; +}; + +CategorySelector.prototype.getEditorToggle = function() { + return this._editor_toggle; +}; + CategorySelector.prototype.getSelectHandler = function(level) { var me = this; return function(item_data) { //1) run the assigned select handler var tag_name = item_data['title'] - me.getSelectHandlerInternal()(tag_name); + if (me.getState() === 'select') { + /* this one will actually select the tag + * maybe a bit too implicit + */ + me.getSelectHandlerInternal()(tag_name); + } //2) if appropriate, populate the higher level if (level < 2) { var current_path = me.getSelectedPath(level); + me.setCurrentPath(current_path); me.populateCategoryLevel(current_path); } } @@ -2832,23 +3265,35 @@ CategorySelector.prototype.decorate = function(element) { this._selectors = []; var selector0 = new CategorySelectBox(); + selector0.setLevel(0); + selector0.setCategoryTreeObject(this); selector0.decorate(element.find('.cat-col-0')); selector0.setSelectHandler(this.getSelectHandler(0)); - this.maybeAddEditButton(selector0); this._selectors.push(selector0); var selector1 = new CategorySelectBox(); + selector1.setLevel(1); + selector1.setCategoryTreeObject(this); selector1.decorate(element.find('.cat-col-1')); selector1.setSelectHandler(this.getSelectHandler(1)); - this.maybeAddEditButton(selector1); this._selectors.push(selector1) var selector2 = new CategorySelectBox(); + selector2.setLevel(2); + selector2.setCategoryTreeObject(this); selector2.decorate(element.find('.cat-col-2')); selector2.setSelectHandler(this.getSelectHandler(2)); - this.maybeAddEditButton(selector2); this._selectors.push(selector2); + if (askbot['data']['userIsAdminOrMod']) { + var editor_toggle = new CategoryEditorToggle(); + editor_toggle.setCategorySelector(this); + var toggle_element = $('.category-editor-toggle'); + toggle_element.show(); + editor_toggle.decorate(toggle_element); + this._editor_toggle = editor_toggle; + } + this.populateCategoryLevel([0]); }; @@ -2864,6 +3309,10 @@ var CategorySelectorLoader = function() { }; inherits(CategorySelectorLoader, WrappedElement); +CategorySelectorLoader.prototype.setCategorySelector = function(sel) { + this._category_selector = sel; +}; + CategorySelectorLoader.prototype.setLoaded = function(is_loaded) { this._is_loaded = is_loaded; }; @@ -2890,6 +3339,9 @@ CategorySelectorLoader.prototype.openEditor = function() { this._display_tags_container.hide(); this._question_body.hide(); this._question_controls.hide(); + var sel = this._category_selector; + sel.setState('select'); + sel.getEditorToggle().setState('off-state'); }; CategorySelectorLoader.prototype.addEditorButtons = function() { @@ -2906,7 +3358,8 @@ CategorySelectorLoader.prototype.getOnLoadHandler = function() { me.setEditor(editor); $('#question-tags').after(editor); - askbot['functions']['initCategoryTree'](); + var selector = askbot['functions']['initCategoryTree'](); + me.setCategorySelector(selector); me.addEditorButtons(); me.openEditor(); diff --git a/askbot/skins/common/media/js/utils.js b/askbot/skins/common/media/js/utils.js index b4d08b2c..63f0027f 100644 --- a/askbot/skins/common/media/js/utils.js +++ b/askbot/skins/common/media/js/utils.js @@ -267,6 +267,31 @@ WrappedElement.prototype.dispose = function(){ }; /** + * @constructor + * Widget is a Wrapped element with state + */ +var Widget = function() { + WrappedElement.call(this); + this._state = undefined; +}; +inherits(Widget, WrappedElement); + +Widget.prototype.setState = function(state) { + this._state = state; +}; + +Widget.prototype.getState = function() { + return this._state; +}; + +Widget.prototype.makeButton = function(label, handler) { + var button = this.makeElement('button'); + button.html(label); + setupButtonEventHandlers(button, handler); + return button; +}; + +/** * Can be used for an input box or textarea. * The original value will be treated as an instruction. * When user focuses on the field, the tip will be gone, @@ -412,6 +437,34 @@ AlertBox.prototype.createDom = function(){ this._element.alert();//bootstrap.js alert }; +/** + * @constructor + * just a span with content + * useful for subclassing + */ +var SimpleContent = function(){ + WrappedElement.call(this); + this._content = ''; +}; +inherits(SimpleContent, WrappedElement); + +SimpleContent.prototype.setContent = function(text) { + //todo: for text we should use .html() for text nodes .append() + this._content = text; + if (this._element) { + this._element.append(text); + } +}; + +SimpleContent.prototype.getContent = function() { + return this._content; +}; + +SimpleContent.prototype.createDom = function() { + this._element = this.makeElement('span'); + this._element.html(this._content); +}; + var SimpleControl = function(){ WrappedElement.call(this); this._handler = null; @@ -808,7 +861,7 @@ TwoStateToggle.prototype.decorate = function(element){ } else { me.setState('on-prompt'); } - element.css('background-color', 'red'); + //element.css('background-color', 'red'); return false; }); element.mouseout(function(){ @@ -818,24 +871,42 @@ TwoStateToggle.prototype.decorate = function(element){ } else { me.setState('off-state'); } - element.css('background-color', 'white'); + //element.css('background-color', 'white'); return false; }); setupButtonEventHandlers(element, this.getHandler()); }; +var BoxItemContent = function() { + SimpleContent.call(this); +}; +inherits(BoxItemContent, SimpleContent); + +/** + * @override to allow for more complex content + */ +BoxItemContent.prototype.setName = function(name) { + BoxItemContent.superClass_.setContent.call(this, name); +}; +BoxItemContent.prototype.getName = function() { + return BoxItemContent.superClass_.getContent.call(this); +}; + /** * @constructor * an item used for the select box described below */ var SelectBoxItem = function() { - WrappedElement.call(this); + Widget.call(this); this._id = null; this._name = null; this._description = null; + this._content_class = BoxItemContent;//default expects a single text node + //content element - instance of this._content_class + this._content = undefined; }; -inherits(SelectBoxItem, WrappedElement); +inherits(SelectBoxItem, Widget); SelectBoxItem.prototype.setId = function(id) { this._id = id; @@ -846,8 +917,8 @@ SelectBoxItem.prototype.setId = function(id) { SelectBoxItem.prototype.setName = function(name) { this._name = name; - if (this._element) { - this._element.html(name); + if (this._content) { + this._content.setName(name); } }; @@ -872,6 +943,10 @@ SelectBoxItem.prototype.getData = function () { }; }; +SelectBoxItem.prototype.getContent = function() { + return this._content; +}; + SelectBoxItem.prototype.isSelected = function() { return this._element.hasClass('selected'); }; @@ -887,29 +962,42 @@ SelectBoxItem.prototype.setSelected = function(is_selected) { SelectBoxItem.prototype.createDom = function() { var elem = this.makeElement('li'); this._element = elem; - elem.html(this._name); elem.data('itemId', this._id); elem.data('itemOriginalTitle', this._description); + var content = new this._content_class(); + content.setName(this._name); + elem.append(content.getElement()); + this._content = content; } SelectBoxItem.prototype.decorate = function(element) { this._element = element; + //set id and description this._id = element.data('itemId'); - this._name = element.html(); this._description = element.data('originalTitle'); + + //work on setting name + var content_source = element.contents().detach(); + var content = new this._content_class(); + //assume that we want first node only + content.setContent(content_source[0]); + this._content = content; + this._name = content.getName();//allows to abstract from structure + + this._element.append(content.getElement()); }; /** * A list of items from where one can be selected */ var SelectBox = function(){ - WrappedElement.call(this); + Widget.call(this); this._items = []; this._select_handler = function(){};//empty default this._is_editable = false; this._item_class = SelectBoxItem; }; -inherits(SelectBox, WrappedElement); +inherits(SelectBox, Widget); SelectBox.prototype.setEditable = function(is_editable) { this._is_editable = is_editable; @@ -1209,7 +1297,7 @@ Tag.prototype.createDom = function(){ //Search Engine Keyword Highlight with Javascript //http://scott.yang.id.au/code/se-hilite/ Hilite={elementid:"content",exact:true,max_nodes:1000,onload:true,style_name:"hilite",style_name_suffix:true,debug_referrer:""};Hilite.search_engines=[["local","q"],["cnprog\\.","q"],["google\\.","q"],["search\\.yahoo\\.","p"],["search\\.msn\\.","q"],["search\\.live\\.","query"],["search\\.aol\\.","userQuery"],["ask\\.com","q"],["altavista\\.","q"],["feedster\\.","q"],["search\\.lycos\\.","q"],["alltheweb\\.","q"],["technorati\\.com/search/([^\\?/]+)",1],["dogpile\\.com/info\\.dogpl/search/web/([^\\?/]+)",1,true]];Hilite.decodeReferrer=function(d){var g=null;var e=new RegExp("");for(var c=0;c<Hilite.search_engines.length;c++){var f=Hilite.search_engines[c];e.compile("^http://(www\\.)?"+f[0],"i");var b=d.match(e);if(b){var a;if(isNaN(f[1])){a=Hilite.decodeReferrerQS(d,f[1])}else{a=b[f[1]+1]}if(a){a=decodeURIComponent(a);if(f.length>2&&f[2]){a=decodeURIComponent(a)}a=a.replace(/\'|"/g,"");a=a.split(/[\s,\+\.]+/);return a}break}}return null};Hilite.decodeReferrerQS=function(f,d){var b=f.indexOf("?");var c;if(b>=0){var a=new String(f.substring(b+1));b=0;c=0;while((b>=0)&&((c=a.indexOf("=",b))>=0)){var e,g;e=a.substring(b,c);b=a.indexOf("&",c)+1;if(e==d){if(b<=0){return a.substring(c+1)}else{return a.substring(c+1,b-1)}}else{if(b<=0){return null}}}}return null};Hilite.hiliteElement=function(f,e){if(!e||f.childNodes.length==0){return}var c=new Array();for(var b=0;b<e.length;b++){e[b]=e[b].toLowerCase();if(Hilite.exact){c.push("\\b"+e[b]+"\\b")}else{c.push(e[b])}}c=new RegExp(c.join("|"),"i");var a={};for(var b=0;b<e.length;b++){if(Hilite.style_name_suffix){a[e[b]]=Hilite.style_name+(b+1)}else{a[e[b]]=Hilite.style_name}}var d=function(m){var j=c.exec(m.data);if(j){var n=j[0];var i="";var h=m.splitText(j.index);var g=h.splitText(n.length);var l=m.ownerDocument.createElement("SPAN");m.parentNode.replaceChild(l,h);l.className=a[n.toLowerCase()];l.appendChild(h);return l}else{return m}};Hilite.walkElements(f.childNodes[0],1,d)};Hilite.hilite=function(){var a=Hilite.debug_referrer?Hilite.debug_referrer:document.referrer;var b=null;a=Hilite.decodeReferrer(a);if(a&&((Hilite.elementid&&(b=document.getElementById(Hilite.elementid)))||(b=document.body))){Hilite.hiliteElement(b,a)}};Hilite.walkElements=function(d,f,e){var a=/^(script|style|textarea)/i;var c=0;while(d&&f>0){c++;if(c>=Hilite.max_nodes){var b=function(){Hilite.walkElements(d,f,e)};setTimeout(b,50);return}if(d.nodeType==1){if(!a.test(d.tagName)&&d.childNodes.length>0){d=d.childNodes[0];f++;continue}}else{if(d.nodeType==3){d=e(d)}}if(d.nextSibling){d=d.nextSibling}else{while(f>0){d=d.parentNode;f--;if(d.nextSibling){d=d.nextSibling;break}}}}};if(Hilite.onload){if(window.attachEvent){window.attachEvent("onload",Hilite.hilite)}else{if(window.addEventListener){window.addEventListener("load",Hilite.hilite,false)}else{var __onload=window.onload;window.onload=function(){Hilite.hilite();__onload()}}}}; -/* json2.js by D. Crockford */ + if(!this.JSON){this.JSON={}}(function(){function f(n){return n<10?"0"+n:n}if(typeof Date.prototype.toJSON!=="function"){Date.prototype.toJSON=function(key){return isFinite(this.valueOf())?this.getUTCFullYear()+"-"+f(this.getUTCMonth()+1)+"-"+f(this.getUTCDate())+"T"+f(this.getUTCHours())+":"+f(this.getUTCMinutes())+":"+f(this.getUTCSeconds())+"Z":null};String.prototype.toJSON=Number.prototype.toJSON=Boolean.prototype.toJSON=function(key){return this.valueOf()}}var cx=/[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,escapable=/[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,gap,indent,meta={"\b":"\\b","\t":"\\t","\n":"\\n","\f":"\\f","\r":"\\r",'"':'\\"',"\\":"\\\\"},rep;function quote(string){escapable.lastIndex=0;return escapable.test(string)?'"'+string.replace(escapable,function(a){var c=meta[a];return typeof c==="string"?c:"\\u"+("0000"+a.charCodeAt(0).toString(16)).slice(-4)})+'"':'"'+string+'"'}function str(key,holder){var i,k,v,length,mind=gap,partial,value=holder[key];if(value&&typeof value==="object"&&typeof value.toJSON==="function"){value=value.toJSON(key)}if(typeof rep==="function"){value=rep.call(holder,key,value)}switch(typeof value){case"string":return quote(value);case"number":return isFinite(value)?String(value):"null";case"boolean":case"null":return String(value);case"object":if(!value){return"null"}gap+=indent;partial=[];if(Object.prototype.toString.apply(value)==="[object Array]"){length=value.length;for(i=0;i<length;i+=1){partial[i]=str(i,value)||"null"}v=partial.length===0?"[]":gap?"[\n"+gap+partial.join(",\n"+gap)+"\n"+mind+"]":"["+partial.join(",")+"]";gap=mind;return v}if(rep&&typeof rep==="object"){length=rep.length;for(i=0;i<length;i+=1){k=rep[i];if(typeof k==="string"){v=str(k,value);if(v){partial.push(quote(k)+(gap?": ":":")+v)}}}}else{for(k in value){if(Object.hasOwnProperty.call(value,k)){v=str(k,value);if(v){partial.push(quote(k)+(gap?": ":":")+v)}}}}v=partial.length===0?"{}":gap?"{\n"+gap+partial.join(",\n"+gap)+"\n"+mind+"}":"{"+partial.join(",")+"}";gap=mind;return v}}if(typeof JSON.stringify!=="function"){JSON.stringify=function(value,replacer,space){var i;gap="";indent="";if(typeof space==="number"){for(i=0;i<space;i+=1){indent+=" "}}else{if(typeof space==="string"){indent=space}}rep=replacer;if(replacer&&typeof replacer!=="function"&&(typeof replacer!=="object"||typeof replacer.length!=="number")){throw new Error("JSON.stringify")}return str("",{"":value})}}if(typeof JSON.parse!=="function"){JSON.parse=function(text,reviver){var j;function walk(holder,key){var k,v,value=holder[key];if(value&&typeof value==="object"){for(k in value){if(Object.hasOwnProperty.call(value,k)){v=walk(value,k);if(v!==undefined){value[k]=v}else{delete value[k]}}}}return reviver.call(holder,key,value)}text=String(text);cx.lastIndex=0;if(cx.test(text)){text=text.replace(cx,function(a){return"\\u"+("0000"+a.charCodeAt(0).toString(16)).slice(-4)})}if(/^[\],:{}\s]*$/.test(text.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g,"@").replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g,"]").replace(/(?:^|:|,)(?:\s*\[)+/g,""))){j=eval("("+text+")");return typeof reviver==="function"?walk({"":j},""):j}throw new SyntaxError("JSON.parse")}}}()); //jquery fieldselection (function(){var a={getSelection:function(){var b=this.jquery?this[0]:this;return(("selectionStart" in b&&function(){var c=b.selectionEnd-b.selectionStart;return{start:b.selectionStart,end:b.selectionEnd,length:c,text:b.value.substr(b.selectionStart,c)}})||(document.selection&&function(){b.focus();var d=document.selection.createRange();if(d==null){return{start:0,end:b.value.length,length:0}}var c=b.createTextRange();var e=c.duplicate();c.moveToBookmark(d.getBookmark());e.setEndPoint("EndToStart",c);return{start:e.text.length,end:e.text.length+d.text.length,length:d.text.length,text:d.text}})||function(){return{start:0,end:b.value.length,length:0}})()},replaceSelection:function(){var b=this.jquery?this[0]:this;var c=arguments[0]||"";return(("selectionStart" in b&&function(){b.value=b.value.substr(0,b.selectionStart)+c+b.value.substr(b.selectionEnd,b.value.length);return this})||(document.selection&&function(){b.focus();document.selection.createRange().text=c;return this})||function(){b.value+=c;return this})()}};jQuery.each(a,function(b){jQuery.fn[b]=this})})(); diff --git a/askbot/skins/default/media/style/style.less b/askbot/skins/default/media/style/style.less index 02635b7c..993daa33 100644 --- a/askbot/skins/default/media/style/style.less +++ b/askbot/skins/default/media/style/style.less @@ -3486,13 +3486,19 @@ textarea.tipped-input { padding-left: 7px; font-size: 14px; line-height: 25px; + input { + margin: 0 0 2px -5px; + font-size: 14px; + line-height: 14px; + vertical-align: middle; + color: @info-text; + } } li.selected, li.selected:hover { background-color: #fcf8e3; color: #c09853; } - li:hover { background-color: #cecece; color: white; @@ -3537,6 +3543,9 @@ textarea.tipped-input { .question-page { .category-selector ul.select-box { width: 217px; + input { + width: 95px; + } } .tag-editor { width: 660px; diff --git a/askbot/skins/default/templates/meta/category_tree_js.html b/askbot/skins/default/templates/meta/category_tree_js.html index e480cc2c..1a0af93a 100644 --- a/askbot/skins/default/templates/meta/category_tree_js.html +++ b/askbot/skins/default/templates/meta/category_tree_js.html @@ -5,9 +5,6 @@ if (sel_elems.length > 0) { var selector = new CategorySelector(); selector.setData(JSON.parse("{{category_tree_data|escapejs}}")); - if (askbot['data']['userIsAdminOrMod']) { - selector.setEditable(true); - } selector.decorate(sel_elems); var tag_editor = new TagEditor(); @@ -18,7 +15,9 @@ {% endfor %} {% endif %} selector.setSelectHandler(tag_editor.getAddTagHandler()); + return selector; } }; askbot['functions']['initCategoryTree'](); + askbot['urls']['add_tag_category'] = '{% url add_tag_category %}'; </script> diff --git a/askbot/skins/default/templates/meta/html_head_javascript.html b/askbot/skins/default/templates/meta/html_head_javascript.html index 65d0bdce..6e480f8b 100644 --- a/askbot/skins/default/templates/meta/html_head_javascript.html +++ b/askbot/skins/default/templates/meta/html_head_javascript.html @@ -9,6 +9,9 @@ request.user.is_administrator() or request.user.is_moderator() %}true{% else %}false{% endif %}; + askbot['data']['userIsAdmin'] = {% if + request.user.is_administrator() + %}true{% else %}false{% endif %}; askbot['data']['userReputation'] = {{request.user.reputation}}; {% else %} askbot['data']['userIsAuthenticated'] = false; diff --git a/askbot/skins/default/templates/widgets/three_column_category_selector.html b/askbot/skins/default/templates/widgets/three_column_category_selector.html index 9c7b7e6d..ab0886c6 100644 --- a/askbot/skins/default/templates/widgets/three_column_category_selector.html +++ b/askbot/skins/default/templates/widgets/three_column_category_selector.html @@ -1,7 +1,16 @@ {# just a skeleton for the category selector - filled by js #} <table class="category-selector"> <thead> - <th colspan="3">{% trans %}Categorize your question using this tag selector or entering text in tag box{% endtrans %}</th> + <th colspan="3">{% trans %}Categorize your question using this tag selector or entering text in tag box.{% endtrans %} + <a style="display:none;" + class='category-editor-toggle' + data-on-state-text='{% trans %}(done editing){% endtrans %}' + data-off-state-text='{% trans %}(edit categories){% endtrans %}' + data-on-prompt-text='{% trans %}(edit categories){% endtrans %}' + data-off-prompt-text='{% trans %}(done editing){% endtrans %}' + >{% trans %}(edit categories){% endtrans %} + </a> + </th> </thead> <tbody> <tr> diff --git a/askbot/tests/__init__.py b/askbot/tests/__init__.py index 1b25e064..198bf9e8 100644 --- a/askbot/tests/__init__.py +++ b/askbot/tests/__init__.py @@ -15,3 +15,4 @@ from askbot.tests.markup_test import * from askbot.tests.misc_tests import * from askbot.tests.post_model_tests import * from askbot.tests.reply_by_email_tests import * +from askbot.tests.category_tree_tests import CategoryTreeTests diff --git a/askbot/tests/category_tree_tests.py b/askbot/tests/category_tree_tests.py new file mode 100644 index 00000000..7f6ed592 --- /dev/null +++ b/askbot/tests/category_tree_tests.py @@ -0,0 +1,116 @@ +import unittest +from askbot.utils import category_tree as ct +from django.utils import simplejson + +class CategoryTreeTests(unittest.TestCase): + def setUp(self): + self.tree = [ + [ + u'dummy', [#dummy is a sentinel node + [ + u'cars', [ + [u'volkswagen', []], + [u'zhiguli', []] + ] + ], + [ + u'cats', [ + [u'meow', []], + [u'tigers', [ + [u'rrrr', []] + ] + ] + ] + ], + [ + u'music', [ + [u'play', [ + [u'loud', []]] + ], + [u'listen', []], + [u'buy', []], + [u'download', []] + ] + ] + ] + ] + ] + + def test_dummy_is_absent(self): + self.assertEqual( + ct.has_category(self.tree, 'dummy'), + False + ) + + def test_first_level_subcat_is_there(self): + self.assertEqual( + ct.has_category(self.tree, 'cars'), + True + ) + + def test_deep_level_subcat_is_there(self): + self.assertEqual( + ct.has_category(self.tree, 'download'), + True + ) + + def test_get_subtree_dummy(self): + dummy = ct.get_subtree(self.tree, [0]) + self.assertEqual(dummy[0], 'dummy') + + def test_get_subtree_cars(self): + cars = ct.get_subtree(self.tree, [0,0]) + self.assertEqual(cars[0], 'cars') + + def test_get_subtree_listen_music(self): + listen_music = ct.get_subtree(self.tree, [0,2,1]) + self.assertEqual(listen_music[0], 'listen') + + def test_path_is_valid_dummy(self): + self.assertEqual( + ct.path_is_valid(self.tree, [0]), True + ) + + def test_path_is_valid_deep(self): + self.assertEqual( + ct.path_is_valid(self.tree, [0,2,0,0]), True + ) + def test_path_is_nvalid_too_deep(self): + self.assertEqual( + ct.path_is_valid(self.tree, [0,2,0,0,0]), False + ) + + def test_add_category(self): + ct.add_category(self.tree, 'appreciate', [0, 2]) + appreciate = ct.get_subtree(self.tree, [0, 2, 4]) + self.assertEqual(appreciate[0] , 'appreciate') + + def test_sort_data(self): + unsorted_data = [ + [ + 'dummy', + [ + [ + 'cars', + [] + ], + [ + 'audio', + [ + [ + 'mp3', [] + ], + [ + 'amadeus', [] + ] + ] + ] + ] + ] + ] + sorted_data = ct.sort_tree(unsorted_data) + sorted_dump = simplejson.dumps(sorted_data) + self.assertEqual( + sorted_dump, + '[["dummy", [["audio", [["amadeus", []], ["mp3", []]]], ["cars", []]]]]' + ) diff --git a/askbot/urls.py b/askbot/urls.py index 39170660..1250a685 100644 --- a/askbot/urls.py +++ b/askbot/urls.py @@ -210,6 +210,11 @@ urlpatterns = patterns('', name = 'save_tag_wiki_text' ), url(#ajax only + r'^add-tag-category/', + views.commands.add_tag_category, + name = 'add_tag_category' + ), + url(#ajax only r'^save-group-logo-url/', views.commands.save_group_logo_url, name = 'save_group_logo_url' diff --git a/askbot/utils/category_tree.py b/askbot/utils/category_tree.py index 501b74fa..fb5e622f 100644 --- a/askbot/utils/category_tree.py +++ b/askbot/utils/category_tree.py @@ -28,53 +28,26 @@ example of desired structure, when input is parsed from askbot.conf import settings as askbot_settings from django.utils import simplejson -def get_subtree(tree, path): - if len(path) == 1: - assert(path[0] == 0) - return tree - else: - import copy - parent_path = copy.copy(path) - leaf_index = parent_path.pop() - branch_index = parent_path[-1] - parent_tree = get_subtree(tree, parent_path) - return parent_tree[branch_index][1] - -def parse_tree(text): - """parse tree represented as indented text - one item per line, with two spaces per level of indentation - """ - lines = text.split('\n') - import re - in_re = re.compile(r'^([ ]*)') - - tree = [['dummy', []]] - subtree_path = [0] - clevel = 0 +def _get_subtree(tree, path): + clevel = tree + for pace in path: + clevel = clevel[1][pace] + return clevel - for line in lines: - if line.strip() == '': - continue - match = in_re.match(line) - level = len(match.group(1))/2 + 1 - - if level > clevel: - subtree_path.append(0)# - elif level < clevel: - subtree_path = subtree_path[:level+1] - leaf_index = subtree_path.pop() - subtree_path.append(leaf_index + 1) - else: - leaf_index = subtree_path.pop() - subtree_path.append(leaf_index + 1) - - clevel = level - try: - subtree = get_subtree(tree, subtree_path) - except: - return tree - subtree.append([line.strip(), []]) +def get_subtree(tree, path): + """path always starts with 0, + and is a list of integers""" + assert(path[0] == 0) + if len(path) == 1:#special case + return tree[0] + else: + return _get_subtree(tree[0], path[1:]) +def sort_tree(tree): + """sorts contents of the nodes alphabetically""" + tree = sorted(tree, lambda x,y: cmp(x[0], y[0])) + for item in tree: + item[1] = sort_tree(item[1]) return tree def get_data(): @@ -82,7 +55,38 @@ def get_data(): or None, if category_tree is disabled """ if askbot_settings.TAG_SOURCE == 'category-tree': - cat_tree = parse_tree(askbot_settings.CATEGORY_TREE) - return simplejson.dumps(cat_tree) + return simplejson.loads(askbot_settings.CATEGORY_TREE) else: return None + +def path_is_valid(tree, path): + try: + get_subtree(tree, path) + return True + except IndexError: + return False + except AssertionError: + return False + +def add_category(tree, category_name, path): + subtree = get_subtree(tree, path) + subtree[1].append([category_name, []]) + +def _has_category(tree, category_name): + for item in tree: + if item[0] == category_name: + return True + if _has_category(item[1], category_name): + return True + return False + +def has_category(tree, category_name): + """true if category is in tree""" + #skip the dummy + return _has_category(tree[0][1], category_name) + +def save_data(tree): + assert(askbot_settings.TAG_SOURCE == 'category-tree') + tree = sort_tree(tree) + tree_json = simplejson.dumps(tree) + askbot_settings.update('CATEGORY_TREE', tree_json) diff --git a/askbot/views/commands.py b/askbot/views/commands.py index 79c266c2..a0af45de 100644 --- a/askbot/views/commands.py +++ b/askbot/views/commands.py @@ -20,6 +20,7 @@ from askbot import models from askbot import forms from askbot.conf import should_show_sort_by_relevance from askbot.conf import settings as askbot_settings +from askbot.utils import category_tree from askbot.utils import decorators from askbot.utils import url_utils from askbot import mail @@ -477,6 +478,7 @@ def get_html_template(request): allowed_templates = ( 'widgets/tag_category_selector.html', ) + #have allow simple context for the templates if template_name not in allowed_templates: raise Http404 return { @@ -526,7 +528,42 @@ def save_tag_wiki_text(request): return {'html': tag_wiki.html} else: raise ValueError('invalid post data') - + +@csrf.csrf_exempt +@decorators.ajax_only +@decorators.post_only +def add_tag_category(request): + """adds a category at the tip of a given path expects + the following keys in the ``request.POST`` + * path - array starting with zero giving path to + the category page where to add the category + * new_category_name - string that must satisfy the + same requiremets as a tag + + return json with the category tree data + todo: switch to json stored in the live settings + now we have indented input + """ + if request.user.is_anonymous() \ + or not request.user.is_administrator_or_moderator(): + raise exceptions.PermissionDenied() + + post_data = simplejson.loads(request.raw_post_data) + category_name = forms.clean_tag(post_data['new_category_name']) + path = post_data['path'] + + tree = category_tree.get_data() + + if category_tree.has_category(tree, category_name): + raise ValueError('category already exists') + + if category_tree.path_is_valid(tree, path) == False: + raise ValueError('category insertion path is invalid') + + category_tree.add_category(tree, category_name, path) + category_tree.save_data(tree) + return {'tree_data': tree} + @decorators.get_only def get_groups_list(request): diff --git a/askbot/views/readers.py b/askbot/views/readers.py index c4778d7a..d27846e3 100644 --- a/askbot/views/readers.py +++ b/askbot/views/readers.py @@ -32,7 +32,6 @@ from askbot import models from askbot import schedules from askbot.models.tag import Tag from askbot import const -from askbot.utils import category_tree from askbot.utils import functions from askbot.utils.decorators import anonymous_forbidden, ajax_only, get_only from askbot.search.state_manager import SearchState, DummySearchState @@ -541,7 +540,7 @@ def question(request, id):#refactor - long subroutine. display question body, an 'answer' : answer_form, 'answers' : page_objects.object_list, 'answer_count': len(answers), - 'category_tree_data': category_tree.get_data(), + 'category_tree_data': askbot_settings.CATEGORY_TREE, 'user_votes': user_votes, 'user_post_id_list': user_post_id_list, 'user_can_post_comment': user_can_post_comment,#in general diff --git a/askbot/views/writers.py b/askbot/views/writers.py index 41391887..b5beb65b 100644 --- a/askbot/views/writers.py +++ b/askbot/views/writers.py @@ -32,7 +32,6 @@ from askbot.utils import decorators from askbot.utils.functions import diff_date from askbot.utils import url_utils from askbot.utils.file_utils import store_file -from askbot.utils import category_tree from askbot.views import context from askbot.templatetags import extra_filters_jinja as template_filters from askbot.importers.stackexchange import management as stackexchange#todo: may change @@ -268,7 +267,7 @@ def ask(request):#view used to ask a new question 'form' : form, 'mandatory_tags': models.tag.get_mandatory_tags(), 'email_validation_faq_url':reverse('faq') + '#validate', - 'category_tree_data': category_tree.get_data(), + 'category_tree_data': askbot_settings.CATEGORY_TREE, 'tag_names': list()#need to keep context in sync with edit_question for tag editor } data.update(context.get_for_tag_editor()) @@ -408,7 +407,7 @@ def edit_question(request, id): 'mandatory_tags': models.tag.get_mandatory_tags(), 'form' : form, 'tag_names': question.thread.get_tag_names(), - 'category_tree_data': category_tree.get_data() + 'category_tree_data': askbot_settings.CATEGORY_TREE } data.update(context.get_for_tag_editor()) return render_into_skin('question_edit.html', data, request) |