FKN SHITSTATION AND THEIR GODDAMN SANDBOX BS - Sandbox will be it's own separate entity
This commit is contained in:
+19
-10
@@ -319,16 +319,25 @@ def is_shipstation_test_mode() -> bool:
|
||||
def get_shipstation_setting(key: str) -> str:
|
||||
"""
|
||||
Test-mode-aware lookup for ShipStation-related settings (API key,
|
||||
store/warehouse/carrier IDs). When SHIPSTATION_TEST_MODE is on, looks
|
||||
for a TEST_{key} override first, falling back to the normal {key}
|
||||
value if the override is blank - so test mode works immediately if
|
||||
your test/sandbox account mirrors production's store/warehouse/
|
||||
carrier IDs, while still letting you override specific values if your
|
||||
test account uses different ones. When test mode is off, this is
|
||||
identical to config.get(key).
|
||||
store/warehouse/carrier IDs, service codes). When SHIPSTATION_TEST_MODE
|
||||
is on, ONLY the TEST_{key} value is used - no fallback to the
|
||||
production value. When test mode is off, this is identical to
|
||||
config.get(key).
|
||||
|
||||
This used to fall back to the production value when the TEST_ override
|
||||
was blank, on the theory the sandbox might share IDs with production.
|
||||
Confirmed against ShipStation's own docs that it never does - "Sandbox
|
||||
data is isolated from production data... anything you create in the
|
||||
sandbox will not be accessible in production, or vice-versa." A
|
||||
fallback to a production store/warehouse/carrier ID under a sandbox API
|
||||
key isn't a convenience, it's a guaranteed rejection - and it's exactly
|
||||
what produced three separate confusing "not found"/"invalid" errors in
|
||||
a row (carrier, then warehouse, then store) before this was caught.
|
||||
Every caller of this function already raises a clear, specific
|
||||
"X is not configured" error when it gets back an empty string, so
|
||||
isolating test mode fully just turns those three confusing
|
||||
ShipStation-side rejections into one obvious message instead.
|
||||
"""
|
||||
if is_shipstation_test_mode():
|
||||
test_value = get(f"TEST_{key}", "")
|
||||
if test_value:
|
||||
return test_value
|
||||
return get(f"TEST_{key}", "")
|
||||
return get(key, "")
|
||||
|
||||
@@ -81,18 +81,44 @@ def list_carriers() -> List[dict]:
|
||||
|
||||
def list_stores() -> List[dict]:
|
||||
"""
|
||||
Same idea as list_carriers() but for GET /v2/stores - the test/sandbox
|
||||
account almost certainly has different store IDs than production too
|
||||
(confirmed the same is true for carriers), so this is worth checking
|
||||
before it becomes the next "not found" error rather than after.
|
||||
DEAD END, kept only so nobody re-attempts this the hard way: ShipStation's
|
||||
V2 API has NO stores/marketplaces listing endpoint. GET /v2/stores returns
|
||||
a plain 404 ("No route matched with those values") - confirmed against
|
||||
ShipStation's own V2 OpenAPI reference, which lists every real section
|
||||
(Carriers, Warehouses, Connections, etc.) and where store_id only ever
|
||||
appears as an INPUT field on label/shipment requests (example value
|
||||
"se-12345"), never as its own resource with a list/get endpoint. This
|
||||
matches ShipStation's help docs too, which say the only ways to get a
|
||||
store_id are (1) ShipStation support looks it up for you, or (2) the
|
||||
legacy V1 API's List Stores call - different auth (key+secret Basic
|
||||
Auth), not the single V2 API-Key header this app uses everywhere else.
|
||||
|
||||
Bottom line: there is no way to discover a store_id from inside this
|
||||
app. Log into the ShipStation account's own UI (Settings > Store Setup
|
||||
/ Selling Channels) to find or create a manual store and read its ID
|
||||
from there - for test mode specifically, that means logging into the
|
||||
TEST/sandbox account, not production.
|
||||
"""
|
||||
raise ShipStationSendError(
|
||||
"ShipStation's V2 API has no endpoint to list stores (confirmed - GET /v2/stores "
|
||||
"doesn't exist, it 404s). Log into the ShipStation account's own UI under "
|
||||
"Settings > Store Setup to find the store_id, then enter it in Settings - for "
|
||||
"test mode, log into the TEST/sandbox account and set the TEST_ override."
|
||||
)
|
||||
|
||||
|
||||
def list_warehouses() -> List[dict]:
|
||||
"""Same idea as list_carriers()/list_stores() but for GET /v2/warehouses -
|
||||
fixes the exact next error in the sequence (warehouse_id not found),
|
||||
same root cause as the carrier_id one: the test/sandbox account is a
|
||||
completely separate ShipStation environment with its own IDs."""
|
||||
api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY")
|
||||
if not api_key:
|
||||
raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.")
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{API_BASE}/stores",
|
||||
f"{API_BASE}/warehouses",
|
||||
headers={"API-Key": api_key, "Accept": "application/json"},
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
@@ -111,8 +137,86 @@ def list_stores() -> List[dict]:
|
||||
except ValueError as exc:
|
||||
raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc
|
||||
|
||||
# Some ShipStation accounts return a bare list, others wrap it - handle both.
|
||||
return data if isinstance(data, list) else data.get("stores", [])
|
||||
return data if isinstance(data, list) else data.get("warehouses", [])
|
||||
|
||||
|
||||
def create_warehouse(name: str, origin_address: dict) -> dict:
|
||||
"""POST /v2/warehouses - creates a new warehouse for whichever API key
|
||||
is currently active. Needed because a sandbox/test ShipStation account
|
||||
starts with zero warehouses (confirmed against ShipStation's own docs):
|
||||
list_warehouses() correctly returning an empty list in test mode wasn't
|
||||
a bug, there was just nothing there yet to list. Returns the created
|
||||
warehouse's JSON (includes warehouse_id - the value that goes into
|
||||
TEST_SHIPSTATION_SIGNIFY_WAREHOUSE_ID / TEST_SHIPSTATION_OAKSTREET_WAREHOUSE_ID)."""
|
||||
api_key = config.get_shipstation_setting("SHIPSTATION_API_KEY")
|
||||
if not api_key:
|
||||
raise ShipStationSendError("ShipStation API Key is not set. Add it in Settings.")
|
||||
|
||||
payload = {"name": name, "origin_address": origin_address}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{API_BASE}/warehouses",
|
||||
json=payload,
|
||||
headers={"API-Key": api_key, "Accept": "application/json"},
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise ShipStationSendError(f"Could not reach ShipStation: {exc}") from exc
|
||||
|
||||
if response.status_code == 401:
|
||||
raise ShipStationSendError("ShipStation rejected the API key (401). Check it in Settings.")
|
||||
if not response.ok:
|
||||
raise ShipStationSendError(
|
||||
f"ShipStation returned an error ({response.status_code}): {response.text[:400]}"
|
||||
)
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise ShipStationSendError("ShipStation returned a response that wasn't valid JSON.") from exc
|
||||
|
||||
|
||||
def _origin_address_for_company(company: str) -> dict:
|
||||
"""Same physical address as _return_address_for_order's ship-from/
|
||||
ship-to address (SIGNIFY_RETURN_*/OAKSTREET_RETURN_* settings) - a
|
||||
warehouse's origin_address is just that address registered with
|
||||
ShipStation. address_residential_indicator is required by
|
||||
POST /v2/warehouses; this is always a business address."""
|
||||
settings = config.load_settings()
|
||||
prefix = "SIGNIFY_RETURN_" if company == "Signify Health" else "OAKSTREET_RETURN_"
|
||||
return {
|
||||
"name": settings.get(f"{prefix}NAME", ""),
|
||||
"phone": settings.get(f"{prefix}PHONE", ""),
|
||||
"company_name": company,
|
||||
"address_line1": settings.get(f"{prefix}ADDRESS1", ""),
|
||||
"address_line2": settings.get(f"{prefix}ADDRESS2", "") or None,
|
||||
"city_locality": settings.get(f"{prefix}CITY", ""),
|
||||
"state_province": settings.get(f"{prefix}STATE", ""),
|
||||
"postal_code": settings.get(f"{prefix}ZIP", ""),
|
||||
"country_code": "US",
|
||||
"address_residential_indicator": "no",
|
||||
}
|
||||
|
||||
|
||||
def create_test_warehouse_for_company(company: str) -> dict:
|
||||
"""Creates a warehouse for whichever API key is currently active
|
||||
(callers should confirm test mode is on first - this isn't something
|
||||
you want to accidentally run against production) using the company's
|
||||
own return-address settings as the origin_address. Returns the created
|
||||
warehouse's JSON."""
|
||||
origin_address = _origin_address_for_company(company)
|
||||
required_fields = (
|
||||
"name", "phone", "address_line1", "city_locality", "state_province", "postal_code",
|
||||
)
|
||||
missing = [field for field in required_fields if not origin_address.get(field)]
|
||||
if missing:
|
||||
prefix = "SIGNIFY_RETURN_" if company == "Signify Health" else "OAKSTREET_RETURN_"
|
||||
raise ShipStationSendError(
|
||||
f"{prefix}* address settings for {company} are incomplete (missing: "
|
||||
f"{', '.join(missing)}). Fill those in under Settings > Return Labels first."
|
||||
)
|
||||
return create_warehouse(f"{company} (Test)", origin_address)
|
||||
|
||||
# Column order confirmed against the real ShipStation upload template
|
||||
# (OAK.csv) - "COPY ME ALREADY" is a spreadsheet-only helper column and
|
||||
|
||||
+84
-3
@@ -159,14 +159,31 @@ class MainWindow(QMainWindow):
|
||||
list_carriers_action.triggered.connect(self._on_list_carriers_clicked)
|
||||
data_menu.addAction(list_carriers_action)
|
||||
|
||||
list_stores_action = QAction("List ShipStation Stores...", self)
|
||||
list_stores_action = QAction("About ShipStation Store IDs...", self)
|
||||
list_stores_action.setStatusTip(
|
||||
"Shows the store IDs actually valid for whichever API key is currently active "
|
||||
"(test or production)"
|
||||
"ShipStation's V2 API has no way to list stores - explains where to find a "
|
||||
"store_id instead"
|
||||
)
|
||||
list_stores_action.triggered.connect(self._on_list_stores_clicked)
|
||||
data_menu.addAction(list_stores_action)
|
||||
|
||||
list_warehouses_action = QAction("List ShipStation Warehouses...", self)
|
||||
list_warehouses_action.setStatusTip(
|
||||
"Shows the warehouse IDs actually valid for whichever API key is currently active "
|
||||
"(test or production)"
|
||||
)
|
||||
list_warehouses_action.triggered.connect(self._on_list_warehouses_clicked)
|
||||
data_menu.addAction(list_warehouses_action)
|
||||
|
||||
create_test_warehouses_action = QAction("Create Test Warehouse(s)...", self)
|
||||
create_test_warehouses_action.setStatusTip(
|
||||
"Test Mode only - the sandbox account starts with zero warehouses, so this "
|
||||
"creates one per company (using the same return-address settings) and saves "
|
||||
"the resulting IDs into TEST_SHIPSTATION_*_WAREHOUSE_ID"
|
||||
)
|
||||
create_test_warehouses_action.triggered.connect(self._on_create_test_warehouses_clicked)
|
||||
data_menu.addAction(create_test_warehouses_action)
|
||||
|
||||
orders_menu = menu_bar.addMenu("&Orders")
|
||||
|
||||
self._pack_ticket_action = QAction("Pack Ticket", self)
|
||||
@@ -305,6 +322,70 @@ class MainWindow(QMainWindow):
|
||||
)
|
||||
QMessageBox.information(self, f"ShipStation Stores ({mode})", "\n".join(lines))
|
||||
|
||||
def _on_list_warehouses_clicked(self) -> None:
|
||||
from app.services.shipstation_send import list_warehouses, ShipStationSendError
|
||||
|
||||
mode = "TEST" if config.is_shipstation_test_mode() else "PRODUCTION"
|
||||
try:
|
||||
warehouses = list_warehouses()
|
||||
except ShipStationSendError as exc:
|
||||
QMessageBox.critical(self, "Could not list warehouses", str(exc))
|
||||
return
|
||||
|
||||
if not warehouses:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
f"ShipStation Warehouses ({mode})",
|
||||
f"No warehouses are set up in this {mode.lower()} ShipStation account.",
|
||||
)
|
||||
return
|
||||
|
||||
lines = [f"Warehouses visible to your current {mode} API key:", ""]
|
||||
for warehouse in warehouses:
|
||||
lines.append(
|
||||
f" warehouse_id: {warehouse.get('warehouse_id', '?')} "
|
||||
f"{warehouse.get('name', '?')}"
|
||||
)
|
||||
QMessageBox.information(self, f"ShipStation Warehouses ({mode})", "\n".join(lines))
|
||||
|
||||
def _on_create_test_warehouses_clicked(self) -> None:
|
||||
if not config.is_shipstation_test_mode():
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Test Mode is off",
|
||||
"Turn on ShipStation Test Mode first (Data menu). This creates a "
|
||||
"warehouse in whichever ShipStation account your API key currently "
|
||||
"points to, and it's meant for the sandbox account only.",
|
||||
)
|
||||
return
|
||||
|
||||
from app.services.shipstation_send import (
|
||||
create_test_warehouse_for_company,
|
||||
ShipStationSendError,
|
||||
)
|
||||
|
||||
companies = [
|
||||
("Signify Health", "TEST_SHIPSTATION_SIGNIFY_WAREHOUSE_ID"),
|
||||
("Oak Street Health", "TEST_SHIPSTATION_OAKSTREET_WAREHOUSE_ID"),
|
||||
]
|
||||
|
||||
lines = []
|
||||
for company, setting_key in companies:
|
||||
existing = config.load_settings().get(setting_key)
|
||||
if existing:
|
||||
lines.append(f"{company}: already set to {existing} - skipped")
|
||||
continue
|
||||
try:
|
||||
warehouse = create_test_warehouse_for_company(company)
|
||||
except ShipStationSendError as exc:
|
||||
lines.append(f"{company}: FAILED - {exc}")
|
||||
continue
|
||||
warehouse_id = warehouse.get("warehouse_id", "")
|
||||
config.save_settings({setting_key: warehouse_id})
|
||||
lines.append(f"{company}: created {warehouse_id} and saved it to {setting_key}")
|
||||
|
||||
QMessageBox.information(self, "Create Test Warehouse(s)", "\n".join(lines))
|
||||
|
||||
def _on_order_double_clicked(self, order) -> None:
|
||||
dialog = OrderDetailDialog(order, self)
|
||||
dialog.exec()
|
||||
|
||||
Reference in New Issue
Block a user