summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEvgeny Fadeev <evgeny.fadeev@gmail.com>2011-06-17 22:34:08 -0400
committerEvgeny Fadeev <evgeny.fadeev@gmail.com>2011-06-17 22:34:08 -0400
commite930ab656c6e9a849a2a1b5a9576797f7c37c5d8 (patch)
tree287098dc9dd198b61d59cfd2359d0f066c87dc81
parent7aa9acd1b39b4ab2b1b10bfc060a3deff72c274d (diff)
parent845f5960a312ae21319b7eda2e584304bb48045f (diff)
downloadaskbot-e930ab656c6e9a849a2a1b5a9576797f7c37c5d8.tar.gz
askbot-e930ab656c6e9a849a2a1b5a9576797f7c37c5d8.tar.bz2
askbot-e930ab656c6e9a849a2a1b5a9576797f7c37c5d8.zip
Merge branch 'zendesk-import'
-rw-r--r--askbot/conf/forum_data_rules.py14
-rw-r--r--askbot/doc/source/import-data.rst40
-rw-r--r--askbot/doc/source/index.rst2
-rw-r--r--askbot/importers/zendesk/__init__.py0
-rw-r--r--askbot/importers/zendesk/management/__init__.py0
-rw-r--r--askbot/importers/zendesk/management/commands/__init__.py0
-rw-r--r--askbot/importers/zendesk/management/commands/import_zendesk.py329
-rw-r--r--askbot/importers/zendesk/models.py78
-rw-r--r--askbot/models/__init__.py4
-rw-r--r--askbot/models/answer.py46
-rw-r--r--askbot/models/question.py36
-rw-r--r--askbot/skins/default/media/js/post.js51
-rw-r--r--askbot/skins/default/templates/question.html8
-rw-r--r--askbot/urls.py5
-rw-r--r--askbot/utils/console.py29
-rw-r--r--askbot/utils/dummy_transaction.py24
-rw-r--r--askbot/utils/html.py27
-rw-r--r--askbot/views/commands.py20
18 files changed, 693 insertions, 20 deletions
diff --git a/askbot/conf/forum_data_rules.py b/askbot/conf/forum_data_rules.py
index 3392278b..dc801d41 100644
--- a/askbot/conf/forum_data_rules.py
+++ b/askbot/conf/forum_data_rules.py
@@ -64,6 +64,20 @@ settings.register(
)
settings.register(
+ livesettings.BooleanValue(
+ FORUM_DATA_RULES,
+ 'ALLOW_SWAPPING_QUESTION_WITH_ANSWER',
+ default = False,
+ description = _('Allow swapping answer with question'),
+ help_text = _(
+ 'This setting will help import data from other forums '
+ 'such as zendesk, when automatic '
+ 'data import fails to detect the original question correctly.'
+ )
+ )
+)
+
+settings.register(
livesettings.IntegerValue(
FORUM_DATA_RULES,
'MAX_TAG_LENGTH',
diff --git a/askbot/doc/source/import-data.rst b/askbot/doc/source/import-data.rst
index c2cfc1b3..44b902b5 100644
--- a/askbot/doc/source/import-data.rst
+++ b/askbot/doc/source/import-data.rst
@@ -4,17 +4,45 @@
Import other forums into Askbot
===============================
-At this time only StackExchange import is supported.
+Askbot supports importing of data from StackExchange and Zendesk.
-There are two ways to import your StackExchange dump into Askbot:
+.. warning::
+ If your database contains any data prior to importing, please back it up before proceeding.
+
+StackExchange
+=============
+
+Add `askbot.importers.stackexchange` to the list of `INSTALLED_APPS` list in your `settings.py`, then run::
+
+ python manage.py syncdb
+
+Then there will be two ways to import your StackExchange dump:
* via the web at url `/import-data/`, relative to your forum installation
* using a management command::
python manage.py load_stackexchange /path/to/your-se-data.zip
-Before importing the data, an entry `askbot.importers.stackexchange` must be added to
-the `INSTALLED_APPS` list in your `settings.py` file and a command `python manage.py syncdb` run
-to initialize the stackexchange tables.
-In the case your database is not empty at the beginning of the process - **please do back it up**.
+Zendesk
+=======
+Add `askbot.importers.zendesk` to the list of `INSTALLED_APPS` in the `settings.py`,
+run `python manage.py syncdb`.
+
+Prepare your zendesk files: put all your .xml files into one directory and tar-zip it::
+
+ mkdir somedir
+ mv *.xml somedir #select the zendesk xml files and move them to the directory
+ tar cvfz zendesk.tgz somedir #name of the tgz file is not important
+
+Then run the import script::
+
+ python manage.py import_zendesk zendesk.tgz #file name is the parameter
+
+.. note::
+ It is possible that import script will make some mistakes in determining
+ which post in the group is the question, due to some specifics of zendesk
+ data format. If so, please enable feature
+ "Forum data rules"->"allow switching question with answer"
+ in :ref:`live settings <live-settings>` and use it in an admin or a moderator
+ account.
diff --git a/askbot/doc/source/index.rst b/askbot/doc/source/index.rst
index c8580a46..2653b2df 100644
--- a/askbot/doc/source/index.rst
+++ b/askbot/doc/source/index.rst
@@ -19,7 +19,7 @@ at the forum_ or by email at admin@askbot.org
Create and configure the site files<initial-configuration>
Initialize the database tables <initialize-database-tables>
Deploy on a webserver <deployment>
- Import data (StackExchange) <import-data>
+ Import data (StackExchange & ZenDesk) <import-data>
Appendix A: Maintenance procedures <management-commands>
Appendix B: Sending email to askbot <sending-email-to-askbot>
Appendix C: Optional modules <optional-modules>
diff --git a/askbot/importers/zendesk/__init__.py b/askbot/importers/zendesk/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/askbot/importers/zendesk/__init__.py
diff --git a/askbot/importers/zendesk/management/__init__.py b/askbot/importers/zendesk/management/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/askbot/importers/zendesk/management/__init__.py
diff --git a/askbot/importers/zendesk/management/commands/__init__.py b/askbot/importers/zendesk/management/commands/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/askbot/importers/zendesk/management/commands/__init__.py
diff --git a/askbot/importers/zendesk/management/commands/import_zendesk.py b/askbot/importers/zendesk/management/commands/import_zendesk.py
new file mode 100644
index 00000000..4229cbde
--- /dev/null
+++ b/askbot/importers/zendesk/management/commands/import_zendesk.py
@@ -0,0 +1,329 @@
+"""importer from zendesk data dump
+the dump must be a tar/gzipped file, containing one directory
+with all the .xml files.
+
+Run this command as::
+
+ python manage.py import_zendesk path/to/dump.tgz
+"""
+import os
+import re
+import sys
+import tarfile
+import tempfile
+from datetime import datetime, date
+from django.core.management.base import BaseCommand, CommandError
+from django.conf import settings
+from django.db import transaction
+from lxml import etree
+from askbot import models as askbot_models
+from askbot.utils import console
+from askbot.utils.html import unescape
+
+from askbot.importers.zendesk import models as zendesk_models
+
+#a hack, did not know how to parse timezone offset
+ZERO_TIME = datetime.strptime('00:00', '%H:%M')
+
+def get_unique_username(name_seed):
+ """returns unique user name, by modifying the
+ name if the same name exists in the database
+ until the modified name is unique
+ """
+ original_name = name_seed
+ attempt_no = 1
+ while True:
+ try:
+ askbot_models.User.objects.get(username = name_seed)
+ name_seed = original_name + str(attempt_no)
+ attempt_no += 1
+ except askbot_models.User.DoesNotExist:
+ return name_seed
+
+def clean_username(name_seed):
+ """makes sure that the name is unique
+ and is no longer than 30 characters"""
+ username = get_unique_username(name_seed)
+ if len(username) > 30:
+ username = get_unique_username(username[:28])
+ if len(username) > 30:
+ #will allow about a million extra possible unique names
+ username = get_unique_username(username[:24])
+ return username
+
+def create_askbot_user(zd_user):
+ """create askbot user from zendesk user record
+ return askbot user or None, if there is error
+ """
+ #special treatment for the user name
+ raw_username = unescape(zd_user.name)
+ username = clean_username(raw_username)
+ if len(username) > 30:#nearly impossible skip such user
+ print "Warning: could not import user %s" % raw_username
+ return None
+
+ if zd_user.email is None:
+ email = ''
+ else:
+ email = zd_user.email
+
+ ab_user = askbot_models.User(
+ email = email,
+ email_isvalid = zd_user.is_verified,
+ date_joined = zd_user.created_at,
+ last_seen = zd_user.created_at,#add initial date for now
+ username = username,
+ is_active = zd_user.is_active
+ )
+ ab_user.save()
+ return ab_user
+
+def post_question(zendesk_post):
+ """posts question to askbot, using zendesk post item"""
+ try:
+ return zendesk_post.get_author().post_question(
+ title = zendesk_post.get_fake_title(),
+ body_text = zendesk_post.get_body_text(),
+ tags = zendesk_post.get_tag_name(),
+ timestamp = zendesk_post.created_at
+ )
+ except Exception, e:
+ msg = unicode(e)
+ print "Warning: post %d dropped: %s" % (zendesk_post.post_id, msg)
+
+def post_answer(zendesk_post, question = None):
+ try:
+ zendesk_post.get_author().post_answer(
+ question = question,
+ body_text = zendesk_post.get_body_text(),
+ timestamp = zendesk_post.created_at
+ )
+ except Exception, e:
+ msg = unicode(e)
+ print "Warning: post %d dropped: %s" % (zendesk_post.post_id, msg)
+
+def get_val(elem, field_name):
+ field = elem.find(field_name)
+ if field is None:
+ return None
+ try:
+ field_type = field.attrib['type']
+ except KeyError:
+ field_type = ''
+ raw_val = field.text
+ if raw_val is None:
+ return None
+
+ if field_type == 'boolean':
+ if raw_val == 'true':
+ return True
+ elif raw_val == 'false':
+ return False
+ else:
+ raise ValueError('"true" or "false" expected, found "%s"' % raw_val)
+ elif field_type.endswith('integer'):
+ return int(raw_val)
+ elif field_type == 'datetime':
+ if raw_val is None:
+ return None
+ raw_datetime = raw_val[:19]
+ tzoffset_sign = raw_val[19]
+ raw_tzoffset = raw_val[20:]
+ if raw_val:
+ dt = datetime.strptime(raw_datetime, '%Y-%m-%dT%H:%M:%S')
+ tzoffset_amt = datetime.strptime(raw_tzoffset, '%H:%M')
+ tzoffset = tzoffset_amt - ZERO_TIME
+ if tzoffset_sign == '-':
+ return dt - tzoffset
+ else:
+ return dt + tzoffset
+ else:
+ return None
+ else:
+ return raw_val
+
+class Command(BaseCommand):
+ def handle(self, *args, **kwargs):
+ if len(args) != 1:
+ raise CommandError('please provide path to tarred and gzipped cnprog dump')
+
+ self.tar = tarfile.open(args[0], 'r:gz')
+
+ #sys.stdout.write('Reading users.xml: ')
+ #self.read_users()
+ #sys.stdout.write('Reading posts.xml: ')
+ #self.read_posts()
+ #sys.stdout.write('Reading forums.xml: ')
+ #self.read_forums()
+
+ sys.stdout.write("Importing user accounts: ")
+ self.import_users()
+ sys.stdout.write("Loading threads: ")
+ self.import_content()
+
+ def get_file(self, file_name):
+ first_item = self.tar.getnames()[0]
+ file_path = file_name
+ if not first_item.endswith('.xml'):
+ file_path = os.path.join(first_item, file_path)
+
+ file_info = self.tar.getmember(file_path)
+ xml_file = self.tar.extractfile(file_info)
+ return etree.parse(xml_file)
+
+ @transaction.commit_manually
+ def read_xml_file(self,
+ file_name = None,
+ entry_name = None,
+ model = None,
+ fields = None,
+ extra_field_mappings = None
+ ):
+ """
+ * file_name - is name of xml file,
+ * entry_name - name of entries to read from the xml file
+ * model - model, which is to receive data
+ * fields - list of field names in xml that will be translated to model fields
+ by simple substitiution of '-' with '_'
+ * extra field mappings - list of two tuples where xml field names are
+ translated to model fields in a special way
+ """
+ xml = self.get_file(file_name)
+ items_saved = 0
+ for xml_entry in xml.findall(entry_name):
+ instance = model()
+ for field in fields:
+ value = get_val(xml_entry, field)
+ model_field_name = field.replace('-', '_')
+ setattr(instance, model_field_name, value)
+ if extra_field_mappings:
+ for (field, model_field_name) in extra_field_mappings:
+ value = get_val(xml_entry, field)
+ setattr(instance, model_field_name, value)
+ instance.save()
+ transaction.commit()
+ items_saved += 1
+ console.print_action('%d items' % items_saved)
+ console.print_action('%d items' % items_saved, nowipe = True)
+
+
+ def read_users(self):
+ self.read_xml_file(
+ file_name = 'users.xml',
+ entry_name = 'user',
+ model = zendesk_models.User,
+ fields = (
+ 'created-at', 'is-active', 'last-login', 'name',
+ 'openid-url', 'organization-id', 'phone', 'restriction-id',
+ 'roles', 'time-zone', 'updated-at', 'uses-12-hour-clock',
+ 'email', 'is-verified', 'photo-url'
+ ),
+ extra_field_mappings = (('id', 'user_id'),)
+ )
+
+ def read_posts(self):
+ self.read_xml_file(
+ file_name = 'posts.xml',
+ entry_name = 'post',
+ model = zendesk_models.Post,
+ fields = (
+ 'body', 'created-at', 'updated-at', 'entry-id',
+ 'forum-id', 'user-id', 'is-informative'
+ ),
+ extra_field_mappings = (
+ ('id', 'post_id'),
+ )
+ )
+
+ def read_forums(self):
+ self.read_xml_file(
+ file_name = 'forums.xml',
+ entry_name = 'forum',
+ model = zendesk_models.Forum,
+ fields = (
+ 'description', 'display-type-id',
+ 'entries-count', 'is-locked',
+ 'name', 'organization-id',
+ 'position', 'updated-at',
+ 'translation-locale-id',
+ 'use-for-suggestions',
+ 'visibility-restriction-id',
+ 'is-public'
+ ),
+ extra_field_mappings = (('id', 'forum_id'),)
+ )
+
+ @transaction.commit_manually
+ def import_users(self):
+ added_users = 0
+ for zd_user in zendesk_models.User.objects.all():
+ #a whole bunch of fields are actually dropped now
+ #see what's available in users.xml meanings of some
+ #values there is not clear
+
+ #if email is blank, just create a new user
+ if zd_user.email == '':
+ ab_user = create_askbot_user(zd_user)
+ if ab_user in None:
+ print 'Warning: could not create user %s ' % zd_user.name
+ continue
+ console.print_action(ab_user.username)
+ else:
+ #else see if user with the same email already exists
+ #and only create new askbot user if email is not yet in the
+ #database
+ try:
+ ab_user = askbot_models.User.objects.get(email = zd_user.email)
+ except askbot_models.User.DoesNotExist:
+ ab_user = create_askbot_user(zd_user)
+ if ab_user is None:
+ continue
+ console.print_action(ab_user.username, nowipe = True)
+ added_users += 1
+ zd_user.askbot_user_id = ab_user.id
+ zd_user.save()
+
+ if zd_user.openid_url != None and \
+ 'askbot.deps.django_authopenid' in settings.INSTALLED_APPS:
+ from askbot.deps.django_authopenid.models import UserAssociation
+ from askbot.deps.django_authopenid.util import get_provider_name
+ try:
+ assoc = UserAssociation(
+ user = ab_user,
+ openid_url = zd_user.openid_url,
+ provider_name = get_provider_name(zd_user.openid_url)
+ )
+ assoc.save()
+ except:
+ #drop user association
+ pass
+
+ transaction.commit()
+ console.print_action('%d users added' % added_users, nowipe = True)
+
+ @transaction.commit_manually
+ def import_content(self):
+ thread_ids = zendesk_models.Post.objects.values_list(
+ 'entry_id',
+ flat = True
+ ).distinct()
+ threads_posted = 0
+ for thread_id in thread_ids:
+ thread_entries = zendesk_models.Post.objects.filter(
+ entry_id = thread_id
+ ).order_by('created_at')
+ question_post = thread_entries[0]
+ question = post_question(question_post)
+ question_post.is_processed = True
+ question_post.save()
+ transaction.commit()
+ entry_count = thread_entries.count()
+ threads_posted += 1
+ console.print_action(str(threads_posted))
+ if entry_count > 1:
+ for answer_post in thread_entries[1:]:
+ post_answer(answer_post, question = question)
+ answer_post.is_processed = True
+ answer_post.save()
+ transaction.commit()
+ console.print_action(str(threads_posted), nowipe = True)
diff --git a/askbot/importers/zendesk/models.py b/askbot/importers/zendesk/models.py
new file mode 100644
index 00000000..6a321915
--- /dev/null
+++ b/askbot/importers/zendesk/models.py
@@ -0,0 +1,78 @@
+import re
+from django.db import models
+from django.contrib.auth.models import User as DjangoUser
+from django.utils.html import strip_tags
+from askbot.utils.html import unescape
+
+TAGS = {}#internal cache for mappings forum id -> forum name
+
+class Post(models.Model):
+ body = models.TextField()
+ created_at = models.DateTimeField()
+ updated_at = models.DateTimeField()
+ entry_id = models.IntegerField()
+ post_id = models.IntegerField()
+ forum_id = models.IntegerField()
+ user_id = models.IntegerField()
+ is_informative = models.BooleanField()
+ is_processed = models.BooleanField(default = False)
+
+ def get_author(self):
+ """returns author of the post, from the Django user table"""
+ zendesk_user = User.objects.get(user_id = self.user_id)
+ return DjangoUser.objects.get(id = zendesk_user.askbot_user_id)
+
+ def get_body_text(self):
+ """unescapes html entities in the body text,
+ saves in the internal cache and returns the value"""
+ if not hasattr(self, '_body_text'):
+ self._body_text = unescape(self.body)
+ return self._body_text
+
+ def get_fake_title(self):
+ """extract first 10 words from the body text and strip tags"""
+ words = re.split(r'\s+', self.get_body_text())
+ if len(words) > 10:
+ words = words[:10]
+ return strip_tags(' '.join(words))
+
+ def get_tag_name(self):
+ if self.forum_id not in TAGS:
+ forum = Forum.objects.get(forum_id = self.forum_id)
+ tag_name = re.sub(r'\s+', '-', forum.name.lower())
+ TAGS[self.forum_id] = tag_name
+ return TAGS[self.forum_id]
+
+class User(models.Model):
+ user_id = models.IntegerField()
+ askbot_user_id = models.IntegerField(null = True)
+ created_at = models.DateTimeField()
+ is_active = models.BooleanField()
+ last_login = models.DateTimeField(null = True)
+ name = models.CharField(max_length = 255)
+ openid_url = models.URLField(null = True)
+ organization_id = models.IntegerField(null = True)
+ phone = models.CharField(max_length = 32, null = True)
+ restriction_id = models.IntegerField()
+ roles = models.IntegerField()
+ time_zone = models.CharField(max_length = 255)
+ updated_at = models.DateTimeField()
+ uses_12_hour_clock = models.BooleanField()
+ email = models.EmailField(null = True)
+ is_verified = models.BooleanField()
+ photo_url = models.URLField()
+
+class Forum(models.Model):
+ description = models.CharField(max_length = 255, null = True)
+ display_type_id = models.IntegerField()
+ entries_count = models.IntegerField()
+ forum_id = models.IntegerField()
+ is_locked = models.BooleanField()
+ name = models.CharField(max_length = 255)
+ organization_id = models.IntegerField(null = True)
+ position = models.IntegerField(null = True)
+ updated_at = models.DateTimeField()
+ translation_locale_id = models.IntegerField(null = True)
+ use_for_suggestions = models.BooleanField()
+ visibility_restriction_id = models.IntegerField()
+ is_public = models.BooleanField()
diff --git a/askbot/models/__init__.py b/askbot/models/__init__.py
index 73adcf34..6c25aaa5 100644
--- a/askbot/models/__init__.py
+++ b/askbot/models/__init__.py
@@ -1373,6 +1373,9 @@ def user_add_missing_askbot_subscriptions(self):
def user_is_moderator(self):
return (self.status == 'm' and self.is_administrator() == False)
+def user_is_administrator_or_moderator(self):
+ return (self.is_administrator() or self.is_moderator())
+
def user_is_suspended(self):
return (self.status == 's')
@@ -1950,6 +1953,7 @@ User.add_to_class('mark_tags', user_mark_tags)
User.add_to_class('update_response_counts', user_update_response_counts)
User.add_to_class('can_have_strong_url', user_can_have_strong_url)
User.add_to_class('is_administrator', user_is_administrator)
+User.add_to_class('is_administrator_or_moderator', user_is_administrator_or_moderator)
User.add_to_class('set_admin_status', user_set_admin_status)
User.add_to_class('remove_admin_status', user_remove_admin_status)
User.add_to_class('is_moderator', user_is_moderator)
diff --git a/askbot/models/answer.py b/askbot/models/answer.py
index c268551d..09b5a444 100644
--- a/askbot/models/answer.py
+++ b/askbot/models/answer.py
@@ -161,6 +161,52 @@ class Answer(content.Content, DeletableContent):
self.question.last_activity_by = edited_by
self.question.save()
+ def repost_as_question(self, new_title = None):
+ """posts answer as question, together with all the comments
+ while preserving time stamps and authors
+ does not delete the answer itself though
+ """
+ revisions = self.revisions.all().order_by('revised_at')
+ rev0 = revisions[0]
+ new_question = rev0.author.post_question(
+ title = new_title,
+ body_text = rev0.text,
+ tags = self.question.tagnames,
+ wiki = self.question.wiki,
+ is_anonymous = self.question.is_anonymous,
+ timestamp = rev0.revised_at
+ )
+ if len(revisions) > 1:
+ for rev in revisions[1:]:
+ rev.author.edit_question(
+ question = new_question,
+ body_text = rev.text,
+ revision_comment = rev.summary,
+ timestamp = rev.revised_at
+ )
+ for comment in self.comments.all():
+ comment.content_object = new_question
+ comment.save()
+ return new_question
+
+ def swap_with_question(self, new_title = None):
+ """swaps answer with the question it belongs to and
+ sets the title of question to ``new_title``
+ """
+ #1) make new question by using new title, tags of old question
+ # and the answer body, as well as the authors of all revisions
+ # and repost all the comments
+ new_question = self.repost_as_question(new_title = new_title)
+
+ #2) post question (all revisions and comments) as answer
+ new_answer = self.question.repost_as_answer(question = new_question)
+
+ #3) assign all remaining answers to the new question
+ self.question.answers.update(question = new_question)
+ self.question.delete()
+ self.delete()
+ return new_question
+
def add_revision(self, author=None, revised_at=None, text=None, comment=None):
#todo: this may be identical to Question.add_revision
if None in (author, revised_at, text):
diff --git a/askbot/models/question.py b/askbot/models/question.py
index 41579c11..7a2be48c 100644
--- a/askbot/models/question.py
+++ b/askbot/models/question.py
@@ -457,15 +457,13 @@ class Question(content.Content, DeletableContent):
self.save()
def update_favorite_count(self):
- """
- update favourite_count for given question
+ """update favourite_count for given question
"""
self.favourite_count = FavoriteQuestion.objects.filter(
question=self
).count()
self.save()
-
def get_similar_questions(self):
"""
Get 10 similar questions for given one.
@@ -602,6 +600,31 @@ class Question(content.Content, DeletableContent):
return False
+ def repost_as_answer(self, question = None):
+ """posts question as answer to another question,
+ but does not delete the question,
+ but moves all the comments to the new answer"""
+ revisions = self.revisions.all().order_by('revised_at')
+ rev0 = revisions[0]
+ new_answer = rev0.author.post_answer(
+ question = question,
+ body_text = rev0.text,
+ wiki = self.wiki,
+ timestamp = rev0.revised_at
+ )
+ if len(revisions) > 1:
+ for rev in revisions:
+ rev.author.edit_answer(
+ answer = new_answer,
+ body_text = rev.text,
+ revision_comment = rev.summary,
+ timestamp = rev.revised_at
+ )
+ for comment in self.comments.all():
+ comment.content_object = new_answer
+ comment.save()
+ return new_answer
+
def delete(self):
super(Question, self).delete()
try:
@@ -782,7 +805,12 @@ class Question(content.Content, DeletableContent):
if no_slug == True:
return url
else:
- return url + django_urlquote(slugify(self.title))
+ return url + django_urlquote(self.slug)
+
+ def _get_slug(self):
+ return slugify(self.title)
+
+ slug = property(_get_slug)
def has_favorite_by_user(self, user):
if not user.is_authenticated():
diff --git a/askbot/skins/default/media/js/post.js b/askbot/skins/default/media/js/post.js
index 0d84508e..a228e9ce 100644
--- a/askbot/skins/default/media/js/post.js
+++ b/askbot/skins/default/media/js/post.js
@@ -1394,11 +1394,62 @@ var socialSharing = function(){
}
}();
+/**
+ * @constructor
+ * @extends {SimpleControl}
+ */
+var QASwapper = function(){
+ SimpleControl.call(this);
+ this._ans_id = null;
+};
+inherits(QASwapper, SimpleControl);
+
+QASwapper.prototype.decorate = function(element){
+ this._element = element;
+ this._ans_id = parseInt(element.attr('id').split('-').pop());
+ var me = this;
+ this.setHandler(function(){
+ me.startSwapping();
+ });
+};
+
+QASwapper.prototype.startSwapping = function(){
+ while (true){
+ var title = prompt(gettext('Please enter question title (>10 characters)'));
+ if (title.length >= 10){
+ var data = {new_title: title, answer_id: this._ans_id};
+ $.ajax({
+ type: "POST",
+ cache: false,
+ dataType: "json",
+ url: askbot['urls']['swap_question_with_answer'],
+ data: data,
+ success: function(data){
+ var url_template = askbot['urls']['question_url_template'];
+ new_question_url = url_template.replace(
+ '{{QuestionID}}',
+ data['id']
+ ).replace(
+ '{{questionSlug}}',
+ data['slug']
+ );
+ window.location.href = new_question_url;
+ }
+ });
+ break;
+ }
+ }
+};
+
$(document).ready(function() {
$('[id^="comments-for-"]').each(function(index, element){
var comments = new PostCommentsWidget();
comments.decorate(element);
});
+ $('[id^="swap-question-with-answer-"]').each(function(idx, element){
+ var swapper = new QASwapper();
+ swapper.decorate($(element));
+ });
questionRetagger.init();
socialSharing.init();
});
diff --git a/askbot/skins/default/templates/question.html b/askbot/skins/default/templates/question.html
index 6b51c756..d0eadaef 100644
--- a/askbot/skins/default/templates/question.html
+++ b/askbot/skins/default/templates/question.html
@@ -3,7 +3,7 @@
<!-- question.html -->
{% block title %}{% spaceless %}{{ question.get_question_title() }}{% endspaceless %}{% endblock %}
{% block meta_description %}
- <meta name="description" content="{{question.summary}}" />
+ <meta name="description" content="{{question.summary|striptags|escape}}" />
{% endblock %}
{% block keywords %}{{question.tagname_meta_generator()}}{% endblock %}
{% block forestyle %}
@@ -264,6 +264,11 @@
</span>
{% endspaceless %}
{% endif %}
+ {% if settings.ALLOW_SWAPPING_QUESTION_WITH_ANSWER and request.user.is_authenticated() and request.user.is_administrator_or_moderator() %}{{ pipe() }}
+ <span class="action-link">
+ <a id="swap-question-with-answer-{{answer.id}}">{% trans %}swap with question{% endtrans %}</a>
+ </span>
+ {% endif %}
</div>
<div class="post-update-info-container">
{{
@@ -441,6 +446,7 @@
askbot['urls']['question_url_template'] = scriptUrl + '{% trans %}question/{% endtrans %}{{ "{{QuestionID}}/{{questionSlug}}" }}';{# yes it needs to be that whacky #}
askbot['urls']['user_signin'] = '{{ settings.LOGIN_URL }}';
askbot['urls']['vote_url_template'] = scriptUrl + '{% trans %}questions/{% endtrans %}{{ "{{QuestionID}}/" }}{% trans %}vote/{% endtrans %}';
+ askbot['urls']['swap_question_with_answer'] = '{% url swap_question_with_answer %}';
askbot['messages']['addComment'] = '{% trans %}add comment{% endtrans %}';
{% if settings.SAVE_COMMENT_ON_ENTER %}
askbot['settings']['saveCommentOnEnter'] = true;
diff --git a/askbot/urls.py b/askbot/urls.py
index 877e0beb..f6c5e937 100644
--- a/askbot/urls.py
+++ b/askbot/urls.py
@@ -165,6 +165,11 @@ urlpatterns = patterns('',
name = 'get_tag_list'
),
url(
+ r'^swap-question-with-answer/',
+ views.commands.swap_question_with_answer,
+ name = 'swap_question_with_answer'
+ ),
+ url(
r'^%s$' % _('subscribe-for-tags/'),
views.commands.subscribe_for_tags,
name = 'subscribe_for_tags'
diff --git a/askbot/utils/console.py b/askbot/utils/console.py
index 041fc839..470856b5 100644
--- a/askbot/utils/console.py
+++ b/askbot/utils/console.py
@@ -49,13 +49,28 @@ def open_new_file(prompt_phrase, extension = '', hint = None):
return file_object
-def print_progress(format_string, progress):
+def print_action(action_text, nowipe = False):
+ """print the string to the standard output
+ then wipe it out to clear space
+ """
+ #for some reason sys.stdout.write does not work here
+ #when action text is unicode
+ print action_text,
+ sys.stdout.flush()
+ if nowipe == False:
+ #return to the beginning of the word
+ sys.stdout.write('\b' * len(action_text))
+ #white out the printed text
+ sys.stdout.write(' ' * len(action_text))
+ #return again
+ sys.stdout.write('\b' * len(action_text))
+ else:
+ sys.stdout.write('\n')
+
+def print_progress(elapsed, total, nowipe = False):
"""print dynamic output of progress of some
- operation to the console and clear the output with
+ operation, in percent, to the console and clear the output with
a backspace character to have the number increment
in-place"""
- output = format_string % progress
- sys.stdout.write(output)
- sys.stdout.flush()
- sys.stdout.write('\b' * len(output))
-
+ output = '%6.2f%%' % 100 * float(elapsed)/float(total)
+ print_action(output, nowipe)
diff --git a/askbot/utils/dummy_transaction.py b/askbot/utils/dummy_transaction.py
new file mode 100644
index 00000000..87ba38e7
--- /dev/null
+++ b/askbot/utils/dummy_transaction.py
@@ -0,0 +1,24 @@
+"""Dummy transaction module, use instead of :mod:`django.db.transaction`
+when you want to debug code that would normally run under transaction management.
+Usage::
+
+ from askbot.utils import dummy_transaction as transaction
+
+ @transaction.commit_manually
+ def do_something():
+ #your code making changes to the database
+ transaction.commit()
+ return
+"""
+import functools
+
+def commit_manually(func):
+ """fake ``commit_manually`` decorator"""
+ @functools.wraps(func)
+ def wrapper(*args, **kwargs):
+ return func(*args, **kwargs)
+ return wrapper
+
+def commit():
+ """fake transaction commit"""
+ pass
diff --git a/askbot/utils/html.py b/askbot/utils/html.py
index aa8e24d8..f6c168fb 100644
--- a/askbot/utils/html.py
+++ b/askbot/utils/html.py
@@ -1,6 +1,7 @@
"""Utilities for working with HTML."""
import html5lib
from html5lib import sanitizer, serializer, tokenizer, treebuilders, treewalkers
+import re, htmlentitydefs
class HTMLSanitizerMixin(sanitizer.HTMLSanitizerMixin):
acceptable_elements = ('a', 'abbr', 'acronym', 'address', 'b', 'big',
@@ -49,3 +50,29 @@ def sanitize_html(html):
quote_attr_values=True)
output_generator = s.serialize(stream)
return u''.join(output_generator)
+
+def unescape(text):
+ """source: http://effbot.org/zone/re-sub.htm#unescape-html
+ Removes HTML or XML character references and entities from a text string.
+ @param text The HTML (or XML) source text.
+ @return The plain text, as a Unicode string, if necessary.
+ """
+ def fixup(m):
+ text = m.group(0)
+ if text[:2] == "&#":
+ # character reference
+ try:
+ if text[:3] == "&#x":
+ return unichr(int(text[3:-1], 16))
+ else:
+ return unichr(int(text[2:-1]))
+ except ValueError:
+ pass
+ else:
+ # named entity
+ try:
+ text = unichr(htmlentitydefs.name2codepoint[text[1:-1]])
+ except KeyError:
+ pass
+ return text # leave as is
+ return re.sub("&#?\w+;", fixup, text)
diff --git a/askbot/views/commands.py b/askbot/views/commands.py
index 9b9e7af5..5b7e8f18 100644
--- a/askbot/views/commands.py
+++ b/askbot/views/commands.py
@@ -9,7 +9,7 @@ from django.conf import settings as django_settings
from django.core import exceptions
from django.core.urlresolvers import reverse
from django.contrib.auth.decorators import login_required
-from django.http import HttpResponse, HttpResponseRedirect
+from django.http import HttpResponse, HttpResponseRedirect, Http404
from django.forms import ValidationError
from django.shortcuts import get_object_or_404
from django.views.decorators import csrf
@@ -518,6 +518,24 @@ def reopen(request, id):#re-open question
request.user.message_set.create(message = unicode(e))
return HttpResponseRedirect(question.get_absolute_url())
+
+@decorators.ajax_only
+def swap_question_with_answer(request):
+ """receives two json parameters - answer id
+ and new question title
+ the view is made to be used only by the site administrator
+ or moderators
+ """
+ if request.user.is_authenticated():
+ if request.user.is_administrator() or request.user.is_moderator():
+ answer = models.Answer.objects.get(id = request.POST['answer_id'])
+ new_question = answer.swap_with_question(new_title = request.POST['new_title'])
+ return {
+ 'id': new_question.id,
+ 'slug': new_question.slug
+ }
+ raise Http404
+
#askbot-user communication system
def read_message(request):#marks message a read
if request.method == "POST":