From db93c371a3805416479c908e909fa31efd6b89a1 Mon Sep 17 00:00:00 2001 From: Robert Coup Date: Thu, 8 Feb 2018 15:20:21 +0000 Subject: [PATCH 1/3] Simple global request mocking capability --- chargebee/__init__.py | 2 + chargebee/compat.py | 8 +-- chargebee/environment.py | 100 ++++++++++++++++++++++++++++++++++++++ chargebee/http_request.py | 14 ++++-- chargebee/main.py | 19 +++++++- 5 files changed, 134 insertions(+), 9 deletions(-) diff --git a/chargebee/__init__.py b/chargebee/__init__.py index ac2deef..b688fc3 100644 --- a/chargebee/__init__.py +++ b/chargebee/__init__.py @@ -8,3 +8,5 @@ def configure(api_key, site): 'api_key': api_key, 'site': site, }) + +mock = ChargeBee.mock diff --git a/chargebee/compat.py b/chargebee/compat.py index dd4808f..2618923 100644 --- a/chargebee/compat.py +++ b/chargebee/compat.py @@ -15,10 +15,10 @@ if py_major_v < 3: from urllib import urlencode - from urlparse import urlparse + from urlparse import urlparse, urlsplit, parse_qs from urllib2 import urlopen as _urlopen, Request elif py_major_v >= 3: - from urllib.parse import urlencode, urlparse + from urllib.parse import urlencode, urlparse, urlsplit, parse_qs from urllib.request import urlopen as _urlopen, Request @@ -26,12 +26,12 @@ try: SSLError = None ssl = None - + if Environment.chargebee_domain is None: HTTPSConnection = object else: HTTPConnection = object - + if py_major_v < 3: from httplib import HTTPConnection, HTTPSConnection, HTTPException else: diff --git a/chargebee/environment.py b/chargebee/environment.py index 6cc4e97..6b660fd 100644 --- a/chargebee/environment.py +++ b/chargebee/environment.py @@ -1,3 +1,7 @@ +import re +from collections import namedtuple + + class Environment(object): chargebee_domain = None @@ -15,3 +19,99 @@ def __init__(self, options): def api_url(self, url): return self.api_endpoint + url + + +class MockEnvironment(Environment): + Request = namedtuple('Request', ['method', 'url_path', 'payload']) + + def __init__(self, **options): + params = { + 'api_key': 'mock-api-key', + 'site': 'mock-test', + } + params.update(options) + super(MockEnvironment, self).__init__(params) + + self.clear() + + def clear(self): + """ Clear any expected and previous requests """ + self.expecting = [] + self.requests = [] + + def add(self, url_path, response_body, response_status=200, request_method=None): + """ + Add a mock response. + + url_path is the API path after the API version. eg. '/subscriptions/1mkVvvHQiQMbLBBf/cancel' + It can also be a regex pattern from re.compile() + response_body is the JSON response body to return. + It can be a string, a JSON-serializable dict, or a callable which + will be called with (url_path, payload, method) and should return a (json_body_string, http_status) tuple. + response_status is the HTTP response code to return. + This is ignored if response_body is a callable. + request_method optional HTTP method to check against the request + + >>> with chargebee.mock() as mock: + >>> mock.add('/subscriptions/1mkVvvHQiQMbLBBf/cancel', {"subscription": {...}}) + >>> result = chargebee.Subscription.cancel('1mkVvvHQiQMbLBBf') + >>> subscription = result.subscription + >>> mock.requests + [Request(method='POST', url_path='/subscriptions/1mkVvvHQiQMbLBBf/cancel', payload={})] + >>> chargebee.Subscription.list({}) + AssertionError: No more mock requests left: /subscriptions + """ + self.expecting.append((url_path, request_method, response_body, response_status)) + + def request(self, method, url, payload): + """ + Called for each Chargebee request + + Adds the request content to MockEnvironment.requests + Matches the next expected request and returns the associated content + + Returns a (json_body_string, http_status) tuple + """ + from chargebee.compat import urlsplit, parse_qs, json + + url_obj = urlsplit(url) + try: + url_path = url_obj.path.split('/api/%s' % self.API_VERSION)[1] + except IndexError: + raise ValueError("Request URL (%s) didn't match endpoint %s" % (url, self.api_endpoint)) + + # un-encode the body/querystring payload + payload = parse_qs(payload or url_obj.query, keep_blank_values=True) + + # save to MockEnvironment.requests + self.requests.append(MockEnvironment.Request(method, url_path, payload)) + + # check the next expected request + try: + req_path, req_method, resp_body, resp_status = self.expecting.pop(0) + except IndexError: + raise AssertionError("No more mock requests left: %s" % url_path) + + # Match the URL path + if isinstance(req_path, re._pattern_type): + # url_path is a regex + if not req_path.match(url_path): + raise AssertionError("Request URL %s does not match %s" % (url_path, req_path.pattern)) + elif req_path != url_path: + # url_path is a string + raise AssertionError("Request URL %s != %s" % (url_path, req_path)) + + # Match any request method + if req_method and method != req_method.upper(): + raise AssertionError("Request Method %s != %s (%s)" % (method, req_method, url_path)) + + # Return the response + if callable(resp_body): + # response_body is a callable, use it to generate the response + return resp_body(url_path, payload, method) + elif isinstance(resp_body, (dict, list, tuple)): + # response body is a dict/sequence, JSON-encode it + return (json.dumps(resp_body), resp_status) + else: + # string body + return (resp_body, resp_status) diff --git a/chargebee/http_request.py b/chargebee/http_request.py index e011acc..a21c589 100644 --- a/chargebee/http_request.py +++ b/chargebee/http_request.py @@ -2,9 +2,10 @@ import platform from chargebee import APIError,PaymentError,InvalidRequestError,OperationFailedError, compat from chargebee.main import ChargeBee -from chargebee.main import Environment +from chargebee.main import Environment, MockEnvironment from chargebee.version import VERSION + def _basic_auth_str(username): return 'Basic ' + base64.b64encode(('%s:' % username).encode('latin1')).strip().decode('latin1') @@ -23,6 +24,11 @@ def request(method, url, env, params=None, headers=None): payload = compat.urlencode(params) headers['Content-type'] = 'application/x-www-form-urlencoded' + if isinstance(env, MockEnvironment): + # we're running in a testing environment. It will provide responses + data, resp_status = env.request(method.upper(), url, payload) + return process_response(url, data, resp_status) + headers.update({ 'User-Agent': 'ChargeBee-Python-Client v%s' % VERSION, 'Accept': 'application/json', @@ -39,8 +45,8 @@ def request(method, url, env, params=None, headers=None): if Environment.protocol == "https": connection = compat.HTTPSConnection(meta.netloc) else: - connection = compat.HTTPConnection(meta.netloc) - + connection = compat.HTTPConnection(meta.netloc) + connection.request(method.upper(), meta.path + '?' + meta.query, payload, headers) try: response = connection.getresponse() @@ -56,7 +62,7 @@ def request(method, url, env, params=None, headers=None): def process_response(url,response, http_code): try: resp_json = compat.json.loads(response) - except Exception as ex: + except Exception as ex: raise Exception("Response not in JSON format. Probably not a chargebee error. \n URL is " + url + "\n Content is \n" + response) if http_code < 200 or http_code > 299: handle_api_resp_error(url,http_code, resp_json) diff --git a/chargebee/main.py b/chargebee/main.py index 630a8a0..eebcb57 100644 --- a/chargebee/main.py +++ b/chargebee/main.py @@ -1,6 +1,7 @@ import os.path +from contextlib import contextmanager -from chargebee.environment import Environment +from chargebee.environment import Environment, MockEnvironment class ChargeBee(object): @@ -13,3 +14,19 @@ class ChargeBee(object): @classmethod def configure(cls, options): cls.default_env = Environment(options) + + @classmethod + @contextmanager + def mock(cls, **options): + """ + Context manager for unit testing environments. + This changes the default environment, so saves passing custom environments around. + + See MockEnvironment for more details & example usage. + """ + prev_env = cls.default_env + try: + cls.default_env = MockEnvironment(**options) + yield cls.default_env + finally: + cls.default_env = prev_env From d6d4a9debf8cfb7c22bdca8279eb3d703f673055 Mon Sep 17 00:00:00 2001 From: Robert Coup Date: Wed, 7 Feb 2018 16:05:48 +0000 Subject: [PATCH 2/3] Add useful __repr__() methods to Models & Result Uses the Model.fields[0] by default, but can be overridden via `Model.repr_field` --- chargebee/model.py | 17 ++++++++++------- chargebee/models/customer.py | 1 + chargebee/models/invoice.py | 2 ++ chargebee/models/payment_source.py | 1 + chargebee/models/subscription.py | 1 + chargebee/models/subscription_estimate.py | 1 + chargebee/result.py | 4 +++- 7 files changed, 19 insertions(+), 8 deletions(-) diff --git a/chargebee/model.py b/chargebee/model.py index a75c18d..b572da5 100644 --- a/chargebee/model.py +++ b/chargebee/model.py @@ -2,21 +2,25 @@ class Model(object): - - fields = [] + fields = [] # field list + repr_field = None # field to use for repr(), default is fields[0] def __init__(self, values, sub_types=None, dependant_types=None): if sub_types is None: sub_types = {} if dependant_types is None: dependant_types = {} - + self.values = values self.sub_types = sub_types self.dependant_types = dependant_types for field in self.fields: setattr(self, field, None) + def __repr__(self): + repr_field = self.repr_field or self.fields[0] + return "".format(self.__class__.__name__, repr_field, getattr(self, repr_field)) + def __str__(self): return json.dumps(self.values, indent=4) @@ -40,16 +44,16 @@ def load(self, values): # Returns null for any attribute that starts with cf_ to access the custom fields. def __getattr__(self, name): - if( name[0:3] == "cf_"): + if( name[0:3] == "cf_"): return None - raise AttributeError("Attribute %s not found " % name) + raise AttributeError("Attribute %s not found " % name) @classmethod def construct(cls, values, sub_types=None, dependant_types=None): obj = cls(values, sub_types, dependant_types) obj.load(values) return obj - + def init_dependant(self, obj, type, sub_types={}): if obj.get(type) != None: if isinstance(obj, dict) and type in self.dependant_types: @@ -63,4 +67,3 @@ def init_dependant_list(self, obj, type, sub_types={}): set_val = [self.dependant_types[type].construct(dt, sub_types) for dt in obj[type]] setattr(self, type, set_val) - \ No newline at end of file diff --git a/chargebee/models/customer.py b/chargebee/models/customer.py index 835e369..80a9cb3 100644 --- a/chargebee/models/customer.py +++ b/chargebee/models/customer.py @@ -6,6 +6,7 @@ class Customer(Model): class BillingAddress(Model): fields = ["first_name", "last_name", "email", "company", "phone", "line1", "line2", "line3", "city", "state_code", "state", "country", "zip", "validation_status"] + repr_field = "zip" pass class ReferralUrl(Model): fields = ["external_customer_id", "referral_sharing_url", "created_at", "updated_at", "referral_campaign_id", "referral_account_id", "referral_external_campaign_id", "referral_system"] diff --git a/chargebee/models/invoice.py b/chargebee/models/invoice.py index edcf63d..eef7550 100644 --- a/chargebee/models/invoice.py +++ b/chargebee/models/invoice.py @@ -45,9 +45,11 @@ class Note(Model): pass class ShippingAddress(Model): fields = ["first_name", "last_name", "email", "company", "phone", "line1", "line2", "line3", "city", "state_code", "state", "country", "zip", "validation_status"] + repr_field = "zip" pass class BillingAddress(Model): fields = ["first_name", "last_name", "email", "company", "phone", "line1", "line2", "line3", "city", "state_code", "state", "country", "zip", "validation_status"] + repr_field = "zip" pass fields = ["id", "po_number", "customer_id", "subscription_id", "recurring", "status", "vat_number", \ diff --git a/chargebee/models/payment_source.py b/chargebee/models/payment_source.py index 2f58a9f..9c73f7f 100644 --- a/chargebee/models/payment_source.py +++ b/chargebee/models/payment_source.py @@ -6,6 +6,7 @@ class PaymentSource(Model): class Card(Model): fields = ["first_name", "last_name", "iin", "last4", "brand", "funding_type", "expiry_month", "expiry_year", "billing_addr1", "billing_addr2", "billing_city", "billing_state_code", "billing_state", "billing_country", "billing_zip", "masked_number"] + repr_field = "last4" pass class BankAccount(Model): fields = ["last4", "name_on_account", "bank_name", "mandate_id", "account_type", "echeck_type", "account_holder_type"] diff --git a/chargebee/models/subscription.py b/chargebee/models/subscription.py index 87625d4..fc41eae 100644 --- a/chargebee/models/subscription.py +++ b/chargebee/models/subscription.py @@ -18,6 +18,7 @@ class Coupon(Model): pass class ShippingAddress(Model): fields = ["first_name", "last_name", "email", "company", "phone", "line1", "line2", "line3", "city", "state_code", "state", "country", "zip", "validation_status"] + repr_field = "zip" pass class ReferralInfo(Model): fields = ["referral_code", "coupon_code", "referrer_id", "external_reference_id", "reward_status", "referral_system", "account_id", "campaign_id", "external_campaign_id", "friend_offer_type", "referrer_reward_type", "notify_referral_system", "destination_url", "post_purchase_widget_enabled"] diff --git a/chargebee/models/subscription_estimate.py b/chargebee/models/subscription_estimate.py index 10afcaa..cecaeb4 100644 --- a/chargebee/models/subscription_estimate.py +++ b/chargebee/models/subscription_estimate.py @@ -6,6 +6,7 @@ class SubscriptionEstimate(Model): class ShippingAddress(Model): fields = ["first_name", "last_name", "email", "company", "phone", "line1", "line2", "line3", "city", "state_code", "state", "country", "zip", "validation_status"] + repr_field = "zip" pass fields = ["id", "currency_code", "status", "next_billing_at", "pause_date", "resume_date", \ diff --git a/chargebee/result.py b/chargebee/result.py index 2f6b1ad..04b1aad 100644 --- a/chargebee/result.py +++ b/chargebee/result.py @@ -236,7 +236,7 @@ def invoices(self): def _get_list(self, type, cls, sub_types={}, dependant_types={}, dependant_sub_types={}): if not type in self._response: return None - + set_val = [] for obj in self._response[type]: if isinstance(obj, dict): @@ -260,6 +260,8 @@ def _get(self, type, cls, sub_types=None, dependant_types=None): def __str__(self): return json.dumps(self._response, indent=4) + def __repr__(self): + return "".format(";".join(self._response.keys())) class Content(Result): pass From e3ce01b0cd95612eb2d32398f5c00aee6c241fa6 Mon Sep 17 00:00:00 2001 From: Robert Coup Date: Thu, 8 Feb 2018 12:04:22 +0000 Subject: [PATCH 3/3] Refactor model construction from results. * move sub type & dependent type declarations to the model classes * dependent_sub_types moves to the sub type classes themselves * simplify construct/init_* methods --- chargebee/model.py | 37 +++---- chargebee/models/addon.py | 3 + chargebee/models/credit_note.py | 11 ++ chargebee/models/customer.py | 8 ++ chargebee/models/estimate.py | 15 +++ chargebee/models/event.py | 11 +- chargebee/models/export.py | 5 + chargebee/models/gift.py | 5 + chargebee/models/invoice.py | 18 ++++ chargebee/models/invoice_estimate.py | 8 ++ chargebee/models/order.py | 8 ++ chargebee/models/payment_intent.py | 3 + chargebee/models/payment_source.py | 7 ++ chargebee/models/plan.py | 6 ++ chargebee/models/portal_session.py | 4 + chargebee/models/quote.py | 9 ++ chargebee/models/subscription.py | 9 ++ chargebee/models/subscription_estimate.py | 3 + chargebee/models/transaction.py | 7 ++ chargebee/models/unbilled_charge.py | 3 + chargebee/result.py | 114 +++++++------------- tests/result.py | 120 ++++++++++++++++++++++ 22 files changed, 312 insertions(+), 102 deletions(-) create mode 100644 tests/result.py diff --git a/chargebee/model.py b/chargebee/model.py index b572da5..47ea2d4 100644 --- a/chargebee/model.py +++ b/chargebee/model.py @@ -4,16 +4,11 @@ class Model(object): fields = [] # field list repr_field = None # field to use for repr(), default is fields[0] + sub_types = {} # mapping {attr: type} + dependant_types = {} # mapping {attr: type}. If type is a 1-tuple, indicates it's a list. - def __init__(self, values, sub_types=None, dependant_types=None): - if sub_types is None: - sub_types = {} - if dependant_types is None: - dependant_types = {} - + def __init__(self, values): self.values = values - self.sub_types = sub_types - self.dependant_types = dependant_types for field in self.fields: setattr(self, field, None) @@ -49,21 +44,15 @@ def __getattr__(self, name): raise AttributeError("Attribute %s not found " % name) @classmethod - def construct(cls, values, sub_types=None, dependant_types=None): - obj = cls(values, sub_types, dependant_types) + def construct(cls, values): + obj = cls(values) obj.load(values) + for k, dependent_type in cls.dependant_types.items(): + if values.get(k) is not None: + if isinstance(dependent_type, tuple): + # dependent type being a 1-tuple indicates a list + set_val = [dependent_type[0].construct(v) for v in values[k]] + else: + set_val = dependent_type.construct(values[k]) + setattr(obj, k, set_val) return obj - - def init_dependant(self, obj, type, sub_types={}): - if obj.get(type) != None: - if isinstance(obj, dict) and type in self.dependant_types: - dependant_obj = self.dependant_types[type].construct(obj[type], sub_types) - setattr(self, type, dependant_obj) - - def init_dependant_list(self, obj, type, sub_types={}): - if obj.get(type) != None: - if isinstance(obj[type],(list, tuple)) and type in self.dependant_types: - if(self.dependant_types != None): - set_val = [self.dependant_types[type].construct(dt, sub_types) for dt in obj[type]] - setattr(self, type, set_val) - diff --git a/chargebee/models/addon.py b/chargebee/models/addon.py index a4fac68..5c054a2 100644 --- a/chargebee/models/addon.py +++ b/chargebee/models/addon.py @@ -15,6 +15,9 @@ class Tier(Model): "shipping_frequency_period_unit", "resource_version", "updated_at", "invoice_notes", "taxable", \ "tax_profile_id", "meta_data", "tiers"] + sub_types = { + 'tiers' : Tier, + } @staticmethod def create(params, env=None, headers=None): diff --git a/chargebee/models/credit_note.py b/chargebee/models/credit_note.py index 3681a08..9770d02 100644 --- a/chargebee/models/credit_note.py +++ b/chargebee/models/credit_note.py @@ -36,6 +36,17 @@ class Allocation(Model): "round_off_amount", "fractional_correction", "line_items", "discounts", "line_item_discounts", \ "line_item_tiers", "taxes", "line_item_taxes", "linked_refunds", "allocations", "deleted"] + sub_types = { + 'line_items': LineItem, + 'discounts': Discount, + 'line_item_discounts': LineItemDiscount, + 'line_item_tiers' : LineItemTier, + 'taxes': Tax, + 'line_item_taxes': LineItemTax, + 'linked_refunds': LinkedRefund, + 'allocations': Allocation, + } + @staticmethod def create(params, env=None, headers=None): diff --git a/chargebee/models/customer.py b/chargebee/models/customer.py index 80a9cb3..a60bc1d 100644 --- a/chargebee/models/customer.py +++ b/chargebee/models/customer.py @@ -35,6 +35,14 @@ class Relationship(Model): "registered_for_gst", "business_customer_without_vat_number", "customer_type", "client_profile_id", \ "relationship"] + sub_types = { + 'billing_address': BillingAddress, + 'referral_urls': ReferralUrl, + 'contacts': Contact, + 'payment_method': PaymentMethod, + 'balances': Balance, + 'relationship': Relationship, + } @staticmethod def create(params=None, env=None, headers=None): diff --git a/chargebee/models/estimate.py b/chargebee/models/estimate.py index 657d221..7be5dad 100644 --- a/chargebee/models/estimate.py +++ b/chargebee/models/estimate.py @@ -3,11 +3,26 @@ from chargebee import request from chargebee import APIError +from chargebee.models.credit_note_estimate import CreditNoteEstimate +from chargebee.models.invoice_estimate import InvoiceEstimate +from chargebee.models.subscription_estimate import SubscriptionEstimate +from chargebee.models.unbilled_charge import UnbilledCharge + + class Estimate(Model): fields = ["created_at", "subscription_estimate", "invoice_estimate", "invoice_estimates", \ "next_invoice_estimate", "credit_note_estimates", "unbilled_charge_estimates"] + dependant_types = { + 'subscription_estimate': SubscriptionEstimate, + 'invoice_estimate': InvoiceEstimate, + 'next_invoice_estimate': InvoiceEstimate, + 'invoice_estimates': (InvoiceEstimate,), + 'credit_note_estimates': (CreditNoteEstimate,), + 'unbilled_charge_estimates': (UnbilledCharge,), + } + @staticmethod def create_subscription(params, env=None, headers=None): diff --git a/chargebee/models/event.py b/chargebee/models/event.py index f5196cb..7486d14 100644 --- a/chargebee/models/event.py +++ b/chargebee/models/event.py @@ -12,6 +12,11 @@ class Webhook(Model): fields = ["id", "occurred_at", "source", "user", "webhook_status", "webhook_failure_reason", \ "webhooks", "event_type", "api_version"] + sub_types = { + 'webhooks': Webhook, + } + + @property def content(self): from chargebee import Content @@ -23,12 +28,12 @@ def deserialize(json_data): webhook_data = json.loads(json_data) except (TypeError, ValueError) as ex: raise Exception("The passed json_data is not JSON formatted . " + ex.message) - + api_version = webhook_data.get('api_version', None) env_version = Environment.API_VERSION - if api_version != None and api_version.upper() != env_version.upper(): + if api_version != None and api_version.upper() != env_version.upper(): raise Exception("API version [" + api_version.upper() + "] in response does not match " - + "with client library API version [" + env_version.upper() + "]") + + "with client library API version [" + env_version.upper() + "]") return Event.construct(webhook_data) @staticmethod diff --git a/chargebee/models/export.py b/chargebee/models/export.py index ce2cc73..e9f34e0 100644 --- a/chargebee/models/export.py +++ b/chargebee/models/export.py @@ -9,6 +9,11 @@ class Download(Model): pass fields = ["id", "operation_type", "mime_type", "status", "created_at", "download"] + + sub_types = { + 'download': Download, + } + def wait_for_export_completion(self): return wait_for_export_completion() diff --git a/chargebee/models/gift.py b/chargebee/models/gift.py index e24cd1b..5fa50d9 100644 --- a/chargebee/models/gift.py +++ b/chargebee/models/gift.py @@ -17,6 +17,11 @@ class GiftTimeline(Model): fields = ["id", "status", "scheduled_at", "auto_claim", "no_expiry", "claim_expiry_date", \ "resource_version", "updated_at", "gifter", "gift_receiver", "gift_timelines"] + sub_types = { + 'gifter': Gifter, + 'gift_receiver': GiftReceiver, + 'gift_timelines': GiftTimeline, + } @staticmethod def create(params, env=None, headers=None): diff --git a/chargebee/models/invoice.py b/chargebee/models/invoice.py index eef7550..3ea5517 100644 --- a/chargebee/models/invoice.py +++ b/chargebee/models/invoice.py @@ -62,6 +62,24 @@ class BillingAddress(Model): "linked_payments", "dunning_attempts", "applied_credits", "adjustment_credit_notes", "issued_credit_notes", \ "linked_orders", "notes", "shipping_address", "billing_address", "payment_owner", "deleted"] + sub_types = { + 'line_items': LineItem, + 'discounts': Discount, + 'line_item_discounts': LineItemDiscount, + 'taxes': Tax, + 'line_item_taxes': LineItemTax, + 'line_item_tiers': LineItemTier, + 'linked_payments': LinkedPayment, + 'dunning_attempts': DunningAttempt, + 'applied_credits': AppliedCredit, + 'adjustment_credit_notes': AdjustmentCreditNote, + 'issued_credit_notes': IssuedCreditNote, + 'linked_orders': LinkedOrder, + 'notes': Note, + 'shipping_address': ShippingAddress, + 'billing_address': BillingAddress, + } + @staticmethod def create(params, env=None, headers=None): diff --git a/chargebee/models/invoice_estimate.py b/chargebee/models/invoice_estimate.py index d54a2df..cbf4cc3 100644 --- a/chargebee/models/invoice_estimate.py +++ b/chargebee/models/invoice_estimate.py @@ -27,3 +27,11 @@ class LineItemDiscount(Model): "amount_paid", "amount_due", "line_items", "discounts", "taxes", "line_item_taxes", "line_item_tiers", \ "line_item_discounts", "round_off_amount", "customer_id"] + sub_types = { + 'line_items': LineItem, + 'discounts': Discount, + 'taxes': Tax, + 'line_item_taxes': LineItemTax, + 'line_item_tiers' : LineItemTier, + 'line_item_discounts': LineItemDiscount, + } diff --git a/chargebee/models/order.py b/chargebee/models/order.py index ae9547a..b3db7c6 100644 --- a/chargebee/models/order.py +++ b/chargebee/models/order.py @@ -33,6 +33,14 @@ class LinkedCreditNote(Model): "line_item_taxes", "line_item_discounts", "linked_credit_notes", "deleted", "currency_code", \ "is_gifted", "gift_note", "gift_id"] + sub_types = { + 'order_line_items': OrderLineItem, + 'shipping_address': ShippingAddress, + 'billing_address': BillingAddress, + 'line_item_taxes': LineItemTax, + 'line_item_discounts': LineItemDiscount, + 'linked_credit_notes': LinkedCreditNote, + } @staticmethod def create(params, env=None, headers=None): diff --git a/chargebee/models/payment_intent.py b/chargebee/models/payment_intent.py index fef94e8..f2726e6 100644 --- a/chargebee/models/payment_intent.py +++ b/chargebee/models/payment_intent.py @@ -11,6 +11,9 @@ class PaymentAttempt(Model): fields = ["id", "status", "currency_code", "amount", "gateway_account_id", "expires_at", \ "reference_id", "created_at", "modified_at", "customer_id", "gateway", "active_payment_attempt"] + sub_types = { + 'payment_attempt': PaymentAttempt, + } @staticmethod def create(params, env=None, headers=None): diff --git a/chargebee/models/payment_source.py b/chargebee/models/payment_source.py index 9c73f7f..84c2b87 100644 --- a/chargebee/models/payment_source.py +++ b/chargebee/models/payment_source.py @@ -22,6 +22,13 @@ class Paypal(Model): "reference_id", "status", "gateway", "gateway_account_id", "ip_address", "issuing_country", \ "card", "bank_account", "amazon_payment", "paypal", "deleted"] + sub_types = { + 'card': Card, + 'bank_account': BankAccount, + 'amazon_payment': AmazonPayment, + 'paypal': Paypal, + } + @staticmethod def create_using_temp_token(params, env=None, headers=None): diff --git a/chargebee/models/plan.py b/chargebee/models/plan.py index 06c48bb..f33fa34 100644 --- a/chargebee/models/plan.py +++ b/chargebee/models/plan.py @@ -27,6 +27,12 @@ class EventBasedAddon(Model): "invoice_notes", "taxable", "tax_profile_id", "meta_data", "tiers", "applicable_addons", "attached_addons", \ "event_based_addons"] + sub_types = { + 'tiers': Tier, + 'applicable_addons': ApplicableAddon, + 'attached_addons': AttachedAddon, + 'event_based_addons': EventBasedAddon, + } @staticmethod def create(params, env=None, headers=None): diff --git a/chargebee/models/portal_session.py b/chargebee/models/portal_session.py index 887b7ff..0b740c6 100644 --- a/chargebee/models/portal_session.py +++ b/chargebee/models/portal_session.py @@ -11,6 +11,10 @@ class LinkedCustomer(Model): fields = ["id", "token", "access_url", "redirect_url", "status", "created_at", "expires_at", \ "customer_id", "login_at", "logout_at", "login_ipaddress", "logout_ipaddress", "linked_customers"] + sub_types = { + 'linked_customers': LinkedCustomer + } + @staticmethod def create(params, env=None, headers=None): diff --git a/chargebee/models/quote.py b/chargebee/models/quote.py index 225897a..67864c7 100644 --- a/chargebee/models/quote.py +++ b/chargebee/models/quote.py @@ -32,6 +32,15 @@ class BillingAddress(Model): "discounts", "line_item_discounts", "taxes", "line_item_taxes", "notes", "shipping_address", \ "billing_address"] + sub_types = { + 'line_items': LineItem, + 'discounts': Discount, + 'line_item_discounts': LineItemDiscount, + 'taxes': Tax, + 'line_item_taxes': LineItemTax, + 'shipping_address': ShippingAddress, + 'billing_address': BillingAddress, + } @staticmethod def retrieve(id, env=None, headers=None): diff --git a/chargebee/models/subscription.py b/chargebee/models/subscription.py index fc41eae..99d19e1 100644 --- a/chargebee/models/subscription.py +++ b/chargebee/models/subscription.py @@ -34,6 +34,15 @@ class ReferralInfo(Model): "base_currency_code", "addons", "event_based_addons", "charged_event_based_addons", "coupon", \ "coupons", "shipping_address", "referral_info", "invoice_notes", "meta_data", "deleted"] + sub_types = { + 'addons': Addon, + 'event_based_addons' : EventBasedAddon, + 'charged_event_based_addons' : ChargedEventBasedAddon, + 'coupons': Coupon, + 'shipping_address': ShippingAddress, + 'referral_info': ReferralInfo, + } + @staticmethod def create(params, env=None, headers=None): diff --git a/chargebee/models/subscription_estimate.py b/chargebee/models/subscription_estimate.py index cecaeb4..1ed3044 100644 --- a/chargebee/models/subscription_estimate.py +++ b/chargebee/models/subscription_estimate.py @@ -12,3 +12,6 @@ class ShippingAddress(Model): fields = ["id", "currency_code", "status", "next_billing_at", "pause_date", "resume_date", \ "shipping_address"] + sub_types = { + 'shipping_address': ShippingAddress, + } diff --git a/chargebee/models/transaction.py b/chargebee/models/transaction.py index 69c12bf..f2aaf5e 100644 --- a/chargebee/models/transaction.py +++ b/chargebee/models/transaction.py @@ -25,6 +25,13 @@ class LinkedPayment(Model): "amount_capturable", "reversal_transaction_id", "linked_invoices", "linked_credit_notes", "linked_refunds", \ "linked_payments", "deleted"] + sub_types = { + 'linked_invoices': LinkedInvoice, + 'linked_credit_notes': LinkedCreditNote, + 'linked_refunds': LinkedRefund, + 'linked_payments': LinkedPayment, + } + @staticmethod def create_authorization(params, env=None, headers=None): diff --git a/chargebee/models/unbilled_charge.py b/chargebee/models/unbilled_charge.py index 88033ee..b2c7a55 100644 --- a/chargebee/models/unbilled_charge.py +++ b/chargebee/models/unbilled_charge.py @@ -12,6 +12,9 @@ class Tier(Model): "pricing_model", "quantity", "amount", "currency_code", "discount_amount", "description", "entity_type", \ "entity_id", "is_voided", "voided_at", "tiers", "deleted"] + sub_types = { + 'tiers' : Tier, + } @staticmethod def invoice_unbilled_charges(params=None, env=None, headers=None): diff --git a/chargebee/result.py b/chargebee/result.py index 04b1aad..3db512b 100644 --- a/chargebee/result.py +++ b/chargebee/result.py @@ -10,14 +10,12 @@ def __init__(self, response): @property def subscription(self): - subscription = self._get('subscription', Subscription, - {'addons' : Subscription.Addon, 'event_based_addons' : Subscription.EventBasedAddon, 'charged_event_based_addons' : Subscription.ChargedEventBasedAddon, 'coupons' : Subscription.Coupon, 'shipping_address' : Subscription.ShippingAddress, 'referral_info' : Subscription.ReferralInfo}); + subscription = self._get('subscription', Subscription) return subscription; @property def customer(self): - customer = self._get('customer', Customer, - {'billing_address' : Customer.BillingAddress, 'referral_urls' : Customer.ReferralUrl, 'contacts' : Customer.Contact, 'payment_method' : Customer.PaymentMethod, 'balances' : Customer.Balance, 'relationship' : Customer.Relationship}); + customer = self._get('customer', Customer) return customer; @property @@ -37,13 +35,12 @@ def token(self): @property def payment_source(self): - payment_source = self._get('payment_source', PaymentSource, - {'card' : PaymentSource.Card, 'bank_account' : PaymentSource.BankAccount, 'amazon_payment' : PaymentSource.AmazonPayment, 'paypal' : PaymentSource.Paypal}); + payment_source = self._get('payment_source', PaymentSource) return payment_source; @property def third_party_payment_method(self): - third_party_payment_method = self._get('third_party_payment_method', ThirdPartyPaymentMethod); + third_party_payment_method = self._get('third_party_payment_method', ThirdPartyPaymentMethod) return third_party_payment_method; @property @@ -53,207 +50,173 @@ def virtual_bank_account(self): @property def card(self): - card = self._get('card', Card); + card = self._get('card', Card) return card; @property def promotional_credit(self): - promotional_credit = self._get('promotional_credit', PromotionalCredit); + promotional_credit = self._get('promotional_credit', PromotionalCredit) return promotional_credit; @property def invoice(self): - invoice = self._get('invoice', Invoice, - {'line_items' : Invoice.LineItem, 'discounts' : Invoice.Discount, 'line_item_discounts' : Invoice.LineItemDiscount, 'taxes' : Invoice.Tax, 'line_item_taxes' : Invoice.LineItemTax, 'line_item_tiers' : Invoice.LineItemTier, 'linked_payments' : Invoice.LinkedPayment, 'dunning_attempts' : Invoice.DunningAttempt, 'applied_credits' : Invoice.AppliedCredit, 'adjustment_credit_notes' : Invoice.AdjustmentCreditNote, 'issued_credit_notes' : Invoice.IssuedCreditNote, 'linked_orders' : Invoice.LinkedOrder, 'notes' : Invoice.Note, 'shipping_address' : Invoice.ShippingAddress, 'billing_address' : Invoice.BillingAddress}); + invoice = self._get('invoice', Invoice) return invoice; @property def credit_note(self): - credit_note = self._get('credit_note', CreditNote, - {'line_items' : CreditNote.LineItem, 'discounts' : CreditNote.Discount, 'line_item_discounts' : CreditNote.LineItemDiscount, 'line_item_tiers' : CreditNote.LineItemTier, 'taxes' : CreditNote.Tax, 'line_item_taxes' : CreditNote.LineItemTax, 'linked_refunds' : CreditNote.LinkedRefund, 'allocations' : CreditNote.Allocation}); + credit_note = self._get('credit_note', CreditNote) return credit_note; @property def unbilled_charge(self): - unbilled_charge = self._get('unbilled_charge', UnbilledCharge, - {'tiers' : UnbilledCharge.Tier}); + unbilled_charge = self._get('unbilled_charge', UnbilledCharge) return unbilled_charge; @property def order(self): - order = self._get('order', Order, - {'order_line_items' : Order.OrderLineItem, 'shipping_address' : Order.ShippingAddress, 'billing_address' : Order.BillingAddress, 'line_item_taxes' : Order.LineItemTax, 'line_item_discounts' : Order.LineItemDiscount, 'linked_credit_notes' : Order.LinkedCreditNote}); + order = self._get('order', Order) return order; @property def gift(self): - gift = self._get('gift', Gift, - {'gifter' : Gift.Gifter, 'gift_receiver' : Gift.GiftReceiver, 'gift_timelines' : Gift.GiftTimeline}); + gift = self._get('gift', Gift) return gift; @property def transaction(self): - transaction = self._get('transaction', Transaction, - {'linked_invoices' : Transaction.LinkedInvoice, 'linked_credit_notes' : Transaction.LinkedCreditNote, 'linked_refunds' : Transaction.LinkedRefund, 'linked_payments' : Transaction.LinkedPayment}); + transaction = self._get('transaction', Transaction) return transaction; @property def hosted_page(self): - hosted_page = self._get('hosted_page', HostedPage); + hosted_page = self._get('hosted_page', HostedPage) return hosted_page; @property def estimate(self): - estimate = self._get('estimate', Estimate, {}, - {'subscription_estimate' : SubscriptionEstimate, 'invoice_estimate' : InvoiceEstimate, 'invoice_estimates' : InvoiceEstimate, 'next_invoice_estimate' : InvoiceEstimate, 'credit_note_estimates' : CreditNoteEstimate, 'unbilled_charge_estimates' : UnbilledCharge}); - estimate.init_dependant(self._response['estimate'], 'subscription_estimate', - {'shipping_address' : SubscriptionEstimate.ShippingAddress}); - estimate.init_dependant(self._response['estimate'], 'invoice_estimate', - {'line_items' : InvoiceEstimate.LineItem, 'discounts' : InvoiceEstimate.Discount, 'taxes' : InvoiceEstimate.Tax, 'line_item_taxes' : InvoiceEstimate.LineItemTax, 'line_item_tiers' : InvoiceEstimate.LineItemTier, 'line_item_discounts' : InvoiceEstimate.LineItemDiscount}); - estimate.init_dependant(self._response['estimate'], 'next_invoice_estimate', - {'line_items' : InvoiceEstimate.LineItem, 'discounts' : InvoiceEstimate.Discount, 'taxes' : InvoiceEstimate.Tax, 'line_item_taxes' : InvoiceEstimate.LineItemTax, 'line_item_tiers' : InvoiceEstimate.LineItemTier, 'line_item_discounts' : InvoiceEstimate.LineItemDiscount}); - estimate.init_dependant_list(self._response['estimate'], 'invoice_estimates', - {'line_items' : InvoiceEstimate.LineItem, 'discounts' : InvoiceEstimate.Discount, 'taxes' : InvoiceEstimate.Tax, 'line_item_taxes' : InvoiceEstimate.LineItemTax, 'line_item_tiers' : InvoiceEstimate.LineItemTier, 'line_item_discounts' : InvoiceEstimate.LineItemDiscount}); - estimate.init_dependant_list(self._response['estimate'], 'credit_note_estimates', - {'line_items' : CreditNoteEstimate.LineItem, 'discounts' : CreditNoteEstimate.Discount, 'taxes' : CreditNoteEstimate.Tax, 'line_item_taxes' : CreditNoteEstimate.LineItemTax, 'line_item_discounts' : CreditNoteEstimate.LineItemDiscount, 'line_item_tiers' : CreditNoteEstimate.LineItemTier}); - estimate.init_dependant_list(self._response['estimate'], 'unbilled_charge_estimates', - {'tiers' : UnbilledCharge.Tier}); + estimate = self._get('estimate', Estimate) return estimate; @property def quote(self): - quote = self._get('quote', Quote, - {'line_items' : Quote.LineItem, 'discounts' : Quote.Discount, 'line_item_discounts' : Quote.LineItemDiscount, 'taxes' : Quote.Tax, 'line_item_taxes' : Quote.LineItemTax, 'shipping_address' : Quote.ShippingAddress, 'billing_address' : Quote.BillingAddress}); + quote = self._get('quote', Quote) return quote; @property def plan(self): - plan = self._get('plan', Plan, - {'tiers' : Plan.Tier, 'applicable_addons' : Plan.ApplicableAddon, 'attached_addons' : Plan.AttachedAddon, 'event_based_addons' : Plan.EventBasedAddon}); + plan = self._get('plan', Plan) return plan; @property def addon(self): - addon = self._get('addon', Addon, - {'tiers' : Addon.Tier}); + addon = self._get('addon', Addon) return addon; @property def coupon(self): - coupon = self._get('coupon', Coupon); + coupon = self._get('coupon', Coupon) return coupon; @property def coupon_set(self): - coupon_set = self._get('coupon_set', CouponSet); + coupon_set = self._get('coupon_set', CouponSet) return coupon_set; @property def coupon_code(self): - coupon_code = self._get('coupon_code', CouponCode); + coupon_code = self._get('coupon_code', CouponCode) return coupon_code; @property def address(self): - address = self._get('address', Address); + address = self._get('address', Address) return address; @property def event(self): - event = self._get('event', Event, - {'webhooks' : Event.Webhook}); + event = self._get('event', Event) return event; @property def comment(self): - comment = self._get('comment', Comment); + comment = self._get('comment', Comment) return comment; @property def download(self): - download = self._get('download', Download); + download = self._get('download', Download) return download; @property def portal_session(self): - portal_session = self._get('portal_session', PortalSession, - {'linked_customers' : PortalSession.LinkedCustomer}); + portal_session = self._get('portal_session', PortalSession) return portal_session; @property def site_migration_detail(self): - site_migration_detail = self._get('site_migration_detail', SiteMigrationDetail); + site_migration_detail = self._get('site_migration_detail', SiteMigrationDetail) return site_migration_detail; @property def resource_migration(self): - resource_migration = self._get('resource_migration', ResourceMigration); + resource_migration = self._get('resource_migration', ResourceMigration) return resource_migration; @property def time_machine(self): - time_machine = self._get('time_machine', TimeMachine); + time_machine = self._get('time_machine', TimeMachine) return time_machine; @property def export(self): - export = self._get('export', Export, - {'download' : Export.Download}); + export = self._get('export', Export) return export; @property def payment_intent(self): - payment_intent = self._get('payment_intent', PaymentIntent, - {'payment_attempt' : PaymentIntent.PaymentAttempt}); + payment_intent = self._get('payment_intent', PaymentIntent) return payment_intent; - @property def unbilled_charges(self): - unbilled_charges = self._get_list('unbilled_charges', UnbilledCharge, - {'tiers' : UnbilledCharge.Tier}); + unbilled_charges = self._get_list('unbilled_charges', UnbilledCharge) return unbilled_charges; @property def credit_notes(self): - credit_notes = self._get_list('credit_notes', CreditNote, - {'line_items' : CreditNote.LineItem, 'discounts' : CreditNote.Discount, 'line_item_discounts' : CreditNote.LineItemDiscount, 'line_item_tiers' : CreditNote.LineItemTier, 'taxes' : CreditNote.Tax, 'line_item_taxes' : CreditNote.LineItemTax, 'linked_refunds' : CreditNote.LinkedRefund, 'allocations' : CreditNote.Allocation}); + credit_notes = self._get_list('credit_notes', CreditNote) return credit_notes; @property def hierarchies(self): - hierarchies = self._get_list('hierarchies', Hierarchy, - {}); + hierarchies = self._get_list('hierarchies', Hierarchy) return hierarchies; @property def invoices(self): - invoices = self._get_list('invoices', Invoice, - {'line_items' : Invoice.LineItem, 'discounts' : Invoice.Discount, 'line_item_discounts' : Invoice.LineItemDiscount, 'taxes' : Invoice.Tax, 'line_item_taxes' : Invoice.LineItemTax, 'line_item_tiers' : Invoice.LineItemTier, 'linked_payments' : Invoice.LinkedPayment, 'dunning_attempts' : Invoice.DunningAttempt, 'applied_credits' : Invoice.AppliedCredit, 'adjustment_credit_notes' : Invoice.AdjustmentCreditNote, 'issued_credit_notes' : Invoice.IssuedCreditNote, 'linked_orders' : Invoice.LinkedOrder, 'notes' : Invoice.Note, 'shipping_address' : Invoice.ShippingAddress, 'billing_address' : Invoice.BillingAddress}); + invoices = self._get_list('invoices', Invoice) return invoices; - - def _get_list(self, type, cls, sub_types={}, dependant_types={}, dependant_sub_types={}): + def _get_list(self, type, cls): if not type in self._response: return None set_val = [] for obj in self._response[type]: if isinstance(obj, dict): - model = cls.construct(obj, sub_types, dependant_types) - for k in dependant_sub_types: - model.init_dependant(obj, k, dependant_sub_types[k]) + model = cls.construct(obj) set_val.append(model) self._response_obj[type] = set_val return self._response_obj[type] - def _get(self, type, cls, sub_types=None, dependant_types=None): + def _get(self, type, cls): if not type in self._response: return None if not type in self._response_obj: - self._response_obj[type] = cls.construct(self._response[type], sub_types, dependant_types) + self._response_obj[type] = cls.construct(self._response[type]) return self._response_obj[type] @@ -263,5 +226,6 @@ def __str__(self): def __repr__(self): return "".format(";".join(self._response.keys())) + class Content(Result): pass diff --git a/tests/result.py b/tests/result.py new file mode 100644 index 0000000..8fbd4f2 --- /dev/null +++ b/tests/result.py @@ -0,0 +1,120 @@ +import unittest + +from chargebee import Customer, Estimate +from chargebee.models.invoice_estimate import InvoiceEstimate + + +class ResultTest(unittest.TestCase): + def test_construct(self): + values = { + "id": "XpbGElGQgEIrbF77", + "first_name": "John", + "last_name": "Doe", + "email": "john@test.com", + "auto_collection": "on", + "net_term_days": 0, + "allow_direct_debit": False, + "created_at": 1515495068, + "taxability": "taxable", + "updated_at": 1515495068, + "locale": "fr-CA", + "resource_version": 1515495068000, + "deleted": False, + "object": "customer", + "billing_address": { + "first_name": "John", + "last_name": "Doe", + "line1": "PO Box 9999", + "city": "Walnut", + "state_code": "CA", + "state": "California", + "country": "US", + "zip": "91789", + "validation_status": "not_validated", + "object": "billing_address" + }, + "contacts": [ + { + "id": "ty68op521m", + "first_name": "Michel", + "last_name": "Ross", + "email": "Mike@test.com", + "label": "Mike", + "enabled": True, + "send_account_email": True, + "send_billing_email": False, + "object": "contact" + }, + ], + "card_status": "no_card", + "promotional_credits": 0, + "refundable_credits": 0, + "excess_payments": 0, + "unbilled_charges": 0, + "preferred_currency_code": "USD" + } + + cust = Customer.construct(values) + + self.assertIsInstance(cust, Customer) + self.assertIsInstance(cust.billing_address, Customer.BillingAddress) + self.assertEqual(cust.billing_address.zip, "91789") + + self.assertIsNone(cust.payment_method) + + self.assertIsInstance(cust.contacts, list) + self.assertIsInstance(cust.contacts[0], Customer.Contact) + self.assertEqual(cust.contacts[0].id, "ty68op521m") + + def test_dependent_sub_types(self): + values = { + "created_at": 1515494922, + "object": "estimate", + "invoice_estimates": [ + { + "recurring": True, + "price_type": "tax_exclusive", + "sub_total": 2000, + "total": 2000, + "credits_applied": 0, + "amount_paid": 0, + "amount_due": 2000, + "object": "invoice_estimate", + "line_items": [ + { + "id": "li_XpbGElGQgEIDTu2k", + "date_from": 1515494914, + "date_to": 1518173314, + "unit_amount": 1000, + "quantity": 1, + "is_taxed": False, + "tax_amount": 0, + "object": "line_item", + "subscription_id": "addams", + "amount": 1000, + "description": "No Trial", + "entity_type": "plan", + "entity_id": "no_trial", + "discount_amount": 0, + "item_level_discount_amount": 0 + }, + ], + "taxes": [], + "line_item_taxes": [], + "currency_code": "USD", + "line_item_discounts": [] + }, + ] + } + estimate = Estimate.construct(values) + + self.assertIsInstance(estimate, Estimate) + self.assertIsInstance(estimate.invoice_estimates, list) + + inv_estimate = estimate.invoice_estimates[0] + self.assertIsInstance(inv_estimate, InvoiceEstimate) + self.assertEqual(inv_estimate.currency_code, "USD") + + self.assertIsInstance(inv_estimate.line_items, list) + self.assertIsInstance(inv_estimate.line_items[0], InvoiceEstimate.LineItem) + self.assertEqual(inv_estimate.line_items[0].id, "li_XpbGElGQgEIDTu2k")