From 0690e3241e6f76d96b932f05efa7aaa022120d69 Mon Sep 17 00:00:00 2001 From: Michael Hall Date: Fri, 22 Jun 2018 14:27:26 -0400 Subject: [PATCH] Add simple_ga app to push backend event info to Google Analytics --- get_together/settings.py | 3 + get_together/templates/get_together/base.html | 5 + get_together/views/__init__.py | 5 + simple_ga/__init__.py | 1 + simple_ga/api.py | 36 +++++ simple_ga/apps.py | 5 + simple_ga/context_processors.py | 11 ++ simple_ga/middleware.py | 130 ++++++++++++++++++ simple_ga/migrations/__init__.py | 0 simple_ga/tests.py | 3 + 10 files changed, 199 insertions(+) create mode 100644 simple_ga/__init__.py create mode 100644 simple_ga/api.py create mode 100644 simple_ga/apps.py create mode 100644 simple_ga/context_processors.py create mode 100644 simple_ga/middleware.py create mode 100644 simple_ga/migrations/__init__.py create mode 100644 simple_ga/tests.py diff --git a/get_together/settings.py b/get_together/settings.py index e294101..b3b10e4 100644 --- a/get_together/settings.py +++ b/get_together/settings.py @@ -55,6 +55,7 @@ INSTALLED_APPS = [ 'events', 'accounts', 'resume', + 'simple_ga', ] LOGIN_URL = 'login' @@ -84,6 +85,7 @@ MIDDLEWARE = [ 'social_django.middleware.SocialAuthExceptionMiddleware', 'resume.middleware.ResumeMiddleware', + 'simple_ga.middleware.GAEventMiddleware', ] ROOT_URLCONF = 'get_together.urls' @@ -102,6 +104,7 @@ TEMPLATES = [ 'social_django.context_processors.backends', 'social_django.context_processors.login_redirect', 'django_settings_export.settings_export', + 'simple_ga.context_processors.events', ], }, }, diff --git a/get_together/templates/get_together/base.html b/get_together/templates/get_together/base.html index d7bf68a..3763bf6 100644 --- a/get_together/templates/get_together/base.html +++ b/get_together/templates/get_together/base.html @@ -21,6 +21,11 @@ gtag('js', new Date()); gtag('config', '{{settings.GOOGLE_ANALYTICS_ID}}'); + + // GA Events + {% for event in ga_events %} + {{ event.gtag }} + {% endfor %} {% block extra_google_analytics %}{% endblock %} {% endif %} diff --git a/get_together/views/__init__.py b/get_together/views/__init__.py index 1410be3..96ccdff 100644 --- a/get_together/views/__init__.py +++ b/get_together/views/__init__.py @@ -15,6 +15,8 @@ from events import location from accounts.decorators import setup_wanted from django.conf import settings +import simple_ga as ga + import datetime import simplejson import geocoder @@ -45,6 +47,8 @@ def home(request, *args, **kwards): near_distance = int(request.GET.get("distance", DEFAULT_NEAR_DISTANCE)) context['distance'] = near_distance + if "distance" in request.GET and request.GET.get("distance"): + ga.add_event(request, 'homepage_search', category='search', label='distance', value=near_distance) city=None ll = None @@ -53,6 +57,7 @@ def home(request, *args, **kwards): city = City.objects.get(id=request.GET.get("city")) context['city'] = city ll = [city.latitude, city.longitude] + ga.add_event(request, 'homepage_search', category='search', label='city', value=city.short_name) else : context['city_search'] = False try: diff --git a/simple_ga/__init__.py b/simple_ga/__init__.py new file mode 100644 index 0000000..15405e8 --- /dev/null +++ b/simple_ga/__init__.py @@ -0,0 +1 @@ +from simple_ga.api import * \ No newline at end of file diff --git a/simple_ga/api.py b/simple_ga/api.py new file mode 100644 index 0000000..c18bb10 --- /dev/null +++ b/simple_ga/api.py @@ -0,0 +1,36 @@ + +class GAFailure(Exception): + pass + +def add_event(request, action, category=None, label=None, value=None, fail_silently=False): + """ + Attempt to add a message to the request using the 'messages' app. + """ + try: + events = request._ga_events + except AttributeError: + if not hasattr(request, 'META'): + raise TypeError( + "add_message() argument must be an HttpRequest object, not " + "'%s'." % request.__class__.__name__ + ) + if not fail_silently: + raise GAFailure( + 'You cannot add messages without installing ' + 'django.contrib.messages.middleware.MessageMiddleware' + ) + else: + return events.add(action, category, label, value) + + +def get_events(request): + """ + Return the message storage on the request if it exists, otherwise return + an empty list. + """ + if not hasattr(request, '_ga_events'): + return [] + next_event = request._ga_events.pop() + while next_event is not None: + yield next_event + next_event = request._ga_events.pop(); diff --git a/simple_ga/apps.py b/simple_ga/apps.py new file mode 100644 index 0000000..882aaee --- /dev/null +++ b/simple_ga/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class SimpleGaConfig(AppConfig): + name = 'simple_ga' diff --git a/simple_ga/context_processors.py b/simple_ga/context_processors.py new file mode 100644 index 0000000..b75ca1d --- /dev/null +++ b/simple_ga/context_processors.py @@ -0,0 +1,11 @@ +from simple_ga.api import get_events + + +def events(request): + """ + Return a lazy 'messages' context variable as well as + 'DEFAULT_MESSAGE_LEVELS'. + """ + return { + 'ga_events': get_events(request), + } \ No newline at end of file diff --git a/simple_ga/middleware.py b/simple_ga/middleware.py new file mode 100644 index 0000000..5c91100 --- /dev/null +++ b/simple_ga/middleware.py @@ -0,0 +1,130 @@ +import json +from django.utils.deprecation import MiddlewareMixin +from django.utils.safestring import SafeData, mark_safe + +class GAEvent: + def __init__(self, action, category=None, label=None, value=None): + self.action = action + self.category = category + self.label = label + self.value = value + + def gtag(self): + return mark_safe( + "gtag('event', '%(action)s', {'event_category' : '%(category)s', 'event_label' : '%(label)s' }, 'event_value' : '%(value)s' });" % { + 'action': self.action, + 'category': self.category, + 'label': self.label, + 'value': self.value, + }) + +class EventEncoder(json.JSONEncoder): + """ + Compactly serialize instances of the ``Message`` class as JSON. + """ + message_key = '__json_message' + + def default(self, obj): + if isinstance(obj, GAEvent): + event = { + 'type': 'GAEvent', + 'action': obj.action, + 'category': obj.category, + 'label': obj.label, + 'value': obj.value, + } + return event + return super().default(obj) + + +class EventDecoder(json.JSONDecoder): + """ + Decode JSON that includes serialized ``Message`` instances. + """ + + def process_events(self, obj): + if isinstance(obj, list) and obj: + return [self.process_events(item) for item in obj] + if isinstance(obj, dict): + if getattr(obj, 'type', None) == 'GAEvent': + return GAEvent(**obj) + return {key: self.process_events(value) + for key, value in obj.items()} + return obj + + def decode(self, s, **kwargs): + decoded = super().decode(s, **kwargs) + return self.process_events(decoded) + +class EventStorage: + session_key = '_ga_events' + + def __init__(self, request): + self.request = request + self._ga_events = self.load() + + def __len__(self): + return len(self._ga_events) + + def __iter__(self): + return iter(self._ga_events) + + def __contains__(self, item): + return item in self._ga_events + + def load(self): + """ + Retrieve a list of resume points from the request's session. + """ + if self.session_key not in self.request.session: + return [] + return self.deserialize_events(self.request.session.get(self.session_key)) + + def store(self): + """ + Store a list of resume points to the request's session. + """ + if self._ga_events: + self.request.session[self.session_key] = self.serialize_events(self._ga_events) + else: + self.request.session.pop(self.session_key, None) + return [] + + def serialize_events(self, events): + encoder = EventEncoder(separators=(',', ':')) + return encoder.encode(events) + + def deserialize_events(self, data): + if data and isinstance(data, str): + return json.loads(data, cls=EventDecoder) + return data + + def add(self, action, category=None, label=None, value=None): + self._ga_events.append(GAEvent(action, category, label, value)) + + def pop(self): + if len(self._ga_events) > 0: + return self._ga_events.pop() + else: + return None + + +class GAEventMiddleware(MiddlewareMixin): + """ + Middleware that handles setting resume points in a user flow. + """ + + def process_request(self, request): + request._ga_events = EventStorage(request) + + def process_response(self, request, response): + """ + Update the storage backend (i.e., save the resume points). + + Raise ValueError if not all resume points could be stored and DEBUG is True. + """ + # A higher middleware layer may return a request which does not contain + # resume storage, so make no assumption that it will be there. + if hasattr(request, '_ga_events'): + request._ga_events.store() + return response \ No newline at end of file diff --git a/simple_ga/migrations/__init__.py b/simple_ga/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/simple_ga/tests.py b/simple_ga/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/simple_ga/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here.