Files
print_forge/site/shipping/services.py
T
westfarn c9b81ceed7
CI / test (pull_request) Successful in 35s
Add customer accounts, shipment tracking, and purchase reviews.
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.
2026-09-07 06:37:52 -05:00

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