diff --git a/common/djangoapps/student/migrations/0024_fbeenrollmentexclusion.py b/common/djangoapps/student/migrations/0024_fbeenrollmentexclusion.py new file mode 100644 index 0000000000..2ac84ced8a --- /dev/null +++ b/common/djangoapps/student/migrations/0024_fbeenrollmentexclusion.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.25 on 2019-11-01 15:56 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('student', '0023_bulkunenrollconfiguration'), + ] + + operations = [ + migrations.CreateModel( + name='FBEEnrollmentExclusion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('enrollment', models.OneToOneField(on_delete=django.db.models.deletion.DO_NOTHING, to='student.CourseEnrollment')), + ], + ), + ] diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index a3ad3b184a..44352ab77c 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -2045,6 +2045,22 @@ class CourseEnrollment(models.Model): cache[(user_id, course_key)] = enrollment_state +@python_2_unicode_compatible +class FBEEnrollmentExclusion(models.Model): + """ + Disable FBE for enrollments in this table. + + .. no_pii: + """ + enrollment = models.OneToOneField( + CourseEnrollment, + on_delete=models.DO_NOTHING, + ) + + def __str__(self): + return "[FBEEnrollmentExclusion] %s" % (self.enrollment,) + + @receiver(models.signals.post_save, sender=CourseEnrollment) @receiver(models.signals.post_delete, sender=CourseEnrollment) def invalidate_enrollment_mode_cache(sender, instance, **kwargs): # pylint: disable=unused-argument, invalid-name diff --git a/openedx/core/djangoapps/config_model_utils/utils.py b/openedx/core/djangoapps/config_model_utils/utils.py index 8d2bf1b860..50a986b109 100644 --- a/openedx/core/djangoapps/config_model_utils/utils.py +++ b/openedx/core/djangoapps/config_model_utils/utils.py @@ -3,9 +3,10 @@ from __future__ import absolute_import from experiments.models import ExperimentData from openedx.features.course_duration_limits.config import EXPERIMENT_DATA_HOLDBACK_KEY, EXPERIMENT_ID +from student.models import FBEEnrollmentExclusion -def is_in_holdback(user): +def is_in_holdback(user, enrollment): """ Return true if given user is in holdback expermiment """ @@ -21,4 +22,11 @@ def is_in_holdback(user): except ExperimentData.DoesNotExist: pass + if enrollment is not None: + try: + if enrollment.fbeenrollmentexclusion: + return True + except FBEEnrollmentExclusion.DoesNotExist: + pass + return in_holdback diff --git a/openedx/core/djangoapps/schedules/resolvers.py b/openedx/core/djangoapps/schedules/resolvers.py index f35787eb16..1d4b3399e5 100644 --- a/openedx/core/djangoapps/schedules/resolvers.py +++ b/openedx/core/djangoapps/schedules/resolvers.py @@ -121,6 +121,7 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver): 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 diff --git a/openedx/features/content_type_gating/models.py b/openedx/features/content_type_gating/models.py index b3093f0450..cd7bd1336f 100644 --- a/openedx/features/content_type_gating/models.py +++ b/openedx/features/content_type_gating/models.py @@ -86,7 +86,7 @@ class ContentTypeGatingConfig(StackedConfigurationModel): return False @classmethod - def enabled_for_enrollment(cls, enrollment=None, user=None, course_key=None, user_partition=None): + def enabled_for_enrollment(cls, user=None, course_key=None, user_partition=None): """ Return whether Content Type Gating is enabled for this enrollment. @@ -95,27 +95,15 @@ class ContentTypeGatingConfig(StackedConfigurationModel): such as the org, site, or globally), and if the configuration is specified to be ``enabled_as_of`` before the enrollment was created. - Only one of enrollment and (user, course_key) may be specified at a time. - Arguments: enrollment: The enrollment being queried. user: The user being queried. course_key: The CourseKey of the course being queried. """ - if enrollment is not None and (user is not None or course_key is not None): - raise ValueError('Specify enrollment or user/course_key, but not both') - - if enrollment is None and (user is None or course_key is None): + if user is None or course_key is None: raise ValueError('Both user and course_key must be specified if no enrollment is provided') - if enrollment is None and user is None and course_key is None: - raise ValueError('At least one of enrollment or user and course_key must be specified') - - if course_key is None: - course_key = enrollment.course_id - - if enrollment is None: - enrollment = CourseEnrollment.get_enrollment(user, course_key) + enrollment = CourseEnrollment.get_enrollment(user, course_key, ['fbeenrollmentexclusion']) if user is None and enrollment is not None: user = enrollment.user @@ -134,7 +122,7 @@ class ContentTypeGatingConfig(StackedConfigurationModel): return False # check if user is in holdback - if user_variable_represents_correct_user and is_in_holdback(user): + if user_variable_represents_correct_user and is_in_holdback(user, enrollment): return False if not correct_modes_for_fbe(course_key, enrollment, user): diff --git a/openedx/features/content_type_gating/tests/test_models.py b/openedx/features/content_type_gating/tests/test_models.py index e7cb035d6b..902762aae0 100644 --- a/openedx/features/content_type_gating/tests/test_models.py +++ b/openedx/features/content_type_gating/tests/test_models.py @@ -33,18 +33,15 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase): super(TestContentTypeGatingConfig, self).setUp() @ddt.data( - (True, True, True), - (True, True, False), - (True, False, True), - (True, False, False), - (False, False, True), - (False, False, False), + (True, True), + (True, False), + (False, True), + (False, False), ) @ddt.unpack def test_enabled_for_enrollment( self, already_enrolled, - pass_enrollment, enrolled_before_enabled, ): @@ -69,22 +66,14 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase): else: existing_enrollment = None - if pass_enrollment: - enrollment = existing_enrollment - user = None - course_key = None - else: - enrollment = None - user = self.user - course_key = self.course_overview.id + enrollment = None + user = self.user + course_key = self.course_overview.id - query_count = 7 - if not already_enrolled or not pass_enrollment and already_enrolled: - query_count = 8 + query_count = 8 with self.assertNumQueries(query_count): enabled = ContentTypeGatingConfig.enabled_for_enrollment( - enrollment=enrollment, user=user, course_key=course_key, ) @@ -92,11 +81,11 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase): def test_enabled_for_enrollment_failure(self): with self.assertRaises(ValueError): - ContentTypeGatingConfig.enabled_for_enrollment(None, None, None) + ContentTypeGatingConfig.enabled_for_enrollment(None, None) with self.assertRaises(ValueError): - ContentTypeGatingConfig.enabled_for_enrollment(Mock(name='enrollment'), Mock(name='user'), None) + ContentTypeGatingConfig.enabled_for_enrollment(Mock(name='user'), None) with self.assertRaises(ValueError): - ContentTypeGatingConfig.enabled_for_enrollment(Mock(name='enrollment'), None, Mock(name='course_key')) + ContentTypeGatingConfig.enabled_for_enrollment(None, Mock(name='course_key')) @ddt.data(True, False) def test_enabled_for_course( diff --git a/openedx/features/course_duration_limits/management/__init__.py b/openedx/features/course_duration_limits/management/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openedx/features/course_duration_limits/management/commands/__init__.py b/openedx/features/course_duration_limits/management/commands/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openedx/features/course_duration_limits/management/commands/cdl_setup_models_to_send_test_emails.py b/openedx/features/course_duration_limits/management/commands/cdl_setup_models_to_send_test_emails.py deleted file mode 100644 index f78964de5b..0000000000 --- a/openedx/features/course_duration_limits/management/commands/cdl_setup_models_to_send_test_emails.py +++ /dev/null @@ -1,57 +0,0 @@ -""" -A managment command that can be used to set up Schedules with various configurations for testing. -""" - -from __future__ import absolute_import - -import datetime -from textwrap import dedent - -import factory -import pytz -from django.contrib.sites.models import Site -from django.core.management.base import BaseCommand - -from course_modes.models import CourseMode -from course_modes.tests.factories import CourseModeFactory -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from openedx.core.djangoapps.schedules.tests.factories import ScheduleConfigFactory, ScheduleFactory -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.factories import XMODULE_FACTORY_LOCK, CourseFactory - - -class CourseDurationLimitExpirySchedule(ScheduleFactory): - """ - A ScheduleFactory that creates a Schedule set up for Course Duration Limit expiry - """ - start = factory.Faker('date_time_between', start_date='-21d', end_date='-21d', tzinfo=pytz.UTC) - - -class Command(BaseCommand): - """ - A management command that generates schedule objects for all expected course duration limit - email types, so that it is easy to generate test emails of all available types. - """ - help = dedent(__doc__).strip() - - def handle(self, *args, **options): - courses = modulestore().get_courses() - - # Find the largest auto-generated course, and pick the next sequence id to generate the next - # course with. - max_org_sequence_id = max([0] + [int(course.org[4:]) for course in courses if course.org.startswith('org.')]) - - XMODULE_FACTORY_LOCK.enable() - CourseFactory.reset_sequence(max_org_sequence_id + 1, force=True) - course = CourseFactory.create( - start=datetime.datetime.today() - datetime.timedelta(days=30), - end=datetime.datetime.today() + datetime.timedelta(days=30), - number=factory.Sequence('schedules_test_course_{}'.format), - display_name=factory.Sequence(u'Schedules Test Course {}'.format), - ) - XMODULE_FACTORY_LOCK.disable() - course_overview = CourseOverview.load_from_module_store(course.id) - CourseModeFactory.create(course_id=course_overview.id, mode_slug=CourseMode.AUDIT) - CourseModeFactory.create(course_id=course_overview.id, mode_slug=CourseMode.VERIFIED) - CourseDurationLimitExpirySchedule.create_batch(20, enrollment__course=course_overview) - ScheduleConfigFactory.create(site=Site.objects.get(name='example.com')) diff --git a/openedx/features/course_duration_limits/management/commands/send_access_expiry_reminder.py b/openedx/features/course_duration_limits/management/commands/send_access_expiry_reminder.py deleted file mode 100644 index 354bb188fa..0000000000 --- a/openedx/features/course_duration_limits/management/commands/send_access_expiry_reminder.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -./manage.py lms send_access_expiry_reminder - -Send out reminder emails for any students who will lose access to course content in 7 days. -""" -from __future__ import absolute_import - -from textwrap import dedent - -from openedx.core.djangoapps.schedules.management.commands import SendEmailBaseCommand -from openedx.features.course_duration_limits.tasks import CourseDurationLimitExpiryReminder - -from ... import resolvers - - -class Command(SendEmailBaseCommand): - """ - Send out reminder emails for any students who will lose access - to course content in 7 days. - - ./manage.py lms send_access_expiry_reminder - """ - help = dedent(__doc__).strip() - async_send_task = CourseDurationLimitExpiryReminder - log_prefix = resolvers.EXPIRY_REMINDER_LOG_PREFIX - offsets = (7,) # Days until Course Duration Limit expiry diff --git a/openedx/features/course_duration_limits/message_types.py b/openedx/features/course_duration_limits/message_types.py deleted file mode 100644 index 9cc8c73c57..0000000000 --- a/openedx/features/course_duration_limits/message_types.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Message types used for ACE communication by the course_duration_limits app. -""" -from __future__ import absolute_import - -import logging - -from openedx.core.djangoapps.ace_common.message import BaseMessageType -from openedx.core.djangoapps.schedules.config import DEBUG_MESSAGE_WAFFLE_FLAG - - -class ExpiryReminder(BaseMessageType): - def __init__(self, *args, **kwargs): - super(ExpiryReminder, self).__init__(*args, **kwargs) - self.log_level = logging.DEBUG if DEBUG_MESSAGE_WAFFLE_FLAG.is_enabled() else None diff --git a/openedx/features/course_duration_limits/models.py b/openedx/features/course_duration_limits/models.py index 9feede4249..a19a52f933 100644 --- a/openedx/features/course_duration_limits/models.py +++ b/openedx/features/course_duration_limits/models.py @@ -80,7 +80,7 @@ class CourseDurationLimitConfig(StackedConfigurationModel): return False @classmethod - def enabled_for_enrollment(cls, enrollment=None, user=None, course_key=None): + def enabled_for_enrollment(cls, user=None, course_key=None): """ Return whether Course Duration Limits are enabled for this enrollment. @@ -97,20 +97,10 @@ class CourseDurationLimitConfig(StackedConfigurationModel): course_key: The CourseKey of the course being queried. """ - if enrollment is not None and (user is not None or course_key is not None): - raise ValueError('Specify enrollment or user/course_key, but not both') - - if enrollment is None and (user is None or course_key is None): + if user is None or course_key is None: raise ValueError('Both user and course_key must be specified if no enrollment is provided') - if enrollment is None and user is None and course_key is None: - raise ValueError('At least one of enrollment or user and course_key must be specified') - - if course_key is None: - course_key = enrollment.course_id - - if enrollment is None: - enrollment = CourseEnrollment.get_enrollment(user, course_key) + enrollment = CourseEnrollment.get_enrollment(user, course_key, ['fbeenrollmentexclusion']) if user is None and enrollment is not None: user = enrollment.user @@ -128,7 +118,7 @@ class CourseDurationLimitConfig(StackedConfigurationModel): student_masquerade = is_masquerading_as_specific_student(user, course_key) # check if user is in holdback - if (no_masquerade or student_masquerade) and is_in_holdback(user): + if (no_masquerade or student_masquerade) and is_in_holdback(user, enrollment): return False not_student_masquerade = is_masquerading and not student_masquerade diff --git a/openedx/features/course_duration_limits/resolvers.py b/openedx/features/course_duration_limits/resolvers.py deleted file mode 100644 index 9d3dfc68ed..0000000000 --- a/openedx/features/course_duration_limits/resolvers.py +++ /dev/null @@ -1,186 +0,0 @@ -""" -Resolvers used to find users for course_duration_limit message -""" - -from __future__ import absolute_import - -import logging -from datetime import datetime, timedelta - -from django.contrib.staticfiles.templatetags.staticfiles import static -from django.db.models import Q -from django.utils.timesince import timeuntil -from django.utils.translation import ugettext as _ -from eventtracking import tracker - -from course_modes.models import CourseMode -from lms.djangoapps.courseware.date_summary import verified_upgrade_deadline_link -from lms.djangoapps.experiments.stable_bucketing import stable_bucketing_hash_group -from openedx.core.djangoapps.catalog.utils import get_course_run_details -from openedx.core.djangoapps.schedules.resolvers import ( - BinnedSchedulesBaseResolver, - InvalidContextError, - _get_trackable_course_home_url -) -from track import segment - -from .access import MAX_DURATION, MIN_DURATION, get_user_course_expiration_date -from .models import CourseDurationLimitConfig - -LOG = logging.getLogger(__name__) - -DEFAULT_NUM_BINS = 24 -EXPIRY_REMINDER_NUM_BINS = 1 -EXPIRY_REMINDER_LOG_PREFIX = 'FBE Expiry Reminder' - - -class ExpiryReminderResolver(BinnedSchedulesBaseResolver): - """ - Send a message to all users whose course duration limit expiration date - is at ``self.current_date`` + ``day_offset``. - """ - log_prefix = EXPIRY_REMINDER_LOG_PREFIX - schedule_date_field = 'start' - num_bins = EXPIRY_REMINDER_NUM_BINS - - def __init__( - self, - async_send_task, - site, - course_key, - target_datetime, - day_offset, - override_recipient_email=None - ): - access_duration = MIN_DURATION - discovery_course_details = get_course_run_details(course_key, ['weeks_to_complete']) - expected_weeks = discovery_course_details.get('weeks_to_complete') - if expected_weeks: - access_duration = timedelta(weeks=expected_weeks) - - access_duration = max(MIN_DURATION, min(MAX_DURATION, access_duration)) - - self.course_key = course_key - - super(ExpiryReminderResolver, self).__init__( - async_send_task, - site, - target_datetime - access_duration, - day_offset - access_duration.days, - 0, - override_recipient_email, - ) - - # TODO: This isn't named well, given the purpose we're using it for. That's ok for now, - # this is just a test. - @property - def experience_filter(self): - return Q(enrollment__course_id=self.course_key, enrollment__mode=CourseMode.AUDIT) - - def get_template_context(self, user, user_schedules): - course_id_strs = [] - course_links = [] - first_valid_upsell_context = None - first_schedule = None - first_expiration_date = None - - self.log_info(u"Found %s schedules for %s", len(user_schedules), user.username) - - for schedule in user_schedules: - upsell_context = _get_upsell_information_for_schedule(user, schedule) - if not upsell_context['show_upsell']: - self.log_info(u"No upsell available for %r", schedule.enrollment) - continue - - if not CourseDurationLimitConfig.enabled_for_enrollment(enrollment=schedule.enrollment): - self.log_info(u"course duration limits not enabled for %r", schedule.enrollment) - continue - - expiration_date = get_user_course_expiration_date(user, schedule.enrollment.course) - if expiration_date is None: - self.log_info(u"No course expiration date for %r", schedule.enrollment.course) - continue - - if first_valid_upsell_context is None: - first_schedule = schedule - first_valid_upsell_context = upsell_context - first_expiration_date = expiration_date - 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_info(u'No courses eligible for upgrade for user %s.', user.username) - raise InvalidContextError() - - # Experiment code: Skip users who are in the control bucket - hash_bucket = stable_bucketing_hash_group('fbe_access_expiry_reminder', 2, user.username) - properties = { - 'site': self.site.domain, # pylint: disable=no-member - 'app_label': 'course_duration_limits', - 'nonInteraction': 1, - 'bucket': hash_bucket, - 'experiment': 'REVMI-95', - } - course_ids = course_id_strs - properties['num_courses'] = len(course_ids) - if course_ids: - properties['course_ids'] = course_ids[:10] - properties['primary_course_id'] = course_ids[0] - - tracking_context = { - 'host': self.site.domain, # pylint: disable=no-member - 'path': '/', # make up a value, in order to allow the host to be passed along. - } - # I wonder if the user of this event should be the recipient, as they are not the ones - # who took an action. Rather, the system is acting, and they are the object. - # Admittedly that may be what 'nonInteraction' is meant to address. But sessionization may - # get confused by these events if they're attributed in this way, because there's no way for - # this event to get context that would match with what the user might be doing at the moment. - # But the events do show up in GA being joined up with existing sessions (i.e. within a half - # hour in the past), so they don't always break sessions. Not sure what happens after these. - # We can put the recipient_user_id into the properties, and then export as a custom dimension. - with tracker.get_tracker().context('course_duration_limits', tracking_context): - segment.track( - user_id=user.id, - event_name='edx.bi.experiment.user.bucketed', - properties=properties, - ) - if hash_bucket == 0: - 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, - 'first_course_expiration_date': first_expiration_date.strftime(_(u"%b. %d, %Y")), - 'time_until_expiration': timeuntil(first_expiration_date.date(), now=datetime.utcnow().date()) - } - context.update(first_valid_upsell_context) - return context - - -def _get_verified_upgrade_link(user, schedule): - enrollment = schedule.enrollment - if enrollment.is_active: - return verified_upgrade_deadline_link(user, enrollment.course) - - -def _get_upsell_information_for_schedule(user, schedule): - """ - Return upsell variables for inclusion in a message template being sent to this user. - """ - template_context = {} - - 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['show_upsell'] = has_verified_upgrade_link - return template_context diff --git a/openedx/features/course_duration_limits/tasks.py b/openedx/features/course_duration_limits/tasks.py deleted file mode 100644 index 446428eafa..0000000000 --- a/openedx/features/course_duration_limits/tasks.py +++ /dev/null @@ -1,174 +0,0 @@ -""" -Tasks requiring asynchronous handling for course_duration_limits -""" - -from __future__ import absolute_import - -import datetime -import logging - -import six -import waffle -from celery import task -from celery_utils.logged_task import LoggedTask -from django.conf import settings -from django.contrib.auth.models import User -from django.contrib.sites.models import Site -from edx_ace import ace -from edx_ace.message import Message -from edx_ace.utils.date import deserialize, serialize -from edx_django_utils.monitoring import set_custom_metric -from opaque_keys.edx.keys import CourseKey - -from openedx.core.djangoapps.schedules.resolvers import _get_datetime_beginning_of_day -from openedx.core.djangoapps.schedules.tasks import _annonate_send_task_for_monitoring, _track_message_sent -from openedx.core.lib.celery.task_utils import emulate_http_request - -from . import message_types, resolvers -from .models import CourseDurationLimitConfig - -LOG = logging.getLogger(__name__) -ROUTING_KEY = getattr(settings, 'ACE_ROUTING_KEY', None) - - -class CourseDurationLimitMessageBaseTask(LoggedTask): - """ - Base class for top-level Schedule tasks that create subtasks - for each Bin. - """ - ignore_result = True - routing_key = ROUTING_KEY - num_bins = resolvers.DEFAULT_NUM_BINS - enqueue_config_var = None # define in subclass - log_prefix = None - resolver = None # define in subclass - async_send_task = None # define in subclass - - @classmethod - def log_debug(cls, message, *args, **kwargs): - """ - Wrapper around LOG.debug that prefixes the message. - """ - LOG.debug(cls.log_prefix + ': ' + message, *args, **kwargs) - - @classmethod - def log_info(cls, message, *args, **kwargs): - """ - Wrapper around LOG.info that prefixes the message. - """ - LOG.info(cls.log_prefix + ': ' + message, *args, **kwargs) - - @classmethod - def enqueue(cls, site, current_date, day_offset, override_recipient_email=None): - current_date = _get_datetime_beginning_of_day(current_date) - - for course_key, config in CourseDurationLimitConfig.all_current_course_configs().items(): - if not config['enabled'][0]: - cls.log_info(u'Course duration limits disabled for course_key %s, skipping', course_key) - continue - - # enqueue_enabled, _ = config[cls.enqueue_config_var] - # TODO: Switch over to a model where enqueing is based in CourseDurationLimitConfig - enqueue_enabled = waffle.switch_is_active('course_duration_limits.enqueue_enabled') - - if not enqueue_enabled: - cls.log_info(u'Message queuing disabled for course_key %s', course_key) - continue - - target_date = current_date + datetime.timedelta(days=day_offset) - task_args = ( - site.id, - six.text_type(course_key), - serialize(target_date), - day_offset, - override_recipient_email, - ) - cls().apply_async( - task_args, - retry=False, - ) - - def run( # pylint: disable=arguments-differ - self, site_id, course_key_str, target_day_str, day_offset, override_recipient_email=None, - ): - try: - site = Site.objects.select_related('configuration').get(id=site_id) - with emulate_http_request(site=site): - msg_type = self.make_message_type(day_offset) - _annotate_for_monitoring(msg_type, course_key_str, target_day_str, day_offset) - return self.resolver( # pylint: disable=not-callable - self.async_send_task, - site, - CourseKey.from_string(course_key_str), - deserialize(target_day_str), - day_offset, - override_recipient_email=override_recipient_email, - ).send(msg_type) - except Exception: # pylint: disable=broad-except - LOG.exception("Task failed") - - def make_message_type(self, day_offset): - raise NotImplementedError - - -@task(base=LoggedTask, ignore_result=True, routing_key=ROUTING_KEY) -def _expiry_reminder_schedule_send(site_id, msg_str): - _schedule_send( - msg_str, - site_id, - 'deliver_expiry_reminder', - resolvers.EXPIRY_REMINDER_LOG_PREFIX, - ) - - -class CourseDurationLimitExpiryReminder(CourseDurationLimitMessageBaseTask): - """ - Task to send out a reminder that a users access to course content is expiring soon. - """ - num_bins = resolvers.EXPIRY_REMINDER_NUM_BINS - enqueue_config_var = 'enqueue_expiry_reminder' - log_prefix = resolvers.EXPIRY_REMINDER_LOG_PREFIX - resolver = resolvers.ExpiryReminderResolver - async_send_task = _expiry_reminder_schedule_send - - def make_message_type(self, day_offset): - return message_types.ExpiryReminder() - - -def _schedule_send(msg_str, site_id, delivery_config_var, log_prefix): - site = Site.objects.select_related('configuration').get(pk=site_id) - if _is_delivery_enabled(site, delivery_config_var, log_prefix): - msg = Message.from_string(msg_str) - - user = User.objects.get(username=msg.recipient.username) # pylint: disable=no-member - with emulate_http_request(site=site, user=user): - _annonate_send_task_for_monitoring(msg) - LOG.debug(u'%s: Sending message = %s', log_prefix, msg_str) - ace.send(msg) - _track_message_sent(site, user, msg) - - -def _is_delivery_enabled(site, delivery_config_var, log_prefix): # pylint: disable=unused-argument - # Experiment TODO: when this is going to prod, switch over to a config-model backed solution - #if getattr(CourseDurationLimitConfig.current(site=site), delivery_config_var, False): - if waffle.switch_is_active('course_duration_limits.delivery_enabled'): - return True - else: - LOG.info(u'%s: Message delivery disabled for site %s', log_prefix, site.domain) - return False - - -def _annotate_for_monitoring(message_type, course_key, target_day_str, day_offset): - """ - Set custom metrics in monitoring to make it easier to identify what messages are being sent and why. - """ - # This identifies the type of message being sent, for example: schedules.recurring_nudge3. - set_custom_metric('message_name', '{0}.{1}'.format(message_type.app_label, message_type.name)) - # The domain name of the site we are sending the message for. - set_custom_metric('course_key', course_key) - # The date we are processing data for. - set_custom_metric('target_day', target_day_str) - # The number of days relative to the current date to process data for. - set_custom_metric('day_offset', day_offset) - # A unique identifier for this batch of messages being sent. - set_custom_metric('send_uuid', message_type.uuid) diff --git a/openedx/features/course_duration_limits/templates/course_duration_limits/edx_ace/expiryreminder/email/body.html b/openedx/features/course_duration_limits/templates/course_duration_limits/edx_ace/expiryreminder/email/body.html deleted file mode 100644 index 900044b8fa..0000000000 --- a/openedx/features/course_duration_limits/templates/course_duration_limits/edx_ace/expiryreminder/email/body.html +++ /dev/null @@ -1,47 +0,0 @@ -{% extends 'ace_common/edx_ace/common/base_body.html' %} -{% load i18n %} -{% load ace %} - -{% block preview_text %} -{% blocktrans trimmed %} -We hope you have enjoyed {{first_course_name}}! You lose all access to this course in {{time_until_expiration}}. -{% endblocktrans %} -{% endblock %} - -{% block content %} - - - - -
-

- {% blocktrans trimmed %} - We hope you have enjoyed {{first_course_name}}! You lose all access to this course, including your progress, on - {{ first_course_expiration_date }} ({{time_until_expiration}}). - {% endblocktrans %} -

-

- {% blocktrans trimmed %} - Upgrade now to get unlimited access and for the chance to earn a verified certificate. - {% endblocktrans %} -

- - {% if show_upsell %} -

- - {% trans "Upgrade Now" %} - -

- {% endif %} -
-{% endblock %} diff --git a/openedx/features/course_duration_limits/templates/course_duration_limits/edx_ace/expiryreminder/email/body.txt b/openedx/features/course_duration_limits/templates/course_duration_limits/edx_ace/expiryreminder/email/body.txt deleted file mode 100644 index d6839e070a..0000000000 --- a/openedx/features/course_duration_limits/templates/course_duration_limits/edx_ace/expiryreminder/email/body.txt +++ /dev/null @@ -1,13 +0,0 @@ -{% autoescape off %} -{% load i18n %} -{% load ace %} - -{% blocktrans trimmed %} -We hope you have enjoyed {{first_course_name}}! You lose all access to this course, including your progress, on {{ first_course_expiration_date }} ({{time_until_expiration}}). - -Upgrade now to get unlimited access and for the chance to earn a verified certificate. -{% endblocktrans %} -{% if show_upsell %} -{% trans "Upgrade Now" %} <{% with_link_tracking upsell_link %}> -{% endif %} -{% endautoescape %} diff --git a/openedx/features/course_duration_limits/templates/course_duration_limits/edx_ace/expiryreminder/email/from_name.txt b/openedx/features/course_duration_limits/templates/course_duration_limits/edx_ace/expiryreminder/email/from_name.txt deleted file mode 100644 index 039cc99c9a..0000000000 --- a/openedx/features/course_duration_limits/templates/course_duration_limits/edx_ace/expiryreminder/email/from_name.txt +++ /dev/null @@ -1,3 +0,0 @@ -{% autoescape off %} -{{ first_course_name }} -{% endautoescape %} diff --git a/openedx/features/course_duration_limits/templates/course_duration_limits/edx_ace/expiryreminder/email/head.html b/openedx/features/course_duration_limits/templates/course_duration_limits/edx_ace/expiryreminder/email/head.html deleted file mode 100644 index 366ada7ad9..0000000000 --- a/openedx/features/course_duration_limits/templates/course_duration_limits/edx_ace/expiryreminder/email/head.html +++ /dev/null @@ -1 +0,0 @@ -{% extends 'ace_common/edx_ace/common/base_head.html' %} diff --git a/openedx/features/course_duration_limits/templates/course_duration_limits/edx_ace/expiryreminder/email/subject.txt b/openedx/features/course_duration_limits/templates/course_duration_limits/edx_ace/expiryreminder/email/subject.txt deleted file mode 100644 index 6e3dccbcd2..0000000000 --- a/openedx/features/course_duration_limits/templates/course_duration_limits/edx_ace/expiryreminder/email/subject.txt +++ /dev/null @@ -1,5 +0,0 @@ -{% autoescape off %} -{% load i18n %} - -{% blocktrans trimmed %}Upgrade to keep your access to {{first_course_name}}{% endblocktrans %} -{% endautoescape %} diff --git a/openedx/features/course_duration_limits/tests/test_models.py b/openedx/features/course_duration_limits/tests/test_models.py index 1ec9898b0a..18eaafb45e 100644 --- a/openedx/features/course_duration_limits/tests/test_models.py +++ b/openedx/features/course_duration_limits/tests/test_models.py @@ -39,18 +39,15 @@ class TestCourseDurationLimitConfig(CacheIsolationTestCase): super(TestCourseDurationLimitConfig, self).setUp() @ddt.data( - (True, True, True), - (True, True, False), - (True, False, True), - (True, False, False), - (False, False, True), - (False, False, False), + (True, True), + (True, False), + (False, True), + (False, False), ) @ddt.unpack def test_enabled_for_enrollment( self, already_enrolled, - pass_enrollment, enrolled_before_enabled, ): @@ -75,22 +72,13 @@ class TestCourseDurationLimitConfig(CacheIsolationTestCase): else: existing_enrollment = None - if pass_enrollment: - enrollment = existing_enrollment - user = None - course_key = None - else: - enrollment = None - user = self.user - course_key = self.course_overview.id + user = self.user + course_key = self.course_overview.id query_count = 7 - if pass_enrollment and already_enrolled: - query_count = 6 with self.assertNumQueries(query_count): enabled = CourseDurationLimitConfig.enabled_for_enrollment( - enrollment=enrollment, user=user, course_key=course_key, ) @@ -98,16 +86,14 @@ class TestCourseDurationLimitConfig(CacheIsolationTestCase): def test_enabled_for_enrollment_failure(self): with self.assertRaises(ValueError): - CourseDurationLimitConfig.enabled_for_enrollment(None, None, None) + CourseDurationLimitConfig.enabled_for_enrollment(None, None) with self.assertRaises(ValueError): CourseDurationLimitConfig.enabled_for_enrollment( - Mock(name='enrollment'), Mock(name='user'), None ) with self.assertRaises(ValueError): CourseDurationLimitConfig.enabled_for_enrollment( - Mock(name='enrollment'), None, Mock(name='course_key') )