resolvers.py 16 KB
Newer Older
1
import datetime
2 3 4
from itertools import groupby
import logging

5
import attr
6 7 8 9
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.staticfiles.templatetags.staticfiles import static
from django.core.urlresolvers import reverse
10
from django.db.models import F, Q
11 12
from django.utils.formats import dateformat, get_format

13 14

from edx_ace.recipient_resolver import RecipientResolver
15
from edx_ace.recipient import Recipient
16

17
from courseware.date_summary import verified_upgrade_deadline_link, verified_upgrade_link_is_valid
18
from openedx.core.djangoapps.monitoring_utils import function_trace, set_custom_metric
19
from openedx.core.djangoapps.schedules.content_highlights import get_week_highlights
20
from openedx.core.djangoapps.schedules.exceptions import CourseUpdateDoesNotExist
21
from openedx.core.djangoapps.schedules.models import Schedule, ScheduleExperience
22
from openedx.core.djangoapps.schedules.utils import PrefixedDebugLoggerMixin
23 24 25 26
from openedx.core.djangoapps.schedules.template_context import (
    absolute_url,
    get_base_template_context
)
27
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
28

29 30

LOG = logging.getLogger(__name__)
31

32 33 34 35 36
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

37 38

@attr.s
39 40
class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver):
    """
41 42 43 44
    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.
45 46

    Arguments:
47
        async_send_task -- celery task function that sends the message
48
        site -- Site object that filtered Schedules will be a part of
49 50 51 52 53 54 55 56 57
        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)
58 59

    Static attributes:
60 61 62
        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".
63
        num_bins -- the int number of bins to split the users into
64 65 66
        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.
67
    """
68 69 70 71 72 73 74
    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)

75 76
    schedule_date_field = None
    num_bins = DEFAULT_NUM_BINS
77 78
    experience_filter = (Q(experience__experience_type=ScheduleExperience.EXPERIENCES.default)
                         | Q(experience__isnull=True))
79 80 81 82 83

    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)

84
    def send(self, msg_type):
85 86 87 88 89 90 91 92 93 94 95
        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)
96

97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118
    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,
            **schedule_day_equals_target_day_filter
        ).annotate(
            id_mod=F('id') % self.num_bins
        ).filter(
            id_mod=self.bin_num
        )
119

120 121 122 123 124 125 126 127 128 129
        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',
        ).filter(
            Q(enrollment__course__end__isnull=True) | Q(
                enrollment__course__end__gte=self.current_datetime),
130
            self.experience_filter,
131 132 133 134 135
            enrollment__user__in=users,
            enrollment__is_active=True,
            **schedule_day_equals_target_day_filter
        ).order_by(order_by)

136
        schedules = self.filter_by_org(schedules)
137 138 139 140 141 142 143 144 145 146

        if "read_replica" in settings.DATABASES:
            schedules = schedules.using("read_replica")

        LOG.debug('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)

147 148
        LOG.debug('Number of schedules = %d', num_schedules)

149 150 151 152
        # 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
153

154
    def filter_by_org(self, schedules):
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174
        """
        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)
175
                return schedules.exclude(enrollment__course__org__in=not_orgs)
176
            elif not isinstance(org_list, list):
177
                return schedules.filter(enrollment__course__org=org_list)
178
        except SiteConfiguration.DoesNotExist:
179
            return schedules
180

181
        return schedules.filter(enrollment__course__org__in=org_list)
182

183
    def schedules_for_bin(self):
184
        schedules = self.get_schedules_with_target_date_by_bin_and_orgs()
185
        template_context = get_base_template_context(self.site)
186

187 188 189
        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]
190

191 192
            # This is used by the bulk email optout policy
            template_context['course_ids'] = course_id_strs
193

194
            first_schedule = user_schedules[0]
195 196 197 198
            try:
                template_context.update(self.get_template_context(user, user_schedules))
            except InvalidContextError:
                continue
199

200
            yield (user, first_schedule.enrollment.course.language, template_context)
201

202 203 204 205 206 207 208 209 210 211 212 213 214 215 216
    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.
217 218 219 220 221

        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.
222 223 224 225
        """
        return {}


226 227 228 229
class InvalidContextError(Exception):
    pass


230
class RecurringNudgeResolver(BinnedSchedulesBaseResolver):
231 232 233
    """
    Send a message to all users whose schedule started at ``self.current_date`` + ``day_offset``.
    """
234
    log_prefix = 'Recurring Nudge'
235 236 237
    schedule_date_field = 'start'
    num_bins = RECURRING_NUDGE_NUM_BINS

238 239 240
    @property
    def experience_filter(self):
        if self.day_offset == -3:
241
            experiences = [ScheduleExperience.EXPERIENCES.default, ScheduleExperience.EXPERIENCES.course_updates]
242 243
            return Q(experience__experience_type__in=experiences) | Q(experience__isnull=True)
        else:
244
            return Q(experience__experience_type=ScheduleExperience.EXPERIENCES.default) | Q(experience__isnull=True)
245

246 247
    def get_template_context(self, user, user_schedules):
        first_schedule = user_schedules[0]
248
        context = {
249 250 251 252 253 254
            'course_name': first_schedule.enrollment.course.display_name,
            'course_url': absolute_url(
                self.site, reverse('course_root', args=[str(first_schedule.enrollment.course_id)])
            ),
        }

255 256 257 258 259
        # Information for including upsell messaging in template.
        context.update(_get_upsell_information_for_schedule(user, first_schedule))

        return context

260 261 262 263 264 265 266 267

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)


