487 lines
21 KiB
Python
487 lines
21 KiB
Python
import datetime
|
|
from django.core.management.base import NoArgsCommand
|
|
from django.core.urlresolvers import reverse
|
|
from django.db import connection
|
|
from django.db.models import Q, F
|
|
from askbot.models import User, Post, PostRevision, Thread
|
|
from askbot.models import Activity, EmailFeedSetting
|
|
from django.template.loader import get_template
|
|
from django.utils.translation import ugettext as _
|
|
from django.utils.translation import ungettext
|
|
from django.utils.translation import activate as activate_language
|
|
from django.conf import settings as django_settings
|
|
from askbot.conf import settings as askbot_settings
|
|
from django.utils.datastructures import SortedDict
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from askbot import const
|
|
from askbot import mail
|
|
from askbot.utils.slug import slugify
|
|
from askbot.utils.html import site_url
|
|
|
|
DEBUG_THIS_COMMAND = False
|
|
|
|
def get_all_origin_posts(mentions):
|
|
origin_posts = set()
|
|
for mention in mentions:
|
|
post = mention.content_object
|
|
origin_posts.add(post.get_origin_post())
|
|
return list(origin_posts)
|
|
|
|
#todo: refactor this as class
|
|
def extend_question_list(
|
|
src, dst, cutoff_time = None,
|
|
limit=False, add_mention=False,
|
|
add_comment = False,
|
|
languages=None
|
|
):
|
|
"""src is a query set with questions
|
|
or None
|
|
dst - is an ordered dictionary
|
|
update reporting cutoff time for each question
|
|
to the latest value to be more permissive about updates
|
|
"""
|
|
if src is None:#is not QuerySet
|
|
return #will not do anything if subscription of this type is not used
|
|
if limit and len(dst.keys()) >= askbot_settings.MAX_ALERTS_PER_EMAIL:
|
|
return
|
|
if cutoff_time is None:
|
|
if hasattr(src, 'cutoff_time'):
|
|
cutoff_time = src.cutoff_time
|
|
else:
|
|
raise ValueError('cutoff_time is a mandatory parameter')
|
|
|
|
for q in src:
|
|
if languages and q.language_code not in languages:
|
|
continue
|
|
if q in dst:
|
|
meta_data = dst[q]
|
|
else:
|
|
meta_data = {'cutoff_time': cutoff_time}
|
|
dst[q] = meta_data
|
|
|
|
if cutoff_time > meta_data['cutoff_time']:
|
|
#the latest cutoff time wins for a given question
|
|
#if the question falls into several subscription groups
|
|
#this makes mailer more eager in sending email
|
|
meta_data['cutoff_time'] = cutoff_time
|
|
if add_mention:
|
|
if 'mentions' in meta_data:
|
|
meta_data['mentions'] += 1
|
|
else:
|
|
meta_data['mentions'] = 1
|
|
if add_comment:
|
|
if 'comments' in meta_data:
|
|
meta_data['comments'] += 1
|
|
else:
|
|
meta_data['comments'] = 1
|
|
|
|
def format_action_count(string, number, output):
|
|
if number > 0:
|
|
output.append(_(string) % {'num':number})
|
|
|
|
class Command(NoArgsCommand):
|
|
def handle_noargs(self, **options):
|
|
if askbot_settings.ENABLE_EMAIL_ALERTS:
|
|
try:
|
|
try:
|
|
self.send_email_alerts()
|
|
except Exception, e:
|
|
print e
|
|
finally:
|
|
connection.close()
|
|
|
|
def get_updated_questions_for_user(self, user):
|
|
"""
|
|
retreive relevant question updates for the user
|
|
according to their subscriptions and recorded question
|
|
views
|
|
"""
|
|
|
|
user_feeds = EmailFeedSetting.objects.filter(
|
|
subscriber=user
|
|
).exclude(
|
|
frequency__in=('n', 'i')
|
|
)
|
|
|
|
should_proceed = False
|
|
for feed in user_feeds:
|
|
if feed.should_send_now() == True:
|
|
should_proceed = True
|
|
break
|
|
|
|
#shortcircuit - if there is no ripe feed to work on for this user
|
|
if should_proceed == False:
|
|
return {}
|
|
|
|
#these are placeholders for separate query sets per question group
|
|
#there are four groups - one for each EmailFeedSetting.feed_type
|
|
#and each group has subtypes A and B
|
|
#that's because of the strange thing commented below
|
|
#see note on Q and F objects marked with todo tag
|
|
q_sel_A = None
|
|
q_sel_B = None
|
|
|
|
q_ask_A = None
|
|
q_ask_B = None
|
|
|
|
q_ans_A = None
|
|
q_ans_B = None
|
|
|
|
q_all_A = None
|
|
q_all_B = None
|
|
|
|
#base question query set for this user
|
|
#basic things - not deleted, not closed, not too old
|
|
#not last edited by the same user
|
|
base_qs = Post.objects.get_questions().exclude(
|
|
thread__last_activity_by=user
|
|
).exclude(
|
|
thread__last_activity_at__lt=user.date_joined#exclude old stuff
|
|
).exclude(
|
|
deleted=True
|
|
).exclude(
|
|
thread__closed=True
|
|
).order_by('-thread__last_activity_at')
|
|
|
|
if askbot_settings.ENABLE_CONTENT_MODERATION:
|
|
base_qs = base_qs.filter(approved = True)
|
|
#todo: for some reason filter on did not work as expected ~Q(viewed__who=user) |
|
|
# Q(viewed__who=user,viewed__when__lt=F('thread__last_activity_at'))
|
|
#returns way more questions than you might think it should
|
|
#so because of that I've created separate query sets Q_set2 and Q_set3
|
|
#plus two separate queries run faster!
|
|
|
|
#build two two queries based
|
|
|
|
#questions that are not seen by the user at all
|
|
not_seen_qs = base_qs.filter(~Q(viewed__who=user))
|
|
#questions that were seen, but before last modification
|
|
seen_before_last_mod_qs = base_qs.filter(
|
|
Q(
|
|
viewed__who=user,
|
|
viewed__when__lt=F('thread__last_activity_at')
|
|
)
|
|
)
|
|
|
|
#shorten variables for convenience
|
|
Q_set_A = not_seen_qs
|
|
Q_set_B = seen_before_last_mod_qs
|
|
|
|
if getattr(django_settings,'ASKBOT_MULTILINGUAL', False):
|
|
languages = user.languages.split()
|
|
else:
|
|
languages = None
|
|
|
|
for feed in user_feeds:
|
|
if feed.feed_type == 'm_and_c':
|
|
#alerts on mentions and comments are processed separately
|
|
#because comments to questions do not trigger change of last_updated
|
|
#this may be changed in the future though, see
|
|
#http://askbot.org/en/question/96/
|
|
continue
|
|
|
|
#each group of updates represented by the corresponding
|
|
#query set has it's own cutoff time
|
|
#that cutoff time is computed for each user individually
|
|
#and stored as a parameter "cutoff_time"
|
|
|
|
#we won't send email for a given question if an email has been
|
|
#sent after that cutoff_time
|
|
if feed.should_send_now():
|
|
if DEBUG_THIS_COMMAND == False:
|
|
feed.mark_reported_now()
|
|
cutoff_time = feed.get_previous_report_cutoff_time()
|
|
|
|
if feed.feed_type == 'q_sel':
|
|
q_sel_A = Q_set_A.filter(thread__followed_by=user)
|
|
q_sel_A.cutoff_time = cutoff_time #store cutoff time per query set
|
|
q_sel_B = Q_set_B.filter(thread__followed_by=user)
|
|
q_sel_B.cutoff_time = cutoff_time #store cutoff time per query set
|
|
|
|
elif feed.feed_type == 'q_ask':
|
|
q_ask_A = Q_set_A.filter(author=user)
|
|
q_ask_A.cutoff_time = cutoff_time
|
|
q_ask_B = Q_set_B.filter(author=user)
|
|
q_ask_B.cutoff_time = cutoff_time
|
|
|
|
elif feed.feed_type == 'q_ans':
|
|
q_ans_A = Q_set_A.filter(thread__posts__author=user, thread__posts__post_type='answer')
|
|
q_ans_A = q_ans_A[:askbot_settings.MAX_ALERTS_PER_EMAIL]
|
|
q_ans_A.cutoff_time = cutoff_time
|
|
|
|
q_ans_B = Q_set_B.filter(thread__posts__author=user, thread__posts__post_type='answer')
|
|
q_ans_B = q_ans_B[:askbot_settings.MAX_ALERTS_PER_EMAIL]
|
|
q_ans_B.cutoff_time = cutoff_time
|
|
|
|
elif feed.feed_type == 'q_all':
|
|
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]
|
|
q_all_A.cutoff_time = cutoff_time
|
|
q_all_B.cutoff_time = cutoff_time
|
|
|
|
#build ordered list questions for the email report
|
|
q_list = SortedDict()
|
|
|
|
#todo: refactor q_list into a separate class?
|
|
extend_question_list(q_sel_A, q_list, languages=languages)
|
|
extend_question_list(q_sel_B, q_list, languages=languages)
|
|
|
|
#build list of comment and mention responses here
|
|
#it is separate because posts are not marked as changed
|
|
#when people add comments
|
|
#mention responses could be collected in the loop above, but
|
|
#it is inconvenient, because feed_type m_and_c bundles the two
|
|
#also we collect metadata for these here
|
|
try:
|
|
feed = user_feeds.get(feed_type='m_and_c')
|
|
if feed.should_send_now():
|
|
cutoff_time = feed.get_previous_report_cutoff_time()
|
|
comments = Post.objects.get_comments().filter(
|
|
added_at__lt = cutoff_time,
|
|
).exclude(
|
|
author = user
|
|
)
|
|
q_commented = list()
|
|
for c in comments:
|
|
post = c.parent
|
|
if post.author != user:
|
|
continue
|
|
|
|
#skip is post was seen by the user after
|
|
#the comment posting time
|
|
q_commented.append(post.get_origin_post())
|
|
|
|
extend_question_list(
|
|
q_commented,
|
|
q_list,
|
|
cutoff_time=cutoff_time,
|
|
add_comment=True,
|
|
languages=languages
|
|
)
|
|
|
|
mentions = Activity.objects.get_mentions(
|
|
mentioned_at__lt = cutoff_time,
|
|
mentioned_whom = user
|
|
)
|
|
|
|
#print 'have %d mentions' % len(mentions)
|
|
#MM = Activity.objects.filter(activity_type = const.TYPE_ACTIVITY_MENTION)
|
|
#print 'have %d total mentions' % len(MM)
|
|
#for m in MM:
|
|
# print m
|
|
|
|
mention_posts = get_all_origin_posts(mentions)
|
|
q_mentions_id = [q.id for q in mention_posts]
|
|
|
|
q_mentions_A = Q_set_A.filter(id__in = q_mentions_id)
|
|
q_mentions_A.cutoff_time = cutoff_time
|
|
extend_question_list(
|
|
q_mentions_A,
|
|
q_list,
|
|
add_mention=True,
|
|
languages=languages
|
|
)
|
|
|
|
q_mentions_B = Q_set_B.filter(id__in = q_mentions_id)
|
|
q_mentions_B.cutoff_time = cutoff_time
|
|
extend_question_list(
|
|
q_mentions_B,
|
|
q_list,
|
|
add_mention=True,
|
|
languages=languages
|
|
)
|
|
except EmailFeedSetting.DoesNotExist:
|
|
pass
|
|
|
|
if user.email_tag_filter_strategy == const.INCLUDE_INTERESTING:
|
|
extend_question_list(q_all_A, q_list, languages=languages)
|
|
extend_question_list(q_all_B, q_list, languages=languages)
|
|
|
|
extend_question_list(q_ask_A, q_list, limit=True, languages=languages)
|
|
extend_question_list(q_ask_B, q_list, limit=True, languages=languages)
|
|
|
|
extend_question_list(q_ans_A, q_list, limit=True, languages=languages)
|
|
extend_question_list(q_ans_B, q_list, limit=True, languages=languages)
|
|
|
|
if user.email_tag_filter_strategy == const.EXCLUDE_IGNORED:
|
|
extend_question_list(q_all_A, q_list, limit=True, languages=languages)
|
|
extend_question_list(q_all_B, q_list, limit=True, languages=languages)
|
|
|
|
ctype = ContentType.objects.get_for_model(Post)
|
|
EMAIL_UPDATE_ACTIVITY = const.TYPE_ACTIVITY_EMAIL_UPDATE_SENT
|
|
|
|
#up to this point we still don't know if emails about
|
|
#collected questions were sent recently
|
|
#the next loop examines activity record and decides
|
|
#for each question, whether it needs to be included or not
|
|
#into the report
|
|
|
|
for q, meta_data in q_list.items():
|
|
#this loop edits meta_data for each question
|
|
#so that user will receive counts on new edits new answers, etc
|
|
#and marks questions that need to be skipped
|
|
#because an email about them was sent recently enough
|
|
|
|
#also it keeps a record of latest email activity per question per user
|
|
try:
|
|
#todo: is it possible to use content_object here, instead of
|
|
#content type and object_id pair?
|
|
update_info = Activity.objects.get(
|
|
user=user,
|
|
content_type=ctype,
|
|
object_id=q.id,
|
|
activity_type=EMAIL_UPDATE_ACTIVITY
|
|
)
|
|
emailed_at = update_info.active_at
|
|
except Activity.DoesNotExist:
|
|
update_info = Activity(
|
|
user=user,
|
|
content_object=q,
|
|
activity_type=EMAIL_UPDATE_ACTIVITY
|
|
)
|
|
emailed_at = datetime.datetime(1970, 1, 1)#long time ago
|
|
except Activity.MultipleObjectsReturned:
|
|
raise Exception(
|
|
'server error - multiple question email activities '
|
|
'found per user-question pair'
|
|
)
|
|
|
|
cutoff_time = meta_data['cutoff_time']#cutoff time for the question
|
|
|
|
#skip question if we need to wait longer because
|
|
#the delay before the next email has not yet elapsed
|
|
#or if last email was sent after the most recent modification
|
|
if emailed_at > cutoff_time or emailed_at > q.thread.last_activity_at:
|
|
meta_data['skip'] = True
|
|
continue
|
|
|
|
#collect info on all sorts of news that happened after
|
|
#the most recent emailing to the user about this question
|
|
q_rev = q.revisions.filter(revised_at__gt=emailed_at)
|
|
q_rev = q_rev.exclude(author=user)
|
|
|
|
#now update all sorts of metadata per question
|
|
meta_data['q_rev'] = len(q_rev)
|
|
if len(q_rev) > 0 and q.added_at == q_rev[0].revised_at:
|
|
meta_data['q_rev'] = 0
|
|
meta_data['new_q'] = True
|
|
else:
|
|
meta_data['new_q'] = False
|
|
|
|
new_ans = Post.objects.get_answers(user).filter(
|
|
thread=q.thread,
|
|
added_at__gt=emailed_at,
|
|
deleted=False,
|
|
)
|
|
new_ans = new_ans.exclude(author=user)
|
|
meta_data['new_ans'] = len(new_ans)
|
|
|
|
ans_ids = Post.objects.get_answers(user).filter(
|
|
thread=q.thread,
|
|
added_at__gt=emailed_at,
|
|
deleted=False,
|
|
).values_list(
|
|
'id', flat = True
|
|
)
|
|
ans_rev = PostRevision.objects.filter(post__id__in = ans_ids)
|
|
ans_rev = ans_rev.exclude(author=user).distinct()
|
|
meta_data['ans_rev'] = len(ans_rev)
|
|
|
|
comments = meta_data.get('comments', 0)
|
|
mentions = meta_data.get('mentions', 0)
|
|
|
|
#print meta_data
|
|
#finally skip question if there are no news indeed
|
|
if len(q_rev) + len(new_ans) + len(ans_rev) + comments + mentions == 0:
|
|
meta_data['skip'] = True
|
|
#print 'skipping'
|
|
else:
|
|
meta_data['skip'] = False
|
|
#print 'not skipping'
|
|
update_info.active_at = datetime.datetime.now()
|
|
if DEBUG_THIS_COMMAND == False:
|
|
update_info.save() #save question email update activity
|
|
#q_list is actually an ordered dictionary
|
|
#print 'user %s gets %d' % (user.username, len(q_list.keys()))
|
|
#todo: sort question list by update time
|
|
return q_list
|
|
|
|
def send_email_alerts(self):
|
|
#does not change the database, only sends the email
|
|
#todo: move this to template
|
|
activate_language(django_settings.LANGUAGE_CODE)
|
|
template = get_template('email/delayed_email_alert.html')
|
|
for user in User.objects.all():
|
|
user.add_missing_askbot_subscriptions()
|
|
#todo: q_list is a dictionary, not a list
|
|
q_list = self.get_updated_questions_for_user(user)
|
|
if len(q_list.keys()) == 0:
|
|
continue
|
|
num_q = 0
|
|
for question, meta_data in q_list.items():
|
|
if meta_data['skip']:
|
|
del q_list[question]
|
|
else:
|
|
num_q += 1
|
|
if num_q > 0:
|
|
threads = Thread.objects.filter(id__in=[qq.thread_id for qq in q_list.keys()])
|
|
tag_summary = Thread.objects.get_tag_summary_from_threads(threads)
|
|
|
|
question_count = len(q_list.keys())
|
|
|
|
subject_line = ungettext(
|
|
'%(question_count)d update about %(topics)s',
|
|
'%(question_count)d updates about %(topics)s',
|
|
question_count
|
|
) % {
|
|
'question_count': question_count,
|
|
'topics': tag_summary
|
|
}
|
|
|
|
items_added = 0
|
|
items_unreported = 0
|
|
questions_data = list()
|
|
for q, meta_data in q_list.items():
|
|
act_list = []
|
|
if meta_data['skip']:
|
|
continue
|
|
if items_added >= askbot_settings.MAX_ALERTS_PER_EMAIL:
|
|
items_unreported = num_q - items_added #may be inaccurate actually, but it's ok
|
|
break
|
|
else:
|
|
items_added += 1
|
|
if meta_data['new_q']:
|
|
act_list.append(_('new question'))
|
|
format_action_count(_('%(num)d rev'), meta_data['q_rev'], act_list)
|
|
format_action_count(_('%(num)d ans'), meta_data['new_ans'], act_list)
|
|
#format_action_count(_('%(num)d ans rev'), meta_data['ans_rev'], act_list)
|
|
questions_data.append({
|
|
'url': site_url(q.get_absolute_url()),
|
|
'info': ', '.join(act_list),
|
|
'title': q.thread.title
|
|
})
|
|
|
|
activate_language(user.get_primary_language())
|
|
text = template.render({
|
|
'recipient_user': user,
|
|
'questions': questions_data,
|
|
'name': user.username,
|
|
'admin_email': askbot_settings.ADMIN_EMAIL,
|
|
'site_name': askbot_settings.APP_SHORT_NAME,
|
|
'is_multilingual': django_settings.ASKBOT_MULTILINGUAL
|
|
})
|
|
|
|
if DEBUG_THIS_COMMAND == True:
|
|
recipient_email = askbot_settings.ADMIN_EMAIL
|
|
else:
|
|
recipient_email = user.email
|
|
|
|
mail.send_mail(
|
|
subject_line = subject_line,
|
|
body_text = text,
|
|
recipient_list = [recipient_email]
|
|
)
|