Template
## Summary - Closes #5 - Optional `shop`, `pos_sync`, `events`, and `shipping` apps gated by `FEATURE_*` flags - Deps match the catalog: shop/events need email + Stripe; POS/shipping need shop - Portal inventory, POS webhooks, capacity tickets, EasyPost/Pirate Ship shipping ## Test plan - [ ] `manage.py test` (152 passed locally) - [ ] Shop cart + paid order decrements stocked inventory - [ ] POS inbound webhook decrements SKU; paid order queues outbound reserve - [ ] Event capacity blocks overbook; paid order emails ticket codes - [ ] Shipping stub label + Pirate Ship CSV of unshipped paid orders - [ ] `validate-env.sh` rejects shop without email/payments, POS/shipping without shop Reviewed-on: #6
185 lines
5.8 KiB
Python
185 lines
5.8 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
|
|
|
|
import requests
|
|
from django.conf import settings
|
|
|
|
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",
|
|
]
|
|
)
|
|
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()
|