public
Description: The Python interface to the FreshBooks API
Homepage:
Clone URL: git://github.com/mattc58/freshbooks.py.git
freshbooks.py / freshbooks.py
100755 644 lines (541 sloc) 21.275 kb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
"""
freshbooks.py - Python interface to the FreshBooks API (http://developers.freshbooks.com)
 
Library Maintainer:
Matt Culbreth
mattculbreth@gmail.com
http://mattculbreth.com
 
#####################################################################
 
This work is distributed under an MIT License:
http://www.opensource.org/licenses/mit-license.php
 
The MIT License
 
Copyright (c) 2008 Matt Culbreth (http://mattculbreth.com)
 
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
 
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
 
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
 
#####################################################################
 
Hello, this is an open source Python library that serves as an interface to FreshBooks.
The code is heavily based on the existing Ruby implementation
by Ben Vinegar of the same interface:
http://freshbooks.rubyforge.org/
USAGE:
 
import freshbooks
freshbooks.setup('YOU.freshbooks.com', '<YOUR AUTH TOKEN>')
clients = freshbooks.Client.list()
client_1 = freshbooks.Client.get(<client_id>)
"""
 
import sys, os, datetime
import urllib, urllib2
import xml.dom.minidom as xml_lib
 
# module level constants
VERSION = '0.5' # Library version
API_VERSION = '2.1' # FreshBooks API version
SERVICE_URL = "/api/%s/xml-in" % API_VERSION
 
# module level variables
account_url = None
account_name = None
auth_token = None
user_agent = None
request_headers = None
last_response = None
 
def setup(url, token, user_agent_name=None, headers={}):
    '''
This funtion sets the high level variables for use in the interface.
'''
    global account_url, account_name, auth_token, user_agent, request_headers
    
    account_url = url
    if url.find('//') == -1:
        account_name = url[:(url.find('freshbooks.com') - 1)]
    else:
        account_name = url[(url.find('//') + 2):(url.find('freshbooks.com') - 1)]
    auth_token = token
    user_agent = user_agent_name
    request_headers = headers
    if 'user-agent' not in [x.lower() for x in request_headers.keys()]:
        if not user_agent:
            user_agent = 'Python:%s' % account_name
        request_headers['User-Agent'] = user_agent
    
# these three classes are for typed exceptions
class InternalError(Exception):
    pass
    
class AuthenticationError(Exception):
    pass
    
class UnknownSystemError(Exception):
    pass
    
class InvalidParameterError(Exception):
    pass
 
 
def call_api(method, elems = []):
    '''
This function calls into the FreshBooks API and returns the Response
'''
    global last_response
    
    # make the request, which is an XML document
    doc = xml_lib.Document()
    request = doc.createElement('request')
    request.setAttribute('method', method)
    for key, value in elems.items():
        if isinstance(value, BaseObject):
            request.appendChild(value.to_xml())
        else:
            e = doc.createElement(key)
            e.appendChild(doc.createTextNode(str(value)))
            request.appendChild(e)
    doc.appendChild(request)
            
    # send it
    result = post(doc.toxml('utf-8'))
    last_response = Response(result)
    
    # check for failure and throw an exception
    if not last_response.success:
        msg = last_response.error_message
        if not msg:
            raise Exception("Error in response: %s" % last_response.doc.toxml())
        if 'not formatted correctly' in msg:
            raise InternalError(msg)
        elif 'uthentication failed' in msg:
            raise AuthenticationError(msg)
        elif 'does not exit' in msg:
            raise UnknownSystemError(msg)
        elif 'Invalid parameter' in msg:
            raise InvalidParameterError(msg)
        else:
            raise Exception(msg)
            
    return last_response
    
def post(body):
    '''
This function actually communicates with the FreshBooks API
'''
    
    # setup HTTP basic authentication
    password_mgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
    url = ""
    if account_url.find('//') == -1:
        url = "https://"
    url += account_url + SERVICE_URL
    password_mgr.add_password(None, url, auth_token, '')
    handler = urllib2.HTTPBasicAuthHandler(password_mgr)
    opener = urllib2.build_opener(handler)
    urllib2.install_opener(opener)
    
    # make the request and return the response body
    request = urllib2.Request(url, body, request_headers)
    response = urllib2.urlopen(request)
    response_content = response.read()
    return response_content
 
