summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdolfo Fitoria <adolfo.fitoria@gmail.com>2013-06-17 09:16:21 -0600
committerAdolfo Fitoria <adolfo.fitoria@gmail.com>2013-06-17 09:16:21 -0600
commite4a0be5d55f15c044dff8fa285da4fbe8ae878a3 (patch)
treee446d940a74ffdaa2ec80e0795fb303ddf70162c
parent85efae812a35a8b010777b4bb76df8bad9e54e5c (diff)
parent48c8d730c2e076fa7fe5e769a74d1026c5cfb113 (diff)
downloadaskbot-e4a0be5d55f15c044dff8fa285da4fbe8ae878a3.tar.gz
askbot-e4a0be5d55f15c044dff8fa285da4fbe8ae878a3.tar.bz2
askbot-e4a0be5d55f15c044dff8fa285da4fbe8ae878a3.zip
Merge branch 'master' of github.com:ASKBOT/askbot-devel into solrmultilang
-rw-r--r--askbot/doc/source/changelog.rst1
-rw-r--r--askbot/importers/zendesk/management/commands/import_zendesk.py1036
-rw-r--r--askbot/importers/zendesk/models.py190
-rw-r--r--askbot/media/jquery-openid/openid.css7
-rw-r--r--askbot/media/style/style.css49
-rw-r--r--askbot/media/style/style.less54
-rw-r--r--askbot/templates/authopenid/signin.html5
-rw-r--r--askbot/templates/meta/bottom_scripts.html6
-rw-r--r--askbot/templates/question/answer_vote_buttons.html1
-rw-r--r--askbot/templates/user_profile/macros.html9
-rw-r--r--askbot/templates/user_profile/user_info.html238
-rw-r--r--askbot/templates/user_profile/user_stats.html119
-rw-r--r--askbot/templates/widgets/secondary_header.html2
-rw-r--r--askbot/utils/console.py96
14 files changed, 1455 insertions, 358 deletions
diff --git a/askbot/doc/source/changelog.rst b/askbot/doc/source/changelog.rst
index 2e300278..7cdecfd5 100644
--- a/askbot/doc/source/changelog.rst
+++ b/askbot/doc/source/changelog.rst
@@ -3,6 +3,7 @@ Changes in Askbot
Development version
-------------------
+* Improved Zendesk import feature
* Added backend support for the tag synonyms
* Added management command `apply_hinted_tags` to batch-apply tags from a list
* Added hovercard on the user's karma display in the header
diff --git a/askbot/importers/zendesk/management/commands/import_zendesk.py b/askbot/importers/zendesk/management/commands/import_zendesk.py
index 2614a51c..7472f4f7 100644
--- a/askbot/importers/zendesk/management/commands/import_zendesk.py
+++ b/askbot/importers/zendesk/management/commands/import_zendesk.py
@@ -1,10 +1,75 @@
-"""importer from zendesk data dump
-the dump must be a tar/gzipped file, containing one directory
-with all the .xml files.
+"""
+Zendesk XML data import
+
+This script will import a tar/gzipped file generated by Zendesk into Askbot.
+For more info see https://support.zendesk.com/entries/23002207 (use XML
+instead of CSV)
+
+The tgz archive must contain a single directory with the following xml
+files:
+ accounts.xml (ignored)
+ categories.xml (ignored)
+ entries.xml
+ forums.xml
+ groups.xml (ignored)
+ organizations.xml
+ posts.xml
+ tickets.xml
+ users.xml
+
+You have the ability to filter choose whether to import forums, tickets, or
+both. Additionally, you can specify whether you wish to filter the content
+further by specific forums, tags, and date.
+
+FORUMS:
+ Importing forums will give you a list of your public forums to choose from.
+ Private forums will not be shown by default. Once you choose the forums
+ you wish to import, you can filter down the entries by tags (any entry
+ with any matching tag will be imported), and by date range (of created_at
+ datetime).
+
+ Votes on Entries will be converted to Votes for the question in Askbot.
+ View counts are transferred as well. If a Post is marked as is_informative,
+ it will mark the answer as accepted. Note that since Zendesk supports
+ multiple "accepted" answers and Askbot does not, this will cause the
+ accepted answer in Askbot to be the most recent accepted Post.
+
+TICKETS:
+ Importing Tickets will give you an option to filter down the entries by
+ tags (any Ticket with any matching tag will be imported), and by date range
+ (of created_at datetime).
+
+ Tickets don't have any view count or vote stats so none of that info is
+ transferred. Additionally, there's no easy way to determine which comment
+ on the Ticket may be the accepted answer so the script doesn't mark any
+ answer as accepted.
+
+ Private comments are not imported.
+
+REQUIREMENTS:
+ This script requires the lxml module which is not part of the base
+ Askbot install. The lxml module will require your server have the
+ libxml2-devel and libxslt-devel packages installed in order to
+ install correctly.
+
+NOTES:
+ Running this import will truncate the existing zendesk_* tables in order
+ to ensure you don't end up re-importing existing data.
+
+ If your site is configured to only allow a single answer per user
+ (LIMIT_ONE_ANSWER_PER_USER = True), you will be prompted to disable this
+ setting temporarily while the import proceeds. It will turn it back on
+ when complete. If you choose not to disable this setting, then the import
+ will add any additional answers from a user as comments on their original
+ answer. This is not ideal so it's encouraged you agree to turn this
+ setting off while doing the import.
Run this command as::
+ python manage.py import_zendesk /path/to/zendesk/archive.tgz
- python manage.py import_zendesk path/to/dump.tgz
+TODO:
+ - Use logging for more verbose output
+ - Add option to import Attachments from existing Zendesk installation
"""
import os
import re
@@ -12,97 +77,310 @@ import sys
import tarfile
import tempfile
from datetime import datetime, date
+from lxml import etree
from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
from django.db import transaction
-from lxml import etree
+from django.db import connection
from askbot import models as askbot_models
from askbot.utils import console
from askbot.utils.html import unescape
-
+from askbot import exceptions as askbot_exceptions
+from askbot.conf import settings as askbot_settings
from askbot.importers.zendesk import models as zendesk_models
-#a hack, did not know how to parse timezone offset
+# a hack, did not know how to parse timezone offset
+# todo: clean this up
ZERO_TIME = datetime.strptime('00:00', '%H:%M')
+# load admin user where a user is needed (eg. user who closed thread)
+ADMIN_USER = askbot_models.User.objects.filter(is_superuser=True)[:1]
+# option choices for what data to import from Zendesk
+DATA_IMPORT_ALL = 0
+DATA_IMPORT_FORUMS = 1
+DATA_IMPORT_TICKETS = 2
+
+# used for seeding Vote count when importing Zendesk Forum content
+try:
+ PHANTOM_VOTER_USER = askbot_models.User.objects.get(username='phantom_voter')
+except askbot_models.User.DoesNotExist:
+ PHANTOM_VOTER_USER = askbot_models.User(
+ username = 'phantom_voter',
+ first_name = 'Phantom',
+ last_name = 'Voter',
+ real_name = 'Phantom Voter',
+ date_joined = datetime.now(),
+ is_active = False,
+ about = 'Fake account for seeding vote counts during Zendesk import',
+ ).save()
+
+def ensure_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.
+
+ :param name_seed: (str) proposed user name
-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
+ :returns: (str) validated unique user name
"""
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)
+ name_seed = original_name[:29] + 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
+ """Create askbot user from Zendesk User record
+ Zendesk User fields that are copied over or otherwise translated:
+ name
+ username
+ email
+ Organization name matching organization_id
+ is_verified
+ is_active
+ last_login
+ created_at
+
+ :param zd_user: (obj) zendesk_models.User object to create Askbot user
+ from.
+
+ :returns: (mixed) askbot user object or None if there is an error
+ """
if zd_user.email is None:
+ username = zd_user.name.replace(" ", "_").lower()
email = ''
else:
+ username = zd_user.email
email = zd_user.email
+ username = ensure_unique_username(username[:30])
+
+ # last_seen cannot be null
+ last_seen = zd_user.last_login
+ if not last_seen:
+ last_seen = zd_user.created_at
+
+ # lookup organization name (todo: cache this)
+ about = ""
+ if zd_user.organization_id:
+ try:
+ org = zendesk_models.Organization.objects.get(organization_id=zd_user.organization_id)
+ about = org.name
+ except zendesk_models.Organization.DoesNotExist:
+ pass
ab_user = askbot_models.User(
+ username = username,
+ first_name = zd_user.name.rpartition(' ')[0].strip()[:30],
+ last_name = zd_user.name.rpartition(' ')[2].strip()[:30],
+ real_name = zd_user.name[:100],
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
+ last_seen = last_seen,
+ is_active = zd_user.is_active,
+ about = about,
)
ab_user.save()
return ab_user
-def post_question(zendesk_post):
- """posts question to askbot, using zendesk post item"""
+def seed_post_with_votes(post, votes_count):
+ """Seed imported Question with an initial vote count
+
+ Votes are set in multple locations for caching. points = (vote_up_count -
+ vote_down_count). Since we're creating the post now and Zendesk doesn't
+ have down votes, we just calculate this as up votes.
+
+ Vote objects require a user. We have created an inactive PHANTOM_VOTER_USER
+ above to artificially serve this purpose.
+
+ NOTE: Vote objects are indended to be +1 (VOTE_UP) or -1 (VOTE_DOWN).
+ We're overriding this by adding a Vote object that is +votes_count to
+ create a weighted artificial Vote. This may cause problems if the votes
+ are recalculated for some reason later.
+
+ :param post: (obj) the askbot.models.Post object to seed with the votes
+ :param votes_count: (int) number of votes to seed the Post with
+ """
+ post.points = votes_count
+ post.vote_up_count = votes_count
+ post.save()
+ post.thread.points = votes_count
+ post.thread.save()
+ askbot_models.Vote(user=PHANTOM_VOTER_USER, voted_post=post,
+ vote=votes_count, voted_at=datetime.now()).save()
+
+def post_question(zendesk_entry):
+ """Posts question to askbot from Zendesk Entry
+
+ Translates Zendesk Entry to an Askbot question. Links correct user,
+ updates the view count and vote count. Closes the question if the
+ Entry is locked.
+
+ :param zendesk_entry: (obj) zendesk_models.Entry object
+
+ :returns: (obj) askbot Post object if it succeeded. None if there was
+ an error.
+ """
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
+ askbot_post = zendesk_entry.get_author().post_question(
+ title = zendesk_entry.title,
+ body_text = zendesk_entry.get_body_text(),
+ tags = zendesk_entry.get_tag_names(),
+ timestamp = zendesk_entry.created_at,
)
+ # seed the views with the # hits we had on zendesk
+ askbot_post.thread.increase_view_count(increment=zendesk_entry.hits)
+ if zendesk_entry.votes_count:
+ seed_post_with_votes(askbot_post, zendesk_entry.votes_count)
+
+ # close threads that were locked in Zendesk and assign a default
+ # reason of "question answered". Set default user to admin.
+ if zendesk_entry.is_locked:
+ askbot_post.thread.set_closed_status(
+ closed=True,
+ closed_by=ADMIN_USER,
+ closed_at=datetime.now(),
+ close_reason=5)
+ askbot_post.thread.save()
+ return askbot_post
except Exception, e:
msg = unicode(e)
- print "Warning: post %d dropped: %s" % (zendesk_post.post_id, msg)
+ print "Warning: entry %d skipped: %s" % (zendesk_entry.entry_id, msg)
+
+def post_question_from_ticket(zendesk_ticket):
+ """Posts question to Askbot from Zendesk Ticket
-def post_answer(zendesk_post, question = None):
+ Translates Zendesk Ticket to an Askbot question. View count and votes
+ aren't relevant on Tickets in Zendesk so we don't seed any of that info
+ (like we do on post_question()).
+
+ :param zendesk_ticket: (obj) zendesk_models.Ticket object
+
+ :returns: (obj) askbot Post object if it succeeded. None if there was
+ an error.
+
+ :todo: wrap this into post_question()
+ """
try:
- zendesk_post.get_author().post_answer(
+ askbot_post = zendesk_ticket.get_author().post_question(
+ title = zendesk_ticket.subject,
+ body_text = zendesk_ticket.get_body_text(),
+ tags = zendesk_ticket.get_tag_names(),
+ timestamp = zendesk_ticket.created_at
+ )
+ return askbot_post
+ except Exception, e:
+ msg = unicode(e)
+ print "Warning: ticket %d skipped: %s" % (zendesk_ticket.ticket_id, msg)
+
+def post_comment(source_post, parent):
+ """Post comment on an answer from a Zendesk Post or Comment.
+
+ :param source_post: (obj) A zendesk_models.Post or zendesk_models.Comment
+ object
+ :param parent: (obj) Askbot Post object which will be the parent of the
+ comment
+
+ :returns: (obj) Askbot Post object with post_type='comment' or None if
+ there was an error.
+ """
+ try:
+ askbot_comment = source_post.get_author().post_comment(
+ parent_post = parent,
+ body_text = source_post.get_body_text(),
+ timestamp = source_post.created_at
+ )
+ return askbot_comment
+ except Exception, e:
+ msg = unicode(e)
+ print "Warning: post %d skipped: %s" % (zendesk_post.post_id, msg)
+
+def post_answer(zendesk_post, question):
+ """Posts an answer to Askbot, from a Zendesk Post
+
+ If the Post was marked as informative in Zendesk, we mark it as an accepted
+ answer in Askbot. Since Askbot only allows a single accepted answer and
+ Zendesk supports multiple answers, this will re-mark the answer
+ for each one and ultimately end on the most recent post. This may not be
+ the most relevant answer in the end.
+
+ If Askbot is configured to only allow a single answer per user, any
+ additional answers from the user will be added as comments on the original
+ answer from the user. This will likely create some context confusion so
+ it's recommended you have this setting during for the import.
+
+ :param zendesk_post: (obj) zendesk_models.Post object to create answer from
+ :param question: (obj) Askbot Post object with post_type='question' to post
+ the answer to.
+
+ :returns: (obj) Askbot Post object with post_type='answer' or 'comment'
+ depending on the setting for LIMIT_ONE_ANSWER_PER_USER or None if
+ there was an error.
+ """
+ try:
+ askbot_post = zendesk_post.get_author().post_answer(
question = question,
body_text = zendesk_post.get_body_text(),
timestamp = zendesk_post.created_at
)
+ if zendesk_post.is_informative:
+ askbot_post.thread.accepted_answer_id = askbot_post.id
+ askbot_post.thread.save()
+ return askbot_post
+ except askbot_exceptions.AnswerAlreadyGiven:
+ answer = question.thread.get_answers_by_user(user=zendesk_post.get_author())[0]
+ askbot_comment = post_comment(zendesk_post, answer)
+ return askbot_comment
+ except Exception, e:
+ msg = unicode(e)
+ print "Warning: post %d skipped: %s" % (zendesk_post.post_id, msg)
+
+def post_answer_from_comment(zendesk_comment, question):
+ """Posts an answer to Askbot, from Zendesk Comment on a ticket
+
+ If Askbot is configured to only allow a single answer per user, any
+ additional answers from the user will be added as comments on the original
+ answer from the user. This will likely create some context confusion so
+ it's recommended you have this setting during for the import.
+
+ There is no reliable way to know which comment is the "accepted" answer
+ so we don't try and set that automatically.
+
+ :param zendesk_comment: (obj) zendesk_models.Comment object to create
+ answer from.
+ :param question: (obj) Askbot Post object with post_type='question' to post
+ the answer to.
+ """
+ if not zendesk_comment.is_public:
+ return
+ try:
+ askbot_post = zendesk_comment.get_author().post_answer(
+ question = question,
+ body_text = zendesk_comment.get_body_text(),
+ timestamp = zendesk_comment.created_at
+ )
+ return askbot_post
+ except askbot_exceptions.AnswerAlreadyGiven:
+ answer = question.thread.get_answers_by_user(user=zendesk_comment.get_author())[0]
+ askbot_comment = post_comment(zendesk_comment, answer)
+ return askbot_comment
except Exception, e:
msg = unicode(e)
- print "Warning: post %d dropped: %s" % (zendesk_post.post_id, msg)
+ print "Warning: comment %d skipped: %s" % (zendesk_comment.id, msg)
+
+def get_xml_element_val(elem, field_name):
+ """Return the value of the etree element for field_name and cast it to the
+ correct data type.
+
+ :param elem: (obj) etree element object to search
+ :param field_name: (str) field name to search for in elem
-def get_val(elem, field_name):
+ :returns: (mixed) value of field_name in etree object cast to the correct
+ native Python data type
+ """
field = elem.find(field_name)
if field is None:
return None
@@ -113,7 +391,6 @@ def get_val(elem, field_name):
raw_val = field.text
if raw_val is None:
return None
-
if field_type == 'boolean':
if raw_val == 'true':
return True
@@ -126,6 +403,7 @@ def get_val(elem, field_name):
elif field_type == 'datetime':
if raw_val is None:
return None
+ # todo: clean this up
raw_datetime = raw_val[:19]
tzoffset_sign = raw_val[19]
raw_tzoffset = raw_val[20:]
@@ -139,29 +417,252 @@ def get_val(elem, field_name):
return dt + tzoffset
else:
return None
+ elif field_type == 'array':
+ # returns a list of child elements
+ # comments > comment
+ sfield_name = field_name[:-1]
+ return field.findall(sfield_name)
else:
return raw_val
+def toggle_user_answer_limit_setting(val):
+ """Turns the Askbot live_setting for LIMIT_ONE_ANSWER_PER_USER on
+ or off.
+
+ :param val: (bool) value to set LIMIT_ONE_ANSWER_PER_USER to
+ """
+ if val:
+ askbot_settings.update('LIMIT_ONE_ANSWER_PER_USER', True)
+ else:
+ askbot_settings.update('LIMIT_ONE_ANSWER_PER_USER', False)
+ print "set LIMIT_ONE_ANSWER_PER_USER to %s" % val
+
+def check_user_answer_limit():
+ """Checks if LIMIT_ONE_ANSWER_PER_USER is True, if so, warn the user
+ and give them an option to turn it off temporarily for the import.
+
+ The import is really messy if we don't allow multiple answers for a user
+ when translating from Zendesk to Askbot. If the user opts to turn this
+ off at the beginning of the import it will be turned on automatically when
+ we're done.
+ """
+ if not askbot_settings.LIMIT_ONE_ANSWER_PER_USER:
+ return
+ else:
+ print
+ print "*"*64
+ print "* WARNING"
+ print "*"*64
+ print "* Your settings are currently limiting users to a single"
+ print "* answer per question. Zendesk doesn't translate well to"
+ print "* this. It's highly recommended you let us switch this"
+ print "* off temporarily while the import proceeds. We'll switch"
+ print "* it back on when we're done."
+ print "*"
+ print "* If you choose not to do this, each additional post on"
+ print "* a forum topic or additional comment on a ticket will be"
+ print "* appended as a comment on to the first answer by the user."
+ print "*" * 64
+ prompt = "Okay to turn off the LIMIT_ONE_ANSWER_PER_USER setting?"
+ response = console.get_yes_or_no(prompt, 'yes')
+ if response == 'yes':
+ toggle_user_answer_limit_setting(False)
+ print
+ return True
+ print
+
class Command(BaseCommand):
def handle(self, *args, **kwargs):
+ """Base handler for command run from command line
+
+ Walks the user through the complete import process.
+
+ Checks the LIMIT_ONE_ANSWER_PER_USER and prompts the user to turn it
+ OFF if it is currently enabled since imported data will make more sense
+ with it off temporarily. If it is on prior to the import and they
+ agree to turn it off, the importer will turn it back on when the import
+ is complete.
+
+ Prompts user to choose whether they would like to import Forums &
+ Tickets, Forums only, or Tickets only. Users are automatically imported
+ as they are required to link new Posts to. Organizations are only used
+ for looking up the organization name to add the user's profile.
+
+ All content of the required xml files is loaded into separate Zendesk
+ model tables as an intermediate step.
+
+ When importing Forums, the user is given the choice to choose which
+ Forums they would like to import content from.
+
+ Tag Filters
+ The user is able to specify tags to filter on (importing any Forum or
+ Ticket entries matching ANY of the tags specified).
+
+ Date Filters
+ The user is able to specify a date range for filtering Forum Posts by
+ date created.
+
+ These options allow some simple control over potentially large amounts
+ of data, weeding out noise and otherwise outdated or irrelevant
+ content.
+
+ :param args: (mixed) positional arguments to command. We require
+ a single str argument here as the full path to the Zendesk tgz archive
+ containing the xml files.
+ """
if len(args) != 1:
- raise CommandError('please provide path to tarred and gzipped cnprog dump')
+ raise CommandError('Please provide the path to the Zendesk tgz archive.')
self.tar = tarfile.open(args[0], 'r:gz')
- sys.stdout.write('Reading users.xml: ')
+ # ask what data we are importing
+ print
+ print
+ print "-"*64
+ print "This script will attempt to import your Zendesk data into"
+ print "Askbot. If you are importing into an existing installation,"
+ print "** backup your database before continuing **!"
+ print "-"*64
+ print "You will have a chance to decide if you want to import"
+ print "tickets, forums, or both. Additional options are presented"
+ print "to filter the imported content by forum, tag, and date."
+ print "Users are always imported."
+ print "-"*64
+
+ user_answer_limit_reset = check_user_answer_limit()
+ choices = ['Forums and Tickets', 'Forums Only', 'Tickets Only']
+ prompt = "What data do you wish to import from Zendesk?"
+ data_choice = console.numeric_choice_dialog(prompt, choices=choices)
+ print
+
+ # read relevant data into temporary tables. We read everything and then
+ # filter when we actually import into the Askbot tablespace
+ sys.stdout.write("Reading organizations.xml... ")
+ self.read_organizations()
+ 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()
+ if data_choice in [DATA_IMPORT_ALL, DATA_IMPORT_FORUMS]:
+ sys.stdout.write("Reading forums.xml... ")
+ self.read_forums()
+ sys.stdout.write("Reading entries.xml... ")
+ self.read_entries()
+ sys.stdout.write("Reading posts.xml... ")
+ self.read_posts()
+ if data_choice in [DATA_IMPORT_ALL, DATA_IMPORT_TICKETS]:
+ sys.stdout.write("Reading tickets.xml... ")
+ self.read_tickets()
+
+ # forums choices
+ # ---------------------------------------------------------------------
+ print
+ if data_choice in [DATA_IMPORT_ALL, DATA_IMPORT_FORUMS]:
+ print "="*64
+ print " FORUMS"
+ print "="*64
+ forum_choices = self.prompt_for_forums()
+ forum_tag_choices = self.prompt_for_tags()
+ (forum_date_filter) = self.prompt_for_date()
+ # tickets choices
+ # ---------------------------------------------------------------------
+ if data_choice in [DATA_IMPORT_ALL, DATA_IMPORT_TICKETS]:
+ print
+ print "="*64
+ print " TICKETS"
+ print "="*64
+ ticket_tag_choices = self.prompt_for_tags()
+ (ticket_date_filter) = self.prompt_for_date()
+
+ # import data
+ # ---------------------------------------------------------------------
sys.stdout.write("Importing user accounts: ")
self.import_users()
- sys.stdout.write("Loading threads: ")
- self.import_content()
+ if data_choice in [DATA_IMPORT_ALL, DATA_IMPORT_FORUMS]:
+ self.import_forums(forum_choices, forum_tag_choices, forum_date_filter)
+ if data_choice in [DATA_IMPORT_ALL, DATA_IMPORT_TICKETS]:
+ self.import_tickets(tags=ticket_tag_choices, date_filter=ticket_date_filter)
+
+ # cleaning up
+ # ---------------------------------------------------------------------
+ if user_answer_limit_reset:
+ toggle_user_answer_limit_setting(True)
+ print
+ print "Done!"
+ print
+
+
+ def prompt_for_forums(self):
+ """Prompt user to select the forums they'd like to import or choose all
+ of them
+
+ :returns: (list) zendesk_models.Forum objects selected by user
+ """
+ # special case for console.numeric_multiple_choice_dialog 0 = all
+ ALL_FORUMS = 0
+ public_forums = zendesk_models.Forum.objects.filter(is_public='t').order_by('forum_id')
+ choices = [f.name for f in public_forums]
+ prompt = "Which forums do you want to import (separate multiple choices by a space)?"
+ numeric_choices = console.numeric_multiple_choice_dialog(prompt, choices=choices, all_option=True)
+ if ALL_FORUMS in numeric_choices:
+ return public_forums
+ return [public_forums[f-1] for f in numeric_choices]
+
+ def prompt_for_tags(self):
+ """Prompt user for a space-separated list of tags to filter imported
+ objects by.
+
+ Tags are case-insensitive for the import and everything is forced to
+ lowercase.
+
+ :returns: (list) tags specified by user
+ """
+ prompt = "Enter tags separated by spaces to filter by (leave blank for all):"
+ tags = console.simple_dialog(prompt, required=False)
+ return tags.split()
+
+ def prompt_for_date(self):
+ """Prompt user for start and end dates in YYYY-MM-DD format for
+ filtering imported objects by a date range of when they were created.
+
+ :returns: (tuple) 2 datetime objects representing the start date and
+ end date respectively to filter by. Either or both of the elements can
+ also be None indicating no filter is required for that boundary.
+ """
+ start_date = False
+ end_date = False
+ while not start_date:
+ prompt = "Enter earliest date (yyyy-mm-dd) to import content from (leave blank for all):"
+ start = console.simple_dialog(prompt, required=False).strip()
+ if start:
+ try:
+ start_date = datetime.strptime(start,"%Y-%m-%d")
+ except ValueError:
+ print
+ print "*** Please enter a date in the format YYYY-MM-DD or leave it blank ***"
+ else:
+ start_date = None
+ break
+ while not end_date:
+ prompt = "Enter latest date (yyyy-mm-dd) to import content from (leave blank for all):"
+ end = console.simple_dialog(prompt, required=False).strip()
+ if end:
+ try:
+ end_date = datetime.strptime(end,"%Y-%m-%d")
+ except ValueError:
+ print
+ print "*** Please enter a date in the format YYYY-MM-DD or leave it blank ***"
+ else:
+ end_date = None
+ break
+ return (start_date, end_date)
def get_file(self, file_name):
+ """Opens file and reads in xml data
+
+ :param file_name: (str) full path to Zendesk export tgz file
+
+ :returns: (obj) etree object for traversing xml element tree
+ """
first_item = self.tar.getnames()[0]
file_path = file_name
if not first_item.endswith('.xml'):
@@ -171,29 +672,52 @@ class Command(BaseCommand):
xml_file = self.tar.extractfile(file_info)
return etree.parse(xml_file)
- @transaction.commit_manually
+ @transaction.autocommit
def read_xml_file(self,
file_name = None,
entry_name = None,
model = None,
fields = None,
- extra_field_mappings = None
+ extra_field_mappings = None,
+ sub_entities = []
):
+ """Reads xml file, parses entries into Zendesk model objects, and saves
+ them to the database.
+
+ Values are cast to their correct data types.
+
+ Sub-entities are used for extracting an embedded structure from the
+ element tree into a separate model and table.
+
+ :param file_name: (str) name of xml file,
+ :param entry_name: (str) name of entries to read from the xml file
+ :param model: (obj) model where data will be stored
+ :param fields: (list) field names (str) in xml that will be translated
+ to model fields by simple substitiution of '_' for '-'
+ :param extra_field_mappings (tuple) list of two tuples for xml field
+ names have specific translation that doesn't follow the standard for
+ the fields parameter
+ :param sub_entities: (list) of dicts describing fields that should be
+ treated as separate models (like Ticket.comments). The structure is
+ similar to this method. Each dict key is the field name to be treated
+ as a sub-entity. The value is a tuple with (model, [sub-entity fields],
+ (sub-entity extra_field_mappings)).
+ [{'comments': (
+ zendesk_models.Comment,
+ ['author-id', 'created-at', 'is-public', 'type',
+ 'value', 'via-id', 'ticket-id'],
+ (),)
+ }]
+ todo: support blank values vs. nulls for strings
"""
- * 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
- """
+ cursor = connection.cursor()
+ cursor.execute('TRUNCATE TABLE "{0}" CASCADE'.format(model._meta.db_table))
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)
+ value = get_xml_element_val(xml_entry, field)
model_field_name = field.replace('-', '_')
max_length = instance._meta.get_field(model_field_name).max_length
if value and max_length:
@@ -201,16 +725,55 @@ class Command(BaseCommand):
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)
+ value = get_xml_element_val(xml_entry, field)
setattr(instance, model_field_name, value)
+
+ sub_instances = []
+ for sub_entity in sub_entities:
+ for sub_field_name, sub_def in sub_entity.iteritems():
+ sub_list = get_xml_element_val(xml_entry, sub_field_name)
+ sub_model, sub_fields, sub_extra_field_mappings = sub_def
+ for child in sub_list:
+ sub_instance = sub_model()
+ for sub_field in sub_fields:
+ sub_value = get_xml_element_val(child, sub_field)
+ sub_model_field_name = sub_field.replace('-', '_')
+ sub_max_length = sub_instance._meta.get_field(sub_model_field_name).max_length
+ if sub_value and sub_max_length:
+ sub_value = sub_value[:sub_max_length]
+ setattr(sub_instance, sub_model_field_name, sub_value)
+ sub_instances.append(sub_instance)
+
instance.save()
- transaction.commit()
+ for si in sub_instances:
+ # set the parent id
+ setattr(si, "%s_id" % entry_name, instance.id)
+ si.save()
items_saved += 1
- console.print_action('%d items' % items_saved)
- console.print_action('%d items' % items_saved, nowipe = True)
+ console.print_action('%d' % items_saved)
+ console.print_action('%d total' % items_saved, nowipe = True)
+ def read_organizations(self):
+ """Read Zendesk Organizations from xml file and save them as Zendesk
+ models
+ """
+ self.read_xml_file(
+ file_name = 'organizations.xml',
+ entry_name = 'organization',
+ model = zendesk_models.Organization,
+ fields = (
+ 'created-at', 'default', 'details', 'external-id', 'group-id',
+ 'is-shared', 'is-shared-comments', 'name',
+ 'notes', 'suspended', 'updated-at'
+ ),
+ extra_field_mappings = (('id', 'organization_id'),)
+ )
+
def read_users(self):
+ """Read Zendesk Users from xml file and save them as Zendesk
+ models
+ """
self.read_xml_file(
file_name = 'users.xml',
entry_name = 'user',
@@ -221,10 +784,38 @@ class Command(BaseCommand):
'roles', 'time-zone', 'updated-at', 'uses-12-hour-clock',
'email', 'is-verified', 'photo-url'
),
- extra_field_mappings = (('id', 'user_id'),)
+ extra_field_mappings = (('id', 'zendesk_user_id'),)
+ )
+
+ def read_entries(self):
+ """Read Zendesk Entries from xml file and save them as Zendesk
+ models.
+
+ Entries in Zendesk are top-level posts in a forum.
+ """
+ self.read_xml_file(
+ file_name = 'entries.xml',
+ entry_name = 'entry',
+ model = zendesk_models.Entry,
+ fields = (
+ 'body', 'created-at', 'flag-type-id', 'forum-id',
+ 'hits', 'entry-id', 'is-highlighted', 'is-locked', 'is-pinned',
+ 'is-public', 'organization-id', 'position', 'posts-count',
+ 'submitter-id', 'title', 'updated-at', 'votes-count'
+ ),
+ extra_field_mappings = (
+ ('id', 'entry_id'),
+ ('current-tags', 'tags'),
+ )
)
def read_posts(self):
+ """Read Zendesk Posts from xml file and save them as Zendesk
+ models.
+
+ Posts in Zendesk are children of Entries. They are like replies
+ on a top-level forum post.
+ """
self.read_xml_file(
file_name = 'posts.xml',
entry_name = 'post',
@@ -239,6 +830,24 @@ class Command(BaseCommand):
)
def read_forums(self):
+ """Read Zendesk Forums from xml file and save them as Zendesk
+ models.
+
+ Forums in Zendesk are category groupings for forum posts. They
+ do not have any "posts" themselves, but have Entries. Entries
+ then have Posts:
+ - Forum
+ - Entry
+ - Post
+ - Post
+ - Entry
+ - Post
+ - Forum
+ - Entry
+ - Post
+ ...
+ ...
+ """
self.read_xml_file(
file_name = 'forums.xml',
entry_name = 'forum',
@@ -256,36 +865,75 @@ class Command(BaseCommand):
extra_field_mappings = (('id', 'forum_id'),)
)
- @transaction.commit_manually
+ def read_tickets(self):
+ """Read Zendesk Tickets from xml file and save them as Zendesk
+ models.
+
+ This is a little more complex in that we want to read the Comments
+ as well which are child elements on the ticket. We define this with the
+ sub_entities parameter.
+ """
+ self.read_xml_file(
+ file_name = 'tickets.xml',
+ entry_name = 'ticket',
+ model = zendesk_models.Ticket,
+ fields = (
+ 'assigned-at', 'assignee-id', 'base-score', 'created-at',
+ 'current-collaborators','current-tags','description',
+ 'due-date', 'entry-id', 'external-id', 'group-id',
+ 'initially-assigned-at', 'latest-recipients',
+ 'organization-id', 'original-recipient-address', 'priority-id',
+ 'recipient', 'requester-id', 'resolution-time', 'solved-at',
+ 'status-id', 'status-updated-at', 'subject', 'submitter-id',
+ 'ticket-type-id', 'updated-at', 'updated-by-type-id', 'via-id',
+ 'score', 'problem-id', 'has-incidents'
+ ),
+ extra_field_mappings = (('nice-id', 'ticket_id'),),
+ sub_entities = [
+ {'comments': (
+ zendesk_models.Comment,
+ ['author-id', 'created-at', 'is-public', 'type', 'value',
+ 'via-id', 'ticket-id'],
+ None
+ )
+ }
+ ]
+ )
+
+ @transaction.autocommit
def import_users(self):
+ """Creates new Askbot users for each zendesk_models.User.
+
+ For each Zendesk user, see if there are any matching Askbot users
+ with the same email. If not, create a new Askbot user and copy
+ over any openauth id info as well.
+
+ See create_askbot_user() for a full list of fields that are copied over
+ from Zendesk.
+ """
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 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
+ # todo: check for failure?
+ if ab_user is None:
continue
+ added_users += 1
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
+ # create new user if no matching user email was found
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
+ console.print_action("%d %s" % (added_users, ab_user.username))
zd_user.askbot_user_id = ab_user.id
zd_user.save()
-
+ # save open auth info as well.
if zd_user.openid_url != None and \
'askbot.deps.django_authopenid' in settings.INSTALLED_APPS:
from askbot.deps.django_authopenid.models import UserAssociation
@@ -298,35 +946,197 @@ class Command(BaseCommand):
)
assoc.save()
except:
- #drop user association
+ # unsupported provider
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)
+ @transaction.autocommit
+ def _import_posts(self, question, entry):
+ """Create Askbot answers from Zendesk Entries.
+
+ :param question: (obj) Askbot Post object with post_type='question'
+ :param entry: (obj) Zendesk Entry object
+ """
+ for post in zendesk_models.Post.objects.filter(
+ entry_id=entry.entry_id
+ ).order_by('created_at'):
+ # create answers
+ answer = post_answer(post, question=question)
+ if not answer:
+ continue
+ post.ab_id = answer.id
+ post.save()
+
+ @transaction.autocommit
+ def _import_entry(self, entry):
+ """Create an Askbot question and answers from a Zendesk Entry
+
+ :param entry: (obj) Zendesk Entry object
+
+ :returns: (bool) True if Entry (and Posts linked to the Entry) were
+ posted successfully. False if not.
+ """
+ question = post_question(entry)
+ if not question:
+ return
+ entry.ab_id = question.id
+ entry.save()
+ self._import_posts(question, entry)
+ return True
+
+ def import_forums(self, forums, tags, date_filter):
+ """Import Zendesk forums into Askbot. Create questions from Zendesk
+ Entries and answers from Zendesk Posts.
+
+ :param forums: (list) zendesk_models.Forum objects to import
+ :param tags: (list) tags (str) to filter Zendesk Forum Entries by.
+ Entries that match ANY of the tags will be posted as questions. Tags
+ are case-insensitive in this import regardless of your settings in
+ Askbot.
+ :param date_filter: (tuple) two-element tuple representing the start
+ date and end date to filter Zendesk Forum Entries by date range. The
+ tuple values are datetime objects or None.
+ """
+ if tags:
+ print "Filtering forum posts by tags: %s" % tags
+ if date_filter:
+ print "Filtering forum post by dates between %s and %s" % (date_filter[0], date_filter[1])
+ print "Importing forums... "
+ print "="*64
+ for forum in forums:
+ thread_count = 0
+ # don't import private forums, forums restricted to organizations
+ # or forums that require login (comment this out if you don't care,
+ # or modify the viewable_to_public() method for zendesk_models.Forum)
+ if not forum.viewable_to_public():
+ console.print_action("Skipping private forum \"%s\"" % forum.name,
+ nowipe = True)
+ continue
+ sys.stdout.write("[#%d] %s: " % (forum.forum_id, forum.name))
+ for entry in zendesk_models.Entry.objects.filter(forum_id=forum.forum_id):
+ # filters
+ # if provided, only post entries matching ANY of the tags
+ if not self._matches_tag_filter(entry.tags, tags):
+ continue
+ if not self._matches_date_filter(entry.created_at, date_filter):
+ continue
+ if self._import_entry(entry):
+ thread_count += 1
+ console.print_action("%d threads" % thread_count)
+ console.print_action("%d total threads" % thread_count, nowipe = True)
+
+ @transaction.autocommit
+ def _import_comments(self, question, ticket):
+ """Import Zendesk Ticket Comments into Askbot as answers.
+
+ :param question: (obj) askbot Post object with post_type='question' to
+ create the answers for.
+ :param ticket: (obj) zendesk_models.Ticket object to pull the comments
+ from for creating answers.
+ """
+ first = True
+ i=0
+ for comment in zendesk_models.Comment.objects.filter(
+ ticket_id=ticket.ticket_id, is_public=True
+ ).order_by('created_at'):
+ # create answers, first comment is a copy of the one on the ticket
+ if first:
+ first = False
+ continue
+ i+=1
+ answer = post_answer_from_comment(comment, question=question)
+ if not answer:
+ continue
+ comment.ab_id = answer.id
+ comment.save()
+
+ @transaction.autocommit
+ def import_tickets(self, tags, date_filter):
+ """Import Zendesk Tickets into Askbot as questions.
+
+ :param tags: (list) tags (str) to filter Zendesk Tickets by.
+ Tickets that match ANY of the tags will be posted as questions. Tags
+ are case-insensitive in this import regardless of your settings in
+ Askbot.
+ :param date_filter: (tuple) two-element tuple representing the start
+ date and end date to filter Zendesk Tickets by date range. The
+ tuple values are datetime objects or None. The date_filter is matched
+ against Ticket.created_at.
+ """
+ # todo: optimmize with smart query
+ # Ticket.objects.get(
+ # Q(created_at__gt=date_filter[0]),
+ # Q(created_at__lt=date_filter[1]),
+ # Q(tags__icontains='foo') | Q(tags__icontains='bar')
+ # )
+ if tags:
+ print "Filtering tickets by tags: %s" % tags
+ if date_filter:
+ print "Filtering tickets by dates between %s and %s" % (date_filter[0], date_filter[1])
+ sys.stdout.write("Importing tickets: ")
+ ticket_count = 0
+ for ticket in zendesk_models.Ticket.objects.all():
+ # filters
+ # if provided, only post entries matching ANY of the tags
+ if not self._matches_tag_filter(ticket.current_tags, tags):
+ continue
+ if not self._matches_date_filter(ticket.created_at, date_filter):
+ continue
+ question = post_question_from_ticket(ticket)
+ if not question:
+ continue
+ ticket.ab_id = question.id
+ ticket.save()
+ self._import_comments(question, ticket)
+ ticket_count += 1
+ console.print_action("%d tickets" % ticket_count)
+ console.print_action("%d total tickets" % ticket_count, nowipe = True)
+
+ def _matches_tag_filter(self, item_tags, tag_filter):
+ """Determine if an item's tags satisfy the tag filter. The comparison
+ is case-insensitive.
+
+ :param item_tags: (str) space-separated string of tags associated with
+ the item.
+
+ :param filter_tags: (str) space-separated string of tags being filtered
+ for.
+
+ :returns: (bool) True if ANY of the tags in item_tags match the tags
+ in tag_filter. False if no matches are found.
+ """
+ if not tag_filter:
+ return True
+ if not item_tags:
+ return False
+ item_tags_list = item_tags.lower().split()
+ for t in tag_filter:
+ if t.lower() in item_tags_list:
+ return True
+ return False
+
+ def _matches_date_filter(self, item_date, date_filter):
+ """determine if an item's datetime stamp satisfies the date filter.
+
+ :param item_date: (datetime) generally the item's created_at datetime
+ object
+
+ :param date_filter: (tuple) pair of datetime objects representing the
+ start and end dates to filter items by. If the first object is None,
+ then the filter implies all items before the second datetime. Conversely,
+ if the second datetime is None, the filter implies all items after the
+ first datetime.
+
+ :returns: (bool) True if date_filter is an empty tuple OR the item_date
+ falls within the date_filter tuple.
+ """
+ if not date_filter:
+ return True
+ start_date = date_filter[0]
+ end_date = date_filter[1]
+ if not start_date:
+ start_date = datetime.min
+ if not end_date:
+ end_date = datetime.max
+ return item_date > start_date and item_date < end_date
diff --git a/askbot/importers/zendesk/models.py b/askbot/importers/zendesk/models.py
index 6a321915..da16bb51 100644
--- a/askbot/importers/zendesk/models.py
+++ b/askbot/importers/zendesk/models.py
@@ -4,9 +4,64 @@ 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
+TAGS = {}#internal cache for mappings forum id _> forum name
+
+# todo: don't allow nulls in char fields that should just allow empty strings
+
+class Entry(models.Model):
+ """
+ Top level topic posts in a forum
+ """
+ body = models.TextField()
+ created_at = models.DateTimeField()
+ tags = models.CharField(max_length = 255, null = True)
+ flag_type_id = models.IntegerField() # topic type
+ forum_id = models.IntegerField() # forum entry is in
+ hits = models.IntegerField(null = True) # number of views
+ entry_id = models.IntegerField()
+ is_highlighted = models.BooleanField(default = False) # ignored
+ is_locked = models.BooleanField(default = False) # close
+ is_pinned = models.BooleanField(default = False) # ignored
+ is_public = models.BooleanField(default = True)
+ organization_id = models.IntegerField(null = True)
+ position = models.IntegerField(null = True) # ignored
+ posts_count = models.IntegerField(null = True)
+ submitter_id = models.IntegerField()
+ title = models.CharField(max_length = 300)
+ updated_at = models.DateTimeField()
+ votes_count = models.IntegerField(null = True, default = 0)
+ ab_id = models.IntegerField(null = True)
+
+ def get_author(self):
+ """returns author of the post, from the Django user table"""
+ zendesk_user = User.objects.get(zendesk_user_id = self.submitter_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_tag_names(self):
+ """return tags on entry as well as forum title as a tag"""
+ # 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
+ # tags = TAGS[self.forum_id]
+ # if self.tags:
+ # tags += " %s" % self.tags
+ if not self.tags:
+ return "forum"
+ else:
+ return "forum %s" % self.tags.lower()
class Post(models.Model):
+ """
+ comments on an Entry in a Forum
+ """
body = models.TextField()
created_at = models.DateTimeField()
updated_at = models.DateTimeField()
@@ -15,11 +70,11 @@ class Post(models.Model):
forum_id = models.IntegerField()
user_id = models.IntegerField()
is_informative = models.BooleanField()
- is_processed = models.BooleanField(default = False)
+ ab_id = models.IntegerField(null = True)
def get_author(self):
"""returns author of the post, from the Django user table"""
- zendesk_user = User.objects.get(user_id = self.user_id)
+ zendesk_user = User.objects.get(zendesk_user_id = self.user_id)
return DjangoUser.objects.get(id = zendesk_user.askbot_user_id)
def get_body_text(self):
@@ -29,31 +84,31 @@ class Post(models.Model):
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 Organization(models.Model):
+ created_at = models.DateTimeField()
+ default = models.CharField(max_length = 255, null=True)
+ details = models.TextField(null=True)
+ external_id = models.IntegerField(null = True)
+ group_id = models.IntegerField(null = True)
+ organization_id = models.IntegerField(unique=True)
+ is_shared = models.BooleanField()
+ is_shared_comments = models.BooleanField()
+ name = models.CharField(max_length = 255)
+ notes = models.TextField(null=True)
+ suspended = models.BooleanField()
+ updated_at = models.DateTimeField()
class User(models.Model):
- user_id = models.IntegerField()
+ zendesk_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()
+ organization_id = models.IntegerField(null=True)
roles = models.IntegerField()
time_zone = models.CharField(max_length = 255)
updated_at = models.DateTimeField()
@@ -61,6 +116,10 @@ class User(models.Model):
email = models.EmailField(null = True)
is_verified = models.BooleanField()
photo_url = models.URLField()
+ # can't use foreign keys because Zendesk doesn't necessarily remove
+ # the user's organization_id if it's deleted which then causes an
+ # integrity error when trying to import here
+ # organization = models.ForeignKey(Organization, to_field='organization_id', null=True)
class Forum(models.Model):
description = models.CharField(max_length = 255, null = True)
@@ -76,3 +135,98 @@ class Forum(models.Model):
use_for_suggestions = models.BooleanField()
visibility_restriction_id = models.IntegerField()
is_public = models.BooleanField()
+
+ def viewable_to_public(self):
+ """There are two ways to restrict visibility of the forum. If is_public
+ is False, then it's not public, duh. But for
+ visibility_restriction_id:
+ 1=viewable to everyone
+ 2=viewable to logged in users only
+ 3=viewable to logged in agents only
+ organization_id:
+ if not null, this forum is restricted to a specific organization
+ on top of other restrictions
+ """
+ if (not self.is_public or self.visibility_restriction_id != 1 or
+ self.organization_id):
+ return False
+ else:
+ return True
+
+class Ticket(models.Model):
+ """todo: custom fields"""
+ assigned_at = models.DateTimeField(null=True)
+ assignee_id = models.IntegerField(null=True)
+ base_score = models.IntegerField()
+ created_at = models.DateTimeField()
+ current_collaborators = models.CharField(max_length = 255, null=True)
+ current_tags = models.CharField(max_length = 255, null=True)
+ description = models.CharField(max_length = 1000, null=True)
+ due_date = models.DateTimeField(null=True)
+ entry_id = models.IntegerField(null = True)
+ external_id = models.IntegerField(null = True)
+ group_id = models.IntegerField(null = True)
+ initially_assigned_at = models.DateTimeField(null=True)
+ latest_recipients = models.CharField(max_length = 255, null = True)
+ ticket_id = models.IntegerField()
+ organization_id = models.IntegerField(null = True)
+ original_recipient_address = models.CharField(max_length = 255, null = True)
+ priority_id = models.IntegerField()
+ recipient = models.CharField(max_length = 255, null=True)
+ requester_id = models.IntegerField()
+ resolution_time = models.IntegerField(null = True)
+ solved_at = models.DateTimeField(null=True)
+ status_id = models.IntegerField()
+ status_updated_at = models.DateTimeField()
+ subject = models.CharField(max_length = 255, null=True)
+ submitter_id = models.IntegerField()
+ ticket_type_id = models.IntegerField()
+ updated_at = models.DateTimeField()
+ updated_by_type_id = models.IntegerField(null = True)
+ via_id = models.IntegerField()
+ score = models.IntegerField()
+ problem_id = models.IntegerField(null = True)
+ has_incidents = models.BooleanField(default = False)
+ ab_id = models.IntegerField(null = True)
+
+ def get_author(self):
+ """returns author of the comment, from the Django user table"""
+ zendesk_user = User.objects.get(zendesk_user_id = self.requester_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.description)
+ return self._body_text
+
+ def get_tag_names(self):
+ if not self.current_tags:
+ return "ticket"
+ else:
+ return "ticket %s" % self.current_tags.lower()
+
+class Comment(models.Model):
+ """todo: attachments"""
+ author_id = models.IntegerField()
+ created_at = models.DateTimeField()
+ is_public = models.BooleanField(default = True)
+ type = models.CharField(max_length = 255)
+ value = models.CharField(max_length = 1000)
+ via_id = models.IntegerField()
+ ticket_id = models.IntegerField()
+ ab_id = models.IntegerField(null = True)
+
+ def get_author(self):
+ """returns author of the comment, from the Django user table"""
+ zendesk_user = User.objects.get(zendesk_user_id = self.author_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.value)
+ return self._body_text
+
diff --git a/askbot/media/jquery-openid/openid.css b/askbot/media/jquery-openid/openid.css
index 9a1db85f..b46522bd 100644
--- a/askbot/media/jquery-openid/openid.css
+++ b/askbot/media/jquery-openid/openid.css
@@ -15,7 +15,12 @@ ul.large input {height: 40px; width: 90px;border:1px solid #ccc;margin:0 5px 5px
/*#signin-form #account-recovery-form input {cursor:pointer;}
#signin-form #account-recovery-form input.text {cursor:default;}*/
-table.login { text-align: right;}
+table.login {
+ text-align: right;
+}
+table.login td {
+ padding: 0 10px 8px 0;
+}
.openid-signin .submit-b {
cursor: pointer; /*letter-spacing:1px;*/
diff --git a/askbot/media/style/style.css b/askbot/media/style/style.css
index 55769a17..b5a76686 100644
--- a/askbot/media/style/style.css
+++ b/askbot/media/style/style.css
@@ -2887,11 +2887,12 @@ ul#related-tags li {
#local_login_buttons #id_password,
#password-fs #id_password,
#openid-fs #id_password {
+ color: #525252;
font-size: 12px;
- line-height: 20px;
- height: 20px;
+ line-height: 25px;
+ height: 25px;
margin: 0px;
- padding: 0px 0 0 5px;
+ padding: 0 5px;
width: 200px;
}
.openid-input {
@@ -2998,6 +2999,22 @@ a:hover.medal {
padding: 10px 0px 10px 0px;
font-family: 'Open Sans Condensed', Arial, sans-serif;
}
+.user-profile-page .up-votes,
+.user-profile-page .down-votes {
+ display: inline-block;
+ font-size: 18px;
+ font-weight: bold;
+ height: 30px;
+ padding-left: 27px;
+ line-height: 22px;
+ margin: 0 15px 0 2px;
+}
+.user-profile-page .up-votes {
+ background: url(../images/vote-arrow-up-on.png) no-repeat;
+}
+.user-profile-page .down-votes {
+ background: url(../images/vote-arrow-down-on.png) no-repeat;
+}
.user-profile-page .inputs {
margin-top: 10px;
margin-bottom: 10px;
@@ -3418,8 +3435,29 @@ label.retag-error {
float: left;
}
.user-info-table {
- margin-bottom: 10px;
+ margin: 10px 0;
border-spacing: 0;
+ display: table;
+}
+.user-info-table .col1,
+.user-info-table .col2,
+.user-info-table .col3 {
+ display: table-cell;
+ vertical-align: top;
+}
+.user-info-table .col1 {
+ width: 140px;
+ text-align: center;
+}
+.user-info-table .col2 {
+ padding: 0 0 0 10px;
+ width: 400px;
+}
+.user-info-table .col3 {
+ width: 460px;
+}
+.user-info-table .gravatar {
+ margin: 0;
}
/* todo: remove this hack? */
.user-stats-table .narrow {
@@ -3795,9 +3833,6 @@ p.signup_p {
text-align: right;
padding-right: 5px;
}
-.user-info-table .gravatar {
- margin: 0;
-}
#responses {
clear: both;
line-height: 18px;
diff --git a/askbot/media/style/style.less b/askbot/media/style/style.less
index 83be895f..7472eb9a 100644
--- a/askbot/media/style/style.less
+++ b/askbot/media/style/style.less
@@ -2982,12 +2982,13 @@ ul#related-tags li {
#email-input-fs,#local_login_buttons,#password-fs,#openid-fs{
margin-top:10px;
#id_email,#id_username,#id_password{
+ color: #525252;
font-size: 12px;
- line-height: 20px;
- height: 20px;
+ line-height: 25px;
+ height: 25px;
margin: 0px;
- padding: 0px 0 0 5px;
- width:200px;
+ padding: 0 5px;
+ width: 200px;
}
}
@@ -3117,6 +3118,23 @@ a:hover.medal {
font-family:@main-font;
}
+ .up-votes,
+ .down-votes {
+ display: inline-block;
+ font-size: 18px;
+ font-weight: bold;
+ height: 30px;
+ padding-left: 27px;
+ line-height: 22px;
+ margin: 0 15px 0 2px;
+ }
+ .up-votes {
+ background: url(../images/vote-arrow-up-on.png) no-repeat;
+ }
+ .down-votes {
+ background: url(../images/vote-arrow-down-on.png) no-repeat;
+ }
+
.inputs {
margin-top: 10px;
margin-bottom: 10px;
@@ -3573,8 +3591,30 @@ label.retag-error {
}
.user-info-table {
- margin-bottom: 10px;
+ margin: 10px 0;
border-spacing: 0;
+ display: table;
+
+ .col1,
+ .col2,
+ .col3 {
+ display: table-cell;
+ vertical-align: top;
+ }
+ .col1 {
+ width: 140px;
+ text-align: center;
+ }
+ .col2 {
+ padding: 0 0 0 10px;
+ width: 400px;
+ }
+ .col3 {
+ width: 460px;
+ }
+ .gravatar {
+ margin:0;
+ }
}
/* todo: remove this hack? */
@@ -4033,10 +4073,6 @@ p.signup_p {
}
-.user-info-table .gravatar {
- margin:0;
-}
-
#responses {
clear:both;
line-height:18px;
diff --git a/askbot/templates/authopenid/signin.html b/askbot/templates/authopenid/signin.html
index ff7d47a4..2ec05558 100644
--- a/askbot/templates/authopenid/signin.html
+++ b/askbot/templates/authopenid/signin.html
@@ -87,16 +87,13 @@
{% if user.is_anonymous() %}
{% if have_buttons %}
<h2 id="password-heading">
- {% trans %}or enter your <span>user name and password</span>, then sign in{% endtrans %}
+ {% trans %}or enter your <span>user name and password</span>{% endtrans %}
</h2>
{% else %}
<h1 class="section-title">
{% trans %}Please, sign in{% endtrans %}
</h1>
{% endif %}
- {% if have_buttons %}
- <p class="hint">{% trans %}(or select another login method above){% endtrans %}</p>
- {% endif %}
<table class="login">
{% if login_form.password_login_failed %}
<tr>
diff --git a/askbot/templates/meta/bottom_scripts.html b/askbot/templates/meta/bottom_scripts.html
index 5c398358..f1180339 100644
--- a/askbot/templates/meta/bottom_scripts.html
+++ b/askbot/templates/meta/bottom_scripts.html
@@ -75,8 +75,10 @@
searchInput.focus();
putCursorAtEnd(searchInput);
}
-
- if (inArray(activeTab, ['questions', 'badges', 'ask']) && searchInput.length) {
+
+ var haveFullTextSearchTab = inArray(activeTab, ['questions', 'badges', 'ask']);
+ var haveUserProfilePage = $('body').hasClass('user-profile-page');
+ if ((haveUserProfilePage || haveFullTextSearchTab) && searchInput.length) {
var search = new FullTextSearch();
askbot['controllers'] = askbot['controllers'] || {};
askbot['controllers']['fullTextSearch'] = search;
diff --git a/askbot/templates/question/answer_vote_buttons.html b/askbot/templates/question/answer_vote_buttons.html
index cd6f62f0..68503310 100644
--- a/askbot/templates/question/answer_vote_buttons.html
+++ b/askbot/templates/question/answer_vote_buttons.html
@@ -1,3 +1,4 @@
+{% import "macros.html" as macros %}
{{ macros.post_vote_buttons(post = answer) }}
{% if settings.ACCEPTING_ANSWERS_ENABLED %}
<div
diff --git a/askbot/templates/user_profile/macros.html b/askbot/templates/user_profile/macros.html
index ac573553..9e334e8b 100644
--- a/askbot/templates/user_profile/macros.html
+++ b/askbot/templates/user_profile/macros.html
@@ -13,12 +13,7 @@
</h2>
{% endspaceless %}
<div class="user-stats-table">
- <table class="tags">
- <tr>
- <td valign="top">
- {{ macros.tag_list_widget(tag_names, deletable = False) }}
- </td>
- </tr>
- </table>
+ {{ macros.tag_list_widget(tag_names, deletable = False) }}
+ <div class="clearfix"></div>
</div>
{% endmacro %}
diff --git a/askbot/templates/user_profile/user_info.html b/askbot/templates/user_profile/user_info.html
index 6c20f1f4..9786d4ec 100644
--- a/askbot/templates/user_profile/user_info.html
+++ b/askbot/templates/user_profile/user_info.html
@@ -1,130 +1,126 @@
<!-- user_info.html -->
{% import "macros.html" as macros %}
-<table class="user-info-table">
- <tr>
- <td style="vertical-align:top;text-align:center;">
- <div class='avatar'>
- {{ macros.gravatar(view_user, 128) }}
- {% if request.user == view_user %}
- <p><a
- {% if support_custom_avatars %}
- href="{% url avatar_change %}"
- {% else %}
- href="{% url faq %}#gravatar"
- {% endif %}
- >{% trans %}change picture{% endtrans %}</a></p>
+<div class="user-info-table">
+ <div class="col1">
+ <div class='avatar'>
+ {{ macros.gravatar(view_user, 128) }}
+ {% if request.user == view_user %}
+ <p><a
{% if support_custom_avatars %}
- <p><a
- href="{% url avatar_delete %}"
- >{% trans %}remove{% endtrans %}</a>
- </p>
+ href="{% url avatar_change %}"
+ {% else %}
+ href="{% url faq %}#gravatar"
{% endif %}
+ >{% trans %}change picture{% endtrans %}</a></p>
+ {% if support_custom_avatars %}
+ <p><a
+ href="{% url avatar_delete %}"
+ >{% trans %}remove{% endtrans %}</a>
+ </p>
{% endif %}
- </div>
- {% if can_show_karma %}
- <div class="scoreNumber">{{view_user.reputation|intcomma}}</div>
- <p><b style="color:#777;">{% trans %}karma{% endtrans %}</b></p>
{% endif %}
- {% if user_follow_feature_on %}
- {{ macros.follow_user_toggle(visitor = request.user, subject = view_user) }}
- {% endif %}
- </td>
- <td width="360" style="padding-left:5px;vertical-align: top;">
- <table class="user-details">
- {% if request.user == view_user %}
- <tr>
- <td class="user-profile-tool-links" align="left" colspan="2">
- <a href="{% url edit_user view_user.id %}">
- {% trans %}update profile{% endtrans %}
+ </div>
+ {% if can_show_karma %}
+ <div class="scoreNumber">{{view_user.reputation|intcomma}}</div>
+ <p><b style="color:#777;">{% trans %}karma{% endtrans %}</b></p>
+ {% endif %}
+ {% if user_follow_feature_on %}
+ {{ macros.follow_user_toggle(visitor = request.user, subject = view_user) }}
+ {% endif %}
+ </div>
+ <div class="col2">
+ <table class="user-details">
+ {% if request.user == view_user %}
+ <tr>
+ <td class="user-profile-tool-links" align="left" colspan="2">
+ <a href="{% url edit_user view_user.id %}">
+ {% trans %}update profile{% endtrans %}
+ </a>
+ {% if settings.USE_ASKBOT_LOGIN_SYSTEM and request.user == view_user and settings.ALLOW_ADD_REMOVE_LOGIN_METHODS %}
+ | <a href="{{ settings.LOGIN_URL }}?next={{ settings.LOGIN_URL }}">
+ {% trans %}manage login methods{% endtrans %}
</a>
- {% if settings.USE_ASKBOT_LOGIN_SYSTEM and request.user == view_user and settings.ALLOW_ADD_REMOVE_LOGIN_METHODS %}
- | <a href="{{ settings.LOGIN_URL }}?next={{ settings.LOGIN_URL }}">
- {% trans %}manage login methods{% endtrans %}
- </a>
- {% endif %}
- </td>
- </tr>
- {% endif %}
- <tr>
- <th colspan="2" align="left">
- <h3>{{user_status_for_display}}</h3>
- </th>
- </tr>
- {% if view_user.real_name %}
- <tr>
- <td>{% trans %}real name{% endtrans %}</td>
- <td><b>{{view_user.real_name}}</b></td>
- </tr>
- {% endif %}
- {% if settings.GROUPS_ENABLED %}
- <tr>
- <td>{% trans %}groups{% endtrans %}</td>
- <td>
- <div id="user-groups">
- <table id="groups-list">
- {% for group in user_groups %}
- <tr>
- {{ macros.user_group(group, groups_membership_info[group.id]) }}
- </tr>
- {% endfor %}
- </table>
- <div class="clearfix"></div>
- <a id="add-group">{% trans %}add group{% endtrans %}</a>
- </div>
- </td>
- </tr>
- {% endif %}
- <tr>
- <td>{% trans %}member since{% endtrans %}</td>
- <td><strong>{{ macros.timeago(view_user.date_joined) }}</strong></td>
- </tr>
- {% if view_user.last_seen %}
- <tr>
- <td>{% trans %}last seen{% endtrans %}</td>
- <td><strong title="{{ view_user.last_seen }}">{{ macros.timeago(view_user.last_seen) }}</strong></td>
- </tr>
- {% endif %}
- {% if view_user.website and (not view_user.is_blocked()) %}
- <tr>
- <td>{% trans %}website{% endtrans %}</td>
- <td>{{ macros.user_website_link(view_user, max_display_length = 30) }}</td>
- </tr>
- {% endif %}
- {% if request.user == view_user and
- settings.TWITTER_SECRET and
- settings.TWITTER_KEY and
- settings.ENABLE_SHARING_TWITTER
- %}
- {% include "user_profile/twitter_sharing_controls.html" %}
- {% endif %}
- {% if view_user.location or view_user.country %}
- <tr>
- <td>{% trans %}location{% endtrans %}</td>
- <td>{{ macros.user_full_location(view_user) }}</td>
- </tr>
- {% endif %}
- {% if view_user.date_of_birth %}
- <tr>
- <!--todo - redo this with whole sentence translation -->
- <td>{% trans %}age{% endtrans %}</td>
- <td>{% trans age=view_user.date_of_birth|get_age%}{{ age }} years old{% endtrans %}</td>
- </tr>
- {% endif %}
- {% if votes_today_left %}
- <tr>
- <td>{% trans %}todays unused votes{% endtrans %}</td>
- <td><strong class="darkred">{{ votes_today_left }}</strong> {% trans %}votes left{% endtrans %}</td>
- </tr>
- {% endif %}
- </table>
- </td>
- <td width="380">
- <div class="user-about">
- {% if view_user.about and (not view_user.is_blocked()) %}
- {{view_user.about|linebreaks}}
+ {% endif %}
+ </td>
+ </tr>
+ {% endif %}
+ <tr>
+ <th colspan="2" align="left">
+ <h3>{{user_status_for_display}}</h3>
+ </th>
+ </tr>
+ {% if view_user.real_name %}
+ <tr>
+ <td>{% trans %}real name{% endtrans %}</td>
+ <td><b>{{view_user.real_name}}</b></td>
+ </tr>
+ {% endif %}
+ {% if settings.GROUPS_ENABLED %}
+ <tr>
+ <td>{% trans %}groups{% endtrans %}</td>
+ <td>
+ <div id="user-groups">
+ <table id="groups-list">
+ {% for group in user_groups %}
+ <tr>
+ {{ macros.user_group(group, groups_membership_info[group.id]) }}
+ </tr>
+ {% endfor %}
+ </table>
+ <div class="clearfix"></div>
+ <a id="add-group">{% trans %}add group{% endtrans %}</a>
+ </div>
+ </td>
+ </tr>
+ {% endif %}
+ <tr>
+ <td>{% trans %}member since{% endtrans %}</td>
+ <td><strong>{{ macros.timeago(view_user.date_joined) }}</strong></td>
+ </tr>
+ {% if view_user.last_seen %}
+ <tr>
+ <td>{% trans %}last seen{% endtrans %}</td>
+ <td><strong title="{{ view_user.last_seen }}">{{ macros.timeago(view_user.last_seen) }}</strong></td>
+ </tr>
+ {% endif %}
+ {% if view_user.website and (not view_user.is_blocked()) %}
+ <tr>
+ <td>{% trans %}website{% endtrans %}</td>
+ <td>{{ macros.user_website_link(view_user, max_display_length = 30) }}</td>
+ </tr>
+ {% endif %}
+ {% if request.user == view_user and
+ settings.TWITTER_SECRET and
+ settings.TWITTER_KEY and
+ settings.ENABLE_SHARING_TWITTER
+ %}
+ {% include "user_profile/twitter_sharing_controls.html" %}
+ {% endif %}
+ {% if view_user.location or view_user.country %}
+ <tr>
+ <td>{% trans %}location{% endtrans %}</td>
+ <td>{{ macros.user_full_location(view_user) }}</td>
+ </tr>
+ {% endif %}
+ {% if view_user.date_of_birth %}
+ <tr>
+ <!--todo - redo this with whole sentence translation -->
+ <td>{% trans %}age{% endtrans %}</td>
+ <td>{% trans age=view_user.date_of_birth|get_age%}{{ age }} years old{% endtrans %}</td>
+ </tr>
+ {% endif %}
+ {% if votes_today_left %}
+ <tr>
+ <td>{% trans %}todays unused votes{% endtrans %}</td>
+ <td><strong class="darkred">{{ votes_today_left }}</strong> {% trans %}votes left{% endtrans %}</td>
+ </tr>
{% endif %}
- </div>
- </td>
- </tr>
-</table>
+ </table>
+ </div>
+ <div class="col3 user-about">
+ {% if view_user.about and (not view_user.is_blocked()) %}
+ {{view_user.about|linebreaks}}
+ {% endif %}
+ </div>
+</div>
<!-- end user_info.html -->
diff --git a/askbot/templates/user_profile/user_stats.html b/askbot/templates/user_profile/user_stats.html
index fe446c44..648280c4 100644
--- a/askbot/templates/user_profile/user_stats.html
+++ b/askbot/templates/user_profile/user_stats.html
@@ -23,54 +23,32 @@
<h2>{% trans cnt=total_votes %}<span class="count">{{cnt}}</span> Vote{% pluralize %}<span class="count">{{cnt}}</span> Votes {% endtrans %}</h2>
{% endspaceless %}
<div class="user-stats-table">
- <table>
- <tr>
- <td width="60">
- <img style="cursor: default;" src="{{"/images/vote-arrow-up-on.png"|media}}" alt="{% trans %}thumb up{% endtrans %}" />
- <span title="{% trans %}user has voted up this many times{% endtrans %}" class="vote-count">{{up_votes}}</span>
- </td>
- <td width="60">
- <img style="cursor: default;" src="{{"/images/vote-arrow-down-on.png"|media}}" alt="{% trans %}thumb down{% endtrans %}" />
- <span title="{% trans %}user voted down this many times{% endtrans %}" class="vote-count">{{down_votes}}</span>
-
- </td>
- </tr>
- </table>
+ <div class="up-votes">{{ up_votes }}</div>
+ <div class="down-votes">{{ down_votes }}</div>
</div>
<a name="tags"></a>
{% spaceless %}
<h2>{% trans counter=user_tags|length %}<span class="count">{{counter}}</span> Tag{% pluralize %}<span class="count">{{counter}}</span> Tags{% endtrans %}</h2>
{% endspaceless %}
<div class="user-stats-table">
- <table class="tags">
- <tr>
- <td valign="top">
- <ul id="ab-user-tags" class="tags">
- {% for tag in user_tags %}
- <li>
- {{ macros.tag_widget(
- tag.name,
- html_tag = 'div',
- search_state = search_state,
- truncate_long_tag = True,
- extra_content =
- '<span class="tag-number">&#215; ' ~
- tag.user_tag_usage_count|intcomma ~
- '</span>'
- )
- }}
- </li>
- {#
- {% if loop.index is divisibleby 10 %}
- </td>
- <td width="180" valign="top">
- {% endif %}
- #}
- {% endfor %}
- </ul>
- </td>
- </tr>
- </table>
+ <ul id="ab-user-tags" class="tags">
+ {% for tag in user_tags %}
+ <li>
+ {{ macros.tag_widget(
+ tag.name,
+ html_tag = 'div',
+ search_state = search_state,
+ truncate_long_tag = True,
+ extra_content =
+ '<span class="tag-number">&#215; ' ~
+ tag.user_tag_usage_count|intcomma ~
+ '</span>'
+ )
+ }}
+ </li>
+ {% endfor %}
+ </ul>
+ <div class="clearfix"></div>
</div>
{% if interesting_tag_names %}
{{ user_profile_macros.tag_selection(interesting_tag_names, 'interesting') }}
@@ -87,39 +65,32 @@
<h2>{% trans counter=total_badges %}<span class="count">{{counter}}</span> Badge{% pluralize %}<span class="count">{{counter}}</span> Badges{% endtrans %}</h2>
{% endspaceless %}
<div class="user-stats-table badges">
- <table>
- <tr>
- <td style="line-height:35px">
- {% for badge, badge_user_awards in badges %}
+ {% for badge, badge_user_awards in badges %}
+ <a
+ href="{{badge.get_absolute_url()}}"
+ title="{% trans description=badge.get_description() %}{{description}}{% endtrans %}"
+ class="medal"
+ ><span class="{{ badge.get_css_class() }}">&#9679;</span>&nbsp;{% trans name=badge.get_name() %}{{name}}{% endtrans %}
+ </a>&nbsp;
+ <span class="tag-number">&#215;
+ <span class="badge-context-toggle">{{ badge_user_awards|length|intcomma }}</span>
+ </span>
+ <ul id="badge-context-{{ badge.id }}" class="badge-context-list" style="display:none">
+ {% for award in badge_user_awards %}
+ {% if award.content_object and award.content_object_is_post %}
+ <li>
<a
- href="{{badge.get_absolute_url()}}"
- title="{% trans description=badge.get_description() %}{{description}}{% endtrans %}"
- class="medal"
- ><span class="{{ badge.get_css_class() }}">&#9679;</span>&nbsp;{% trans name=badge.get_name() %}{{name}}{% endtrans %}
- </a>&nbsp;
- <span class="tag-number">&#215;
- <span class="badge-context-toggle">{{ badge_user_awards|length|intcomma }}</span>
- </span>
- <ul id="badge-context-{{ badge.id }}" class="badge-context-list" style="display:none">
- {% for award in badge_user_awards %}
- {% if award.content_object and award.content_object_is_post %}
- <li>
- <a
- title="{{ award.content_object.get_snippet()|collapse }}"
- href="{{ award.content_object.get_absolute_url() }}"
- >{% if award.content_type.post_type == 'answer' %}{% trans %}Answer to:{% endtrans %}{% endif %} {{ award.content_object.thread.title|escape }}</a>
- </li>
- {% endif %}
- {% endfor %}
- </ul>
- {% if loop.index is divisibleby 3 %}
- </td></tr>
- <tr><td style="line-height:35px">
- {% endif %}
- {% endfor %}
- </td>
- </tr>
- </table>
+ title="{{ award.content_object.get_snippet()|collapse }}"
+ href="{{ award.content_object.get_absolute_url() }}"
+ >{% if award.content_type.post_type == 'answer' %}{% trans %}Answer to:{% endtrans %}{% endif %} {{ award.content_object.thread.title|escape }}</a>
+ </li>
+ {% endif %}
+ {% endfor %}
+ </ul>
+ {% if loop.index is divisibleby 3 %}
+ <br/>
+ {% endif %}
+ {% endfor %}
</div>
{% endif %}
{% endblock %}
diff --git a/askbot/templates/widgets/secondary_header.html b/askbot/templates/widgets/secondary_header.html
index f3f78fa7..69e5742c 100644
--- a/askbot/templates/widgets/secondary_header.html
+++ b/askbot/templates/widgets/secondary_header.html
@@ -10,6 +10,8 @@
<form
{% if active_tab == "tags" %}
action="{% url tags %}"
+ {% elif page_class == 'user-profile-page' %}
+ action="{% url questions %}" id="searchForm"
{% elif active_tab == "users" %}
action=""
{% else %}
diff --git a/askbot/utils/console.py b/askbot/utils/console.py
index 23cff6f9..ef318580 100644
--- a/askbot/utils/console.py
+++ b/askbot/utils/console.py
@@ -34,6 +34,80 @@ def choice_dialog(prompt_phrase, choices = None, invalid_phrase = None):
print invalid_phrase % {'opt_string': opt_string}
time.sleep(1)
+def numeric_choice_dialog(prompt_phrase, choices):
+ """Prints a list of choices with numeric options and requires the
+ user to select a single choice from the list.
+
+ :param prompt_phrase: (str) Prompt to give the user asking them to
+ choose from the list.
+
+ :param choices: (list) List of string choices for the user to choose
+ from. The numeric value they will use to select from the list is the
+ list index of the choice.
+
+ :returns: (int) index number of the choice selected by the user
+ """
+ assert(hasattr(choices, '__iter__'))
+ assert(not isinstance(choices, basestring))
+ choice_menu = "\n".join(["%d - %s" % (i,x) for i, x in enumerate(choices)])
+ while True:
+ response = raw_input('\n%s\n%s> ' % (choice_menu, prompt_phrase))
+ try:
+ index = int(response)
+ except ValueError:
+ index = False
+ if index is False or index < 0 or index >= len(choices):
+ print "\n*** Please enter a number between 0 and %d ***" % (len(choices)-1)
+ else:
+ return index
+
+def numeric_multiple_choice_dialog(prompt_phrase, choices, all_option=False):
+ """Prints a list of choices with numeric options and requires the
+ user to select zero or more choices from the list.
+
+ :param prompt_phrase: (str) Prompt to give the user asking them to
+ choose from the list.
+
+ :param choices: (list) List of string choices for the user to choose
+ from. The numeric value they will use to select from the list is the
+ list index of the choice.
+
+ :param all_option: (bool) Optional. If True, the first choice will be a
+ fake option to choose all options. This is a convenience to avoid requiring
+ the user provide a lot of input when there are a lot of options
+
+ :returns: (list) list of index numbers of the choices selected by
+ the user
+ """
+ assert(hasattr(choices, '__iter__'))
+ assert(not isinstance(choices, basestring))
+ if all_option:
+ choices.insert(0, 'ALL')
+ choice_menu = "\n".join(["%d - %s" % (i,x) for i, x in enumerate(choices)])
+ choice_indexes = []
+ index = False
+ while True:
+ response = raw_input('\n%s\n%s> ' % (choice_menu, prompt_phrase))
+ selections = response.split()
+ print "selections: %s" % selections
+ for c in selections:
+ try:
+ index = int(c)
+ except ValueError:
+ index = False
+ if index < 0 or index >= len(choices):
+ index = False
+ print "\n*** Please enter only numbers between 0 and " +\
+ "%d separated by spaces ***" % (len(choices)-1)
+ break
+ else:
+ choice_indexes.append(index)
+ if index:
+ if all_option and 0 in choice_indexes and len(choice_indexes) > 1:
+ print "\n*** You cannot include other choices with the ALL " +\
+ "option ***"
+ else:
+ return choice_indexes
def simple_dialog(prompt_phrase, required=False):
"""asks user to enter a string, if `required` is True,
@@ -52,9 +126,27 @@ def simple_dialog(prompt_phrase, required=False):
time.sleep(1)
-def get_yes_or_no(prompt_phrase):
+def get_yes_or_no(prompt_phrase, default=None):
+ """Prompts user for a yes or no response with an optional default
+ value which will be inferred if the user just hits enter
+
+ :param prompt_phrase: (str) Question to prompt the user with
+
+ :param default: (str) Either 'yes' or 'no'. If a valid option is
+ provided, the user can simply press enter to accept the default.
+ If an invalid option is passed in, a `ValueError` is raised.
+
+ :returns: (str) 'yes' or 'no'
+ """
while True:
- response = raw_input(prompt_phrase + ' (yes/no)\n> ').strip()
+ prompt_phrase += ' (yes/no)'
+ if default:
+ prompt_phrase += '\n[%s] >' % default
+ else:
+ prompt_phrase += '\n >'
+ response = raw_input(prompt_phrase).strip()
+ if not response and default:
+ return default
if response in ('yes', 'no'):
return response