Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
hub-client / dockerhub / marketo / adapter.py
Size: Mime:
import logging
import datetime

from django.conf import settings
from django.utils import dateparse
import pytz
from dockerhub.marketo import decorators
from dockerhub.marketo import constants
from dockerhub.marketo.client import MarketoClient
from dockerhub.marketo.exceptions import (
    UnableToCompleteRequestException, MarketoImproperlyConfigured,
    UnknownActivityTypeException, InvalidActivityDateException)
from dockerhub.marketo.utils import flatten

log = logging.getLogger(__name__)
VALID_LEAD_FIELDS = ('hubUsername', 'plan_type', 'email', 'firstName',
                     'lastName', 'company', 'country', 'postalCode',
                     'address', 'address2', 'city', 'partnerValue',
                     'accept_eval_terms', 'accept_eval_terms_date',
                     'accept_eusa_terms', 'accept_eusa_terms_date',
                     'docker_index_add_organization',
                     'docker_index_plan_downgrade',
                     'docker_index_plan_subscription',
                     'docker_index_plan_termination',
                     'docker_index_plan_upgrade',
                     'Docker_Hub_User_Name__c', 'phone', 'state',
                     'jobFunction', 'dockerUUID', 'title', 'leadStatus', 'unsubscribed')
VALID_ATTRIBUTE_FIELDS = ('Type', 'SubType', 'UserName', 'UUID', 'Other1',
                          'Other2')


class MarketoClientConfig(object):
    """
    Instantiates a marketo client as part of object creation, looking
    to the django settings if values are not passed directly.
    """

    def __init__(self, host=None, client_id=None, client_secret=None,
                 track_api_usage=False):
        self._host = host or getattr(settings, 'MARKETO_HOST')
        if self._host is None:
            raise MarketoImproperlyConfigured('MARKETO_HOST')
        self._client_id = client_id or getattr(settings, 'MARKETO_CLIENT_ID')
        if self._client_id is None:
            raise MarketoImproperlyConfigured('MARKETO_CLIENT_ID')
        self._client_secret = client_secret or getattr(
            settings, 'MARKETO_SECRET'
        )
        if self._client_secret is None:
            raise MarketoImproperlyConfigured('MARKETO_SECRET')
        self._track_api_usage = (
            track_api_usage or
            getattr(settings, 'MARKETO_TRACK_API_USAGE', False))
        self._client = MarketoClient(
            host=self._host,
            client_id=self._client_id,
            client_secret=self._client_secret
        )


