ShipStation tracking pull, status tracking, dashboard rework
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user