2013-10-21 23:25:53 +02:00
|
|
|
from __future__ import absolute_import
|
2016-12-21 13:17:53 +01:00
|
|
|
from typing import Any, Callable, Iterable, Tuple, Text
|
2013-10-21 23:25:53 +02:00
|
|
|
|
|
|
|
from collections import defaultdict
|
|
|
|
import datetime
|
2016-06-04 21:50:32 +02:00
|
|
|
import six
|
2013-10-21 23:25:53 +02:00
|
|
|
|
2016-06-04 21:50:32 +02:00
|
|
|
from django.db.models import Q, QuerySet
|
2013-10-21 23:25:53 +02:00
|
|
|
from django.template import loader
|
2013-11-16 00:54:12 +01:00
|
|
|
from django.conf import settings
|
2013-10-21 23:25:53 +02:00
|
|
|
|
2014-01-24 22:29:17 +01:00
|
|
|
from zerver.lib.notifications import build_message_list, hashchange_encode, \
|
2013-12-02 01:39:10 +01:00
|
|
|
send_future_email, one_click_unsubscribe_link
|
2013-10-21 23:25:53 +02:00
|
|
|
from zerver.models import UserProfile, UserMessage, Recipient, Stream, \
|
2014-01-24 23:30:53 +01:00
|
|
|
Subscription, get_active_streams
|
2016-11-08 10:07:47 +01:00
|
|
|
from zerver.context_processors import common_context
|
2013-10-21 23:25:53 +02:00
|
|
|
|
2014-01-08 17:33:12 +01:00
|
|
|
import logging
|
|
|
|
|
|
|
|
log_format = "%(asctime)s: %(message)s"
|
|
|
|
logging.basicConfig(format=log_format)
|
|
|
|
|
|
|
|
formatter = logging.Formatter(log_format)
|
|
|
|
file_handler = logging.FileHandler(settings.DIGEST_LOG_PATH)
|
|
|
|
file_handler.setFormatter(formatter)
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
logger.setLevel(logging.DEBUG)
|
|
|
|
logger.addHandler(file_handler)
|
|
|
|
|
2013-10-21 23:25:53 +02:00
|
|
|
# Digests accumulate 4 types of interesting traffic for a user:
|
|
|
|
# 1. Missed PMs
|
|
|
|
# 2. New streams
|
|
|
|
# 3. New users
|
|
|
|
# 4. Interesting stream traffic, as determined by the longest and most
|
|
|
|
# diversely comment upon topics.
|
|
|
|
|
|
|
|
def gather_hot_conversations(user_profile, stream_messages):
|
2016-06-04 21:50:32 +02:00
|
|
|
# type: (UserProfile, QuerySet) -> List[Dict[str, Any]]
|
2013-10-21 23:25:53 +02:00
|
|
|
# Gather stream conversations of 2 types:
|
|
|
|
# 1. long conversations
|
|
|
|
# 2. conversations where many different people participated
|
|
|
|
#
|
|
|
|
# Returns a list of dictionaries containing the templating
|
|
|
|
# information for each hot conversation.
|
|
|
|
|
2016-12-21 13:17:53 +01:00
|
|
|
conversation_length = defaultdict(int) # type: Dict[Tuple[int, Text], int]
|
|
|
|
conversation_diversity = defaultdict(set) # type: Dict[Tuple[int, Text], Set[Text]]
|
2013-10-21 23:25:53 +02:00
|
|
|
for user_message in stream_messages:
|
2013-12-31 22:45:21 +01:00
|
|
|
if not user_message.message.sent_by_human():
|
|
|
|
# Don't include automated messages in the count.
|
|
|
|
continue
|
|
|
|
|
2013-10-21 23:25:53 +02:00
|
|
|
key = (user_message.message.recipient.type_id,
|
|
|
|
user_message.message.subject)
|
|
|
|
conversation_diversity[key].add(
|
|
|
|
user_message.message.sender.full_name)
|
|
|
|
conversation_length[key] += 1
|
|
|
|
|
2016-01-25 01:27:18 +01:00
|
|
|
diversity_list = list(conversation_diversity.items())
|
2013-10-21 23:25:53 +02:00
|
|
|
diversity_list.sort(key=lambda entry: len(entry[1]), reverse=True)
|
|
|
|
|
2016-01-25 01:27:18 +01:00
|
|
|
length_list = list(conversation_length.items())
|
2013-10-21 23:25:53 +02:00
|
|
|
length_list.sort(key=lambda entry: entry[1], reverse=True)
|
|
|
|
|
|
|
|
# Get up to the 4 best conversations from the diversity list
|
|
|
|
# and length list, filtering out overlapping conversations.
|
|
|
|
hot_conversations = [elt[0] for elt in diversity_list[:2]]
|
|
|
|
for candidate, _ in length_list:
|
|
|
|
if candidate not in hot_conversations:
|
|
|
|
hot_conversations.append(candidate)
|
|
|
|
if len(hot_conversations) >= 4:
|
|
|
|
break
|
|
|
|
|
2013-12-13 20:18:44 +01:00
|
|
|
# There was so much overlap between the diversity and length lists that we
|
|
|
|
# still have < 4 conversations. Try to use remaining diversity items to pad
|
|
|
|
# out the hot conversations.
|
|
|
|
num_convos = len(hot_conversations)
|
|
|
|
if num_convos < 4:
|
|
|
|
hot_conversations.extend([elt[0] for elt in diversity_list[num_convos:4]])
|
|
|
|
|
2013-10-21 23:25:53 +02:00
|
|
|
hot_conversation_render_payloads = []
|
|
|
|
for h in hot_conversations:
|
|
|
|
stream_id, subject = h
|
|
|
|
users = list(conversation_diversity[h])
|
|
|
|
count = conversation_length[h]
|
|
|
|
|
|
|
|
# We'll display up to 2 messages from the conversation.
|
2016-12-03 18:07:49 +01:00
|
|
|
first_few_messages = [user_message.message for user_message in
|
2016-12-11 14:30:45 +01:00
|
|
|
stream_messages.filter(
|
2016-12-02 08:15:16 +01:00
|
|
|
message__recipient__type_id=stream_id,
|
|
|
|
message__subject=subject)[:2]]
|
2013-10-21 23:25:53 +02:00
|
|
|
|
2013-10-25 18:53:35 +02:00
|
|
|
teaser_data = {"participants": users,
|
2013-10-21 23:25:53 +02:00
|
|
|
"count": count - len(first_few_messages),
|
2016-12-02 08:15:16 +01:00
|
|
|
"first_few_messages": build_message_list(
|
|
|
|
user_profile, first_few_messages)}
|
2013-10-21 23:25:53 +02:00
|
|
|
|
|
|
|
hot_conversation_render_payloads.append(teaser_data)
|
2013-12-13 20:26:44 +01:00
|
|
|
return hot_conversation_render_payloads
|
2013-10-21 23:25:53 +02:00
|
|
|
|
|
|
|
def gather_new_users(user_profile, threshold):
|
2016-12-21 13:17:53 +01:00
|
|
|
# type: (UserProfile, datetime.datetime) -> Tuple[int, List[Text]]
|
2013-10-21 23:25:53 +02:00
|
|
|
# Gather information on users in the realm who have recently
|
|
|
|
# joined.
|
2016-07-27 01:45:29 +02:00
|
|
|
if user_profile.realm.is_zephyr_mirror_realm:
|
2016-01-25 23:42:16 +01:00
|
|
|
new_users = [] # type: List[UserProfile]
|
2013-12-16 22:26:04 +01:00
|
|
|
else:
|
|
|
|
new_users = list(UserProfile.objects.filter(
|
|
|
|
realm=user_profile.realm, date_joined__gt=threshold,
|
|
|
|
is_bot=False))
|
2013-10-25 18:53:35 +02:00
|
|
|
user_names = [user.full_name for user in new_users]
|
2013-10-21 23:25:53 +02:00
|
|
|
|
2013-10-25 18:53:35 +02:00
|
|
|
return len(user_names), user_names
|
2013-10-21 23:25:53 +02:00
|
|
|
|
|
|
|
def gather_new_streams(user_profile, threshold):
|
2016-12-21 13:17:53 +01:00
|
|
|
# type: (UserProfile, datetime.datetime) -> Tuple[int, Dict[str, List[Text]]]
|
2016-07-27 01:45:29 +02:00
|
|
|
if user_profile.realm.is_zephyr_mirror_realm:
|
2016-01-25 23:42:16 +01:00
|
|
|
new_streams = [] # type: List[Stream]
|
2013-12-13 19:46:47 +01:00
|
|
|
else:
|
2014-01-24 23:30:53 +01:00
|
|
|
new_streams = list(get_active_streams(user_profile.realm).filter(
|
|
|
|
invite_only=False, date_created__gt=threshold))
|
2013-10-21 23:25:53 +02:00
|
|
|
|
2017-01-22 07:20:29 +01:00
|
|
|
base_url = u"%s/#narrow/stream/" % (user_profile.realm.uri,)
|
2013-10-25 18:53:35 +02:00
|
|
|
|
|
|
|
streams_html = []
|
|
|
|
streams_plain = []
|
|
|
|
|
2013-10-21 23:25:53 +02:00
|
|
|
for stream in new_streams:
|
|
|
|
narrow_url = base_url + hashchange_encode(stream.name)
|
2016-06-13 10:14:33 +02:00
|
|
|
stream_link = u"<a href='%s'>%s</a>" % (narrow_url, stream.name)
|
2013-10-25 18:53:35 +02:00
|
|
|
streams_html.append(stream_link)
|
|
|
|
streams_plain.append(stream.name)
|
2013-10-21 23:25:53 +02:00
|
|
|
|
|
|
|
return len(new_streams), {"html": streams_html, "plain": streams_plain}
|
|
|
|
|
|
|
|
def enough_traffic(unread_pms, hot_conversations, new_streams, new_users):
|
2016-12-21 13:17:53 +01:00
|
|
|
# type: (Text, Text, int, int) -> bool
|
2013-10-21 23:25:53 +02:00
|
|
|
if unread_pms or hot_conversations:
|
|
|
|
# If you have any unread traffic, good enough.
|
|
|
|
return True
|
|
|
|
if new_streams and new_users:
|
|
|
|
# If you somehow don't have any traffic but your realm did get
|
|
|
|
# new streams and users, good enough.
|
|
|
|
return True
|
|
|
|
return False
|
|
|
|
|
2017-01-05 02:10:12 +01:00
|
|
|
def send_digest_email(user_profile, subject, html_content, text_content):
|
|
|
|
# type: (UserProfile, Text, Text, Text) -> None
|
2013-12-02 02:35:51 +01:00
|
|
|
recipients = [{'email': user_profile.email, 'name': user_profile.full_name}]
|
2013-12-18 19:04:40 +01:00
|
|
|
sender = {'email': settings.NOREPLY_EMAIL_ADDRESS, 'name': 'Zulip'}
|
2013-12-02 02:35:51 +01:00
|
|
|
|
|
|
|
# Send now, through Mandrill.
|
|
|
|
send_future_email(recipients, html_content, text_content, subject,
|
|
|
|
delay=datetime.timedelta(0), sender=sender,
|
|
|
|
tags=["digest-emails"])
|
|
|
|
|
2013-10-21 23:25:53 +02:00
|
|
|
def handle_digest_email(user_profile_id, cutoff):
|
2016-10-11 14:20:05 +02:00
|
|
|
# type: (int, float) -> None
|
2016-11-28 23:29:01 +01:00
|
|
|
user_profile = UserProfile.objects.get(id=user_profile_id)
|
2013-10-21 23:25:53 +02:00
|
|
|
# Convert from epoch seconds to a datetime object.
|
2016-06-04 21:50:32 +02:00
|
|
|
cutoff_date = datetime.datetime.utcfromtimestamp(int(cutoff))
|
2013-10-21 23:25:53 +02:00
|
|
|
|
|
|
|
all_messages = UserMessage.objects.filter(
|
|
|
|
user_profile=user_profile,
|
2016-06-04 21:50:32 +02:00
|
|
|
message__pub_date__gt=cutoff_date).order_by("message__pub_date")
|
2013-10-21 23:25:53 +02:00
|
|
|
|
2016-11-08 10:07:47 +01:00
|
|
|
template_payload = common_context(user_profile)
|
|
|
|
|
2013-10-21 23:25:53 +02:00
|
|
|
# Start building email template data.
|
2016-11-08 10:07:47 +01:00
|
|
|
template_payload.update({
|
2013-12-02 01:39:10 +01:00
|
|
|
'name': user_profile.full_name,
|
|
|
|
'unsubscribe_link': one_click_unsubscribe_link(user_profile, "digest")
|
2016-11-08 10:07:47 +01:00
|
|
|
})
|
2013-10-21 23:25:53 +02:00
|
|
|
|
|
|
|
# Gather recent missed PMs, re-using the missed PM email logic.
|
2013-12-04 23:49:43 +01:00
|
|
|
# You can't have an unread message that you sent, but when testing
|
|
|
|
# this causes confusion so filter your messages out.
|
|
|
|
pms = all_messages.filter(
|
2016-12-02 08:15:05 +01:00
|
|
|
~Q(message__recipient__type=Recipient.STREAM) &
|
|
|
|
~Q(message__sender=user_profile))
|
2013-10-21 23:25:53 +02:00
|
|
|
|
|
|
|
# Show up to 4 missed PMs.
|
|
|
|
pms_limit = 4
|
|
|
|
|
|
|
|
template_payload['unread_pms'] = build_message_list(
|
|
|
|
user_profile, [pm.message for pm in pms[:pms_limit]])
|
|
|
|
template_payload['remaining_unread_pms_count'] = min(0, len(pms) - pms_limit)
|
|
|
|
|
2016-12-03 18:07:49 +01:00
|
|
|
home_view_recipients = [sub.recipient for sub in
|
2016-11-30 14:17:35 +01:00
|
|
|
Subscription.objects.filter(
|
|
|
|
user_profile=user_profile,
|
|
|
|
active=True,
|
|
|
|
in_home_view=True)]
|
2013-10-21 23:25:53 +02:00
|
|
|
|
|
|
|
stream_messages = all_messages.filter(
|
|
|
|
message__recipient__type=Recipient.STREAM,
|
|
|
|
message__recipient__in=home_view_recipients)
|
|
|
|
|
|
|
|
# Gather hot conversations.
|
|
|
|
template_payload["hot_conversations"] = gather_hot_conversations(
|
|
|
|
user_profile, stream_messages)
|
|
|
|
|
|
|
|
# Gather new streams.
|
|
|
|
new_streams_count, new_streams = gather_new_streams(
|
2016-06-04 21:50:32 +02:00
|
|
|
user_profile, cutoff_date)
|
2013-10-21 23:25:53 +02:00
|
|
|
template_payload["new_streams"] = new_streams
|
|
|
|
template_payload["new_streams_count"] = new_streams_count
|
|
|
|
|
|
|
|
# Gather users who signed up recently.
|
|
|
|
new_users_count, new_users = gather_new_users(
|
2016-06-04 21:50:32 +02:00
|
|
|
user_profile, cutoff_date)
|
2013-10-21 23:25:53 +02:00
|
|
|
template_payload["new_users"] = new_users
|
|
|
|
|
2017-01-05 02:10:12 +01:00
|
|
|
subject = loader.render_to_string('zerver/emails/digest/digest_email.subject').strip()
|
2013-10-21 23:25:53 +02:00
|
|
|
text_content = loader.render_to_string(
|
|
|
|
'zerver/emails/digest/digest_email.txt', template_payload)
|
|
|
|
html_content = loader.render_to_string(
|
2017-01-05 02:18:15 +01:00
|
|
|
'zerver/emails/digest/digest_email.html', template_payload)
|
2013-10-21 23:25:53 +02:00
|
|
|
|
|
|
|
# We don't want to send emails containing almost no information.
|
|
|
|
if enough_traffic(template_payload["unread_pms"],
|
|
|
|
template_payload["hot_conversations"],
|
|
|
|
new_streams_count, new_users_count):
|
2014-01-08 17:33:12 +01:00
|
|
|
logger.info("Sending digest email for %s" % (user_profile.email,))
|
2017-01-05 02:10:12 +01:00
|
|
|
send_digest_email(user_profile, subject, html_content, text_content)
|