268 269 270 271
class UpgradeReminderResolver(BinnedSchedulesBaseResolver):
    """
    Send a message to all users whose verified upgrade deadline is at ``self.current_date`` + ``day_offset``.
    """
272
    log_prefix = 'Upgrade Reminder'
273 274
    schedule_date_field = 'upgrade_deadline'
    num_bins = UPGRADE_REMINDER_NUM_BINS
275

276
    def get_template_context(self, user, user_schedules):
277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301
        course_id_strs = []
        course_links = []
        first_valid_upsell_context = None
        first_schedule = None
        for schedule in user_schedules:
            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': absolute_url(self.site, reverse('course_root', args=[course_id_str])),
                '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,
302 303
            'first_course_name': first_schedule.enrollment.course.display_name,
            'cert_image': absolute_url(self.site, static('course_experience/images/verified-cert.png')),
304
            'course_ids': course_id_strs,
305
        }
306 307
        context.update(first_valid_upsell_context)
        return context
308 309


310 311
def _get_upsell_information_for_schedule(user, schedule):
    template_context = {}
312 313 314
    enrollment = schedule.enrollment
    course = enrollment.course

315
    verified_upgrade_link = _get_verified_upgrade_link(user, schedule)
316 317 318 319 320 321 322 323 324 325 326 327 328 329
    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'] = dateformat.format(
            enrollment.dynamic_upgrade_deadline,
            get_format(
                'DATE_FORMAT',
                lang=course.language,
                use_l10n=True
            )
        )

    template_context['show_upsell'] = has_verified_upgrade_link
330
    return template_context
331 332


333 334 335 336
def _get_verified_upgrade_link(user, schedule):
    enrollment = schedule.enrollment
    if enrollment.dynamic_upgrade_deadline is not None and verified_upgrade_link_is_valid(enrollment):
        return verified_upgrade_deadline_link(user, enrollment.course)
337 338


339 340 341 342 343
class CourseUpdateResolver(BinnedSchedulesBaseResolver):
    """
    Send a message to all users whose schedule started at ``self.current_date`` + ``day_offset`` and the
    course has updates.
    """
344
    log_prefix = 'Course Update'
345 346
    schedule_date_field = 'start'
    num_bins = COURSE_UPDATE_NUM_BINS
347
    experience_filter = Q(experience__experience_type=ScheduleExperience.EXPERIENCES.course_updates)
348

349 350
    def schedules_for_bin(self):
        week_num = abs(self.day_offset) / 7
351
        schedules = self.get_schedules_with_target_date_by_bin_and_orgs(
352
            order_by='enrollment__course',
353
        )
354

355
        template_context = get_base_template_context(self.site)
356 357
        for schedule in schedules:
            enrollment = schedule.enrollment
358 359
            user = enrollment.user

360
            try:
361
                week_highlights = get_week_highlights(user, enrollment.course_id, week_num)
362 363 364 365 366 367
            except CourseUpdateDoesNotExist:
                continue

            course_id_str = str(enrollment.course_id)
            template_context.update({
                'course_name': schedule.enrollment.course.display_name,
368
                'course_url': absolute_url(
369
                    self.site, reverse('course_root', args=[course_id_str])
370
                ),
371
                'week_num': week_num,
372
                'week_highlights': week_highlights,
373 374 375 376

                # This is used by the bulk email optout policy
                'course_ids': [course_id_str],
            })
377
            template_context.update(_get_upsell_information_for_schedule(user, schedule))
378 379

            yield (user, schedule.enrollment.course.language, template_context)