Initial commit

This commit is contained in:
2026-08-26 09:15:35 -05:00
commit ffaaa0c8c8
23 changed files with 1775 additions and 0 deletions
View File
+191
View File
@@ -0,0 +1,191 @@
"""
Main window.
Deliberately thin: it wires the toolbar, tabs, and dialogs together,
and delegates real work to app.services (fetching), app.workers
(saving/loading/stats), and app.services.odoo_export (file export).
JIRA and ShipStation are pulled independently (separate buttons,
separate workers) per how the business actually operates - they are
correlated afterwards for the dashboard via ticket_number, not forced
into one pipeline.
"""
from __future__ import annotations
from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import (
QMainWindow,
QWidget,
QVBoxLayout,
QToolBar,
QStatusBar,
QMessageBox,
QLabel,
QTabWidget,
QFileDialog,
)
from app.services import SERVICE_REGISTRY
from app.services.odoo_export import export_orders_to_csv
from app.ui.settings_dialog import SettingsDialog
from app.ui.widgets.orders_table import OrdersTableView
from app.ui.widgets.dashboard import DashboardWidget
from app.ui.widgets.order_detail_dialog import OrderDetailDialog
from app.workers import FetchOrdersWorker, load_all_orders, get_dashboard_stats
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Order Manager")
self.resize(1150, 700)
self._workers: dict[str, FetchOrdersWorker] = {}
self._import_actions: dict[str, QAction] = {}
self._build_ui()
self._refresh_everything()
# -- UI construction -------------------------------------------------
def _build_ui(self) -> None:
central = QWidget()
layout = QVBoxLayout(central)
self.tabs = QTabWidget()
self.dashboard = DashboardWidget()
self.orders_table = OrdersTableView()
self.orders_table.order_double_clicked.connect(self._on_order_double_clicked)
self.tabs.addTab(self.dashboard, "Dashboard")
self.tabs.addTab(self.orders_table, "All Orders")
layout.addWidget(self.tabs)
self.setCentralWidget(central)
toolbar = QToolBar("Main")
toolbar.setMovable(False)
self.addToolBar(toolbar)
jira_action = QAction("Import from JIRA", self)
jira_action.triggered.connect(lambda: self._on_import_clicked("jira"))
toolbar.addAction(jira_action)
self._import_actions["jira"] = jira_action
shipstation_action = QAction("Import from ShipStation", self)
shipstation_action.triggered.connect(lambda: self._on_import_clicked("shipstation"))
toolbar.addAction(shipstation_action)
self._import_actions["shipstation"] = shipstation_action
toolbar.addSeparator()
export_action = QAction("Export Visible Orders to Odoo CSV", self)
export_action.triggered.connect(self._on_export_clicked)
toolbar.addAction(export_action)
toolbar.addSeparator()
settings_action = QAction("Settings", self)
settings_action.triggered.connect(self._on_settings_clicked)
toolbar.addAction(settings_action)
refresh_action = QAction("Refresh from Local DB", self)
refresh_action.triggered.connect(self._refresh_everything)
toolbar.addAction(refresh_action)
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
self.status_label = QLabel("Ready.")
self.status_bar.addWidget(self.status_label)
# -- actions -----------------------------------------------------------
def _on_settings_clicked(self) -> None:
dialog = SettingsDialog(self)
dialog.exec()
def _on_order_double_clicked(self, order) -> None:
dialog = OrderDetailDialog(order, self)
dialog.exec()
def _on_import_clicked(self, service_name: str) -> None:
service_cls = SERVICE_REGISTRY[service_name]
service = service_cls()
if not service.is_configured():
QMessageBox.warning(
self,
f"{service_name.title()} not configured",
f"Fill in the {service_name.title()} settings first, then try again.",
)
return
action = self._import_actions[service_name]
action.setEnabled(False)
self.status_label.setText(f"Importing orders from {service_name.title()}...")
worker = FetchOrdersWorker(service)
worker.finished_ok.connect(
lambda new_count, updated_count: self._on_import_finished(
service_name, new_count, updated_count
)
)
worker.failed.connect(lambda message: self._on_import_failed(service_name, message))
self._workers[service_name] = worker # keep a reference so it isn't garbage collected
worker.start()
def _on_import_finished(self, service_name: str, new_count: int, updated_count: int) -> None:
self._import_actions[service_name].setEnabled(True)
self.status_label.setText(
f"{service_name.title()} import complete: {new_count} new, {updated_count} updated."
)
self._refresh_everything()
def _on_import_failed(self, service_name: str, message: str) -> None:
self._import_actions[service_name].setEnabled(True)
self.status_label.setText(f"{service_name.title()} import failed.")
QMessageBox.critical(self, f"{service_name.title()} import failed", message)
def _on_export_clicked(self) -> None:
orders = self.orders_table.visible_orders()
if not orders:
QMessageBox.information(
self, "Nothing to export", "No orders match the current filters."
)
return
filepath, _ = QFileDialog.getSaveFileName(
self, "Export to Odoo CSV", "odoo_orders_export.csv", "CSV Files (*.csv)"
)
if not filepath:
return
try:
count = export_orders_to_csv(orders, filepath)
except OSError as exc:
QMessageBox.critical(self, "Export failed", str(exc))
return
self.status_label.setText(f"Exported {count} order(s) to {filepath}")
def _refresh_everything(self) -> None:
orders = load_all_orders()
self.orders_table.set_orders(orders)
self.dashboard.update_stats(get_dashboard_stats())
if not hasattr(self, "_suppress_ready_status"):
self.status_label.setText(f"{len(orders)} order(s) in local database.")
# ---------------------------------------------------------------------------
# Future growth notes:
#
# - Odoo push API (once ready): add app/services/odoo_service.py with a
# push_orders(orders) method, wire a new toolbar action similarly to
# _on_import_clicked, and it can eventually replace/augment odoo_export.py.
# - Order detail view: connect a table double-click to a dialog showing
# selected_order().raw_data (full JIRA/ShipStation payload) for debugging
# ticket-number/SKU extraction as real data comes in.
# - Pipeline actions ("mark fulfilled", "create ShipStation label"): add
# toolbar actions gated on table selection.
# - If the window grows too much, split each tab's toolbar into its own
# QToolBar shown only while that tab is active.
# ---------------------------------------------------------------------------
+74
View File
@@ -0,0 +1,74 @@
"""
Settings dialog.
Built dynamically from app.config.SETTINGS_SCHEMA, grouped by section
(Database, JIRA, ...). When a new service adds settings to that
schema, they show up here automatically - no UI changes needed.
"""
from __future__ import annotations
from collections import defaultdict
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QFormLayout,
QLineEdit,
QPushButton,
QHBoxLayout,
QGroupBox,
QLabel,
QMessageBox,
)
from app import config
class SettingsDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Settings")
self.setMinimumWidth(480)
self._fields: dict[str, QLineEdit] = {}
current_values = config.load_settings()
# Group schema entries by their group label, preserving order
groups: dict[str, list[tuple[str, str, bool]]] = defaultdict(list)
for key, (label, group, is_secret) in config.SETTINGS_SCHEMA.items():
groups[group].append((key, label, is_secret))
layout = QVBoxLayout(self)
layout.addWidget(
QLabel("Changes are saved to your .env file and applied immediately.")
)
for group_name, entries in groups.items():
box = QGroupBox(group_name)
form = QFormLayout(box)
for key, label, is_secret in entries:
field = QLineEdit(current_values.get(key, ""))
if is_secret:
field.setEchoMode(QLineEdit.EchoMode.Password)
form.addRow(label, field)
self._fields[key] = field
layout.addWidget(box)
button_row = QHBoxLayout()
save_button = QPushButton("Save")
cancel_button = QPushButton("Cancel")
save_button.clicked.connect(self._on_save)
cancel_button.clicked.connect(self.reject)
button_row.addStretch()
button_row.addWidget(cancel_button)
button_row.addWidget(save_button)
layout.addLayout(button_row)
def _on_save(self) -> None:
values = {key: field.text().strip() for key, field in self._fields.items()}
try:
config.save_settings(values)
except OSError as exc:
QMessageBox.critical(self, "Couldn't save settings", str(exc))
return
self.accept()
View File
+124
View File
@@ -0,0 +1,124 @@
"""
Dashboard tab: at-a-glance counts of what's come in.
Kept intentionally simple for now (stat cards, no charts) per the
"just a dashboard for now" scope. The stats themselves come from
app.workers.get_dashboard_stats(), so adding a new stat later is a
matter of adding a key there and a card here - this widget doesn't
know anything about how orders are fetched or stored.
"""
from __future__ import annotations
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import (
QWidget,
QGridLayout,
QVBoxLayout,
QHBoxLayout,
QLabel,
QFrame,
QSizePolicy,
)
class StatCard(QFrame):
def __init__(self, title: str, parent=None):
super().__init__(parent)
self.setFrameShape(QFrame.Shape.StyledPanel)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
layout = QVBoxLayout(self)
self.value_label = QLabel("0")
value_font = self.value_label.font()
value_font.setPointSize(28)
value_font.setBold(True)
self.value_label.setFont(value_font)
self.value_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
title_label = QLabel(title)
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
title_label.setWordWrap(True)
layout.addWidget(self.value_label)
layout.addWidget(title_label)
def set_value(self, value) -> None:
self.value_label.setText(str(value))
class DashboardWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
outer = QVBoxLayout(self)
heading = QLabel("Today's Orders at a Glance")
heading_font = heading.font()
heading_font.setPointSize(16)
heading_font.setBold(True)
heading.setFont(heading_font)
outer.addWidget(heading)
# Top row: overall + per-source totals
top_row = QHBoxLayout()
self.total_card = StatCard("Total Orders")
self.jira_card = StatCard("From JIRA")
self.shipstation_card = StatCard("From ShipStation")
for card in (self.total_card, self.jira_card, self.shipstation_card):
top_row.addWidget(card)
outer.addLayout(top_row)
# Second row: per-company totals (grid so adding a 3rd company later just works)
outer.addWidget(self._section_label("By Company"))
self.company_grid = QGridLayout()
self.company_cards: dict[str, StatCard] = {}
outer.addLayout(self.company_grid)
# Third row: cross-source matching, since JIRA and ShipStation are pulled independently
outer.addWidget(self._section_label("JIRA <-> ShipStation Matching (by ticket #)"))
match_row = QHBoxLayout()
self.matched_card = StatCard("Matched in Both")
self.jira_only_card = StatCard("JIRA Only (not yet in ShipStation)")
self.shipstation_only_card = StatCard("ShipStation Only (no matching ticket)")
for card in (self.matched_card, self.jira_only_card, self.shipstation_only_card):
match_row.addWidget(card)
outer.addLayout(match_row)
outer.addStretch()
@staticmethod
def _section_label(text: str) -> QLabel:
label = QLabel(text)
font = label.font()
font.setBold(True)
label.setFont(font)
return label
def update_stats(self, stats: dict) -> None:
self.total_card.set_value(stats.get("total", 0))
self.jira_card.set_value(stats.get("by_source", {}).get("jira", 0))
self.shipstation_card.set_value(stats.get("by_source", {}).get("shipstation", 0))
self.matched_card.set_value(stats.get("matched_count", 0))
self.jira_only_card.set_value(stats.get("jira_only_count", 0))
self.shipstation_only_card.set_value(stats.get("shipstation_only_count", 0))
by_company = stats.get("by_company", {})
# Rebuild company cards if the set of companies changed (e.g. a 3rd company added)
if set(by_company.keys()) != set(self.company_cards.keys()):
self._rebuild_company_cards(by_company.keys())
for company, count in by_company.items():
self.company_cards[company].set_value(count)
def _rebuild_company_cards(self, companies) -> None:
while self.company_grid.count():
item = self.company_grid.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
self.company_cards.clear()
for i, company in enumerate(sorted(companies)):
card = StatCard(company)
self.company_cards[company] = card
self.company_grid.addWidget(card, i // 3, i % 3)
+46
View File
@@ -0,0 +1,46 @@
"""
Order detail dialog.
Mainly a debugging aid: shows exactly what the source system (JIRA or
ShipStation) sent back for this order, so field-shape mismatches - like
the "Hardware Needed" single-select vs. "Deliverables" multi-select
issue - are easy to spot by just double-clicking a row instead of
reading logs or re-running a script.
"""
from __future__ import annotations
import json
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QLabel, QPushButton
from app.models import Order
class OrderDetailDialog(QDialog):
def __init__(self, order: Order, parent=None):
super().__init__(parent)
self.setWindowTitle(f"Order Detail - {order.ticket_number or order.external_id}")
self.resize(700, 600)
layout = QVBoxLayout(self)
summary_lines = [
f"Source: {order.source}",
f"Ticket #: {order.ticket_number or '(none found)'}",
f"Company: {order.company}",
f"SKUs: {', '.join(order.skus or []) or '(none extracted)'}",
f"Status: {order.status}",
]
summary_label = QLabel("\n".join(summary_lines))
layout.addWidget(summary_label)
layout.addWidget(QLabel("Raw payload from source system:"))
text = QTextEdit()
text.setReadOnly(True)
text.setFontFamily("Courier New")
text.setPlainText(json.dumps(order.raw_data, indent=2, default=str))
layout.addWidget(text)
close_button = QPushButton("Close")
close_button.clicked.connect(self.accept)
layout.addWidget(close_button)
+217
View File
@@ -0,0 +1,217 @@
"""
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.
"""
from __future__ import annotations
from typing import List, Any, Optional
from PyQt6.QtCore import Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel, pyqtSignal
from PyQt6.QtWidgets import (
QTableView,
QAbstractItemView,
QHeaderView,
QWidget,
QVBoxLayout,
QHBoxLayout,
QComboBox,
QLineEdit,
QLabel,
)
from app.models import Order
COLUMNS = [
("company", "Company"),
("ticket_number", "Ticket #"),
("skus_display", "SKUs"),
("source", "Source"),
("external_id", "Source ID"),
("summary", "Summary"),
("status", "Status"),
("source_created_at", "Created"),
("imported_at", "Imported"),
("fulfilled", "Fulfilled"),
]
ALL_COMPANIES = "All Companies"
ALL_SOURCES = "All Sources"
class OrdersTableModel(QAbstractTableModel):
def __init__(self, orders: List[Order] | None = None, parent=None):
super().__init__(parent)
self._orders: List[Order] = orders or []
def set_orders(self, orders: List[Order]) -> None:
self.beginResetModel()
self._orders = orders
self.endResetModel()
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
return len(self._orders)
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
return len(COLUMNS)
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if role != Qt.ItemDataRole.DisplayRole:
return None
if orientation == Qt.Orientation.Horizontal:
return COLUMNS[section][1]
return str(section + 1)
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
if not index.isValid() or role != Qt.ItemDataRole.DisplayRole:
return None
order = self._orders[index.row()]
field_name, _ = COLUMNS[index.column()]
if field_name == "skus_display":
return ", ".join(order.skus or [])
value = getattr(order, field_name)
if value is None:
return ""
if field_name == "fulfilled":
return "Yes" if value else "No"
return 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."""
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()
def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool:
model: OrdersTableModel = self.sourceModel()
order = model.order_at(source_row)
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 "",
" ".join(order.skus or []),
]
).lower()
if self.search_text not in haystack:
return False
return True
class OrdersTableView(QWidget):
"""Filter bar + sortable, read-only, full-row-selection table."""
order_double_clicked = pyqtSignal(object) # emits the Order that was double-clicked
def __init__(self, parent=None):
super().__init__(parent)
self._source_model = OrdersTableModel()
self._proxy_model = OrdersFilterProxyModel()
self._proxy_model.setSourceModel(self._source_model)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
filter_bar = QHBoxLayout()
filter_bar.addWidget(QLabel("Company:"))
self.company_combo = QComboBox()
self.company_combo.addItem(ALL_COMPANIES)
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.textChanged.connect(self._proxy_model.set_search_text)
filter_bar.addWidget(self.search_box, stretch=1)
layout.addLayout(filter_bar)
self.table = QTableView()
self.table.setModel(self._proxy_model)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.table.setSortingEnabled(True)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self.table.horizontalHeader().setStretchLastSection(False)
self.table.doubleClicked.connect(self._on_row_double_clicked)
layout.addWidget(self.table)
def _on_row_double_clicked(self, index) -> None:
source_index = self._proxy_model.mapToSource(index)
order = self._source_model.order_at(source_index.row())
self.order_double_clicked.emit(order)
def set_orders(self, orders: List[Order]) -> None:
self._source_model.set_orders(orders)
self.table.resizeColumnsToContents()
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)
def selected_order(self) -> Optional[Order]:
indexes = self.table.selectionModel().selectedRows()
if not indexes:
return None
source_index = self._proxy_model.mapToSource(indexes[0])
return self._source_model.order_at(source_index.row())
def visible_orders(self) -> List[Order]:
"""Orders currently passing the active filters - used for export."""
result = []
for row in range(self._proxy_model.rowCount()):
source_index = self._proxy_model.mapToSource(self._proxy_model.index(row, 0))
result.append(self._source_model.order_at(source_index.row()))
return result