-
Michael Terry authored
The content highlights code assumed due dates existed on all sections. But we recently broke that assumption. So now we recalculate the spread of sections across the expected duration ourselves rather than rely on due dates.
3f2b2da2
resolvers.py 22.43 KiB
import datetime
import logging
from itertools import groupby
import attr
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.staticfiles.templatetags.staticfiles import static
from django.db.models import F, Q
from django.urls import reverse
from edx_ace.recipient import Recipient
from edx_ace.recipient_resolver import RecipientResolver
from edx_django_utils.monitoring import function_trace, set_custom_metric
from edx_when.api import get_schedules_with_due_date
from opaque_keys.edx.keys import CourseKey
from lms.djangoapps.courseware.utils import verified_upgrade_deadline_link, can_show_verified_upgrade
from lms.djangoapps.discussion.notification_prefs.views import UsernameCipher
from openedx.core.djangoapps.ace_common.template_context import get_base_template_context
from openedx.core.djangoapps.schedules.config import COURSE_UPDATE_SHOW_UNSUBSCRIBE_WAFFLE_SWITCH
from openedx.core.djangoapps.schedules.content_highlights import get_week_highlights, get_next_section_highlights
from openedx.core.djangoapps.schedules.exceptions import CourseUpdateDoesNotExist
from openedx.core.djangoapps.schedules.message_types import CourseUpdate, InstructorLedCourseUpdate
from openedx.core.djangoapps.schedules.models import Schedule, ScheduleExperience
from openedx.core.djangoapps.schedules.utils import PrefixedDebugLoggerMixin
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
from openedx.core.djangolib.translation_utils import translate_date
from openedx.features.course_experience import course_home_url_name
LOG = logging.getLogger(__name__)
DEFAULT_NUM_BINS = 24
RECURRING_NUDGE_NUM_BINS = DEFAULT_NUM_BINS
UPGRADE_REMINDER_NUM_BINS = DEFAULT_NUM_BINS
COURSE_UPDATE_NUM_BINS = DEFAULT_NUM_BINS
@attr.s
class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver):
"""
Identifies learners to send messages to, pulls all needed context and sends a message to each learner.
Note that for performance reasons, it actually enqueues a task to send the message instead of sending the message
directly.
Arguments:
async_send_task -- celery task function that sends the message
site -- Site object that filtered Schedules will be a part of
target_datetime -- datetime that the User's Schedule's schedule_date_field value should fall under
day_offset -- int number of days relative to the Schedule's schedule_date_field that we are targeting
bin_num -- int for selecting the bin of Users whose id % num_bins == bin_num
org_list -- list of course_org names (strings) that the returned Schedules must or must not be in
(default: None)
exclude_orgs -- boolean indicating whether the returned Schedules should exclude (True) the course_orgs in
org_list or strictly include (False) them (default: False)
override_recipient_email -- string email address that should receive all emails instead of the normal
recipient. (default: None)
Static attributes:
schedule_date_field -- the name of the model field that represents the date that offsets should be computed
relative to. For example, if this resolver finds schedules that started 7 days ago
this variable should be set to "start".
num_bins -- the int number of bins to split the users into
experience_filter -- a queryset filter used to select only the users who should be getting this message as part
of their experience. This defaults to users without a specified experience type and those
in the "recurring nudges and upgrade reminder" experience.
"""
async_send_task = attr.ib()
site = attr.ib()
target_datetime = attr.ib()
day_offset = attr.ib()
bin_num = attr.ib()
override_recipient_email = attr.ib(default=None)
schedule_date_field = None
num_bins = DEFAULT_NUM_BINS
experience_filter = (Q(experience__experience_type=ScheduleExperience.EXPERIENCES.default)
| Q(experience__isnull=True))
def __attrs_post_init__(self):
# TODO: in the next refactor of this task, pass in current_datetime instead of reproducing it here
self.current_datetime = self.target_datetime - datetime.timedelta(days=self.day_offset)
def send(self, msg_type):
for (user, language, context) in self.schedules_for_bin():
msg = msg_type.personalize(
Recipient(
user.username,
self.override_recipient_email or user.email,
),
language,
context,
)
with function_trace('enqueue_send_task'):
self.async_send_task.apply_async((self.site.id, str(msg)), retry=False)
@classmethod
def bin_num_for_user_id(cls, user_id):
"""
Returns the bin number used for the given (numeric) user ID.
"""
return user_id % cls.num_bins
def get_schedules_with_target_date_by_bin_and_orgs(
self, order_by='enrollment__user__id'
):
"""
Returns Schedules with the target_date, related to Users whose id matches the bin_num, and filtered by org_list.
Arguments:
order_by -- string for field to sort the resulting Schedules by
"""
target_day = _get_datetime_beginning_of_day(self.target_datetime)
schedule_day_equals_target_day_filter = {
'courseenrollment__schedule__{}__gte'.format(self.schedule_date_field): target_day,
'courseenrollment__schedule__{}__lt'.format(self.schedule_date_field): target_day + datetime.timedelta(days=1),
}
users = User.objects.filter(
courseenrollment__is_active=True,
is_active=True,
**schedule_day_equals_target_day_filter
).annotate(
id_mod=self.bin_num_for_user_id(F('id'))
).filter(
id_mod=self.bin_num
)
schedule_day_equals_target_day_filter = {
'{}__gte'.format(self.schedule_date_field): target_day,
'{}__lt'.format(self.schedule_date_field): target_day + datetime.timedelta(days=1),
}
schedules = Schedule.objects.select_related(
'enrollment__user__profile',
'enrollment__course',
'enrollment__fbeenrollmentexclusion',
).filter(
Q(enrollment__course__end__isnull=True) | Q(
enrollment__course__end__gte=self.current_datetime
),
self.experience_filter,
enrollment__user__in=users,
enrollment__is_active=True,
active=True,
**schedule_day_equals_target_day_filter
).order_by(order_by)
schedules = self.filter_by_org(schedules)
if "read_replica" in settings.DATABASES:
schedules = schedules.using("read_replica")
LOG.info(u'Query = %r', schedules.query.sql_with_params())
with function_trace('schedule_query_set_evaluation'):
# This will run the query and cache all of the results in memory.
num_schedules = len(schedules)
LOG.info(u'Number of schedules = %d', num_schedules)
# This should give us a sense of the volume of data being processed by each task.
set_custom_metric('num_schedules', num_schedules)
return schedules
def filter_by_org(self, schedules):
"""
Given the configuration of sites, get the list of orgs that should be included or excluded from this send.
Returns:
tuple: Returns a tuple (exclude_orgs, org_list). If exclude_orgs is True, then org_list is a list of the
only orgs that should be included in this send. If exclude_orgs is False, then org_list is a list of
orgs that should be excluded from this send. All other orgs should be included.
"""
try:
site_config = self.site.configuration
org_list = site_config.get_value('course_org_filter')
if not org_list:
not_orgs = set()
for other_site_config in SiteConfiguration.objects.all():
other = other_site_config.get_value('course_org_filter')
if not isinstance(other, list):
if other is not None:
not_orgs.add(other)
else:
not_orgs.update(other)
return schedules.exclude(enrollment__course__org__in=not_orgs)
elif not isinstance(org_list, list):
return schedules.filter(enrollment__course__org=org_list)
except SiteConfiguration.DoesNotExist:
return schedules
return schedules.filter(enrollment__course__org__in=org_list)
def schedules_for_bin(self):
schedules = self.get_schedules_with_target_date_by_bin_and_orgs()
template_context = get_base_template_context(self.site)
for (user, user_schedules) in groupby(schedules, lambda s: s.enrollment.user):
user_schedules = list(user_schedules)
course_id_strs = [str(schedule.enrollment.course_id) for schedule in user_schedules]
# This is used by the bulk email optout policy
template_context['course_ids'] = course_id_strs
first_schedule = user_schedules[0]
try:
template_context.update(self.get_template_context(user, user_schedules))
except InvalidContextError:
continue
yield (user, first_schedule.enrollment.course.closest_released_language, template_context)
def get_template_context(self, user, user_schedules):
"""
Given a user and their schedules, build the context needed to render the template for this message.
Arguments:
user -- the User who will be receiving the message
user_schedules -- a list of Schedule objects representing all of their schedules that should be covered by
this message. For example, when a user enrolls in multiple courses on the same day, we
don't want to send them multiple reminder emails. Instead this list would have multiple
elements, allowing us to send a single message for all of the courses.
Returns:
dict: This dict must be JSON serializable (no datetime objects!). When rendering the message templates it
it will be used as the template context. Note that it will also include several default values that
injected into all template contexts. See `get_base_template_context` for more information.
Raises:
InvalidContextError: If this user and set of schedules are not valid for this type of message. Raising this
exception will prevent this user from receiving the message, but allow other messages to be sent to other
users.
"""
return {}
class InvalidContextError(Exception):
pass
class RecurringNudgeResolver(BinnedSchedulesBaseResolver):
"""
Send a message to all users whose schedule started at ``self.current_date`` + ``day_offset``.
"""
log_prefix = 'Recurring Nudge'
schedule_date_field = 'start_date'
num_bins = RECURRING_NUDGE_NUM_BINS
@property
def experience_filter(self):
if self.day_offset == -3:
experiences = [ScheduleExperience.EXPERIENCES.default, ScheduleExperience.EXPERIENCES.course_updates]
return Q(experience__experience_type__in=experiences) | Q(experience__isnull=True)
else:
return Q(experience__experience_type=ScheduleExperience.EXPERIENCES.default) | Q(experience__isnull=True)
def get_template_context(self, user, user_schedules):
first_schedule = user_schedules[0]
if not first_schedule.enrollment.course.self_paced:
raise InvalidContextError
context = {
'course_name': first_schedule.enrollment.course.display_name,
'course_url': _get_trackable_course_home_url(first_schedule.enrollment.course_id),
}
# Information for including upsell messaging in template.
context.update(_get_upsell_information_for_schedule(user, first_schedule))
return context
def _get_datetime_beginning_of_day(dt):
"""
Truncates hours, minutes, seconds, and microseconds to zero on given datetime.
"""
return dt.replace(hour=0, minute=0, second=0, microsecond=0)
class UpgradeReminderResolver(BinnedSchedulesBaseResolver):
"""
Send a message to all users whose verified upgrade deadline is at ``self.current_date`` + ``day_offset``.
"""
log_prefix = 'Upgrade Reminder'
schedule_date_field = 'upgrade_deadline'
num_bins = UPGRADE_REMINDER_NUM_BINS
def get_template_context(self, user, user_schedules):
course_id_strs = []
course_links = []
first_valid_upsell_context = None
first_schedule = None
for schedule in user_schedules:
if not schedule.enrollment.course.self_paced:
# We don't want to include instructor led courses in this email
continue
upsell_context = _get_upsell_information_for_schedule(user, schedule)
if not upsell_context['show_upsell']:
continue
if first_valid_upsell_context is None:
first_schedule = schedule
first_valid_upsell_context = upsell_context
course_id_str = str(schedule.enrollment.course_id)
course_id_strs.append(course_id_str)
course_links.append({
'url': _get_trackable_course_home_url(schedule.enrollment.course_id),
'name': schedule.enrollment.course.display_name
})
if first_schedule is None:
self.log_debug('No courses eligible for upgrade for user.')
raise InvalidContextError()
context = {
'course_links': course_links,
'first_course_name': first_schedule.enrollment.course.display_name,
'cert_image': static('course_experience/images/verified-cert.png'),
'course_ids': course_id_strs,
}
context.update(first_valid_upsell_context)
return context
def _get_upsell_information_for_schedule(user, schedule):
template_context = {}
enrollment = schedule.enrollment
course = enrollment.course
verified_upgrade_link = _get_verified_upgrade_link(user, schedule)
has_verified_upgrade_link = verified_upgrade_link is not None
if has_verified_upgrade_link:
template_context['upsell_link'] = verified_upgrade_link
template_context['user_schedule_upgrade_deadline_time'] = translate_date(
date=enrollment.dynamic_upgrade_deadline,
language=course.closest_released_language,
)
template_context['show_upsell'] = has_verified_upgrade_link
return template_context
def _get_verified_upgrade_link(user, schedule):
enrollment = schedule.enrollment
if enrollment.dynamic_upgrade_deadline is not None and can_show_verified_upgrade(user, enrollment):
return verified_upgrade_deadline_link(user, enrollment.course)
class CourseUpdateResolver(BinnedSchedulesBaseResolver):
"""
Send a message to all users whose schedule started at ``self.current_date`` + ``day_offset`` and the
course has updates.
"""
log_prefix = 'Course Update'
schedule_date_field = 'start_date'
num_bins = COURSE_UPDATE_NUM_BINS
experience_filter = Q(experience__experience_type=ScheduleExperience.EXPERIENCES.course_updates)
def send(self, msg_type):
for (user, language, context, is_self_paced) in self.schedules_for_bin():
msg_type = CourseUpdate() if is_self_paced else InstructorLedCourseUpdate()
msg = msg_type.personalize(
Recipient(
user.username,
self.override_recipient_email or user.email,
),
language,
context,
)
with function_trace('enqueue_send_task'):
self.async_send_task.apply_async((self.site.id, str(msg)), retry=False) # pylint: disable=no-member
def schedules_for_bin(self):
week_num = abs(self.day_offset) // 7
schedules = self.get_schedules_with_target_date_by_bin_and_orgs(
order_by='enrollment__course',
)
template_context = get_base_template_context(self.site)
for schedule in schedules:
enrollment = schedule.enrollment
course = schedule.enrollment.course
user = enrollment.user
try:
week_highlights = get_week_highlights(user, enrollment.course_id, week_num)
except CourseUpdateDoesNotExist:
LOG.warning(
u'Weekly highlights for user {} in week {} of course {} does not exist or is disabled'.format(
user, week_num, enrollment.course_id
)
)
# continue to the next schedule, don't yield an email for this one
else:
unsubscribe_url = None
if (COURSE_UPDATE_SHOW_UNSUBSCRIBE_WAFFLE_SWITCH.is_enabled() and
'bulk_email_optout' in settings.ACE_ENABLED_POLICIES):
unsubscribe_url = reverse('bulk_email_opt_out', kwargs={
'token': UsernameCipher.encrypt(user.username),
'course_id': str(enrollment.course_id),
})
template_context.update({
'course_name': schedule.enrollment.course.display_name,
'course_url': _get_trackable_course_home_url(enrollment.course_id),
'week_num': week_num,
'week_highlights': week_highlights,
# This is used by the bulk email optout policy
'course_ids': [str(enrollment.course_id)],
'unsubscribe_url': unsubscribe_url,
})
template_context.update(_get_upsell_information_for_schedule(user, schedule))
yield (user, schedule.enrollment.course.closest_released_language, template_context, course.self_paced)
@attr.s
class CourseNextSectionUpdate(PrefixedDebugLoggerMixin, RecipientResolver):
"""
Send a message to all users whose schedule gives them a due date of yesterday.
"""
async_send_task = attr.ib()
site = attr.ib()
target_datetime = attr.ib()
course_id = attr.ib()
override_recipient_email = attr.ib(default=None)
log_prefix = 'Next Section Course Update'
experience_filter = Q(experience__experience_type=ScheduleExperience.EXPERIENCES.course_updates)
def send(self):
schedules = self.get_schedules()
for (user, language, context, is_self_paced) in schedules:
msg_type = CourseUpdate() if is_self_paced else InstructorLedCourseUpdate()
msg_type.personalize(
Recipient(
user.username,
self.override_recipient_email or user.email,
),
language,
context,
)
LOG.info(
u'Sending email to user: {} for course-key: {}'.format(
user.username,
self.course_id
)
)
# TODO: Uncomment below when going live
# with function_trace('enqueue_send_task'):
# self.async_send_task.apply_async((self.site.id, str(msg)), retry=False)
def get_schedules(self):
course_key = CourseKey.from_string(self.course_id)
target_date = self.target_datetime.date()
schedules = get_schedules_with_due_date(course_key, target_date).filter(
self.experience_filter,
active=True,
enrollment__user__is_active=True,
)
template_context = get_base_template_context(self.site)
for schedule in schedules:
enrollment = schedule.enrollment
course = schedule.enrollment.course
user = enrollment.user
start_date = schedule.start_date
LOG.info(u'Received a schedule for user {} in course {} for date {}'.format(
user.username,
self.course_id,
target_date,
))
try:
week_highlights, week_num = get_next_section_highlights(user, course.id, start_date, target_date)
except CourseUpdateDoesNotExist:
LOG.warning(
u'Weekly highlights for user {} of course {} does not exist or is disabled'.format(
user, course.id
)
)
# continue to the next schedule, don't yield an email for this one
continue
unsubscribe_url = None
if (COURSE_UPDATE_SHOW_UNSUBSCRIBE_WAFFLE_SWITCH.is_enabled() and
'bulk_email_optout' in settings.ACE_ENABLED_POLICIES):
unsubscribe_url = reverse('bulk_email_opt_out', kwargs={
'token': UsernameCipher.encrypt(user.username),
'course_id': str(enrollment.course_id),
})
template_context.update({
'course_name': course.display_name,
'course_url': _get_trackable_course_home_url(enrollment.course_id),
'week_num': week_num,
'week_highlights': week_highlights,
# This is used by the bulk email optout policy
'course_ids': [str(enrollment.course_id)],
'unsubscribe_url': unsubscribe_url,
})
template_context.update(_get_upsell_information_for_schedule(user, schedule))
yield (user, enrollment.course.closest_released_language, template_context, course.self_paced)
def _get_trackable_course_home_url(course_id):
"""
Get the home page URL for the course.
NOTE: For us to be able to track clicks in the email, this URL needs to point to a landing page that does not result
in a redirect so that the GA snippet can register the UTM parameters.
Args:
course_id (CourseKey): The course to get the home page URL for.
Returns:
A relative path to the course home page.
"""
course_url_name = course_home_url_name(course_id)
return reverse(course_url_name, args=[str(course_id)])