generated from westfarn/web_django_template
## Summary - Slim the public contact form to email, interest, and message. Name, phone, and address live on the customer profile instead. - Customers can register, sign in, save shipping details, and view order history. Logged-in checkout creates a Stripe Customer and saves cards on Stripe (`setup_future_usage`); we only store `stripe_customer_id`. - Shipment tracking: EasyPost tracker lookup + webhook, plus paste-in numbers from Pirate Ship/Shippo. Customers see carrier status on their orders; `dispatch_due` refreshes open shipments. - Product reviews (1–5) only after a paid/fulfilled purchase of that product. Fixes #7 ## Test plan - [ ] Contact form submits with only email + message; extra name/phone/address fields are ignored - [ ] Register, sign in, save profile (name/phone/shipping) - [ ] Guest checkout still works; after signup, prior orders with that email show in history - [ ] Logged-in checkout prefills shipping and does not collect card data locally - [ ] Portal: buy label or paste a Pirate Ship tracking number, confirm status/events; customer order page shows tracking - [ ] Product page: non-buyers cannot review; buyers can leave one 1–5 star review - [ ] Non-staff users hitting `/portal/` redirect to `/account/` Reviewed-on: #8
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
|