class MarketoMailingListImpl(MarketoClientConfig):
    """
    Implementation for high level interactions with Marketo.
    """
    @decorators.track_api_calls
    def is_subscribed(self, mailing_list, email):
        """
        Check to see if the email address is on the mailing list specified

        :param mailing_list: integer id for the mailing list
        :param email: email address
        :return: True if the email is on the mailing list, False otherwise.
        :raises UnableToCompleteRequestException:  when there's an issue
        completing the request.
        """
        try:
            leads = self._client.find_lead(email)
        except Exception as exc:  # pylint: disable=W0703
            # We're casting a wide net here and assume the transaction failed
            message = "Unable to verify if {0} is_subscribed to {1}".format(
                email, mailing_list
            )
            log.exception(message)
            raise UnableToCompleteRequestException(message)
        else:
            try:
                return self._client.is_member_of_list(mailing_list, leads)
            except Exception as exc:  # pylint: disable=W0703
                # We're casting a wide net here and assume the transaction
                # failed
                message = "Unable to verify if {0} is_subscribed to " \
                          "{1}".format(email, mailing_list)
                log.exception(message)
                raise UnableToCompleteRequestException(message)

    @decorators.track_api_calls
    def subscribe(self, mailing_list, email):
        """
        subscribes the email address to the mailing_list.

        :param mailing_list: integer id for the mailing list
        :param email: email address
        :return: Returns True if the email is subscribed, False otherwise
        :raises UnableToCompleteRequestException:  when there's an issue
        completing the request.
        """
        try:
            leads = self._client.find_lead(email)
        except Exception as exc:  # pylint: disable=W0703
            # We're casting a wide net here and assume the transaction failed
            message = "Unable to subscribe {0} to mailinglist {1}".format(
                email, mailing_list
            )
            log.exception(message)
            raise UnableToCompleteRequestException(message)
        else:
            try:
                return self._client.add_leads_to_list(mailing_list, leads)
            except Exception as exc:  # pylint: disable=W0703
                # We're casting a wide net here and assume the transaction
                # failed
                message = "Unable to subscribe {0} to mailinglist {1}".format(
                    email, mailing_list
                )
                log.exception(message)
                raise UnableToCompleteRequestException(message)

    @decorators.track_api_calls
    def unsubscribe(self, mailing_list, email):
        """
        Unsubscribes the email address from the mailing list

        :param mailing_list: integer id for the mailing list
        :param email: email address
        :return: Returns True if the email is unsubscribed, False otherwise
        :raises UnableToCompleteRequestException:  when there's an issue
        completing the request.
        """
        try:
            leads = self._client.find_lead(email)
        except Exception as exc:  # pylint: disable=W0703
            # We're casting a wide net here and assume the transaction failed
            message = "Unable to unsubscribe {0} from mailinglist {1}".format(
                email, mailing_list
            )
            log.exception(message)
            raise UnableToCompleteRequestException(message)
        else:
            try:
                return self._client.remove_leads_from_list(mailing_list, leads)
            except Exception as exc:  # pylint: disable=W0703
                # We're casting a wide net here and assume the transaction
                # failed
                message = "Unable to unsubscribe {0} from mailinglist " \
                          "{1}".format(email, mailing_list)
                log.exception(message)
                raise UnableToCompleteRequestException(message)


