Skip to content

Commit b5dbc6a

Browse files
authored
Merge pull request #1 from patriotresearch/add-import-export-support
Add import export support and fixup tests.
2 parents c04443a + 29c1fc1 commit b5dbc6a

File tree

6 files changed

+224
-86
lines changed

6 files changed

+224
-86
lines changed

fishbowl/api.py

Lines changed: 148 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import logging
99
import socket
1010
import struct
11-
import sys
1211
import time
1312
from functools import partial
1413
from 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

174214
class 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

898984
class 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()]

fishbowl/objects.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ def all_fishbowl_objects():
4141
)
4242

4343

44+
def strip_text(el):
45+
if el.text:
46+
return el.text.strip()
47+
return ""
48+
49+
4450
class FishbowlObject(collections.Mapping):
4551
id_field = None
4652
name_attr = None
@@ -143,7 +149,7 @@ def get_xml_data(self, base_el):
143149
children = len(child)
144150
key = child.tag
145151
if children:
146-
if [el for el in child if el.text.strip()]:
152+
if [el for el in child if strip_text(el)]:
147153
data[key] = self.get_xml_data(child)
148154
else:
149155
inner = []

fishbowl/tests/objects/test_so.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
from __future__ import unicode_literals
2+
3+
import datetime
24
import os
35
from decimal import Decimal
46

57
from fishbowl import objects
8+
69
from . import utils
710

811

@@ -17,9 +20,12 @@ class SalesOrderTest(utils.ObjectTest):
1720
"Zip": "93101",
1821
},
1922
"Carrier": "Will Call",
23+
"CreatedDate": datetime.datetime(2007, 8, 29, 0, 0),
2024
"CustomerContact": "Beach Bike",
2125
"CustomerName": "Beach Bike",
2226
"FOB": "Origin",
27+
"FirstShipDate": datetime.datetime(2007, 8, 29, 0, 0),
28+
"IssuedDate": datetime.datetime(2007, 8, 29, 16, 48, 56),
2329
"Items": [
2430
{
2531
"Description": "Battery Pack",

0 commit comments

Comments
 (0)