ShipStation tracking pull, status tracking, dashboard rework

This commit is contained in:
2026-08-26 11:28:42 -05:00
parent ffaaa0c8c8
commit 18be8588f6
16 changed files with 769 additions and 304 deletions
+69 -43
View File
@@ -2,15 +2,18 @@
Table view + model for displaying orders.
Using QAbstractTableModel instead of QTableWidget on purpose: it scales
to thousands of rows, and features like sorting and the company/source
filters below are straightforward extensions of this model rather than
rewrites.
to thousands of rows, and features like sorting and the company filter
below are straightforward extensions of this model rather than
rewrites. There's no "Source" filter - every row is a JIRA ticket;
ShipStation only enriches rows with tracking numbers, it doesn't add
its own.
"""
from __future__ import annotations
from typing import List, Any, Optional
from PyQt6.QtCore import Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel, pyqtSignal
from PyQt6.QtGui import QColor
from PyQt6.QtWidgets import (
QTableView,
QAbstractItemView,
@@ -23,31 +26,60 @@ from PyQt6.QtWidgets import (
QLabel,
)
from app import config
from app.models import Order
from app.status_rules import parse_status_list, status_in
CANCELLED_ROW_COLOR = QColor(255, 210, 210)
FULFILLED_ROW_COLOR = QColor(210, 240, 210)
COLUMNS = [
("company", "Company"),
("ticket_number", "Ticket #"),
("skus_display", "SKUs"),
("source", "Source"),
("external_id", "Source ID"),
("summary", "Summary"),
("status", "Status"),
("tracking_display", "Tracking #"),
("source_created_at", "Created"),
("imported_at", "Imported"),
("fulfilled", "Fulfilled"),
]
ALL_COMPANIES = "All Companies"
ALL_SOURCES = "All Sources"
def _format_tracking(tracking_numbers) -> str:
if not tracking_numbers:
return ""
return ", ".join(
f"{t.get('number')} ({'return' if t.get('is_return') else 'out'})"
for t in tracking_numbers
)
class OrdersTableModel(QAbstractTableModel):
def __init__(self, orders: List[Order] | None = None, parent=None):
super().__init__(parent)
self._orders: List[Order] = orders or []
self._cancelled_statuses: set[str] = set()
self._fulfilled_statuses: set[str] = set()
def set_orders(self, orders: List[Order]) -> None:
# Re-read status lists each refresh, in case Settings changed.
self._cancelled_statuses = parse_status_list(
config.get("CANCELLED_STATUSES", config.DEFAULT_CANCELLED_STATUSES)
)
self._fulfilled_statuses = parse_status_list(
",".join(
[
config.get(
"FULFILLED_STATUS_WITH_RETURN", config.DEFAULT_FULFILLED_WITH_RETURN
),
config.get(
"FULFILLED_STATUS_WITHOUT_RETURN",
config.DEFAULT_FULFILLED_WITHOUT_RETURN,
),
]
)
)
self.beginResetModel()
self._orders = orders
self.endResetModel()
@@ -66,42 +98,47 @@ class OrdersTableModel(QAbstractTableModel):
return str(section + 1)
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
if not index.isValid() or role != Qt.ItemDataRole.DisplayRole:
if not index.isValid():
return None
order = self._orders[index.row()]
if role == Qt.ItemDataRole.BackgroundRole:
if status_in(order.status, self._cancelled_statuses):
return CANCELLED_ROW_COLOR
if status_in(order.status, self._fulfilled_statuses):
return FULFILLED_ROW_COLOR
return None
if role != Qt.ItemDataRole.DisplayRole:
return None
field_name, _ = COLUMNS[index.column()]
if field_name == "skus_display":
return ", ".join(order.skus or [])
if field_name == "tracking_display":
return _format_tracking(order.tracking_numbers)
value = getattr(order, field_name)
if value is None:
return ""
if field_name == "fulfilled":
return "Yes" if value else "No"
return str(value)
return "" if value is None else str(value)
def order_at(self, row: int) -> Order:
return self._orders[row]
class OrdersFilterProxyModel(QSortFilterProxyModel):
"""Filters by company, source, and a free-text search across ticket/SKU/summary."""
"""Filters by company and a free-text search across ticket/SKU/summary/tracking."""
def __init__(self, parent=None):
super().__init__(parent)
self.company_filter: str = ALL_COMPANIES
self.source_filter: str = ALL_SOURCES
self.search_text: str = ""
def set_company_filter(self, company: str) -> None:
self.company_filter = company
self.invalidateFilter()
def set_source_filter(self, source: str) -> None:
self.source_filter = source
self.invalidateFilter()
def set_search_text(self, text: str) -> None:
self.search_text = text.strip().lower()
self.invalidateFilter()
@@ -112,16 +149,15 @@ class OrdersFilterProxyModel(QSortFilterProxyModel):
if self.company_filter != ALL_COMPANIES and order.company != self.company_filter:
return False
if self.source_filter != ALL_SOURCES and order.source != self.source_filter:
return False
if self.search_text:
haystack = " ".join(
[
order.ticket_number or "",
order.external_id or "",
order.summary or "",
order.status or "",
" ".join(order.skus or []),
_format_tracking(order.tracking_numbers),
]
).lower()
if self.search_text not in haystack:
@@ -152,15 +188,9 @@ class OrdersTableView(QWidget):
self.company_combo.currentTextChanged.connect(self._proxy_model.set_company_filter)
filter_bar.addWidget(self.company_combo)
filter_bar.addWidget(QLabel("Source:"))
self.source_combo = QComboBox()
self.source_combo.addItem(ALL_SOURCES)
self.source_combo.currentTextChanged.connect(self._proxy_model.set_source_filter)
filter_bar.addWidget(self.source_combo)
filter_bar.addWidget(QLabel("Search:"))
self.search_box = QLineEdit()
self.search_box.setPlaceholderText("Ticket #, SKU, or summary...")
self.search_box.setPlaceholderText("Ticket #, SKU, status, or tracking #...")
self.search_box.textChanged.connect(self._proxy_model.set_search_text)
filter_bar.addWidget(self.search_box, stretch=1)
@@ -187,19 +217,15 @@ class OrdersTableView(QWidget):
self._refresh_filter_options(orders)
def _refresh_filter_options(self, orders: List[Order]) -> None:
for combo, attr, all_label in (
(self.company_combo, "company", ALL_COMPANIES),
(self.source_combo, "source", ALL_SOURCES),
):
current = combo.currentText()
values = sorted({getattr(o, attr) for o in orders if getattr(o, attr)})
combo.blockSignals(True)
combo.clear()
combo.addItem(all_label)
combo.addItems(values)
restore_index = combo.findText(current)
combo.setCurrentIndex(restore_index if restore_index >= 0 else 0)
combo.blockSignals(False)
current = self.company_combo.currentText()
values = sorted({o.company for o in orders if o.company})
self.company_combo.blockSignals(True)
self.company_combo.clear()
self.company_combo.addItem(ALL_COMPANIES)
self.company_combo.addItems(values)
restore_index = self.company_combo.findText(current)
self.company_combo.setCurrentIndex(restore_index if restore_index >= 0 else 0)
self.company_combo.blockSignals(False)
def selected_order(self) -> Optional[Order]:
indexes = self.table.selectionModel().selectedRows()