diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5e269fd946..ba8901b5f1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -93,7 +93,7 @@ repos: - --color - --fix - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 + rev: v2.3.0 hooks: - id: trailing-whitespace # exclude autogenerated files diff --git a/delivery_easypost_oca/README.rst b/delivery_easypost_oca/README.rst new file mode 100644 index 0000000000..c65cca5e09 --- /dev/null +++ b/delivery_easypost_oca/README.rst @@ -0,0 +1,100 @@ +===================== +Easypost Shipping OCA +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:1c03edae8165390f8e9d39a3d769966ebd3328004e4379597dc418d0032d32fb + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fdelivery--carrier-lightgray.png?logo=github + :target: https://github.com/OCA/delivery-carrier/tree/14.0/delivery_easypost_oca + :alt: OCA/delivery-carrier +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/delivery-carrier-14-0/delivery-carrier-14-0-delivery_easypost_oca + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/delivery-carrier&target_branch=14.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds `Easpost `_ to the available carriers. + +It allows you to register shippings, generate labels, get rates from order so no need of exchanging +any kind of file. + +When a sales order is created in Odoo and the EasyPost carrier is assigned, the shipping price +will be automatically calculated using the lowest estimated rate from EasyPost, +based on the order information, including the shipping address and products. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To configure this module, you need to: + +#. Add a carrier account with delivery type ``easypost oca`` and fill in your credentials (Easypost + Test API Key and Easypost Production API Key) +#. Configure in Odoo the field File Format). + +Usage +===== + +You have to set the created shipping method in the delivery order to ship. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Binhex + +Contributors +~~~~~~~~~~~~ + +* `Binhex `_: + + * Antonio Ruban + * Christian Ramos + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/delivery-carrier `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/delivery_easypost_oca/__init__.py b/delivery_easypost_oca/__init__.py new file mode 100644 index 0000000000..9b4296142f --- /dev/null +++ b/delivery_easypost_oca/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/delivery_easypost_oca/__manifest__.py b/delivery_easypost_oca/__manifest__.py new file mode 100644 index 0000000000..822fd20255 --- /dev/null +++ b/delivery_easypost_oca/__manifest__.py @@ -0,0 +1,19 @@ +{ + "name": "Easypost Shipping OCA", + "version": "14.0.1.0.5", + "summary": """ OCA Delivery Easypost """, + "author": "Binhex, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/delivery-carrier", + "category": "Inventory/Delivery", + "depends": [ + "delivery", + "mail", + ], + "data": [ + "views/delivery_carrier_views.xml", + "views/product_packaging_views.xml", + ], + "external_dependencies": {"python": ["easypost", "easypost==7.15.0"]}, + "installable": True, + "license": "AGPL-3", +} diff --git a/delivery_easypost_oca/models/__init__.py b/delivery_easypost_oca/models/__init__.py new file mode 100644 index 0000000000..6a250044d5 --- /dev/null +++ b/delivery_easypost_oca/models/__init__.py @@ -0,0 +1,6 @@ +from . import delivery_carrier +from . import product_packaging +from . import stock_picking +from . import easypost_request +from . import sale_order +from . import ir_logging diff --git a/delivery_easypost_oca/models/delivery_carrier.py b/delivery_easypost_oca/models/delivery_carrier.py new file mode 100644 index 0000000000..a5cd6264f2 --- /dev/null +++ b/delivery_easypost_oca/models/delivery_carrier.py @@ -0,0 +1,440 @@ +from datetime import datetime + +from odoo import _, fields, models +from odoo.exceptions import UserError +from odoo.tools.float_utils import float_round + +from ..utils.pdf import assemble_pdf +from ..utils.zpl import assemble_zpl +from .easypost_request import EasypostRequest + + +class DeliveryCarrier(models.Model): + _inherit = "delivery.carrier" + + delivery_type = fields.Selection( + selection_add=[("easypost_oca", "Easypost OCA")], + ondelete={ + "easypost_oca": lambda recs: recs.write( + {"delivery_type": "fixed", "fixed_price": 0} + ) + }, + ) + + easypost_oca_test_api_key = fields.Char( + "Easypost Test API Key", + groups="base.group_system", + help="Enter your API test key from Easypost account.", + ) + easypost_oca_production_api_key = fields.Char( + "Easypost Production API Key", + groups="base.group_system", + help="Enter your API production key from Easypost account", + ) + + easypost_oca_label_file_type = fields.Selection( + [("PDF", "PDF"), ("ZPL", "ZPL"), ("EPL2", "EPL2")], + string="Label Format", + default="PDF", + ) + + easypost_oca_delivery_multiple_packages = fields.Selection( + selection=[("shipments", "Shipments"), ("batch", "Batch")], + string="Delivery Multiple Packages", + default="shipments", + ) + + def easypost_oca_rate_shipment(self, order): + """Return the rates for a quotation/SO.""" + ep_request = EasypostRequest(self) + total_weight = self._easypost_oca_convert_weight(order._get_estimated_weight()) + parcel = {"weight": total_weight} + recipient = self._prepare_address(order.partner_shipping_id) + shipper = self._prepare_address(order.warehouse_id.partner_id) + options = self._prepare_options() + lowest_rate = ep_request.calculate_shipping_rate( + to_address=recipient, + from_address=shipper, + parcel=parcel, + options=options, + ) + + if not lowest_rate: + raise UserError(_("No rate found for this shipping.")) + + # Update price with the order currency + rate = lowest_rate.get("rate", 0.0) + currency = lowest_rate.get("currency", "USD") + price = self._get_price_currency( + rate=float(rate), currency=currency, order=order + ) + + return { + "success": True, + "price": price, + "error_message": False, + "warning_message": False, + "easypost_oca_carrier_name": lowest_rate.get("carrier", None), + "easypost_oca_shipment_id": lowest_rate.get("shipment_id", None), + "easypost_oca_rate_id": lowest_rate.get("id", None), + } + + def easypost_oca_send_shipping(self, pickings) -> list: + res = [] + ep_request = EasypostRequest(self) + for picking in pickings: + shipment = None + price = 0.00 + tracking_code = "" + shipping_data = { + "exact_price": price, + "tracking_number": tracking_code, + } + processed_shipments = [] + picking_shipments = self._prepare_shipments(picking) + carrier_services = self._get_easypost_carrier_services(picking) + + if len(picking_shipments) > 1: + # Create a batch with all shipments + shipments = ep_request.create_multiples_shipments(picking_shipments) + processed_shipments = ep_request.buy_shipments( + shipments, carrier_services=carrier_services + ) + price, tracking_code = self._get_shipment_info( + processed_shipments, + picking.sale_id, + ) + + shipping_data.update( + { + "exact_price": price, + "tracking_number": tracking_code, + } + ) + self._easypost_message_post(processed_shipments, picking) + + else: + # Create a single shipment + shipment = ep_request.create_shipment( + to_address=picking_shipments[0]["to_address"], + from_address=picking_shipments[0]["from_address"], + parcel=picking_shipments[0]["parcel"], + options=picking_shipments[0]["options"], + reference=picking_shipments[0]["reference"], + carrier_accounts=picking_shipments[0]["carrier_accounts"], + ) + bought_shipment = ep_request.buy_shipment( + shipment, carrier_services=carrier_services + ) + price, tracking_code = self._get_shipment_info( + [bought_shipment], picking.sale_id + ) + + shipping_data.update( + { + "exact_price": price, + "tracking_number": tracking_code, + } + ) + + picking.write( + { + "easypost_oca_shipment_id": bought_shipment.shipment_id, + "easypost_oca_rate_id": bought_shipment.rate, + "easypost_oca_carrier_id": bought_shipment.carrier_id, + "easypost_oca_carrier_name": bought_shipment.carrier_name, + "easypost_oca_carrier_service": bought_shipment.carrier_service, + "easypost_oca_tracking_url": bought_shipment.public_url, + } + ) + self._easypost_message_post([bought_shipment], picking) + + res = res + [shipping_data] + return res + + def easypost_oca_get_tracking_link(self, picking): + return picking.easypost_oca_tracking_url + + def easypost_oca_cancel_shipment(self, pickings): + raise UserError(_("You can't cancel Easypost shipping.")) + + @staticmethod + def _get_easypost_carrier_services(picking=None): + return False + + def _easypost_oca_convert_weight(self, weight): + """Each API request for easypost required + a weight in pounds. + """ + if weight == 0: + return weight + weight_uom_id = self.env[ + "product.template" + ]._get_weight_uom_id_from_ir_config_parameter() + weight_in_pounds = weight_uom_id._compute_quantity( + weight, self.env.ref("uom.product_uom_lb") + ) + weigth_in_ounces = max( + 0.1, float_round((weight_in_pounds * 16), precision_digits=1) + ) + return weigth_in_ounces + + def _get_delivery_type(self): + """Override of delivery to return the easypost delivery type.""" + res = super()._get_delivery_type() + if self.delivery_type != "easypost": + return res + return self.easypost_delivery_type + + def _prepare_shipments(self, picking) -> list: + shipments = [] + recipient = self._prepare_address(picking.partner_id) + shipper = self._prepare_address(picking.picking_type_id.warehouse_id.partner_id) + options = self._prepare_options(self.easypost_oca_label_file_type) + carrier_accounts = self._prepare_carrier_account(picking) + move_lines_with_package = picking.move_line_ids.filtered( + lambda ml: ml.result_package_id + ) + move_lines_without_package = picking.move_line_ids - move_lines_with_package + if move_lines_without_package: + # If the user didn't use a specific package we consider + # that he put everything inside a single package. + # The user still able to reorganise its packages if a + # mistake happens. + if picking.picking_type_code == "incoming": + weight = sum( + [ + ml.product_id.weight + * ml.product_uom_id._compute_quantity( + ml.product_qty, + ml.product_id.uom_id, + rounding_method="HALF-UP", + ) + for ml in move_lines_without_package + ] + ) + else: + weight = sum( + [ + ml.product_id.weight + * ml.product_uom_id._compute_quantity( + ml.qty_done, + ml.product_id.uom_id, + rounding_method="HALF-UP", + ) + for ml in move_lines_without_package + ] + ) + + parcel = self._prepare_parcel( + weight=self._easypost_oca_convert_weight(weight) + ) + + shipments.append( + { + "to_address": recipient, + "from_address": shipper, + "parcel": parcel, + "options": { + **options, + **{"print_custom_1": (picking.name if picking.name else "")}, + }, + "reference": picking.name if picking.name else "", + "carrier_accounts": carrier_accounts, + } + ) + + if move_lines_with_package: + # Generate an easypost shipment for each package in picking. + for package in picking.package_ids: + # compute move line weight in package + move_lines = picking.move_line_ids.filtered( + lambda ml: ml.result_package_id == package + ) + if picking.picking_type_code == "incoming": + weight = sum( + [ + ml.product_id.weight + * ml.product_uom_id._compute_quantity( + ml.product_qty, + ml.product_id.uom_id, + rounding_method="HALF-UP", + ) + for ml in move_lines + ] + ) + else: + weight = package.shipping_weight + + parcel = self._prepare_parcel( + package=package.packaging_id, + weight=self._easypost_oca_convert_weight(weight), + ) + shipments.append( + { + "to_address": recipient, + "from_address": shipper, + "parcel": parcel, + "options": { + **options, + **{ + "print_custom_1": (package.name if package.name else "") + }, + }, + "reference": package.name if package.name else "", + "carrier_accounts": carrier_accounts, + } + ) + # Prepare an easypost parcel with same info than package. + + return shipments + + def _prepare_parcel(self, package=None, weight: float = 0.0): + parcel = { + "weight": weight, + } + if package and package.package_carrier_type == "easypost_oca": + if package.shipper_package_code: + parcel.update({"predefined_package": package.shipper_package_code}) + if package.width > 0: + parcel.update({"width": package.width}) + if package.height > 0: + parcel.update({"height": package.height}) + if package.packaging_length > 0: + parcel.update({"length": package.packaging_length}) + + return parcel + + def _prepare_options(self, easypost_oca_label_file_type: str = "PDF"): + return { + "label_date": datetime.now().isoformat(), + "label_format": easypost_oca_label_file_type, + } + + def _prepare_carrier_account(self, picking): + return [] + + def _prepare_address(self, addr_obj): + addr_fields = { + "street1": "street", + "street2": "street2", + "city": "city", + "zip": "zip", + "phone": "phone", + "email": "email", + } + address = { + f"{field_name}": addr_obj[addr_obj_field] + for field_name, addr_obj_field in addr_fields.items() + if addr_obj[addr_obj_field] + } + address["name"] = addr_obj.name or addr_obj.display_name + if addr_obj.state_id: + address["state"] = addr_obj.state_id.code + address["country"] = addr_obj.country_id.code + if addr_obj.commercial_company_name: + address["company"] = addr_obj.commercial_company_name + return address + + def _get_price_currency(self, rate: float, currency: str, order=False) -> float: + price = float(rate) + currency_id = order.currency_id if order else self.env.company.currency_id + if currency_id.name != currency: + quote_currency = self.env["res.currency"].search( + [("name", "=", currency)], limit=1 + ) + price = quote_currency._convert( + price, + currency_id, + self.env.company, + fields.Date.today(), + ) + return price + + def _easypost_message_post(self, shipments, picking, batch_mode=False): + carrier_tracking_links: list[str] = [] + files_to_merge: list = [] + + for shipment in shipments: + public_url = ( + shipment.tracker.get("public_url", "") + if batch_mode + else shipment.public_url + ) + tracking_code = ( + shipment.tracker.get("tracking_code", "") + if batch_mode + else shipment.tracking_code + ) + carrier_tracking_links.append( + ( + f" {tracking_code}", + shipment.carrier_name, + shipment.carrier_service, + ) + ) + files_to_merge.append(shipment.get_label_content()) + + logmessage = _( + "Shipment created into Easypost
" + "Tracking Numbers: %s
" + "Carrier Account: %s
" + "Carrier Service: %s
" + ) % ( + ", ".join([link[0] for link in carrier_tracking_links]), + ", ".join({link[1] for link in carrier_tracking_links}), + ", ".join({link[2] for link in carrier_tracking_links}), + ) + + file_merged = self._contact_files( + self.easypost_oca_label_file_type, files_to_merge + ) + labels = [ + ( + f"Label-{picking.name}.{self.easypost_oca_label_file_type.lower()}", + file_merged, + ) + ] + picking.message_post(body=logmessage, attachments=labels) + + def _get_shipment_info(self, shipments, sale_id, batch_mode=False): + price, tracking_code = (0.0, "") + if batch_mode: + price = sum( + [ + self._get_price_currency( + float(shipment.selected_rate.get("rate", 0.0)), + shipment.selected_rate.get("currency"), + sale_id, + ) + for shipment in shipments + ] + ) + tracking_code = ", ".join( + [shipment.tracker.get("tracking_code", "") for shipment in shipments] + ) + else: + price = sum( + [ + self._get_price_currency( + shipment.rate, + shipment.currency, + sale_id, + ) + for shipment in shipments + ] + ) + tracking_code = ", ".join( + [shipment.tracking_code for shipment in shipments] + ) + + return price, tracking_code + + @staticmethod + def _contact_files(f_type, files): + if f_type == "PDF": + return assemble_pdf(files) + elif f_type == "ZPL": + return assemble_zpl(files) + + return files diff --git a/delivery_easypost_oca/models/easypost_request.py b/delivery_easypost_oca/models/easypost_request.py new file mode 100644 index 0000000000..7253363fbe --- /dev/null +++ b/delivery_easypost_oca/models/easypost_request.py @@ -0,0 +1,230 @@ +import logging + +import easypost +import requests + +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class EasyPostShipment: + def __init__( + self, + shipment_id, + tracking_code, + label_url, + public_url, + rate, + currency, + carrier_id, + carrier_name, + carrier_service, + ): + self.shipment_id = shipment_id + self.tracking_code = tracking_code + self.label_url = label_url + self.public_url = public_url + self.rate = rate + self.currency = currency + self.carrier_id = carrier_id + self.carrier_name = carrier_name + self.carrier_service = carrier_service + + def get_label_content(self): + response = requests.get(self.label_url) + return response.content + + +class EasypostRequest: + def __init__(self, carrier): + self.carrier = carrier + self.debug_logger = self.carrier.log_xml + self.api_key = self.carrier.easypost_oca_test_api_key + if self.carrier.prod_environment: + self.api_key = self.carrier.easypost_oca_production_api_key + + easypost.api_key = self.api_key + self.client = easypost + + def create_end_shipper(self, address): + try: + if not address["street2"]: + address["street2"] = address["street1"] + + end_shipper = self.client.end_shipper.create(**address) + except Exception as e: + raise UserError(self._get_message_errors(e)) from e + return end_shipper + + def create_multiples_shipments(self, shipments: list, batch_mode=False) -> list: + if batch_mode: + return self.create_shipments_batch(shipments) + return self.create_shipments(shipments) + + def create_shipments_batch(self, shipments: list): + created_shipments = self.create_shipments(shipments) + return [ + { + "id": shipment.id, + "carrier": shipment.lowest_rate().carrier, + "service": shipment.lowest_rate().service, + } + for shipment in created_shipments + ] + + def create_shipments(self, shipments: list): + created_shipments = [] + for shipment in shipments: + _ship = self.create_shipment( + to_address=shipment["to_address"], + from_address=shipment["from_address"], + parcel=shipment["parcel"], + options=shipment["options"], + reference=shipment["reference"], + carrier_accounts=shipment["carrier_accounts"], + ) + created_shipments.append(_ship) + return created_shipments + + def create_shipment( + self, + from_address: dict, + to_address: dict, + parcel: dict, + options=None, + reference=None, + carrier_accounts=None, + ): + if options is None: + options = {} + if carrier_accounts is None: + carrier_accounts = [] + try: + created_shipment = self.client.Shipment.create( + from_address=from_address, + to_address=to_address, + parcel=parcel, + options=options, + reference=reference, + carrier_accounts=carrier_accounts, + ) + except Exception as e: + raise UserError(self._get_message_errors(e)) from e + return created_shipment + + def buy_shipments(self, shipments, carrier_services=None): + bought_shipments = [] + for shipment in shipments: + bought_shipments.append(self.buy_shipment(shipment, carrier_services)) + return bought_shipments + + @staticmethod + def _get_selected_rate(shipment, carrier_services=None): + return shipment.lowest_rate() + + def buy_shipment(self, shipment, carrier_services=None): + selected_rate = self._get_selected_rate(shipment, carrier_services) + end_shipper = None + if selected_rate.carrier in ("USPS", "UPS"): + end_shippers = self.client.EndShipper.all(page_size=1)["end_shippers"] + if not end_shippers: + end_shipper = self.create_end_shipper(shipment.from_address)["id"] + else: + end_shipper = end_shippers[0]["id"] + try: + bought_shipment = shipment.buy( + rate=selected_rate, end_shipper_id=end_shipper + ) + except easypost.Error as error: + raise UserError(self._get_message_errors(error)) from error + + return EasyPostShipment( + shipment_id=bought_shipment.id, + tracking_code=bought_shipment.tracking_code, + label_url=bought_shipment.postage_label.label_url, + public_url=bought_shipment.tracker.public_url, + rate=float(bought_shipment.selected_rate.rate), + currency=bought_shipment.selected_rate.currency, + carrier_id=bought_shipment.selected_rate.carrier_account_id, + carrier_name=bought_shipment.selected_rate.carrier, + carrier_service=bought_shipment.selected_rate.service, + ) + + def retreive_shipment(self, shipment_id: str): + try: + shipment = self.client.shipment.Retrieve(id=shipment_id) + except Exception as e: + raise UserError(self._get_message_errors(e)) from e + return shipment + + def retreive_multiple_shipment(self, ids: list): + return [self.retreive_shipment(id) for id in ids] + + def calculate_shipping_rate( + self, from_address: dict, to_address: dict, parcel: dict, options: dict + ): + _shipment = self.create_shipment( + from_address=from_address, + to_address=to_address, + parcel=parcel, + options=options, + ) + return _shipment.lowest_rate() + + def create_batch(self, shipments: list): + try: + created_batch = self.client.batch.create( + shipments=shipments, + ) + except Exception as e: + raise UserError(self._get_message_errors(e)) from e + return created_batch + + def buy_batch(self, batch_id: str): + try: + bought_batch = self.client.batch.Buy(id=batch_id) + except Exception as e: + raise UserError(self._get_message_errors(e)) from e + return bought_batch + + def label_batch(self, batch_id: str, file_format: str): + try: + label = self.client.batch.Label(id=batch_id, file_format=file_format) + except Exception as e: + raise UserError(self._get_message_errors(e)) from e + return label + + def retreive_batch(self, batch_id: str): + try: + batch = self.client.batch.retrieve(id=batch_id) + except Exception as e: + raise UserError(self._get_message_errors(e)) from e + return batch + + def track_shipment(self, tracking_number: str): + tracker = self.client.tracker.create(tracking_code=tracking_number) + return tracker + + def retrieve_shipment(self, shipment_id: str): + return self.client.shipment.retrieve(id=shipment_id) + + def retrieve_carrier_metadata(self): + return self.client.beta.CarrierMetadata.retrieve_carrier_metadata() + + def retrieve_all_carrier_accounts(self): + try: + carrier_accounts = self.client.CarrierAccount.all() + except Exception as e: + raise UserError(self._get_message_errors(e)) from e + return carrier_accounts + + def _get_message_errors(self, e: easypost.Error) -> str: + if not hasattr(e, "errors"): + return getattr(e, "message", str(e)) + return "\n".join( + [ + f"Error: {err['message']}\nError Body: {err['http_body']}" + for err in e.errors + ] + ) diff --git a/delivery_easypost_oca/models/ir_logging.py b/delivery_easypost_oca/models/ir_logging.py new file mode 100644 index 0000000000..1e1e382871 --- /dev/null +++ b/delivery_easypost_oca/models/ir_logging.py @@ -0,0 +1,10 @@ +from odoo import fields, models + + +class IrLogging(models.Model): + _inherit = "ir.logging" + + type = fields.Selection( + selection_add=[("easypost_oca", "Easypost OCA")], + ondelete={"easypost_oca": lambda recs: recs.write({"type": "server"})}, + ) diff --git a/delivery_easypost_oca/models/product_packaging.py b/delivery_easypost_oca/models/product_packaging.py new file mode 100644 index 0000000000..831ae65f1f --- /dev/null +++ b/delivery_easypost_oca/models/product_packaging.py @@ -0,0 +1,10 @@ +from odoo import fields, models + + +class ProductPackaging(models.Model): + _inherit = "product.packaging" + + package_carrier_type = fields.Selection( + selection_add=[("easypost_oca", "Easypost OCA")] + ) + easypost_oca_carrier = fields.Char("Carrier Prefix", index=True) diff --git a/delivery_easypost_oca/models/sale_order.py b/delivery_easypost_oca/models/sale_order.py new file mode 100644 index 0000000000..7fc8eb020e --- /dev/null +++ b/delivery_easypost_oca/models/sale_order.py @@ -0,0 +1,34 @@ +from odoo import fields, models + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + easypost_oca_shipment_id = fields.Char(tracking=False, copy=False) + easypost_oca_rate_id = fields.Char(tracking=False, copy=False) + easypost_oca_carrier_name = fields.Char(tracking=True, copy=False) + + def _create_delivery_line(self, carrier, price_unit): + sol = super()._create_delivery_line(carrier=carrier, price_unit=price_unit) + if self.env.context.get("easypost_oca_carrier_name", False): + carrier_name = ( + f" - {self.easypost_oca_carrier_name}" + if self.easypost_oca_carrier_name + else "" + ) + sol.name = f"{sol.name}{carrier_name}" + + self.with_context(auto_refresh_delivery=True).write( + { + "easypost_oca_rate_id": self.env.context.get( + "easypost_oca_rate_id", False + ), + "easypost_oca_shipment_id": self.env.context.get( + "easypost_oca_shipment_id", False + ), + "easypost_oca_carrier_name": self.env.context.get( + "easypost_oca_carrier_name", False + ), + } + ) + return sol diff --git a/delivery_easypost_oca/models/stock_picking.py b/delivery_easypost_oca/models/stock_picking.py new file mode 100644 index 0000000000..dfd2647de1 --- /dev/null +++ b/delivery_easypost_oca/models/stock_picking.py @@ -0,0 +1,15 @@ +from odoo import fields, models + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + easypost_oca_shipment_id = fields.Char( + related="sale_id.easypost_oca_shipment_id", readonly=False + ) + easypost_oca_batch_id = fields.Char(tracking=True) + easypost_oca_rate_id = fields.Char() + easypost_oca_carrier_name = fields.Char() + easypost_oca_carrier_service = fields.Char() + easypost_oca_carrier_id = fields.Char() + easypost_oca_tracking_url = fields.Char() diff --git a/delivery_easypost_oca/readme/CONFIGURE.rst b/delivery_easypost_oca/readme/CONFIGURE.rst new file mode 100644 index 0000000000..16c899aa87 --- /dev/null +++ b/delivery_easypost_oca/readme/CONFIGURE.rst @@ -0,0 +1,5 @@ +To configure this module, you need to: + +#. Add a carrier account with delivery type ``easypost oca`` and fill in your credentials (Easypost + Test API Key and Easypost Production API Key) +#. Configure in Odoo the field File Format). diff --git a/delivery_easypost_oca/readme/CONTRIBUTORS.rst b/delivery_easypost_oca/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..c054e0ec8f --- /dev/null +++ b/delivery_easypost_oca/readme/CONTRIBUTORS.rst @@ -0,0 +1,4 @@ +* `Binhex `_: + + * Antonio Ruban + * Christian Ramos diff --git a/delivery_easypost_oca/readme/DESCRIPTION.rst b/delivery_easypost_oca/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..1285964066 --- /dev/null +++ b/delivery_easypost_oca/readme/DESCRIPTION.rst @@ -0,0 +1,8 @@ +This module adds `Easpost `_ to the available carriers. + +It allows you to register shippings, generate labels, get rates from order so no need of exchanging +any kind of file. + +When a sales order is created in Odoo and the EasyPost carrier is assigned, the shipping price +will be automatically calculated using the lowest estimated rate from EasyPost, +based on the order information, including the shipping address and products. diff --git a/delivery_easypost_oca/readme/ROADMAP.rst b/delivery_easypost_oca/readme/ROADMAP.rst new file mode 100644 index 0000000000..e69de29bb2 diff --git a/delivery_easypost_oca/readme/USAGE.rst b/delivery_easypost_oca/readme/USAGE.rst new file mode 100644 index 0000000000..cd0a754a56 --- /dev/null +++ b/delivery_easypost_oca/readme/USAGE.rst @@ -0,0 +1 @@ +You have to set the created shipping method in the delivery order to ship. diff --git a/delivery_easypost_oca/static/description/icon.png b/delivery_easypost_oca/static/description/icon.png new file mode 100644 index 0000000000..b0b6ff94cd Binary files /dev/null and b/delivery_easypost_oca/static/description/icon.png differ diff --git a/delivery_easypost_oca/static/description/index.html b/delivery_easypost_oca/static/description/index.html new file mode 100644 index 0000000000..c7ae52307d --- /dev/null +++ b/delivery_easypost_oca/static/description/index.html @@ -0,0 +1,447 @@ + + + + + +Easypost Shipping OCA + + + +
+

