Compare commits
2
Commits
df9958cb97
...
6655b02f4a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6655b02f4a | ||
|
|
bf5566e911 |
@@ -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
@@ -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.
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user