Skip to content

Commit e1f7a33

Browse files
committed
USPS API updates.
1 parent b7d61f2 commit e1f7a33

5 files changed

Lines changed: 93 additions & 98 deletions

File tree

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: 74 additions & 94 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-
42-
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
4+
import threading
645

6+
import requests
657

66-
def expand_weight(weight):
67-
pounds = int(weight)
68-
ounces = (weight - int(weight)) * 16
69-
return pounds, ounces
70-
8+
from .config import SHIPPING_COUNTRIES
9+
from .utils import get_setting
7110

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)
11+
ENDPOINT = "https://apis.usps.com"
12+
USPS_COUNTRY_MAP = dict(SHIPPING_COUNTRIES)
13+
USPS_TOKEN = {}
14+
token_update_lock = threading.Lock()
8515

8616

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,
93-
}
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)
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"]
9839

9940

10041
def calculate_usps_rate(weight, service_type, country, zip):
101-
if country == 'United States':
102-
return calculate_domestic_usps_rate(weight, service_type, zip)
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+
"length": 1,
52+
"width": 1,
53+
"height": 1,
54+
}
55+
# We can't calculate rates without a destination zip code. Returning None
56+
# will skip this shipping method
57+
if not zip:
58+
return None
59+
if country == "United States":
60+
endpoint = f"{ENDPOINT}/prices/v3/base-rates/search"
61+
options["destinationZIPCode"] = zip
62+
if service_type == "USPS Media Mail":
63+
options["mailClass"] = "MEDIA_MAIL"
64+
else:
65+
options["mailClass"] = "PRIORITY_MAIL"
10366
else:
104-
return calculate_international_usps_rate(weight, country)
67+
endpoint = f"{ENDPOINT}/international-prices/v3/base-rates/search"
68+
options["destinationCountryCode"] = USPS_COUNTRY_MAP[country]
69+
options["foreignPostalCode"] = zip
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()["response"]["errors"][0]["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)