generated from westfarn/web_django_template
Stand up Print Forge as a 3D-printed toy shop with color variants and printer photography.
Replaces the client_site template branding, adds shop/shipping, and points beta CI at master for easy deploy. Closes #1
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from shipping.models import Shipment
|
||||
|
||||
|
||||
@admin.register(Shipment)
|
||||
class ShipmentAdmin(admin.ModelAdmin):
|
||||
list_display = ("order", "status", "carrier", "tracking_number")
|
||||
list_filter = ("status", "carrier")
|
||||
search_fields = ("tracking_number", "order__number")
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ShippingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "shipping"
|
||||
verbose_name = "Shipping automation"
|
||||
|
||||
def ready(self):
|
||||
from shipping import hooks
|
||||
|
||||
hooks.register()
|
||||
@@ -0,0 +1,31 @@
|
||||
from core.registry import (
|
||||
register_dashboard_collector,
|
||||
register_feature,
|
||||
register_portal_nav,
|
||||
)
|
||||
|
||||
|
||||
def register() -> None:
|
||||
register_feature("shipping")
|
||||
register_portal_nav(
|
||||
section="shipping",
|
||||
label="Shipping",
|
||||
url_name="shipping:shipment_list",
|
||||
group="Retail",
|
||||
order=50,
|
||||
)
|
||||
register_dashboard_collector(_dashboard)
|
||||
|
||||
|
||||
def _dashboard(request) -> dict:
|
||||
from shipping.models import Shipment
|
||||
from shop.models import Order
|
||||
|
||||
labeled = Shipment.objects.filter(status=Shipment.Status.LABELED).values_list(
|
||||
"order_id", flat=True
|
||||
)
|
||||
return {
|
||||
"unshipped_orders": Order.objects.filter(status=Order.Status.PAID)
|
||||
.exclude(pk__in=labeled)
|
||||
.count()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 6.1 on 2026-09-06 11:17
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('shop', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Shipment',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('rated', 'Rated'), ('labeled', 'Labeled'), ('void', 'Void')], default='draft', max_length=16)),
|
||||
('carrier', models.CharField(blank=True, max_length=32)),
|
||||
('service', models.CharField(blank=True, max_length=64)),
|
||||
('tracking_number', models.CharField(blank=True, max_length=64)),
|
||||
('label_url', models.URLField(blank=True)),
|
||||
('rate_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||
('currency', models.CharField(default='usd', max_length=8)),
|
||||
('provider_shipment_id', models.CharField(blank=True, max_length=255)),
|
||||
('rates', models.JSONField(blank=True, default=list)),
|
||||
('weight_oz', models.PositiveIntegerField(default=16)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='shipments', to='shop.order')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
from django.db import models
|
||||
|
||||
from core.models import TimeStampedModel, UUIDPrimaryKeyModel
|
||||
from shop.models import Order
|
||||
|
||||
|
||||
class Shipment(UUIDPrimaryKeyModel, TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = "draft", "Draft"
|
||||
RATED = "rated", "Rated"
|
||||
LABELED = "labeled", "Labeled"
|
||||
VOID = "void", "Void"
|
||||
|
||||
order = models.ForeignKey(Order, on_delete=models.PROTECT, related_name="shipments")
|
||||
status = models.CharField(
|
||||
max_length=16, choices=Status.choices, default=Status.DRAFT
|
||||
)
|
||||
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)
|
||||
label_url = models.URLField(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)
|
||||
weight_oz = models.PositiveIntegerField(default=16)
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.order.number} · {self.tracking_number or self.status}"
|
||||
@@ -0,0 +1,184 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}{{ shipment.order.number }} · Shipping{% endblock %}
|
||||
{% 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.label_url %}<p><a href="{{ shipment.label_url }}">Download label</a></p>{% endif %}
|
||||
{% if shipment.status != 'labeled' %}
|
||||
<form method="post" action="{% url 'shipping:shipment_buy' shipment.pk %}">
|
||||
{% csrf_token %}
|
||||
{% for rate in shipment.rates %}
|
||||
<label>
|
||||
<input type="radio" name="rate_id" value="{{ rate.id }}" {% if forloop.first %}checked{% endif %}>
|
||||
{{ rate.carrier }} {{ rate.service }} — {{ rate.rate }} {{ rate.currency }}
|
||||
</label><br>
|
||||
{% empty %}
|
||||
<p>No rates yet.</p>
|
||||
{% endfor %}
|
||||
{% if shipment.rates %}
|
||||
<button class="btn btn-primary" type="submit">Buy label</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
<p><a href="{% url 'shipping:shipment_list' %}">← All shipments</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Shipping · Portal{% endblock %}
|
||||
{% block topbar_title %}Shipping{% endblock %}
|
||||
{% block portal_content %}
|
||||
<p><a class="btn btn-ghost" href="{% url 'shipping:pirate_ship_export' %}">Pirate Ship CSV</a></p>
|
||||
<h2>Unshipped paid orders</h2>
|
||||
<table class="table">
|
||||
<thead><tr><th>Order</th><th>Email</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for order in unshipped %}
|
||||
<tr>
|
||||
<td>{{ order.number }}</td>
|
||||
<td>{{ order.email }}</td>
|
||||
<td>
|
||||
<form method="post" action="{% url 'shipping:shipment_create' %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="order" value="{{ order.pk }}">
|
||||
<input name="weight_oz" type="number" min="1" value="16" style="width:5rem"> oz
|
||||
<button class="btn btn-primary" type="submit">Quote rates</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="empty-state">No paid orders waiting to ship.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>Shipments</h2>
|
||||
<table class="table">
|
||||
<thead><tr><th>Order</th><th>Status</th><th>Tracking</th></tr></thead>
|
||||
<tbody>
|
||||
{% for shipment in shipments %}
|
||||
<tr>
|
||||
<td><a href="{% url 'shipping:shipment_detail' shipment.pk %}">{{ shipment.order.number }}</a></td>
|
||||
<td>{{ shipment.get_status_display }}</td>
|
||||
<td>{{ shipment.tracking_number|default:"—" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="empty-state">No shipments yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,84 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from shipping.models import Shipment
|
||||
from shipping.services import buy_label, create_shipment_for_order, pirate_ship_csv, quote_rates
|
||||
from shop.models import Order
|
||||
|
||||
|
||||
class ShippingServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.order = Order.objects.create(
|
||||
number="ORD-20260101-001",
|
||||
email="buyer@example.com",
|
||||
customer_name="Pat Lee",
|
||||
status=Order.Status.PAID,
|
||||
amount=Decimal("18.00"),
|
||||
shipping_address={
|
||||
"line1": "123 Main",
|
||||
"city": "Aurora",
|
||||
"state": "IL",
|
||||
"zip": "60506",
|
||||
"country": "US",
|
||||
},
|
||||
)
|
||||
|
||||
def test_quote_and_buy_stub_label(self):
|
||||
shipment = create_shipment_for_order(self.order)
|
||||
rates = quote_rates(shipment)
|
||||
self.assertGreaterEqual(len(rates), 1)
|
||||
shipment.refresh_from_db()
|
||||
self.assertEqual(shipment.status, Shipment.Status.RATED)
|
||||
buy_label(shipment, rate_id=rates[0]["id"])
|
||||
shipment.refresh_from_db()
|
||||
self.assertEqual(shipment.status, Shipment.Status.LABELED)
|
||||
self.assertTrue(shipment.tracking_number)
|
||||
|
||||
def test_pirate_ship_csv_includes_unshipped(self):
|
||||
csv_body = pirate_ship_csv()
|
||||
self.assertIn("ORD-20260101-001", csv_body)
|
||||
self.assertIn("123 Main", csv_body)
|
||||
self.assertIn("buyer@example.com", csv_body)
|
||||
|
||||
def test_csv_omits_labeled_orders(self):
|
||||
shipment = create_shipment_for_order(self.order)
|
||||
quote_rates(shipment)
|
||||
buy_label(shipment)
|
||||
self.assertNotIn(self.order.number, pirate_ship_csv())
|
||||
|
||||
|
||||
class ShippingPortalTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user("shipper", password="test-pass-123")
|
||||
self.client = Client()
|
||||
self.client.login(username="shipper", password="test-pass-123")
|
||||
self.order = Order.objects.create(
|
||||
number="ORD-20260101-002",
|
||||
email="a@example.com",
|
||||
status=Order.Status.PAID,
|
||||
amount=Decimal("9.00"),
|
||||
shipping_address={"line1": "9 Oak", "city": "Town", "state": "IL", "zip": "60189"},
|
||||
)
|
||||
|
||||
def test_list_requires_login(self):
|
||||
self.assertEqual(Client().get(reverse("shipping:shipment_list")).status_code, 302)
|
||||
|
||||
def test_create_shipment_from_portal(self):
|
||||
response = self.client.post(
|
||||
reverse("shipping:shipment_create"),
|
||||
{"order": str(self.order.pk), "weight_oz": "8"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
shipment = Shipment.objects.get()
|
||||
self.assertEqual(shipment.weight_oz, 8)
|
||||
self.assertEqual(shipment.status, Shipment.Status.RATED)
|
||||
|
||||
def test_csv_export(self):
|
||||
response = self.client.get(reverse("shipping:pirate_ship_export"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["Content-Type"], "text/csv")
|
||||
self.assertIn(b"ORD-20260101-002", response.content)
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.urls import path
|
||||
|
||||
from shipping import views
|
||||
|
||||
app_name = "shipping"
|
||||
|
||||
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("<uuid:pk>/", views.shipment_detail, name="shipment_detail"),
|
||||
path("<uuid:pk>/buy/", views.shipment_buy, name="shipment_buy"),
|
||||
]
|
||||
@@ -0,0 +1,86 @@
|
||||
import logging
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.decorators.http import require_http_methods, require_POST
|
||||
|
||||
from shipping.models import Shipment
|
||||
from shipping.services import (
|
||||
ShippingError,
|
||||
buy_label,
|
||||
create_shipment_for_order,
|
||||
pirate_ship_csv,
|
||||
quote_rates,
|
||||
)
|
||||
from shop.models import Order
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@login_required
|
||||
def shipment_list(request):
|
||||
shipments = Shipment.objects.select_related("order")[:200]
|
||||
labeled = Shipment.objects.filter(status=Shipment.Status.LABELED).values_list(
|
||||
"order_id", flat=True
|
||||
)
|
||||
unshipped = Order.objects.filter(status=Order.Status.PAID).exclude(pk__in=labeled)
|
||||
return render(
|
||||
request,
|
||||
"shipping/list.html",
|
||||
{"shipments": shipments, "unshipped": unshipped},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def shipment_create(request):
|
||||
order = get_object_or_404(Order, pk=request.POST.get("order"))
|
||||
try:
|
||||
weight = int(request.POST.get("weight_oz") or "16")
|
||||
except ValueError:
|
||||
weight = 16
|
||||
try:
|
||||
shipment = create_shipment_for_order(order, weight_oz=weight)
|
||||
quote_rates(shipment)
|
||||
except ShippingError as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect("shipping:shipment_list")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("rate quote failed")
|
||||
messages.error(request, f"Could not quote rates: {exc}")
|
||||
return redirect("shipping:shipment_list")
|
||||
return redirect("shipping:shipment_detail", pk=shipment.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
def shipment_detail(request, pk):
|
||||
shipment = get_object_or_404(Shipment.objects.select_related("order"), pk=pk)
|
||||
return render(request, "shipping/detail.html", {"shipment": shipment})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def shipment_buy(request, pk):
|
||||
shipment = get_object_or_404(Shipment, pk=pk)
|
||||
rate_id = (request.POST.get("rate_id") or "").strip()
|
||||
try:
|
||||
buy_label(shipment, rate_id=rate_id)
|
||||
except ShippingError as exc:
|
||||
messages.error(request, str(exc))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("buy label failed")
|
||||
messages.error(request, f"Could not buy label: {exc}")
|
||||
else:
|
||||
messages.success(request, f"Label created for {shipment.order.number}.")
|
||||
return redirect("shipping:shipment_detail", pk=shipment.pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def pirate_ship_export(request):
|
||||
body = pirate_ship_csv()
|
||||
response = HttpResponse(body, content_type="text/csv")
|
||||
response["Content-Disposition"] = 'attachment; filename="pirate-ship-orders.csv"'
|
||||
return response
|
||||
Reference in New Issue
Block a user