"""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 / 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