support a GA tracking pixel
This commit is contained in:
@@ -101,10 +101,9 @@ def _get_course_language(course_id):
|
||||
|
||||
|
||||
def _build_message_context(context):
|
||||
message_context = get_base_template_context(Site.objects.get(id=context['site_id']))
|
||||
message_context = get_base_template_context(context['site'])
|
||||
message_context.update(_deserialize_context_dates(context))
|
||||
message_context['post_link'] = _get_thread_url(context)
|
||||
message_context['ga_tracking_pixel_url'] = _generate_ga_pixel_url(context)
|
||||
return message_context
|
||||
|
||||
|
||||
@@ -122,25 +121,3 @@ def _get_thread_url(context):
|
||||
'id': context['thread_id'],
|
||||
}
|
||||
return urljoin(context['site'].domain, permalink(thread_content))
|
||||
|
||||
|
||||
def _generate_ga_pixel_url(context):
|
||||
# used for analytics
|
||||
query_params = {
|
||||
'v': '1', # version, required for GA
|
||||
't': 'event', #
|
||||
'ec': 'email', # event category
|
||||
'ea': 'edx.bi.email.opened', # event action: in this case, the user opened the email
|
||||
'tid': get_value("GOOGLE_ANALYTICS_TRACKING_ID", getattr(settings, "GOOGLE_ANALYTICS_TRACKING_ID", None)), # tracking ID to associate this link with our GA instance
|
||||
'uid': context['thread_author_id'],
|
||||
'cs': 'sailthru', # Campaign source - what sent the email
|
||||
'cm': 'email', # Campaign medium - how the content is being delivered
|
||||
'cn': 'triggered_discussionnotification', # Campaign name - human-readable name for this particular class of message
|
||||
'dp': '/email/ace/discussions/responsenotification/{0}/'.format(context['course_id']), # document path, used for drilling down into specific events
|
||||
'dt': 'Reply to {0} at {1}'.format(context['thread_title'], context['comment_created_at']), # document title, should match the title of the email
|
||||
}
|
||||
|
||||
return u"{url}?{params}".format(
|
||||
url="https://www.google-analytics.com/collect",
|
||||
params=urlencode(query_params)
|
||||
)
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block preview_text %}
|
||||
{% blocktrans trimmed %}
|
||||
Hi {{ thread_username }}
|
||||
{% endblocktrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<table width="100%" align="left" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
{% blocktrans trimmed %}
|
||||
<h1>
|
||||
Hi {{ thread_username }},
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
{{ comment_username }} made the following reply to {{ thread_title }} at {{ comment_created_at }}.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{{ comment_body }}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="{{ post_link }}"> View the discussion.</a>
|
||||
</p>
|
||||
{% endblocktrans %}
|
||||
|
||||
<p>
|
||||
{# email client support for style sheets is pretty spotty, so we have to inline all of these styles #}
|
||||
<a
|
||||
{% if course_ids|length == 1 %}
|
||||
href="{{ upsell_link }}"
|
||||
{% else %}
|
||||
href="{{ dashboard_url }}"
|
||||
{% endif %}
|
||||
style="
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
-webkit-border-radius: 4px;
|
||||
-moz-border-radius: 4px;
|
||||
background-color: #005686;
|
||||
border-top: 10px solid #005686;
|
||||
border-bottom: 10px solid #005686;
|
||||
border-right: 16px solid #005686;
|
||||
border-left: 16px solid #005686;
|
||||
display: inline-block;
|
||||
">
|
||||
</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock %}
|
||||
|
||||
{% block google_analytics_pixel %}
|
||||
<img src="{{ ga_tracking_pixel_url }}" alt="" role="presentation" aria-hidden="true" />
|
||||
{% endblock %}
|
||||
@@ -1,29 +0,0 @@
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<title>{% block title %}Reply to {{ thread_title }} at {{ comment_created_at }} {% endblock %}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
|
||||
<style type="text/css">
|
||||
@media only screen and (min-device-width: 601px) {
|
||||
.content {
|
||||
width: 600px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@-ms-viewport{
|
||||
width: device-width;
|
||||
}
|
||||
|
||||
/* Column Drop Layout Pattern CSS */
|
||||
@media only screen and (max-width: 450px) {
|
||||
td[class="col"] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
-moz-box-sizing: border-box;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
float: left;
|
||||
text-align: left !important;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends 'schedules/edx_ace/common/base_body.html' %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block preview_text %}
|
||||
{% blocktrans trimmed %}
|
||||
{{ comment_username }} replied to your thread.
|
||||
{% endblocktrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<table width="100%" align="left" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
{% blocktrans trimmed %}
|
||||
<p>
|
||||
Hi {{ thread_username }},
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{{ comment_username }} made the following reply to {{ thread_title }} at {{ comment_created_at }}.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{{ comment_body }}
|
||||
</p>
|
||||
{% endblocktrans %}
|
||||
|
||||
{% trans "View the discussion" as course_cta_text %}
|
||||
{% include "schedules/edx_ace/common/return_to_course_cta.html" with course_cta_text=course_cta_text course_cta_url=post_link%}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1 @@
|
||||
{% extends 'schedules/edx_ace/common/base_head.html' %}
|
||||
@@ -1,25 +1,23 @@
|
||||
"""
|
||||
Tests the execution of forum notification tasks.
|
||||
"""
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import math
|
||||
from urlparse import urljoin
|
||||
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
import mock
|
||||
|
||||
import lms.lib.comment_client as cc
|
||||
|
||||
from django_comment_common.models import ForumsConfig
|
||||
from django_comment_common.signals import comment_created
|
||||
from edx_ace.recipient import Recipient
|
||||
from edx_ace.utils import date
|
||||
from lms.djangoapps.discussion.config.waffle import waffle, FORUM_RESPONSE_NOTIFICATIONS, SEND_NOTIFICATIONS_FOR_COURSE
|
||||
from lms.djangoapps.discussion.signals.handlers import ENABLE_FORUM_NOTIFICATIONS_FOR_SITE_KEY
|
||||
from lms.djangoapps.discussion.tasks import _should_send_message, _generate_ga_pixel_url
|
||||
import lms.lib.comment_client as cc
|
||||
from lms.djangoapps.discussion.tasks import _should_send_message
|
||||
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
||||
from openedx.core.djangoapps.schedules.template_context import get_base_template_context
|
||||
from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory
|
||||
@@ -204,11 +202,10 @@ class TaskTestCase(ModuleStoreTestCase):
|
||||
'thread_title': 'thread-title',
|
||||
'thread_username': self.thread_author.username,
|
||||
'thread_commentable_id': self.thread['commentable_id'],
|
||||
'post_link': urljoin(site.domain, self.mock_permalink.return_value),
|
||||
'post_link': self.mock_permalink.return_value,
|
||||
'site': site,
|
||||
'site_id': site.id
|
||||
})
|
||||
expected_message_context['ga_tracking_pixel_url'] = _generate_ga_pixel_url(expected_message_context)
|
||||
expected_recipient = Recipient(self.thread_author.username, self.thread_author.email)
|
||||
actual_message = self.mock_ace_send.call_args_list[0][0][0]
|
||||
self.assertEqual(expected_message_context, actual_message.context)
|
||||
|
||||
@@ -400,7 +400,9 @@ class ScheduleSendEmailTestBase(FilteredQueryCountMixin, CacheIsolationTestCase)
|
||||
self.assertEqual(len(sent_messages), num_expected_messages)
|
||||
|
||||
with self.assertNumQueries(NUM_QUERIES_PER_MESSAGE_DELIVERY):
|
||||
self.deliver_task(*sent_messages[0])
|
||||
with patch('analytics.track') as mock_analytics_track:
|
||||
self.deliver_task(*sent_messages[0])
|
||||
self.assertEqual(mock_analytics_track.call_count, 1)
|
||||
|
||||
self.assertEqual(mock_channel.deliver.call_count, 1)
|
||||
for (_name, (_msg, email), _kwargs) in mock_channel.deliver.mock_calls:
|
||||
|
||||
@@ -20,10 +20,7 @@ from openedx.core.djangoapps.schedules.content_highlights import get_week_highli
|
||||
from openedx.core.djangoapps.schedules.exceptions import CourseUpdateDoesNotExist
|
||||
from openedx.core.djangoapps.schedules.models import Schedule, ScheduleExperience
|
||||
from openedx.core.djangoapps.schedules.utils import PrefixedDebugLoggerMixin
|
||||
from openedx.core.djangoapps.schedules.template_context import (
|
||||
absolute_url,
|
||||
get_base_template_context
|
||||
)
|
||||
from openedx.core.djangoapps.schedules.template_context import get_base_template_context
|
||||
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
|
||||
|
||||
|
||||
@@ -247,9 +244,7 @@ class RecurringNudgeResolver(BinnedSchedulesBaseResolver):
|
||||
first_schedule = user_schedules[0]
|
||||
context = {
|
||||
'course_name': first_schedule.enrollment.course.display_name,
|
||||
'course_url': absolute_url(
|
||||
self.site, reverse('course_root', args=[str(first_schedule.enrollment.course_id)])
|
||||
),
|
||||
'course_url': reverse('course_root', args=[str(first_schedule.enrollment.course_id)]),
|
||||
}
|
||||
|
||||
# Information for including upsell messaging in template.
|
||||
@@ -289,7 +284,7 @@ class UpgradeReminderResolver(BinnedSchedulesBaseResolver):
|
||||
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])),
|
||||
'url': reverse('course_root', args=[course_id_str]),
|
||||
'name': schedule.enrollment.course.display_name
|
||||
})
|
||||
|
||||
@@ -300,7 +295,7 @@ class UpgradeReminderResolver(BinnedSchedulesBaseResolver):
|
||||
context = {
|
||||
'course_links': course_links,
|
||||
'first_course_name': first_schedule.enrollment.course.display_name,
|
||||
'cert_image': absolute_url(self.site, static('course_experience/images/verified-cert.png')),
|
||||
'cert_image': static('course_experience/images/verified-cert.png'),
|
||||
'course_ids': course_id_strs,
|
||||
}
|
||||
context.update(first_valid_upsell_context)
|
||||
@@ -365,9 +360,7 @@ class CourseUpdateResolver(BinnedSchedulesBaseResolver):
|
||||
course_id_str = str(enrollment.course_id)
|
||||
template_context.update({
|
||||
'course_name': schedule.enrollment.course.display_name,
|
||||
'course_url': absolute_url(
|
||||
self.site, reverse('course_root', args=[course_id_str])
|
||||
),
|
||||
'course_url': reverse('course_root', args=[course_id_str]),
|
||||
'week_num': week_num,
|
||||
'week_highlights': week_highlights,
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
import analytics
|
||||
from celery.task import task, Task
|
||||
from crum import CurrentRequestUserMiddleware
|
||||
from django.conf import settings
|
||||
@@ -180,7 +181,7 @@ class ScheduleCourseUpdate(ScheduleMessageBaseTask):
|
||||
|
||||
|
||||
def _schedule_send(msg_str, site_id, delivery_config_var, log_prefix):
|
||||
site = Site.objects.get(pk=site_id)
|
||||
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)
|
||||
|
||||
@@ -193,6 +194,31 @@ def _schedule_send(msg_str, site_id, delivery_config_var, log_prefix):
|
||||
_annonate_send_task_for_monitoring(msg)
|
||||
LOG.debug('%s: Sending message = %s', log_prefix, msg_str)
|
||||
ace.send(msg)
|
||||
_track_message_sent(site, user, msg)
|
||||
|
||||
|
||||
def _track_message_sent(site, user, msg):
|
||||
properties = {
|
||||
'site': site.domain,
|
||||
'app_label': msg.app_label,
|
||||
'name': msg.name,
|
||||
'language': msg.language,
|
||||
'uuid': msg.uuid,
|
||||
'send_uuid': msg.send_uuid,
|
||||
}
|
||||
course_ids = msg.context.get('course_ids', [])
|
||||
if len(course_ids) > 0:
|
||||
properties['course_ids'] = course_ids[:10]
|
||||
properties['primary_course_id'] = course_ids[0]
|
||||
|
||||
if len(course_ids) > 1:
|
||||
properties['num_courses'] = len(course_ids)
|
||||
|
||||
analytics.track(
|
||||
user_id=user.id,
|
||||
event='edx.bi.email.sent',
|
||||
properties=properties
|
||||
)
|
||||
|
||||
|
||||
def _is_delivery_enabled(site, delivery_config_var, log_prefix):
|
||||
|
||||
@@ -1,65 +1,20 @@
|
||||
from urlparse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.http import urlquote
|
||||
|
||||
from edxmako.shortcuts import marketing_link
|
||||
from openedx.core.djangoapps.schedules.utils import get_config_value_from_site_or_settings
|
||||
|
||||
|
||||
def get_base_template_context(site):
|
||||
"""Dict with entries needed for all templates that use the base template"""
|
||||
return {
|
||||
# Platform information
|
||||
'homepage_url': encode_url(marketing_link('ROOT')),
|
||||
'dashboard_url': absolute_url(site, reverse('dashboard')),
|
||||
'template_revision': settings.EDX_PLATFORM_REVISION,
|
||||
'platform_name': site.configuration.get_value('platform_name', settings.PLATFORM_NAME),
|
||||
'contact_mailing_address': site.configuration.get_value(
|
||||
'contact_mailing_address',
|
||||
settings.CONTACT_MAILING_ADDRESS
|
||||
),
|
||||
'social_media_urls': encode_urls_in_dict(
|
||||
site.configuration.get_value(
|
||||
'SOCIAL_MEDIA_FOOTER_URLS',
|
||||
getattr(settings, 'SOCIAL_MEDIA_FOOTER_URLS', {})
|
||||
)
|
||||
),
|
||||
'mobile_store_urls': encode_urls_in_dict(
|
||||
site.configuration.get_value(
|
||||
'MOBILE_STORE_URLS',
|
||||
getattr(settings, 'MOBILE_STORE_URLS', {})
|
||||
)
|
||||
|
||||
),
|
||||
'homepage_url': marketing_link('ROOT'),
|
||||
'dashboard_url': reverse('dashboard'),
|
||||
'template_revision': getattr(settings, 'EDX_PLATFORM_REVISION', None),
|
||||
'platform_name': get_config_value_from_site_or_settings('PLATFORM_NAME', site=site, site_config_name='platform_name'),
|
||||
'contact_mailing_address': get_config_value_from_site_or_settings(
|
||||
'CONTACT_MAILING_ADDRESS', site=site, site_config_name='contact_mailing_address'),
|
||||
'social_media_urls': get_config_value_from_site_or_settings('SOCIAL_MEDIA_FOOTER_URLS', site=site),
|
||||
'mobile_store_urls': get_config_value_from_site_or_settings('MOBILE_STORE_URLS', site=site),
|
||||
}
|
||||
|
||||
|
||||
def encode_url(url):
|
||||
# Sailthru has a bug where URLs that contain "+" characters in their path components are misinterpreted
|
||||
# when GA instrumentation is enabled. We need to percent-encode the path segments of all URLs that are
|
||||
# injected into our templates to work around this issue.
|
||||
parsed_url = urlparse(url)
|
||||
modified_url = parsed_url._replace(path=urlquote(parsed_url.path))
|
||||
return modified_url.geturl()
|
||||
|
||||
|
||||
def absolute_url(site, relative_path):
|
||||
"""
|
||||
Add site.domain to the beginning of the given relative path.
|
||||
|
||||
If the given URL is already absolute (has a netloc part), then it is just returned.
|
||||
"""
|
||||
if bool(urlparse(relative_path).netloc):
|
||||
# Given URL is already absolute
|
||||
return relative_path
|
||||
root = site.domain.rstrip('/')
|
||||
relative_path = relative_path.lstrip('/')
|
||||
return encode_url(u'https://{root}/{path}'.format(root=root, path=relative_path))
|
||||
|
||||
|
||||
def encode_urls_in_dict(mapping):
|
||||
urls = {}
|
||||
for key, value in mapping.iteritems():
|
||||
urls[key] = encode_url(value)
|
||||
return urls
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% load i18n %}
|
||||
{% load ace %}
|
||||
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
{% get_current_language_bidi as LANGUAGE_BIDI %}
|
||||
@@ -23,6 +24,8 @@
|
||||
{# Sailthru when the email is sent. Other email providers would need to replace this variable in the HTML as well. #}
|
||||
<img src="{beacon_src}" alt="" role="presentation" aria-hidden="true" />
|
||||
|
||||
{% google_analytics_tracking_pixel %}
|
||||
|
||||
<div bgcolor="#f5f5f5" lang="{{ LANGUAGE_CODE|default:"en" }}" dir="{{ LANGUAGE_BIDI|yesno:"rtl,ltr" }}" style="
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -57,12 +60,12 @@
|
||||
<table role="presentation" width="100%" align="left" border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td width="70">
|
||||
<a href="{{ homepage_url }}"><img
|
||||
<a href="{% with_link_tracking homepage_url %}"><img
|
||||
src="https://media.sailthru.com/595/1k1/8/o/599f355101b3f.png" width="70"
|
||||
height="30" alt="{% blocktrans %}Go to {{ platform_name }} Home Page{% endblocktrans %}"/></a>
|
||||
</td>
|
||||
<td align="right" style="text-align: {{ LANGUAGE_BIDI|yesno:"left,right" }};">
|
||||
<a class="login" href="{{ dashboard_url }}" style="color: #005686;">{% trans "Sign In" %}</a>
|
||||
<a class="login" href="{% with_link_tracking dashboard_url %}" style="color: #005686;">{% trans "Sign In" %}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -90,7 +93,7 @@
|
||||
<tr>
|
||||
{% if social_media_urls.linkedin %}
|
||||
<td height="32" width="42">
|
||||
<a href="{{ social_media_urls.linkedin }}">
|
||||
<a href="{{ social_media_urls.linkedin|safe }}">
|
||||
<img src="https://media.sailthru.com/595/1k1/8/o/599f354ec70cb.png"
|
||||
width="32" height="32" alt="{% blocktrans %}{{ platform_name }} on LinkedIn{% endblocktrans %}"/>
|
||||
</a>
|
||||
@@ -98,7 +101,7 @@
|
||||
{% endif %}
|
||||
{% if social_media_urls.twitter %}
|
||||
<td height="32" width="42">
|
||||
<a href="{{ social_media_urls.twitter }}">
|
||||
<a href="{{ social_media_urls.twitter|safe }}">
|
||||
<img src="https://media.sailthru.com/595/1k1/8/o/599f354d9c26e.png"
|
||||
width="32" height="32" alt="{% blocktrans %}{{ platform_name }} on Twitter{% endblocktrans %}"/>
|
||||
</a>
|
||||
@@ -106,7 +109,7 @@
|
||||
{% endif %}
|
||||
{% if social_media_urls.facebook %}
|
||||
<td height="32" width="42">
|
||||
<a href="{{ social_media_urls.facebook }}">
|
||||
<a href="{{ social_media_urls.facebook|safe }}">
|
||||
<img src="https://media.sailthru.com/595/1k1/8/o/599f355052c8e.png"
|
||||
width="32" height="32" alt="{% blocktrans %}{{ platform_name }} on Facebook{% endblocktrans %}"/>
|
||||
</a>
|
||||
@@ -114,7 +117,7 @@
|
||||
{% endif %}
|
||||
{% if social_media_urls.google_plus %}
|
||||
<td height="32" width="42">
|
||||
<a href="{{ social_media_urls.google_plus }}">
|
||||
<a href="{{ social_media_urls.google_plus|safe }}">
|
||||
<img src="https://media.sailthru.com/595/1k1/8/o/599f354fc554a.png"
|
||||
width="32" height="32" alt="{% blocktrans %}{{ platform_name }} on Google Plus{% endblocktrans %}"/>
|
||||
</a>
|
||||
@@ -122,7 +125,7 @@
|
||||
{% endif %}
|
||||
{% if social_media_urls.reddit %}
|
||||
<td height="32" width="42">
|
||||
<a href="{{ social_media_urls.reddit }}">
|
||||
<a href="{{ social_media_urls.reddit|safe }}">
|
||||
<img src="https://media.sailthru.com/595/1k1/8/o/599f354e326b9.png"
|
||||
width="32" height="32" alt="{% blocktrans %}{{ platform_name }} on Reddit{% endblocktrans %}"/>
|
||||
</a>
|
||||
@@ -136,14 +139,14 @@
|
||||
<!-- APP BUTTONS -->
|
||||
<td style="padding-bottom: 20px;">
|
||||
{% if mobile_store_urls.apple %}
|
||||
<a href="{{ mobile_store_urls.apple }}" style="text-decoration: none">
|
||||
<a href="{{ mobile_store_urls.apple|safe }}" style="text-decoration: none">
|
||||
<img src="https://media.sailthru.com/595/1k1/6/2/5931cfbba391b.png"
|
||||
alt="{% trans "Download the iOS app on the Apple Store" %}"
|
||||
width="136" height="50" style="margin-{{ LANGUAGE_BIDI|yesno:"left,right" }}: 10px"/>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if mobile_store_urls.google %}
|
||||
<a href="{{ mobile_store_urls.google }}" style="text-decoration: none">
|
||||
<a href="{{ mobile_store_urls.google|safe }}" style="text-decoration: none">
|
||||
<img src="https://media.sailthru.com/595/1k1/6/2/5931cf879a033.png"
|
||||
alt="{% trans "Download the Android app on the Google Play Store" %}"
|
||||
width="136" height="50"/>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
{% load i18n %}
|
||||
{% load ace %}
|
||||
|
||||
<p>
|
||||
{# email client support for style sheets is pretty spotty, so we have to inline all of these styles #}
|
||||
<a
|
||||
{% if course_ids|length > 1 %}
|
||||
href="{{ dashboard_url }}"
|
||||
{% if course_cta_url %}
|
||||
href="{% with_link_tracking course_cta_url %}"
|
||||
{% else %}
|
||||
href="{{ course_url }}"
|
||||
{%if course_ids|length > 1 %}
|
||||
href="{% with_link_tracking dashboard_url %}"
|
||||
{% else %}
|
||||
href="{% with_link_tracking course_url %}"
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
style="
|
||||
color: #ffffff;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% load i18n %}
|
||||
{% load ace %}
|
||||
|
||||
{% if show_upsell %}
|
||||
<p>
|
||||
@@ -8,7 +9,7 @@
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p>
|
||||
<a href="{{ upsell_link }}"
|
||||
<a href="{% with_link_tracking upsell_link %}"
|
||||
style="
|
||||
color: #1e8142;
|
||||
text-decoration: none;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{% load i18n %}
|
||||
{% load ace %}
|
||||
{% if show_upsell %}
|
||||
{% blocktrans trimmed %}
|
||||
Don't miss the opportunity to highlight your new knowledge and skills by earning a verified
|
||||
certificate. Upgrade by {{ user_schedule_upgrade_deadline_time }}.
|
||||
|
||||
Upgrade Now! <{{ upsell_link }}>
|
||||
{% endblocktrans %}
|
||||
|
||||
{% trans "Upgrade Now" %} <{% with_link_tracking upsell_link %}>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{% extends 'schedules/edx_ace/common/base_body.html' %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block preview_text %}
|
||||
{% blocktrans trimmed %}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
{% load i18n %}
|
||||
{% load ace %}
|
||||
{% if course_ids|length > 1 %}
|
||||
{% blocktrans trimmed %}
|
||||
Many edX learners are completing more problems every week, and
|
||||
participating in the discussion forums. What do you want to do to keep learning?
|
||||
{% endblocktrans %}
|
||||
{% trans "Keep learning" %} <{{dashboard_url}}>
|
||||
{% trans "Keep learning" %} <{% with_link_tracking dashboard_url %}>
|
||||
{% else %}
|
||||
{% blocktrans trimmed %}
|
||||
Many edX learners in {{course_name}} are completing more problems every week, and
|
||||
participating in the discussion forums. What do you want to do to keep learning?
|
||||
{% endblocktrans %}
|
||||
{% trans "Keep learning" %} <{{course_url}}>
|
||||
{% trans "Keep learning" %} <{% with_link_tracking course_url %}>
|
||||
{% endif %}
|
||||
{% include "schedules/edx_ace/common/upsell_cta.txt"%}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% load ace %}
|
||||
{% if course_ids|length > 1 %}
|
||||
{% blocktrans trimmed %}
|
||||
Remember when you enrolled in {{ course_name }}, and other courses on edX.org? We do, and we’re glad
|
||||
to have you! Come see what everyone is learning.
|
||||
{% endblocktrans %}
|
||||
|
||||
{% trans "Start learning now" %} <{{ dashboard_url }}>
|
||||
{% trans "Start learning now" %} <{% with_link_tracking dashboard_url %}>
|
||||
{% else %}
|
||||
{% blocktrans trimmed %}
|
||||
Remember when you enrolled in {{ course_name }} on edX.org? We do, and we’re glad
|
||||
to have you! Come see what everyone is learning.
|
||||
{% endblocktrans %}
|
||||
|
||||
{% trans "Start learning now" %} <{{ course_url }}>
|
||||
{% trans "Start learning now" %} <{% with_link_tracking course_url %}>
|
||||
{% endif %}
|
||||
{% include "schedules/edx_ace/common/upsell_cta.txt"%}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends 'schedules/edx_ace/common/base_body.html' %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load ace %}
|
||||
|
||||
{% block preview_text %}
|
||||
{% if course_ids|length > 1 %}
|
||||
@@ -56,7 +57,7 @@
|
||||
<ul style="margin-bottom: 30px;">
|
||||
{% for course_link in course_links %}
|
||||
<li>
|
||||
<a href="{{ course_link.url }}">{{ course_link.name }}</a>
|
||||
<a href="{% with_link_tracking course_link.url %}">{{ course_link.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -82,9 +83,9 @@
|
||||
{# email client support for style sheets is pretty spotty, so we have to inline all of these styles #}
|
||||
<a
|
||||
{% if course_ids|length == 1 %}
|
||||
href="{{ upsell_link }}"
|
||||
href="{% with_link_tracking upsell_link %}"
|
||||
{% else %}
|
||||
href="{{ dashboard_url }}"
|
||||
href="{% with_link_tracking dashboard_url %}"
|
||||
{% endif %}
|
||||
style="
|
||||
color: #ffffff;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% load ace %}
|
||||
{% if course_ids|length > 1 %}
|
||||
{% blocktrans trimmed %}
|
||||
We hope you are enjoying learning with us so far on {{ platform_name }}! A verified certificate
|
||||
@@ -11,11 +11,11 @@ Upgrade by {{ user_schedule_upgrade_deadline_time }}.
|
||||
|
||||
{% if course_ids|length > 1 and course_ids|length < 10 %}
|
||||
{% for course_link in course_links %}
|
||||
* {{ course_link.name }} <{{ course_link.url }}>
|
||||
* {{ course_link.name }} <{% with_link_tracking course_link.url %}>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% trans "Upgrade now at" %} <{{ dashboard_url }}>
|
||||
{% trans "Upgrade now at" %} <{% with_link_tracking dashboard_url %}>
|
||||
{% else %}
|
||||
{% blocktrans trimmed %}
|
||||
We hope you are enjoying learning with us so far in {{ first_course_name }}! A verified certificate
|
||||
@@ -25,5 +25,5 @@ official and easily shareable.
|
||||
Upgrade by {{ user_schedule_upgrade_deadline_time }}.
|
||||
{% endblocktrans %}
|
||||
|
||||
{% trans "Upgrade now at" %} <{{ upsell_link }}>
|
||||
{% trans "Upgrade now at" %} <{% with_link_tracking upsell_link %}>
|
||||
{% endif %}
|
||||
|
||||
150
openedx/core/djangoapps/schedules/templatetags/ace.py
Normal file
150
openedx/core/djangoapps/schedules/templatetags/ace.py
Normal file
@@ -0,0 +1,150 @@
|
||||
from urlparse import urlparse, parse_qs
|
||||
|
||||
from crum import get_current_request
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from openedx.core.djangoapps.schedules.tracking import CampaignTrackingInfo, GoogleAnalyticsTrackingPixel
|
||||
from openedx.core.djangolib.markup import HTML
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def with_link_tracking(context, url):
|
||||
"""
|
||||
Modifies the provided URL to ensure it is safe for usage in an email template and adds UTM parameters to it.
|
||||
|
||||
The provided URL can be relative or absolute. If it is relative, it will be made absolute.
|
||||
|
||||
All URLs will be augmented to include UTM parameters so that clicks can be tracked.
|
||||
|
||||
Args:
|
||||
context (dict): The template context. Must include a "request" and "message".
|
||||
url (str): The url to rewrite.
|
||||
|
||||
Returns:
|
||||
str: The URL as an absolute URL with appropriate query string parameters that allow clicks to be tracked.
|
||||
|
||||
"""
|
||||
site, _user, message = _get_variables_from_context(context, 'with_link_tracking')
|
||||
|
||||
campaign = CampaignTrackingInfo(
|
||||
source=message.app_label,
|
||||
campaign=message.name,
|
||||
content=message.uuid,
|
||||
)
|
||||
course_ids = context.get('course_ids')
|
||||
if course_ids is not None and len(course_ids) > 0:
|
||||
campaign.term = course_ids[0]
|
||||
|
||||
return mark_safe(
|
||||
modify_url_to_track_clicks(
|
||||
ensure_url_is_absolute(site, url),
|
||||
campaign=campaign
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _get_variables_from_context(context, tag_name):
|
||||
if 'request' in context:
|
||||
request = context['request']
|
||||
else:
|
||||
request = get_current_request()
|
||||
|
||||
if request is None:
|
||||
raise template.VariableDoesNotExist(
|
||||
'The {0} template tag requires a "request" to be present in the template context. Consider using '
|
||||
'"emulate_http_request" if you are rendering the template in a celery task.'.format(tag_name)
|
||||
)
|
||||
|
||||
message = context.get('message')
|
||||
if message is None:
|
||||
raise template.VariableDoesNotExist(
|
||||
'The {0} template tag requires a "message" to be present in the template context.'.format(tag_name)
|
||||
)
|
||||
|
||||
return request.site, request.user, message
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def google_analytics_tracking_pixel(context):
|
||||
"""
|
||||
If configured, inject a google analytics tracking pixel into the template.
|
||||
|
||||
This tracking pixel will allow email open events to be tracked.
|
||||
|
||||
Args:
|
||||
context (dict): The template context. Must include a "request" and "message".
|
||||
|
||||
Returns:
|
||||
str: A string containing an HTML image tag that implements the GA measurement protocol or an empty string if
|
||||
GA is not configured. For this to work, the site or settings must include the GA tracking ID.
|
||||
"""
|
||||
image_url = _get_google_analytics_tracking_url(context)
|
||||
if image_url is not None:
|
||||
return mark_safe(
|
||||
HTML('<img src="{0}" alt="" role="presentation" aria-hidden="true" />').format(HTML(image_url))
|
||||
)
|
||||
else:
|
||||
return ''
|
||||
|
||||
|
||||
def _get_google_analytics_tracking_url(context):
|
||||
site, user, message = _get_variables_from_context(context, 'google_analytics_tracking_pixel')
|
||||
|
||||
pixel = GoogleAnalyticsTrackingPixel(
|
||||
site=site,
|
||||
user_id=user.id,
|
||||
campaign_source=message.app_label,
|
||||
campaign_name=message.name,
|
||||
campaign_content=message.uuid,
|
||||
document_path='/email/{0}/{1}/{2}/{3}'.format(
|
||||
message.app_label,
|
||||
message.name,
|
||||
message.send_uuid,
|
||||
message.uuid,
|
||||
),
|
||||
)
|
||||
course_ids = context.get('course_ids')
|
||||
if course_ids is not None and len(course_ids) > 0:
|
||||
pixel.course_id = course_ids[0]
|
||||
|
||||
return pixel.image_url
|
||||
|
||||
|
||||
def modify_url_to_track_clicks(url, campaign=None):
|
||||
"""
|
||||
Given a URL, this method modifies the query string parameters to include UTM tracking parameters.
|
||||
|
||||
These UTM codes are used to by Google Analytics to identify the source of traffic. This will help us better
|
||||
understand how users behave when they come to the site by clicking a link in this email.
|
||||
|
||||
Arguments:
|
||||
url (str): pass
|
||||
campaign (CampaignTrackingInfo): pass
|
||||
|
||||
Returns:
|
||||
str: The url with appropriate query string parameters.
|
||||
"""
|
||||
parsed_url = urlparse(url)
|
||||
if campaign is None:
|
||||
campaign = CampaignTrackingInfo()
|
||||
modified_url = parsed_url._replace(query=campaign.to_query_string(parsed_url.query))
|
||||
return modified_url.geturl()
|
||||
|
||||
|
||||
def ensure_url_is_absolute(site, relative_path):
|
||||
"""
|
||||
Add site.domain to the beginning of the given relative path.
|
||||
|
||||
If the given URL is already absolute (has a netloc part), then it is just returned.
|
||||
"""
|
||||
if bool(urlparse(relative_path).netloc):
|
||||
# Given URL is already absolute
|
||||
url = relative_path
|
||||
else:
|
||||
root = site.domain.rstrip('/')
|
||||
relative_path = relative_path.lstrip('/')
|
||||
url = u'https://{root}/{path}'.format(root=root, path=relative_path)
|
||||
return url
|
||||
53
openedx/core/djangoapps/schedules/tests/mixins.py
Normal file
53
openedx/core/djangoapps/schedules/tests/mixins.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from urlparse import parse_qs, urlparse
|
||||
|
||||
|
||||
class QueryStringAssertionMixin(object):
|
||||
|
||||
def assert_query_string_equal(self, expected_qs, actual_qs):
|
||||
"""
|
||||
Compares two query strings to see if they are equivalent. Note that order of parameters is not significant.
|
||||
|
||||
Args:
|
||||
expected_qs (str): The expected query string.
|
||||
actual_qs (str): The actual query string.
|
||||
|
||||
Raises:
|
||||
AssertionError: If the two query strings are not equal.
|
||||
"""
|
||||
self.assertDictEqual(parse_qs(expected_qs), parse_qs(actual_qs))
|
||||
|
||||
def assert_url_components_equal(self, url, **kwargs):
|
||||
"""
|
||||
Assert that the provided URL has the expected components with the expected values.
|
||||
|
||||
Args:
|
||||
url (str): The URL to parse and make assertions about.
|
||||
**kwargs: The expected component values. For example: scheme='https' would assert that the URL scheme was
|
||||
https.
|
||||
|
||||
Raises:
|
||||
AssertionError: If any of the expected components do not match.
|
||||
"""
|
||||
parsed_url = urlparse(url)
|
||||
for expected_component, expected_value in kwargs.items():
|
||||
if expected_component == 'query':
|
||||
self.assert_query_string_equal(expected_value, parsed_url.query)
|
||||
else:
|
||||
self.assertEqual(expected_value, getattr(parsed_url, expected_component))
|
||||
|
||||
def assert_query_string_parameters_equal(self, url, **kwargs):
|
||||
"""
|
||||
Assert that the provided URL has query string paramters that match the kwargs.
|
||||
|
||||
Args:
|
||||
url (str): The URL to parse and make assertions about.
|
||||
**kwargs: The expected query string parameter values. For example: foo='bar' would assert that foo=bar
|
||||
appeared in the query string.
|
||||
|
||||
Raises:
|
||||
AssertionError: If any of the expected parameters values do not match.
|
||||
"""
|
||||
parsed_url = urlparse(url)
|
||||
parsed_qs = parse_qs(parsed_url.query)
|
||||
for expected_key, expected_value in kwargs.items():
|
||||
self.assertEqual(parsed_qs[expected_key], [str(expected_value)])
|
||||
@@ -1,23 +0,0 @@
|
||||
from openedx.core.djangoapps.schedules.template_context import absolute_url
|
||||
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class TestTemplateContext(CacheIsolationTestCase):
|
||||
def setUp(self):
|
||||
self.site = SiteFactory.create()
|
||||
self.site.domain = 'example.com'
|
||||
|
||||
def test_absolute_url(self):
|
||||
absolute = absolute_url(self.site, '/foo/bar')
|
||||
self.assertEqual(absolute, 'https://example.com/foo/bar')
|
||||
|
||||
def test_absolute_url_domain_lstrip(self):
|
||||
self.site.domain = 'example.com/'
|
||||
absolute = absolute_url(self.site, 'foo/bar')
|
||||
self.assertEqual(absolute, 'https://example.com/foo/bar')
|
||||
|
||||
def test_absolute_url_already_absolute(self):
|
||||
absolute = absolute_url(self.site, 'https://some-cdn.com/foo/bar')
|
||||
self.assertEqual(absolute, 'https://some-cdn.com/foo/bar')
|
||||
172
openedx/core/djangoapps/schedules/tests/test_templatetags.py
Normal file
172
openedx/core/djangoapps/schedules/tests/test_templatetags.py
Normal file
@@ -0,0 +1,172 @@
|
||||
import uuid
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.template import VariableDoesNotExist
|
||||
from django.test import override_settings
|
||||
from mock import patch
|
||||
|
||||
from edx_ace import Message, Recipient
|
||||
from openedx.core.djangoapps.schedules.templatetags.ace import (
|
||||
ensure_url_is_absolute,
|
||||
with_link_tracking,
|
||||
google_analytics_tracking_pixel,
|
||||
_get_google_analytics_tracking_url
|
||||
)
|
||||
from openedx.core.djangoapps.schedules.tests.mixins import QueryStringAssertionMixin
|
||||
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class TestAbsoluteUrl(CacheIsolationTestCase):
|
||||
def setUp(self):
|
||||
self.site = SiteFactory.create()
|
||||
self.site.domain = 'example.com'
|
||||
|
||||
def test_absolute_url(self):
|
||||
absolute = ensure_url_is_absolute(self.site, '/foo/bar')
|
||||
self.assertEqual(absolute, 'https://example.com/foo/bar')
|
||||
|
||||
def test_absolute_url_domain_lstrip(self):
|
||||
self.site.domain = 'example.com/'
|
||||
absolute = ensure_url_is_absolute(self.site, 'foo/bar')
|
||||
self.assertEqual(absolute, 'https://example.com/foo/bar')
|
||||
|
||||
def test_absolute_url_already_absolute(self):
|
||||
absolute = ensure_url_is_absolute(self.site, 'https://some-cdn.com/foo/bar')
|
||||
self.assertEqual(absolute, 'https://some-cdn.com/foo/bar')
|
||||
|
||||
|
||||
class EmailTemplateTagMixin(object):
|
||||
|
||||
def setUp(self):
|
||||
patcher = patch('openedx.core.djangoapps.schedules.templatetags.ace.get_current_request')
|
||||
self.mock_get_current_request = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
self.fake_request = HttpRequest()
|
||||
self.fake_request.user = UserFactory.create()
|
||||
self.fake_request.site = SiteFactory.create()
|
||||
self.fake_request.site.domain = 'example.com'
|
||||
self.mock_get_current_request.return_value = self.fake_request
|
||||
|
||||
self.message = Message(
|
||||
app_label='test_app_label',
|
||||
name='test_name',
|
||||
recipient=Recipient(username='test_user'),
|
||||
context={},
|
||||
send_uuid=uuid.uuid4(),
|
||||
)
|
||||
self.context = {
|
||||
'message': self.message
|
||||
}
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class TestLinkTrackingTag(QueryStringAssertionMixin, EmailTemplateTagMixin, CacheIsolationTestCase):
|
||||
|
||||
def test_default(self):
|
||||
result_url = str(with_link_tracking(self.context, 'http://example.com/foo'))
|
||||
self.assert_url_components_equal(
|
||||
result_url,
|
||||
scheme='http',
|
||||
netloc='example.com',
|
||||
path='/foo',
|
||||
query='utm_source=test_app_label&utm_campaign=test_name&utm_medium=email&utm_content={uuid}'.format(
|
||||
uuid=self.message.uuid
|
||||
)
|
||||
)
|
||||
|
||||
def test_missing_request(self):
|
||||
self.mock_get_current_request.return_value = None
|
||||
|
||||
with self.assertRaises(VariableDoesNotExist):
|
||||
with_link_tracking(self.context, 'http://example.com/foo')
|
||||
|
||||
def test_missing_message(self):
|
||||
del self.context['message']
|
||||
|
||||
with self.assertRaises(VariableDoesNotExist):
|
||||
with_link_tracking(self.context, 'http://example.com/foo')
|
||||
|
||||
def test_course_id(self):
|
||||
self.context['course_ids'] = ['foo/bar/baz']
|
||||
result_url = str(with_link_tracking(self.context, 'http://example.com/foo'))
|
||||
self.assert_query_string_parameters_equal(
|
||||
result_url,
|
||||
utm_term='foo/bar/baz',
|
||||
)
|
||||
|
||||
def test_multiple_course_ids(self):
|
||||
self.context['course_ids'] = ['foo/bar/baz', 'course-v1:FooX+bar+baz']
|
||||
result_url = str(with_link_tracking(self.context, 'http://example.com/foo'))
|
||||
self.assert_query_string_parameters_equal(
|
||||
result_url,
|
||||
utm_term='foo/bar/baz',
|
||||
)
|
||||
|
||||
def test_relative_url(self):
|
||||
result_url = str(with_link_tracking(self.context, '/foobar'))
|
||||
self.assert_url_components_equal(
|
||||
result_url,
|
||||
scheme='https',
|
||||
netloc='example.com',
|
||||
path='/foobar'
|
||||
)
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
@override_settings(GOOGLE_ANALYTICS_TRACKING_ID='UA-123456-1')
|
||||
class TestGoogleAnalyticsPixelTag(QueryStringAssertionMixin, EmailTemplateTagMixin, CacheIsolationTestCase):
|
||||
|
||||
def test_default(self):
|
||||
result_url = _get_google_analytics_tracking_url(self.context)
|
||||
self.assert_query_string_parameters_equal(
|
||||
result_url,
|
||||
uid=self.fake_request.user.id,
|
||||
cs=self.message.app_label,
|
||||
cn=self.message.name,
|
||||
cc=self.message.uuid,
|
||||
dp='/email/test_app_label/test_name/{send_uuid}/{uuid}'.format(
|
||||
send_uuid=self.message.send_uuid,
|
||||
uuid=self.message.uuid,
|
||||
)
|
||||
)
|
||||
|
||||
def test_missing_request(self):
|
||||
self.mock_get_current_request.return_value = None
|
||||
|
||||
with self.assertRaises(VariableDoesNotExist):
|
||||
google_analytics_tracking_pixel(self.context)
|
||||
|
||||
def test_missing_message(self):
|
||||
del self.context['message']
|
||||
|
||||
with self.assertRaises(VariableDoesNotExist):
|
||||
google_analytics_tracking_pixel(self.context)
|
||||
|
||||
def test_course_id(self):
|
||||
self.context['course_ids'] = ['foo/bar/baz']
|
||||
result_url = _get_google_analytics_tracking_url(self.context)
|
||||
self.assert_query_string_parameters_equal(
|
||||
result_url,
|
||||
el='foo/bar/baz',
|
||||
)
|
||||
|
||||
def test_multiple_course_ids(self):
|
||||
self.context['course_ids'] = ['foo/bar/baz', 'course-v1:FooX+bar+baz']
|
||||
result_url = _get_google_analytics_tracking_url(self.context)
|
||||
self.assert_query_string_parameters_equal(
|
||||
result_url,
|
||||
el='foo/bar/baz',
|
||||
)
|
||||
|
||||
def test_html_emitted(self):
|
||||
result_html = google_analytics_tracking_pixel(self.context)
|
||||
self.assertIn('<img src', result_html)
|
||||
|
||||
@override_settings(GOOGLE_ANALYTICS_TRACKING_ID=None)
|
||||
def test_no_html_emitted_if_not_enabled(self):
|
||||
result_html = google_analytics_tracking_pixel(self.context)
|
||||
self.assertEqual('', result_html)
|
||||
166
openedx/core/djangoapps/schedules/tests/test_tracking.py
Normal file
166
openedx/core/djangoapps/schedules/tests/test_tracking.py
Normal file
@@ -0,0 +1,166 @@
|
||||
from unittest import TestCase
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
from openedx.core.djangoapps.schedules.tests.mixins import QueryStringAssertionMixin
|
||||
from openedx.core.djangoapps.schedules.tracking import (
|
||||
CampaignTrackingInfo,
|
||||
DEFAULT_CAMPAIGN_SOURCE,
|
||||
DEFAULT_CAMPAIGN_MEDIUM,
|
||||
GoogleAnalyticsTrackingPixel)
|
||||
from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
|
||||
|
||||
|
||||
class TestCampaignTrackingInfo(QueryStringAssertionMixin, TestCase):
|
||||
|
||||
def test_default_campaign_info(self):
|
||||
campaign = CampaignTrackingInfo()
|
||||
self.assertEqual(campaign.source, DEFAULT_CAMPAIGN_SOURCE)
|
||||
self.assertEqual(campaign.medium, DEFAULT_CAMPAIGN_MEDIUM)
|
||||
self.assertIsNone(campaign.campaign)
|
||||
self.assertIsNone(campaign.term)
|
||||
self.assertIsNone(campaign.content)
|
||||
|
||||
def test_to_query_string(self):
|
||||
campaign = CampaignTrackingInfo(
|
||||
source='test_source with spaces',
|
||||
medium='test_medium',
|
||||
campaign='test_campaign',
|
||||
term='test_term',
|
||||
content='test_content'
|
||||
)
|
||||
self.assert_query_string_equal(
|
||||
'utm_source=test_source%20with%20spaces&utm_medium=test_medium&utm_campaign=test_campaign'
|
||||
'&utm_term=test_term&utm_content=test_content',
|
||||
campaign.to_query_string(),
|
||||
)
|
||||
|
||||
def test_query_string_with_existing_parameters(self):
|
||||
campaign = CampaignTrackingInfo(
|
||||
source='test_source',
|
||||
medium=None
|
||||
)
|
||||
self.assert_query_string_equal(
|
||||
'some_parameter=testing&utm_source=test_source&other=test2',
|
||||
campaign.to_query_string('some_parameter=testing&other=test2')
|
||||
)
|
||||
|
||||
def test_query_string_with_existing_repeated_parameters(self):
|
||||
campaign = CampaignTrackingInfo(
|
||||
source='test_source',
|
||||
medium=None
|
||||
)
|
||||
self.assert_query_string_equal(
|
||||
'some_parameter=testing&utm_source=test_source&other=test2&some_parameter=baz',
|
||||
campaign.to_query_string('some_parameter=testing&other=test2&some_parameter=baz')
|
||||
)
|
||||
|
||||
def test_query_string_with_existing_utm_parameters(self):
|
||||
campaign = CampaignTrackingInfo(
|
||||
source='test_source',
|
||||
medium=None
|
||||
)
|
||||
self.assert_query_string_equal(
|
||||
'utm_source=test_source&utm_medium=custom_medium',
|
||||
campaign.to_query_string('utm_source=custom_source&utm_medium=custom_medium')
|
||||
)
|
||||
|
||||
|
||||
class TestGoogleAnalyticsTrackingPixel(QueryStringAssertionMixin, CacheIsolationTestCase):
|
||||
|
||||
@override_settings(GOOGLE_ANALYTICS_TRACKING_ID='UA-123456-1')
|
||||
def test_default_parameters(self):
|
||||
pixel = GoogleAnalyticsTrackingPixel()
|
||||
self.assertIsNotNone(pixel.image_url)
|
||||
self.assert_url_components_equal(
|
||||
pixel.image_url,
|
||||
scheme='https',
|
||||
netloc='www.google-analytics.com',
|
||||
path='/collect',
|
||||
query='v=1&t=event&cs={cs}&cm={cm}&ec=email&ea=edx.bi.email.opened&cid={cid}&tid=UA-123456-1'.format(
|
||||
cs=DEFAULT_CAMPAIGN_SOURCE,
|
||||
cm=DEFAULT_CAMPAIGN_MEDIUM,
|
||||
cid=GoogleAnalyticsTrackingPixel.ANONYMOUS_USER_CLIENT_ID,
|
||||
)
|
||||
)
|
||||
|
||||
@override_settings(GOOGLE_ANALYTICS_TRACKING_ID='UA-123456-1')
|
||||
def test_all_parameters(self):
|
||||
pixel = GoogleAnalyticsTrackingPixel(
|
||||
version=2,
|
||||
hit_type='ev',
|
||||
campaign_source='test_cs',
|
||||
campaign_medium='test_cm',
|
||||
campaign_name='test_cn',
|
||||
campaign_content='test_cc',
|
||||
event_category='test_ec',
|
||||
event_action='test_ea',
|
||||
event_label='test_el',
|
||||
document_path='test_dp',
|
||||
client_id='123456.123456',
|
||||
)
|
||||
self.assertIsNotNone(pixel.image_url)
|
||||
self.assert_url_components_equal(
|
||||
pixel.image_url,
|
||||
scheme='https',
|
||||
netloc='www.google-analytics.com',
|
||||
path='/collect',
|
||||
query='tid=UA-123456-1&v=2&t=ev&cs=test_cs&cm=test_cm&cn=test_cn&ec=test_ec&ea=test_ea&el=test_el'
|
||||
'&dp=test_dp&cid=123456.123456&cc=test_cc'
|
||||
)
|
||||
|
||||
def test_missing_settings(self):
|
||||
pixel = GoogleAnalyticsTrackingPixel()
|
||||
self.assertIsNone(pixel.image_url)
|
||||
|
||||
@override_settings(GOOGLE_ANALYTICS_TRACKING_ID='UA-123456-1')
|
||||
def test_site_config_override(self):
|
||||
site_config = SiteConfigurationFactory.create(
|
||||
values=dict(
|
||||
GOOGLE_ANALYTICS_ACCOUNT='UA-654321-1'
|
||||
)
|
||||
)
|
||||
pixel = GoogleAnalyticsTrackingPixel(site=site_config.site)
|
||||
self.assert_query_string_parameters_equal(pixel.image_url, tid='UA-654321-1')
|
||||
|
||||
@override_settings(
|
||||
GOOGLE_ANALYTICS_TRACKING_ID='UA-123456-1',
|
||||
GOOGLE_ANALYTICS_USER_ID_CUSTOM_DIMENSION=40
|
||||
)
|
||||
def test_custom_dimension(self):
|
||||
pixel = GoogleAnalyticsTrackingPixel(user_id=10, campaign_source=None, campaign_medium=None)
|
||||
self.assertIsNotNone(pixel.image_url)
|
||||
self.assert_url_components_equal(
|
||||
pixel.image_url,
|
||||
query='v=1&t=event&ec=email&ea=edx.bi.email.opened&cid={cid}&tid=UA-123456-1&cd40=10&uid=10'.format(
|
||||
cid=GoogleAnalyticsTrackingPixel.ANONYMOUS_USER_CLIENT_ID,
|
||||
)
|
||||
)
|
||||
|
||||
@override_settings(
|
||||
GOOGLE_ANALYTICS_TRACKING_ID='UA-123456-1',
|
||||
GOOGLE_ANALYTICS_USER_ID_CUSTOM_DIMENSION=40
|
||||
)
|
||||
def test_custom_dimension_without_user_id(self):
|
||||
pixel = GoogleAnalyticsTrackingPixel(campaign_source=None, campaign_medium=None)
|
||||
self.assertIsNotNone(pixel.image_url)
|
||||
self.assert_url_components_equal(
|
||||
pixel.image_url,
|
||||
query='v=1&t=event&ec=email&ea=edx.bi.email.opened&cid={cid}&tid=UA-123456-1'.format(
|
||||
cid=GoogleAnalyticsTrackingPixel.ANONYMOUS_USER_CLIENT_ID,
|
||||
)
|
||||
)
|
||||
|
||||
@override_settings(GOOGLE_ANALYTICS_TRACKING_ID='UA-123456-1')
|
||||
def test_course_id(self):
|
||||
course_id = 'foo/bar/baz'
|
||||
pixel = GoogleAnalyticsTrackingPixel(course_id=course_id)
|
||||
self.assertIsNotNone(pixel.image_url)
|
||||
self.assert_query_string_parameters_equal(pixel.image_url, el=course_id)
|
||||
|
||||
@override_settings(GOOGLE_ANALYTICS_TRACKING_ID='UA-123456-1')
|
||||
def test_course_id_with_event_label(self):
|
||||
pixel = GoogleAnalyticsTrackingPixel(course_id='foo/bar/baz', event_label='test_label')
|
||||
self.assertIsNotNone(pixel.image_url)
|
||||
self.assert_query_string_parameters_equal(pixel.image_url, el='test_label')
|
||||
109
openedx/core/djangoapps/schedules/tracking.py
Normal file
109
openedx/core/djangoapps/schedules/tracking.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from urlparse import parse_qs
|
||||
|
||||
import attr
|
||||
from django.utils.http import urlencode
|
||||
|
||||
from openedx.core.djangoapps.schedules.utils import get_config_value_from_site_or_settings
|
||||
|
||||
|
||||
DEFAULT_CAMPAIGN_SOURCE = 'ace'
|
||||
DEFAULT_CAMPAIGN_MEDIUM = 'email'
|
||||
|
||||
|
||||
@attr.s
|
||||
class CampaignTrackingInfo(object):
|
||||
"""
|
||||
A struct for storing the set of UTM parameters that are recognized by tracking tools when included in URLs.
|
||||
"""
|
||||
source = attr.ib(default=DEFAULT_CAMPAIGN_SOURCE)
|
||||
medium = attr.ib(default=DEFAULT_CAMPAIGN_MEDIUM)
|
||||
campaign = attr.ib(default=None)
|
||||
term = attr.ib(default=None)
|
||||
content = attr.ib(default=None)
|
||||
|
||||
def to_query_string(self, existing_query_string=None):
|
||||
"""
|
||||
Generate a query string that includes the tracking parameters in addition to any existing parameters.
|
||||
|
||||
Note that any existing UTM parameters will be overridden by the values in this instance of CampaignTrackingInfo.
|
||||
|
||||
Args:
|
||||
existing_query_string (str): An existing query string that needs to be updated to include this tracking
|
||||
information.
|
||||
|
||||
Returns:
|
||||
str: The URL encoded string that should be used as the query string in the URL.
|
||||
"""
|
||||
parameters = {}
|
||||
if existing_query_string is not None:
|
||||
parameters = parse_qs(existing_query_string)
|
||||
|
||||
for attribute, value in attr.asdict(self).iteritems():
|
||||
if value is not None:
|
||||
parameters['utm_' + attribute] = [value]
|
||||
return urlencode(parameters, doseq=True)
|
||||
|
||||
|
||||
@attr.s
|
||||
class GoogleAnalyticsTrackingPixel(object):
|
||||
"""
|
||||
Implementation of the Google Analytics measurement protocol for email tracking.
|
||||
|
||||
See this document for more info: https://developers.google.com/analytics/devguides/collection/protocol/v1/email
|
||||
"""
|
||||
ANONYMOUS_USER_CLIENT_ID = 555
|
||||
|
||||
site = attr.ib(default=None)
|
||||
course_id = attr.ib(default=None)
|
||||
|
||||
version = attr.ib(default=1, metadata={'param_name': 'v'})
|
||||
hit_type = attr.ib(default='event', metadata={'param_name': 't'})
|
||||
|
||||
campaign_source = attr.ib(default=DEFAULT_CAMPAIGN_SOURCE, metadata={'param_name': 'cs'})
|
||||
campaign_medium = attr.ib(default=DEFAULT_CAMPAIGN_MEDIUM, metadata={'param_name': 'cm'})
|
||||
campaign_name = attr.ib(default=None, metadata={'param_name': 'cn'})
|
||||
campaign_content = attr.ib(default=None, metadata={'param_name': 'cc'})
|
||||
|
||||
event_category = attr.ib(default='email', metadata={'param_name': 'ec'})
|
||||
event_action = attr.ib(default='edx.bi.email.opened', metadata={'param_name': 'ea'})
|
||||
event_label = attr.ib(default=None, metadata={'param_name': 'el'})
|
||||
|
||||
document_path = attr.ib(default=None, metadata={'param_name': 'dp'})
|
||||
|
||||
user_id = attr.ib(default=None, metadata={'param_name': 'uid'})
|
||||
client_id = attr.ib(default=ANONYMOUS_USER_CLIENT_ID, metadata={'param_name': 'cid'})
|
||||
|
||||
@property
|
||||
def image_url(self):
|
||||
"""
|
||||
A URL to a clear image that can be embedded in HTML documents to track email open events.
|
||||
|
||||
The query string of this URL is used to capture data about the email and visitor.
|
||||
"""
|
||||
parameters = {}
|
||||
fields = attr.fields(self.__class__)
|
||||
for attribute in fields:
|
||||
value = getattr(self, attribute.name, None)
|
||||
if value is not None and 'param_name' in attribute.metadata:
|
||||
parameter_name = attribute.metadata['param_name']
|
||||
parameters[parameter_name] = str(value)
|
||||
|
||||
tracking_id = get_config_value_from_site_or_settings("GOOGLE_ANALYTICS_ACCOUNT", site=self.site)
|
||||
if tracking_id is None:
|
||||
tracking_id = get_config_value_from_site_or_settings("GOOGLE_ANALYTICS_TRACKING_ID", site=self.site)
|
||||
|
||||
if tracking_id is None:
|
||||
return None
|
||||
|
||||
parameters['tid'] = tracking_id
|
||||
|
||||
user_id_dimension = get_config_value_from_site_or_settings("GOOGLE_ANALYTICS_USER_ID_CUSTOM_DIMENSION", site=self.site)
|
||||
if user_id_dimension is not None and self.user_id is not None:
|
||||
parameter_name = 'cd{0}'.format(user_id_dimension)
|
||||
parameters[parameter_name] = self.user_id
|
||||
|
||||
if self.course_id is not None and self.event_label is None:
|
||||
param_name = fields.event_label.metadata['param_name']
|
||||
parameters[param_name] = unicode(self.course_id)
|
||||
|
||||
return u"https://www.google-analytics.com/collect?{params}".format(params=urlencode(parameters))
|
||||
@@ -1,5 +1,10 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
|
||||
from openedx.core.djangoapps.theming.helpers import get_current_site
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -15,3 +20,37 @@ class PrefixedDebugLoggerMixin(object):
|
||||
|
||||
def log_debug(self, message, *args, **kwargs):
|
||||
LOG.debug(self.log_prefix + ': ' + message, *args, **kwargs)
|
||||
|
||||
|
||||
def get_config_value_from_site_or_settings(name, site=None, site_config_name=None):
|
||||
"""
|
||||
Given a configuration setting name, try to get it from the site configuration and then fall back on the settings.
|
||||
|
||||
If site_config_name is not specified then "name" is used as the key for both collections.
|
||||
|
||||
Args:
|
||||
name (str): The name of the setting to get the value of.
|
||||
site: The site that we are trying to fetch the value for.
|
||||
site_config_name: The name of the setting within the site configuration.
|
||||
|
||||
Returns:
|
||||
The value stored in the configuration.
|
||||
"""
|
||||
if site_config_name is None:
|
||||
site_config_name = name
|
||||
|
||||
if site is None:
|
||||
site = get_current_site()
|
||||
|
||||
site_configuration = None
|
||||
if site is not None:
|
||||
try:
|
||||
site_configuration = getattr(site, "configuration", None)
|
||||
except SiteConfiguration.DoesNotExist:
|
||||
pass
|
||||
|
||||
value_from_settings = getattr(settings, name, None)
|
||||
if site_configuration is not None:
|
||||
return site_configuration.get_value(site_config_name, default=value_from_settings)
|
||||
else:
|
||||
return value_from_settings
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% load i18n %}
|
||||
{% load ace %}
|
||||
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
{% get_current_language_bidi as LANGUAGE_BIDI %}
|
||||
@@ -25,6 +26,8 @@
|
||||
{# Sailthru when the email is sent. Other email providers would need to replace this variable in the HTML as well. #}
|
||||
<img src="{beacon_src}" alt="" role="presentation" aria-hidden="true" />
|
||||
|
||||
{% google_analytics_tracking_pixel %}
|
||||
|
||||
<div bgcolor="#f5f5f5" lang="{{ LANGUAGE_CODE|default:"en" }}" dir="{{ LANGUAGE_BIDI|yesno:"rtl,ltr" }}" style="
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -59,12 +62,12 @@
|
||||
<table role="presentation" width="100%" align="left" border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td width="70">
|
||||
<a href="{{ homepage_url }}"><img
|
||||
<a href="{% with_link_tracking homepage_url %}"><img
|
||||
src="http://localhost:18000/static/red-theme/images/logo.png"
|
||||
alt="{% blocktrans %}Go to {{ platform_name }} Home Page{% endblocktrans %}"/></a>
|
||||
</td>
|
||||
<td align="right" style="text-align: {{ LANGUAGE_BIDI|yesno:"left,right" }};">
|
||||
<a class="login" href="{{ dashboard_url }}" style="color: #960909;">{% trans "Sign In" %}</a>
|
||||
<a class="login" href="{% with_link_tracking dashboard_url %}" style="color: #960909;">{% trans "Sign In" %}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -92,7 +95,7 @@
|
||||
<tr>
|
||||
{% if social_media_urls.linkedin %}
|
||||
<td height="32" width="42">
|
||||
<a href="{{ social_media_urls.linkedin }}">
|
||||
<a href="{{ social_media_urls.linkedin|safe }}">
|
||||
<img src="https://media.sailthru.com/595/1k1/8/o/599f354ec70cb.png"
|
||||
width="32" height="32" alt="{% blocktrans %}{{ platform_name }} on LinkedIn{% endblocktrans %}"/>
|
||||
</a>
|
||||
@@ -100,7 +103,7 @@
|
||||
{% endif %}
|
||||
{% if social_media_urls.twitter %}
|
||||
<td height="32" width="42">
|
||||
<a href="{{ social_media_urls.twitter }}">
|
||||
<a href="{{ social_media_urls.twitter|safe }}">
|
||||
<img src="https://media.sailthru.com/595/1k1/8/o/599f354d9c26e.png"
|
||||
width="32" height="32" alt="{% blocktrans %}{{ platform_name }} on Twitter{% endblocktrans %}"/>
|
||||
</a>
|
||||
@@ -108,7 +111,7 @@
|
||||
{% endif %}
|
||||
{% if social_media_urls.facebook %}
|
||||
<td height="32" width="42">
|
||||
<a href="{{ social_media_urls.facebook }}">
|
||||
<a href="{{ social_media_urls.facebook|safe }}">
|
||||
<img src="https://media.sailthru.com/595/1k1/8/o/599f355052c8e.png"
|
||||
width="32" height="32" alt="{% blocktrans %}{{ platform_name }} on Facebook{% endblocktrans %}"/>
|
||||
</a>
|
||||
@@ -116,7 +119,7 @@
|
||||
{% endif %}
|
||||
{% if social_media_urls.google_plus %}
|
||||
<td height="32" width="42">
|
||||
<a href="{{ social_media_urls.google_plus }}">
|
||||
<a href="{{ social_media_urls.google_plus|safe }}">
|
||||
<img src="https://media.sailthru.com/595/1k1/8/o/599f354fc554a.png"
|
||||
width="32" height="32" alt="{% blocktrans %}{{ platform_name }} on Google Plus{% endblocktrans %}"/>
|
||||
</a>
|
||||
@@ -124,7 +127,7 @@
|
||||
{% endif %}
|
||||
{% if social_media_urls.reddit %}
|
||||
<td height="32" width="42">
|
||||
<a href="{{ social_media_urls.reddit }}">
|
||||
<a href="{{ social_media_urls.reddit|safe }}">
|
||||
<img src="https://media.sailthru.com/595/1k1/8/o/599f354e326b9.png"
|
||||
width="32" height="32" alt="{% blocktrans %}{{ platform_name }} on Reddit{% endblocktrans %}"/>
|
||||
</a>
|
||||
@@ -138,14 +141,14 @@
|
||||
<!-- APP BUTTONS -->
|
||||
<td style="padding-bottom: 20px;">
|
||||
{% if mobile_store_urls.apple %}
|
||||
<a href="{{ mobile_store_urls.apple }}" style="text-decoration: none">
|
||||
<a href="{{ mobile_store_urls.apple|safe }}" style="text-decoration: none">
|
||||
<img src="https://media.sailthru.com/595/1k1/6/2/5931cfbba391b.png"
|
||||
alt="{% trans "Download the iOS app on the Apple Store" %}"
|
||||
width="136" height="50" style="margin-{{ LANGUAGE_BIDI|yesno:"left,right" }}: 10px"/>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if mobile_store_urls.google %}
|
||||
<a href="{{ mobile_store_urls.google }}" style="text-decoration: none">
|
||||
<a href="{{ mobile_store_urls.google|safe }}" style="text-decoration: none">
|
||||
<img src="https://media.sailthru.com/595/1k1/6/2/5931cf879a033.png"
|
||||
alt="{% trans "Download the Android app on the Google Play Store" %}"
|
||||
width="136" height="50"/>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
{% load i18n %}
|
||||
{% load ace %}
|
||||
|
||||
<p>
|
||||
{# email client support for style sheets is pretty spotty, so we have to inline all of these styles #}
|
||||
<a
|
||||
{% if course_ids|length > 1 %}
|
||||
href="{{ dashboard_url }}"
|
||||
{% if course_cta_url %}
|
||||
href="{% with_link_tracking course_cta_url %}"
|
||||
{% else %}
|
||||
href="{{ course_url }}"
|
||||
{%if course_ids|length > 1 %}
|
||||
href="{% with_link_tracking dashboard_url %}"
|
||||
{% else %}
|
||||
href="{% with_link_tracking course_url %}"
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
style="
|
||||
color: #ffffff;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% load i18n %}
|
||||
{% load ace %}
|
||||
|
||||
This is the RED theme!
|
||||
{% if course_ids|length > 1 %}
|
||||
@@ -7,13 +8,13 @@ This is the RED theme!
|
||||
to have you! Come see what everyone is learning.
|
||||
{% endblocktrans %}
|
||||
|
||||
{% trans "Start learning now" %} <{{ dashboard_url }}>
|
||||
{% trans "Start learning now" %} <{% with_link_tracking dashboard_url %}>
|
||||
{% else %}
|
||||
{% blocktrans trimmed %}
|
||||
Remember when you enrolled in {{ course_name }} on edX.org? We do, and we’re glad
|
||||
to have you! Come see what everyone is learning.
|
||||
{% endblocktrans %}
|
||||
|
||||
{% trans "Start learning now" %} <{{ course_url }}>
|
||||
{% trans "Start learning now" %} <{% with_link_tracking course_url %}>
|
||||
{% endif %}
|
||||
{% include "schedules/edx_ace/common/upsell_cta.txt"%}
|
||||
|
||||
Reference in New Issue
Block a user