class Response(object):
    '''
A response from FreshBooks
'''
    def __init__(self, xml_raw):
        '''
The constructor, taking in the xml as the source
'''
        self._doc = xml_lib.parseString(xml_raw)
        
    def __repr__(self):
        '''
Print the Response and show the XML document
'''
        s = "Response: success: %s, error_message: %s" % \
            (self.success,self.error_message)
        s += "\nResponse Document: \n%s" % self.doc.toxml()
        return s
      
    @property
    def doc(self):
        '''
Return the document
'''
        return self._doc
    
    @property
    def elements(self):
        '''
Return the doc's elements
'''
        return self._doc.childNodes
       
    @property
    def success(self):
        '''
return True if this is a successful response from the API
'''
        return self._doc.firstChild.attributes['status'].firstChild.nodeValue == 'ok'
    
    @property
    def error_message(self):
        '''
returns the error message associated with this API response
'''
        error = self._doc.getElementsByTagName('error')
        if error:
            return error[0].childNodes[0].nodeValue
        else:
            return None
            
class BaseObject(object):
    '''
This serves as the base object for all FreshBooks objects.
'''
    
    # this is used to provide typing help for certain type, ie
    # client.id is an int
    TYPE_MAPPINGS = {}
    
    # anonymous functions to do the conversions on type
    MAPPING_FUNCTIONS = {
        'int' : lambda val: int(val),
        'float' : lambda val: float(val),
        'bool' : lambda val: bool(int(val)) if val in ('0', '1') else val,
        'datetime' : lambda val: \
            datetime.datetime.strptime(val,
            '%Y-%m-%d %H:%M:%S') if (val != '0000-00-00 00:00:00' and len(val) == 19) else datetime.datetime.strptime(val, '%Y-%m-%d') if len(val) == 10 else val
    }
 
    @classmethod
    def _new_from_xml(cls, element):
        '''
This internal method is used to create a new FreshBooks
object from the XML.
'''
        obj = cls()
        
        # basically just go through the XML creating attributes on the
        # object.
        for elem in [node for node in element.childNodes if node.nodeType == node.ELEMENT_NODE]:
            val = None
            if elem.firstChild:
                val = elem.firstChild.nodeValue
                # HACK: find another way to detect arrays, probably
                # based on a list of elements instead of a textnode
                if elem.nodeName == 'lines':
                    val = []
                    for item in [node for node in elem.childNodes if node.nodeType == node.ELEMENT_NODE]:
                        c = eval(item.nodeName.capitalize())
                        if c:
                            val.append(c._new_from_xml(item))
                        
                # if there is typing information supplied by
                # the child class then use that
                elif cls.TYPE_MAPPINGS.has_key(elem.nodeName):
                    val = \
                        cls.MAPPING_FUNCTIONS[\
                            cls.TYPE_MAPPINGS[elem.nodeName]](val)
            setattr(obj, elem.nodeName, val)
            
        return obj
        
    @classmethod
    def get(cls, object_id, element_name = None):
        '''
Get a single object from the API
'''
        resp = call_api('%s.get' % cls.object_name, {'%s_id' % cls.object_name : object_id})
 
        if resp.success:
            items = resp.doc.getElementsByTagName(element_name or cls.object_name)
            if items:
                return cls._new_from_xml(items[0])
 
        return None
        
    @classmethod
    def list(cls, options = {}, element_name = None, get_all=False):
        '''
Get a summary list of this object.
If get_all is True then the paging will be checked to get all of the items.
'''
        result = None
        if get_all:
            options['per_page'] = 100
            options['page'] = 1
            objects = []
            while True:
                resp = call_api('%s.list' % cls.object_name, options)
                if not resp.success:
                    return result
                new_objects = resp.doc.getElementsByTagName(element_name or cls.object_name)
                objects.extend(new_objects)
                if len(new_objects) < options['per_page']:
                    break
                options['page'] += 1
            result = [cls._new_from_xml(elem) for elem in objects]
        else:
            resp = call_api('%s.list' % cls.object_name, options)
            if (resp.success):
                result = [cls._new_from_xml(elem) for elem in \
                    resp.doc.getElementsByTagName(element_name or cls.object_name)]
 
        return result
        
        
    def to_xml(self, doc, element_name=None):
        '''
Create an XML representation of the object for use
in sending to FreshBooks
'''
        # The root element is the class name, downcased
        element_name = element_name or \
            self.object_name.lower()
        root = doc.createElement(element_name)
        
        # Add each member to the root element
        for key, value in self.__dict__.items():
            if isinstance(value, list):
                array = doc.createElement(key)
                for item in value:
                    item_name = 'line' if key == 'lines' else key[:-1]
                    array_item = doc.createElement(item_name)
                    array_item.appendChild(doc.createTextNode(str(item)))
                root.append(array)
            elif value:
                elem = doc.createElement(key)
                elem.appendChild(doc.createTextNode(str(value)))
                root.appendChild(elem)
        
        return root
    
 
