Compare commits

...
2 Commits
Author SHA1 Message Date
madmin 6655b02f4a Major JQL bugfixes 2026-09-01 16:30:54 -05:00
madmin bf5566e911 TimeZone Bug Fix 2026-09-01 14:12:19 -05:00
5 changed files with 122 additions and 14 deletions
+15
View File
@@ -210,6 +210,21 @@ summary. Company is resolved from the SKU prefix (`SH`/`OK`) via
usually blank, the app falls back to the joined deliverable usually blank, the app falls back to the joined deliverable
descriptions for the Summary column when there's nothing else there. descriptions for the Summary column when there's nothing else there.
## Resetting the local database
**Reset Local Database** in the toolbar clears every cached order (with
a confirmation first). This is always safe - JIRA is the real source of
truth, this is just a rebuildable cache - but it's worth knowing when
you'd actually need it: if a ticket transitioned status under an older,
buggy version of this app, its `fulfilled_at`/`cancelled_at` timestamp
can get stuck wrong, since those are only recalculated **at the moment
of a transition** - re-importing an already-transitioned ticket finds
"no status change" and never touches that timestamp again. A reset
clears the stale value entirely; the next Import from JIRA then stamps
everything correctly from scratch. Follow a reset with **Import from
JIRA**, and **Pull Tracking Numbers** if you rely on today's
already-pulled tracking data.
## Moving storage to your MariaDB LXC later ## Moving storage to your MariaDB LXC later
In Settings (or directly in `.env`), change: In Settings (or directly in `.env`), change:
+1 -1
View File
@@ -77,7 +77,7 @@ class Order(Base):
# When the order/ticket was created in the source system # When the order/ticket was created in the source system
source_created_at = Column(DateTime, nullable=True) source_created_at = Column(DateTime, nullable=True)
# When we pulled it into this app # When we pulled it into this app
imported_at = Column(DateTime, nullable=False, default=dt.datetime.utcnow) imported_at = Column(DateTime, nullable=False, default=dt.datetime.now)
# When this ticket first transitioned into a fulfilled status - used # When this ticket first transitioned into a fulfilled status - used
# for "fulfilled today" counts and as a rough audit trail. Set once, # for "fulfilled today" counts and as a rough audit trail. Set once,
# on the transition; not touched again while it stays fulfilled. # on the transition; not touched again while it stays fulfilled.
+43 -7
View File
@@ -21,8 +21,12 @@ from app.queries import get_open_ticket_numbers
from app.services.base import OrderService, NormalizedOrder from app.services.base import OrderService, NormalizedOrder
from app.status_rules import get_active_statuses from app.status_rules import get_active_statuses
SEARCH_PAGE_SIZE = 50 SEARCH_PAGE_SIZE = 100
REQUEST_TIMEOUT_SECONDS = 30 REQUEST_TIMEOUT_SECONDS = 30
# Hard ceiling on pages per fetch, purely as a backstop against the new
# search endpoint's documented pagination flakiness (tokens that repeat
# or never advance) - normal usage should never come close to this.
MAX_SEARCH_PAGES = 200
# Deliverable values come back like "SH011: Shipping - Return Label iPad - # Deliverable values come back like "SH011: Shipping - Return Label iPad -
# Physical in Box" - the part before the colon is the actual SKU code; # Physical in Box" - the part before the colon is the actual SKU code;
@@ -138,7 +142,23 @@ class JiraService(OrderService):
return combined_where + order_by_clause return combined_where + order_by_clause
def _search_all_issues(self, jql: str) -> List[dict]: def _search_all_issues(self, jql: str) -> List[dict]:
url = f"{self.base_url}/rest/api/3/search" # /rest/api/3/search (with startAt/total pagination) has been
# deprecated and removed by Atlassian - /rest/api/3/search/jql is
# the replacement, using nextPageToken instead. That migration is
# NOT optional: hitting the old endpoint either errors outright or,
# worse, silently stops returning `total`, which made the old
# startAt-based loop here think page 1 was the whole result set -
# exactly the "only pulled tickets from this afternoon onward" bug,
# since with newest-first sorting, page 1 is just the most recent
# slice.
#
# The new endpoint's token pagination has its own documented
# flakiness in the wild (tokens that repeat or never advance), so
# this loop defends against that directly: it stops on an empty
# batch, an explicit isLast, a missing token, OR a token identical
# to one already seen - plus a hard page-count ceiling as a last
# resort so a pagination bug can never turn into an infinite loop.
url = f"{self.base_url}/rest/api/3/search/jql"
auth = (self.email, self.api_token) auth = (self.email, self.api_token)
headers = {"Accept": "application/json"} headers = {"Accept": "application/json"}
@@ -152,15 +172,19 @@ class JiraService(OrderService):
fields += "," + ",".join(contact_field_ids) fields += "," + ",".join(contact_field_ids)
all_issues: List[dict] = [] all_issues: List[dict] = []
start_at = 0 next_page_token: str | None = None
seen_tokens: set[str] = set()
page_count = 0
while True: while True:
params = { params = {
"jql": jql, "jql": jql,
"startAt": start_at,
"maxResults": SEARCH_PAGE_SIZE, "maxResults": SEARCH_PAGE_SIZE,
"fields": fields, "fields": fields,
} }
if next_page_token:
params["nextPageToken"] = next_page_token
try: try:
response = requests.get( response = requests.get(
url, url,
@@ -193,12 +217,24 @@ class JiraService(OrderService):
batch = data.get("issues", []) batch = data.get("issues", [])
all_issues.extend(batch) all_issues.extend(batch)
page_count += 1
total = data.get("total", len(all_issues)) new_token = data.get("nextPageToken")
start_at += len(batch) is_last = bool(data.get("isLast")) or not new_token
if start_at >= total or not batch:
if (
not batch
or is_last
or not new_token
or new_token == next_page_token
or new_token in seen_tokens
or page_count >= MAX_SEARCH_PAGES
):
break break
seen_tokens.add(new_token)
next_page_token = new_token
return all_issues return all_issues
def _raw_values_from_field(self, fields: dict, field_id: str) -> List[str]: def _raw_values_from_field(self, fields: dict, field_id: str) -> List[str]:
+27
View File
@@ -39,6 +39,7 @@ from app.workers import (
load_orders_by_view, load_orders_by_view,
get_dashboard_stats, get_dashboard_stats,
mark_shipstation_sent, mark_shipstation_sent,
reset_local_database,
) )
@@ -115,6 +116,11 @@ class MainWindow(QMainWindow):
refresh_action.triggered.connect(self._refresh_everything) refresh_action.triggered.connect(self._refresh_everything)
toolbar.addAction(refresh_action) toolbar.addAction(refresh_action)
reset_action = QAction("Reset Local Database", self)
reset_action.setToolTip("Wipes the local cache - re-import from JIRA afterward")
reset_action.triggered.connect(self._on_reset_database_clicked)
toolbar.addAction(reset_action)
self.status_bar = QStatusBar() self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar) self.setStatusBar(self.status_bar)
self.status_label = QLabel("Ready.") self.status_label = QLabel("Ready.")
@@ -333,6 +339,27 @@ class MainWindow(QMainWindow):
"Upload this into ShipStation's Import Orders wizard.", "Upload this into ShipStation's Import Orders wizard.",
) )
def _on_reset_database_clicked(self) -> None:
confirm = QMessageBox.warning(
self,
"Reset Local Database",
"This clears every locally cached order (JIRA is still the real source of "
"truth, so nothing there is affected). You'll need to click Import from JIRA "
"afterward to repopulate, and Pull Tracking Numbers again if you rely on "
"today's already-pulled tracking data.\n\n"
"Continue?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if confirm != QMessageBox.StandardButton.Yes:
return
count = reset_local_database()
self._refresh_everything()
self.status_label.setText(
f"Local database reset - {count} order(s) cleared. Run Import from JIRA to repopulate."
)
def _refresh_everything(self) -> None: def _refresh_everything(self) -> None:
active_orders, cancelled_orders, done_orders = load_orders_by_view() active_orders, cancelled_orders, done_orders = load_orders_by_view()
self.orders_table.set_orders(active_orders) self.orders_table.set_orders(active_orders)
+36 -6
View File
@@ -12,7 +12,7 @@ import datetime as dt
from typing import List, Tuple, TypedDict from typing import List, Tuple, TypedDict
from PyQt6.QtCore import QThread, pyqtSignal from PyQt6.QtCore import QThread, pyqtSignal
from sqlalchemy import select from sqlalchemy import select, delete
from app.database import get_session from app.database import get_session
from app.models import Order from app.models import Order
@@ -102,6 +102,13 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
what moves it into the Done pile. cancelled_at works the same way for what moves it into the Done pile. cancelled_at works the same way for
CANCELLED_STATUSES - it's what limits the Cancelled tab to "cancelled CANCELLED_STATUSES - it's what limits the Cancelled tab to "cancelled
today" rather than showing every cancelled ticket ever. today" rather than showing every cancelled ticket ever.
These get stamped with dt.datetime.now() (LOCAL time), not utcnow() -
deliberately, since every "is this today" check elsewhere compares
against dt.date.today() (also local). Mixing the two caused a real
bug: a ticket cancelled in the evening in a US timezone would get a
UTC timestamp that had already rolled into tomorrow, failing the
same-day check immediately and landing on Done instead of Cancelled.
""" """
session = get_session() session = get_session()
new_count = 0 new_count = 0
@@ -157,10 +164,10 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
source_created_at=order["source_created_at"], source_created_at=order["source_created_at"],
raw_data=order["raw_data"], raw_data=order["raw_data"],
fulfilled_at=( fulfilled_at=(
dt.datetime.utcnow() if status_in(new_status, fulfilled_statuses) else None dt.datetime.now() if status_in(new_status, fulfilled_statuses) else None
), ),
cancelled_at=( cancelled_at=(
dt.datetime.utcnow() if status_in(new_status, cancelled_statuses) else None dt.datetime.now() if status_in(new_status, cancelled_statuses) else None
), ),
) )
) )
@@ -178,11 +185,11 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
if status_in(new_status, fulfilled_statuses) and not status_in( if status_in(new_status, fulfilled_statuses) and not status_in(
old_status, fulfilled_statuses old_status, fulfilled_statuses
): ):
existing.fulfilled_at = dt.datetime.utcnow() existing.fulfilled_at = dt.datetime.now()
if status_in(new_status, cancelled_statuses) and not status_in( if status_in(new_status, cancelled_statuses) and not status_in(
old_status, cancelled_statuses old_status, cancelled_statuses
): ):
existing.cancelled_at = dt.datetime.utcnow() existing.cancelled_at = dt.datetime.now()
existing.ticket_number = order.get("ticket_number") existing.ticket_number = order.get("ticket_number")
existing.company = order.get("company", "Unknown") existing.company = order.get("company", "Unknown")
@@ -209,6 +216,29 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
) )
def reset_local_database() -> int:
"""
Wipes every locally cached order. This is a rebuildable cache, not a
system of record - JIRA is - so this is always safe, just requires
re-running Import from JIRA (and Pull Tracking Numbers) afterward.
Also the real fix for one specific situation: a ticket that
transitioned status under an older, buggy version of this app can end
up with a permanently-wrong fulfilled_at/cancelled_at timestamp, since
those are only recalculated ON a transition - re-importing an
already-transitioned ticket finds "no change" and never touches it
again. A reset clears that stale timestamp entirely; the fresh
re-import then stamps everything correctly from scratch.
"""
session = get_session()
try:
result = session.execute(delete(Order))
session.commit()
return result.rowcount or 0
finally:
session.close()
def load_all_orders() -> List[Order]: def load_all_orders() -> List[Order]:
session = get_session() session = get_session()
try: try:
@@ -265,7 +295,7 @@ def mark_shipstation_sent(ticket_number: str) -> None:
select(Order).where(Order.source == "jira", Order.ticket_number == ticket_number) select(Order).where(Order.source == "jira", Order.ticket_number == ticket_number)
).scalar_one_or_none() ).scalar_one_or_none()
if order is not None: if order is not None:
order.shipstation_sent_at = dt.datetime.utcnow() order.shipstation_sent_at = dt.datetime.now()
session.commit() session.commit()
finally: finally:
session.close() session.close()