├── README.md └── quickbooks2.py /README.md: -------------------------------------------------------------------------------- 1 | quickbooks-python 2 | ================= 3 | 4 | This builds on the work of simonv3. 5 | -------------------------------------------------------------------------------- /quickbooks2.py: -------------------------------------------------------------------------------- 1 | from rauth import OAuth1Session, OAuth1Service 2 | import xml.etree.ElementTree as ET 3 | import xmltodict 4 | from xml.dom import minidom 5 | import requests, urllib 6 | import collections, datetime, json, pandas, os, time 7 | import textwrap # for uploading files 8 | 9 | class QuickBooks(): 10 | """A wrapper class around Python's Rauth module for Quickbooks the API""" 11 | 12 | session = None 13 | 14 | base_url_v3 = "https://quickbooks.api.intuit.com/v3" 15 | 16 | request_token_url = "https://oauth.intuit.com/oauth/v1/get_request_token" 17 | access_token_url = "https://oauth.intuit.com/oauth/v1/get_access_token" 18 | authorize_url = "https://appcenter.intuit.com/Connect/Begin" 19 | 20 | # Added for token refreshing (within 30 days of expiry) 21 | # This is known in Intuit's parlance as a "Reconnect" 22 | _attemps_count = 5 23 | _namespace = "http://platform.intuit.com/api/v1" 24 | # See here for more: 25 | # https://developer.intuit.com/v2/docs/0100_accounting/ 26 | # 0060_authentication_and_authorization/oauth_management_api 27 | 28 | # Things needed for authentication 29 | qbService = None 30 | 31 | def __init__(self, **args): 32 | if 'cred_path' in args: 33 | self.read_creds_from_file(args['cred_path']) 34 | 35 | self.consumer_key = args.get('consumer_key', '') 36 | self.consumer_secret = args.get('consumer_secret', '') 37 | self.callback_url = args.get('callback_url', '') 38 | 39 | self.access_token = args.get('access_token', '') 40 | self.access_token_secret = args.get('access_token_secret', '') 41 | 42 | self.request_token = args.get('request_token', '') 43 | self.request_token_secret = args.get('request_token_secret', '') 44 | 45 | self.expires_on = args.get("expire_date", args.get("expires_on")) 46 | 47 | self.verbosity = self.vb = args.get('verbosity', 0) 48 | 49 | if not self.expires_on: 50 | if self.verbosity > 8: 51 | print "No expiration date for this token!?" 52 | import ipdb;ipdb.set_trace() 53 | if isinstance(self.expires_on, (str, unicode)): 54 | self.expires_on = datetime.datetime.strptime( 55 | self.expires_on.replace("-","").replace("/",""), 56 | "%Y%m%d").date() 57 | 58 | self.reconnect_window_days_count = int(args.get( 59 | "reconnect_window_days_count", 30)) 60 | self.acc_token_changed_callback = args.get( 61 | "acc_token_changed_callback", self.default_call_back) 62 | 63 | self.company_id = args.get('company_id', 0) 64 | 65 | self._BUSINESS_OBJECTS = [ 66 | "Account", "Attachable", "Bill", "BillPayment", 67 | "Class", "CompanyInfo", "CreditMemo", "Customer", 68 | "Department", "Deposit", "Employee", "Estimate", "Invoice", 69 | "Item", "JournalEntry", "Payment", "PaymentMethod", 70 | "Preferences", "Purchase", "PurchaseOrder", "RefundReceipt", 71 | #"ReimburseCharge", 72 | "SalesReceipt", "TaxAgency", "TaxCode", "TaxRate", 73 | #"TaxService", 74 | "Term", "TimeActivity", "Transfer", "Vendor", "VendorCredit",] 75 | 76 | self._NAME_LIST_OBJECTS = [ 77 | "Account", "Class", "Customer", "Department", "Employee", "Item", 78 | "PaymentMethod", "TaxCode", "TaxRate", "Term", "Vendor",] 79 | 80 | self._TRANSACTION_OBJECTS = [ 81 | "Bill", "BillPayment", "CreditMemo", "Deposit", 82 | "Estimate", "Invoice", "JournalEntry", "Payment", "Purchase", 83 | #"PurchaseOrder", 84 | #"ReimburseCharge", 85 | "RefundReceipt", "SalesReceipt", 86 | #"TimeActivity", 87 | "Transfer", "VendorCredit",] 88 | 89 | # Sometimes in linked transactions, the API calls txn objects by 90 | # another name 91 | self._biz_object_correctors = { 92 | "Bill" : "Bill", 93 | "Check" : "Purchase", 94 | "CreditCardCredit" : "Purchase", 95 | "Credit Card Credit" : "Purchase", 96 | "Deposit" : "Deposit", 97 | "Expense" : "Purchase", 98 | "Invoice" : "Invoice", 99 | "Journal Entry" : "JournalEntry", 100 | "JournalEntry" : "JournalEntry", 101 | "Payment" : "Payment", 102 | "Vendor Credit" : "VendorCredit", 103 | "VendorCredit" : "VendorCredit", 104 | "CreditMemo" : "CreditMemo",} 105 | 106 | # Before they even had QBO... 107 | self.latest = datetime.datetime( 108 | 1980, 1, 1, 0, 0, 0).isoformat()+"-08:00" 109 | 110 | def _reconnect_if_time(self): 111 | current_date = datetime.date.today() 112 | if not self.expires_on: 113 | return 114 | days_diff = (self.expires_on - current_date).days 115 | if days_diff > 0: 116 | if days_diff <= self.reconnect_window_days_count: 117 | print "Going to reconnect %s..." % self.company_id 118 | if self._reconnect(): 119 | print "Reconnected %s successfully!" % self.company_id 120 | else: 121 | print "For %s:" % self.company_id 122 | print "Unable to reconnect, try again later, you have " \ 123 | "{} days left to do that".format(days_diff) 124 | elif self.verbosity > 4: 125 | #import ipdb;ipdb.set_trace() 126 | print "Days remaining on %s QBO Access Token: %s" % ( 127 | self.company_id, days_diff) 128 | else: 129 | raise Exception("The token is expired, unable to reconnect. " \ 130 | "Please get a new one.") 131 | 132 | def default_call_back(self, access_token, access_token_secret, 133 | company_id, expires_on): 134 | """ 135 | In case the caller of the QuickBooks session doesn't provide a callback 136 | function, new creds (after a reconnect) won't be ENTIRELY lost... 137 | """ 138 | print "NEW CREDENTIALS (POST RECONNECT):" 139 | print "access_token: {}".format(access_token) 140 | print "access_token_secreat: {}".format(access_token_secret) 141 | print "company_id: {}".format(company_id) 142 | print "expires_on: {}".format(expires_on) 143 | raw_input("Press to acknowledge and continue.") 144 | 145 | def _reconnect(self, i=1): 146 | if i > self._attemps_count: 147 | print "Unable to reconnect, there're no attempts left " \ 148 | "({} attempts sent).".format(i) 149 | return False 150 | else: 151 | self._get_session() 152 | resp = self.session.request( 153 | "GET", 154 | "https://appcenter.intuit.com/api/v1/connection/reconnect", 155 | True, 156 | self.company_id, 157 | verify=True 158 | ) 159 | dom = minidom.parseString(ET.tostring(ET.fromstring(resp.content), 160 | "utf-8")) 161 | if resp.status_code == 200: 162 | error_code = int(dom.getElementsByTagNameNS( 163 | self._namespace, "ErrorCode")[0].firstChild.nodeValue) 164 | if error_code == 0: 165 | print "Reconnected successfully" 166 | 167 | date_raw = dom.getElementsByTagNameNS( 168 | self._namespace, "ServerTime")[0].firstChild.nodeValue 169 | from dateutil import parser 170 | added_date = parser.parse(date_raw).date() 171 | self.expires_on = added_date + datetime.timedelta(days=180) 172 | 173 | self.access_token = str(dom.getElementsByTagNameNS( 174 | self._namespace, "OAuthToken")[0].firstChild.nodeValue) 175 | self.access_token_secret = str(dom.getElementsByTagNameNS( 176 | self._namespace, 177 | "OAuthTokenSecret")[0].firstChild.nodeValue) 178 | 179 | if self.verbosity > 9 or \ 180 | not self.acc_token_changed_callback: 181 | print "at, ats, cid, expires_on:" 182 | print self.access_token 183 | print self.access_token_secret 184 | print self.company_id 185 | print self.expires_on 186 | raw_input("Press to continue") 187 | 188 | self.acc_token_changed_callback( 189 | self.access_token, 190 | self.access_token_secret, 191 | self.company_id, 192 | self.expires_on 193 | ) 194 | 195 | return True 196 | 197 | else: 198 | msg = str(dom.getElementsByTagNameNS( 199 | self._namespace, 200 | "ErrorMessage")[0].firstChild.nodeValue) 201 | 202 | print "An error occurred while trying to reconnect, code:" \ 203 | "{}, message: \"{}\"".format(error_code, msg) 204 | 205 | i += 1 206 | 207 | print "Trying to reconnect again... attempt #{}".format(i) 208 | 209 | self._reconnect(i) 210 | else: 211 | print "An HTTP error {} occurred,".format(resp.status_code) \ 212 | + "trying again, attempt #{}".format(i) 213 | 214 | i += 1 215 | self._reconnect(i) 216 | 217 | def _get_session(self): 218 | if not self.session: 219 | self.create_session() # sets self.session... 220 | self._reconnect_if_time() 221 | 222 | return self.session 223 | 224 | def get_authorize_url(self): 225 | """Returns the Authorize URL as returned by QB, 226 | and specified by OAuth 1.0a. 227 | :return URI: 228 | """ 229 | self.qbService = OAuth1Service( 230 | name = None, 231 | consumer_key = self.consumer_key, 232 | consumer_secret = self.consumer_secret, 233 | request_token_url = self.request_token_url, 234 | access_token_url = self.access_token_url, 235 | authorize_url = self.authorize_url, 236 | base_url = None 237 | ) 238 | 239 | rt, rts = self.qbService.get_request_token( 240 | params={'oauth_callback':self.callback_url} 241 | ) 242 | 243 | self.request_token, self.request_token_secret = [rt, rts] 244 | 245 | return self.qbService.get_authorize_url(self.request_token) 246 | 247 | def get_access_tokens(self, oauth_verifier): 248 | """Wrapper around get_auth_session, returns session, and sets 249 | access_token and access_token_secret on the QB Object. 250 | :param oauth_verifier: the oauth_verifier as specified by OAuth 1.0a 251 | """ 252 | session = self.qbService.get_auth_session( 253 | self.request_token, 254 | self.request_token_secret, 255 | data={'oauth_verifier': oauth_verifier}) 256 | 257 | self.access_token = session.access_token 258 | self.access_token_secret = session.access_token_secret 259 | 260 | return session 261 | 262 | def create_session(self): 263 | if self.consumer_secret and self.consumer_key and \ 264 | self.access_token_secret and self.access_token: 265 | self.session = OAuth1Session(self.consumer_key, 266 | self.consumer_secret, 267 | self.access_token, 268 | self.access_token_secret) 269 | 270 | else: 271 | # shouldn't there be a workflow somewhere to GET the auth tokens? 272 | # add that or ask someone on oDesk to build it... 273 | raise Exception("Need four creds for Quickbooks.create_session.") 274 | 275 | return self.session 276 | 277 | def query_fetch_more(self, r_type, header_auth, realm, 278 | qb_object, original_payload =''): 279 | """ 280 | Wrapper script around hammer_it (previously keep_trying) to fetch more 281 | results if there are more. 282 | """ 283 | # 500 is the maximum number of results returned by QB 284 | # Or is it 1,000? Hmmm... 285 | max_results = 1000 286 | start_position = 1 287 | more = True 288 | data_set = [] 289 | url = self.base_url_v3 + "/company/%s/query" % self.company_id 290 | 291 | # Edit the payload to return more results. 292 | 293 | payload = original_payload + " MAXRESULTS " + str(max_results) 294 | 295 | while more: 296 | if self.verbosity > 4: 297 | print payload 298 | 299 | # Don't keep an extra method around and have to maintain both 300 | """ 301 | r_dict = self.keep_trying(r_type, url, True, 302 | self.company_id, payload) 303 | """ 304 | r_dict = self.hammer_it(r_type, url, payload, "text") 305 | 306 | try: 307 | if "count(*)" in payload.lower(): 308 | return r_dict['QueryResponse']["totalCount"] 309 | else: 310 | access = r_dict['QueryResponse'][qb_object] 311 | except: 312 | if 'QueryResponse' in r_dict and r_dict['QueryResponse'] == {}: 313 | #print "Query OK, no results: %s" % r_dict['QueryResponse'] 314 | return data_set 315 | else: 316 | print "FAILED", 317 | #import ipdb;ipdb.set_trace() 318 | print json.dumps(r_dict, indent=4) 319 | """ 320 | r_dict = self.keep_trying(r_type, 321 | url, 322 | True, 323 | self.company_id, 324 | payload) 325 | """ 326 | r_dict = self.hammer_it(r_type, url, payload, "json") 327 | 328 | # For some reason the totalCount isn't returned for some queries, 329 | # in that case, check the length, even though that actually requires 330 | # measuring 331 | try: 332 | result_count = int(r_dict['QueryResponse']['totalCount']) 333 | if result_count < max_results: 334 | more = False 335 | except KeyError: 336 | try: 337 | result_count = len(r_dict['QueryResponse'][qb_object]) 338 | if result_count < max_results: 339 | more = False 340 | except KeyError: 341 | print "\n\n ERROR", r_dict 342 | pass 343 | 344 | 345 | if self.verbosity > 3: 346 | print "({} batch begins with record {:7} and contains ".format( 347 | qb_object, start_position) + "{:4} records)".format( 348 | result_count) 349 | 350 | start_position = start_position + max_results 351 | payload = "{} STARTPOSITION {} MAXRESULTS {}".format( 352 | original_payload, start_position, max_results) 353 | 354 | try: 355 | data_set += r_dict['QueryResponse'][qb_object] 356 | except KeyError: 357 | if self.verbosity > 0: 358 | import traceback;traceback.print_exc() 359 | 360 | raise Exception("QBO Query Failed") 361 | 362 | return data_set 363 | 364 | def create_object(self, qbbo, create_dict, content_type = "json"): 365 | """ 366 | One of the four glorious CRUD functions. 367 | Getting this right means using the correct object template and 368 | and formulating a valid request_body. This doesn't help with that. 369 | It just submits the request and adds the newly-created object to the 370 | session's brain. 371 | """ 372 | 373 | if qbbo not in self._BUSINESS_OBJECTS: 374 | raise Exception("%s is not a valid QBO Business Object." % qbbo, 375 | " (Note that this validation is case sensitive.)") 376 | 377 | url = "https://qb.sbfinance.intuit.com/v3/company/%s/%s" % \ 378 | (self.company_id, qbbo.lower()) 379 | 380 | request_body = json.dumps(create_dict, indent=4) 381 | 382 | if self.verbosity > 0: 383 | if qbbo in ["Employee", "Vendor"]: 384 | reffer = "called %s" % create_dict.get("DisplayName") 385 | elif qbbo in ["Account", "Customer", "Item"]: 386 | reffer = "called %s" % create_dict.get("FullyQualifiedName") 387 | else: 388 | reffer = "labeled %s" % create_dict.get( 389 | "DocNumber", "") 390 | 391 | print "About to create a(n) %s object (%s)." % (qbbo, reffer) 392 | 393 | if self.verbosity > 5: 394 | print "Here's the request_body:" 395 | print request_body 396 | 397 | response = self.hammer_it("POST", url, request_body, content_type) 398 | 399 | if qbbo in response: 400 | new_object = response[qbbo] 401 | 402 | else: 403 | if self.verbosity > 0: 404 | print "It looks like the create failed for this {}.".format( 405 | qbbo) 406 | 407 | return None 408 | 409 | new_Id = new_object["Id"] 410 | 411 | attr_name = qbbo+"s" 412 | 413 | if not hasattr(self, attr_name): 414 | if self.verbosity > 3: 415 | print "Creating a %ss attribute for this session." % qbbo 416 | 417 | self.get_objects(qbbo).update({new_Id:new_object}) 418 | 419 | else: 420 | if self.verbosity > 3: 421 | print "Adding this new %s to the existing set of them." \ 422 | % qbbo 423 | print json.dumps(new_object, indent=4) 424 | 425 | getattr(self, attr_name)[new_Id] = new_object 426 | 427 | self.latest = max( 428 | self.latest, new_object["MetaData"]["LastUpdatedTime"]) 429 | 430 | return new_object 431 | 432 | def read_object(self, qbbo, object_id, content_type = "json"): 433 | """Makes things easier for an update because you just do a read, 434 | tweak the things you want to change, and send that as the update 435 | request body (instead of having to create one from scratch).""" 436 | 437 | if qbbo not in self._BUSINESS_OBJECTS: 438 | if qbbo in self._biz_object_correctors: 439 | qbbo = self._biz_object_correctors[qbbo] 440 | 441 | else: 442 | raise Exception("No business object called %s" \ 443 | % qbbo) 444 | 445 | Id = str(object_id).replace(".0","") 446 | 447 | url = "https://quickbooks.api.intuit.com/v3/company/%s/%s/%s" % \ 448 | (self.company_id, qbbo.lower(), Id) 449 | 450 | if self.verbosity > 0: 451 | print "Reading %s %s." % (qbbo, Id) 452 | 453 | response = self.hammer_it("GET", url, None, content_type) 454 | 455 | if not qbbo in response: 456 | if self.verbosity > 0: 457 | print "It looks like the read failed for {} {}.".format( 458 | qbbo, object_id) 459 | 460 | return None 461 | 462 | return response[qbbo] 463 | 464 | def update_object(self, qbbo, Id, update_dict, content_type = "json"): 465 | """ 466 | Generally before calling this, you want to call the read_object 467 | command on what you want to update. The alternative is forming a valid 468 | update request_body from scratch, which doesn't look like fun to me. 469 | """ 470 | 471 | Id = str(Id).replace(".0","") 472 | 473 | if qbbo not in self._BUSINESS_OBJECTS: 474 | raise Exception("%s is not a valid QBO Business Object." % qbbo, 475 | " (Note that this validation is case sensitive.)") 476 | 477 | """ 478 | url = "https://qb.sbfinance.intuit.com/v3/company/%s/%s" % \ 479 | (self.company_id, qbbo.lower()) + "?operation=update" 480 | 481 | url = "https://quickbooks.api.intuit.com/v3/company/%s/%s" % \ 482 | (self.company_id, qbbo.lower()) + "?requestid=%s" % Id 483 | """ 484 | 485 | #see this link for url troubleshooting info: 486 | #http://stackoverflow.com/questions/23333300/whats-the-correct-uri- 487 | # for-qbo-v3-api-update-operation/23340464#23340464 488 | 489 | url = "https://quickbooks.api.intuit.com/v3/company/%s/%s" % \ 490 | (self.company_id, qbbo.lower()) 491 | 492 | ''' 493 | #work from the existing account json dictionary 494 | e_dict = self.get_objects(qbbo)[str(Id)] 495 | e_dict.update(update_dict) 496 | ''' 497 | # NO! DON'T DO THAT, THEN YOU CAN'T DELETE STUFF YOU WANT TO DELETE! 498 | 499 | e_dict = update_dict 500 | request_body = json.dumps(e_dict, indent=4) 501 | 502 | if self.verbosity > 0: 503 | if qbbo in ["Employee", "Vendor"]: 504 | reffer = "called %s" % e_dict.get("DisplayName") 505 | elif qbbo in ["Account", "Class", "Customer", "Item"]: 506 | reffer = "called %s" % e_dict.get("Name") 507 | elif qbbo in ["Attachable"]: 508 | reffer = "called %s" % e_dict.get("FileName", "") 509 | else: 510 | reffer = "labeled %s" % e_dict.get( 511 | "DocNumber", "") 512 | 513 | print "About to update %s Id %s (%s)." % (qbbo, Id, reffer) 514 | 515 | if self.verbosity > 5: 516 | print "Here's the request body:" 517 | print request_body 518 | 519 | if self.verbosity > 9: 520 | raw_input("Waiting...") 521 | 522 | response = self.hammer_it("POST", url, request_body, content_type) 523 | 524 | if qbbo in response: 525 | new_object = response[qbbo] 526 | 527 | else: 528 | if self.verbosity > 0: 529 | print "It looks like the update failed for {} {}.".format( 530 | qbbo, Id) 531 | 532 | return None 533 | 534 | attr_name = qbbo+"s" 535 | 536 | if not hasattr(self,attr_name): 537 | if self.verbosity > 3: 538 | print "Creating a %ss attribute for this session." % qbbo 539 | 540 | self.get_objects(qbbo) 541 | 542 | else: 543 | if self.verbosity > 3: 544 | print "Adding this new %s to the existing set of them." \ 545 | % qbbo 546 | print json.dumps(new_object, indent=4) 547 | 548 | getattr(self, attr_name)[Id] = new_object 549 | 550 | self.latest = max( 551 | self.latest, new_object["MetaData"]["LastUpdatedTime"]) 552 | 553 | return new_object 554 | 555 | def delete_object(self, qbbo, object_id = None, content_type = "json", 556 | json_dict = None): 557 | """ 558 | Don't need to give it an Id, just the whole object as returned by 559 | a read operation. 560 | """ 561 | attr_name = qbbo+"s" 562 | if not hasattr(self, attr_name): 563 | setattr(self, attr_name, collections.OrderedDict()) 564 | 565 | if object_id: 566 | Id = str(object_id).replace(".0","") 567 | json_dict = self.read_object(qbbo, Id) 568 | if not json_dict: 569 | # There was a read problem...assume the object doesn't even 570 | # exist anymore (if it ever did) 571 | if object_id in getattr(self, attr_name): 572 | del(getattr(self, attr_name)[object_id]) 573 | return {"Synthetic Response" : 574 | "qbo.py failed to read object, which may not exist."} 575 | elif json_dict: 576 | Id = json_dict["Id"] 577 | else: 578 | raise Exception("Need either an Id or an existing object dict!") 579 | 580 | if hasattr(self, attr_name) and not Id in getattr(self, attr_name): 581 | # It's already been deleted (or was never there) 582 | return {"Synthetic Response" : 583 | "Object Was Already Gone / Never There"} 584 | 585 | if not 'Id' in json_dict: 586 | print json.dumps(json_dict, indent=4) 587 | 588 | raise Exception("No Id attribute found in the above dict!") 589 | 590 | request_body = json.dumps(json_dict, indent=4) 591 | 592 | url = "https://quickbooks.api.intuit.com/v3/company/%s/%s" % \ 593 | (self.company_id, qbbo.lower()) 594 | 595 | if self.verbosity > 0: 596 | if qbbo in ["Employee", "Vendor"]: 597 | reffer = "called %s" % json_dict.get("DisplayName") 598 | elif qbbo in ["Account", "Class", "Customer", "Item",]: 599 | reffer = "called %s" % json_dict.get("FullyQualifiedName") 600 | elif qbbo in ["Attachable"]: 601 | reffer = "called %s" % json_dict.get( 602 | "FileName", "") 603 | else: 604 | reffer = "labeled %s" % json_dict.get( 605 | "DocNumber", "") 606 | 607 | print "Deleting %s %s (%s)." % (qbbo, Id, reffer) 608 | 609 | response = self.hammer_it("POST", url, request_body, content_type, 610 | **{"params":{"operation":"delete"}}) 611 | 612 | if object_id in getattr(self, attr_name): 613 | # Even if it failed, best to delete it from the cache... 614 | del(getattr(self, attr_name)[object_id]) 615 | 616 | if not qbbo in response: 617 | if self.verbosity > 0: 618 | print "It looks like the delete failed for {} {}.".format( 619 | qbbo, object_id) 620 | 621 | return response 622 | 623 | return response[qbbo] 624 | 625 | def upload_file(self, path, name="same", upload_type="automatic", 626 | qbbo=None, Id=None): 627 | """ 628 | Uploads a file that can be linked to a specific transaction (or other 629 | entity probably), or not... 630 | 631 | Either way, it should return the id the attachment. 632 | """ 633 | 634 | url = "https://quickbooks.api.intuit.com/v3/company/%s/upload" % \ 635 | self.company_id 636 | 637 | bare_name, extension = path.rsplit("/",1)[-1].rsplit(".",1) 638 | 639 | if upload_type == "automatic": 640 | upload_type = "application/%s" % extension 641 | 642 | if name == "same": 643 | name = "{}.{}".format(bare_name, extension) 644 | 645 | result = self.hammer_it("POST", url, None, 646 | "multipart/form-data", 647 | file_name=path) 648 | 649 | aid = attachment_id = result[ 650 | "AttachableResponse"][0]["Attachable"]["Id"] 651 | 652 | # Because the case of the file name is not preserved (because of the 653 | # implementation of this particular API), we have to update the 654 | # object's name 655 | att = self.read_object("Attachable", aid) # to cache it too... 656 | att["FileName"] = name 657 | 658 | if qbbo and Id: 659 | # This file should not be attached to any other object since 660 | # we're just uploading it, so we should be creating a NEW 661 | # EntityRef dict here, not adding to an existing one... 662 | if self.vb > 3: 663 | print "Attaching {} to {}/{}!".format(aid, qbbo, Id) 664 | att_blob = att.get("AttachableRef", []) 665 | att_blob.append({"EntityRef" : {"value" : Id, "type" : qbbo,}}) 666 | att["AttachableRef"] = att_blob 667 | 668 | att_update_result = self.update_object("Attachable", aid, att) 669 | 670 | return attachment_id 671 | 672 | def download_file(self, attachment_id, path, only_if_newly_updated=True): 673 | """ 674 | Download a file to the requested (or default) directory, then also 675 | return a download link for convenience. 676 | 677 | Only download the file if it a) does not already exist OR b) if the 678 | update timestamp on the file (as reported by the OS) is EARLIER 679 | than the updatestamp of the Attachable object 680 | """ 681 | 682 | if os.path.exists(path): 683 | file_mtime = pandas.to_datetime(os.path.getmtime(path)*1000000000) 684 | atch_mtime = pandas.to_datetime( 685 | self.get_objects("Attachable")[attachment_id][ 686 | "MetaData"]["LastUpdatedTime"]) 687 | 688 | if file_mtime >= atch_mtime: 689 | if self.vb > 3: 690 | print "Not redownloading attachment, which is newer than" 691 | print " the LastUpdatedTime of Attachable {}.".format( 692 | attachment_id) 693 | print " file_mtime: {}".format(file_mtime) 694 | print " atch_mtime: {}".format(atch_mtime) 695 | print "The newer file is called: {}".format( 696 | path.rsplit("/", 1)[1]) 697 | return "DOWNLOAD NOT REPEATED" 698 | 699 | url = "https://quickbooks.api.intuit.com/v3/company/%s/download/%s" % \ 700 | (self.company_id, attachment_id) 701 | 702 | # Custom accept for file link! 703 | link = self.hammer_it("GET", url, None, "json", accept="filelink") 704 | 705 | # No session required for file download 706 | success = False 707 | tries_remaining = 6 708 | 709 | # special hammer it routine for this very un-oauthed GET... 710 | while not success and tries_remaining >= 0: 711 | if self.verbosity > 1 and tries_remaining < 6: 712 | print "This is attempt #%d to download Attachable id %s." % \ 713 | (6-tries_remaining+1, attachment_id) 714 | 715 | try: 716 | my_r = requests.get(link) 717 | 718 | with open(path, 'wb') as f: 719 | for chunk in my_r.iter_content(1024): 720 | f.write(chunk) 721 | 722 | success = True 723 | 724 | except: 725 | tries_remaining -= 1 726 | time.sleep(1) 727 | 728 | if tries_remaining == 0: 729 | print "Max retries reached...download failed!" 730 | raise 731 | 732 | return link 733 | 734 | def capture_changes(self, since, qbbo_list="all"): 735 | """ 736 | https://developer.intuit.com/docs/api/accounting/ChangeDataCapture 737 | 738 | THIS ONLY GETS YOU THINGS AS RECENT AS THE LAST 30 DAYS!!! 739 | 740 | YOU MUST PASS A UTC TIME TO THIS METHOD UNLESS YOU PASS A STRING 741 | WITH THE CORRECTLY (INTUIT)-FORMATTED OFFSET BUILT IN... 742 | """ 743 | url = "https://qb.sbfinance.intuit.com/v3/company/{}/cdc".format( 744 | self.company_id) 745 | 746 | if qbbo_list == "all": 747 | qbbo_list = self._BUSINESS_OBJECTS 748 | 749 | if not since: 750 | # get the max available by default 751 | since = datetime.datetime.utcnow().replace( 752 | tzinfo=pytz.utc) - datetime.timedelta(days=29) 753 | 754 | if isinstance(since, datetime.datetime): 755 | # WE'RE ASSUMING UTC TIME HERE!!! 756 | since = since.strftime("%Y-%m-%dT%H:%M:%S.000-00:00") 757 | 758 | test_time = datetime.datetime.strptime( 759 | str(since), "%Y-%m-%dT%H:%M:%S.000-00:00") 760 | test_days = (datetime.datetime.now() - test_time).days 761 | 762 | if test_days > 29: 763 | print "You asked for changes since {}".format(test_time) 764 | print "That's {} days ago!".format(test_days) 765 | 766 | raise Exception("You can only get up to 30 days of changes.") 767 | 768 | resp = self.hammer_it( 769 | "GET", url, "", "", **{ 770 | "params" : { 771 | "changedSince" : since, 772 | "entities" : ",".join(qbbo_list)}}) 773 | 774 | # This will be a list of dictionaries, each of which relates to 775 | # a specific response... 776 | return resp 777 | 778 | 779 | def hammer_it(self, request_type, url, request_body, content_type, 780 | accept = 'json', file_name=None, **req_kwargs): 781 | """ 782 | A slim version of simonv3's excellent keep_trying method. Among other 783 | trimmings, it assumes we can only use v3 of the 784 | QBO API. It also allows for requests and responses 785 | in xml OR json. (No xml parsing added yet but the way is paved...) 786 | """ 787 | if not self.session: 788 | self.session = self._get_session() 789 | 790 | session = self.session 791 | 792 | #haven't found an example of when this wouldn't be True, but leaving 793 | #it for the meantime... 794 | header_auth = True 795 | 796 | if accept == "filelink": 797 | headers = {} 798 | 799 | else: 800 | headers = {'Accept': 'application/%s' % accept} 801 | 802 | if file_name == None: 803 | if not request_type == "GET": 804 | headers.update({'Content-Type': 805 | 'application/%s' % content_type}) 806 | 807 | else: 808 | # Avoid full paths in filenames... 809 | fn = file_name + "" 810 | if "/" in fn: 811 | fn = fn.rsplit("/", 1)[1] 812 | 813 | # Special request construction in the case of a file upload 814 | boundary = "-------------PythonMultipartPost" 815 | headers.update({ 816 | 'Content-Type': 817 | #'application/json', 818 | 'multipart/form-data; boundary=%s' % boundary, 819 | 'Accept-Encoding': 820 | #'multipart/form-data; boundary=%s' % boundary, 821 | 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 822 | #'application/json', 823 | 'User-Agent': 'OAuth gem v0.4.7', 824 | #'User-Agent': 'python2.7', 825 | #'Accept': '*/*', 826 | 'Accept':'application/json', 827 | 'Connection': 'close' 828 | }) 829 | 830 | with open(file_name, "rb") as file_handler: 831 | binary_data = file_handler.read() 832 | 833 | extension = file_name.rsplit(".", 1)[1] 834 | 835 | mime_type = { 836 | "pdf" : "pdf", 837 | # because here: https://technet.microsoft.com/en-us/library/ 838 | # ee309278(office.12).aspx 839 | #"xlsx":"vnd.openxmlformats-officedocument.spreadsheetml.sheet", 840 | # But then, on subsequent testing with a successful upload... 841 | "xlsx" : "vnd.ms-excel", 842 | "pptx" : "vnd.ms-powerpoint"}.get(extension, "plain/text") 843 | 844 | request_body = textwrap.dedent( 845 | """ 846 | --%s 847 | Content-Disposition: form-data; name="file_content_0"; filename="%s" 848 | Content-Type: application/%s 849 | Content-Length: %d 850 | Content-Transfer-Encoding: binary 851 | 852 | %s 853 | 854 | --%s-- 855 | """ 856 | ) % (boundary, fn, 857 | #content_type, 858 | mime_type, 859 | len(binary_data), 860 | binary_data, boundary) 861 | 862 | trying = True 863 | print_errors = False 864 | 865 | tries = 0 866 | 867 | # collect them to help troubleshoot later 868 | fault_list = [] 869 | 870 | while trying: 871 | tries += 1 872 | if tries > 10: 873 | print "qbo.hammer_it() is giving up after 10 tries!" 874 | return None 875 | elif tries > 1: 876 | #we don't want to get shut out... 877 | time.sleep(1) 878 | 879 | if self.verbosity > 2: 880 | print "(this is try #%d)" % tries 881 | 882 | try: 883 | if self.verbosity > 20: 884 | print "headers:", headers 885 | if not file_name: 886 | print "request_body:", request_body 887 | print "req_kwargs:", req_kwargs 888 | 889 | my_r = session.request(request_type, url, header_auth, 890 | self.company_id, headers=headers, 891 | data=request_body, verify=True, 892 | **req_kwargs) 893 | 894 | resp_cont_type = my_r.headers['content-type'] 895 | 896 | except: 897 | if self.verbosity > 5: 898 | import traceback;traceback.print_exc() 899 | if self.verbosity > 15: 900 | import ipbd;ipbd.set_trace() 901 | fault_list.append("") 902 | if tries < 10: 903 | continue 904 | else: 905 | print "qbo.hammer_it() failed to get a response" 906 | print "after {} tries:".format(tries) 907 | for fault in fault_list: 908 | print fault 909 | 910 | if 'xml' in resp_cont_type: 911 | result = ET.fromstring(my_r.content) 912 | rough_string = ET.tostring(result, "utf-8") 913 | reparsed = minidom.parseString(rough_string) 914 | ''' 915 | if self.verbosity > 7: 916 | print reparsed.toprettyxml(indent="\t") 917 | ''' 918 | if self.verbosity > 3: 919 | print my_r, my_r.reason, 920 | 921 | if my_r.status_code in [503]: 922 | print " (Service Unavailable)" 923 | 924 | elif my_r.status_code in [401]: 925 | print " (Unauthorized -- a dubious response)" 926 | 927 | else: 928 | print " (xml parse failed)" 929 | 930 | if self.verbosity > 8: 931 | print my_r.text 932 | result = None 933 | 934 | elif 'json' in resp_cont_type: 935 | try: 936 | result = my_r.json() 937 | 938 | except: 939 | result = {"Fault" : {"type":"(synthetic, inconclusive)"}} 940 | 941 | if "Fault" in result: 942 | if self.verbosity > 3: 943 | print my_r, my_r.reason, my_r.text 944 | 945 | fault_list.append(result) 946 | 947 | if "type" in result["Fault"] and \ 948 | result["Fault"]["type"] == "ValidationFault": 949 | # Don't try 10 times; this won't get any better 950 | trying = False 951 | print_errors = True 952 | 953 | elif tries >= 10: 954 | trying = False 955 | print_errors = True 956 | 957 | else: 958 | #sounds like a success 959 | trying = False 960 | 961 | if (not trying and print_errors): 962 | print "Giving up after {} tries. The fault list:".format( 963 | tries) 964 | 965 | for fault in fault_list: 966 | print json.dumps(fault, indent=1) 967 | 968 | elif 'plain/text' in resp_cont_type or accept == 'filelink': 969 | if not "Fault" in my_r.text: 970 | trying = False 971 | 972 | else: 973 | fault_list.append(my_r.text) 974 | 975 | if tries >= 10: 976 | trying = False 977 | print "Failed to get file link after {} tries.".format( 978 | tries) 979 | print "The faults:" 980 | for fault in fault_list: 981 | print fault 982 | 983 | result = my_r.text 984 | 985 | elif 'text/html' in resp_cont_type: 986 | if self.verbosity > 0: 987 | print "Hmmmm....why is text/html the resp_cont_type?" 988 | import ipdb;ipdb.set_trace() 989 | else: 990 | raise Exception("WTF?") 991 | 992 | else: 993 | raise NotImplementedError("How do I parse a %s response?" \ 994 | #% accept) 995 | % resp_cont_type) 996 | 997 | return result 998 | 999 | def get_report(self, report_name, params = None): 1000 | """ 1001 | Tries to use the QBO reporting API: 1002 | https://developer.intuit.com/docs/0025_quickbooksapi/ 1003 | 0050_data_services/reports 1004 | """ 1005 | 1006 | if params == None: 1007 | params = {} 1008 | 1009 | url = "https://quickbooks.api.intuit.com/v3/company/%s/" % \ 1010 | self.company_id + "reports/%s" % report_name 1011 | 1012 | return self.hammer_it("GET", url, None, "json", **{"params" : params}) 1013 | 1014 | def query_objects(self, business_object, params={}, query_tail="", 1015 | count_only=False): 1016 | """ 1017 | Runs a query-type request against the QBOv3 API 1018 | Gives you the option to create an AND-joined query by parameter 1019 | or just pass in a whole query tail 1020 | The parameter dicts should be keyed by parameter name and 1021 | have twp-item tuples for values, which are operator and criterion 1022 | 1023 | count_only allows you to figure out how many objects there are 1024 | without actually pulling all of them. This is VERY important if you 1025 | want to figure out if something (created in the past) has been deleted. 1026 | """ 1027 | 1028 | if business_object not in self._BUSINESS_OBJECTS: 1029 | if business_object in self._biz_object_correctors: 1030 | business_object = self._biz_object_correctors[business_object] 1031 | 1032 | else: 1033 | raise Exception("%s not in list of QBO Business Objects." % \ 1034 | business_object + " Please use one of the " + \ 1035 | "following: %s" % self._BUSINESS_OBJECTS) 1036 | 1037 | #eventually, we should be able to select more than just *, 1038 | #but chances are any further filtering is easier done with Python 1039 | #than in the query... 1040 | 1041 | if count_only: 1042 | query_string="SELECT COUNT(*) FROM %s" % business_object 1043 | else: 1044 | query_string="SELECT * FROM %s" % business_object 1045 | 1046 | if query_tail == "" and not params == {}: 1047 | #It's not entirely obvious what are valid properties for 1048 | #filtering, so we'll collect the working ones here and 1049 | #validate the properties before sending it 1050 | #datatypes are defined here: 1051 | #https://developer.intuit.com/docs/0025_quickbooksapi/ 1052 | # 0050_data_services/020_key_concepts/0700_other_topics 1053 | 1054 | props = { 1055 | "TxnDate":"Date", 1056 | "MetaData.CreateTime":"DateTime", #takes a Date though 1057 | "MetaData.LastUpdatedTime":"DateTime" #ditto 1058 | } 1059 | 1060 | p = params.keys() 1061 | 1062 | #only validating the property name for now, not the DataType 1063 | if p[0] not in props: 1064 | raise Exception("Unfamiliar property: %s" % p[0]) 1065 | 1066 | query_string+=" WHERE %s %s %s" % (p[0], 1067 | params[p[0]][0], 1068 | params[p[0]][1]) 1069 | 1070 | if len(p)>1: 1071 | for i in range(1,len(p)+1): 1072 | if p[i] not in props: 1073 | raise Exception("Unfamiliar property: %s" % p[i]) 1074 | 1075 | query_string+=" AND %s %s %s" % (p[i], 1076 | params[p[i]][0], 1077 | params[p[i]][1]) 1078 | 1079 | elif not query_tail == "": 1080 | if not query_tail[0]==" ": 1081 | query_tail = " "+query_tail 1082 | query_string+=query_tail 1083 | 1084 | url = self.base_url_v3 + "/company/%s/query" % self.company_id 1085 | 1086 | 1087 | results = self.query_fetch_more( 1088 | r_type="POST", header_auth=True, realm=self.company_id, 1089 | qb_object=business_object, original_payload=query_string) 1090 | 1091 | if count_only: 1092 | if self.verbosity > 4: 1093 | print "QBO counts {} {} objects".format( 1094 | results, business_object) 1095 | return results 1096 | 1097 | if self.verbosity > 4: 1098 | print "qbo.query_objects() Found %s %ss!" % ( 1099 | len(results), business_object) 1100 | 1101 | return results 1102 | 1103 | def get_objects(self, qbbo, requery=False, params = {}, query_tail = ""): 1104 | """ 1105 | Rather than have to look up the account that's associate with an 1106 | invoice item, for example, which requires another query, it might 1107 | be easier to just have a local dict for reference. 1108 | 1109 | The same is true with linked transactions, so transactions can 1110 | also be cloned with this method 1111 | """ 1112 | 1113 | #we'll call the attributes by the Business Object's name + 's', 1114 | #case-sensitive to what Intuit's documentation uses 1115 | 1116 | if qbbo not in self._BUSINESS_OBJECTS: 1117 | if qbbo in self._biz_object_correctors: 1118 | qbbo = self._biz_object_correctors[qbbo] 1119 | 1120 | else: 1121 | raise Exception("%s is not a valid QBO Business Object." % qbbo) 1122 | 1123 | elif qbbo in self._NAME_LIST_OBJECTS and query_tail == "": 1124 | #to avoid confusion from 'deleted' accounts later... 1125 | query_tail = "WHERE Active IN (true,false)" 1126 | 1127 | attr_name = qbbo+"s" 1128 | 1129 | #if we've already populated this list, only redo if told to 1130 | #because, say, we've created another Account or Item or something 1131 | #during the session 1132 | 1133 | if not hasattr(self, attr_name): 1134 | setattr(self, attr_name, collections.OrderedDict()) 1135 | requery=True 1136 | 1137 | if requery: 1138 | if self.verbosity > 3: 1139 | print "Caching list of %ss." % qbbo 1140 | if not params == {}: 1141 | print "params:\n%s" % params 1142 | if query_tail: 1143 | print "query_tail:\n%s" % query_tail 1144 | 1145 | object_list = self.query_objects(qbbo, params, query_tail) 1146 | 1147 | if self.verbosity > 3: 1148 | print "Found %s %ss!" % (len(object_list), qbbo) 1149 | 1150 | # Any previously stored objects (with the same ID) will 1151 | # be overwritten (which presumably is desirable) 1152 | for obj in object_list: 1153 | Id = obj["Id"] 1154 | self.latest = max( 1155 | self.latest, obj["MetaData"]["LastUpdatedTime"]) 1156 | getattr(self, attr_name)[Id] = obj 1157 | 1158 | return getattr(self,attr_name) 1159 | 1160 | def object_dicts(self, qbbo_list = [], requery=False, params={}, 1161 | query_tail=""): 1162 | """ 1163 | returns a dict of dicts of ALL the Business Objects of 1164 | each of these types (filtering with params and query_tail) 1165 | """ 1166 | object_dicts = {} #{qbbo:[object_list]} 1167 | 1168 | for qbbo in qbbo_list: 1169 | if qbbo == "TimeActivity": 1170 | #for whatever reason, this failed with some basic criteria, so 1171 | query_tail = "" 1172 | elif qbbo in self._NAME_LIST_OBJECTS and query_tail == "": 1173 | #just something to avoid confusion from 'deleted' accounts later 1174 | query_tail = "WHERE Active IN (true,false)" 1175 | 1176 | object_dicts[qbbo] = self.get_objects( 1177 | qbbo, requery, params, query_tail) 1178 | 1179 | return object_dicts 1180 | 1181 | def names(self, requery=False, params = {}, 1182 | query_tail = "WHERE Active IN (true,false)"): 1183 | """ 1184 | get a dict of every Name List Business Object (of every type) 1185 | 1186 | results are subject to the filter if applicable 1187 | 1188 | returned dict has two dimensions: 1189 | name = names[qbbo][Id] 1190 | """ 1191 | 1192 | return self.object_dicts(self._NAME_LIST_OBJECTS, requery, 1193 | params, query_tail) 1194 | 1195 | def transactions(self, requery=False, params = {}, query_tail = ""): 1196 | """ 1197 | get a dict of every Transaction Business Object (of every type) 1198 | 1199 | results are subject to the filter if applicable 1200 | 1201 | returned dict has two dimensions: 1202 | transaction = transactions[qbbo][Id] 1203 | """ 1204 | return self.object_dicts(self._TRANSACTION_OBJECTS, requery, 1205 | params, query_tail) 1206 | 1207 | --------------------------------------------------------------------------------