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
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
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
source_created_at = Column(DateTime, nullable=True)
# 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
# for "fulfilled today" counts and as a rough audit trail. Set once,
# 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.status_rules import get_active_statuses
SEARCH_PAGE_SIZE = 50
SEARCH_PAGE_SIZE = 100
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 -
# 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
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)
headers = {"Accept": "application/json"}
@@ -152,15 +172,19 @@ class JiraService(OrderService):
fields += "," + ",".join(contact_field_ids)
all_issues: List[dict] = []
start_at = 0
next_page_token: str | None = None
seen_tokens: set[str] = set()
page_count = 0
while True:
params = {
"jql": jql,
"startAt": start_at,
"maxResults": SEARCH_PAGE_SIZE,
"fields": fields,
}
if next_page_token:
params["nextPageToken"] = next_page_token
try:
response = requests.get(
url,
@@ -193,12 +217,24 @@ class JiraService(OrderService):
batch = data.get("issues", [])
all_issues.extend(batch)
page_count += 1
total = data.get("total", len(all_issues))
start_at += len(batch)
if start_at >= total or not batch:
new_token = data.get("nextPageToken")
is_last = bool(data.get("isLast")) or not new_token
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
seen_tokens.add(new_token)
next_page_token = new_token
return all_issues
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,
get_dashboard_stats,
mark_shipstation_sent,
reset_local_database,
)
@@ -115,6 +116,11 @@ class MainWindow(QMainWindow):
refresh_action.triggered.connect(self._refresh_everything)
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.setStatusBar(self.status_bar)
self.status_label = QLabel("Ready.")
@@ -333,6 +339,27 @@ class MainWindow(QMainWindow):
"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:
active_orders, cancelled_orders, done_orders = load_orders_by_view()
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 PyQt6.QtCore import QThread, pyqtSignal
from sqlalchemy import select
from sqlalchemy import select, delete
from app.database import get_session
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
CANCELLED_STATUSES - it's what limits the Cancelled tab to "cancelled
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()
new_count = 0
@@ -157,10 +164,10 @@ def save_orders(orders: List[NormalizedOrder]) -> SaveResult:
source_created_at=order["source_created_at"],
raw_data=order["raw_data"],
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=(
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(
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(
old_status, cancelled_statuses
):
existing.cancelled_at = dt.datetime.utcnow()
existing.cancelled_at = dt.datetime.now()
existing.ticket_number = order.get("ticket_number")
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]:
session = get_session()
try:
@@ -265,7 +295,7 @@ def mark_shipstation_sent(ticket_number: str) -> None:
select(Order).where(Order.source == "jira", Order.ticket_number == ticket_number)
).scalar_one_or_none()
if order is not None:
order.shipstation_sent_at = dt.datetime.utcnow()
order.shipstation_sent_at = dt.datetime.now()
session.commit()
finally:
session.close()