generated from westfarn/web_django_template
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
This commit was merged in pull request #8.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from core.registry import (
|
||||
register_dashboard_collector,
|
||||
register_dispatcher,
|
||||
register_feature,
|
||||
register_portal_nav,
|
||||
)
|
||||
@@ -15,6 +16,7 @@ def register() -> None:
|
||||
order=50,
|
||||
)
|
||||
register_dashboard_collector(_dashboard)
|
||||
register_dispatcher(_sync_tracking)
|
||||
|
||||
|
||||
def _dashboard(request) -> dict:
|
||||
@@ -29,3 +31,10 @@ def _dashboard(request) -> dict:
|
||||
.exclude(pk__in=labeled)
|
||||
.count()
|
||||
}
|
||||
|
||||
|
||||
def _sync_tracking() -> int:
|
||||
from shipping.services import sync_open_tracking
|
||||
|
||||
return sync_open_tracking()
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 6.1
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("shipping", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="shipment",
|
||||
name="last_tracked_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="shipment",
|
||||
name="tracker_id",
|
||||
field=models.CharField(blank=True, max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="shipment",
|
||||
name="tracking_events",
|
||||
field=models.JSONField(blank=True, default=list),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="shipment",
|
||||
name="tracking_status",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("unknown", "Unknown"),
|
||||
("pre_transit", "Pre-transit"),
|
||||
("in_transit", "In transit"),
|
||||
("out_for_delivery", "Out for delivery"),
|
||||
("delivered", "Delivered"),
|
||||
("available_for_pickup", "Available for pickup"),
|
||||
("return_to_sender", "Return to sender"),
|
||||
("failure", "Exception"),
|
||||
("cancelled", "Cancelled"),
|
||||
("error", "Error"),
|
||||
],
|
||||
default="unknown",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="shipment",
|
||||
name="tracking_url",
|
||||
field=models.URLField(blank=True),
|
||||
),
|
||||
]
|
||||
+25
-1
@@ -11,6 +11,18 @@ class Shipment(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
LABELED = "labeled", "Labeled"
|
||||
VOID = "void", "Void"
|
||||
|
||||
class TrackingStatus(models.TextChoices):
|
||||
UNKNOWN = "unknown", "Unknown"
|
||||
PRE_TRANSIT = "pre_transit", "Pre-transit"
|
||||
IN_TRANSIT = "in_transit", "In transit"
|
||||
OUT_FOR_DELIVERY = "out_for_delivery", "Out for delivery"
|
||||
DELIVERED = "delivered", "Delivered"
|
||||
AVAILABLE_FOR_PICKUP = "available_for_pickup", "Available for pickup"
|
||||
RETURN_TO_SENDER = "return_to_sender", "Return to sender"
|
||||
FAILURE = "failure", "Exception"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
ERROR = "error", "Error"
|
||||
|
||||
order = models.ForeignKey(Order, on_delete=models.PROTECT, related_name="shipments")
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.DRAFT
|
||||
@@ -18,8 +30,20 @@ class Shipment(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
carrier = models.CharField(max_length=32, blank=True)
|
||||
service = models.CharField(max_length=64, blank=True)
|
||||
tracking_number = models.CharField(max_length=64, blank=True)
|
||||
tracking_status = models.CharField(
|
||||
max_length=32,
|
||||
choices=TrackingStatus.choices,
|
||||
default=TrackingStatus.UNKNOWN,
|
||||
blank=True,
|
||||
)
|
||||
tracking_url = models.URLField(blank=True)
|
||||
tracker_id = models.CharField(max_length=255, blank=True)
|
||||
tracking_events = models.JSONField(default=list, blank=True)
|
||||
last_tracked_at = models.DateTimeField(null=True, blank=True)
|
||||
label_url = models.URLField(blank=True)
|
||||
rate_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||
rate_amount = models.DecimalField(
|
||||
max_digits=10, decimal_places=2, null=True, blank=True
|
||||
)
|
||||
currency = models.CharField(max_length=8, default="usd")
|
||||
provider_shipment_id = models.CharField(max_length=255, blank=True)
|
||||
rates = models.JSONField(default=list, blank=True)
|
||||
|
||||
@@ -6,9 +6,11 @@ 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
|
||||
@@ -135,6 +137,10 @@ def buy_label(shipment: Shipment, *, rate_id: str = "") -> Shipment:
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
try:
|
||||
refresh_tracking(shipment)
|
||||
except Exception:
|
||||
logger.exception("tracking refresh failed after label buy for %s", shipment.pk)
|
||||
return shipment
|
||||
|
||||
|
||||
@@ -182,3 +188,169 @@ def pirate_ship_csv(orders=None) -> str:
|
||||
]
|
||||
)
|
||||
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
|
||||
|
||||
@@ -3,8 +3,52 @@
|
||||
{% block topbar_title %}{{ shipment.order.number }}{% endblock %}
|
||||
{% block portal_content %}
|
||||
<p>{{ shipment.order.email }} · {{ shipment.get_status_display }}</p>
|
||||
{% if shipment.tracking_number %}<p>Tracking: {{ shipment.tracking_number }}</p>{% endif %}
|
||||
{% if shipment.tracking_number %}
|
||||
<p>
|
||||
Tracking: {{ shipment.tracking_number }}
|
||||
{% if shipment.tracking_status %} · {{ shipment.get_tracking_status_display }}{% endif %}
|
||||
</p>
|
||||
{% if shipment.tracking_url %}<p><a href="{{ shipment.tracking_url }}" target="_blank" rel="noopener">Track package</a></p>{% endif %}
|
||||
<form method="post" action="{% url 'shipping:shipment_refresh_tracking' shipment.pk %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-ghost btn-sm" type="submit">Refresh tracking</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if shipment.label_url %}<p><a href="{{ shipment.label_url }}">Download label</a></p>{% endif %}
|
||||
|
||||
<h3>Add tracking</h3>
|
||||
<p class="hint-block">Paste a number from Pirate Ship, Shippo, or the carrier. EasyPost looks up scan events when an API key is set.</p>
|
||||
<form method="post" action="{% url 'shipping:shipment_attach_tracking' shipment.pk %}">
|
||||
{% csrf_token %}
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label for="id_tracking_number">Tracking number</label>
|
||||
<input id="id_tracking_number" name="tracking_number" value="{{ shipment.tracking_number }}" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="id_carrier">Carrier</label>
|
||||
<input id="id_carrier" name="carrier" value="{{ shipment.carrier }}" placeholder="USPS, UPS, FedEx">
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Save tracking</button>
|
||||
</form>
|
||||
|
||||
{% if shipment.tracking_events %}
|
||||
<h3>Scan history</h3>
|
||||
<table class="table">
|
||||
<thead><tr><th>When</th><th>Status</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
{% for event in shipment.tracking_events %}
|
||||
<tr>
|
||||
<td>{{ event.datetime }}</td>
|
||||
<td>{{ event.status }}</td>
|
||||
<td>{{ event.message }}{% if event.location %} · {{ event.location }}{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if shipment.status != 'labeled' %}
|
||||
<form method="post" action="{% url 'shipping:shipment_buy' shipment.pk %}">
|
||||
{% csrf_token %}
|
||||
|
||||
+54
-1
@@ -1,4 +1,5 @@
|
||||
from decimal import Decimal
|
||||
import json
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase
|
||||
@@ -49,11 +50,63 @@ class ShippingServiceTests(TestCase):
|
||||
buy_label(shipment)
|
||||
self.assertNotIn(self.order.number, pirate_ship_csv())
|
||||
|
||||
def test_stub_buy_sets_pre_transit_tracking(self):
|
||||
shipment = create_shipment_for_order(self.order)
|
||||
quote_rates(shipment)
|
||||
buy_label(shipment)
|
||||
shipment.refresh_from_db()
|
||||
self.assertTrue(shipment.tracking_number)
|
||||
self.assertEqual(shipment.tracking_status, Shipment.TrackingStatus.PRE_TRANSIT)
|
||||
self.assertTrue(shipment.tracking_url)
|
||||
|
||||
def test_attach_tracking_from_pirate_ship(self):
|
||||
from shipping.services import attach_tracking
|
||||
|
||||
shipment = create_shipment_for_order(self.order)
|
||||
attach_tracking(shipment, tracking_number="9400111899223197428490", carrier="USPS")
|
||||
shipment.refresh_from_db()
|
||||
self.assertEqual(shipment.status, Shipment.Status.LABELED)
|
||||
self.assertEqual(shipment.tracking_status, Shipment.TrackingStatus.PRE_TRANSIT)
|
||||
self.assertIn("usps.com", shipment.tracking_url.lower())
|
||||
|
||||
def test_easypost_webhook_updates_status(self):
|
||||
shipment = create_shipment_for_order(self.order)
|
||||
shipment.tracking_number = "EZ1000000001"
|
||||
shipment.status = Shipment.Status.LABELED
|
||||
shipment.save()
|
||||
payload = {
|
||||
"description": "tracker.updated",
|
||||
"result": {
|
||||
"id": "trk_test",
|
||||
"tracking_code": "EZ1000000001",
|
||||
"status": "in_transit",
|
||||
"public_url": "https://track.easypost.com/djE0",
|
||||
"tracking_details": [
|
||||
{
|
||||
"status": "in_transit",
|
||||
"message": "Departed facility",
|
||||
"datetime": "2026-09-07T12:00:00Z",
|
||||
"tracking_location": {"city": "Chicago", "state": "IL"},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
response = Client().post(
|
||||
reverse("shipping:easypost_webhook"),
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
shipment.refresh_from_db()
|
||||
self.assertEqual(shipment.tracking_status, Shipment.TrackingStatus.IN_TRANSIT)
|
||||
self.assertEqual(shipment.tracker_id, "trk_test")
|
||||
self.assertEqual(shipment.tracking_events[0]["location"], "Chicago IL")
|
||||
|
||||
|
||||
class ShippingPortalTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user("shipper", password="test-pass-123")
|
||||
self.user = User.objects.create_user("shipper", password="test-pass-123", is_staff=True)
|
||||
self.client = Client()
|
||||
self.client.login(username="shipper", password="test-pass-123")
|
||||
self.order = Order.objects.create(
|
||||
|
||||
@@ -8,6 +8,17 @@ urlpatterns = [
|
||||
path("", views.shipment_list, name="shipment_list"),
|
||||
path("new/", views.shipment_create, name="shipment_create"),
|
||||
path("export/pirate-ship.csv", views.pirate_ship_export, name="pirate_ship_export"),
|
||||
path("webhooks/easypost/", views.easypost_webhook, name="easypost_webhook"),
|
||||
path("<uuid:pk>/", views.shipment_detail, name="shipment_detail"),
|
||||
path("<uuid:pk>/buy/", views.shipment_buy, name="shipment_buy"),
|
||||
path(
|
||||
"<uuid:pk>/tracking/",
|
||||
views.shipment_attach_tracking,
|
||||
name="shipment_attach_tracking",
|
||||
),
|
||||
path(
|
||||
"<uuid:pk>/tracking/refresh/",
|
||||
views.shipment_refresh_tracking,
|
||||
name="shipment_refresh_tracking",
|
||||
),
|
||||
]
|
||||
|
||||
+74
-1
@@ -1,18 +1,24 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods, require_POST
|
||||
|
||||
from shipping.models import Shipment
|
||||
from shipping.services import (
|
||||
ShippingError,
|
||||
apply_tracker_payload,
|
||||
attach_tracking,
|
||||
buy_label,
|
||||
create_shipment_for_order,
|
||||
pirate_ship_csv,
|
||||
quote_rates,
|
||||
refresh_tracking,
|
||||
)
|
||||
from shop.models import Order
|
||||
|
||||
@@ -77,6 +83,40 @@ def shipment_buy(request, pk):
|
||||
return redirect("shipping:shipment_detail", pk=shipment.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def shipment_attach_tracking(request, pk):
|
||||
shipment = get_object_or_404(Shipment, pk=pk)
|
||||
try:
|
||||
attach_tracking(
|
||||
shipment,
|
||||
tracking_number=request.POST.get("tracking_number") or "",
|
||||
carrier=request.POST.get("carrier") or "",
|
||||
)
|
||||
except ShippingError as exc:
|
||||
messages.error(request, str(exc))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("attach tracking failed")
|
||||
messages.error(request, f"Could not save tracking: {exc}")
|
||||
else:
|
||||
messages.success(request, "Tracking saved.")
|
||||
return redirect("shipping:shipment_detail", pk=shipment.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def shipment_refresh_tracking(request, pk):
|
||||
shipment = get_object_or_404(Shipment, pk=pk)
|
||||
try:
|
||||
refresh_tracking(shipment)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("refresh tracking failed")
|
||||
messages.error(request, f"Could not refresh tracking: {exc}")
|
||||
else:
|
||||
messages.success(request, "Tracking updated.")
|
||||
return redirect("shipping:shipment_detail", pk=shipment.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def pirate_ship_export(request):
|
||||
@@ -84,3 +124,36 @@ def pirate_ship_export(request):
|
||||
response = HttpResponse(body, content_type="text/csv")
|
||||
response["Content-Disposition"] = 'attachment; filename="pirate-ship-orders.csv"'
|
||||
return response
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(["POST"])
|
||||
def easypost_webhook(request):
|
||||
secret = (getattr(settings, "EASYPOST_WEBHOOK_SECRET", "") or "").strip()
|
||||
if secret:
|
||||
got = (
|
||||
request.headers.get("X-Webhook-Secret")
|
||||
or request.GET.get("token")
|
||||
or ""
|
||||
).strip()
|
||||
if got != secret:
|
||||
return HttpResponseBadRequest("invalid secret")
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return HttpResponseBadRequest("invalid json")
|
||||
result = payload.get("result") or payload
|
||||
if not isinstance(result, dict):
|
||||
return HttpResponse("ok")
|
||||
tracker_id = (result.get("id") or "").strip()
|
||||
tracking = (result.get("tracking_code") or result.get("tracking_number") or "").strip()
|
||||
shipment = None
|
||||
if tracker_id:
|
||||
shipment = Shipment.objects.filter(tracker_id=tracker_id).first()
|
||||
if shipment is None and tracking:
|
||||
shipment = Shipment.objects.filter(tracking_number=tracking).first()
|
||||
if shipment is None:
|
||||
return HttpResponse("ok")
|
||||
apply_tracker_payload(shipment, result)
|
||||
logger.info("easypost tracker updated shipment %s", shipment.pk)
|
||||
return HttpResponse("ok")
|
||||
|
||||
Reference in New Issue
Block a user