#-----------------------------------------------#
# Client
#-----------------------------------------------#
class Client(BaseObject):
    '''
The Client object
'''
    
    TYPE_MAPPINGS = {'client_id' : 'int'}
    object_name = 'client'
    
    def __init__(self):
        '''
The constructor is where we initially create the
attributes for this class
'''
        # self.object_name = 'client'
        for att in ('client_id', 'first_name', 'last_name', 'organization','email', 'username', 'password', 'work_phone', 'home_phone', 'mobile', 'fax', 'notes', 'p_street1', 'p_street2', 'p_city', 'p_state', 'p_country', 'p_code','s_street1', 's_street2', 's_city', 's_state', 's_country', 's_code', 'url'):
            setattr(self, att, None)
        
  
#-----------------------------------------------#
# Invoice
#-----------------------------------------------#
class Invoice(BaseObject):
    '''
The Invoice object
'''
 
    object_name = 'invoice'
    TYPE_MAPPINGS = {'invoice_id' : 'int', 'client_id' : 'int',
        'po_number' : 'int', 'discount' : 'float', 'amount' : 'float',
        'date' : 'datetime', 'amount_outstanding' : 'float',
        'paid' : 'float'}
 
    def __init__(self):
        '''
The constructor is where we initially create the
attributes for this class
'''
        for att in ('invoice_id', 'client_id', 'number', 'date', 'po_number',
      'terms', 'first_name', 'last_name', 'organization', 'p_street1', 'p_street2',
      'p_city','p_state', 'p_country', 'p_code', 'amount', 'amount_outstanding', 'paid',
      'lines', 'discount', 'status', 'notes', 'url'):
            setattr(self, att, None)
        self.lines = []
        self.links = []
 
        
#-----------------------------------------------#
# Line--really just a part of Invoice
#-----------------------------------------------#
class Line(BaseObject):
    TYPE_MAPPINGS = {'unit_cost' : 'float', 'quantity' : 'float',
        'tax1_percent' : 'float', 'tax2_percent' : 'float', 'amount' : 'float'}
 
    def __init__(self):
        '''
The constructor is where we initially create the
attributes for this class
'''
        for att in ('name', 'description', 'unit_cost', 'quantity', 'tax1_name',
        'tax2_name', 'tax1_percent', 'tax2_percent', 'amount'):
            setattr(self, att, None)
    
    @classmethod
    def get(cls, object_id, element_name = None):
        '''
The Line doesn't do this
'''
        raise NotImplementedError("the Line doesn't support this")
 
    @classmethod
    def list(cls, options = {}, element_name = None):
        '''
The Line doesn't do this
'''
        raise NotImplementedError("the Line doesn't support this")
 
 
#-----------------------------------------------#
# Item
#-----------------------------------------------#
class Item(BaseObject):
    '''
The Item object
'''
 
    object_name = 'item'
    TYPE_MAPPINGS = {'item_id' : 'int', 'unit_cost' : 'float',
        'quantity' : 'int', 'inventory' : 'int'}
 
    def __init__(self):
        '''
The constructor is where we initially create the
attributes for this class
'''
        for att in ('item_id', 'name', 'description', 'unit_cost',
        'quantity', 'inventory'):
            setattr(self, att, None)
 
 
#-----------------------------------------------#
# Payment
#-----------------------------------------------#
class Payment(BaseObject):
    '''
The Payment object
'''
    object_name = 'payment'
    TYPE_MAPPINGS = {'client_id' : 'int', 'invoice_id' : 'int',
        'amount' : 'float', 'date' : 'datetime'}
 
    def __init__(self):
        '''
The constructor is where we initially create the
attributes for this class
'''
        for att in ('payment_id', 'client_id', 'invoice_id', 'date',
        'amount', 'type', 'notes'):
            setattr(self, att, None)
 
 
#-----------------------------------------------#
# Recurring
#-----------------------------------------------#
class Recurring(BaseObject):
    '''
The Recurring object
'''
 
    object_name = 'recurring'
    TYPE_MAPPINGS = {'recurring_id' : 'int', 'client_id' : 'int',
        'po_number' : 'int', 'discount' : 'float', 'amount' : 'float',
        'occurrences' : 'int', 'date' : 'datetime'}
 
    def __init__(self):
        '''
The constructor is where we initially create the
attributes for this class
'''
        for att in ('recurring_id', 'client_id', 'date', 'po_number',
      'terms', 'first_name', 'last_name', 'organization', 'p_street1', 'p_street2', 'p_city','p_state', 'p_country', 'p_code', 'amount', 'lines', 'discount', 'status', 'notes', 'occurrences', 'frequency', 'stopped', 'send_email', 'send_snail_mail'):
            setattr(self, att, None)
        self.lines = []
 
    
