diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..efb50c5 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,14 @@ +language: python +python: +- "2.7" +script: +- echo "1" +deploy: + provider: pypi + user: hparfr + password: + secure: yyroV8BdvIaQZSbDf/6zKXmu0uL4u5SdyxCPLTJU+qyTlhtS/IJAJZK+W00zu6rOimHUFSgkhi0s21kuck9m4gYuDZnl9ghSuTCkmlra1l4pY565XkIutu1ArGBViQb5A6EkWf8oX9JPXY2fnYr4R/7PMQbDEyY/Ft9yG5zLsug5DtmUJgYU3M9r3lweN8Jv9TFRXjBEFwhbtgpe+/0mWHAMyDQmZE/+CocSf2F7LtILPaqIGvVVXJg3mItV1vCzGi0FbHolIWRnSpLCSun/G2ldQcNUrtKnZY+2vPFfnOGLOwHn8iKqY7Rxid7cI8vYFE65tWkJZ+g6gKI8Rk0JG0IBE1hUl86Smy3+g84Q5SfVDiCXRFQwpAx77SPsfrUD7uYzFQAf+n43inF1X/r26hdZnhr0FjWVUVvtJIVeUGOtO1Dp1NFIpLmCvXHDT8nV6qva7+yIMU2+6HPI2Sy41xMpDAKPJWCCq7rdKd4t0sPnpj2PFdDQ1nY8ZuQV1iQfuqv82Vz89fFCW5S5OHoN01EC6zThAjfRFkAefPtGP7QCZnaAGMcsLhDDG9J/S5Np4sx33hrFuvGtrv/P6zQCPOS7iBn3UA0xEzZORUEImVKL5eFJtQdS7Firl2HYYvPkv7SsDHUvONjk0Drv3Gsk4AtB7Wxx26bXgoqLDrdt0Pk= + distributions: sdist bdist_wheel + on: + tags: true + branch: master diff --git a/CHANGELOG.md b/CHANGELOG.md index 57a4ddd..0afb9be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,31 @@ -# 0.1.1 Roadmap / TODO: - Add Kuehne & Nagel carrier + - Add Geodis EDI - Add UPS carrier - Support test_mode for some carriers - Improve documentation - Support additionnal methods of api - - Support carrier tracking - Write tests +# 0.1.1 2017-03-20 + + +### Features / Refactorings + - Refactoring of error handling: exception are now always raised, returns code (success | warning) removed + - Simplification of return code + - Remove duplicate code of get_parts + - Handling of additionnal attachments ("annexes") + - tracking code is always in tracking.number + - label is always in label.data + +### BREAKING CHANGES + - errors are all raised with exceptions + - raise Exception removed in favor of better defined exceptions + - return of get_label harmonized + + # 0.1.0 2016-12-19 ### BREAKING CHANGES diff --git a/README.md b/README.md index 31f929d..67f4993 100755 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Roulier will get a label + tracking number to your carrier for you. * Roulier runs on your server and call each carrier API directly. * You have to use your own credentials provided by each carriers. * Roulier is Open Source software, AGPL-3 -* Roulier integrate a multitude of carriers : Laposte, Geodis, DPD, K&N... more to come. +* Roulier integrate a multitude of carriers : Laposte, Geodis, DPD, K&N, TRS... more to come. @@ -74,7 +74,38 @@ pprint(laposte.api()) ``` -Advanced usage for Laposte +### Return + +```python +from roulier import roulier +laposte = roulier.get('laposte') +api = laposte.api() +api['auth']['login'] = '12345' +... +print laposte.get_label(api) + +# { +# label: { +# 'name':'label', +# 'type': 'zpl', +# 'data': 'base64 here', +# }, +# tracking: { +# 'number': 'tracking code here', +# }, +# annexes: [ +# { +# 'name': 'cn23', +# 'type': 'pdf', +# 'data': 'base64 here' +# }, ... +# ], +#} +``` + + + +### Advanced usage for Laposte Usefull for debugging: get the xml before the call, send an xml directly, analyse the response @@ -138,7 +169,7 @@ l_api._parcel() ``` -###Contributors +### Contributors * [@hparfr](https://github.com/hparfr) ([Akretion.com](https://akretion.com)) @@ -153,3 +184,5 @@ l_api._parcel() * [Jinja2](http://jinja.pocoo.org/) - templating * [Requests](http://docs.python-requests.org/) - HTTP requests * [zplgrf](https://github.com/kylemacfarlane/zplgrf) - PNG to ZPL conversion +* [unidecode](https://pypi.python.org/pypi/Unidecode) - Remove accents from ZPL +* [unicodecsv](https://github.com/jdunck/python-unicodecsv) - CSV generation \ No newline at end of file diff --git a/VERSION b/VERSION index 6c6aa7c..6da28dd 100755 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 \ No newline at end of file +0.1.1 \ No newline at end of file diff --git a/roulier/api.py b/roulier/api.py index f25e4e7..f2225da 100644 --- a/roulier/api.py +++ b/roulier/api.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """API interface.""" from cerberus import Validator +from unidecode import unidecode class MyValidator(Validator): @@ -14,6 +15,26 @@ def _validate_description(self, description, field, value): """ pass + def _normalize_coerce_zpl(self, value): + """Sanitze input for ZPL. + + Remove ZPL ctrl caraters + Remove accents + """ + if not isinstance(value, basestring): + return value + + ctrl_cars = [ + 0xFE, # Tilde ~ + 0x5E, # Caret ^ + 0x1E, # RS (^ substitution) + 0x10, # DLE (~ substitution) + ] + val = unidecode(value) + for ctrl in ctrl_cars: + val = val.replace("%c" % ctrl, "") + return val + class Api(object): """Define expected fields of carriers. diff --git a/roulier/carriers/__init__.py b/roulier/carriers/__init__.py index b3e4e60..d5a3e6f 100755 --- a/roulier/carriers/__init__.py +++ b/roulier/carriers/__init__.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- from . import laposte +from . import dummy from . import geodis from . import dummy from . import dpd +from . import trs diff --git a/roulier/carriers/dpd/dpd.py b/roulier/carriers/dpd/dpd.py index 95570db..d797f9f 100755 --- a/roulier/carriers/dpd/dpd.py +++ b/roulier/carriers/dpd/dpd.py @@ -4,9 +4,7 @@ from .dpd_decoder import DpdDecoder from .dpd_encoder import DpdEncoder from .dpd_transport import DpdTransport -from .dpd_api import DpdApi from roulier.carrier import Carrier -from roulier import ws_tools as tools class Dpd(Carrier): @@ -24,33 +22,10 @@ def get(self, data, action): """Run an action with data against Dpd WS.""" request = self.encoder.encode(data, action) response = self.ws.send(request) - if not response['payload']: - return response - payload = self.decoder.decode(response, {}) - zpl = self.handle_zpl(data, payload['label']) - payload['label'] = zpl if zpl else payload['label'] - return payload + return self.decoder.decode( + response['body'], request['output_format']) # shortcuts def get_label(self, data): """Genereate a createShipmentWithLabels.""" return self.get(data, 'createShipmentWithLabels') - - # utils - def handle_zpl(self, data, png): - """Convert a png in zpl. - - if labelFormat was asked as ZPL, WS returns a png - This function rotate it and convert it an suitable zpl format - @params: - data : full dict with all the params of the get method - png : a base64 formatted string (returned by ws) - @returns: - a zpl in a string - - """ - label_format = DpdApi().normalize(data)['service']['labelFormat'] - - if label_format == 'ZPL': - return tools.png_to_zpl(png, True) - return None diff --git a/roulier/carriers/dpd/dpd_api.py b/roulier/carriers/dpd/dpd_api.py index 8f413ab..b993915 100644 --- a/roulier/carriers/dpd/dpd_api.py +++ b/roulier/carriers/dpd/dpd_api.py @@ -12,7 +12,13 @@ 'No', 'Predict', 'AutomaticSMS', - 'AutomaticEmail', + 'AutomaticMail', +) +DPD_PRODUCTS = ( + 'DPD_Classic', + 'DPD_Relais', + 'DPD_Predict', + # 'DPD Retour en Relais', # Not implemented yet ) @@ -22,16 +28,35 @@ def _service(self): schema['labelFormat']['allowed'] = DPD_LABEL_FORMAT schema['labelFormat']['default'] = 'ZPL' schema['labelFormat'].update({'required': True, 'empty': False}) - schema['agencyId'].update({'required': True, 'empty': False, 'description': 'Agency code int(3)'}) - schema['customerCountry'] = {'required': True, 'empty': False, 'description': 'Customer country code (France = 250) int(3)'} - schema['customerId'].update({'required': True, 'empty': False, 'description': 'Customer number int(6)'}) + schema['agencyId'].update({ + 'required': True, 'empty': False, + 'description': 'Agency code int(3)'}) + schema['customerCountry'] = { + 'required': True, 'empty': False, + 'description': 'Customer country code (France = 250) int(3)'} + schema['customerId'].update({ + 'required': True, 'empty': False, + 'description': 'Customer number int(6)'}) schema['shippingDate'].update({'required': False, 'empty': True}) # mettre ça ensemble ? - schema['notifications'] = {'default': 'Predict', 'allowed': DPD_ALLOWED_NOTIFICATIONS} - schema['product'].update({'description': 'N/A for DPD'}) + schema['notifications'] = { + 'default': DPD_ALLOWED_NOTIFICATIONS[0], + 'allowed': DPD_ALLOWED_NOTIFICATIONS} + schema['product'].update({ + 'empty': False, + 'required': True, + 'default': DPD_PRODUCTS[0], + 'description': 'Type de produit', + 'allowed': DPD_PRODUCTS + }) - schema['dropOffLocation'] = {'default': '', 'description': 'Drop-off Location id (Relais Colis)' } + schema['dropOffLocation'] = { + 'default': '', + 'empty': True, + 'required': False, + 'description': 'Drop-off Location id (Relais Colis)' + } return schema diff --git a/roulier/carriers/dpd/dpd_decoder.py b/roulier/carriers/dpd/dpd_decoder.py index da4caef..d9e7ef7 100755 --- a/roulier/carriers/dpd/dpd_decoder.py +++ b/roulier/carriers/dpd/dpd_decoder.py @@ -2,17 +2,13 @@ """Dpd XML -> Python.""" from lxml import objectify from roulier.codec import Decoder +from roulier import ws_tools as tools class DpdDecoder(Decoder): """Dpd XML -> Python.""" - def decode(self, response, parts): - payload_xml = response['payload'] - tag, content = self.decode_payload(payload_xml) - return content - - def decode_payload(self, xml_string): + def decode(self, body, output_format): """Dpd XML -> Python.""" def create_shipment_with_labels(msg): """Understand a CreateShipmentWithLabelsResponse.""" @@ -21,19 +17,41 @@ def create_shipment_with_labels(msg): ) shipment = shipments.getchildren()[0] label, attachment = labels.getchildren() + label_data = self.handle_zpl(label.label.text, output_format) # .text because we want str instead of objectify.StringElement x = { - 'barcode': shipment.barcode.text, - 'parcelnumber': shipment.parcelnumber.text, - 'label': label.label.text, - 'attachment': attachment.label.text + "tracking": { + 'number': shipment.barcode.text, + 'parcelnumber': shipment.parcelnumber.text, + }, + "label": { + "data": label_data, + "name": "label", + "type": output_format, + }, + "annexes": [{ + "data": attachment.label.text, + "name": "Summary", + "type": output_format + }] } return x - xml = objectify.fromstring(xml_string) + xml = objectify.fromstring(body) tag = xml.tag lookup = { "{http://www.cargonet.software}CreateShipmentWithLabelsResponse": create_shipment_with_labels, } - return tag, lookup[tag](xml) + return lookup[tag](xml) + + def handle_zpl(self, png, label_format): + """Convert a png in zpl. + + if labelFormat was asked as ZPL, WS returns a png + This function rotate it and convert it an suitable zpl format + """ + if label_format == 'ZPL': + return tools.png_to_zpl(png, True) + else: + return png diff --git a/roulier/carriers/dpd/dpd_encoder.py b/roulier/carriers/dpd/dpd_encoder.py index c8935bf..a1d1c02 100755 --- a/roulier/carriers/dpd/dpd_encoder.py +++ b/roulier/carriers/dpd/dpd_encoder.py @@ -4,8 +4,11 @@ from roulier.codec import Encoder from datetime import datetime from .dpd_api import DpdApi +from roulier.exception import InvalidApiInput +import logging DPD_ACTIONS = ('createShipmentWithLabels') +log = logging.getLogger(__name__) class DpdEncoder(Encoder): @@ -14,21 +17,50 @@ class DpdEncoder(Encoder): def encode(self, api_input, action): """Transform input to dpd compatible xml.""" if not (action in DPD_ACTIONS): - raise Exception( + raise InvalidApiInput( 'action %s not in %s' % (action, ', '.join(DPD_ACTIONS))) api = DpdApi() if not api.validate(api_input): - raise Exception( + raise InvalidApiInput( 'Input error : %s' % api.errors(api_input)) data = api.normalize(api_input) + # add some rules which are hard to implement with + # cerberus. + # TODO: add additional schemas for that + if data['service']['product'] == 'DPD_Predict': + if len(data['service']['dropOffLocation']) > 0: + raise InvalidApiInput( + "dropOffLocation can't be used with predict") + if data['service']['notifications'] != 'Predict': + log.info( + 'Notification forced to predict because of product') + data['service']['notifications'] = 'Predict' + + if data['service']['product'] == 'DPD_Classic': + if len(data['service']['dropOffLocation']) > 0: + raise InvalidApiInput( + "dropOffLocation can't be used with classic") + if data['service']['notifications'] == 'Predict': + raise InvalidApiInput( + "Predict notifications can't be used with classic") + + if data['service']['product'] == 'DPD_Relais': + if len(data['service']['dropOffLocation']) < 1: + raise InvalidApiInput( + "dropOffLocation is mandatory for this product") + if data['service']['notifications'] == 'Predict': + raise InvalidApiInput( + "Predict notifications can't be used with Relais") + data['service']['shippingDate'] = ( datetime .strptime(data['service']['shippingDate'], '%Y/%M/%d') .strftime('%d/%M/%Y') ) + output_format = data['service']['labelFormat'] if data['service']['labelFormat'] in ('PNG', 'ZPL'): # WS doesn't handle zpl yet, we convert it later # png is named Default, WTF DPD? @@ -45,7 +77,8 @@ def encode(self, api_input, action): parcel=data['parcel'], sender_address=data['from_address'], receiver_address=data['to_address']), - "headers": data['auth'] + "headers": data['auth'], + "output_format": output_format } def api(self): diff --git a/roulier/carriers/dpd/dpd_transport.py b/roulier/carriers/dpd/dpd_transport.py index 634b1eb..e794891 100755 --- a/roulier/carriers/dpd/dpd_transport.py +++ b/roulier/carriers/dpd/dpd_transport.py @@ -1,11 +1,12 @@ # -*- coding: utf-8 -*- """Implement dpdWS.""" import requests -import email.parser from lxml import objectify, etree from jinja2 import Environment, PackageLoader from roulier.transport import Transport from roulier.ws_tools import remove_empty_tags +from roulier.exception import CarrierError + import logging log = logging.getLogger(__name__) @@ -15,8 +16,6 @@ class DpdTransport(Transport): """Implement Dpd WS communication.""" DPD_WS = "https://e-station.cargonet.software/dpd-eprintwebservice/eprintwebservice.asmx" - STATUS_SUCCES = "success" - STATUS_ERROR = "error" def send(self, payload): """Call this function. @@ -26,11 +25,8 @@ def send(self, payload): payload.header : auth Return: { - status: STATUS_SUCCES or STATUS_ERROR, (string) - message: more info about status of result (lxml) response: (Requests.response) - payload: usefull payload (if success) (xml as string) - + body: XML response (without soap) } """ body = payload['body'] @@ -65,21 +61,14 @@ def handle_500(self, response): """Handle reponse in case of ERROR 500 type.""" log.warning('Dpd error 500') obj = objectify.fromstring(response.content) - return { + errors = [{ "id": obj.xpath('//faultcode')[0], - "status": self.STATUS_ERROR, "message": obj.xpath('//faultstring')[0], - "response": response, - "payload": None - } + }] + raise CarrierError(response, errors) def handle_200(self, response): - """ - Handle response type 200 (success). - - It still can be a success or a failure. - """ - + """Handle response type 200 (success).""" def extract_soap(response_xml): obj = objectify.fromstring(response_xml) return obj.Body.getchildren()[0] @@ -87,22 +76,18 @@ def extract_soap(response_xml): body = extract_soap(response.content) body_xml = etree.tostring(body) return { - "status": "ok", - "message": "", - "payload": body_xml, + "body": body_xml, "response": response, } def handle_response(self, response): """Handle response of webservice.""" - if response.status_code == 500: - return self.handle_500(response) - elif response.status_code == 200: + if response.status_code == 200: return self.handle_200(response) + elif response.status_code == 500: + return self.handle_500(response) else: - return { - "status": "error", - "message": "Unexpected status code from server", - "response": response - } - + raise CarrierError(response, [{ + 'id': None, + 'message': "Unexpected status code from server", + }]) diff --git a/roulier/carriers/dpd/templates/dpd_createShipmentWithLabels.xml b/roulier/carriers/dpd/templates/dpd_createShipmentWithLabels.xml index 4f1702d..61349fa 100644 --- a/roulier/carriers/dpd/templates/dpd_createShipmentWithLabels.xml +++ b/roulier/carriers/dpd/templates/dpd_createShipmentWithLabels.xml @@ -13,12 +13,16 @@ {% include "dpd_address.xml" %} + {% endwith %} + + {% with address = receiver_address %} {{ address.phone }} {{ address.email }} {{ service.notifications }} + {% endwith %} {% if service.dropOffLocation %} @@ -27,7 +31,6 @@ {% endif %} - {% endwith %} {{ service.labelFormat }} diff --git a/roulier/carriers/geodis/geodis.py b/roulier/carriers/geodis/geodis.py index ed3ba5f..a9568b1 100755 --- a/roulier/carriers/geodis/geodis.py +++ b/roulier/carriers/geodis/geodis.py @@ -21,10 +21,10 @@ def get(self, data, action): """Run an action with data against Geodis WS.""" request = self.encoder.encode(data, action) response = self.ws.send(request) - if response['status'] == 'error': - return response - parts = response['attachement'] - return self.decoder.decode(response, parts) + return self.decoder.decode( + response['body'], + response['parts'], + request['output_format']) # shortcuts def get_label(self, data): diff --git a/roulier/carriers/geodis/geodis_api.py b/roulier/carriers/geodis/geodis_api.py index 7dd9252..1d06abb 100644 --- a/roulier/carriers/geodis/geodis_api.py +++ b/roulier/carriers/geodis/geodis_api.py @@ -18,9 +18,13 @@ def _service(self): schema['labelFormat']['default'] = 'ZPL' schema['labelFormat'].update({'required': True, 'empty': False}) schema['product'].update({'required': True, 'empty': False}) - schema['agencyId'].update({'required': True, 'empty': False}) + schema['agencyId'].update({'required': False, 'empty': True}) schema['customerId'].update({'required': True, 'empty': False}) schema['shippingId'].update({'required': True, 'empty': False}) + schema['hubId'] = { + 'description': 'TEOS : code agence Hub de sortie', + 'default': '' + } schema['is_test'] = { 'type': 'boolean', 'default': True, 'description': 'Use test WS'} diff --git a/roulier/carriers/geodis/geodis_decoder.py b/roulier/carriers/geodis/geodis_decoder.py index 74c40b0..35c58b2 100755 --- a/roulier/carriers/geodis/geodis_decoder.py +++ b/roulier/carriers/geodis/geodis_decoder.py @@ -1,32 +1,43 @@ # -*- coding: utf-8 -*- """Geodis XML -> Python.""" -from lxml import objectify from roulier.codec import Decoder class GeodisDecoder(Decoder): """Geodis XML -> Python.""" - def decode(self, response, parts): - payload_xml = response['payload'] - tag, content = self.decode_payload(payload_xml, parts) - - return content - - def decode_payload(self, payload, parts): + def decode(self, body, parts, output_format): """Geodis XML -> Python.""" def reponse_impression_etiquette(msg, parts): x = { - 'barcode': payload.infoColis.cab, - 'cab': payload.cabRouting, - 'label': parts, + "tracking": { + "barcode": body.cabRouting, + }, + "label": { + "name": "label", + "data": parts, + "type": output_format + }, + "annexes": [ + ], + "extra": { + "reseau": body.reseau, + "priorite": body.priorite, + "codeDirectionel": body.codire, + "cabRouting": body.cabRouting, + "colis": { + "codeUmg": body.infoColis.codumg, + "numero": body.infoColis.numero, + "cab": body.infoColis.cab, + } + } } return x - tag = payload.tag + tag = body.tag lookup = { "{http://impression.service.web.etiquette.geodis.com}reponseImpressionEtiquette": reponse_impression_etiquette, } - return tag, lookup[tag](payload, parts) + return lookup[tag](body, parts) diff --git a/roulier/carriers/geodis/geodis_encoder.py b/roulier/carriers/geodis/geodis_encoder.py index e4626dd..441ca3e 100755 --- a/roulier/carriers/geodis/geodis_encoder.py +++ b/roulier/carriers/geodis/geodis_encoder.py @@ -14,7 +14,7 @@ class GeodisEncoder(Encoder): def encode(self, api_input, action): """Transform input to geodis compatible xml.""" if not (action in GEODIS_ACTIONS): - raise Exception( + raise InvalidApiInput( 'action %s not in %s' % (action, ', '.join(GEODIS_ACTIONS))) api = GeodisApi() @@ -42,7 +42,8 @@ def encode(self, api_input, action): sender_address=data['from_address'], receiver_address=data['to_address']), "headers": data['auth'], - "is_test": is_test + "is_test": is_test, + "output_format": data['service']['labelFormat'] } def api(self): diff --git a/roulier/carriers/geodis/geodis_transport.py b/roulier/carriers/geodis/geodis_transport.py index 5fe9ee2..25b5815 100755 --- a/roulier/carriers/geodis/geodis_transport.py +++ b/roulier/carriers/geodis/geodis_transport.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- """Implement geodisWS.""" import requests -import email.parser from lxml import objectify from jinja2 import Environment, PackageLoader from roulier.transport import Transport -from roulier.ws_tools import remove_empty_tags +from roulier.ws_tools import remove_empty_tags, get_parts +from roulier.exception import CarrierError import logging log = logging.getLogger(__name__) @@ -16,8 +16,6 @@ class GeodisTransport(Transport): GEODIS_WS = "http://espace.geodis.com/geolabel/services/ImpressionEtiquette" # nopep8 GEODIS_WS_TEST = "http://espace.recette.geodis.com/geolabel/services/ImpressionEtiquette" # nopep8 - STATUS_SUCCES = "success" - STATUS_ERROR = "error" def send(self, payload): """Call this function. @@ -27,18 +25,15 @@ def send(self, payload): payload.header : auth Return: { - status: STATUS_SUCCES or STATUS_ERROR, (string) - message: more info about status of result (lxml) response: (Requests.response) - payload: usefull payload (if success) (xml as string) - + body: XML response (without soap) + parts: dict of attachments } """ body = payload['body'] headers = payload['headers'] is_test = payload['is_test'] soap_message = self.soap_wrap(body, headers) - log.debug(soap_message) response = self.send_request(soap_message, is_test) log.info('WS response time %s' % response.elapsed.total_seconds()) return self.handle_response(response) @@ -72,29 +67,21 @@ def handle_500(self, response): # TODO : put a try catch (like wrong server) # no need to extract_body shit here log.warning('Geodis error 500') - xml = self.get_parts(response)['start'] + xml = get_parts(response)['start'] obj = objectify.fromstring(xml) message = obj.xpath("//*[local-name() = 'message']") if len(message) > 0: message = message[0] or obj.xpath('//faultstring')[0] id_message = obj.xpath("//*[local-name() = 'code']")[0] - return { - "id": obj.xpath('//faultcode')[0], - "status": self.STATUS_ERROR, - "messages": [{ # only one error msg is returned by ws - 'id': id_message, - 'message': message, - }], - "response": response, - } + errors = [{ + "id": id_message, + "message": message, + }] + raise CarrierError(response, errors) def handle_200(self, response): - """ - Handle response type 200 (success). - - It still can be a success or a failure. - """ - parts = self.get_parts(response) + """Handle response type 200.""" + parts = get_parts(response) xml = parts['start'] def extract_soap(response_xml): @@ -102,15 +89,12 @@ def extract_soap(response_xml): return obj.Body.getchildren()[0] payload = extract_soap(xml) - attachement_id = payload.codeAttachement.text[4:] # remove cid: - attachement = parts[attachement_id] - # payload.infoColis.cab + attachement_cid = payload.codeAttachement.text[len('cid:'):] + attachement = parts[attachement_cid] return { - "status": "ok", - "message": "", - "payload": payload, - "attachement": attachement, + "body": payload, + "parts": attachement, "response": response, } @@ -121,33 +105,7 @@ def handle_response(self, response): elif response.status_code == 200: return self.handle_200(response) else: - return { - "status": "error", - "messages": [{ - 'id': False, - 'message': "Unexpected status code from server", - }], - "response": response - } - - def get_parts(self, response): - head_lines = '' - for k, v in response.raw.getheaders().iteritems(): - head_lines += str(k)+':'+str(v)+'\n' - - full = head_lines + response.content - - parser = email.parser.Parser() - decoded_reply = parser.parsestr(full) - parts = {} - start = decoded_reply.get_param('start').lstrip('<').rstrip('>') - i = 0 - for part in decoded_reply.get_payload(): - cid = part.get('content-Id', '').lstrip('<').rstrip('>') - if (not start or start == cid) and 'start' not in parts: - parts['start'] = part.get_payload() - else: - parts[cid or 'Attachment%d' % i] = part.get_payload() - i += 1 - - return parts + raise CarrierError(response, [{ + 'id': None, + 'message': "Unexpected status code from server", + }]) diff --git a/roulier/carriers/geodis/templates/geodis_demandeImpressionEtiquette.xml b/roulier/carriers/geodis/templates/geodis_demandeImpressionEtiquette.xml index 548fae6..e351ee4 100644 --- a/roulier/carriers/geodis/templates/geodis_demandeImpressionEtiquette.xml +++ b/roulier/carriers/geodis/templates/geodis_demandeImpressionEtiquette.xml @@ -1,5 +1,6 @@ {{ service.agencyId }} + {{ service.hubId }} {{ service.customerId }} {{ service.labelFormat }} 1 @@ -25,5 +26,5 @@ {% endwith %} 1 {{ parcel.weight }} - {{ service.reference }} + {{ service.reference1 }} \ No newline at end of file diff --git a/roulier/carriers/laposte/laposte.py b/roulier/carriers/laposte/laposte.py index 3d0b909..feeb561 100755 --- a/roulier/carriers/laposte/laposte.py +++ b/roulier/carriers/laposte/laposte.py @@ -22,15 +22,11 @@ def get(self, data, action): """Run an action with data against Laposte WS.""" request = self.encoder.encode(data, action) response = self.ws.send(request) - if response.get('message') and response['message']['exception']: - return { - 'status': 'error', - 'messages': self.ws.exception_handling( - response['message']['message']), - 'response': response['response'], - } - parts = self.ws.get_parts(response['response']) - return self.decoder.decode(response, parts) + return self.decoder.decode( + response['body'], + response['parts'], + request['output_format'] + ) # shortcuts def get_label(self, data): diff --git a/roulier/carriers/laposte/laposte_api.py b/roulier/carriers/laposte/laposte_api.py index b2d3fd9..c6b9e67 100644 --- a/roulier/carriers/laposte/laposte_api.py +++ b/roulier/carriers/laposte/laposte_api.py @@ -45,6 +45,12 @@ def _service(self): 'default': '', 'description': """Utilisé pour le Colissimo Retour uniquement. " "Définit le mode de transmission de l’étiquette"""} + schema['reference1'].update({ + 'description': """Référence expediteur ('Réf client')""" + }) + schema['reference2'].update({ + 'description': """Référence destinataire ('Réf destinataire')""" + }) return schema def _address(self): @@ -53,15 +59,18 @@ def _address(self): schema['zip'].update({'required': True, 'empty': False}) schema['city'].update({'required': True, 'empty': False}) schema['street0'] = { - 'default': '', 'description': 'Etage, couloir, escalier, appart.'} + 'required': False, 'empty': True, + 'description': """Entrée, bâtiment, immeuble, résidence. """ + """Non utilisé pour la Belgique."""} + schema['street2'].update({ + 'default': '', 'description': 'Etage, couloir, escalier, appart.'}) schema['street1'].update({ 'required': True, 'empty': False, 'description': 'Numéro et libellé de voie. Ex : 5 rue du Bellay'}) - schema['street2'].update({ - 'required': False, 'empty': True, - 'description': 'Entrée, bâtiment, immeuble, résidence'}) schema['street3'] = { - 'default': '', 'description': """Lieu dit ou autre mention"""} + 'default': '', + 'description': """Lieu dit ou autre mention. """ + """Non utilisé pour la Belgique."""} schema['door1'] = {'default': '', 'description': """Code porte 1"""} schema['door2'] = {'default': '', 'description': """Code porte 2"""} schema['intercom'] = {'default': '', 'description': """Interphone"""} @@ -77,12 +86,6 @@ def _to_address(self): schema['firstName'] = { 'default': '', 'description': """Prénom. Obligatoire pour So Colissimo"""} - schema['street2'].update( - {'required': True, 'description': """Numéro et libellé de voie. " - "Ex : « 5 rue du Bellay »"""}) - schema['street1'].update( - {'required': False, 'empty': True, - 'description': 'Entrée, bâtiment, immeuble, résidence'}) return schema def _parcel(self): diff --git a/roulier/carriers/laposte/laposte_decoder.py b/roulier/carriers/laposte/laposte_decoder.py index bbdf863..6e6f5ad 100755 --- a/roulier/carriers/laposte/laposte_decoder.py +++ b/roulier/carriers/laposte/laposte_decoder.py @@ -7,27 +7,7 @@ class LaposteDecoder(Decoder): """Laposte XML -> Python.""" - def decode(self, response, parts): - payload_xml = response['payload'] - tag, content = self.decode_payload(payload_xml) - if tag.endswith('getProductInterResponse'): - return content - else: - # tag is generateLabelResponse - label_cid = content.get('label').getchildren()[0].attrib['href'] - if content.get('cn23'): - cn23_cid = content.get('cn23').getchildren()[0].attrib['href'] - else: - cn23_cid = False - content['cn23'] = False - - content['label'] = parts[label_cid.replace('cid:', '')] - if cn23_cid: - content['cn23'] = parts[cn23_cid.replace('cid:', '')] - - return content - - def decode_payload(self, xml_string): + def decode(self, body, parts, output_format): """Laposte XML -> Python.""" def get_product_inter(msg): """Understand a getProductInterResponse.""" @@ -39,16 +19,48 @@ def get_product_inter(msg): def generate_label_response(msg): """Understand a generateLabelResponse.""" - x = { - "parcelNumber": msg.labelResponse.parcelNumber, - "parcelNumberPartner": msg.labelResponse.find( - 'parcelNumberPartner'), - "cn23": msg.labelResponse.find('cn23'), - "label": msg.labelResponse.find('label'), + def get_cid(tag, tree): + element = tree.find(tag) + if element is None: + return None + href = element.getchildren()[0].attrib['href'] + # href contains cid:236212...-38932@cfx.apache.org + return href[len('cid:'):] # remove prefix + + rep = msg.labelResponse + cn23_cid = get_cid('cn23', rep) + label_cid = get_cid('label', rep) + + annexes = [] + + if cn23_cid: + annexes.append({ + "name": 'cn23', + "data": parts.get(cn23_cid), + "type": "pdf" + }) + + if rep.find('pdfUrl'): + annexes.append({ + "name": "label", + "data": rep.find('pdfUrl'), + "type": "url" + }) + + return { + "tracking": { + "number": rep.parcelNumber, + "partner": rep.find('parcelNumberPartner'), + }, + "label": { + "name": "label", + "data": parts.get(label_cid), + "type": output_format + }, + "annexes": annexes } - return x - xml = objectify.fromstring(xml_string) + xml = objectify.fromstring(body) tag = xml.tag lookup = { "{http://sls.ws.coliposte.fr}getProductInterResponse": @@ -56,4 +68,4 @@ def generate_label_response(msg): "{http://sls.ws.coliposte.fr}generateLabelResponse": generate_label_response } - return tag, lookup[tag](xml.xpath('//return')[0]) + return lookup[tag](xml.xpath('//return')[0]) diff --git a/roulier/carriers/laposte/laposte_encoder.py b/roulier/carriers/laposte/laposte_encoder.py index 5d20c7d..c9af5ec 100755 --- a/roulier/carriers/laposte/laposte_encoder.py +++ b/roulier/carriers/laposte/laposte_encoder.py @@ -15,7 +15,7 @@ class LaposteEncoder(Encoder): def encode(self, api_input, action): """Transform input to laposte compatible xml.""" if not (action in LAPOSTE_ACTIONS): - raise Exception( + raise InvalidApiInput( 'action %s not in %s' % (action, ', '.join(LAPOSTE_ACTIONS))) api = LaposteApi() @@ -42,7 +42,8 @@ def encode(self, api_input, action): sender_address=data['from_address'], receiver_address=data['to_address'], customs=data['customs']), - "headers": data['auth'] + "headers": data['auth'], + "output_format": data['service']['labelFormat'] } def api(self): diff --git a/roulier/carriers/laposte/laposte_transport.py b/roulier/carriers/laposte/laposte_transport.py index 75c8d86..11d5aec 100755 --- a/roulier/carriers/laposte/laposte_transport.py +++ b/roulier/carriers/laposte/laposte_transport.py @@ -5,7 +5,8 @@ from lxml import objectify, etree from jinja2 import Environment, PackageLoader from roulier.transport import Transport -from roulier.ws_tools import remove_empty_tags +from roulier.ws_tools import remove_empty_tags, get_parts +from roulier.exception import CarrierError import logging log = logging.getLogger(__name__) @@ -15,22 +16,18 @@ class LaposteTransport(Transport): """Implement Laposte WS communication.""" LAPOSTE_WS = "https://ws.colissimo.fr/sls-ws/SlsServiceWS" - STATUS_SUCCES = "success" - STATUS_ERROR = "error" def send(self, payload): """Call this function. Args: payload.body: XML in a string - payload.headers: auth + payload.header : auth Return: { - status: STATUS_SUCCES or STATUS_ERROR, (string) - message: more info about status of result (lxml) response: (Requests.response) - payload: usefull payload (if success) (xml as string) - + body: XML response (without soap) + parts: dict of attachments } """ body = payload['body'] @@ -38,7 +35,6 @@ def send(self, payload): soap_message = self.soap_wrap(body, headers) log.debug(soap_message) response = self.send_request(soap_message) - log.info('WS response time %s' % response.elapsed.total_seconds()) return self.handle_response(response) def soap_wrap(self, body, headers): @@ -61,17 +57,13 @@ def send_request(self, body): def handle_500(self, response): """Handle reponse in case of ERROR 500 type.""" - # TODO : put a try catch (like wrong server) - # no need to extract_body shit here log.warning('Laposte error 500') obj = objectify.fromstring(response.text) - return { + errors = [{ "id": obj.xpath('//faultcode')[0], - "status": self.STATUS_ERROR, "message": obj.xpath('//faultstring')[0], - "response": response, - "payload": None - } + }] + raise CarrierError(response, errors) def handle_200(self, response): """ @@ -79,26 +71,20 @@ def handle_200(self, response): It still can be a success or a failure. """ - def extract_message(response_xml): + def raise_on_error(response_xml): xml = objectify.fromstring(response_xml) messages = xml.xpath('//messages') - exception = False - for message in messages: - mess_type = str(message.type) - if mess_type.lower() == self.STATUS_ERROR.lower(): - exception = True - # dirty serialization - return { - "exception": exception, - "message": messages, - } - - def extract_payload(response_xml): - xml = objectify.fromstring(response_xml) - payload_xml = xml.Body.getchildren()[0] - return etree.tostring(payload_xml) + errors = [ + { + 'id': message.id, + 'message': unicode(message.messageContent), + } + for message in messages if message.type == "ERROR" + ] + if len(errors) > 0: + raise CarrierError(response, errors) - def extract_body(response): + def extract_xml(response): """Because the answer is mixedpart we need to extract.""" content_type = response.headers['Content-Type'] boundary = content_type.split('boundary="')[1].split('";')[0] @@ -109,67 +95,30 @@ def extract_body(response): clean_xml = after_start.strip() # = trim() return clean_xml - response_xml = extract_body(response) - - message = extract_message(response_xml) - - payload = None + def extract_body(response_xml): + """Remove soap wrapper.""" + xml = objectify.fromstring(response_xml) + payload_xml = xml.Body.getchildren()[0] + return etree.tostring(payload_xml) - if message['exception']: - log.warning('Laposte error 200') - status = self.STATUS_ERROR - else: - status = self.STATUS_SUCCES - payload = extract_payload(response_xml) - log.info('status: %s' % status) + response_xml = extract_xml(response) + raise_on_error(response_xml) return { - "status": status, - "message": message, - "payload": payload, - "response": response, + 'body': extract_body(response_xml), + 'parts': get_parts(response), + 'response': response, } def handle_response(self, response): """Handle response of webservice.""" - if response.status_code == 500: - return self.handle_500(response) - elif response.status_code == 200: + if response.status_code == 200: return self.handle_200(response) + elif response.status_code == 500: + return self.handle_500(response) # will raise else: - return { - "status": "error", - "message": "Unexpected status code from server", - "response": response - } + raise CarrierError(response, [{ + 'id': None, + 'message': "Unexpected status code from server", + }]) - def get_parts(self, response): - head_lines = '' - for k, v in response.raw.getheaders().iteritems(): - head_lines += str(k) + ':' + str(v) + '\n' - - full = head_lines + response.content - - parser = email.parser.Parser() - decoded_reply = parser.parsestr(full) - parts = {} - start = decoded_reply.get_param('start').lstrip('<').rstrip('>') - i = 0 - for part in decoded_reply.get_payload(): - cid = part.get('content-Id', '').lstrip('<').rstrip('>') - if (not start or start == cid) and 'start' not in parts: - parts['start'] = part.get_payload() - else: - parts[cid or 'Attachment%d' % i] = part.get_payload() - i += 1 - return parts - - def exception_handling(self, messages): - message_labels = [] - for message in messages: - if message.messageContent: - message_labels.append({ - 'id': message.id, - 'message': unicode(message.messageContent), - }) - log.debug('message: %s' % message_labels) - return message_labels + \ No newline at end of file diff --git a/roulier/carriers/laposte/templates/laposte_address.xml b/roulier/carriers/laposte/templates/laposte_address.xml index b2045b3..3aaf199 100755 --- a/roulier/carriers/laposte/templates/laposte_address.xml +++ b/roulier/carriers/laposte/templates/laposte_address.xml @@ -2,8 +2,8 @@ {{ address.company }} {{ address.name }} {{ address.firstName }} - {{ address.street0 }} - {{ address.street2 }} + {{ address.street2 }} + {{ address.street0 }} {{ address.street1 }} {{ address.street3 }} {{ address.country }} @@ -15,4 +15,4 @@ {{ address.email | default('') }} {{ address.intercom }} {{ address.language }} - \ No newline at end of file + diff --git a/roulier/carriers/laposte/templates/laposte_generateLabelRequest.xml b/roulier/carriers/laposte/templates/laposte_generateLabelRequest.xml index 36f3955..837a48b 100755 --- a/roulier/carriers/laposte/templates/laposte_generateLabelRequest.xml +++ b/roulier/carriers/laposte/templates/laposte_generateLabelRequest.xml @@ -8,13 +8,13 @@ {% include "laposte_parcel.xml" %} {% include "laposte_customsDeclarations.xml" %} - refsenderXXX + {{ service.reference1 }} {% with address = sender_address %} {% include "laposte_address.xml" %} {% endwith %} - 12345 + {{ service.reference2 }} false service info diff --git a/roulier/carriers/trs/__init__.py b/roulier/carriers/trs/__init__.py new file mode 100755 index 0000000..4e09c76 --- /dev/null +++ b/roulier/carriers/trs/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +from . import trs +from . import trs_encoder +from . import trs_decoder +from . import trs_transport diff --git a/roulier/carriers/trs/templates/trs_deposit_slip.csv b/roulier/carriers/trs/templates/trs_deposit_slip.csv new file mode 100644 index 0000000..dfc0eff --- /dev/null +++ b/roulier/carriers/trs/templates/trs_deposit_slip.csv @@ -0,0 +1,4 @@ +client,siret,refCommande,dateEnlevement,dateLivraison,cr,va,nom,adr1,adr2,cp,ville,telephone,mobile,email,refDest,commentLiv,nbConducteurs,Poids,nbColis,qtéFacturée,article1,regroupement,refComfour,codeBarre,descColis,porteur,jourLivraison +{{sender_address.name}},{{#siret}},{{parcel.ref?}},{{ ?.date}},{{ #dateLivraison}},{{#cod}},{{#va}},{{ receiver_address.name}},{{ receiver_address.street1}},{{ receiver_address.street2}},{{ receiver_address.zipCode}},{{ receiver_address.city}},{{ receiver_address.phone}},{{ receiver_address.email}},{{#refDest}},{{#commentaire livraison}},{{#nombre de conduteurs?}},{{parcel.weight}},{{#nombre de colis}},{{#qteFacture?}},{{#article}},{{#regroupement}},{{#refComfour}},{{codebar}},descColis,porteur,jourLivraison + + diff --git a/roulier/carriers/trs/templates/trs_generateLabel.zpl b/roulier/carriers/trs/templates/trs_generateLabel.zpl new file mode 100755 index 0000000..446efe1 --- /dev/null +++ b/roulier/carriers/trs/templates/trs_generateLabel.zpl @@ -0,0 +1,43 @@ +^XA + +^FX Grand cadre +^FO30,15^GB800,1200,3^FS + +^FX Expediteur +^CF0,40 +^FO50,30^FD{{ from_address.company }}^FS +^FO30,15^GB800,50,3^FS + +^FX Num colis et poids +^FO60,290^FDColis {{ service.reference2 }}^FS +^FO60,340^FDPoids {{ parcel.weight }}^FS +^FO40,270^GB200,120,3^FS + +^FX Adresse +^FO300,130^TB,480,120^FD{{ to_address.name }}^FS +^FO300,220^TB,480,90^FD^FD{{ to_address.street1 }}^FS +^FO300,300^TB,480,90^FD^FD{{ to_address.street2 }}^FS +^FO300,380^FD{{ to_address.zip }}^FS +^FO400,420^FD{{ to_address.city }}^FS +^FO280,90^GB500,400,3^FS + +^FO300,510^FDTel: {{ to_address.phone }}^FS + +^FX Ref, commentaires +^FO60,800^FDReference / Commentaires: ^FS +^FO60,840^FD{{ service.reference1 }} ^FS +^FO60,880^FD{{ service.reference3 }} ^FS +^FO30,780^GB800,0,3^FS + + +^FX Code barre +^BY3,1,150 +^FO60,580^B3^FD{{ service.shippingId }}^FS + + +^FX Departement +^CF0,160 +^FO60,100^FD{{ to_address.dept }}^FS +^FO40,90^GB200,150,3^FS + +^XZ diff --git a/roulier/carriers/trs/trs.py b/roulier/carriers/trs/trs.py new file mode 100755 index 0000000..81a6532 --- /dev/null +++ b/roulier/carriers/trs/trs.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +"""Implementation for trs.""" +from trs_encoder import TrsEncoder +from trs_decoder import TrsDecoder +from trs_transport import TrsTransport +from roulier.carrier import Carrier + + +class Trs(Carrier): + """Implementation for trs.""" + + encoder = TrsEncoder() + decoder = TrsDecoder() + ws = TrsTransport() + + def api(self): + """Expose how to communicate with trs.""" + return self.encoder.api() + + def get(self, data, action): + """Run an action.""" + if action == "generateLabel": + return self.get_label(data) + if action == "depositSlip": + return self.get_deposit_slip(data) + raise Exception( + 'action %s not in %s' % ( + action, ', '.join(self.encoder.TRS_ACTIONS))) + + def get_label(self, data): + """Generate a label.""" + request = self.encoder.encode(data, 'generateLabel') + response = self.ws.send(request) + + return self.decoder.decode(response, request) + + def get_deposit_slip(self, data): + """Generate a deposit slip (csv file).""" + return self.ws.generate_deposit_slip(data) diff --git a/roulier/carriers/trs/trs_api.py b/roulier/carriers/trs/trs_api.py new file mode 100644 index 0000000..10e227d --- /dev/null +++ b/roulier/carriers/trs/trs_api.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +"""Implementation of TRS Api.""" +from roulier.api import Api + +TRS_LABEL_FORMAT = ( + 'ZPL', +) + + +class TrsApi(Api): + def _service(self): + schema = super(TrsApi, self)._service() + schema['labelFormat']['allowed'] = TRS_LABEL_FORMAT + schema['labelFormat']['default'] = TRS_LABEL_FORMAT[0] + schema['shippingId'].update({'required': True, 'empty': False}) + + return schema + + def _address(self): + schema = super(TrsApi, self)._address() + schema['country'].update({'required': True, 'empty': False}) + schema['zip'].update({'required': True, 'empty': False}) + schema['city'].update({'required': True, 'empty': False}) + return schema + + def _from_address(self): + schema = super(TrsApi, self)._from_address() + schema['company'].update({'required': True, 'empty': False}) + schema['phone'].update({'required': False, 'empty': True}) + schema['street1']['required'] = False + return schema + + def _auth(self): + schema = super(TrsApi, self)._auth() + schema['login'].update({'required': False, 'empty': True}) + schema['password']['required'] = False + return schema + + def _schemas(self): + # Santize all fields for zpl + schemas = super(TrsApi, self)._schemas() + for schema in schemas: + for field in schemas[schema]: + schemas[schema][field].update({'coerce': 'zpl'}) + return schemas diff --git a/roulier/carriers/trs/trs_decoder.py b/roulier/carriers/trs/trs_decoder.py new file mode 100755 index 0000000..084a6b8 --- /dev/null +++ b/roulier/carriers/trs/trs_decoder.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +"""Mock class.""" +from roulier.codec import Decoder + + +class TrsDecoder(Decoder): + """Prepare output""" + + def decode(self, response, request): + """Return {}.""" + + return { + 'label': response['payload'], + 'tracking_number': request['body']['service']['shippingId'], + 'tracking_url': '', + 'annexes': [ + { + 'name': 'meta', + 'type': 'csv', + 'data': response['attachment'] + } + ] + } diff --git a/roulier/carriers/trs/trs_encoder.py b/roulier/carriers/trs/trs_encoder.py new file mode 100755 index 0000000..bcfaae0 --- /dev/null +++ b/roulier/carriers/trs/trs_encoder.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""Transform input to trs zpl.""" +from roulier.codec import Encoder +from roulier.exception import InvalidApiInput +from .trs_api import TrsApi + +TRS_ACTIONS = ('generateLabel', 'generateDepositSlip') + + +class TrsEncoder(Encoder): + """Transform input to trs zpl.""" + + def encode(self, api_input, action): + """Dispatcher.""" + if action == 'generateLabel': + return self.generate_label(api_input) + raise Exception( + 'action %s not in %s' % (action, ', '.join(TRS_ACTIONS))) + + def generate_label(self, api_input): + """Transform input to trs zpl.""" + api = TrsApi() + if not api.validate(api_input): + raise InvalidApiInput( + 'Input error : %s' % api.errors(api_input)) + data = api.normalize(api_input) + + data['to_address']["dept"] = data['to_address']['zip'][0:2] + + return {'body': data, 'header': None} + + def api(self): + api = TrsApi() + return api.api_values() diff --git a/roulier/carriers/trs/trs_transport.py b/roulier/carriers/trs/trs_transport.py new file mode 100755 index 0000000..55c70e0 --- /dev/null +++ b/roulier/carriers/trs/trs_transport.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +"""Implementation for trs.""" +from roulier.transport import Transport +from jinja2 import Environment, PackageLoader +from collections import OrderedDict +import unicodecsv as csv +from io import BytesIO + + +class TrsTransport(Transport): + """Generate ZPL offline and csv for EDI.""" + + STATUS_SUCCESS = "Success" + + def send(self, payload): + """Call this function. + + Args: + body: an object with a lot usefull values + Return: + { + status: STATUS_SUCCES or STATUS_ERROR, (string) + message: more info about status of result (None) + response: (None) + payload: usefull payload (if success) (body as string) + + } + """ + body = payload['body'] + label = self.generate_zpl(body) + attachment = self.map_delivery_line(body) + return { + "status": self.STATUS_SUCCESS, + "message": None, + "response": None, + "payload": label, + "attachment": attachment + } + + def generate_zpl(self, body): + env = Environment( + loader=PackageLoader('roulier', '/carriers/trs/templates'), + extensions=['jinja2.ext.with_']) + + template = env.get_template("trs_generateLabel.zpl") + return template.render( + service=body['service'], + parcel=body['parcel'], + from_address=body['from_address'], + to_address=body['to_address']) + + def map_delivery_line(self, body): + data = OrderedDict([ + (u'client', body['from_address']['company']), + (u'siret', None), + (u'refCommande', body['service']['reference1']), + (u'dateEnlevement', body['service']['shippingDate']), + (u'cr', None), + (u'va', None), + (u'nom', body['to_address']['name']), + (u'adr1', body['to_address']['street1']), + (u'adr2', body['to_address']['street2']), + (u'cp', body['to_address']['zip']), + (u'ville', body['to_address']['city']), + (u'telephone', body['to_address']['phone']), + (u'mobile', body['to_address']['phone']), + (u'email', body['to_address']['email']), + (u'refDest', body['service']['reference1']), + (u'commentLiv', None), + (u'nbConducteurs', None), + (u'Poids', '%.f' % (body['parcel']['weight'] * 1000)), # kg to g + (u'nbColis', 1), # one row per parcel + (u'qtéFacturée1', None), + (u'qtéFacturée2', None), + (u'qtéFacturée3', None), + (u'qtéFacturée4', None), + (u'qtéFacturée5', None), + (u'qtéFacturée6', None), + (u'qtéFacturée7', None), + (u'qtéFacturée8', None), + (u'qtéFacturée9', None), + (u'qtéFacturée10', None), + (u'article1', None), + (u'article2', None), + (u'article3', None), + (u'article4', None), + (u'article5', None), + (u'article6', None), + (u'article7', None), + (u'article8', None), + (u'article9', None), + (u'article10', None), + (u'regroupement', None), + (u'refComfour', None), + (u'codeBarre', body['service']['shippingId']), + (u'descColis', None), + (u'porteur', None), + (u'jourLivraison', None), + ]) + return data + + def generate_deposit_slip(self, rows): + output = BytesIO() + + # l'ordre est important + headers = rows[0].keys() + + # l'ordre est fixé par headers + writer = csv.DictWriter(output, headers, encoding='utf-8') + writer.writeheader() + writer.writerows(rows) + return output diff --git a/roulier/exception.py b/roulier/exception.py index 22cfb13..6906eaf 100644 --- a/roulier/exception.py +++ b/roulier/exception.py @@ -1,8 +1,28 @@ # -*- coding: utf-8 -*- -"""Exception class""" +"""Exception classes""" +import logging + +log = logging.getLogger(__name__) class InvalidApiInput(Exception): - """ Use this class in your application to manage - exception with api call + """Bad input. + + Use this class in your application to manage + exception with api call + """ + + +class CarrierError(Exception): + """Error from WS. + + Use this class in your application to manage + exception with the carrier WS """ + def __init__(self, response, msg=None): + if msg is None: + msg = "An error occured with WS" + super(CarrierError, self).__init__(msg) + self.response = response + if self.response: + log.debug(response.text) diff --git a/roulier/roulier.py b/roulier/roulier.py index e3fa45c..4adb704 100755 --- a/roulier/roulier.py +++ b/roulier/roulier.py @@ -4,6 +4,7 @@ from .carriers.dummy.dummy import Dummy from .carriers.geodis.geodis import Geodis from .carriers.dpd.dpd import Dpd +from .carriers.trs.trs import Trs def _carriers(): @@ -16,6 +17,7 @@ def _carriers(): "dummy": Dummy, "geodis": Geodis, "dpd": Dpd, + "trs": Trs, } diff --git a/roulier/ws_tools.py b/roulier/ws_tools.py index b6375c3..9e59236 100755 --- a/roulier/ws_tools.py +++ b/roulier/ws_tools.py @@ -5,6 +5,7 @@ from zplgrf import GRF from PIL import Image from io import BytesIO +import email.parser import re import base64 @@ -34,6 +35,35 @@ def remove_empty_tags(xml, ouput_as_string=True): return transform(xml) +def get_parts(response): + """Extract parts from headers. + + Params: + response: a request object + Returns: + an array of content-ids + """ + head_lines = '' + for k, v in response.raw.getheaders().iteritems(): + head_lines += str(k) + ':' + str(v) + '\n' + + full = head_lines + response.content + + parser = email.parser.Parser() + decoded_reply = parser.parsestr(full) + parts = {} + start = decoded_reply.get_param('start').lstrip('<').rstrip('>') + i = 0 + for part in decoded_reply.get_payload(): + cid = part.get('content-Id', '').lstrip('<').rstrip('>') + if (not start or start == cid) and 'start' not in parts: + parts['start'] = part.get_payload() + else: + parts[cid or 'Attachment%d' % i] = part.get_payload() + i += 1 + return parts + + def png_to_zpl(png, rotate): u"""Transform a PNG in a suitable format for ZPL. diff --git a/setup.py b/setup.py index 62e93d4..b72e57a 100755 --- a/setup.py +++ b/setup.py @@ -10,7 +10,9 @@ name="roulier", version=version, packages=find_packages(), - install_requires=['lxml', 'Jinja2', 'requests', 'cerberus', 'zplgrf'], + install_requires=[ + 'lxml', 'Jinja2', 'requests', 'cerberus', 'zplgrf', + 'unicodecsv', 'unidecode'], author="Hparfr ", author_email="roulier@hpar.fr", description="Label parcels without pain",