Easypost Shipping OCA

+ + +

Beta License: AGPL-3 OCA/delivery-carrier Translate me on Weblate Try me on Runboat

+

This module adds Easpost to the available carriers.

+

It allows you to register shippings, generate labels, get rates from order so no need of exchanging +any kind of file.

+

When a sales order is created in Odoo and the EasyPost carrier is assigned, the shipping price +will be automatically calculated using the lowest estimated rate from EasyPost, +based on the order information, including the shipping address and products.

+

Table of contents

+ +
+

Configuration

+

To configure this module, you need to:

+
    +
  1. Add a carrier account with delivery type easypost oca and fill in your credentials (Easypost +Test API Key and Easypost Production API Key)
  2. +
  3. Configure in Odoo the field File Format).
  4. +
+
+
+

Usage

+

You have to set the created shipping method in the delivery order to ship.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Binhex
  • +
+
+
+

Contributors

+
    +
  • Binhex:
      +
    • Antonio Ruban
    • +
    • Christian Ramos
    • +
    +
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/delivery-carrier project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/delivery_easypost_oca/tests/__init__.py b/delivery_easypost_oca/tests/__init__.py new file mode 100644 index 0000000000..15149df439 --- /dev/null +++ b/delivery_easypost_oca/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_delivery_easypost +from . import test_easypost_request diff --git a/delivery_easypost_oca/tests/common.py b/delivery_easypost_oca/tests/common.py new file mode 100644 index 0000000000..d268cce11a --- /dev/null +++ b/delivery_easypost_oca/tests/common.py @@ -0,0 +1,116 @@ +from odoo.tests import Form, TransactionCase, tagged + +EASYPOST_TEST_KEY = "EZTK52f7d94f77344a44854f45762f3a4a11QfNflQ9TqssKdvK5fdGuUw" +EASYPOST_PROD_KEY = "EZTK52f7d94f77344a44854f45762f3a4a11QfNflQ9TqssKdvK5fdGuUw" + + +@tagged("post_install", "-at_install") +class EasypostTestBaseCase(TransactionCase): + def setUp(self): + super().setUp() + + product_sudo = self.env["product.product"] + self.company = self.env["res.partner"].create( + { + "name": "Odoo SA", + "street": "44 Wall Street", + "street2": "Suite 603", + "city": "New York", + "zip": 10005, + "state_id": self.env.ref("base.state_us_27").id, + "country_id": self.env.ref("base.us").id, + "phone": "+1 (929) 352-6366", + "email": "", + "website": "www.example.com", + } + ) + + self.partner = self.env["res.partner"].create( + { + "name": "The Jackson Group", + "street": "1515 Main Street", + "street2": "", + "city": "Columbia", + "phone": "+1 (929) 352-6364", + "zip": 29201, + "state_id": self.env.ref("base.state_us_41").id, + "country_id": self.env.ref("base.us").id, + } + ) + + conf = self.env["ir.config_parameter"] + conf.set_param("product.weight_in_lbs", "1") + precision = self.env.ref("product.decimal_stock_weight") + precision.digits = 4 + self.uom_lbs = self.env.ref("uom.product_uom_lb") + self.uom_lbs.rounding = 0.0001 + self.product = product_sudo.create( + { + "name": "Product1", + "type": "consu", + "weight": 3.0, + "volume": 4.0, + } + ) + self.delivery_product = product_sudo.create( + { + "name": "Easypost OCA Delivery", + "type": "service", + "categ_id": self.env.ref("product.product_category_all").id, + } + ) + + self.carrier = self.env["delivery.carrier"].create( + { + "name": "EASYPOST OCA", + "delivery_type": "easypost_oca", + "easypost_oca_test_api_key": EASYPOST_TEST_KEY, + "easypost_oca_production_api_key": EASYPOST_PROD_KEY, + "easypost_oca_label_file_type": "ZPL", + "product_id": self.delivery_product.id, + } + ) + + self.default_packaging = self.env["product.packaging"].create( + { + "name": "My Easypost OCA Box", + "package_carrier_type": "easypost_oca", + "max_weight": 100, + "height": 0, + "packaging_length": 0, + "width": 0, + } + ) + + def _create_sale_order(self, qty=1): + order_form = Form(self.env["sale.order"]) + order_form.partner_id = self.partner + with order_form.order_line.new() as line_form: + line_form.product_id = self.product + line_form.product_uom_qty = qty + sale = order_form.save() + delivery_wizard = Form( + self.env["choose.delivery.carrier"].with_context( + { + "default_order_id": sale.id, + "default_carrier_id": self.carrier.id, + } + ) + ).save() + delivery_wizard.button_confirm() + sale.action_confirm() + return sale + + def _put_in_pack(self, picking): + wiz_action = picking.action_put_in_pack() + self.assertEqual( + wiz_action["res_model"], + "choose.delivery.package", + "Wrong wizard returned", + ) + wiz = ( + self.env[wiz_action["res_model"]] + .with_context(wiz_action["context"]) + .create({"delivery_packaging_id": self.default_packaging.id}) + ) + wiz.action_put_in_pack() diff --git a/delivery_easypost_oca/tests/test_delivery_easypost.py b/delivery_easypost_oca/tests/test_delivery_easypost.py new file mode 100644 index 0000000000..7c0a9dbb65 --- /dev/null +++ b/delivery_easypost_oca/tests/test_delivery_easypost.py @@ -0,0 +1,147 @@ +from odoo.exceptions import UserError + +from .common import EasypostTestBaseCase + + +class TestDeliveryEasypost(EasypostTestBaseCase): + def test_easypost_oca_order_rate_shipment(self): + self.order = self._create_sale_order(qty=5) + try: + res = self.carrier.easypost_oca_rate_shipment(self.order) + self.assertTrue(res["success"]) + self.assertGreater(res["price"], 0) + except UserError: + self.assertTrue(1) + + def test_easypost_oca_default_shipping(self): + SaleOrder = self._create_sale_order(1) + Picking = SaleOrder.picking_ids[0] + Picking.action_assign() + Picking.move_line_ids.write({"qty_done": 1}) + self.assertGreater( + Picking.weight, + 0.0, + "Picking weight should be positive.", + ) + try: + Picking._action_done() + self.assertGreater( + Picking.carrier_price, + 0.0, + "Easypost carrying price is probably incorrect", + ) + self.assertIsNot( + Picking.easypost_oca_carrier_id, + False, + "Easypost did not return any carrier", + ) + self.assertIsNot( + Picking.carrier_tracking_ref, + False, + "Easypost did not return any tracking number", + ) + self.assertIsNot( + Picking.easypost_oca_tracking_url, + False, + "Easypost did not return any tracking url", + ) + except UserError: + self.assertTrue(1) + + def test_easypost_oca_single_package_shipping(self): + SaleOrder = self._create_sale_order(5) + self.assertEqual( + len(SaleOrder.picking_ids), + 1, + "The Sales Order did not generate a picking for Easypost.", + ) + Picking = SaleOrder.picking_ids[0] + self.assertEqual( + Picking.carrier_id.id, + SaleOrder.carrier_id.id, + "Carrier is not the same on Picking and on SO(easypost).", + ) + + Picking.action_assign() + + # First move line + Picking.move_lines[0].write({"quantity_done": 5}) + self._put_in_pack(Picking) + Picking.move_lines[ + 0 + ].move_line_ids.result_package_id.packaging_id = self.default_packaging.id + Picking.move_lines[0].move_line_ids.result_package_id.shipping_weight = 10.0 + + self.assertGreater( + Picking.weight, + 0.0, + "Picking weight should be positive.(ep-fedex)", + ) + try: + Picking._action_done() + self.assertGreater( + Picking.carrier_price, + 0.0, + "Easypost carrying price is probably incorrect(fedex)", + ) + self.assertIsNot( + Picking.carrier_tracking_ref, + False, + "Easypost did not return any tracking number (fedex)", + ) + except UserError: + self.assertTrue(1) + + def test_easypost_oca_carrier_services(self): + """Test carrier services method returns False by default""" + SaleOrder = self._create_sale_order(10) + Picking = SaleOrder.picking_ids[0] + + self.assertFalse(self.carrier._get_easypost_carrier_services()) + self.assertFalse(self.carrier._get_easypost_carrier_services(Picking)) + + def test_easypost_oca_multiple_packages_shipping(self): + """Test shipping with multiple packages""" + SaleOrder = self._create_sale_order(10) + Picking = SaleOrder.picking_ids[0] + Picking.action_assign() + + # Create two packages + Picking.move_lines[0].write({"quantity_done": 5}) + self._put_in_pack(Picking) + Picking.move_lines[ + 0 + ].move_line_ids.result_package_id.packaging_id = self.default_packaging.id + Picking.move_lines[0].move_line_ids.result_package_id.shipping_weight = 5.0 + + Picking.move_lines[0].write({"quantity_done": 5}) + self._put_in_pack(Picking) + Picking.move_lines[ + 0 + ].move_line_ids.result_package_id.packaging_id = self.default_packaging.id + Picking.move_lines[0].move_line_ids.result_package_id.shipping_weight = 5.0 + + self.assertEqual(len(Picking.package_ids), 2, "Should have created 2 packages") + + try: + Picking._action_done() + self.assertGreater( + Picking.carrier_price, + 0.0, + "Easypost carrying price should be positive for multiple packages", + ) + self.assertTrue( + Picking.carrier_tracking_ref, "Should have tracking reference" + ) + except UserError: + self.assertTrue(1) + + def test_easypost_oca_shipping_error_handling(self): + """Test error handling during shipping""" + SaleOrder = self._create_sale_order(1) + Picking = SaleOrder.picking_ids[0] + # Force an error by setting invalid API key + self.carrier.easypost_oca_test_api_key = "invalid_key" + + with self.assertRaises(UserError): + Picking._action_done() diff --git a/delivery_easypost_oca/tests/test_easypost_request.py b/delivery_easypost_oca/tests/test_easypost_request.py new file mode 100644 index 0000000000..e6a27b81a5 --- /dev/null +++ b/delivery_easypost_oca/tests/test_easypost_request.py @@ -0,0 +1,101 @@ +from unittest.mock import Mock, patch + +from odoo.exceptions import UserError + +from odoo.addons.delivery_easypost_oca.models.easypost_request import EasypostRequest + +from .common import EasypostTestBaseCase + + +class TestEasypostRequest(EasypostTestBaseCase): + def setUp(self): + super().setUp() + self.easypost_request = EasypostRequest(self.carrier) + + @patch("easypost.Shipment") + def test_create_shipment(self, mock_shipment): + # Prepare test data + mock_response = Mock() + mock_response.id = "shp_123" + mock_shipment.create.return_value = mock_response + + from_address = { + "name": "EasyPost", + "street1": "118 2nd Street", + "street2": "4th Floor", + "city": "San Francisco", + "state": "CA", + "zip": "94105", + "country": "US", + "phone": "415-456-7890", + } + to_address = { + "name": "Dr. Steve Brule", + "street1": "179 N Harbor Dr", + "city": "Redondo Beach", + "state": "CA", + "zip": "90277", + "country": "US", + "phone": "310-808-5243", + } + parcel = {"weight": 17.5} + + # Execute test + result = self.easypost_request.create_shipment( + from_address=from_address, to_address=to_address, parcel=parcel + ) + + # Verify results + self.assertEqual(result.id, "shp_123") + mock_shipment.create.assert_called_once_with( + from_address=from_address, + to_address=to_address, + parcel=parcel, + options={}, + reference=None, + carrier_accounts=[], + ) + + @patch("easypost.Shipment") + def test_create_shipment_error(self, mock_shipment): + # Simulate API error + mock_shipment.create.side_effect = Exception("API Error") + + # Test error handling + with self.assertRaises(UserError): + self.easypost_request.create_shipment( + from_address={}, to_address={}, parcel={} + ) + + @patch("easypost.Shipment") + def test_buy_shipment(self, mock_shipment): + # Prepare mock response + mock_response = Mock() + mock_response.id = "shp_123" + mock_response.tracking_code = "TRACK123" + mock_response.postage_label.label_url = "http://label.url" + mock_response.tracker.public_url = "http://track.url" + mock_response.selected_rate.rate = "10.0" + mock_response.selected_rate.currency = "USD" + mock_response.selected_rate.carrier_account_id = "ca_123" + mock_response.selected_rate.carrier = "USPS" + mock_response.selected_rate.service = "Priority" + + # Setup mock shipment + mock_shipment_obj = Mock() + mock_shipment_obj.buy.return_value = mock_response + mock_shipment_obj.lowest_rate.return_value = mock_response.selected_rate + + # Execute test + result = self.easypost_request.buy_shipment(mock_shipment_obj) + + # Verify results + self.assertEqual(result.shipment_id, "shp_123") + self.assertEqual(result.tracking_code, "TRACK123") + self.assertEqual(result.label_url, "http://label.url") + self.assertEqual(result.public_url, "http://track.url") + self.assertEqual(result.rate, 10.0) + self.assertEqual(result.currency, "USD") + self.assertEqual(result.carrier_id, "ca_123") + self.assertEqual(result.carrier_name, "USPS") + self.assertEqual(result.carrier_service, "Priority") diff --git a/delivery_easypost_oca/utils/pdf.py b/delivery_easypost_oca/utils/pdf.py new file mode 100644 index 0000000000..d83f0be10f --- /dev/null +++ b/delivery_easypost_oca/utils/pdf.py @@ -0,0 +1,29 @@ +# Copyright 2013-2019 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +import logging +from io import BytesIO + +_logger = logging.getLogger(__name__) + +try: + from PyPDF2 import PdfFileReader, PdfFileWriter +except ImportError: + _logger.debug('Cannot import "PyPDF2". Please make sure it is installed.') + + +def assemble_pdf(pdf_list): + """ + Assemble a list of pdf + """ + output = PdfFileWriter() + for pdf in pdf_list: + + if not pdf: + continue + reader = PdfFileReader(BytesIO(pdf)) + + for page in range(reader.getNumPages()): + output.addPage(reader.getPage(page)) + s = BytesIO() + output.write(s) + return s.getvalue() diff --git a/delivery_easypost_oca/utils/zpl.py b/delivery_easypost_oca/utils/zpl.py new file mode 100644 index 0000000000..3ff3710ca1 --- /dev/null +++ b/delivery_easypost_oca/utils/zpl.py @@ -0,0 +1,3 @@ +def assemble_zpl(files): + combined_zpl = b"\n" + return combined_zpl.join(files) diff --git a/delivery_easypost_oca/views/delivery_carrier_views.xml b/delivery_easypost_oca/views/delivery_carrier_views.xml new file mode 100644 index 0000000000..b1c936291f --- /dev/null +++ b/delivery_easypost_oca/views/delivery_carrier_views.xml @@ -0,0 +1,38 @@ + + + + + + easy.view.delivery.carrier.form.inherit + delivery.carrier + + + + + + + + + + + + + + + + + + + + + diff --git a/delivery_easypost_oca/views/product_packaging_views.xml b/delivery_easypost_oca/views/product_packaging_views.xml new file mode 100644 index 0000000000..dacd9b01bf --- /dev/null +++ b/delivery_easypost_oca/views/product_packaging_views.xml @@ -0,0 +1,47 @@ + + + + + product.packaging.form.delivery.inherit.easypost.oca + product.packaging + + 100 + + + + + +
+ + Inches +
+
+ +
+ + Inches +
+
+ +
+ + Inches +
+
+
+
+ + +
diff --git a/delivery_easypost_oca/wizard/__init__.py b/delivery_easypost_oca/wizard/__init__.py new file mode 100644 index 0000000000..d052299781 --- /dev/null +++ b/delivery_easypost_oca/wizard/__init__.py @@ -0,0 +1 @@ +from . import choose_delivery_carrier diff --git a/delivery_easypost_oca/wizard/choose_delivery_carrier.py b/delivery_easypost_oca/wizard/choose_delivery_carrier.py new file mode 100644 index 0000000000..f588235bad --- /dev/null +++ b/delivery_easypost_oca/wizard/choose_delivery_carrier.py @@ -0,0 +1,33 @@ +import logging + +from odoo import fields, models + +_logger = logging.getLogger(__name__) + + +class ChooseDeliveryCarrier(models.TransientModel): + _inherit = "choose.delivery.carrier" + + easypost_oca_carrier_name = fields.Char() + easypost_oca_shipment_id = fields.Char() + easypost_oca_rate_id = fields.Char() + + def _get_shipment_rate(self): + vals = self.carrier_id.rate_shipment(self.order_id) + if vals.get("success"): + self.write( + { + "easypost_oca_carrier_name": vals.get("easypost_oca_carrier_name"), + "easypost_oca_shipment_id": vals.get("easypost_oca_shipment_id"), + "easypost_oca_rate_id": vals.get("easypost_oca_rate_id"), + } + ) + return super()._get_shipment_rate() + + def button_confirm(self): + self = self.with_context( + easypost_oca_carrier_name=self.easypost_oca_carrier_name, + easypost_oca_shipment_id=self.easypost_oca_shipment_id, + easypost_oca_rate_id=self.easypost_oca_rate_id, + ) + super(ChooseDeliveryCarrier, self).button_confirm() diff --git a/requirements.txt b/requirements.txt index b42b243160..b782bb9c69 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ # generated from manifests external_dependencies dicttoxml +easypost +easypost==7.15.0 PyPDF2 roulier unidecode diff --git a/setup/delivery_easypost_oca/odoo/addons/delivery_easypost_oca b/setup/delivery_easypost_oca/odoo/addons/delivery_easypost_oca new file mode 120000 index 0000000000..16f989047c --- /dev/null +++ b/setup/delivery_easypost_oca/odoo/addons/delivery_easypost_oca @@ -0,0 +1 @@ +../../../../delivery_easypost_oca \ No newline at end of file diff --git a/setup/delivery_easypost_oca/setup.py b/setup/delivery_easypost_oca/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/delivery_easypost_oca/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)