def _track_notification_sent(message, context): """ Send analytics event for a sent email """ properties = { 'app_label': 'discussion', 'name': 'responsenotification', # This is 'Campaign' in GA 'language': message.language, 'uuid': six.text_type(message.uuid), 'send_uuid': six.text_type(message.send_uuid), 'thread_id': context['thread_id'], 'course_id': six.text_type(context['course_id']), 'thread_created_at': date.deserialize(context['thread_created_at']), 'nonInteraction': 1, } tracking_context = { 'host': context['site'].domain, 'path': '/', # make up a value, in order to allow the host to be passed along. } # The event used to specify the user_id as being the recipient of the email (i.e. the thread_author_id). # This has the effect of interrupting the actual chain of events for that author, if any, while the # email-sent event should really be associated with the sender, since that is what triggers the event. with tracker.get_tracker().context(properties['app_label'], tracking_context): segment.track( user_id=context['thread_author_id'], event_name='edx.bi.email.sent', properties=properties )
def _track_notification_sent(message, context): """ Send analytics event for a sent email """ properties = { 'app_label': 'discussion', 'name': 'responsenotification', # This is 'Campaign' in GA 'language': message.language, 'uuid': unicode(message.uuid), 'send_uuid': unicode(message.send_uuid), 'thread_id': context['thread_id'], 'course_id': unicode(context['course_id']), 'thread_created_at': date.deserialize(context['thread_created_at']), 'nonInteraction': 1, } tracking_context = { 'host': context['site'].domain, 'path': '/', # make up a value, in order to allow the host to be passed along. } # The event used to specify the user_id as being the recipient of the email (i.e. the thread_author_id). # This has the effect of interrupting the actual chain of events for that author, if any, while the # email-sent event should really be associated with the sender, since that is what triggers the event. with tracker.get_tracker().context(properties['app_label'], tracking_context): segment.track( user_id=context['thread_author_id'], event_name='edx.bi.email.sent', properties=properties )
def _should_randomly_suppress_schedule_creation( schedule_config, enrollment, upgrade_deadline, experience_type, content_availability_date, ): # The hold back ratio is always between 0 and 1. A value of 0 indicates that schedules should be created for all # schedules. A value of 1 indicates that no schedules should be created for any enrollments. A value of 0.2 would # mean that 20% of enrollments should *not* be given schedules. # This allows us to measure the impact of the dynamic schedule experience by comparing this "control" group that # does not receive any of benefits of the feature against the group that does. if random.random() < schedule_config.hold_back_ratio: log.debug('Schedules: Enrollment held back from dynamic schedule experiences.') upgrade_deadline_str = None if upgrade_deadline: upgrade_deadline_str = upgrade_deadline.isoformat() segment.track( user_id=enrollment.user.id, event_name='edx.bi.schedule.suppressed', properties={ 'course_id': unicode(enrollment.course_id), 'experience_type': experience_type, 'upgrade_deadline': upgrade_deadline_str, 'content_availability_date': content_availability_date.isoformat(), } ) return True return False
def _track_user_login(user, request): """ Sends a tracking event for a successful login. """ segment.identify( user.id, { 'email': request.POST.get('email'), 'username': user.username }, { # Disable MailChimp because we don't want to update the user's email # and username in MailChimp on every page load. We only need to capture # this data on registration/activation. 'MailChimp': False } ) segment.track( user.id, "edx.bi.user.account.authenticated", { 'category': "conversion", 'label': request.POST.get('course_id'), 'provider': None }, )
def test_track_without_tracking_context(self): segment.track(sentinel.user_id, sentinel.name, self.properties) self.assertTrue(self.mock_segment_track.called) args, kwargs = self.mock_segment_track.call_args expected_segment_context = {} self.assertEqual((sentinel.user_id, sentinel.name, self.properties, expected_segment_context), args)
def _should_randomly_suppress_schedule_creation( schedule_config, enrollment, upgrade_deadline, experience_type, content_availability_date, ): # The hold back ratio is always between 0 and 1. A value of 0 indicates that schedules should be created for all # schedules. A value of 1 indicates that no schedules should be created for any enrollments. A value of 0.2 would # mean that 20% of enrollments should *not* be given schedules. # This allows us to measure the impact of the dynamic schedule experience by comparing this "control" group that # does not receive any of benefits of the feature against the group that does. if random.random() < schedule_config.hold_back_ratio: log.debug( 'Schedules: Enrollment held back from dynamic schedule experiences.' ) upgrade_deadline_str = None if upgrade_deadline: upgrade_deadline_str = upgrade_deadline.isoformat() segment.track(user_id=enrollment.user.id, event_name='edx.bi.schedule.suppressed', properties={ 'course_id': six.text_type(enrollment.course_id), 'experience_type': experience_type, 'upgrade_deadline': upgrade_deadline_str, 'content_availability_date': content_availability_date.isoformat(), }) return True return False
def _track_user_registration(user, profile, params, third_party_provider): """ Track the user's registration. """ if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: identity_args = [ user.id, { 'email': user.email, 'username': user.username, 'name': profile.name, # Mailchimp requires the age & yearOfBirth to be integers, we send a sane integer default if falsey. 'age': profile.age or -1, 'yearOfBirth': profile.year_of_birth or datetime.datetime.now(UTC).year, 'education': profile.level_of_education_display, 'address': profile.mailing_address, 'gender': profile.gender_display, 'country': text_type(profile.country), } ] # .. pii: Many pieces of PII are sent to Segment here. Retired directly through Segment API call in Tubular. # .. pii_types: email_address, username, name, birth_date, location, gender # .. pii_retirement: third_party segment.identify(*identity_args) segment.track( user.id, "edx.bi.user.account.registered", { 'category': 'conversion', # ..pii: Learner email is sent to Segment in following line and will be associated with analytics data. 'email': user.email, 'label': params.get('course_id'), 'provider': third_party_provider.name if third_party_provider else None }, )
def _track_message_sent(site, user, msg): properties = { 'site': site.domain, 'app_label': msg.app_label, 'name': msg.name, 'language': msg.language, 'uuid': unicode(msg.uuid), 'send_uuid': unicode(msg.send_uuid), 'nonInteraction': 1, } course_ids = msg.context.get('course_ids', []) properties['num_courses'] = len(course_ids) if len(course_ids) > 0: properties['course_ids'] = course_ids[:10] properties['primary_course_id'] = course_ids[0] tracking_context = { 'host': site.domain, '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(msg.app_label, tracking_context): segment.track( user_id=user.id, event_name='edx.bi.email.sent', properties=properties, )
def _track_message_sent(site, user, msg): properties = { 'site': site.domain, 'app_label': msg.app_label, 'name': msg.name, 'language': msg.language, 'uuid': six.text_type(msg.uuid), 'send_uuid': six.text_type(msg.send_uuid), 'nonInteraction': 1, } course_ids = msg.context.get('course_ids', []) properties['num_courses'] = len(course_ids) if len(course_ids) > 0: properties['course_ids'] = course_ids[:10] properties['primary_course_id'] = course_ids[0] tracking_context = { 'host': site.domain, '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(msg.app_label, tracking_context): segment.track( user_id=user.id, event_name='edx.bi.email.sent', properties=properties, )
def _track_user_login(user, request): """ Sends a tracking event for a successful login. """ segment.identify( user.id, { 'email': request.POST.get('email'), 'username': user.username }, { # Disable MailChimp because we don't want to update the user's email # and username in MailChimp on every page load. We only need to capture # this data on registration/activation. 'MailChimp': False }) segment.track( user.id, "edx.bi.user.account.authenticated", { 'category': "conversion", 'label': request.POST.get('course_id'), 'provider': None }, )
def _track_user_login(user, request): """ Sends a tracking event for a successful login. """ # .. pii: Username and email are sent to Segment here. Retired directly through Segment API call in Tubular. # .. pii_types: email_address, username # .. pii_retirement: third_party segment.identify( user.id, { 'email': request.POST.get('email'), 'username': user.username }, { # Disable MailChimp because we don't want to update the user's email # and username in MailChimp on every page load. We only need to capture # this data on registration/activation. 'MailChimp': False } ) segment.track( user.id, "edx.bi.user.account.authenticated", { 'category': "conversion", 'label': request.POST.get('course_id'), 'provider': None }, )
def _track_user_login(user, request): """ Sends a tracking event for a successful login. """ # .. pii: Username and email are sent to Segment here. Retired directly through Segment API call in Tubular. # .. pii_types: email_address, username # .. pii_retirement: third_party segment.identify( user.id, { 'email': request.POST.get('email'), 'username': user.username }, { # Disable MailChimp because we don't want to update the user's email # and username in MailChimp on every page load. We only need to capture # this data on registration/activation. 'MailChimp': False }) segment.track( user.id, "edx.bi.user.account.authenticated", { 'category': "conversion", 'label': request.POST.get('course_id'), 'provider': None }, )
def _is_in_holdback(user): """ Return whether the specified user is in the first-purchase-discount holdback group. """ if datetime(2020, 8, 1, tzinfo=pytz.UTC) <= datetime.now(tz=pytz.UTC): return False # Holdback is 50/50 bucket = stable_bucketing_hash_group(DISCOUNT_APPLICABILITY_HOLDBACK, 2, user.username) request = get_current_request() if hasattr(request, 'session' ) and DISCOUNT_APPLICABILITY_HOLDBACK not in request.session: properties = { 'site': request.site.domain, 'app_label': 'discounts', 'nonInteraction': 1, 'bucket': bucket, 'experiment': 'REVEM-363', } segment.track( user_id=user.id, event_name='edx.bi.experiment.user.bucketed', properties=properties, ) # Mark that we've recorded this bucketing, so that we don't do it again this session request.session[DISCOUNT_APPLICABILITY_HOLDBACK] = True return bucket == 0
def track_event(user_id, event_name, properties): """ Emit a track event to segment (and forwarded to GA) for some parts of the Enterprise workflows. """ # Only call the endpoint if the import was successful. if segment: segment.track(user_id, event_name, properties)
def emit_course_goal_event(sender, instance, **kwargs): """Emit events for both tracking logs and for Segment.""" name = 'edx.course.goal.added' if kwargs.get( 'created', False) else 'edx.course.goal.updated' tracker.emit(name, { 'goal_key': instance.goal_key, }) segment.track(instance.user.id, name)
def emit_course_goal_event(sender, instance, **kwargs): """Emit events for both tracking logs and for Segment.""" name = 'edx.course.goal.added' if kwargs.get('created', False) else 'edx.course.goal.updated' tracker.emit( name, { 'goal_key': instance.goal_key, } ) segment.track(instance.user.id, name)
def _fire_event(self, user, event_name, parameters): """ Fire an analytics event. Arguments: user (User): The user who submitted photos. event_name (str): Name of the analytics event. parameters (dict): Event parameters. Returns: None """ segment.track(user.id, event_name, parameters)
def login_analytics(strategy, auth_entry, current_partial=None, *args, **kwargs): """ Sends login info to Segment """ event_name = None if auth_entry == AUTH_ENTRY_LOGIN: event_name = 'edx.bi.user.account.authenticated' elif auth_entry in [AUTH_ENTRY_ACCOUNT_SETTINGS]: event_name = 'edx.bi.user.account.linked' if event_name is not None: segment.track(kwargs['user'].id, event_name, { 'category': "conversion", 'label': None, 'provider': kwargs['backend'].name })
def _track_user_registration(user, profile, params, third_party_provider): """ Track the user's registration. """ if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: identity_args = [ user.id, { 'email': user.email, 'username': user.username, 'name': profile.name, # Mailchimp requires the age & yearOfBirth to be integers, we send a sane integer default if falsey. 'age': profile.age or -1, 'yearOfBirth': profile.year_of_birth or datetime.datetime.now(UTC).year, 'education': profile.level_of_education_display, 'address': profile.mailing_address, 'gender': profile.gender_display, 'country': text_type(profile.country), } ] # Provide additional context only if needed. if hasattr(settings, 'MAILCHIMP_NEW_USER_LIST_ID'): identity_args.append( {"MailChimp": { "listId": settings.MAILCHIMP_NEW_USER_LIST_ID }}) segment.identify(*identity_args) segment.track( user.id, "edx.bi.user.account.registered", { 'category': 'conversion', 'label': params.get('course_id'), 'provider': third_party_provider.name if third_party_provider else None }, )
def _track_user_registration(user, profile, params, third_party_provider): """ Track the user's registration. """ if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: identity_args = [ user.id, { 'email': user.email, 'username': user.username, 'name': profile.name, # Mailchimp requires the age & yearOfBirth to be integers, we send a sane integer default if falsey. 'age': profile.age or -1, 'yearOfBirth': profile.year_of_birth or datetime.datetime.now(UTC).year, 'education': profile.level_of_education_display, 'address': profile.mailing_address, 'gender': profile.gender_display, 'country': text_type(profile.country), } ] # Provide additional context only if needed. if hasattr(settings, 'MAILCHIMP_NEW_USER_LIST_ID'): identity_args.append({ "MailChimp": { "listId": settings.MAILCHIMP_NEW_USER_LIST_ID } }) # .. pii: Many pieces of PII are sent to Segment here. Retired directly through Segment API call in Tubular. # .. pii_types: email_address, username, name, birth_date, location, gender # .. pii_retirement: third_party segment.identify(*identity_args) segment.track( user.id, "edx.bi.user.account.registered", { 'category': 'conversion', # ..pii: Learner email is sent to Segment in following line and will be associated with analytics data. 'email': user.email, 'label': params.get('course_id'), 'provider': third_party_provider.name if third_party_provider else None }, )
def _track_update_email_opt_in(user_id, organization, opt_in): """Track an email opt-in preference change. Arguments: user_id (str): The ID of the user making the preference change. organization (str): The organization whose emails are being opted into or out of by the user. opt_in (bool): Whether the user has chosen to opt-in to emails from the organization. Returns: None """ event_name = 'edx.bi.user.org_email.opted_in' if opt_in else 'edx.bi.user.org_email.opted_out' segment.track( user_id, event_name, { 'category': 'communication', 'label': organization }, )
def test_track_with_standard_context(self): # Note that 'host' and 'path' will be urlparsed, so must be strings. tracking_context = { 'accept_language': sentinel.accept_language, 'referer': sentinel.referer, 'username': sentinel.username, 'session': sentinel.session, 'ip': sentinel.ip, 'host': 'hostname', 'agent': sentinel.agent, 'path': '/this/is/a/path', 'user_id': sentinel.user_id, 'course_id': sentinel.course_id, 'org_id': sentinel.org_id, 'client_id': sentinel.client_id, } with self.tracker.context('test', tracking_context): segment.track(sentinel.user_id, sentinel.name, self.properties) self.assertTrue(self.mock_segment_track.called) args, kwargs = self.mock_segment_track.call_args expected_segment_context = { 'ip': sentinel.ip, 'Google Analytics': { 'clientId': sentinel.client_id, }, 'userAgent': sentinel.agent, 'page': { 'path': '/this/is/a/path', 'referrer': sentinel.referer, 'url': 'https://hostname/this/is/a/path' # Synthesized URL value. } } self.assertEqual((sentinel.user_id, sentinel.name, self.properties, expected_segment_context), args)
def _track_user_registration(user, profile, params, third_party_provider): """ Track the user's registration. """ if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: identity_args = [ user.id, { 'email': user.email, 'username': user.username, 'name': profile.name, # Mailchimp requires the age & yearOfBirth to be integers, we send a sane integer default if falsey. 'age': profile.age or -1, 'yearOfBirth': profile.year_of_birth or datetime.datetime.now(UTC).year, 'education': profile.level_of_education_display, 'address': profile.mailing_address, 'gender': profile.gender_display, 'country': text_type(profile.country), } ] # Provide additional context only if needed. if hasattr(settings, 'MAILCHIMP_NEW_USER_LIST_ID'): identity_args.append({ "MailChimp": { "listId": settings.MAILCHIMP_NEW_USER_LIST_ID } }) segment.identify(*identity_args) segment.track( user.id, "edx.bi.user.account.registered", { 'category': 'conversion', 'label': params.get('course_id'), 'provider': third_party_provider.name if third_party_provider else None }, )
def test_track_context_with_stuff(self, tracking_context, provided_context, expected_segment_context): # Test first with tracking and no provided context. with self.tracker.context('test', tracking_context): segment.track(sentinel.user_id, sentinel.name, self.properties) args, kwargs = self.mock_segment_track.call_args self.assertEqual((sentinel.user_id, sentinel.name, self.properties, expected_segment_context), args) # Test with provided context and no tracking context. segment.track(sentinel.user_id, sentinel.name, self.properties, provided_context) args, kwargs = self.mock_segment_track.call_args self.assertEqual((sentinel.user_id, sentinel.name, self.properties, provided_context), args) # Test with provided context and also tracking context. with self.tracker.context('test', tracking_context): segment.track(sentinel.user_id, sentinel.name, self.properties, provided_context) self.assertTrue(self.mock_segment_track.called) args, kwargs = self.mock_segment_track.call_args self.assertEqual((sentinel.user_id, sentinel.name, self.properties, provided_context), args)
def get(self, request): """ Return the if the course should be upsold in the mobile app, if the user has appropriate permissions. """ if not MOBILE_UPSELL_FLAG.is_enabled(): return Response({ 'show_upsell': False, 'upsell_flag': False, }) if 'course_id' not in request.GET: return Response({ 'show_upsell': False, }) # HACK: the url decoding converts plus to space; put them back course_id = request.GET.get('course_id').replace(' ', '+') course_key = CourseKey.from_string(course_id) course = CourseOverview.get_from_id(course_key) user = request.user enrollment = None user_enrollments = None has_non_audit_enrollments = False try: user_enrollments = CourseEnrollment.objects.select_related( 'course').filter(user_id=user.id) has_non_audit_enrollments = user_enrollments.exclude( mode__in=CourseMode.UPSELL_TO_VERIFIED_MODES).exists() enrollment = CourseEnrollment.objects.select_related('course').get( user_id=user.id, course_id=course.id) except CourseEnrollment.DoesNotExist: pass # Not enrolled, use the default values context = get_base_experiment_metadata_context(course, user, enrollment, user_enrollments) bucket = stable_bucketing_hash_group(MOBILE_UPSELL_EXPERIMENT, 2, user.username) if hasattr( request, 'session') and MOBILE_UPSELL_EXPERIMENT not in request.session: properties = { 'site': request.site.domain, 'app_label': 'experiments', 'bucket': bucket, 'experiment': 'REV-934', } segment.track( user_id=user.id, event_name='edx.bi.experiment.user.bucketed', properties=properties, ) # Mark that we've recorded this bucketing, so that we don't do it again this session request.session[MOBILE_UPSELL_EXPERIMENT] = True show_upsell = bucket != 0 and not has_non_audit_enrollments if show_upsell: return Response({ 'show_upsell': show_upsell, 'price': context.get('upgrade_price'), 'basket_url': context.get('upgrade_link'), }) else: return Response({ 'show_upsell': show_upsell, 'upsell_flag': MOBILE_UPSELL_FLAG.is_enabled(), 'experiment_bucket': bucket, })
def test_null_key(self): segment.track(sentinel.user_id, sentinel.name, self.properties) self.assertFalse(self.mock_segment_track.called)
def test_missing_name(self): segment.track(sentinel.user_id, None, self.properties) self.assertFalse(self.mock_segment_track.called)
def get(self, request): """ Return the if the course should be upsold in the mobile app, if the user has appropriate permissions. """ if not MOBILE_UPSELL_FLAG.is_enabled(): return Response({ 'show_upsell': False, 'upsell_flag': False, }) course_id = request.GET.get('course_id') try: course_key = CourseKey.from_string(course_id) except InvalidKeyError: return HttpResponseBadRequest("Missing or invalid course_id") course = CourseOverview.get_from_id(course_key) if not course.has_started() or course.has_ended(): return Response({ 'show_upsell': False, 'upsell_flag': MOBILE_UPSELL_FLAG.is_enabled(), 'course_running': False, }) user = request.user try: enrollment = CourseEnrollment.objects.select_related('course').get( user_id=user.id, course_id=course.id) user_upsell = verified_upgrade_link_is_valid(enrollment) except CourseEnrollment.DoesNotExist: user_upsell = True basket_url = EcommerceService().upgrade_url(user, course.id) upgrade_price = six.text_type( get_cosmetic_verified_display_price(course)) could_upsell = bool(user_upsell and basket_url) bucket = stable_bucketing_hash_group(MOBILE_UPSELL_EXPERIMENT, 2, user.username) if could_upsell and hasattr( request, 'session') and MOBILE_UPSELL_EXPERIMENT not in request.session: properties = { 'site': request.site.domain, 'app_label': 'experiments', 'bucket': bucket, 'experiment': 'REV-934', } segment.track( user_id=user.id, event_name='edx.bi.experiment.user.bucketed', properties=properties, ) # Mark that we've recorded this bucketing, so that we don't do it again this session request.session[MOBILE_UPSELL_EXPERIMENT] = True show_upsell = bool(bucket != 0 and could_upsell) if show_upsell: return Response({ 'show_upsell': show_upsell, 'price': upgrade_price, 'basket_url': basket_url, }) else: return Response({ 'show_upsell': show_upsell, 'upsell_flag': MOBILE_UPSELL_FLAG.is_enabled(), 'experiment_bucket': bucket, 'user_upsell': user_upsell, 'basket_url': basket_url, })
def get_bucket(self, course_key=None, track=True): """ Return which bucket number the specified user is in. Bucket 0 is assumed to be the control bucket and will be returned if the experiment is not enabled for this user and course. """ # Keep some imports in here, because this class is commonly used at a module level, and we want to avoid # circular imports for any models. from experiments.models import ExperimentKeyValue request = get_current_request() if not request: return 0 if not hasattr(request, 'user') or not request.user.id: # We need username for stable bucketing and id for tracking, so just skip anonymous (not-logged-in) users return 0 # Use course key in experiment name to separate caches and segment calls per-course-run experiment_name = self.namespaced_flag_name + ('.{}'.format(course_key) if course_key else '') # Check if we have a cache for this request already request_cache = RequestCache('experiments') cache_response = request_cache.get_cached_response(experiment_name) if cache_response.is_found: return cache_response.value # Check if the main flag is even enabled for this user and course. if not self.is_experiment_on( course_key): # grabs user from the current request, if any return self._cache_bucket(experiment_name, 0) # Check if the enrollment should even be considered (if it started before the experiment wants, we ignore) if course_key and self.experiment_id is not None: values = ExperimentKeyValue.objects.filter( experiment_id=self.experiment_id).values('key', 'value') values = {pair['key']: pair['value'] for pair in values} if not self._is_enrollment_inside_date_bounds( values, request.user, course_key): return self._cache_bucket(experiment_name, 0) bucket = stable_bucketing_hash_group(experiment_name, self.num_buckets, request.user.username) # Now check if the user is forced into a particular bucket, using our subordinate bucket flags for i, bucket_flag in enumerate(self.bucket_flags): if bucket_flag.is_enabled(course_key): bucket = i break session_key = 'tracked.{}'.format(experiment_name) if track and hasattr(request, 'session') and session_key not in request.session: segment.track(user_id=request.user.id, event_name='edx.bi.experiment.user.bucketed', properties={ 'site': request.site.domain, 'app_label': self.waffle_namespace.name, 'experiment': self.flag_name, 'course_id': str(course_key) if course_key else None, 'bucket': bucket, 'is_staff': request.user.is_staff, 'nonInteraction': 1, }) # Mark that we've recorded this bucketing, so that we don't do it again this session request.session[session_key] = True return self._cache_bucket(experiment_name, bucket)
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 # 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() for schedule in user_schedules: upsell_context = _get_upsell_information_for_schedule(user, schedule) if not upsell_context['show_upsell']: continue if not CourseDurationLimitConfig.enabled_for_enrollment(enrollment=schedule.enrollment): LOG.info(u"course duration limits not enabled for %s", schedule.enrollment) continue expiration_date = get_user_course_expiration_date(user, schedule.enrollment.course) if expiration_date is None: LOG.info(u"No course expiration date for %s", 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_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, '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_bucket(self, course_key=None, track=True): """ Return which bucket number the specified user is in. Bucket 0 is assumed to be the control bucket and will be returned if the experiment is not enabled for this user and course. """ # Keep some imports in here, because this class is commonly used at a module level, and we want to avoid # circular imports for any models. from experiments.models import ExperimentKeyValue from student.models import CourseEnrollment request = get_current_request() if not request: return 0 if not request.user.id: # We need username for stable bucketing and id for tracking, so just skip anonymous (not-logged-in) users return 0 # Use course key in experiment name to separate caches and segment calls per-course-run experiment_name = self.namespaced_flag_name + ('.{}'.format(course_key) if course_key else '') # Check if we have a cache for this request already request_cache = RequestCache('experiments') cache_response = request_cache.get_cached_response(experiment_name) if cache_response.is_found: return cache_response.value # Check if the main flag is even enabled for this user and course. if not self._is_enabled( course_key): # grabs user from the current request, if any return self._cache_bucket(experiment_name, 0) # Check if the enrollment should even be considered (if it started before the experiment wants, we ignore) if course_key and self.experiment_id is not None: start_val = ExperimentKeyValue.objects.filter( experiment_id=self.experiment_id, key='enrollment_start') if start_val: try: start_date = dateutil.parser.parse( start_val.first().value).replace(tzinfo=pytz.UTC) except ValueError: log.exception( 'Could not parse enrollment start date for experiment %d', self.experiment_id) return self._cache_bucket(experiment_name, 0) enrollment = CourseEnrollment.get_enrollment( request.user, course_key) # Only bail if they have an enrollment and it's old -- if they don't have an enrollment, we want to do # normal bucketing -- consider the case where the experiment has bits that show before you enroll. We # want to keep your bucketing stable before and after you do enroll. if enrollment and enrollment.created < start_date: return self._cache_bucket(experiment_name, 0) bucket = stable_bucketing_hash_group(experiment_name, self.num_buckets, request.user.username) # Now check if the user is forced into a particular bucket, using our subordinate bucket flags for i, bucket_flag in enumerate(self.bucket_flags): if bucket_flag.is_enabled(course_key): bucket = i break session_key = 'tracked.{}'.format(experiment_name) if track and hasattr(request, 'session') and session_key not in request.session: segment.track(user_id=request.user.id, event_name='edx.bi.experiment.user.bucketed', properties={ 'site': request.site.domain, 'app_label': self.waffle_namespace.name, 'experiment': self.flag_name, 'course_id': str(course_key) if course_key else None, 'bucket': bucket, 'is_staff': request.user.is_staff, 'nonInteraction': 1, }) # Mark that we've recorded this bucketing, so that we don't do it again this session request.session[session_key] = True return self._cache_bucket(experiment_name, bucket)
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_bucket(self, course_key=None, track=True): """ Return which bucket number the specified user is in. The user may be force-bucketed if matching subordinate flags of the form "main_flag.BUCKET_NUM" exist. Otherwise, they will be hashed into a default bucket based on their username, the experiment name, and the course-run key. If `self.use_course_aware_bucketing` is False, the course-run key will be omitted from the hashing formula, thus making it so a given user has the same default bucket across all course runs; however, subordinate flags that match the course-run key will still apply. If `course_key` argument is omitted altogether, then subordinate flags will be evaluated outside of the course-run context, and the default bucket will be calculated as if `self.use_course_aware_bucketing` is False. Finally, Bucket 0 is assumed to be the control bucket and will be returned if the experiment is not enabled for this user and course. Arguments: course_key (Optional[CourseKey]) track (bool): Whether an analytics event should be generated if the user is bucketed for the first time. Returns: int """ # Keep some imports in here, because this class is commonly used at a module level, and we want to avoid # circular imports for any models. from experiments.models import ExperimentKeyValue from lms.djangoapps.courseware.masquerade import get_specific_masquerading_user request = get_current_request() if not request: return 0 if not hasattr(request, 'user') or not request.user.id: # We need username for stable bucketing and id for tracking, so just skip anonymous (not-logged-in) users return 0 user = get_specific_masquerading_user(request.user, course_key) if user is None: user = request.user masquerading_as_specific_student = False else: masquerading_as_specific_student = True # If a course key is passed in, include it in the experiment name # in order to separate caches and analytics calls per course-run. # If we are using course-aware bucketing, then also append that course key # to `bucketing_group_name`, such that users can be hashed into different # buckets for different course-runs. experiment_name = bucketing_group_name = self.namespaced_flag_name if course_key: experiment_name += ".{}".format(course_key) if course_key and self.use_course_aware_bucketing: bucketing_group_name += ".{}".format(course_key) # Check if we have a cache for this request already request_cache = RequestCache('experiments') cache_response = request_cache.get_cached_response(experiment_name) if cache_response.is_found: return cache_response.value # Check if the main flag is even enabled for this user and course. if not self.is_experiment_on( course_key): # grabs user from the current request, if any return self._cache_bucket(experiment_name, 0) # Check if the enrollment should even be considered (if it started before the experiment wants, we ignore) if course_key and self.experiment_id is not None: values = ExperimentKeyValue.objects.filter( experiment_id=self.experiment_id).values('key', 'value') values = {pair['key']: pair['value'] for pair in values} if not self._is_enrollment_inside_date_bounds( values, user, course_key): return self._cache_bucket(experiment_name, 0) # Determine the user's bucket. # First check if forced into a particular bucket, using our subordinate bucket flags. # If not, calculate their default bucket using a consistent hash function. for i, bucket_flag in enumerate(self.bucket_flags): if bucket_flag.is_enabled(course_key): bucket = i break else: bucket = stable_bucketing_hash_group(bucketing_group_name, self.num_buckets, user.username) session_key = 'tracked.{}'.format(experiment_name) if (track and hasattr(request, 'session') and session_key not in request.session and not masquerading_as_specific_student): segment.track(user_id=user.id, event_name='edx.bi.experiment.user.bucketed', properties={ 'site': request.site.domain, 'app_label': self.waffle_namespace.name, 'experiment': self.flag_name, 'course_id': str(course_key) if course_key else None, 'bucket': bucket, 'is_staff': user.is_staff, 'nonInteraction': 1, }) # Mark that we've recorded this bucketing, so that we don't do it again this session request.session[session_key] = True return self._cache_bucket(experiment_name, bucket)