From 46b135059052f57e3105ee5236059350e456a80d Mon Sep 17 00:00:00 2001 From: Andrei Date: Fri, 15 Apr 2011 19:07:48 -0400 Subject: added an send_unanswered_question_reminders management command, untested --- askbot/conf/email.py | 37 ++++++++ askbot/const/__init__.py | 5 ++ askbot/management/commands/send_email_alerts.py | 98 ++++------------------ .../commands/send_unanswered_question_reminders.py | 91 ++++++++++++++++++++ askbot/models/__init__.py | 43 +++++++++- askbot/models/question.py | 38 ++++++++- askbot/tests/email_alert_tests.py | 3 +- 7 files changed, 230 insertions(+), 85 deletions(-) create mode 100644 askbot/management/commands/send_unanswered_question_reminders.py diff --git a/askbot/conf/email.py b/askbot/conf/email.py index f18a554d..b86b7330 100644 --- a/askbot/conf/email.py +++ b/askbot/conf/email.py @@ -54,6 +54,43 @@ settings.register( ) ) +settings.register( + livesettings.BooleanValue( + EMAIL, + 'ENABLE_UNANSWERED_REMINDERS', + default = False, + description = _('Send periodic reminders about unanswered questions'), + help_text = _( + 'NOTE: in order to use this feature, it is necessary to ' + 'run the management command "send_unanswered_question_reminders" ' + '(for example, via a cron job - with an appropriate frequency)' + ) + ) +) + +settings.register( + livesettings.IntegerValue( + EMAIL, + 'DAYS_BEFORE_SENDING_UNANSWERED_REMINDER', + default = 1, + description = _( + 'Days before starting to send reminders about unanswered questions' + ), + ) +) + +settings.register( + livesettings.IntegerValue( + EMAIL, + 'UNANSWERED_REMINDER_FREQUENCY', + default = 1, + description = _( + 'How often to send unanswered question reminders ' + '(in days between the reminders sent).' + ) + ) +) + settings.register( livesettings.BooleanValue( EMAIL, diff --git a/askbot/const/__init__.py b/askbot/const/__init__.py index 5a0781f0..ea9236b6 100644 --- a/askbot/const/__init__.py +++ b/askbot/const/__init__.py @@ -105,6 +105,7 @@ TYPE_ACTIVITY_FAVORITE=16 TYPE_ACTIVITY_USER_FULL_UPDATED = 17 TYPE_ACTIVITY_EMAIL_UPDATE_SENT = 18 TYPE_ACTIVITY_MENTION = 19 +TYPE_ACTIVITY_UNANSWERED_REMINDER_SENT = 20 #TYPE_ACTIVITY_EDIT_QUESTION=17 #TYPE_ACTIVITY_EDIT_ANSWER=18 @@ -128,6 +129,10 @@ TYPE_ACTIVITY = ( (TYPE_ACTIVITY_FAVORITE, _('selected favorite')), (TYPE_ACTIVITY_USER_FULL_UPDATED, _('completed user profile')), (TYPE_ACTIVITY_EMAIL_UPDATE_SENT, _('email update sent to user')), + ( + TYPE_ACTIVITY_UNANSWERED_REMINDER_SENT, + _('reminder about unanswered questions sent'), + ), (TYPE_ACTIVITY_MENTION, _('mentioned in the post')), ) diff --git a/askbot/management/commands/send_email_alerts.py b/askbot/management/commands/send_email_alerts.py index 56286981..5f14618b 100644 --- a/askbot/management/commands/send_email_alerts.py +++ b/askbot/management/commands/send_email_alerts.py @@ -5,6 +5,7 @@ from django.db.models import Q, F from askbot.models import User, Question, Answer, Tag, QuestionRevision from askbot.models import AnswerRevision, Activity, EmailFeedSetting from askbot.models import Comment +from askbot.models.question import get_tag_summary_from_questions from django.utils.translation import ugettext as _ from django.utils.translation import ungettext from django.conf import settings as django_settings @@ -72,52 +73,6 @@ def format_action_count(string, number, output): if number > 0: output.append(_(string) % {'num':number}) -def get_update_subject_line(question_dict): - """forms a subject line based on up to five most - frequently used tags in the question_dict - - question_dict is an instance of SortedDict, - where questions are keys and values are meta_data - accumulated during the question filtering - """ - #todo: in python 2.6 there is collections.Counter() thing - #which would be very useful here - tag_counts = dict() - updated_questions = question_dict.keys() - for question in updated_questions: - tag_names = question.get_tag_names() - for tag_name in tag_names: - if tag_name in tag_counts: - tag_counts[tag_name] += 1 - else: - tag_counts[tag_name] = 1 - tag_list = tag_counts.keys() - #sort in descending order - tag_list.sort(lambda x, y: cmp(tag_counts[y], tag_counts[x])) - - question_count = len(updated_questions) - #note that double quote placement is important here - if len(tag_list) == 1: - last_topic = '"' - elif len(tag_list) <= 5: - last_topic = _('" and "%s"') % tag_list.pop() - else: - tag_list = tag_list[:5] - last_topic = _('" and more') - - topics = '"' + '", "'.join(tag_list) + last_topic - - subject_line = ungettext( - '%(question_count)d updated question about %(topics)s', - '%(question_count)d updated questions about %(topics)s', - question_count - ) % { - 'question_count': question_count, - 'topics': topics - } - - return mail.prefix_the_subject_line(subject_line) - class Command(NoArgsCommand): def handle_noargs(self, **options): try: @@ -244,41 +199,8 @@ class Command(NoArgsCommand): q_ans_B.cutoff_time = cutoff_time elif feed.feed_type == 'q_all': - if user.email_tag_filter_strategy == 'ignored': - - ignored_tags = Tag.objects.filter( - user_selections__reason='bad', - user_selections__user=user - ) - - wk = user.ignored_tags.strip().split() - ignored_by_wildcards = Tag.objects.get_by_wildcards(wk) - - q_all_A = Q_set_A.exclude( - tags__in = ignored_tags - ).exclude( - tags__in = ignored_by_wildcards - ) - - q_all_B = Q_set_B.exclude( - tags__in = ignored_tags - ).exclude( - tags__in = ignored_by_wildcards - ) - else: - selected_tags = Tag.objects.filter( - user_selections__reason='good', - user_selections__user=user - ) - - wk = user.interesting_tags.strip().split() - selected_by_wildcards = Tag.objects.get_by_wildcards(wk) - - tag_filter = Q(tags__in = list(selected_tags)) \ - | Q(tags__in = list(selected_by_wildcards)) - - q_all_A = Q_set_A.filter( tag_filter ) - q_all_B = Q_set_B.filter( tag_filter ) + q_all_A = user.get_tag_filtered_questions(Q_set_A) + q_all_B = user.get_tag_filtered_questions(Q_set_B) q_all_A = q_all_A[:askbot_settings.MAX_ALERTS_PER_EMAIL] q_all_B = q_all_B[:askbot_settings.MAX_ALERTS_PER_EMAIL] @@ -478,7 +400,19 @@ class Command(NoArgsCommand): num_q += 1 if num_q > 0: url_prefix = askbot_settings.APP_URL - subject_line = get_update_subject_line(q_list) + + tag_summary = get_tag_summary_from_questions(q_list.keys()) + question_count = len(q_list.keys()) + + subject_line = ungettext( + '%(question_count)d updated question about %(topics)s', + '%(question_count)d updated questions about %(topics)s', + question_count + ) % { + 'question_count': question_count, + 'topics': tag_summary + } + #todo: send this to special log #print 'have %d updated questions for %s' % (num_q, user.username) text = ungettext('%(name)s, this is an update message header for %(num)d question', diff --git a/askbot/management/commands/send_unanswered_question_reminders.py b/askbot/management/commands/send_unanswered_question_reminders.py new file mode 100644 index 00000000..adef4a2d --- /dev/null +++ b/askbot/management/commands/send_unanswered_question_reminders.py @@ -0,0 +1,91 @@ +import datetime +from django.core.management.base import NoArgsCommand +from django.conf import settings as django_settings +from askbot import models +from askbot import const +from askbot.conf import settings as askbot_settings +from django.utils.translation import ugettext as _ +from django.utils.translation import ungettext +from askbot.utils import mail +from askbot.models.question import get_tag_summary_from_questions + +DEBUG_THIS_COMMAND = False + +class Command(NoArgsCommand): + def handle_noargs(self, **options): + if askbot_settings.ENABLE_UANSWERED_REMINDERS == False: + return + #get questions without answers, excluding closed and deleted + #order it by descending added_at date + wait_period = datetime.timedelta( + askbot_settings.DAYS_BEFORE_SENDING_UNANSWERED_REMINDER + ) + cutoff_date = datetime.datetime.now() + wait_period + + questions = models.Question.objects.exclude( + closed = True + ).exclude( + deleted = False + ).exclude( + added_at__lt = cutoff_date + ).filter( + answer_count__gt = 0 + ).order_by('-added_at') + #for all users, excluding blocked + #for each user, select a tag filtered subset + #format the email reminder and send it + for user in models.User.objects.exclude(status = 'b'): + user_questions = questions.exclude(author = user) + user_questions = user.get_tag_filtered_questions(questions) + + final_question_list = list() + #todo: rewrite using query set filter + #may be a lot more efficient + for question in user_questions: + activity_type = const.TYPE_ACTIVITY_UNANSWERED_REMINDER_SENT + activity, created = models.Activity.objects.get_or_create( + user = user, + question = question, + activity_type = activity_type + ) + + now = datetime.datetime.now() + recurrence_delay = datetime.timedelta( + askbot_settings.UNANSWERED_REMINDER_FREQUENCY + ) + if created == False: + if activity.active_at >= now + recurrence_delay: + continue + + activity.active_at = datetime.datetime.now() + activity.save() + + question_count = len(final_question_list) + if question_count == 0: + continue + + tag_summary = get_tag_summary_from_questions(user_questions) + subject_line = ungettext( + '%(question_count)d unanswered question about %(topics)s', + '%(question_count)d unanswered questions about %(topics)s', + question_count + ) % { + 'question_count': question_count, + 'topics': tag_summary + } + + body_text = '' + + mail.send_mail( + subject_line = subject_line, + body_text = body_text, + recipient_list = (user.email,) + ) diff --git a/askbot/models/__init__.py b/askbot/models/__init__.py index a4d21caa..75426cf5 100644 --- a/askbot/models/__init__.py +++ b/askbot/models/__init__.py @@ -1492,6 +1492,43 @@ def user_get_q_sel_email_feed_frequency(self): raise e return feed_setting.frequency +def user_get_tag_filtered_questions(self, questions = None): + """Returns a query set of questions, tag filtered according + to the user choices. Parameter ``questions`` can be either ``None`` + or a starting query set. + """ + if questions == None: + questions = Question.objects.all() + + if self.email_tag_filter_strategy == 'ignored': + + ignored_tags = Tag.objects.filter( + user_selections__reason = 'bad', + user_selections__user = self + ) + + wk = user.ignored_tags.strip().split() + ignored_by_wildcards = Tag.objects.get_by_wildcards(wk) + + return questions.exclude( + tags__in = ignored_tags + ).exclude( + tags__in = ignored_by_wildcards + ) + else: + selected_tags = Tag.objects.filter( + user_selections__reason = 'good', + user_selections__user = self + ) + + wk = user.interesting_tags.strip().split() + selected_by_wildcards = Tag.objects.get_by_wildcards(wk) + + tag_filter = Q(tags__in = list(selected_tags)) \ + | Q(tags__in = list(selected_by_wildcards)) + + return questions.filter( tag_filter ) + def get_messages(self): messages = [] for m in self.message_set.all(): @@ -1831,10 +1868,14 @@ User.add_to_class('downvote', downvote) User.add_to_class('flag_post', flag_post) User.add_to_class('receive_reputation', user_receive_reputation) User.add_to_class('get_flags', user_get_flags) -User.add_to_class('get_flag_count_posted_today', user_get_flag_count_posted_today) +User.add_to_class( + 'get_flag_count_posted_today', + user_get_flag_count_posted_today +) User.add_to_class('get_flags_for_post', user_get_flags_for_post) User.add_to_class('get_profile_url', get_profile_url) User.add_to_class('get_profile_link', get_profile_link) +User.add_to_class('get_tag_filtered_questions', user_get_tag_filtered_questions) User.add_to_class('get_messages', get_messages) User.add_to_class('delete_messages', delete_messages) User.add_to_class('toggle_favorite_question', toggle_favorite_question) diff --git a/askbot/models/question.py b/askbot/models/question.py index d1570877..ccb587e7 100644 --- a/askbot/models/question.py +++ b/askbot/models/question.py @@ -40,6 +40,42 @@ QUESTION_ORDER_BY_MAP = { 'relevance-desc': None#this is a special case for postges only } +def get_tag_summary_from_questions(questions): + """returns a humanized string containing up to + five most frequently used + unique tags coming from the ``questions``. + Variable ``questions`` is an iterable of + :class:`~askbot.models.Question` model objects. + + This is not implemented yet as a query set method, + because it is used on a list. + """ + #todo: in python 2.6 there is collections.Counter() thing + #which would be very useful here + tag_counts = dict() + for question in questions: + tag_names = question.get_tag_names() + for tag_name in tag_names: + if tag_name in tag_counts: + tag_counts[tag_name] += 1 + else: + tag_counts[tag_name] = 1 + tag_list = tag_counts.keys() + #sort in descending order + tag_list.sort(lambda x, y: cmp(tag_counts[y], tag_counts[x])) + + #note that double quote placement is important here + if len(tag_list) == 1: + last_topic = '"' + elif len(tag_list) <= 5: + last_topic = _('" and "%s"') % tag_list.pop() + else: + tag_list = tag_list[:5] + last_topic = _('" and more') + + return '"' + '", "'.join(tag_list) + last_topic + + class QuestionQuerySet(models.query.QuerySet): """Custom query set subclass for :class:`~askbot.models.Question` """ @@ -112,7 +148,7 @@ class QuestionQuerySet(models.query.QuerySet): return self.extra(**extra_kwargs) else: #fallback to dumb title match search - return extra( + return self.extra( where=['title like %s'], params=['%' + search_query + '%'] ) diff --git a/askbot/tests/email_alert_tests.py b/askbot/tests/email_alert_tests.py index de801967..84d9857b 100644 --- a/askbot/tests/email_alert_tests.py +++ b/askbot/tests/email_alert_tests.py @@ -13,6 +13,7 @@ from askbot import models from askbot.utils import mail from askbot.conf import settings as askbot_settings from askbot import const +from askbot.models.question import get_tag_summary_from_questions def email_alert_test(test_func): """decorator for test methods in @@ -686,7 +687,7 @@ class DelayedAlertSubjectLineTests(TestCase): q8:'', q9:'', q10:'', q11:'', } from askbot.management.commands import send_email_alerts as cmd - subject = cmd.get_update_subject_line(q_dict) + subject = get_tag_summary_from_questions(q_dict.keys()) self.assertTrue('one' not in subject) self.assertTrue('two' in subject) -- cgit v1.2.3-1-g7c22 From 7c5c0a830690a4956393fdd0b21564a3b2e157e5 Mon Sep 17 00:00:00 2001 From: Andrei Date: Fri, 15 Apr 2011 21:15:54 -0400 Subject: basic debugging done, simple test fails --- .../commands/send_unanswered_question_reminders.py | 2 +- askbot/models/__init__.py | 8 +++---- askbot/tests/email_alert_tests.py | 26 ++++++++++++++++++---- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/askbot/management/commands/send_unanswered_question_reminders.py b/askbot/management/commands/send_unanswered_question_reminders.py index adef4a2d..69c663cf 100644 --- a/askbot/management/commands/send_unanswered_question_reminders.py +++ b/askbot/management/commands/send_unanswered_question_reminders.py @@ -13,7 +13,7 @@ DEBUG_THIS_COMMAND = False class Command(NoArgsCommand): def handle_noargs(self, **options): - if askbot_settings.ENABLE_UANSWERED_REMINDERS == False: + if askbot_settings.ENABLE_UNANSWERED_REMINDERS == False: return #get questions without answers, excluding closed and deleted #order it by descending added_at date diff --git a/askbot/models/__init__.py b/askbot/models/__init__.py index 75426cf5..b5c239dd 100644 --- a/askbot/models/__init__.py +++ b/askbot/models/__init__.py @@ -1507,7 +1507,7 @@ def user_get_tag_filtered_questions(self, questions = None): user_selections__user = self ) - wk = user.ignored_tags.strip().split() + wk = self.ignored_tags.strip().split() ignored_by_wildcards = Tag.objects.get_by_wildcards(wk) return questions.exclude( @@ -1521,11 +1521,11 @@ def user_get_tag_filtered_questions(self, questions = None): user_selections__user = self ) - wk = user.interesting_tags.strip().split() + wk = self.interesting_tags.strip().split() selected_by_wildcards = Tag.objects.get_by_wildcards(wk) - tag_filter = Q(tags__in = list(selected_tags)) \ - | Q(tags__in = list(selected_by_wildcards)) + tag_filter = models.Q(tags__in = list(selected_tags)) \ + | models.Q(tags__in = list(selected_by_wildcards)) return questions.filter( tag_filter ) diff --git a/askbot/tests/email_alert_tests.py b/askbot/tests/email_alert_tests.py index 84d9857b..0c239be1 100644 --- a/askbot/tests/email_alert_tests.py +++ b/askbot/tests/email_alert_tests.py @@ -686,7 +686,6 @@ class DelayedAlertSubjectLineTests(TestCase): q1:'', q2:'', q3:'', q4:'', q5:'', q6:'', q7:'', q8:'', q9:'', q10:'', q11:'', } - from askbot.management.commands import send_email_alerts as cmd subject = get_tag_summary_from_questions(q_dict.keys()) self.assertTrue('one' not in subject) @@ -702,9 +701,9 @@ class DelayedAlertSubjectLineTests(TestCase): i6 = subject.index('six') order = [i6, i5, i4, i3, i2] self.assertEquals( - order, - sorted(order) - ) + order, + sorted(order) + ) class FeedbackTests(utils.AskbotTestCase): def setUp(self): @@ -801,3 +800,22 @@ class TagFollowedInstantWholeForumEmailAlertTests(utils.AskbotTestCase): self.assertTrue( self.user1.email in outbox[0].recipients() ) + +class UnansweredReminderTests(utils.AskbotTestCase): + def setUp(self): + self.u1 = self.create_user(username = 'user1') + self.u2 = self.create_user(username = 'user2') + + def test_reminder_simple(self): + """a positive test - user must receive a reminder + """ + askbot_settings.update('ENABLE_UNANSWERED_REMINDERS', True) + days_ago = 5*askbot_settings.DAYS_BEFORE_SENDING_UNANSWERED_REMINDER + long_ago = datetime.datetime.now() - datetime.timedelta(days_ago) + self.post_question( + user = self.u1, + timestamp = long_ago + ) + management.call_command('send_unanswered_question_reminders') + outbox = django.core.mail.outbox + self.assertEqual(len(outbox), 1) -- cgit v1.2.3-1-g7c22 From 17d6da4a1fdd927fe8453c44ec1d0dfb41001389 Mon Sep 17 00:00:00 2001 From: Andrei Date: Fri, 15 Apr 2011 23:23:02 -0400 Subject: basic correctness test passes --- .../commands/send_unanswered_question_reminders.py | 40 +++++++++++++--------- askbot/models/__init__.py | 6 ++-- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/askbot/management/commands/send_unanswered_question_reminders.py b/askbot/management/commands/send_unanswered_question_reminders.py index 69c663cf..50ae6451 100644 --- a/askbot/management/commands/send_unanswered_question_reminders.py +++ b/askbot/management/commands/send_unanswered_question_reminders.py @@ -25,40 +25,46 @@ class Command(NoArgsCommand): questions = models.Question.objects.exclude( closed = True ).exclude( - deleted = False - ).exclude( + deleted = True + ).filter( added_at__lt = cutoff_date ).filter( - answer_count__gt = 0 + answer_count = 0 ).order_by('-added_at') #for all users, excluding blocked #for each user, select a tag filtered subset #format the email reminder and send it for user in models.User.objects.exclude(status = 'b'): user_questions = questions.exclude(author = user) - user_questions = user.get_tag_filtered_questions(questions) + user_questions = user.get_tag_filtered_questions(user_questions) final_question_list = list() #todo: rewrite using query set filter #may be a lot more efficient for question in user_questions: activity_type = const.TYPE_ACTIVITY_UNANSWERED_REMINDER_SENT - activity, created = models.Activity.objects.get_or_create( - user = user, - question = question, - activity_type = activity_type - ) - - now = datetime.datetime.now() - recurrence_delay = datetime.timedelta( - askbot_settings.UNANSWERED_REMINDER_FREQUENCY - ) - if created == False: - if activity.active_at >= now + recurrence_delay: + try: + activity = models.Activity.objects.get( + user = user, + question = question, + activity_type = activity_type + ) + now = datetime.datetime.now() + recurrence_delay = datetime.timedelta( + askbot_settings.UNANSWERED_REMINDER_FREQUENCY + ) + if now < activity.active_at + recurrence_delay: continue - + except models.Activity.DoesNotExist: + activity = models.Activity( + user = user, + question = question, + activity_type = activity_type, + content_object = question, + ) activity.active_at = datetime.datetime.now() activity.save() + final_question_list.append(question) question_count = len(final_question_list) if question_count == 0: diff --git a/askbot/models/__init__.py b/askbot/models/__init__.py index b5c239dd..b11cb976 100644 --- a/askbot/models/__init__.py +++ b/askbot/models/__init__.py @@ -1500,7 +1500,7 @@ def user_get_tag_filtered_questions(self, questions = None): if questions == None: questions = Question.objects.all() - if self.email_tag_filter_strategy == 'ignored': + if self.email_tag_filter_strategy == const.EXCLUDE_IGNORED: ignored_tags = Tag.objects.filter( user_selections__reason = 'bad', @@ -1515,7 +1515,7 @@ def user_get_tag_filtered_questions(self, questions = None): ).exclude( tags__in = ignored_by_wildcards ) - else: + elif self.email_tag_filter_strategy == const.INCLUDE_INTERESTING: selected_tags = Tag.objects.filter( user_selections__reason = 'good', user_selections__user = self @@ -1528,6 +1528,8 @@ def user_get_tag_filtered_questions(self, questions = None): | models.Q(tags__in = list(selected_by_wildcards)) return questions.filter( tag_filter ) + else: + return questions def get_messages(self): messages = [] -- cgit v1.2.3-1-g7c22