#-----------------------------------------------#
# Project
#-----------------------------------------------#
class Project(BaseObject):
    '''
The Project object
'''
    object_name = 'project'
    TYPE_MAPPINGS = {'project_id' : 'int', 'client_id' : 'int',
        'rate' : 'float'}
 
    def __init__(self):
        '''
The constructor is where we initially create the
attributes for this class
'''
        for att in ('project_id', 'client_id', 'name', 'bill_method','rate',
            'description', 'tasks'):
            setattr(self, att, None)
        self.tasks = []
 
 
#-----------------------------------------------#
# Task
#-----------------------------------------------#
class Task(BaseObject):
    '''
The Task object
'''
    object_name = 'task'
    TYPE_MAPPINGS = {'task_id' : 'int', 'rate' : 'float', 'billable' : 'bool'}
 
    def __init__(self):
        '''
The constructor is where we initially create the
attributes for this class
'''
        for att in ('task_id', 'name', 'billable', 'rate',
            'description'):
            setattr(self, att, None)
 
 
#-----------------------------------------------#
# TimeEntry
#-----------------------------------------------#
class TimeEntry(BaseObject):
    '''
The TimeEntry object
'''
    object_name = 'time_entry'
 
    TYPE_MAPPINGS = {'time_entry_id' : 'int', 'project_id' : 'int', 'task_id' : 'int', 'hours' : 'float', 'date' : 'datetime'}
 
    def __init__(self):
        '''
The constructor is where we initially create the
attributes for this class
'''
        for att in ('time_entry_id', 'project_id', 'task_id', 'hours',
            'notes', 'date'):
            setattr(self, att, None)
 
        
#-----------------------------------------------#
# Estimate
#-----------------------------------------------#
class Estimate(BaseObject):
    '''
The Estimate object
'''
    object_name = 'estimate'
    TYPE_MAPPINGS = {'estimate_id' : 'int', 'client_id' : 'int',
        'po_number' : 'int', 'discount' : 'float', 'amount' : 'float',
        'date' : 'datetime'}
 
    def __init__(self):
        '''
The constructor is where we initially create the
attributes for this class
'''
        for att in ('estimate_id', 'client_id', 'status', 'date', 'po_number',
      'terms', 'first_name', 'last_name', 'organization', 'p_street1', 'p_street2', 'p_city','p_state', 'p_country', 'p_code', 'lines', 'discount', 'amount', 'notes'):
            setattr(self, att, None)
        self.lines = []
 
 
#-----------------------------------------------#
# Expense
#-----------------------------------------------#
class Expense(BaseObject):
    '''
The Expense object
'''
    object_name = 'expense'
    TYPE_MAPPINGS = {'expense_id' : 'int', 'staff_id' : 'int',
     'client_id' : 'int', 'category_id' : 'int', 'project_id' : 'int',
        'amount' : 'float', 'date' : 'datetime'}
 
    def __init__(self):
        '''
The constructor is where we initially create the
attributes for this class
'''
        for att in ('expense_id', 'staff_id', 'category_id', 'client_id', 'project_id', 'date', 'amount', 'notes', 'status'):
            setattr(self, att, None)
 
 
#-----------------------------------------------#
# Category
#-----------------------------------------------#
class Category(BaseObject):
    '''
The Category object
'''
 
    object_name = 'category'
    TYPE_MAPPINGS = {'category_id' : 'int', 'tax1' : 'float',
        'tax2' : 'float'}
 
    def __init__(self):
        '''
The constructor is where we initially create the
attributes for this class
'''
        for att in ('category_id', 'name', 'tax1', 'tax2'):
            setattr(self, att, None)
 
#-----------------------------------------------#
# Staff
#-----------------------------------------------#
class Staff(BaseObject):
    '''
The Staff object
'''
    object_name = 'staff'
    TYPE_MAPPINGS = {'staff_id' : 'int', 'rate' : 'float',
        'last_login' : 'datetime',
        'signup_date' : 'datetime'}
 
    def __init__(self):
        '''
The constructor is where we initially create the
attributes for this class
'''
        for att in ('staff_id', 'username', 'first_name', 'last_name',
        'email', 'business_phone', 'mobile_phone', 'rate', 'last_login',
        'number_of_logins', 'signup_date',
        'street1', 'street2', 'city', 'state', 'country', 'code'):
            setattr(self, att, None)
 
    @classmethod
    def list(cls, options = {}, get_all=False):
        '''
Return a list of this object
'''
        return super(Staff, cls).list(options, element_name='member', get_all=get_all)