Skip to content

Commit 69881e8

Browse files
committed
USPS API updates.
1 parent b7d61f2 commit 69881e8

7 files changed

Lines changed: 111 additions & 105 deletions

File tree

jazkarta/shop/browser/templates/shipping_form.pt

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -367,11 +367,11 @@
367367
jQuery(function($) {
368368
var $form = $('#form');
369369
var $street = $('input[name="form.widgets.street"]');
370-
var $city = $('input[name="form.widgets.street"]');
370+
var $city = $('input[name="form.widgets.city"]');
371371
var $state_text = $('input[name="form.widgets.state"]');
372372
var $state_select = $('#state-select');
373373
var $prov_select = $('#province-select');
374-
var $zip = $('input[name="form.widgets.street"]');
374+
var $zip = $('input[name="form.widgets.postal_code"]');
375375
var $country = $('select[name="form.widgets.country"]');
376376

377377
var update_states = function() {
@@ -406,8 +406,13 @@
406406
};
407407
var update_form = function() {
408408
var $state = $('[name="form.widgets.state"]');
409-
if ($country.val() && $state.val() && $zip.val() && $city.val() && $street.val()) {
409+
var country = $country.val();
410+
var is_us = country === 'United States';
411+
// for US addresses we require zip code, for other countries we may not
412+
if (((is_us && $zip.val()) || (country && !is_us)) && $state.val() && $city.val() && $street.val()) {
410413
$('#shipping-method').load(location.href, $form.serialize() + '&update=1');
414+
} else {
415+
$('#shipping-method').html('<div class="portalMessage warning">Please enter a complete shipping address.</div>');
411416
}
412417
};
413418

@@ -468,8 +473,13 @@
468473
};
469474
var update_form = function() {
470475
var $state = $('[name="form.widgets.state"]');
471-
if ($country.val() && $state.val() && $zip.val() && $city.val() && $street.val()) {
476+
var country = $country.val();
477+
var is_us = country === 'United States';
478+
// for US addresses we require zip code, for other countries we may not
479+
if (((is_us && $zip.val()) || (country && !is_us)) && $state.val() && $city.val() && $street.val()) {
472480
$('#shipping-method').load(location.href, $form.serialize() + '&update=1');
481+
} else {
482+
$('#shipping-method').html('<div class="portalMessage warning">Please enter a complete shipping address.</div>');
473483
}
474484
};
475485

jazkarta/shop/browser/templates/shipping_methods_widget.pt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
<tal:b define="status python:view.available_shipping_methods()[0];
2-
methods python:view.available_shipping_methods()[1];
3-
value request/form/shipping_method|nothing;
4-
value python:methods[0]['id'] if (methods and not value) else value;">
1+
<tal:b define="method_info python:view.available_shipping_methods();
2+
status python:method_info[0];
3+
methods python:method_info[1];
4+
value request/form/shipping_method|nothing;
5+
value python:methods[0]['id'] if (methods and not value) else value;">
56

67
<div tal:repeat="method methods">
78
<label>

jazkarta/shop/interfaces.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,8 +347,14 @@ class ISettings(model.Schema):
347347
required=False,
348348
)
349349

350-
usps_userid = schema.TextLine(
351-
title=u'USPS WebTools API User Id',
350+
usps_consumer_key = schema.TextLine(
351+
title=u'USPS API Consumer Key',
352+
description=u"Required if USPS shipping option is being used.",
353+
required=False,
354+
)
355+
356+
usps_consumer_secret = schema.TextLine(
357+
title=u'USPS API Consumer Secret',
352358
description=u"Required if USPS shipping option is being used.",
353359
required=False,
354360
)

jazkarta/shop/profile.zcml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,4 +151,13 @@
151151
import_steps="plone.app.registry"
152152
/>
153153

154+
<genericsetup:upgradeDepends
155+
profile="jazkarta.shop:default"
156+
source="13"
157+
destination="14"
158+
title="Replace USPS userid with OAuth2 consumer key and secret"
159+
description=""
160+
import_steps="plone.app.registry"
161+
/>
162+
154163
</configure>

jazkarta/shop/profiles/plone4/metadata.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?xml version="1.0"?>
22
<metadata>
3-
<version>13</version>
3+
<version>14</version>
44
<dependencies>
55
<dependency>profile-plone.app.dexterity:default</dependency>
66
<dependency>profile-collective.z3cform.datagridfield:default</dependency>

jazkarta/shop/profiles/plone5/metadata.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?xml version="1.0"?>
22
<metadata>
3-
<version>13</version>
3+
<version>14</version>
44
<dependencies>
55
<dependency>profile-collective.z3cform.datagridfield:default</dependency>
66
</dependencies>

jazkarta/shop/ship_usps.py

Lines changed: 73 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,104 +1,84 @@
1+
from datetime import datetime
2+
from datetime import timedelta
13
from decimal import Decimal
2-
from lxml import etree
3-
import re
4-
import requests
5-
from .utils import get_setting
6-
7-
ENDPOINT = 'http://production.shippingapis.com/ShippingAPI.dll'
8-
DOMESTIC_REQUEST = """<RateV4Request USERID="%(userid)s">
9-
<Revision>2</Revision>
10-
<Package ID="0">
11-
<Service>%(service)s</Service>
12-
<ZipOrigination>%(origin_zip)s</ZipOrigination>
13-
<ZipDestination>%(destination_zip)s</ZipDestination>
14-
<Pounds>%(pounds)s</Pounds>
15-
<Ounces>%(ounces)s</Ounces>
16-
<Container></Container>
17-
<Size>REGULAR</Size>
18-
</Package>
19-
</RateV4Request>
20-
"""
21-
INTL_REQUEST = """<IntlRateV2Request USERID="%(userid)s">
22-
<Revision>2</Revision>
23-
<Package ID="0">
24-
<Pounds>%(pounds)s</Pounds>
25-
<Ounces>%(ounces)s</Ounces>
26-
<MailType>Package</MailType>
27-
<ValueOfContents></ValueOfContents>
28-
<Country>%(country)s</Country>
29-
<Container></Container>
30-
<Size>REGULAR</Size>
31-
<Width></Width>
32-
<Length></Length>
33-
<Height></Height>
34-
<Girth></Girth>
35-
<OriginZip>%(origin_zip)s</OriginZip>
36-
</Package>
37-
</IntlRateV2Request>
38-
"""
39-
40-
WHITESPACE_RE = re.compile(r'\s+')
41-
4+
import threading
425

43-
def call_usps_api(api, request, params):
44-
params = params.copy()
45-
params.update({
46-
'userid': get_setting('usps_userid'),
47-
'origin_zip': get_setting('ship_from_zip'),
48-
})
49-
request = (request % params).replace('\n', '')
50-
request = WHITESPACE_RE.sub(' ', request)
51-
res = requests.get(ENDPOINT, params={
52-
'API': api,
53-
'XML': request,
54-
})
55-
tree = etree.fromstring(res.content)
56-
error = None
57-
if tree.tag == 'Error':
58-
error = tree
59-
else:
60-
error = tree.find('.//Error')
61-
if error is not None:
62-
raise Exception('USPS Error: %s' % error.find('Description').text)
63-
return tree
6+
import requests
647

8+
from .config import SHIPPING_COUNTRIES
9+
from .utils import get_setting
6510

66-
def expand_weight(weight):
67-
pounds = int(weight)
68-
ounces = (weight - int(weight)) * 16
69-
return pounds, ounces
11+
ENDPOINT = "https://apis.usps.com"
12+
USPS_COUNTRY_MAP = dict(SHIPPING_COUNTRIES)
13+
USPS_TOKEN = {}
14+
token_update_lock = threading.Lock()
7015

7116

72-
def calculate_domestic_usps_rate(weight, service_type, destination_zip):
73-
pounds, ounces = expand_weight(weight)
74-
usps_services = {'USPS Priority Mail' : 'PRIORITY',
75-
'USPS Media Mail' : 'MEDIA'}
76-
params = {
77-
'destination_zip': destination_zip,
78-
'pounds': pounds,
79-
'ounces': ounces,
80-
'service': usps_services[service_type],
81-
}
82-
res = call_usps_api('RateV4', DOMESTIC_REQUEST, params)
83-
rate = res.find('.//Postage/Rate').text
84-
return Decimal(rate)
17+
def get_usps_access_token():
18+
"""Get USPS OAuth access token.
19+
Cache it in a global variable until it expires.
20+
"""
21+
token = USPS_TOKEN
22+
with token_update_lock:
23+
if not token or token["expires_at"] < datetime.now():
24+
response = requests.post(
25+
f"{ENDPOINT}/oauth2/v3/token",
26+
json={
27+
"grant_type": "client_credentials",
28+
"client_id": get_setting('usps_consumer_key'),
29+
"client_secret": get_setting('usps_consumer_secret'),
30+
},
31+
)
32+
response.raise_for_status()
33+
token.update(response.json())
34+
token["expires_at"] = (
35+
datetime.fromtimestamp(int(token["issued_at"]) / 1000) +
36+
timedelta(seconds=int(token["expires_in"]) - 60)
37+
)
38+
return token["access_token"]
8539

8640

87-
def calculate_international_usps_rate(weight, country):
88-
pounds, ounces = expand_weight(weight)
89-
params = {
90-
'country': country,
91-
'pounds': pounds,
92-
'ounces': ounces,
41+
def calculate_usps_rate(weight, service_type, country, zipcode):
42+
access_token = get_usps_access_token()
43+
origin_zip = get_setting('ship_from_zip')
44+
options = {
45+
"priceType": "RETAIL",
46+
"processingCategory": "NONSTANDARD",
47+
"rateIndicator": "SP", # single piece (not flat rate box or envelope)
48+
"destinationEntryFacilityType": "NONE",
49+
"originZIPCode": origin_zip,
50+
"weight": weight,
51+
"width": 1,
52+
"height": 12,
53+
"length": 12,
9354
}
94-
res = call_usps_api('IntlRateV2', INTL_REQUEST, params)
95-
# get the rate for Priority Mail International
96-
rate = res.find('.//Service[@ID="2"]').find('Postage').text
97-
return Decimal(rate)
98-
9955

100-
def calculate_usps_rate(weight, service_type, country, zip):
101-
if country == 'United States':
102-
return calculate_domestic_usps_rate(weight, service_type, zip)
56+
if country == "United States":
57+
if not zipcode:
58+
return None
59+
endpoint = f"{ENDPOINT}/prices/v3/base-rates/search"
60+
options["destinationZIPCode"] = zipcode
61+
if service_type == "USPS Media Mail":
62+
options["mailClass"] = "MEDIA_MAIL"
63+
else:
64+
options["mailClass"] = "PRIORITY_MAIL"
10365
else:
104-
return calculate_international_usps_rate(weight, country)
66+
endpoint = f"{ENDPOINT}/international-prices/v3/base-rates/search"
67+
options["destinationCountryCode"] = USPS_COUNTRY_MAP[country]
68+
if zipcode:
69+
options["foreignPostalCode"] = zipcode
70+
options["mailClass"] = "PRIORITY_MAIL_INTERNATIONAL"
71+
response = requests.post(
72+
endpoint,
73+
json=options,
74+
headers={"Authorization": f"Bearer {access_token}"},
75+
)
76+
try:
77+
response.raise_for_status()
78+
except Exception as err:
79+
try:
80+
message = response.json()["error"]["message"]
81+
except Exception:
82+
message = str(err)
83+
raise Exception(f"USPS error: {message}")
84+
return Decimal(response.json()["totalBasePrice"])

0 commit comments

Comments
 (0)