Missions Web Component Click Through - Code Sample

The Missions web component has a built-in interface for action logging on linked mission rules (rules that have URLs for metadata). In Bunchball Go a HUB_CLICK_THROUGH action is logged when a user clicks on a linked mission rule. The action is logged prior to routing the user to the URL's destination.

Using the Missions web component in a non-Bunchball Go program requires you to implement both a client and server side solution.

Note: Tracking a click through action does not guarantee that the user consumed the content. However, the Quiz web component can be used in parallel to test content proficiency.

The Missions web component displays an arrow in the detail view's task list to indicate the rule is linked to other content.

Mission Example

Use Case

Adding a linked rule to a mission allows the user to find the content required for the task easily and to be rewarded for accessing it. You may want to track a click through action if:

Process Flow

The following steps need to occur to trigger a Nitro action.

  1. The user clicks the link in the Missions web component, which triggers an event to the browser.

  2. Client-side code detects the event and initiates the call to the server. See the Client Side Implementation section.

  3. Server-side code sends the click-through action to the Nitro API. See the Server Side Implementation section.

Client Side Implementation

The client side implementation requires the window.logAction function. The Missions web component looks for this function when users click a linked rule. If the function exists, it is invoked and sends the metadata as arguments. This function has one job, to relay the action data to Nitro so that it can log the action on behalf of the user. It does this by performing an XHR or ajax request to the server side endpoint defined in the server side implementation section.

JavaScript Code Example

Copy
function makeRequest(url, opts, callback){
  var options = opts || {};
  var request = new XMLHttpRequest();
  request.open(opts.method || "GET", url, true);
  request.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
  request.onload = function(){
    if(request.status >= 200 && request.status < 400) {
      // Success!
      var data = JSON.parse(request.responseText);
      if(callback){
        callback(request, data);
      }
    }
    else if(callback){
      callback(request, null);
    }
  }
  request.onerror = function(err) {
    // There was a connection error of some sort
    if(callback){
      callback(request, null, err);
    }
  }
  if(options.data || (options.method == "POST" || options.method == "PUT")) {
    request.send(JSON.stringify(options.data));
  } else {
    request.send();
  }
}

function logAction(actionTag, data, callback){
  var url = '/actions/' + actionTag;
  makeRequest(url, {
    method: "POST",
    data
  }, callback)
}

Server Side Implementation

The server side implementation should be a restful endpoint that accepts and returns JSON. For example, if the defined route is /actions, then actions should receive a POST request. The payload should include the action name, the user ID, and url metadata. When the request is received, a new server side request should be made to get a 2-legged token. That token should be used to log the action for the user passing along the action tag and metadata.

The 2-legged credentials should not be exposed to the client. If they are, they can be retrieved and used to compromise the program.

Python Code Example

Copy
@plugin.route('actions/<action_tag>', methods=["POST"])
def log_action(action_tag):
    action_logger = ActionLogger()
    action_payload = request.get_json()
    success = jsonify({"response": "Action logged", "code": 200}), 200

    if current_user.is_authenticated:

        elif action_tag == "HUB_CLICK_THROUGH":
            action_logger.log_action(Actions.click_through, current_user, raise_exception=False, payload=action_payload) # noqa
            return success
      
        else:
            current_app.logger.warning('Action not found {}'.format(action_tag))
            return jsonify({"error": "Action not found", "code": 404}), 404

    else:
        current_app.logger.warning('User is not authenticated')
        return jsonify({"error": "No authentication found", "code": 403}),
403

Server Side Action Logger Details

Copy
from enum import Enum
from urllib.parse import unquote
from src.config import Config
from src.exceptions import ApiGatewayException, SecurityError
from src.plugins.auth.oauth import OauthFactory

class Actions(Enum):
    click_through = 'HUB_CLICK_THROUGH', 'Content clicked through'

    def __new__(cls, name, description):
        obj = object.__new__(cls)
        obj._value_ = name
        obj._description = description
        return obj

    def description(self):
        return self._description

    @staticmethod
    def url(gamification_id):
        return '{}/users/{}/actions'.format(Config.SERVICE_API_GATEWAY, gamification_id)

    def payload(self, payload):
        if payload:
            return self.merge_dictionaries({'name': self._value_},payload)
        
        return {'name': self._value_}

    def merge_dictionaries(self, a, b, path=None):
        if path is None: path = []
        for key in b:
            if key in a:
                if isinstance(a[key], dict) and isinstance(b[key], dict):
                    self.merge_dictionaries(a[key], b[key], path + [str(key)])
                elif a[key] == b[key]:
                    pass # same leaf value
                else:
                    raise Exception('Conflict at %s' % '.'.join(path + [str(key)]))
            else:
                a[key] = b[key]
        return a


class ActionLogger:
    def __init__(self):
        self.backend_oauth = OauthFactory().backend_oauth()

    def log_action(self, action, user, raise_exception=False, payload={}):
        if user:
            # sanitize gamification_id from url encoded characters
            user.gamification_id = unquote(user.gamification_id)

            try:
                url = action.url(user.gamification_id)
                response = self.backend_oauth.post(user.ics, user.api_key, url, payload=action.payload(payload))
                if response.status_code == 200:
                    return self.success(action, user)

                self.failure(action, user, raise_exception=raise_exception)
            except ApiGatewayException as e:
                if raise_exception:
                    raise e
                else:
                    current_app.logger.warning('failed to log action {}: {}'.format(url, e))
                    return
        else:
            if raise_exception:
                raise SecurityError('User context unavailable. Please log in', 401)

    @staticmethod
    def success(action, user):
        current_app.logger.info('logged action ({0}) for {1.ics}/{1.api_key}/{1.gamification_id}'.format(action.value, user))

    @staticmethod
    def failure(action, user, details={}, raise_exception=False):
        current_app.logger.error('could not log action ({0}) for {1.ics}/{1.api_key}/{1.gamification_id}'
                     .format(action.value, user), **details)

        if raise_exception:
            raise ApiGatewayException(user.ics, user.api_key, user.gamification_id,
                                      message="Unable to log {} action".format(action.value))

See also

Code samples