├── .gitignore ├── __init__.py ├── examples └── ticket_details.py ├── pycw.py ├── README.md ├── soap.py ├── scaf.py ├── tests.py └── orm.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.ini 3 | examples/pycw 4 | examples/etc 5 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | # Author: Alex Wilson 2 | # License: None, but be cool. 3 | 4 | from pycw import * 5 | -------------------------------------------------------------------------------- /examples/ticket_details.py: -------------------------------------------------------------------------------- 1 | import pycw 2 | 3 | class TicketDetails(pycw.Scaffold): 4 | def __init__(self): 5 | pycw.Scaffold.__init__(self) 6 | self.add_argument('list_tickets', nargs='*') 7 | def loop(self): 8 | for ticket_no in self.args.list_tickets: 9 | try: 10 | t = self.cw.ServiceTicket(int(ticket_no)) 11 | print 'Ticket #%s - %s' % ( t.record_id, t.Summary ) 12 | except pycw.CWObjectNotFound: 13 | print 'No such ticket: #%s!' % ticket_no 14 | 15 | if __name__ == '__main__': 16 | TicketDetails().run() 17 | -------------------------------------------------------------------------------- /pycw.py: -------------------------------------------------------------------------------- 1 | # Author: Alex Wilson 2 | # License: None, but be cool. 3 | 4 | # It's just going to complain anyways. 5 | import logging 6 | logging.getLogger('suds.plugin').setLevel(logging.CRITICAL) 7 | logging.getLogger('suds.client').setLevel(logging.CRITICAL) 8 | 9 | from soap import SoapCaddy 10 | from orm import ConnectWiseORM, CWObjectNotFound 11 | from tests import TestFeatures 12 | from scaf import Scaffold 13 | 14 | def cw_caddy(cw_host, cw_db, cw_user, cw_pass): 15 | """ 16 | Return a SoapCaddy object, this is a place you can store your SOAPs 17 | @cw_host - ConnectWise hostname 'support.yourcompany.com' 18 | @cw_db - Your database name 'yourcompany' (same as login screen) 19 | @cw_user - Integrator Login (Setup Tables > Integrator Logins) 20 | @cw_pass - Integrator Password (Setup Tables > Integrator) 21 | 22 | You should probably just use ORM, from which you can access the caddy anyhow.. 23 | """ 24 | new_caddy = SoapCaddy(cw_host, cw_db, cw_user, cw_pass) 25 | return new_caddy 26 | 27 | def cw_orm(cw_host, cw_db, cw_user, cw_pass): 28 | """ 29 | Return a ORM object, this is a place you can store your SOAPs 30 | @cw_host - ConnectWise hostname 'support.yourcompany.com' 31 | @cw_db - Your database name 'yourcompany' (same as login screen) 32 | @cw_user - Integrator Login (Setup Tables > Integrator Logins) 33 | @cw_pass - Integrator Password (Setup Tables > Integrator) 34 | 35 | Creates a SoapCaddy object, then a ConnectWiseORM object using that caddy. 36 | Access the SoapCaddy directly by accessing YourObject.caddy 37 | """ 38 | new_caddy = cw_caddy(cw_host, cw_db, cw_user, cw_pass) 39 | return ConnectWiseORM(new_caddy) 40 | 41 | def run_tests(**kwargs): 42 | """ 43 | Run the feature tests with default options, you might want to actually inspect the tests file before doing so. 44 | You can provide cw_host etc arguments as with cw_orm() and cw_caddy(), but otherwise these will be picked up from 45 | the following environment variables: 46 | 47 | CW_USERNAME CW_PASSWORD CW_HOSTNAME CW_DATABASE 48 | 49 | To see what else you could override with kwargs, help(TestFeatures) 50 | """ 51 | tests = TestFeatures(**kwargs) 52 | return tests.start() 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pycw - Python bindings for ConnectWise 2 | 3 | Basic feature list: 4 | 5 | * ORM like access to ConnectWise SOAP endpoints 6 | * Some helper functions for handling odd-ball CW API situations 7 | * Caddy object to store all your CW SOAP clients, should you not like the ORM 8 | * Some nasty suds.plugin hacks to get around CW endpoint sending unclean XML 9 | 10 | Third party libraries required (Debian Package name provided): 11 | 12 | * python-dateutil - powerful extensions to the standard datetime module 13 | * python-suds - Lightweight SOAP client for Python 14 | 15 | Here's some example uses: 16 | 17 | ### Getting Started 18 | 19 | ```python 20 | import pycw 21 | cw = pycw.cw_orm('support.mycompany.com', 'mycompany', 'MyIntegratorUser', '1nt3gr4t0rP@ssW3rd') 22 | ``` 23 | 24 | ### Examples 25 | 26 | #### Example: Search for a Company 27 | ```python 28 | companies = cw.search('Company', 'CompanyName LIKE "C%s"', 10) 29 | for company in companies: 30 | print 'Found %s' % company.CompanyName 31 | ``` 32 | 33 | #### Example: Search for a Contact 34 | ```python 35 | contacts = cw.search('Contact', 'Email = "george@example.com"', 1) 36 | for contact in contacts: 37 | print 'Found %s %s' % (contact.FirstName, contact.LastName) 38 | ``` 39 | 40 | #### Example: Create a ServiceTicket 41 | ```python 42 | create_ticket = cw.ServiceTicket() 43 | create_ticket.Summary = "George's server closet is full of eels." 44 | create_ticket.Board = "Helpdesk" 45 | create_ticket.ContactEmailAddress = contact.Email # see pevious example 46 | create_ticket.ContactId = contact.record_id 47 | create_ticket.CompanyIdentifier = contact.get_company().CompanyIdentifier 48 | create_ticket.StatusName = self.status_new 49 | create_ticket.save() 50 | ``` 51 | 52 | #### Example: Attach a Configuration to a ServiceTicket 53 | ```python 54 | configs = ticket.orm.search('Configuration', 'CompanyId = %s And ConfigurationName LIKE "%-sv-%"' % create_ticket.CompanyId, 20) 55 | for config in configs: 56 | create_ticket.assoc_configuration(config) 57 | create_ticket.save() 58 | ``` 59 | 60 | ### tests.py 61 | Only very basic tests have been implemented. You can try these out in an interactive console. The standard routine will: 62 | 63 | * Search for a company (by `tf.with_company` - a full company name) 64 | * Search for a contact (by `tf.with_contact` - an email address) 65 | * Create an acitivty (for `tf.with_member`, of type `tf.with_activity_type` 66 | * Create a service ticket (on `tf.with_board`, with Status `tf.status_new`) 67 | * Create a schedulee ntry (for `tf.with_member`) 68 | * Create an internal note (by `tf.with_member`) 69 | * Associate the 1st Configuration under Company to ServiceTicket 70 | * Create a time entry (by `tf.with_member`) 71 | * Close service ticket (with `tf.status_close`) 72 | 73 | It will dump ids you can use on your own to lookup and inspect, and I would recommend checking data is consistent with your ConnectWise too. 74 | 75 | ```plain 76 | export CW_HOSTNAME=support.mycompany.com 77 | export CW_DATABASE=mycompany 78 | export CW_USERNAME=MyIntegratorUser 79 | export CW_PASSWORD='1nt3gr4t0rP@ssW3rd' 80 | $ python -B tests.py --shell 81 | Python 2.7.3 (default, Mar 13 2014, 11:03:55) 82 | [GCC 4.7.2] on linux2 83 | Type "help", "copyright", "credits" or "license" for more information. 84 | (InteractiveConsole) 85 | >>> cw.ServiceTicket(250263) 86 | 87 | >>> tf 88 | <__main__.TestFeatures instance at 0x2a39128> 89 | >>> tf.with_company = 'XYZ Test Company' 90 | >>> tf.with_board = '1DC | Client Conundrums' 91 | >>> tf.with_activity_type = 'Call' 92 | >>> tf.with_member = 'alex' 93 | >>> tf.with_contact = 'george@example.com' 94 | >>> tf.status_new = 'SilentNew' 95 | >>> tf.status_close = 'SilentClosed' 96 | >>> tf.start() 97 | 9 tests ready to go! 98 | ( 1 of 9) test_search_company("XYZ Test Company") running.. 99 | * Located Company: (XYZ Test Company) 100 | ( 1 of 9) test_search_company("XYZ Test Company") OKAY 101 | ( 2 of 9) test_search_contact("george@example.com") running.. 102 | * Located Contact: (Curious George) 103 | ( 2 of 9) test_search_contact("george@example.com") OKAY 104 | ( 3 of 9) test_create_activity("Call", "alex") running.. 105 | * Created - Just a test Call activity 106 | ( 3 of 9) test_create_activity("Call", "alex") OKAY 107 | ( 4 of 9) test_create_ticket("1DC | Client Conundrums", "XYZ Test Company", "george@example.com") running.. 108 | * Created - Test ticket for Curious George 109 | ( 4 of 9) test_create_ticket("1DC | Client Conundrums", "XYZ Test Company", "george@example.com") OKAY 110 | ( 5 of 9) test_create_schedule("alex") running.. 111 | * Added to 112 | ( 5 of 9) test_create_schedule("alex") OKAY 113 | ( 6 of 9) test_create_internal("alex") running.. 114 | * Added <('TicketNote',)(id=328345)> to 115 | ( 6 of 9) test_create_internal("alex") OKAY 116 | ( 7 of 9) test_assoc_config() running.. 117 | * Added to 118 | ( 7 of 9) test_assoc_config() OKAY 119 | ( 8 of 9) test_create_time_entry("alex") running.. 120 | * Added to 121 | ( 8 of 9) test_create_time_entry("alex") OKAY 122 | ( 9 of 9) test_close_ticket() running.. 123 | * Changed Status of to SilentClosed 124 | ( 9 of 9) test_close_ticket() OKAY 125 | All tests completed okay. 126 | >>> ...do more, if you like... 127 | ``` 128 | 129 | ### Important Information 130 | 131 | Code is offered without any form of warranty. You alone are liable for the data on your system. 132 | 133 | No license is offered. Just be cool. 134 | 135 | If you have any feedback, feel free to email them to me (alex -at- kbni -dot- net). 136 | -------------------------------------------------------------------------------- /soap.py: -------------------------------------------------------------------------------- 1 | # Author: Alex Wilson 2 | # License: None, but be cool. 3 | 4 | import suds.client 5 | import hashlib 6 | import re 7 | import logging 8 | 9 | api_locations = dict( 10 | Activity = '/v4_6_release/apis/2.0/ActivityApi.asmx?wsdl', 11 | Company = '/v4_6_release/apis/2.0/CompanyApi.asmx?wsdl', 12 | Configuration = '/v4_6_release/apis/2.0/ConfigurationApi.asmx?wsdl', 13 | Contact = '/v4_6_release/apis/2.0/ContactApi.asmx?wsdl', 14 | Invoice = '/v4_6_release/apis/2.0/InvoiceApi.asmx?wsdl', 15 | ManagedDevice = '/v4_6_release/apis/2.0/ManagedDeviceApi.asmx?wsdl', 16 | Marketing = '/v4_6_release/apis/2.0/MarketingApi.asmx?wsdl', 17 | Member = '/v4_6_release/apis/2.0/MemberApi.asmx?wsdl', 18 | Opportunity = '/v4_6_release/apis/2.0/OpportunityApi.asmx?wsdl', 19 | OpportunityConversion = '/v4_6_release/apis/2.0/OpportunityConversionApi.asmx?wsdl', 20 | Product = '/v4_6_release/apis/2.0/ProductApi.asmx?wsdl', 21 | Project = '/v4_6_release/apis/2.0/ProjectApi.asmx?wsdl', 22 | Purchasing = '/v4_6_release/apis/2.0/PurchasingApi.asmx?wsdl', 23 | Reporting = '/v4_6_release/apis/2.0/ReportingApi.asmx?wsdl', 24 | Scheduling = '/v4_6_release/apis/2.0/SchedulingApi.asmx?wsdl', 25 | ServiceTicket = '/v4_6_release/apis/2.0/ServiceTicketApi.asmx?wsdl', 26 | System = '/v4_6_release/apis/2.0/SystemApi.asmx?wsdl', 27 | TimeEntry = '/v4_6_release/apis/2.0/TimeEntryApi.asmx?wsdl', 28 | ) 29 | 30 | from suds.plugin import MessagePlugin 31 | 32 | def strip_control_characters(str_to_clean): 33 | # unicode invalid characters 34 | RE_XML_ILLEGAL = u'([\u0000-\u0008\u000b-\u000c\u000e-\u001f\ufffe-\uffff])|' 35 | RE_XML_ILLEGAL += u'([%s-%s][^%s-%s])|([^%s-%s][%s-%s])|([%s-%s]$)|(^[%s-%s])' % ( 36 | unichr(0xd800),unichr(0xdbff),unichr(0xdc00),unichr(0xdfff), 37 | unichr(0xd800),unichr(0xdbff),unichr(0xdc00),unichr(0xdfff), 38 | unichr(0xd800),unichr(0xdbff),unichr(0xdc00),unichr(0xdfff), 39 | ) 40 | 41 | str_to_clean = re.sub(RE_XML_ILLEGAL, '?', str_to_clean) 42 | str_to_clean = re.sub(r"[\x01-\x1F\x7F]", "", input) 43 | 44 | return str_to_clean 45 | # ascii control characters 46 | #input = re.sub(r"[\x01-\x1F\x7F]", "", input) 47 | 48 | 49 | _illegal_xml_chars_RE = re.compile(u'[\x00-\x08\x0b\x0c\x0e-\x1F\uD800-\uDFFF\uFFFE\uFFFF]') 50 | _illegal_xml_encoded_RE = re.compile('&#x.+;') 51 | class RemoveNonValidChars(MessagePlugin): 52 | def received(self, context): 53 | context.reply = context.reply.replace(' ', '') 54 | context.reply = _illegal_xml_chars_RE.sub('?', context.reply) 55 | context.reply = _illegal_xml_encoded_RE.sub('?', context.reply) 56 | context.reply = strip_control_characters(context.reply) 57 | fh = open('/tmp/last_received', 'w') 58 | fh.write(str(context.reply)) 59 | fh.close() 60 | 61 | class ClearEmpty(MessagePlugin): 62 | def clear_empty_tags(self, tags): 63 | for tag in tags: 64 | children = tag.getChildren()[:] 65 | if children: 66 | self.clear_empty_tags(children) 67 | if re.match(r'^<[^>]+?/>$', tag.plain()): 68 | tag.parent.remove(tag) 69 | if tag.parent: 70 | tag.parent.prune() 71 | 72 | def marshalled(self, context): 73 | self.clear_empty_tags(context.envelope.getChildren()[:]) 74 | context.envelope = context.envelope.prune() 75 | 76 | def sending(self, context): 77 | context.envelope = re.sub('\s+<[^>]+?/>', '', context.envelope) 78 | 79 | USE_CLIENT_PLUGINS = [ClearEmpty, RemoveNonValidChars] 80 | 81 | class SoapLoader: 82 | def __init__( self, orm, server, companyid, username, password ): 83 | self.orm = orm 84 | 85 | def __getattr__( self, name ): 86 | if name in api_locations.keys(): 87 | return_client( ) 88 | 89 | raise AttributeError 90 | 91 | class SoapCaddy: 92 | def __init__( self, server, companyid, username = False, password = False): 93 | self.clients = {} 94 | self.credentials = {} 95 | self.suds = suds 96 | self.cached_member_recid = {} 97 | 98 | if not server.startswith('http'): 99 | server = 'https://'+server 100 | 101 | if username and password: 102 | self.add_credentials(username, password) 103 | 104 | self.server = server 105 | self.companyid = companyid 106 | 107 | def add_credentials( self, username, password, module = False ): 108 | if not module: module = '$default' 109 | self.credentials[module] = (username, password) 110 | 111 | def get_credentials( self, module = False ): 112 | 113 | module_creds = self.credentials.get(module, None) 114 | if module_creds is None: 115 | module_creds = self.credentials.get('$default', None) 116 | 117 | return module_creds 118 | 119 | def get_client( self, module ): 120 | if module not in self.clients.keys(): 121 | wsdl_url = '%s%s' % ( self.server, api_locations[module] ) 122 | self.clients[module] = suds.client.Client(wsdl_url, plugins=[ plugin() for plugin in USE_CLIENT_PLUGINS ]) 123 | 124 | client = self.clients[module] 125 | api_user, api_pass = self.get_credentials(module) 126 | 127 | credentials = client.factory.create('ApiCredentials') 128 | credentials.CompanyId = self.companyid 129 | credentials.IntegratorLoginId = api_user 130 | credentials.IntegratorPassword = api_pass 131 | client.credentials = credentials 132 | 133 | return client 134 | 135 | def Activity( self, *args ): return self.get_client('Activity', *args) 136 | def Company( self, *args ): return self.get_client('Company', *args) 137 | def Configuration( self, *args ): return self.get_client('Configuration', *args) 138 | def Contact( self, *args ): return self.get_client('Contact', *args) 139 | def Invoice( self, *args ): return self.get_client('Invoice', *args) 140 | def ManagedDevice( self, *args ): return self.get_client('ManagedDevice', *args) 141 | def Marketing( self, *args ): return self.get_client('Marketing', *args) 142 | def Member( self, *args ): return self.get_client('Member', *args) 143 | def Opportunity( self, *args ): return self.get_client('Opportunity', *args) 144 | def OpportunityConversion( self, *args ): return self.get_client('OpportunityConversion', *args) 145 | def Product( self, *args ): return self.get_client('Product', *args) 146 | def Project( self, *args ): return self.get_client('Project', *args) 147 | def Purchasing( self, *args ): return self.get_client('Purchasing', *args) 148 | def Reporting( self, *args ): return self.get_client('Reporting', *args) 149 | def Scheduling( self, *args ): return self.get_client('Scheduling', *args) 150 | def System( self, *args ): return self.get_client('System', *args) 151 | def ServiceTicket( self, *args ): return self.get_client('ServiceTicket', *args) 152 | def TimeEntry( self, *args ): return self.get_client('TimeEntry', *args) 153 | 154 | def soap_call( self, module, action, *args, **kwargs ): 155 | m = self.get_client(module) 156 | svc_func = getattr(m.service, action) 157 | try: 158 | return svc_func( m.credentials, *args, **kwargs ) 159 | except: 160 | raise 161 | 162 | # Reporting.RunReportQuery / Member report 163 | def get_member_recid( self, need_member_id ): 164 | if need_member_id not in self.cached_member_recid: 165 | res = self.soap_call('Reporting', 'RunReportQuery', 'Member') 166 | for member in res[0]: 167 | member_id = None 168 | member_recid = None 169 | for col in member[1]: 170 | if col._Name == 'Member_ID': 171 | member_id = col.value 172 | if col._Name == 'Member_RecID': 173 | member_recid = col.value 174 | if member_id and member_recid: 175 | break 176 | self.cached_member_recid[member_id] = member_recid 177 | 178 | return self.cached_member_recid[need_member_id] 179 | 180 | -------------------------------------------------------------------------------- /scaf.py: -------------------------------------------------------------------------------- 1 | # Author: Alex Wilson 2 | # License: None, but be cool. 3 | 4 | import sys 5 | import os 6 | import socket 7 | import code 8 | import datetime 9 | 10 | import argparse 11 | try: 12 | import configparser 13 | except: 14 | import ConfigParser as configparser 15 | 16 | import pycw 17 | 18 | def hostname(): 19 | import socket 20 | return socket.gethostname().split('.')[0] 21 | 22 | def split_str(str_to_split): 23 | if ',' in str_to_split: 24 | return str_to_split.split(',') 25 | if '|' in str_to_split: 26 | return str_to_split.split('|') 27 | return [str_to_split,] 28 | 29 | def first_or_false(chk_list): 30 | if len(chk_list) > 0: 31 | return chk_list[0] 32 | else: 33 | return False 34 | 35 | def textual_bool(str_text): 36 | if not str_text: 37 | return False 38 | if str_text.lower() in ('y', 'yes', '1'): 39 | return True 40 | elif str_text.lower() in ('n', 'no', '0'): 41 | return False 42 | else: 43 | return None 44 | 45 | class Scaffold: 46 | argparser = None 47 | args = None 48 | required_keys = None 49 | 50 | def __init__(self): 51 | """ 52 | 53 | """ 54 | defaults = dict( 55 | base_dir = '.', 56 | instance_id = self.__class__.__name__.split('.')[-1], 57 | ) 58 | 59 | self.argparser = argparse.ArgumentParser() 60 | self.argparser.add_argument('--shell', dest='drop_to_shell', action='store_true', help='Just drop to shell after reading config') 61 | self.argparser.add_argument('--setup', dest='setup_config', action='store_true', help='create configuration files') 62 | self.argparser.add_argument('--base-dir', dest='base_dir', action='store', help='Find stale schedules', default=defaults['base_dir']) 63 | self.argparser.add_argument('--instance-id', dest='instance_id', action='store', help='Unique instance ID', default=defaults['instance_id']) 64 | self.required_config_keys = [ 'connectwise.hostname', 'connectwise.password', 'connectwise.username', 'connectwise.database' ] 65 | 66 | def add_argument(self, *args, **kwargs): 67 | """ 68 | Proxy to ArgumentParser.add_argument() 69 | """ 70 | return self.argparser.add_argument(*args, **kwargs) 71 | 72 | def setup(self): 73 | """ 74 | Quick and nasty setup.. 75 | """ 76 | 77 | print "Are you okay with the following configuration locations?" 78 | print " Base Directory (--base-dir) = %s" % self.args.base_dir 79 | print " Instance Id (--instance-id) = %s" % self.args.instance_id 80 | print "Using these two values, we will write to the following file" 81 | print " %s" % self.get_config_file() 82 | print "If this is cool, type yes" 83 | 84 | if raw_input(">") != "yes": 85 | self.fatal('aborted setup..') 86 | 87 | if not os.path.exists(self.args.base_dir): 88 | os.mkdir(self.args.base_dir) 89 | 90 | if not os.path.exists(os.path.join(self.args.base_dir, 'etc')): 91 | os.mkdir(os.path.join(self.args.base_dir, 'etc')) 92 | 93 | print "Let's run through some configuration items. If you are unsure, read the manual." 94 | 95 | new_config = configparser.ConfigParser() 96 | 97 | for req_key in self.required_config_keys: 98 | new_data = raw_input("[%s]\n>" % req_key) 99 | if not new_data: 100 | self.fatal('bad data entered..') 101 | else: 102 | sec, key = req_key.split('.') 103 | if not new_config.has_section(sec): 104 | new_config.add_section(sec) 105 | new_config.set(sec, key, new_data) 106 | 107 | with open(self.get_config_file(), 'wb') as config_fh: 108 | new_config.write(config_fh) 109 | 110 | def get_config_file(self): 111 | return os.path.join(self.args.base_dir, 'etc', self.args.instance_id+'.ini') 112 | 113 | def run(self): 114 | """ 115 | Check with have core configuration values and get started! 116 | """ 117 | 118 | self.args = self.argparser.parse_args() 119 | 120 | if self.args.setup_config: 121 | self.setup() 122 | 123 | if not os.path.exists(self.get_config_file()): 124 | self.fatal("config_file does not exist: %s - perhaps you should run --setup" % config_file) 125 | else: 126 | self._config = configparser.ConfigParser() 127 | self._config.read(self.get_config_file()) 128 | for req_key in self.required_config_keys: 129 | self.ini(*req_key.split('.')) 130 | 131 | self.cw = pycw.cw_orm( 132 | self.ini('connectwise', 'hostname'), 133 | self.ini('connectwise', 'database'), 134 | self.ini('connectwise', 'username'), 135 | self.ini('connectwise', 'password'), 136 | ) 137 | 138 | if self.error_count > 0: 139 | self.fatal('too many errors, unable to start()') 140 | 141 | elif self.args.drop_to_shell: 142 | self.shell() 143 | 144 | else: 145 | try: 146 | for func in ( self.start, self.loop, self.finish ): 147 | func() 148 | sys.exit(0) 149 | except KeyboardInterrupt: 150 | self.stop() 151 | 152 | # 153 | # Now, here's the methods you should be overriding 154 | # 155 | 156 | def start(self): 157 | """ 158 | This is run at the start of your script, for things like 159 | * Establishing connections to other databases 160 | * Caching values for repetitive use in loop() 161 | * Requesting user-input 162 | """ 163 | pass 164 | 165 | def loop(self): 166 | """ 167 | This is the meat of your script, this will not loop by itself 168 | so you should add something to it. :) 169 | """ 170 | pass 171 | 172 | def finish(self): 173 | """ 174 | This is finished when a script succesfully finishes 175 | * Disconnect from external-databases (like who bothers with this anymore?) 176 | * Clean up any unused files or temporary databases 177 | * Success email notification?? 178 | """ 179 | pass 180 | 181 | def stop(self): 182 | """ 183 | Catches Ctrl+C / SIGTERM 184 | * Same as finish(), except this will only run when 185 | """ 186 | sys.exit(1) 187 | 188 | # 189 | # Logging functions, we should probably replace this with logging module at some point 190 | # 191 | 192 | def log(self, message, level='info', stdout_one_line=False): 193 | now = datetime.datetime.now() 194 | msg_start = '[%s][%-6s] ' % ( now.strftime('%Y%m%d %H%M'), level ) 195 | len_start = len(msg_start) 196 | 197 | message = message.encode('utf-8', 'ignore') 198 | 199 | level_out = sys.stdout 200 | if level in ('fatal', 'error'): 201 | level_out = sys.stderr 202 | 203 | for line in str(message).split("\n"): 204 | level_out.write(msg_start+line+"\n") 205 | level_out.flush() 206 | if stdout_one_line: 207 | break 208 | msg_start = ' '*len_start 209 | 210 | if level == 'fatal': 211 | self.stop() 212 | 213 | info_count = 0 214 | def info(self, message, **kwargs): 215 | self.info_count += 1 216 | self.log(message, 'info', **kwargs) 217 | 218 | warning_count = 0 219 | def warning(self, message, **kwargs): 220 | self.warning_count += 1 221 | self.log(message, 'warn', **kwargs) 222 | 223 | debug_count = 0 224 | def debug(self, message, **kwargs): 225 | self.debug_count += 1 226 | self.log(message, 'debug', **kwargs) 227 | 228 | error_count = 0 229 | def error(self, message, **kwargs): 230 | self.error_count += 1 231 | self.log(message, 'error', **kwargs) 232 | 233 | fatal_count = 0 234 | def fatal(self, message, **kwargs): 235 | self.fatal_count += 1 236 | self.log(message, 'fatal', **kwargs) 237 | 238 | # Configuration stuff 239 | 240 | def ini(self, section, key_name, default=-1): 241 | if self._config._sections.has_key(section): 242 | if self._config._sections[section].has_key(key_name): 243 | val = self._config._sections[section][key_name] 244 | 245 | if default != -1 and isinstance(default, bool): 246 | return str(val).lower() in ('1', 'yes', 'true', 'y') 247 | return val 248 | 249 | if default == -1: 250 | self.error('no value for %s.%s in config file' % (section, key_name)) 251 | return None 252 | 253 | return default 254 | 255 | # Useful for testing, start with --shell to just get dumped to shell ;) 256 | 257 | def shell(self, use_locals=None): 258 | if use_locals is None: 259 | use_locals = {} 260 | code.interact(local=use_locals) 261 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | # Author: Alex Wilson 2 | # License: None, but be cool. 3 | 4 | import pycw 5 | import os, sys, code 6 | import datetime 7 | import traceback 8 | 9 | class TestFeatures: 10 | """ 11 | This class will run basic tests using your ConnectWise details, you may 12 | want to override these default properties (kwargs passed to __init__) 13 | 14 | @cw_host = ConnectWise hostname (support.yourcompany.com) 15 | @cw_db = ConnectWise database (yourcompany) 16 | @cw_user = ConnectWise username (SomeIntegratorUsername) 17 | @cw_pass = ConnectWise password (Some1nt3grat0rP@ssw0rd) 18 | 19 | @with_company = 'XYZ Test Company' <- full company name 20 | @with_board = '1DC | Client Conundrums' <- full service board name 21 | @with_activity_type = 'Call' <- activity type 22 | @with_member = 'alex' <- member's email address 23 | @with_contact = 'george@example.com' <- contact's email address 24 | @status_new = 'SilentNew' <- status for create_ticket 25 | @status_close = 'SilentClosed' <- status for close_ticket 26 | 27 | @search_member = True <- test searching for a member 28 | @search_company = True <- test searching for a company 29 | @search_contact = True <- test searching for a contact 30 | @create_ticket = True <- test creating a ticket 31 | @create_schedule = True <- test creating a schedule entry 32 | @create_time_entry = True <- test creating a time entry 33 | @create_internal = True <- test creating an internal note 34 | @close_ticket = True <- test closing a ticket 35 | @assoc_config = True <- test associating a configuration 36 | @create_activity = True <- test creating an activity 37 | @delete_ticket = True <- test deleting a ticket 38 | 39 | It's important to note these tests aren't entirely conclusive, and created 40 | test objects should be inspected for errors before proceeding any further. 41 | """ 42 | 43 | cw_host = None 44 | cw_db = None 45 | cw_user = None 46 | cw_pass = None 47 | 48 | with_company = 'XYZ Test Company' 49 | with_board = '1DC | Client Conundrums' 50 | with_activity_type = 'Call' 51 | with_member = 'alex' 52 | with_contact = 'george@example.com' 53 | status_new = 'SilentNew' 54 | status_close = 'SilentClosed' 55 | 56 | search_member = True 57 | search_company = True 58 | search_contact = True 59 | create_ticket = True 60 | create_schedule = True 61 | create_time_entry = True 62 | create_internal = True 63 | close_ticket = True 64 | assoc_config = True 65 | create_activity = True 66 | delete_ticket = True 67 | 68 | _last_ticket = None 69 | 70 | def __init__(self, **kwargs): 71 | for k,v in kwargs.iteritems(): 72 | if hasattr(self, k): 73 | setattr(self, k, v) 74 | 75 | def start(self): 76 | tests = [] 77 | 78 | if self.search_company: 79 | tests.append([ self.test_search_company, self.with_company ]) 80 | if self.search_contact: 81 | tests.append([ self.test_search_contact, self.with_contact ]) 82 | if self.create_activity: 83 | tests.append([ self.test_create_activity, self.with_activity_type, self.with_member ]) 84 | if self.create_ticket: 85 | tests.append([ self.test_create_ticket, self.with_board, self.with_company, self.with_contact ]) 86 | if self.create_schedule: 87 | tests.append([ self.test_create_schedule, self.with_member ]) 88 | if self.create_internal: 89 | tests.append([ self.test_create_internal, self.with_member ]) 90 | if self.assoc_config: 91 | tests.append([ self.test_assoc_config ]) 92 | if self.create_time_entry: 93 | tests.append([ self.test_create_time_entry, self.with_member ]) 94 | if self.close_ticket: 95 | tests.append([ self.test_close_ticket ]) 96 | 97 | print '%d tests ready to go!' % len(tests) 98 | 99 | success = True 100 | 101 | for index,test in enumerate(tests): 102 | func, args = test[0], test[1:] 103 | func_desc = func.func_name 104 | func_desc += '(' + ', '.join([ '"%s"' % a for a in args ]) + ')' 105 | print '(%2d of %2d) %s running..' % (index+1, len(tests), func_desc) 106 | status = 'FAIL' 107 | try: 108 | func(*args) 109 | status = 'OKAY' 110 | except: 111 | traceback.print_exc() 112 | success = False 113 | print 114 | 115 | print '(%2d of %2d) %s %s' % (index+1, len(tests), func_desc, status) 116 | 117 | if success: 118 | print 'All tests completed okay.' 119 | else: 120 | sys.stderr.write('Looks like some tests failed. Oops\n') 121 | sys.exit(1) 122 | 123 | def get_cw(self): 124 | cw = pycw.cw_orm(self.cw_host, self.cw_db, self.cw_user, self.cw_pass) 125 | return cw 126 | 127 | def test_search_company(self, with_company): 128 | cw = pycw.cw_orm(self.cw_host, self.cw_db, self.cw_user, self.cw_pass) 129 | companies = cw.search('Company', 'CompanyName = "%s"' % with_company, 1) 130 | company = cw.Company(companies[0].record_id) # We already have the object, but let's reload it using the record_id 131 | 132 | print '* Located Company: %s (%s)' % ( company, company.CompanyName ) 133 | 134 | def test_search_contact(self, with_contact): 135 | cw = pycw.cw_orm(self.cw_host, self.cw_db, self.cw_user, self.cw_pass) 136 | contacts = cw.search('Contact', 'Email = "%s"' % with_contact, 1) 137 | contact = contacts[0] 138 | 139 | print '* Located Contact: %s (%s %s)' % (contact, contact.FirstName, contact.LastName) 140 | 141 | def test_create_activity(self, with_activity_type, with_member): 142 | cw = pycw.cw_orm(self.cw_host, self.cw_db, self.cw_user, self.cw_pass) 143 | 144 | activity = cw.Activity() 145 | activity.Subject = 'Just a test %s activity' % with_activity_type 146 | activity.CompanyIdentifier = 'Catchall' # hope you have a catchall :/ 147 | activity.AssignTo = with_member 148 | activity.Type = with_activity_type 149 | activity.Status = 'Open' 150 | activity.Notes = 'Just ignore this - testing pycw' 151 | activity.TimeRange.StartTime = datetime.datetime.now() + datetime.timedelta(minutes=4) 152 | activity.TimeRange.EndTime = datetime.datetime.now() + datetime.timedelta(minutes=7) 153 | activity.DueDate = datetime.datetime.now() + datetime.timedelta(minutes=60) 154 | activity.save() 155 | 156 | print '* Created %s - %s' % (activity, activity.Subject) 157 | 158 | def test_create_ticket(self, with_board, with_company, with_contact): 159 | cw = pycw.cw_orm(self.cw_host, self.cw_db, self.cw_user, self.cw_pass) 160 | 161 | contacts = cw.search('Contact', 'Email = "%s"' % with_contact, 1) 162 | contact = contacts[0] 163 | company = contact.get_company() 164 | 165 | create_ticket = cw.ServiceTicket() 166 | create_ticket.Summary = 'Test ticket for %s %s' % ( contact.FirstName, contact.LastName ) 167 | create_ticket.Board = with_board 168 | create_ticket.ContactEmailAddress = contact.Email 169 | create_ticket.ContactId = contact.record_id 170 | create_ticket.CompanyIdentifier = company.CompanyIdentifier 171 | create_ticket.StatusName = self.status_new 172 | create_ticket.save() 173 | 174 | print '* Created %s - %s' % (create_ticket, create_ticket.Summary) 175 | 176 | self._last_ticket = create_ticket 177 | 178 | def test_create_time_entry(self, with_member, ticket = None): 179 | if not ticket: 180 | ticket = self._last_ticket 181 | 182 | time_entry = ticket.orm.TimeEntry() 183 | time_entry.TicketId = ticket.record_id 184 | time_entry.MemberIdentifier = with_member 185 | time_entry.DateStart = datetime.datetime.now() 186 | time_entry.TimeStart = datetime.datetime.now() + datetime.timedelta(minutes=25) 187 | time_entry.TimeEnd = datetime.datetime.now() + datetime.timedelta(minutes=26) 188 | time_entry.Notes = 'Test time entry..' 189 | time_entry.AddNotesToDetailDescription = True 190 | time_entry.save() 191 | 192 | print '* Added %s to %s' % ( time_entry, ticket ) 193 | 194 | def test_create_internal(self, with_member, ticket = None): 195 | if not ticket: 196 | ticket = self._last_ticket 197 | 198 | member_recid = ticket.orm.caddy.get_member_recid('alex') 199 | 200 | ticket_note = ticket.orm.TicketNote(parent=ticket) 201 | ticket_note.MemberId = member_recid 202 | ticket_note.NoteText = 'Test internal note' 203 | ticket_note.IsInternalNote = False 204 | ticket_note.IsExternalNote = False 205 | ticket_note.ProcessNotifications = False 206 | ticket_note.IsPartOfDetailDescription = False 207 | ticket_note.IsPartOfInternalAnalysis = True 208 | ticket_note.IsPartOfResolution = False 209 | ticket_note.save() 210 | 211 | print '* Added %s to %s' % ( ticket_note, ticket ) 212 | 213 | def test_create_schedule(self, with_member, ticket = None): 214 | if not ticket: 215 | ticket = self._last_ticket 216 | 217 | schedule = ticket.orm.TicketScheduleEntry() 218 | schedule.TicketId = int(ticket.record_id) 219 | schedule.MemberIdentifier = with_member 220 | schedule.DateStart = datetime.datetime.now() + datetime.timedelta(minutes=30) 221 | schedule.DateEnd = datetime.datetime.now() + datetime.timedelta(minutes=60) 222 | schedule.save() 223 | 224 | print '* Added %s to %s' % ( schedule, ticket ) 225 | 226 | def test_close_ticket(self, ticket = None): 227 | if not ticket: 228 | ticket = self._last_ticket 229 | 230 | ticket.load() 231 | ticket.StatusName = self.status_close 232 | ticket.save() 233 | 234 | print '* Changed Status of %s to %s' % ( ticket, ticket.StatusName ) 235 | 236 | def test_assoc_config(self, ticket = None): 237 | if not ticket: 238 | ticket = self._last_ticket 239 | 240 | cw = ticket.orm 241 | 242 | ticket.load() 243 | configs = ticket.orm.search('Configuration', 'CompanyId = %s' % ticket.CompanyId, 1) 244 | 245 | ticket.assoc_configuration(configs[0]) 246 | ticket.save() 247 | ticket.load() 248 | 249 | print '* Added %s to %s' % ( configs[0], ticket ) 250 | 251 | if __name__ == "__main__": 252 | cw_host = os.environ.get('CW_HOSTNAME', None) 253 | cw_user = os.environ.get('CW_USERNAME', None) 254 | cw_pass = os.environ.get('CW_PASSWORD', None) 255 | cw_db = os.environ.get('CW_DATABASE', None) 256 | if '--shell-only' in sys.argv or '--shell' in sys.argv: 257 | cw = pycw.cw_orm(cw_host, cw_db, cw_user, cw_pass) 258 | tf = TestFeatures(cw_host=cw_host, cw_user=cw_user, cw_pass=cw_pass, cw_db=cw_db) 259 | code.interact(local=locals()) 260 | if '--just-run': 261 | TestFeatures(cw_host=cw_host, cw_user=cw_user, cw_pass=cw_pass, cw_db=cw_db).start() 262 | -------------------------------------------------------------------------------- /orm.py: -------------------------------------------------------------------------------- 1 | # Author: Alex Wilson 2 | # License: None, but be cool. 3 | 4 | import suds 5 | import datetime, time, dateutil.parser 6 | import copy 7 | import email.parser # This is a bit WTF, isn't it? (It's for parsing email attachments in tickets.) 8 | 9 | class CWObjectIsStale(Exception): 10 | pass 11 | 12 | class CWObjectNotFound(Exception): 13 | pass 14 | 15 | class ConnectWiseORM: 16 | def __init__( self, caddy ): 17 | self.pycw_types = {} 18 | self.caddy = caddy 19 | self.cache = {} 20 | 21 | # This is kind of nasty - but we need dict of all CWObject items 22 | for k,v in globals().iteritems(): 23 | if [ True for x in ( '_id_fields', '_name', '_load') if hasattr(v, x) ]: 24 | self.pycw_types[k] = v 25 | 26 | def __repr__( self ): 27 | return "" % id(self) 28 | 29 | def __str__( self ): 30 | return str(self.__repr__()) 31 | 32 | def search(self, obj_name, conditions, limit=None, skip=None, orderby=None): 33 | obj = self.pycw_types.get(obj_name, None) 34 | 35 | if not obj and hasattr(obj_name, '_name'): 36 | obj = obj_name 37 | obj_name = obj._name 38 | 39 | if obj._search_cond: 40 | if conditions and conditions != '': 41 | conditions = '%s And ( %s )' % ( obj._search_cond, conditions ) 42 | else: 43 | conditions = obj._saerch_cond 44 | 45 | if '.' in obj._search: 46 | search_api, search_func = obj._search.split('.') 47 | kwargs = dict() 48 | 49 | if limit is not None: kwargs['limit'] = limit 50 | if limit is not None: kwargs['skip'] = skip 51 | if limit is not None: kwargs['orderBy'] = orderby 52 | 53 | soap_res = self.caddy.soap_call(search_api, search_func, conditions, **kwargs) 54 | results = [] 55 | 56 | if soap_res: 57 | for x in soap_res[0]: 58 | for id_field in obj._id_fields: 59 | if hasattr(x, id_field): 60 | found_obj = self._load_object(obj_name, getattr(x, id_field)) 61 | if found_obj not in results: 62 | results.append( found_obj ) 63 | elif hasattr(x, 'Id'): 64 | found_obj = self._load_object(obj_name, x.Id) 65 | if found_obj not in results: 66 | results.append( found_obj ) 67 | 68 | return results 69 | 70 | def _load_object(self, load_obj, *crit1, **crit2): 71 | if load_obj in self.pycw_types.keys(): 72 | load_obj = self.pycw_types[load_obj] 73 | 74 | if len(crit1) == 0: 75 | o = load_obj(orm=self, record_id=None, **crit2) 76 | return o 77 | 78 | if crit1 and len(crit1) == 1 and isinstance(crit1[0], int): 79 | key = (load_obj, crit1[0]) 80 | 81 | o = self.cache.get(key, None) 82 | if o is not None: 83 | return o 84 | 85 | o = load_obj(orm=self, record_id=crit1[0], **crit2) 86 | self.cache[key] = o 87 | return o 88 | 89 | if crit2 and crit2.has_key('parent') and crit2.has_key('from_basic'): 90 | found_id = crit2['from_basic'].Id 91 | key = (load_obj, found_id) 92 | 93 | o = self.cache.get(key, None) 94 | if o is not None: 95 | o.load(use_data=crit2['from_basic']) 96 | return o 97 | 98 | o = load_obj(orm=self, record_id=found_id, **crit2) 99 | self.cache[key] = o 100 | return o 101 | 102 | raise CWObjectNotFound("%s %s" % ( str(crit1), str(crit2) )) 103 | 104 | def _walk_attributes( self, parent, use_type, *args ): 105 | def deepgetattr(obj, attr): 106 | """Recurses through an attribute chain to get the ultimate value.""" 107 | try: 108 | return reduce(getattr, args, obj) 109 | except AttributeError: 110 | return [] 111 | 112 | deepval = deepgetattr(parent.load_data, args) 113 | 114 | def fake_append(self, obj): 115 | print 'fake_append', self, obj 116 | 117 | def fake_remove(self, obj): 118 | print 'fake_remove', self, obj 119 | 120 | if use_type in self.pycw_types.keys(): 121 | for new_child in [ self._load_object(use_type, parent=parent, from_basic=d) for d in deepval ]: 122 | parent.new_child(new_child) 123 | our_list = [ obj for obj in parent.children if isinstance(obj, self.pycw_types[use_type]) ] 124 | return our_list 125 | else: 126 | return use_type(deepval) 127 | 128 | def __getattr__(self, key): 129 | if self.pycw_types.has_key(key): 130 | 131 | def load_it( *crit1, **crit2 ): 132 | return self._load_object(key, *crit1, **crit2) 133 | return load_it 134 | raise AttributeError("%s has no attribute %s" % (self, key)) 135 | 136 | class CWObject(object): 137 | orm = None 138 | parent = None 139 | attrs = [] 140 | children = [] 141 | 142 | old = None 143 | base = None 144 | new = None 145 | 146 | _name = 'None' # 147 | _use_basic = False # 148 | _load = False # 'APIname.APIfunc' or 'FROM_PARENT' 149 | _save = False # 'APIname.APIfunc' or 'FROM_PARENT' 150 | _search = False # 'APIname.APIfunc' 151 | _search_cond = False # If you need to add an additional comdition string to 152 | 153 | def __init__(self, orm = None, record_id = None, parent = None, data = None, from_basic = None): 154 | """ 155 | @param orm = ORM object 156 | @param record_id = Record ID to assign this object (is negative if not_real) 157 | @param data - Pre-loaded data, this would mostly be used for anything with parent_load = True 158 | @param parent - Parent CWObject, for relationship purposes only 159 | @param from_basic - Basic data to supply to create an object without fetching additional data 160 | """ 161 | 162 | self.load_level = 0 163 | self.load_data = data 164 | self.record_id = 0 - id(self) 165 | 166 | self.changes_attrs = {} # Changed attribute dict 167 | self.changes_funcs = [] # Functions that should be applied before save 168 | self.deleted = False # HAs this item been deleted? 169 | 170 | if orm is not None: 171 | self.orm = orm 172 | 173 | self.old = None 174 | self.base = self.get_factory() 175 | self.data = self.get_factory() 176 | 177 | if parent is not None: 178 | self.parent = parent 179 | 180 | if self not in self.parent.children: 181 | self.parent.children.append(self) 182 | 183 | try: 184 | if record_id: 185 | self.record_id = record_id 186 | self.load() 187 | except suds.WebFault as e: 188 | if str(e).startswith('Server raised fault: '): 189 | raise CWObjectNotFound( str(e).split(':',1)[1].strip() ) 190 | else: 191 | raise e 192 | 193 | if False and from_basic is not None: 194 | for attr in dir(from_basic): 195 | if hasattr(self.data, attr): 196 | setattr(self.data, attr, getattr(from_basic, attr)) 197 | 198 | def __repr__(self): 199 | """ 200 | Return , or if object has not been saved! 201 | """ 202 | if not self.is_real_record(): 203 | return '<%s(id=UNSAVED)>' % (self._name,) 204 | else: 205 | return '<%s(id=%s)>' % (self._name, self.record_id) 206 | 207 | def __str__(self): 208 | return str(self.__repr__()) 209 | 210 | def is_real_record(self): 211 | return self.record_id and self.record_id > 0 212 | 213 | def allow_basic_load(self): 214 | return self._use_basic 215 | 216 | def update_cache(self): 217 | self.orm.update_cache(self) 218 | 219 | def has_full_load(self): 220 | """ 221 | Test if we have populated data yet 222 | """ 223 | return self.old is not None 224 | 225 | def require_full_load(self): 226 | if self.is_real_record() and not self.has_full_load(): 227 | self.load() 228 | 229 | return self 230 | 231 | def load(self, use_data = None): 232 | if use_data is not None and self._use_basic: 233 | self.load_data = use_data 234 | self.load_level = 1 235 | return self # successful load 236 | 237 | if '.' in self._load: 238 | load_api, load_func = self._load.split('.') 239 | self.old = self.orm.caddy.soap_call(load_api, load_func, self.record_id) 240 | self.data = copy.copy(self.old) 241 | self.load_level = 3 242 | return self # successful load 243 | 244 | if self._load == 'FROM_PARENT': 245 | self.load_level = 2 246 | if self.parent.load(): 247 | if hasattr(self, 'load_from_parent'): 248 | self.load_from_parent() 249 | return self # successful load 250 | else: 251 | return False 252 | 253 | return False 254 | 255 | def load_once(self): 256 | if not self.load_level or self.load_level == 0: 257 | return self.load() 258 | else: 259 | return self 260 | 261 | def save(self, *args, **kwargs): 262 | if self.parent and self._save == 'FROM_PARENT': 263 | self.parent.save() 264 | return self 265 | 266 | if not self._save or not '.' in self._save: 267 | return False 268 | 269 | save_api, save_func = self._save.split('.') 270 | send_data = copy.deepcopy(self.data) 271 | 272 | for contact_type in 'Phones Emails Faxes Pagers'.split(' '): 273 | if hasattr(send_data, contact_type): 274 | remove_list = [] 275 | for contact_item in getattr(send_data, contact_type)[0]: 276 | if contact_item.Value is None and contact_item.Id in (0, '0', None): 277 | remove_list.append(contact_item) 278 | 279 | for contact_item in remove_list: 280 | getattr(send_data, contact_type)[0].remove(contact_item) 281 | 282 | if len(getattr(send_data, contact_type)[0]) == 0: 283 | delattr(send_data, contact_type) 284 | 285 | if save_func == 'AddOrUpdateTicketNote': 286 | saved_data = self.orm.caddy.soap_call(save_api, save_func, send_data, self.parent.record_id) 287 | elif save_func.endswith('ViaCompanyId'): 288 | saved_data = self.orm.caddy.soap_call(save_api, save_func, send_data.CompanyId, send_data) 289 | elif save_func.endswith('ViaCompanyIdentifier'): 290 | saved_data = self.orm.caddy.soap_call(save_api, save_func, send_data.CompanyIdentifier, send_data) 291 | else: 292 | saved_data = self.orm.caddy.soap_call(save_api, save_func, send_data, *args) 293 | 294 | self.old = saved_data 295 | self.data = copy.copy(self.old) 296 | 297 | for id_attr in list(self._id_fields + ['RecordId','Id',]): 298 | if hasattr(self.data, id_attr): 299 | self.record_id = int(getattr(self.data, id_attr)) 300 | break 301 | 302 | sleep_time = kwargs.get('sleep', 0) 303 | if sleep_time > 0: 304 | time.sleep(sleep_time) 305 | 306 | return self 307 | 308 | def __setattr__(self, key, val): 309 | if hasattr(self.base, key): 310 | setattr(self.data, key, val) 311 | else: 312 | super(CWObject, self).__setattr__(key, val) 313 | 314 | def __getattr__(self, key): 315 | if hasattr(self.base, key): 316 | return getattr(self.data, key) 317 | 318 | elif key == 'Company': return self.get_company() 319 | elif key == 'Contact': return self.get_contact() 320 | 321 | else: 322 | raise AttributeError('No such attribute in data: %s' % key) 323 | 324 | _company = None 325 | def get_company(self): 326 | if self._company is None: 327 | if hasattr(self.data, 'CompanyIdentifier') and self.data.CompanyIdentifier is not None: 328 | res = self.orm.search('Company', 'CompanyIdentifier = "%s"' % self.data.CompanyIdentifier) 329 | if res: 330 | self._company = res[0] 331 | return self._company 332 | 333 | if hasattr(self.data, 'CompanyId') and type(self.data.CompanyId) == int: 334 | res = self.orm.Company(self.data.CompanyId) 335 | if res: 336 | self._company = res 337 | return self._company 338 | 339 | if hasattr(self.data, 'CompanyId') and type(self.data.CompanyId) == str: 340 | res = self.orm.search('Company', 'CompanyIdentifier = "%s"' % self.data.CompanyId) 341 | if res: 342 | self._company = res[0] 343 | return self._company 344 | else: 345 | return self._company 346 | 347 | raise AttributeError("%s has no Company" % self) 348 | 349 | _contact = None 350 | def get_contact(self): 351 | if self._contact is None: 352 | if hasattr(self.data, 'ContactId') and type(self.data.ContactId) == int: 353 | res = self.orm.Company(self.data.ContactId) 354 | if res: 355 | self._contact = res 356 | return self._contact 357 | else: 358 | return self._contact 359 | 360 | raise AttributeError("%s has no Contact" % self) 361 | 362 | def delete(self): 363 | pass 364 | 365 | def get_factory(self, diff_factory_name = None): 366 | if '.' in self._factory: 367 | api_name, factory_name = self._factory.split('.') 368 | if diff_factory_name is not None: 369 | factory_name = diff_factory_name 370 | soap_client = self.orm.caddy.get_client(api_name) 371 | return soap_client.factory.create(factory_name) 372 | 373 | def discard(self): 374 | if self.parent and self._load == 'FROM_PARENT': 375 | self.parent.discard() 376 | return self 377 | else: 378 | self.changes_attrs = {} 379 | self.changes_funcs = [] 380 | return self 381 | 382 | def new_child(self, new_child): 383 | if new_child not in self.children: 384 | self.children.append(new_child) 385 | return new_child 386 | 387 | class Configuration(CWObject): 388 | _name = 'Configuration' 389 | _factory = 'Configuration.Configuration' 390 | _load = 'Configuration.LoadConfiguration' 391 | _search = 'Configuration.FindConfigurations' 392 | _save = 'Configuration.AddOrUpdateConfiguration' 393 | _id_fields = [ 'ConfigID', 'ConfigurationID' ] 394 | 395 | class Activity(CWObject): 396 | _name = 'Activity' 397 | _factory = 'Activity.Activity' 398 | _load = 'Activity.LoadActivity' 399 | _search = 'Activity.FindActivities' 400 | _save = 'Activity.AddOrUpdateActivity' 401 | _delete = 'Activity.DeleteActivity' 402 | _id_fields = [ 'SOActivityRecID', 'ActivityID', 'ActivityRecID' ] 403 | 404 | class Company(CWObject): 405 | _name = 'Company' 406 | _load = 'Company.LoadCompany' 407 | _search = 'Company.FindCompanies' 408 | _save = 'Company.AddOrUpdateCompany' 409 | _factory = 'Company.Company' 410 | _id_fields = [ 'CompanyRecordId', 'CompanyRecId', 'CompanyRecID' ] 411 | 412 | class TicketScheduleEntry(CWObject): 413 | _name = 'TicketScheduleEntry' 414 | _factory = 'Scheduling.TicketScheduleEntry' 415 | _load = 'Scheduling.GetTicketScheduleEntry' 416 | _search = 'Scheduling.FindScheduleEntries' 417 | _search_cond = '( ScheduleType = "Service" OR ScheduleType = "Project" )' 418 | _save = 'Scheduling.AddOrUpdateTicketScheduleEntry' 419 | _id_fields = [ ] 420 | 421 | class ActivityScheduleEntry(CWObject): 422 | _name = 'ActivityScheduleEntry' 423 | _factory = 'Scheduling.ActivityScheduleEntry' 424 | _load = 'Scheduling.GetActivityScheduleEntry' 425 | _search = 'Scheduling.FindActivityEntries' 426 | _search_cond = 'ScheduleType = "Sales"' 427 | _save = 'Scheduling.AddOrUpdateActivityScheduleEntry' 428 | _id_fields = [ ] 429 | 430 | class MiscScheduleEntry(CWObject): 431 | _name = 'MiscScheduleEntry' 432 | _factory = 'Scheduling.MiscScheduleEntry' 433 | _load = 'Scheduling.GetMiscScheduleEntry' 434 | _search = 'Scheduling.FindMiscEntries' 435 | _search_cond = 'ScheduleType = "Sales"' 436 | _save = 'Scheduling.AddOrUpdateMiscScheduleEntry' 437 | _id_fields = [ ] 438 | 439 | class Contact(CWObject): 440 | _name = 'Contact' 441 | _load = 'Contact.LoadContact' 442 | _search = 'Contact.FindContacts' 443 | _save = 'Contact.AddOrUpdateContact' 444 | _delete = 'Contact.DeleteContact' 445 | _factory = 'Contact.Contact' 446 | _id_fields = [ 'ContactRecID', 'ContactID', 'ContactId' ] 447 | 448 | def get_birthday(self): 449 | if self.data.BirthDay and isinstance(self.data.BirthDay, datetime.datetime): 450 | try: 451 | return self.data.BirthDay.strftime('%Y%m%d') 452 | except ValueError: 453 | return # connectwise fuck us again 454 | 455 | def set_birthday(self, birthday_str): 456 | if isinstance(birthday_str, str): 457 | self.data.BirthDay = datetime.datetime.fromtimestamp(time.mktime(time.strptime(birthday_str.replace('-',''), "%Y%m%d"))) 458 | if isinstance(birthday_str, datetime.datetime): 459 | self.data.BirthDay = birthday_str 460 | return self.data.BirthDay 461 | 462 | def get_full_name(self): 463 | return '%s %s' % (self.FirstName, self.LastName) 464 | 465 | class TimeEntry(CWObject): 466 | _name = 'TimeEntry' 467 | _factory = 'TimeEntry.TimeEntry' 468 | _load = 'TimeEntry.LoadTimeEntry' 469 | _search = 'TimeEntry.FindTimeEntries' 470 | _save = 'TimeEntry.AddOrUpdateTimeEntry' 471 | _delete = 'TimeEntry.DeleteTimeEntry' 472 | _id_fields = [ ] 473 | 474 | class TicketNote(CWObject): 475 | _name = 'TicketNote', 476 | _factory = 'ServiceTicket.TicketNote' 477 | _load = 'FROM_PARENT' 478 | _search = 'NOT_AVAILABLE' 479 | _save = 'ServiceTicket.AddOrUpdateTicketNote' 480 | _id_fields = [ 'NoteId', ] 481 | _use_basic = True, 482 | 483 | def load_from_parent(self): 484 | if self.parent._name == 'ServiceTicket': 485 | for attr in ('ResolutionDescription','InternalAnalysisNotes','DetailNotes'): 486 | note_attr = getattr(self.parent.data, attr, None) 487 | if note_attr and len(note_attr[0]) > 0: 488 | for tn_data in note_attr[0]: 489 | if tn_data.Id == self.record_id: 490 | self.data = tn_data 491 | return self 492 | 493 | class ServiceTicket(CWObject): 494 | _name = 'ServiceTicket' 495 | _factory = 'ServiceTicket.ServiceTicket' 496 | _load = 'ServiceTicket.LoadServiceTicket' 497 | _search = 'ServiceTicket.FindServiceTickets' 498 | _save = 'ServiceTicket.AddOrUpdateServiceTicketViaCompanyIdentifier' 499 | _id_fields = [ 'TicketNumber', ] 500 | 501 | original_email = None 502 | 503 | def assoc_configuration(self, config): 504 | _TicketConfiguration = self.orm.caddy.get_client('ServiceTicket').factory.create('TicketConfiguration') 505 | _ArrayOfTicketConfiguration = self.orm.caddy.get_client('ServiceTicket').factory.create('ArrayOfTicketConfiguration') 506 | _ArrayOfTicketNote = self.orm.caddy.get_client('ServiceTicket').factory.create('ArrayOfTicketNote') 507 | 508 | if not self.data.Configurations or self.data.Configurations == "": 509 | self.data.Configurations = _ArrayOfTicketConfiguration 510 | 511 | _TicketConfiguration.Id = config.record_id 512 | self.data.Configurations[0].append(_TicketConfiguration) 513 | 514 | def first_ticket_note(self): 515 | if not self.DetailNotes: 516 | return False 517 | first_note = None 518 | for note in self.DetailNotes[0]: 519 | if first_note is None or first_note.Id > note.Id: 520 | first_note = note 521 | return self.orm.TicketNote(first_note.Id, parent=self) 522 | 523 | def first_internal_note(self): 524 | if not self.InternalAnalysisNotes: 525 | return False 526 | first_note = None 527 | for note in self.InternalAnalysisNotes[0]: 528 | if first_note is None or first_note.Id > note.Id: 529 | first_note = note 530 | return self.orm.TicketNote(first_note.Id, parent=self) 531 | 532 | def first_ticket_doc(self): 533 | if not self.Documents: 534 | return False 535 | first_doc = None 536 | for doc in self.Documents[0]: 537 | if first_doc is None or first_doc.Id > doc.Id: 538 | first_doc = doc 539 | return first_doc 540 | 541 | def get_original_email(self): 542 | if self.original_email is not None: 543 | return self.original_email 544 | if not self.Documents: 545 | return False 546 | 547 | first_doc = None 548 | for doc in self.Documents[0]: 549 | if ( first_doc is None or first_doc.Id > doc.Id ) and doc.FileName.endswith('.eml'): 550 | first_doc = doc 551 | 552 | if first_doc: 553 | real_doc = self.orm.caddy.soap_call('ServiceTicket', 'GetDocument', doc.Id) 554 | email_str = str(real_doc.Content).decode('base64','strict').decode('utf-8-sig').encode('utf-8') 555 | parsed = email.parser.Parser().parsestr(email_str) 556 | self.original_email = parsed 557 | return self.original_email 558 | --------------------------------------------------------------------------------