class LeadManagerImpl(MarketoClientConfig):
    """
    Object used for lead management
    """
    @decorators.track_api_calls
    def create_lead(self, email, extra_values=None, cookie=None):
        """
        Creates a lead if one doesn't exists, adding any additional
        extra_values to the lead.  If a cookie is passed, then
        additional calls are made to get the lead id and link
        the cookie to the newly created lead.

        :param email: email address
        :param extra_values: dictionary of extra values added to the lead
        record
        :param cookie: optional value that can be associated to a lead
        :return: an array of lead ids associated to the email
        :raises UnableToCompleteRequestException:  when there's an issue
        completing the request.
        """
        if extra_values is not None:
            extra_values = LeadManagerImpl._scrub_extra_values(extra_values)

        lead_ids = []
        try:
            lead_ids = self._client.create_lead(
                email,
                extra_values=extra_values
            )
        except Exception as exc:  # pylint: disable=W0703
            # We're casting a wide net here and assume the transaction failed
            message = "Unable to create lead: {0}".format(email)
            log.exception(message)
            raise UnableToCompleteRequestException(message)
        else:
            if cookie is not None:
                # Most likely, we will only get one result, but we should
                # handle multiple since it's possible to get back more
                for lead_id in lead_ids:
                    try:
                        # link the lead to the cookie
                        self._client.associate_lead(lead_id, cookie)
                    except Exception as exc:  # pylint: disable=W0703
                        # We're casting a wide net here and assume the
                        # transaction failed
                        message = "Unable to associate lead {0} to cookie " \
                                  "{1}".format(lead_id, cookie)
                        log.exception(message)
                        raise UnableToCompleteRequestException(message)
        return lead_ids

    @decorators.track_api_calls
    def delete_lead(self, email):
        """
        USED IN INTEGRATION TESTS, NOT FULLY TESTED

        Deletes leads associated to specified email

        :param email: email address
        :return: True if leads are removed, false otherwise
        :raises UnableToCompleteRequestException:  when there's an issue
        completing the request.
        """
        try:
            leads = self.get_lead_ids_by_email(email)
        except Exception as exc:  # pylint: disable=W0703
            # We're casting a wide net here and assume the transaction failed
            message = "Unable to get lead ids for {0}".format(email)
            log.exception(message)
            raise UnableToCompleteRequestException(message)

        try:
            self._client.delete_leads(leads)
        except Exception as exc:  # pylint: disable=W0703
            message = "Unable to delete leads with ids {0} related to " \
                      "email {1}".format(leads, email)
            log.exception(message)
            raise UnableToCompleteRequestException(message)
        return True

    @decorators.track_api_calls
    def create_activity(self, email, activity, activity_date,
                        attributes=None):
        """
        Creates activity for the lead specified

        :param email: email address of the lead
        :param activity: DTR_BINARY_DOWNLOAD or DTRL_LICENSE_DOWNLOAD
        :param activity_date: datetime the activity occurred
        :param attributes: dictionary of activity attributes
        :return: True if the activity is created, False otherwise
        :raises UnknownActivityTypeException: when an unknown activity type is
                passed
        :raises UnableToCompleteRequestException:  when there's an issue
                completing the request.
        :raises InvalidActivityDateException: when the activity date is
                invalid, None, decodes to None, or causes a ValueError when
                parsing
        """
        # attribute default values
        attributes = {} if attributes is None else attributes

        # only continue if the activity type is valid
        try:
            activity = constants.MARKETO_ACTIVITY_TYPES[activity]
        except KeyError:
            raise UnknownActivityTypeException(activity)

        # each marketo attribute should have a "Name" and "Value"
        # component. Convert to the marketo standard, dropping any
        # unexpected "Name" key/values
        attributes = (
            LeadManagerImpl._convert_keyvals_to_custom_activity_format(
                attributes
            )
        )

        activity_date = (
            LeadManagerImpl._convert_date_to_custom_activity_format(
                activity_date
            )
        )

        # find the lead, creating one if there isn't one tied
        # to the email address
        try:
            leads = self._client.find_lead(email)
        except Exception as exc:  # pylint: disable=W0703
            # We're casting a wide net here and assume the
            # transaction failed
            message = "Unable to get lead ids for {0}".format(email)
            log.exception(message)
            raise UnableToCompleteRequestException(message)

        # it's possible that multiple leads are returned
        # so we need to handle the case, however this is
        # unlikely
        for lead in leads:
            try:
                # client api call to create activity
                self._client.create_custom_activity(
                    lead,
                    activity.id,
                    activity.value,
                    activity_date,
                    attributes=attributes
                )
            except Exception:  # pylint: disable=W0703
                # We're casting a wide net here and assume the
                # transaction failed
                message = "Unable to get lead ids for {0}".format(email)
                log.exception(message)
                raise UnableToCompleteRequestException(message)
        return True

    def get_lead_ids_by_email(self, email):
        """
        Common access for retrieving lead ids associated to an email

        :param email: email address
        :return: array of integer leads
        """
        log.debug("Looking for leads with email: %s", email)
        try:
            leads = self._client.get_leads(
                MarketoClient.FilterType.EMAIL,
                values=[email, ],
                fields=[MarketoClient.FieldType.ID, ]
            )
        except Exception as exc:  # pylint: disable=W0703
            # We're casting a wide net here and assume the transaction failed
            message = "Unable to get lead ids for {0}".format(email)
            log.exception(message)
            raise UnableToCompleteRequestException(message)

        log.debug("Found leads as: %s", leads)
        return [int(lead) for lead in flatten('id', leads)]

    @staticmethod
    def _scrub_extra_values(extra_values):
        """
        The api only supports a specific set of fields.  We'll scrub the input
        and send only the fields they expect to see.  If there's any input
        that's not expected, we should emit a warning, because we should
        probably update the code sending the unknown value

        :return:
        """
        scrubbed_values = {}
        for k, v in extra_values.iteritems():
            if k in VALID_LEAD_FIELDS:
                # ignore empty values to avoid overwriting
                # data collected previously
                if v:
                    scrubbed_values[k] = v
            else:
                log.warning("Found unexpected extra value key as %s. "
                            "Ignoring.", k)
        return scrubbed_values

    @staticmethod
    def _convert_keyvals_to_custom_activity_format(attributes):
        """
        converts the dict into an object with name and value components.  If
        there is an attribute key not within the valid attribute field list,
        a warning is emitted and the value is dropped

        ex:

           this:
              {
                  'SubType': '{Ubuntu, RHEL/CentOS}'
              }

           to this:
              {
                  "name": "SubType",
                  "value": "{Ubuntu, RHEL/CentOS}"
              }

        """
        validated_attributes = []
        for k, v in attributes.iteritems():
            if k in VALID_ATTRIBUTE_FIELDS:
                validated_attributes.append({'name': k, 'value': v})
            else:
                log.warning("Found unexpected attribute key as %s. "
                            "Ignoring.", k)
        return validated_attributes

    @staticmethod
    def _convert_date_to_custom_activity_format(activity_date):
        """
        Converts a string formatted datetime or a datetime object (both
        tz aware and tz naive) into the format required for the custom
        activity

        :param activity_date: datetime of the activity
        :return: formatted datetime string
        """
        log.debug("Date before conversion: {0}".format(activity_date))
        orig_activity_date = activity_date
        try:
            if not isinstance(activity_date, datetime.datetime):
                activity_date = dateparse.parse_datetime(activity_date)
                log.debug("Parsed activity date: %s", activity_date)
        except (TypeError, ValueError, AttributeError):
            raise InvalidActivityDateException(activity_date)

        if activity_date is None:
            raise InvalidActivityDateException(
                "Invalid date converted to None: %s", orig_activity_date)

        if activity_date.tzinfo is not None:
            # convert to UTC time
            activity_date = activity_date.astimezone(pytz.utc)
            log.debug("Converted datetime to UTC: %s", activity_date)

        # remove timezone component from date - marketo doesn't understand
        # this component :/
        activity_date = activity_date.replace(tzinfo=None)
        log.debug("Stripped datetime of its tzinfo: %s", activity_date)

        # convert to a json serializable date string
        activity_date = activity_date.isoformat()
        log.debug("json formattable activity date: %s", activity_date)

        return activity_date


