diff --git a/README.md b/README.md index 44a5560..1a55740 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/app/services/jira_service.py b/app/services/jira_service.py index e18398b..588a651 100644 --- a/app/services/jira_service.py +++ b/app/services/jira_service.py @@ -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]: diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 8ea26cd..7eca588 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -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) diff --git a/app/workers.py b/app/workers.py index 9ce8bd1..9e0a991 100644 --- a/app/workers.py +++ b/app/workers.py @@ -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 @@ -216,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: