88import logging
99import socket
1010import struct
11- import sys
1211import time
1312from functools import partial
1413from io import StringIO
@@ -104,6 +103,7 @@ class BaseFishbowl:
104103 port = 28192
105104 encoding = "latin-1"
106105 login_timeout = 3
106+ chunk_size = 1024
107107
108108 def __init__ (self , task_name = None ):
109109 self ._connected = False
@@ -170,6 +170,46 @@ def close(self, skip_errors=False):
170170 )
171171 raise
172172
173+ def read_response (self , stream ):
174+ """
175+ Read a Fishbowl formatted network response from the provided socket.
176+
177+ Fishbowl sends a 32bit unsigned integer (big endian) as the first 4
178+ bytes of every response, this is the length of the response in bytes,
179+ not including those 4 initial bytes.
180+
181+ The rest of the response depends on the message that was sent, so it
182+ is returned after being decoded from a bytestring into whatever
183+ encoding is currently set.
184+ """
185+ response = b""
186+ received_length = False
187+ try :
188+ packed_length = b""
189+ while len (packed_length ) < 4 :
190+ packed_length += stream .recv (4 - len (packed_length ))
191+ length = struct .unpack (">L" , packed_length )[0 ]
192+ received_length = True
193+ left_to_read = length
194+ while left_to_read > 0 :
195+ if left_to_read < self .chunk_size :
196+ buff = stream .recv (left_to_read )
197+ else :
198+ buff = stream .recv (self .chunk_size )
199+ response += buff
200+ left_to_read -= len (buff )
201+ except socket .timeout :
202+ self .close (skip_errors = True )
203+ if received_length :
204+ msg = "Connection timeout (after length received)"
205+ else :
206+ msg = "Connection timeout"
207+ logger .exception (msg )
208+ raise FishbowlTimeoutError (msg )
209+ response = response .decode (self .encoding )
210+ logger .debug ("Response received:\n %s" , response )
211+ return response
212+
173213
174214class JSONFishbowl (BaseFishbowl ):
175215 auth_request = jsonrequests .Login
@@ -237,30 +277,7 @@ def send_message(self, msg):
237277 logger .debug ("Sending message:\n %s" , msg )
238278 self .stream .send (self .pack_message (msg .encode ("utf-8" )))
239279
240- # Get response
241- byte_count = 0
242- response = bytearray ()
243- received_length = False
244- try :
245- packed_length = b""
246- while len (packed_length ) < 4 :
247- packed_length += self .stream .recv (4 - len (packed_length ))
248- length = struct .unpack (">L" , packed_length )[0 ]
249- received_length = True
250- while byte_count < length :
251- byte = ord (self .stream .recv (1 ))
252- byte_count += 1
253- response .append (byte )
254- except socket .timeout :
255- self .close (skip_errors = True )
256- if received_length :
257- msg = "Connection timeout (after length received)"
258- else :
259- msg = "Connection timeout"
260- logger .exception (msg )
261- raise FishbowlTimeoutError (msg )
262- response = response .decode (self .encoding )
263- logger .debug ("Response received:\n %s" , response )
280+ response = self .read_response (self .stream )
264281
265282 return json .loads (response )
266283
@@ -490,30 +507,8 @@ def send_message(self, msg):
490507 logger .debug ("Sending message:\n " + msg .decode (self .encoding ))
491508 self .stream .send (self .pack_message (msg ))
492509
493- # Get response
494- byte_count = 0
495- response = bytearray ()
496- received_length = False
497- try :
498- packed_length = b""
499- while len (packed_length ) < 4 :
500- packed_length += self .stream .recv (4 - len (packed_length ))
501- length = struct .unpack (">L" , packed_length )[0 ]
502- received_length = True
503- while byte_count < length :
504- byte = ord (self .stream .recv (1 ))
505- byte_count += 1
506- response .append (byte )
507- except socket .timeout :
508- self .close (skip_errors = True )
509- if received_length :
510- msg = "Connection timeout (after length received)"
511- else :
512- msg = "Connection timeout"
513- logger .exception (msg )
514- raise FishbowlTimeoutError (msg )
515- response = response .decode (self .encoding )
516- logger .debug ("Response received:\n " + response )
510+ response = self .read_response (self .stream )
511+
517512 return etree .fromstring (response )
518513
519514 @require_connected
@@ -540,7 +535,7 @@ def get_part_info(self, partnum):
540535 return self .send_message (request )
541536
542537 @require_connected
543- def get_total_inventory (self , partnum , locationid ):
538+ def get_total_inventory (self , partnum , locationgroup ):
544539 """
545540 Returns total inventory count at specified location
546541 """
@@ -792,6 +787,14 @@ def get_products_fast(self, populate_uoms=True, custom_bools=None):
792787 )
793788
794789 for row in self .send_query (sql ):
790+ # NOTE: the PRODUCTS_SQL query selects every column from the
791+ # PRODUCT table (the P.* at the beginning) and at some
792+ # point Fishbowl has added a 'customFields' column that
793+ # seems to have a JSON blob in it... anyway, that conflicts
794+ # with how we parse out custom fields in actual XML
795+ # responses, so we get rid of it here.
796+ if "customFields" in row :
797+ del row ["customFields" ]
795798 product = objects .Product (row , name = row .get ("NUM" ))
796799 if not product :
797800 continue
@@ -894,6 +897,89 @@ def save_so(self, so):
894897 check_status (response .find ("FbiMsgsRs" ))
895898 return objects .SalesOrder (response .find ("SalesOrder" ))
896899
900+ @require_connected
901+ def get_available_imports (self ):
902+ """
903+ Return a list of available export types.
904+
905+ Each export type is the string name of the export that can be directly
906+ used with get_export()
907+ """
908+ request = xmlrequests .ImportListRequest (key = self .key )
909+ response = self .send_message (request )
910+ check_status (response .find ("FbiMsgsRs" ))
911+ return [x .text for x in response .xpath ("//ImportNames/ImportName" )]
912+
913+ @require_connected
914+ def get_import_headers (self , import_type ):
915+ """
916+ Return the expected CSV headers for the provided import type.
917+
918+ import_type should be a valid import from the Fishbowl documentation
919+ as found here: https://www.fishbowlinventory.com/wiki/Imports_and_Exports#List_of_imports_and_exports
920+ with the name formatted like 'ImportCsvName' with all spaces removed.
921+ """
922+ request = xmlrequests .ImportHeaders (import_type , key = self .key )
923+ response = self .send_message (request )
924+ check_status (response .find ("FbiMsgsRs" ))
925+ return response .xpath ("//Header/Row" )[0 ].text
926+
927+ @require_connected
928+ def run_import (self , import_type , rows ):
929+ """
930+ Run the provided import type with the provided rows.
931+
932+ Ideally used with format_rows.
933+
934+ Rows can, and ideally should, contain a header entry as the first item
935+ to assist fishbowl in determining the data format. You can get the
936+ full list of headers for a specific import via the get_import_headers()
937+ method.
938+
939+ Depending on the import you are running some columns may be optional
940+ and can be left out of the rows data entirely. Be sure to update the
941+ header entry you provide to match the data you leave out.
942+
943+ For some imports, if a duplicate 'key' is included, for example you have
944+ two rows with the same PartNumber in a ImportPart request, the last
945+ row will take precedence and it is as if the previous rows do not exist.
946+
947+ Refer to the Fishbowl documentation for the specific import you are
948+ using: https://www.fishbowlinventory.com/wiki/Imports_and_Exports#List_of_imports_and_exports
949+ """
950+ request = xmlrequests .ImportRequest (import_type , rows , key = self .key )
951+ response = self .send_message (request )
952+ check_status (response .xpath ("//ImportRs" )[0 ])
953+
954+ @require_connected
955+ def get_available_exports (self ):
956+ """
957+ Return a list of available export types.
958+
959+ Each export type is the string name of the export that can be directly
960+ used with get_export()
961+ """
962+ request = xmlrequests .ExportListRequest (key = self .key )
963+ response = self .send_message (request )
964+ check_status (response .find ("FbiMsgsRs" ))
965+ return [x .text for x in response .xpath ("//Exports/ExportName" )]
966+
967+ @require_connected
968+ def run_export (self , export_type ):
969+ """
970+ Return the result rows of the provided export type.
971+
972+ export_type must be one of the types as returned from
973+ get_available_exports()
974+
975+ The first result should be the header row, but Fishbowl documentation
976+ doesn't appear to guarantee that.
977+ """
978+ request = xmlrequests .ExportRequest (export_type , key = self .key )
979+ response = self .send_message (request )
980+ check_status (response .find ("FbiMsgsRs" ))
981+ return [x .text for x in response .xpath ("//Rows/Row" )]
982+
897983
898984class FishbowlAPI :
899985 """
@@ -952,3 +1038,15 @@ def check_status(element, expected=statuscodes.SUCCESS, allow_none=False):
9521038 if str (code ) != expected and (code is not None or not allow_none ):
9531039 raise FishbowlError (message )
9541040 return message
1041+
1042+
1043+ def format_rows (rows ):
1044+ """
1045+ Format rows for use with run_import.
1046+ """
1047+ buff = StringIO (newline = "" )
1048+ writer = csv .writer (buff , quoting = csv .QUOTE_ALL )
1049+ for row in rows :
1050+ writer .writerow (row )
1051+ buff .seek (0 )
1052+ return [x .strip () for x in buff .readlines ()]
0 commit comments