forked from Mapan/odoo17e
522 lines
25 KiB
Python
522 lines
25 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
import requests
|
|
|
|
from collections import defaultdict
|
|
from datetime import datetime, timedelta
|
|
from werkzeug.urls import url_join
|
|
|
|
from odoo import fields, _
|
|
from odoo.exceptions import ValidationError, UserError, RedirectWarning
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ShipRocket:
|
|
|
|
def __init__(self, carrier, debug_logger):
|
|
"""
|
|
Initial parameters for making api requests.
|
|
"""
|
|
self.url = 'https://apiv2.shiprocket.in/v1/'
|
|
self.session = requests.Session()
|
|
self.carrier = carrier
|
|
self.debug_logger = debug_logger
|
|
|
|
def _make_api_request(self, endpoint, method='GET', data=None, token=None):
|
|
"""
|
|
make an api call, return response for multiple api requests of shiprocket
|
|
"""
|
|
headers = {'Content-Type': 'application/json'}
|
|
if token:
|
|
# Include authorization header if a token is provided
|
|
headers['Authorization'] = 'Bearer {}' .format(token)
|
|
access_url = url_join(self.url, endpoint)
|
|
try:
|
|
# Log the request details for debugging purposes
|
|
self.debug_logger("%s\n%s\n%s" % (access_url, method, data), 'shiprocket_request_%s' % endpoint)
|
|
# Make the API request
|
|
response = self.session.request(method, access_url, json=data, headers=headers, timeout=30)
|
|
# Parse the response as JSON
|
|
response_json = response.json()
|
|
# Log the response details for debugging purposes
|
|
self.debug_logger("%s\n%s" % (response.status_code, response.text), 'shiprocket_response_%s' % endpoint)
|
|
return response_json
|
|
except requests.exceptions.ConnectionError as error:
|
|
_logger.warning('Connection Error: %s with the given URL: %s', error, access_url)
|
|
return {'errors': {'timeout': "Cannot reach the server. Please try again later."}}
|
|
except json.decoder.JSONDecodeError as error:
|
|
_logger.warning('JSONDecodeError: %s', error)
|
|
return {'errors': {'JSONDecodeError': str(error)}}
|
|
|
|
def _authorize_generate_token(self):
|
|
"""
|
|
Generate an access token from shiprocket credentials.
|
|
"""
|
|
data = {
|
|
'email': self.carrier.shiprocket_email,
|
|
'password': self.carrier.shiprocket_password,
|
|
}
|
|
return self._make_api_request('external/auth/login', 'POST', data)
|
|
|
|
def _get_token(self):
|
|
"""
|
|
Generate an access token for shiprocket.
|
|
The token is automatically generates after 9 days as it expires.
|
|
"""
|
|
if not (self.carrier.shiprocket_email and self.carrier.shiprocket_password):
|
|
action = self.carrier.env.ref('delivery.action_delivery_carrier_form')
|
|
raise RedirectWarning(
|
|
_("Please configure Shiprocket credentials in the shipping method"), action.id,
|
|
_("Go to Shipping Method")
|
|
)
|
|
if not self.carrier.shiprocket_access_token or (
|
|
self.carrier.shiprocket_token_valid_upto and
|
|
self.carrier.shiprocket_token_valid_upto < fields.datetime.today()
|
|
):
|
|
response_json = self._authorize_generate_token()
|
|
if 'token' in response_json:
|
|
self.carrier.write({
|
|
'shiprocket_access_token': response_json['token'],
|
|
'shiprocket_token_valid_upto': fields.datetime.today() + timedelta(days=9)
|
|
})
|
|
return self.carrier.shiprocket_access_token
|
|
|
|
def _shiprocket_get_error_message(self, json_data):
|
|
"""
|
|
Return error message(s) from shiprocket requests.
|
|
"""
|
|
errors = json_data.get('errors', {})
|
|
payload = json_data.get('payload', {})
|
|
message = ''
|
|
|
|
if errors:
|
|
for value in errors.values():
|
|
sub_msg = "\n".join(value) if isinstance(value, list) else value or ''
|
|
message += _("Shiprocket Error: %s", sub_msg) + '\n'
|
|
elif 'message' in json_data:
|
|
message = _("Shiprocket Error: %s", json_data['message'])
|
|
elif 'error_message' in payload:
|
|
message = _("Shiprocket Error: %s", payload['error_message'])
|
|
elif 'awb_assign_error' in payload:
|
|
message = _('Shiprocket Error: %s', payload['awb_assign_error'])
|
|
elif not json_data.get('label_created') and 'response' in json_data:
|
|
message = _('Shiprocket Error: %s', json_data['response'])
|
|
return message
|
|
|
|
def _fetch_shiprocket_channels(self):
|
|
"""
|
|
Import all active channels from shiprocket requests.
|
|
https://apiv2.shiprocket.in/v1/external/channels
|
|
"""
|
|
channels_response = self._make_api_request('external/channels', token=self._get_token())
|
|
if 'data' in channels_response:
|
|
channels = {channel['name']: channel['id'] for channel in channels_response['data']}
|
|
return channels
|
|
raise ValidationError(self._shiprocket_get_error_message(channels_response))
|
|
|
|
def _fetch_shiprocket_couriers(self):
|
|
"""
|
|
Import all available courier service(s) from shiprocket requests.
|
|
https://apiv2.shiprocket.in/v1/external/courier/courierListWithCounts
|
|
"""
|
|
carriers_json = self._make_api_request('external/courier/courierListWithCounts', token=self._get_token())
|
|
if 'courier_data' in carriers_json:
|
|
return carriers_json['courier_data']
|
|
raise ValidationError(self._shiprocket_get_error_message(carriers_json))
|
|
|
|
def _get_rate(self, shipper, recipient, weight_in_kg, dimensions):
|
|
"""
|
|
Fetch rate from Shiprocket API based on the parameters.
|
|
"""
|
|
res = {'currency': 'INR'}
|
|
is_india = recipient.country_id.code == 'IN' and shipper.country_id.code == 'IN'
|
|
data = {
|
|
'pickup_postcode': shipper.zip,
|
|
'delivery_postcode': recipient.zip,
|
|
'weight': weight_in_kg,
|
|
'delivery_country': recipient.country_id.code,
|
|
'length': dimensions.get('length'),
|
|
'breadth': dimensions.get('width'),
|
|
'height': dimensions.get('height')
|
|
}
|
|
if is_india:
|
|
data['cod'] = self.carrier.shiprocket_payment_method == 'cod' and '1' or '0'
|
|
rate_json = self._make_api_request('external/courier/serviceability/', data=data, token=self._get_token())
|
|
else:
|
|
data['cod'] = 0
|
|
rate_json = self._make_api_request('external/courier/international/serviceability', data=data, token=self._get_token())
|
|
if rate_json and rate_json.get('data'):
|
|
available_couriers = rate_json['data'].get('available_courier_companies')
|
|
recommended_by = ''
|
|
selected_couriers = False
|
|
for available_courier in available_couriers:
|
|
if self.carrier.shiprocket_courier_ids:
|
|
if available_courier.get('courier_company_id') in self.carrier.shiprocket_courier_ids.mapped('courier_code'):
|
|
selected_couriers = available_courier
|
|
break
|
|
else:
|
|
selected_couriers = available_courier
|
|
recommended_by = rate_json['data'].get('recommended_by', {}).get('title')
|
|
break
|
|
if selected_couriers:
|
|
rate = selected_couriers.get('rate')
|
|
if rate and isinstance(rate, dict):
|
|
rate = rate.get('rate')
|
|
courier_name = selected_couriers.get('courier_name')
|
|
courier_code = selected_couriers.get('courier_company_id')
|
|
res.update({
|
|
'courier_name': courier_name,
|
|
'price': rate,
|
|
'courier_code': courier_code,
|
|
'warning_message': recommended_by and
|
|
_("Courier (%s): %s", recommended_by, courier_name) or
|
|
_("Courier: %s", courier_name)
|
|
})
|
|
else:
|
|
res.update({'error_found': _('Courier is not available for delivery!')})
|
|
else:
|
|
res.update({'error_found': self._shiprocket_get_error_message(rate_json)})
|
|
return res
|
|
|
|
def _rate_request(self, recipient, shipper, order=False, picking=False, package=False):
|
|
"""
|
|
Returns the dictionary of shipment rate from shiprocket
|
|
https://apiv2.shiprocket.in/v1/external/courier/serviceability/
|
|
https://apiv2.shiprocket.in/v1/external/courier/international/serviceability
|
|
"""
|
|
if not (order or picking):
|
|
raise UserError(_('Sale Order or Picking is required to get rate.'))
|
|
products = picking and picking.move_ids.product_id or order.order_line.product_id
|
|
self._check_required_value(
|
|
recipient, shipper,
|
|
products and products.filtered(lambda p: p.detailed_type in ['consu', 'product'])
|
|
)
|
|
if package:
|
|
total_weight = package.weight
|
|
dimensions = package.dimension
|
|
else:
|
|
default_package = self.carrier.shiprocket_default_package_type_id
|
|
if picking:
|
|
packages = self.carrier._get_packages_from_picking(picking, default_package)
|
|
else:
|
|
packages = self.carrier._get_packages_from_order(order, default_package)
|
|
|
|
dimensions = {}
|
|
if len(packages) == 1:
|
|
dimensions = {
|
|
'length': packages[0].dimension['length'],
|
|
'width': packages[0].dimension['width'],
|
|
'height': packages[0].dimension['height']
|
|
}
|
|
total_weight = sum(pack.weight for pack in packages)
|
|
weight_in_kg = self.carrier._shiprocket_convert_weight(total_weight)
|
|
rate_dict = self._get_rate(shipper, recipient, weight_in_kg, dimensions)
|
|
return rate_dict
|
|
|
|
def _get_currency_converted_amount(self, amount, picking):
|
|
"""
|
|
Returns the amount converted in INR currency.
|
|
"""
|
|
inr_currency = picking.env.ref('base.INR')
|
|
picking_currency = picking.sale_id.currency_id if picking.sale_id else picking.company_id.currency_id
|
|
if picking_currency.id != inr_currency.id:
|
|
return picking_currency._convert(amount, inr_currency, picking.company_id or picking.env.company,
|
|
picking.date_done or datetime.today())
|
|
return amount
|
|
|
|
def _get_gst_tax_rate(self, stock_move):
|
|
"""
|
|
Returns the GST tax amount from order lines.
|
|
"""
|
|
gst_tax_amount = 0.0
|
|
tax_ids = stock_move.sale_line_id and stock_move.sale_line_id.sudo().tax_id or stock_move.product_id.sudo().taxes_id
|
|
for tax in tax_ids.flatten_taxes_hierarchy():
|
|
tax_tag_ids = tax.invoice_repartition_line_ids.tag_ids
|
|
if tax_tag_ids and any(tax.env.ref(f"l10n_in.tax_tag_{gst}gst", False) in tax_tag_ids for gst in ["c", "s", "i"]):
|
|
gst_tax_amount += tax.amount
|
|
return gst_tax_amount
|
|
|
|
def _get_subtotal(self, line_vals):
|
|
"""
|
|
Returns the subtotal of the order.
|
|
"""
|
|
return sum(line['selling_price'] * line['units'] for line in line_vals)
|
|
|
|
def _get_phone(self, partner):
|
|
"""
|
|
Return the mobile/phone for shiprocket requests.
|
|
"""
|
|
matches = re.findall(r"\d+", partner.mobile or partner.phone or '')
|
|
return "".join(matches)
|
|
|
|
def _get_shipping_lines(self, package, picking):
|
|
"""
|
|
Returns shipping products data from picking to create an order.
|
|
Get shipping lines from package commodities.
|
|
"""
|
|
line_by_product = {}
|
|
package_total_value = 0
|
|
for commodity in package.commodities:
|
|
moves = picking.env['stock.move']
|
|
for move in picking.move_ids:
|
|
if move.product_id != commodity.product_id:
|
|
continue
|
|
if package.name == "Bulk Content":
|
|
if any(not ml.result_package_id for ml in move.move_line_ids):
|
|
moves |= move
|
|
else:
|
|
if any(ml.result_package_id.name == package.name for ml in move.move_line_ids):
|
|
moves |= move
|
|
dest_moves = picking.env['stock.move']
|
|
for move in picking.move_ids:
|
|
move_dest = move._rollup_move_dests(set())
|
|
if move_dest:
|
|
# need only those moves which have sale_line_id links for 3 step delivery
|
|
dest_moves |= picking.env['stock.move'].browse(move_dest)
|
|
if dest_moves:
|
|
moves = dest_moves
|
|
# label price must be in the INR currency
|
|
unit_price = self._get_currency_converted_amount(round(commodity.monetary_value, 2), package.picking_id)
|
|
line_by_product[commodity.product_id.id] = {
|
|
"name": commodity.product_id.name,
|
|
"sku": commodity.product_id.default_code or "",
|
|
"units": commodity.qty,
|
|
"selling_price": unit_price,
|
|
"hsn": commodity.product_id.hs_code or "",
|
|
"tax": self._get_gst_tax_rate(moves),
|
|
}
|
|
package_total_value += unit_price * commodity.qty
|
|
if package_total_value > 50000 and not picking.eway_bill_number:
|
|
raise ValidationError(_(
|
|
'Eway Bill number is required to ship an order if order amount is more than 50,000 INR.'
|
|
))
|
|
return line_by_product
|
|
|
|
def _prepare_parcel(self, picking, package, courier_code=False, ship_charges=0.00, index=1):
|
|
"""
|
|
Prepare parcel for picking shipment based on the package.
|
|
"""
|
|
database_uuid = picking.env['ir.config_parameter'].sudo().get_param('database.uuid')
|
|
unique_ref = str(index) + '-' + database_uuid[:5]
|
|
order_name = picking.name
|
|
partner = partner_invoice = picking.partner_id
|
|
if partner.child_ids.filtered(lambda p: p.type == 'invoice'):
|
|
partner_invoice = partner.child_ids.filtered(lambda p: p.type == 'invoice')[0]
|
|
warehouse_partner_id = picking.picking_type_id.warehouse_id.partner_id or picking.company_id.partner_id
|
|
warehouse_partner_name = re.sub(r'[^a-zA-Z0-9\s]+', '', warehouse_partner_id.name)[:36]
|
|
if picking.sale_id:
|
|
partner_invoice = picking.sale_id.partner_invoice_id
|
|
order_name = order_name + '-' + picking.sale_id.name
|
|
dimensions = package.dimension
|
|
net_weight_in_kg = self.carrier._shiprocket_convert_weight(package.weight)
|
|
line_vals = self._get_shipping_lines(package, picking).values()
|
|
payment_method = "Prepaid" if self.carrier.shiprocket_payment_method == "prepaid" else "COD"
|
|
return {
|
|
"request_pickup": self.carrier.shiprocket_pickup_request,
|
|
"print_label": True,
|
|
"generate_manifest": self.carrier.shiprocket_manifests_generate,
|
|
"order_id": order_name + '#' + unique_ref,
|
|
"order_date": (picking.sale_id.date_order or picking.scheduled_date.date()).strftime("%Y-%m-%d"),
|
|
"channel_id": self.carrier.shiprocket_channel_id.channel_code,
|
|
"length": dimensions.get('length'),
|
|
"breadth": dimensions.get('width'),
|
|
"height": dimensions.get('height'),
|
|
"weight": net_weight_in_kg,
|
|
"courier_id": courier_code,
|
|
"ewaybill_no": picking.eway_bill_number,
|
|
"company_name": partner_invoice.commercial_partner_id.name,
|
|
"billing_customer_name": partner_invoice.name,
|
|
"billing_last_name": "",
|
|
"billing_address": partner_invoice.street,
|
|
"billing_address_2": partner_invoice.street2 or "",
|
|
"billing_city": partner_invoice.city or "",
|
|
"billing_pincode": partner_invoice.zip,
|
|
"billing_state": partner_invoice.state_id.name or "",
|
|
"billing_country": partner_invoice.country_id.name,
|
|
"billing_email": partner_invoice.email,
|
|
"billing_phone": self._get_phone(partner_invoice),
|
|
"shipping_is_billing": partner_invoice == partner,
|
|
"shipping_customer_name": partner.name,
|
|
"shipping_last_name": "",
|
|
"shipping_address": partner.street or "",
|
|
"shipping_address_2": partner.street2 or "",
|
|
"shipping_city": partner.city or "",
|
|
"shipping_pincode": partner.zip,
|
|
"shipping_country": partner.country_id.name,
|
|
"shipping_state": partner.state_id.name or "",
|
|
"shipping_email": partner.email,
|
|
"shipping_phone": self._get_phone(partner),
|
|
"order_items": list(line_vals),
|
|
"sub_total": self._get_subtotal(line_vals),
|
|
"payment_method": payment_method,
|
|
"shipping_charges": ship_charges,
|
|
"pickup_location": warehouse_partner_name,
|
|
"vendor_details": {
|
|
"email": warehouse_partner_id.email,
|
|
"phone": self._get_phone(warehouse_partner_id),
|
|
"name": warehouse_partner_name,
|
|
"address": warehouse_partner_id.street,
|
|
"address_2": warehouse_partner_id.street2 or "",
|
|
"city": warehouse_partner_id.city or "",
|
|
"state": warehouse_partner_id.state_id.name or "",
|
|
"country": warehouse_partner_id.country_id.name,
|
|
"pin_code": warehouse_partner_id.zip,
|
|
"pickup_location": warehouse_partner_name,
|
|
}
|
|
}
|
|
|
|
def _get_shipping_params(self, picking):
|
|
"""
|
|
Returns the shipping data from picking for create an shiprocket order.
|
|
"""
|
|
if not self.carrier.shiprocket_channel_id:
|
|
action = self.carrier.env.ref('delivery.action_delivery_carrier_form')
|
|
raise RedirectWarning(_("Configure Shiprocket channel in shipping method"), action.id,
|
|
_("Go to Shipping Methods"))
|
|
parcel_dict = {}
|
|
ship_from = picking.picking_type_id.warehouse_id.partner_id or picking.company_id.partner_id
|
|
default_package = self.carrier.shiprocket_default_package_type_id
|
|
packages = self.carrier._get_packages_from_picking(picking, default_package)
|
|
for index, package in enumerate(packages):
|
|
# fetch rate based on package weight and size
|
|
rate_response = self._rate_request(picking.partner_id, ship_from, picking=picking, package=package)
|
|
# need courier code, as the rate request and forward shipment APIs must have to use the same courier code.
|
|
courier_code = rate_response.get('courier_code')
|
|
# ship_charges is mandatory as forward shipment API will not return it in response, used in subtotal.
|
|
ship_charges = rate_response.get('price')
|
|
parcel = self._prepare_parcel(picking, package, courier_code, ship_charges, index=index)
|
|
parcel_dict[package] = parcel
|
|
return parcel_dict
|
|
|
|
def _send_shipping(self, picking):
|
|
"""
|
|
Returns the dictionary with order_id, shipment_id, tracking_number,
|
|
exact_price and courier_name for delivery order.
|
|
- for multiple package, shiprocket create new order.
|
|
https://apiv2.shiprocket.in/v1/external/shipments/create/forward-shipment
|
|
"""
|
|
products = picking.move_line_ids.product_id
|
|
self._check_required_value(
|
|
picking.partner_id,
|
|
picking.picking_type_id.warehouse_id.partner_id or picking.company_id.partner_id,
|
|
products and products.filtered(lambda p: p.detailed_type in ['consu', 'product'])
|
|
)
|
|
res = {
|
|
'exact_price': 0.00,
|
|
'tracking_numbers': [],
|
|
'order_ids': [],
|
|
'all_pack': defaultdict(lambda: {'response': {}, 'order_details': {}})
|
|
}
|
|
params = self._get_shipping_params(picking)
|
|
for delivery_package, shiprocket_parcel in params.items():
|
|
order_response = self._make_api_request(
|
|
'external/shipments/create/forward-shipment', 'POST',
|
|
shiprocket_parcel,
|
|
token=self._get_token()
|
|
)
|
|
if order_response.get('errors'):
|
|
picking.message_post(body=self._shiprocket_get_error_message(order_response))
|
|
continue
|
|
payload = order_response.get('payload')
|
|
if not payload:
|
|
picking.message_post(body=_('AWB assignment was unsuccessful: %s') % (self._shiprocket_get_error_message(order_response)))
|
|
continue
|
|
res['all_pack'][delivery_package]['response'] = payload
|
|
if payload.get('shipment_id') and payload.get('error_message') and 'Oops! Cannot reassign courier' in payload['error_message']:
|
|
payload.pop('error_message')
|
|
res['all_pack'][delivery_package]['response']['warning_message'] = \
|
|
_("Same order is available in Shiprocket so label provided is the copy of existing one.")
|
|
label_response = self._generate_label(payload['shipment_id'])
|
|
res['all_pack'][delivery_package]['response']['label_url'] = label_response.get('label_url')
|
|
if payload.get('shipment_id') and not payload.get('error_message') and not payload.get('awb_assign_error'):
|
|
res['tracking_numbers'].append(payload.get('awb_code'))
|
|
# To get exact_price
|
|
order_details = self._make_api_request('external/shipments/{}'.format(payload['shipment_id']), token=self._get_token())
|
|
order_id = order_details.get('data').get('order_id')
|
|
if order_id:
|
|
res['order_ids'].append(str(order_id))
|
|
res['all_pack'][delivery_package]['order_details'] = order_details
|
|
res['exact_price'] += float(order_details.get('data', {}).get('charges', {}).get('freight_charges', '0.00'))
|
|
else:
|
|
picking.message_post(body=_('AWB assignment was unsuccessful: %s') % (self._shiprocket_get_error_message(order_response)))
|
|
return res
|
|
|
|
def _generate_label(self, shipment_id):
|
|
"""
|
|
Generate Label for shiprocket order if the forward shipment fails
|
|
to generate label again and shipment is already created in shiprocket.
|
|
https://apiv2.shiprocket.in/v1/external/courier/generate/label
|
|
"""
|
|
label_data = {"shipment_id": [shipment_id]}
|
|
label_result = self._make_api_request(
|
|
'external/courier/generate/label',
|
|
'POST',
|
|
label_data,
|
|
token=self._get_token()
|
|
)
|
|
if label_result and 'label_url' in label_result:
|
|
return label_result
|
|
raise ValidationError(self._shiprocket_get_error_message(label_result))
|
|
|
|
def _send_cancelling(self, orders_data, pickup_request):
|
|
"""
|
|
Cancelling shiprocket order/shipment.
|
|
https://apiv2.shiprocket.in/v1/external/orders/cancel
|
|
https://apiv2.shiprocket.in/v1/external/orders/cancel/shipment/awbs
|
|
"""
|
|
cancel_result = {}
|
|
for order in orders_data:
|
|
if pickup_request:
|
|
endpoint = 'external/orders/cancel'
|
|
data = {'ids': [order]}
|
|
else:
|
|
endpoint = 'external/orders/cancel/shipment/awbs'
|
|
data = {'awbs': [order]}
|
|
cancel_result.update({
|
|
order: self._make_api_request(endpoint, 'POST', data, token=self._get_token()
|
|
)})
|
|
return cancel_result
|
|
|
|
def _check_required_value(self, recipient, shipper, products):
|
|
"""
|
|
Check if the required value are not present in order to process an API call.
|
|
return True or return an error if configuration is missing.
|
|
"""
|
|
error_msg = {'Customer': [], 'Shipper': []}
|
|
if not recipient.street:
|
|
error_msg['Customer'].append(_("Street is required!"))
|
|
if not recipient.zip:
|
|
error_msg['Customer'].append(_("Pincode is required!"))
|
|
if not recipient.country_id:
|
|
error_msg['Customer'].append(_("Country is required!"))
|
|
if not recipient.email:
|
|
error_msg['Customer'].append(_("Email is required!"))
|
|
if not recipient.phone and not recipient.mobile:
|
|
error_msg['Customer'].append(_("Phone or Mobile is required!"))
|
|
if not shipper.street:
|
|
error_msg['Shipper'].append(_("Street is required!"))
|
|
if not shipper.zip:
|
|
error_msg['Shipper'].append(_("Pincode is required!"))
|
|
if not shipper.country_id:
|
|
error_msg['Shipper'].append(_("Country is required!"))
|
|
if not shipper.email:
|
|
error_msg['Shipper'].append(_("Email is required!"))
|
|
if not shipper.phone and not shipper.mobile:
|
|
error_msg['Shipper'].append(_("Phone or Mobile is required!"))
|
|
for product in products:
|
|
if not product.weight:
|
|
error_msg.setdefault(product.name, [])
|
|
error_msg[product.name].append(_("Weight is missing!"))
|
|
if not product.default_code:
|
|
error_msg.setdefault(product.name, [])
|
|
error_msg[product.name].append(_("SKU is missing!"))
|
|
if error_msg:
|
|
msg = "".join(e_for + "\n- " + "\n- ".join(e) + "\n" for e_for, e in error_msg.items() if e)
|
|
if msg:
|
|
raise ValidationError(msg)
|
|
return True
|