generated from westfarn/web_django_template
CI / test (pull_request) Successful in 35s
Shoppers can register, save shipping details, and view order history while cards stay on Stripe. EasyPost tracker updates (including numbers from Pirate Ship) and 1–5 star reviews are limited to buyers. The contact form now only asks for email and a message.
357 lines
12 KiB
Python
357 lines
12 KiB
Python
"""EasyPost-style rates/labels plus Pirate Ship CSV export."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import io
|
|
import logging
|
|
from decimal import Decimal
|
|
from urllib.parse import quote
|
|
|
|
import requests
|
|
from django.conf import settings
|
|
from django.utils import timezone
|
|
|
|
from shipping.models import Shipment
|
|
from shop.models import Order
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ShippingError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def _easypost_key() -> str:
|
|
return (getattr(settings, "EASYPOST_API_KEY", "") or "").strip()
|
|
|
|
|
|
def quote_rates(shipment: Shipment) -> list[dict]:
|
|
"""Return carrier rates. Uses EasyPost when configured; otherwise a USPS stub."""
|
|
key = _easypost_key()
|
|
if not key:
|
|
rates = [
|
|
{
|
|
"id": "stub-usps-ground",
|
|
"carrier": "USPS",
|
|
"service": "GroundAdvantage",
|
|
"rate": "5.40",
|
|
"currency": "USD",
|
|
},
|
|
{
|
|
"id": "stub-usps-priority",
|
|
"carrier": "USPS",
|
|
"service": "Priority",
|
|
"rate": "9.80",
|
|
"currency": "USD",
|
|
},
|
|
]
|
|
shipment.rates = rates
|
|
shipment.status = Shipment.Status.RATED
|
|
shipment.save(update_fields=["rates", "status", "updated_at"])
|
|
return rates
|
|
|
|
addr = shipment.order.shipping_address or {}
|
|
response = requests.post(
|
|
"https://api.easypost.com/v2/shipments",
|
|
auth=(key, ""),
|
|
json={
|
|
"shipment": {
|
|
"to_address": {
|
|
"name": shipment.order.customer_name or shipment.order.email,
|
|
"street1": addr.get("line1") or "",
|
|
"street2": addr.get("line2") or "",
|
|
"city": addr.get("city") or "",
|
|
"state": addr.get("state") or "",
|
|
"zip": addr.get("zip") or "",
|
|
"country": addr.get("country") or "US",
|
|
},
|
|
"from_address": {
|
|
"name": settings.SITE_NAME,
|
|
"street1": getattr(settings, "SHIP_FROM_LINE1", "") or "",
|
|
"city": getattr(settings, "SHIP_FROM_CITY", "") or "",
|
|
"state": getattr(settings, "SHIP_FROM_STATE", "") or "",
|
|
"zip": getattr(settings, "SHIP_FROM_ZIP", "") or "",
|
|
"country": "US",
|
|
},
|
|
"parcel": {"weight": shipment.weight_oz},
|
|
}
|
|
},
|
|
timeout=20,
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
rates = data.get("rates") or []
|
|
shipment.provider_shipment_id = data.get("id") or ""
|
|
shipment.rates = rates
|
|
shipment.status = Shipment.Status.RATED
|
|
shipment.save(
|
|
update_fields=["provider_shipment_id", "rates", "status", "updated_at"]
|
|
)
|
|
return rates
|
|
|
|
|
|
def buy_label(shipment: Shipment, *, rate_id: str = "") -> Shipment:
|
|
rates = shipment.rates or quote_rates(shipment)
|
|
chosen = None
|
|
if rate_id:
|
|
chosen = next((r for r in rates if str(r.get("id")) == str(rate_id)), None)
|
|
if chosen is None and rates:
|
|
chosen = rates[0]
|
|
if chosen is None:
|
|
raise ShippingError("No shipping rates available.")
|
|
|
|
key = _easypost_key()
|
|
if key and shipment.provider_shipment_id:
|
|
response = requests.post(
|
|
f"https://api.easypost.com/v2/shipments/{shipment.provider_shipment_id}/buy",
|
|
auth=(key, ""),
|
|
json={"rate": {"id": chosen.get("id")}},
|
|
timeout=20,
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
postage = data.get("postage_label") or {}
|
|
tracking = data.get("tracking_code") or ""
|
|
shipment.label_url = postage.get("label_url") or ""
|
|
shipment.tracking_number = tracking
|
|
else:
|
|
shipment.tracking_number = f"STUB{shipment.order.number[-6:]}"
|
|
shipment.label_url = ""
|
|
|
|
shipment.carrier = chosen.get("carrier") or ""
|
|
shipment.service = chosen.get("service") or ""
|
|
try:
|
|
shipment.rate_amount = Decimal(str(chosen.get("rate") or "0"))
|
|
except Exception:
|
|
shipment.rate_amount = None
|
|
shipment.status = Shipment.Status.LABELED
|
|
shipment.save(
|
|
update_fields=[
|
|
"carrier",
|
|
"service",
|
|
"tracking_number",
|
|
"label_url",
|
|
"rate_amount",
|
|
"status",
|
|
"updated_at",
|
|
]
|
|
)
|
|
try:
|
|
refresh_tracking(shipment)
|
|
except Exception:
|
|
logger.exception("tracking refresh failed after label buy for %s", shipment.pk)
|
|
return shipment
|
|
|
|
|
|
def create_shipment_for_order(order: Order, *, weight_oz: int = 16) -> Shipment:
|
|
if order.status not in {Order.Status.PAID, Order.Status.FULFILLED}:
|
|
raise ShippingError("Ship paid orders only.")
|
|
return Shipment.objects.create(order=order, weight_oz=weight_oz)
|
|
|
|
|
|
def pirate_ship_csv(orders=None) -> str:
|
|
"""CSV Pirate Ship can map: name, address, city, state, zip, email."""
|
|
if orders is None:
|
|
shipped = Shipment.objects.filter(
|
|
status=Shipment.Status.LABELED
|
|
).values_list("order_id", flat=True)
|
|
orders = Order.objects.filter(status=Order.Status.PAID).exclude(pk__in=shipped)
|
|
buffer = io.StringIO()
|
|
writer = csv.writer(buffer)
|
|
writer.writerow(
|
|
[
|
|
"Order Number",
|
|
"Name",
|
|
"Address 1",
|
|
"Address 2",
|
|
"City",
|
|
"State",
|
|
"Zip",
|
|
"Country",
|
|
"Email",
|
|
]
|
|
)
|
|
for order in orders:
|
|
addr = order.shipping_address or {}
|
|
writer.writerow(
|
|
[
|
|
order.number,
|
|
order.customer_name or order.email,
|
|
addr.get("line1") or "",
|
|
addr.get("line2") or "",
|
|
addr.get("city") or "",
|
|
addr.get("state") or "",
|
|
addr.get("zip") or "",
|
|
addr.get("country") or "US",
|
|
order.email,
|
|
]
|
|
)
|
|
return buffer.getvalue()
|
|
|
|
|
|
def public_tracking_url(carrier: str, tracking_number: str) -> str:
|
|
code = quote((tracking_number or "").strip())
|
|
if not code:
|
|
return ""
|
|
name = (carrier or "").upper()
|
|
if "USPS" in name:
|
|
return f"https://tools.usps.com/go/TrackConfirmAction?tLabels={code}"
|
|
if "UPS" in name:
|
|
return f"https://www.ups.com/track?tracknum={code}"
|
|
if "FEDEX" in name or "FDX" in name:
|
|
return f"https://www.fedex.com/fedextrack/?trknbr={code}"
|
|
if "DHL" in name:
|
|
return f"https://www.dhl.com/en/express/tracking.html?AWB={code}"
|
|
return f"https://www.google.com/search?q={quote((tracking_number or '') + ' tracking')}"
|
|
|
|
|
|
def _normalize_tracking_status(raw: str) -> str:
|
|
value = (raw or "").strip().lower().replace(" ", "_")
|
|
aliases = {
|
|
"pretransit": Shipment.TrackingStatus.PRE_TRANSIT,
|
|
"pre_transit": Shipment.TrackingStatus.PRE_TRANSIT,
|
|
"in_transit": Shipment.TrackingStatus.IN_TRANSIT,
|
|
"out_for_delivery": Shipment.TrackingStatus.OUT_FOR_DELIVERY,
|
|
"delivered": Shipment.TrackingStatus.DELIVERED,
|
|
"available_for_pickup": Shipment.TrackingStatus.AVAILABLE_FOR_PICKUP,
|
|
"return_to_sender": Shipment.TrackingStatus.RETURN_TO_SENDER,
|
|
"failure": Shipment.TrackingStatus.FAILURE,
|
|
"cancelled": Shipment.TrackingStatus.CANCELLED,
|
|
"canceled": Shipment.TrackingStatus.CANCELLED,
|
|
"error": Shipment.TrackingStatus.ERROR,
|
|
"unknown": Shipment.TrackingStatus.UNKNOWN,
|
|
}
|
|
return aliases.get(value, Shipment.TrackingStatus.UNKNOWN)
|
|
|
|
|
|
def apply_tracker_payload(shipment: Shipment, payload: dict) -> Shipment:
|
|
"""Apply EasyPost tracker (or compatible) JSON onto a shipment."""
|
|
data = payload or {}
|
|
tracking = (data.get("tracking_code") or data.get("tracking_number") or "").strip()
|
|
if tracking:
|
|
shipment.tracking_number = tracking
|
|
tracker_id = (data.get("id") or "").strip()
|
|
if tracker_id.startswith("trk_"):
|
|
shipment.tracker_id = tracker_id
|
|
carrier = (data.get("carrier") or "").strip()
|
|
if carrier and not shipment.carrier:
|
|
shipment.carrier = carrier
|
|
shipment.tracking_status = _normalize_tracking_status(data.get("status") or "")
|
|
public_url = (data.get("public_url") or "").strip()
|
|
shipment.tracking_url = public_url or public_tracking_url(
|
|
shipment.carrier, shipment.tracking_number
|
|
)
|
|
events = []
|
|
for item in data.get("tracking_details") or []:
|
|
loc = item.get("tracking_location") or {}
|
|
place = " ".join(
|
|
part
|
|
for part in [loc.get("city") or "", loc.get("state") or ""]
|
|
if part
|
|
).strip()
|
|
events.append(
|
|
{
|
|
"status": item.get("status") or "",
|
|
"message": item.get("message") or "",
|
|
"datetime": item.get("datetime") or "",
|
|
"location": place,
|
|
}
|
|
)
|
|
if events:
|
|
shipment.tracking_events = events
|
|
shipment.last_tracked_at = timezone.now()
|
|
shipment.save(
|
|
update_fields=[
|
|
"tracking_number",
|
|
"tracker_id",
|
|
"carrier",
|
|
"tracking_status",
|
|
"tracking_url",
|
|
"tracking_events",
|
|
"last_tracked_at",
|
|
"updated_at",
|
|
]
|
|
)
|
|
return shipment
|
|
|
|
|
|
def refresh_tracking(shipment: Shipment) -> Shipment:
|
|
"""Pull latest scan events from EasyPost, or stub status without an API key.
|
|
|
|
Works for EasyPost-bought labels and for tracking numbers pasted from
|
|
Pirate Ship / Shippo / the carrier — EasyPost's tracker API looks up
|
|
USPS, UPS, FedEx, and DHL by number.
|
|
"""
|
|
tracking = (shipment.tracking_number or "").strip()
|
|
if not tracking:
|
|
return shipment
|
|
key = _easypost_key()
|
|
if not key:
|
|
if not shipment.tracking_status or shipment.tracking_status == Shipment.TrackingStatus.UNKNOWN:
|
|
shipment.tracking_status = Shipment.TrackingStatus.PRE_TRANSIT
|
|
shipment.tracking_url = shipment.tracking_url or public_tracking_url(
|
|
shipment.carrier, tracking
|
|
)
|
|
shipment.last_tracked_at = timezone.now()
|
|
shipment.save(
|
|
update_fields=[
|
|
"tracking_status",
|
|
"tracking_url",
|
|
"last_tracked_at",
|
|
"updated_at",
|
|
]
|
|
)
|
|
return shipment
|
|
|
|
payload = {"tracker": {"tracking_code": tracking}}
|
|
if shipment.carrier:
|
|
payload["tracker"]["carrier"] = shipment.carrier
|
|
response = requests.post(
|
|
"https://api.easypost.com/v2/trackers",
|
|
auth=(key, ""),
|
|
json=payload,
|
|
timeout=20,
|
|
)
|
|
response.raise_for_status()
|
|
return apply_tracker_payload(shipment, response.json())
|
|
|
|
|
|
def attach_tracking(
|
|
shipment: Shipment, *, tracking_number: str, carrier: str = ""
|
|
) -> Shipment:
|
|
tracking_number = (tracking_number or "").strip()
|
|
if not tracking_number:
|
|
raise ShippingError("Tracking number is required.")
|
|
shipment.tracking_number = tracking_number
|
|
if carrier:
|
|
shipment.carrier = carrier.strip()
|
|
if shipment.status != Shipment.Status.LABELED:
|
|
shipment.status = Shipment.Status.LABELED
|
|
shipment.save(
|
|
update_fields=["tracking_number", "carrier", "status", "updated_at"]
|
|
)
|
|
return refresh_tracking(shipment)
|
|
|
|
|
|
def sync_open_tracking() -> int:
|
|
"""Refresh labeled, not-yet-delivered shipments (dispatch_due)."""
|
|
done = 0
|
|
qs = (
|
|
Shipment.objects.filter(status=Shipment.Status.LABELED)
|
|
.exclude(tracking_number="")
|
|
.exclude(
|
|
tracking_status__in=[
|
|
Shipment.TrackingStatus.DELIVERED,
|
|
Shipment.TrackingStatus.CANCELLED,
|
|
]
|
|
)[:50]
|
|
)
|
|
for shipment in qs:
|
|
try:
|
|
refresh_tracking(shipment)
|
|
done += 1
|
|
except Exception:
|
|
logger.exception("tracking sync failed for %s", shipment.pk)
|
|
return done
|