class CampaignManagerImpl(MarketoClientConfig):
    """
    Object used for campaign management
    """
    @decorators.track_api_calls
    def request_campaign(self, campaign_id, leads=None, tokens=None):
        """
        The Smart Campaign must have a "Campaign is Requested" trigger with a Web Service API source.
        A maximum of 100 lead records can be included in each call. If the campaign is not a valid
        trigger campaign that is a child of a program and has the Campaign is Requested,
        source Web Service API, the API will return an error code of 1003.

        :param campaign_id: marketo campaign id
        :param leads: Array of Lead IDs dictionaries (e.g. [{"id" : 123}, {"id" : 456}])
        :param tokens: Array of name/value pairs (e.g. [{"{{my.message}}" : "Updated message",
                                                        {"{{my.other token}}" : Value for other token}])
        :return: True if lead is updated, false otherwise
        :raises UnableToCompleteRequestException:  when there's an issue
        completing the request.
        """
        if leads is None or campaign_id is None:
            message = "Missing parameters: leads = {0}, campaign_id =  {1} ".format(
                leads, campaign_id)
            log.exception(message)
            raise UnableToCompleteRequestException(message)

        try:
            result = self._client.request_campaign(
                campaign_id,
                leads=leads,
                tokens=tokens,
            )
        except:  # pylint: disable=W0703
            # We're casting a wide net here and assume the transaction failed
            message = "Unable to send the campaign request. leads = {0}, campaign_id = {1}".format(
                leads, campaign_id)
            log.exception(message)
            raise UnableToCompleteRequestException(message)

        return result