import time, calendar
from urlparse import urljoin
import requests
from datetime import datetime
from xml.etree import ElementTree as ET
from objectify import objectify_spreedly
import re
__all__ = [
'API_VERSION', 'Client', ]
API_VERSION = 'v4'
_user_exists_re = re.compile(ur"A subscriber with a customer-id of \d+ already exists.", re.UNICODE)
def utc_to_local(dt):
''' Converts utc datetime to local'''
secs = calendar.timegm(dt.timetuple())
return datetime(*time.localtime(secs)[:6])
def str_to_datetime(s):
''' Converts ISO 8601 string (2009-11-10T21:11Z) to LOCAL datetime,
or returns None if None is passed'''
if not s: #TODO am I on crack?
return None
return utc_to_local(datetime.strptime(s, '%Y-%m-%dT%H:%M:%SZ'))
#TODO - more coherent mapping to parse the XML in different methods
[docs]class Client(object):
"""
.. py:class:: Client(token, site_name)
Create an object to manage queries for a Client on a given site.
:param token: API access token for authorization.
:param site_name: the site_name registered with spreedly.
"""
def __init__(self, token, site_name):
self.auth = token
self.site_name = site_name
self.base_host = 'https://spreedly.com'
self.base_path = '/api/{api_version}/{site_name}/'.format(
api_version=API_VERSION, site_name=site_name)
self.base_url = urljoin(self.base_host,self.base_path)
self.url = None
def _ft(self, tree):
def ft(x):
try:
return tree.findtext(x)
except:
None
return ft
[docs] def query(self, url, data=None, action='get'):
""" .. py:method:: query(url[, data=None, put='get'])
which has the problem that it doesn't check if there is data for
PUT, and is hard to read.
status_codes are not checked here, and should be handled by the
caller.
Delete is only supported on test users
:param url: the api url you wish to reach (not incuding site/version)
:param data: the data to send in the request. Default to `None`
:type data: UTF-8 encoded XML or None
:param action: one of 'get', 'post', 'put' and 'delete'. Case insensitive, Default 'get'
:return: response object
:rtype: :py:mod:`requests` response object
"""
action = action.lower()
if action not in ('get', 'put', 'post','delete'):
raise NotImplementedError()
url = urljoin(self.base_url, url)
headers = {
'User-Agent': 'python-spreedly 1.1',
}
if action in ('put','post'):
headers['Content-Type'] = 'application/xml'
auth = (self.auth,'X')
response = getattr(requests, action)(url, auth=auth, headers=headers,
data=data)
return response
[docs] def get_plans(self):
""" .. py:method::get_plans()
get subscription plans for the configured site
:returns: data as dict
:raises: :py:exc:`HTTPError` if response is not 200
"""
response = self.query('subscription_plans.xml', action='get')
if response.status_code != 200:
e = requests.HTTPError()
e.code = response.status_code
raise e
# Parse
result = objectify_spreedly(response.text)
return result
## Subscriber manipulation
[docs] def create_subscriber(self, customer_id, screen_name):
''' .. py:method::create_subscriber(customer_id, screen_name)
Creates a subscription
:param customer_id: Customer ID
:param screen_name: Customer's screen name
:returns: Data for created customer
:raises: HTTPError if response code isn't 201
'''
data = '''
<subscriber>
<customer-id>{id}</customer-id>
<screen-name>{name}</screen-name>
</subscriber>
'''.format(id=customer_id, name=screen_name)
response = self.query(url='subscribers.xml',data=data, action='post')
# Parse
if not response.status_code == 201:
if response.status_code == 403 and _user_exists_re.search(response.text):
return self.get_info(customer_id)
e = requests.HTTPError(
"status code: {0}, text: {1}".format(
response.status_code, response.text))
e.response = response
raise e
return objectify_spreedly(response.text)
[docs] def get_signup_url(self, subscriber_id, plan_id, screen_name, token=None):
''' .. py:method:: get_signup_url(subscriber_id, plan_id, screen_name, token=None)
Subscribe a user to the site plan on a free trial
subscribe a user to a plan, either trial or not
:param subscriber_id: ID of the subscriber
:param plan_id: subscription plan ID
:param screen_name: user screen name
:param token: customer token or None - if passed use the token version
of the url
:returns: url for subscription
'''
subscriber_id = str(subscriber_id)
plan_id = str(plan_id)
if token:
url = '/'.join((self.site_name, 'subscribers',subscriber_id,token,
'subscribe', plan_id))
else:
url = '/'.join((self.site_name, 'subscribers',subscriber_id,'subscribe',
plan_id,screen_name))
url = urljoin(self.base_host, url)
return url
[docs] def subscribe(self, subscriber_id, plan_id=None):
''' .. py:method:: subscribe(subscriber_id, plan_id)
Subscribe a user to the site plan on a free trial
subscribe a user to a free trial plan.
:param subscriber_id: ID of the subscriber
:parma plan_id: subscription plan ID
:returns: dictionary with xml data if all is good
:raises: HTTPError if response status not 200
'''
#TODO - This lacks subscription for a site to a plan_id.
data = '''
<subscription_plan>
<id>{plan_id}</id>
</subscription_plan>'''.format(plan_id=plan_id)
url = 'subscribers/{id}/subscribe_to_free_trial.xml'.format(id=subscriber_id)
response = self.query(url, data, action='post')
if response.status_code != 200:
raise requests.HTTPError("status code: {0}, text: {1}".format(response.status_code, response.text))
# Parse
return objectify_spreedly(response.text)
[docs] def change_plan(self, subscriber_id, plan_id):
''' .. py:method:: change_plan(subscriber_id, plan_id)
Change a subscription to a new plan, needs the user to be activated
subscribe a user to a free trial plan.
:param subscriber_id: ID of the subscriber
:parma plan_id: subscription plan ID to change to
:returns: status code
:raises: HTTPError if response status not 200
'''
#TODO - This lacks subscription for a site to a plan_id.
data = '''
<subscription_plan>
<id>{plan_id}</id>
</subscription_plan>'''.format(plan_id=plan_id)
url = 'subscribers/{id}/change_subscription_plan.xml'.format(id=subscriber_id)
response = self.query(url, data, action='put')
if response.status_code != 200:
raise requests.HTTPError("status code: {0}, text: {1}".format(response.status_code, response.text))
# Parse
return response.status_code
[docs] def get_info(self, subscriber_id):
""" .. py:method:: get_info(subscriber_id)
:param subscriber_id: Id of subscriber to fetch
:returns: Data as dictionary
:raises: HTTPError if not 200
"""
url = 'subscribers/{id}.xml'.format(id=subscriber_id)
response = self.query(url, action='get')
if response.status_code != 200:
e = requests.HTTPError()
e.code = response.status_code
raise e
# Parse
return objectify_spreedly(response.text)
[docs] def allow_free_trial(self, subscriber_id):
""" .. py:method:: allow_free_trial(subscriber_id)
programatically allow for a new free trial
:param subscriber_id: the id of the subscriber
:returns: subscriber data as dictionary if all good,
:raises: HTTPError if not so good (non-200)
"""
url = 'subscribers/{id}/allow_free_trial.xml'.format(id=subscriber_id)
response = self.query(url,'', action='post')
if response.status_code is not 200:
raise requests.HTTPError('status; {0}, text {1}'.format(
response.status_code, response.text))
else:
return objectify_spreedly(response.text)
[docs] def add_fee(self, subscriber_id, name, description, group, amount):
""" .. py:method:: add_fee(subscriber_id, name, description, group, amount)
Add a fee to a user with subscriber_id
:param subscriber_id: the id of the subscriber
:param name: the name of the fee (eg - Excess Bandwidth Charge)
:param description: a description of the charge
:param group: a group to add this charge too
:param amount: the amount the charge is for
:returns: the response object
"""
data = """
<fee>
<name>{name}</name>
<description>{description}</description>
<group>{group}</group>
<amount>{amount}</amount>
</fee>
""".format(name=name, description=description, group=group, amount=amount)
url = 'subscribers/{id}/fees.xml'.format(id=subscriber_id)
response = self.query(url,data, action='post')
return response
[docs] def set_info(self, subscriber_id, **kw):
""" .. py:method: set_info(subscriber_id[, **kw])
this corrisponds to the update-subscriber action. passed kw args are
placed into the xml data (not sure how the -/_ are dealt with though)
There is a design flaw atm where sclient.set_info(sclient.get_info(123))
will not work at all as the keys are all different
"""
root = ET.Element('subscriber')
for key, value in kw.items():
e = ET.SubElement(root, key)
e.text = value
url = 'subscribers/{id}.xml'.format(id=subscriber_id)
self.query(url, data=ET.tostring(root), action='put')
[docs] def create_complimentary_subscription(self, subscriber_id,
duration, duration_units, feature_level,
start_time=None, amount=None):
""" .. py:method:: create_complimentary_subscription(subscriber_id, duration, duration_units, feature_level[, start_time=None, amount=None])
corrisponds to adding corrisponding subscription to a subscriber
:param subscriber_id: Subscriber ID
:param duration: Duration (unitless)
:param duration_units: Unit for above (days, weeks, months i think)
:param feature_level string: what feature level this is at
:param start_time: If assgining a value for pro-rating purpose, you need this start datetime
:type start_time: datetime.datetime or None
:param amount: How much this comp is worth
:type amount: float or None
"""
if start_time and amount:
comp_value = """<start-time>{start_time}</start_time>
<amount>{amount}</amount>""".format(
start_time=start_time.strftime('%Y-%m-%dT%H:%M:%SZ'),
amount=amount)
else:
comp_value = ''
data = """<complimentary_subscription>
<duration_quantity>{duration}</duration_quantity>
<duration_units>{duration_units}</duration_units>
<feature_level>{level}</feature_level>
{comp_value}
</complimentary_subscription>""".format(
duration=duration, duration_units=duration_units,
level=feature_level,comp_value=comp_value)
url = 'subscribers/{subscriber_id}/complimentary_subscriptions.xml'.format(subscriber_id=subscriber_id)
self.query(url, data, action='post')
[docs] def complimentary_time_extensions(self, subscriber_id, duration, duration_units):
""" .. py:method:: complimentary_time_extension(subscriber_id, duration, duration_units)
corrisponds to adding complimentary time extension to a subscriber
"""
data = """<complimentary_time_extension>
<duration_quantity>{duration}</duration_quantity>
<duration_units>{duration_units}</duration_units>
</complimentary_time_extension>""".format(
duration=duration, duration_units=duration_units)
url ='subscribers/{id}/complimentary_time_extensions.xml'.format(
id=subscriber_id)
self.query(url, data, action='post')
[docs] def get_or_create_subscriber(self, subscriber_id, screen_name):
""" .. py:method:: get_or_create_subscriber(subscriber_id, screen_name)
Tries to get info for a subscriber, else creates a new subscriber
"""
try:
return self.get_info(subscriber_id)
except requests.HTTPError, e:
if e.code == 404:
return self.create_subscriber(subscriber_id, screen_name)
## Payment Gateway Configuration
#TODO
## Invoicing
#TODO
## Payments
#TODO
## Reporting
#TODO
## Emails
#TODO
## Testing
[docs] def delete_subscriber(self, id):
""" .. py:method:: delete_subscriber(id)
delete a test subscriber
:param id: user id
:returns: status code
"""
url = "subscribers/{id}.xml".format(id=id)
response = self.query(url,action='delete')
return response.status_code
[docs] def cleanup(self):
""" .. py:method:: cleanup()
Removes ALL subscribers. NEVER USE IN PRODUCTION! (should only Remove
test users...)
:returns: status code
"""
response = self.query('subscribers.xml', action='delete')
return response.status_code