Files
westfarn dd37a2a268
Deploy Beta / docker (push) Successful in 37s
Deploy Beta / deploy-beta (push) Successful in 2m21s
Deploy Beta / unit-tests (push) Successful in 39s
Customer accounts, order tracking, and purchase reviews (#8)
## 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
2026-09-07 04:53:41 -07: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