401 lines
17 KiB
Python
401 lines
17 KiB
Python
"""Shipping history window.
|
|
|
|
Left sidebar: search + filters over the local label history.
|
|
Main toolbar: opens authenticated Stamps.com hosted pages (refund, SCAN form,
|
|
pickup, full history) — the API returns a single-use URL we open in the
|
|
browser so the user gets the real Stamps UI without re-logging in.
|
|
Export: writes the current filtered view to CSV from our local store.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import logging
|
|
import os
|
|
import tempfile
|
|
import threading
|
|
import webbrowser
|
|
from tkinter import ttk
|
|
|
|
import customtkinter as ctk
|
|
|
|
from . import theme as T
|
|
from .label_history import LabelEntry, LabelHistory
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# ── Table columns ──────────────────────────────────────────────────────────
|
|
_COLUMNS = [
|
|
("date", "Date Printed", 105),
|
|
("name", "Recipient", 155),
|
|
("tracking", "Tracking #", 165),
|
|
("service", "Service", 140),
|
|
("amount", "Amount", 80),
|
|
("status", "Status", 110),
|
|
("notes", "Notes", 165),
|
|
]
|
|
|
|
_DATE_FILTERS = ["All time", "Today", "Last 7 days", "Last 30 days"]
|
|
_STATUS_FILTERS = ["All", "Delivered", "In Transit", "Accepted", "Exception", "Voided"]
|
|
|
|
|
|
def _resolve(color) -> str:
|
|
"""Resolve a (light, dark) tuple to the current mode's hex string."""
|
|
if isinstance(color, (list, tuple)) and len(color) == 2:
|
|
mode = ctk.get_appearance_mode().lower()
|
|
return color[1] if mode == "dark" else color[0]
|
|
return color
|
|
|
|
|
|
class HistoryWindow(ctk.CTkToplevel):
|
|
def __init__(self, parent, cfg=None, carrier: str = ""):
|
|
super().__init__(parent)
|
|
self._cfg = cfg
|
|
self._carrier = carrier
|
|
self._history = LabelHistory()
|
|
self._entries: list[LabelEntry] = []
|
|
|
|
_titles = {"usps": "USPS Shipping History", "fedex": "FedEx Shipping History"}
|
|
self.title(_titles.get(carrier, "Shipping History"))
|
|
self.geometry("1100x600")
|
|
self.minsize(800, 400)
|
|
|
|
self.columnconfigure(1, weight=1)
|
|
self.rowconfigure(0, weight=1)
|
|
|
|
self._build_sidebar()
|
|
self._build_main()
|
|
self._apply_treeview_style()
|
|
self._refresh_table()
|
|
|
|
# ── Sidebar ────────────────────────────────────────────────────────────
|
|
|
|
def _build_sidebar(self) -> None:
|
|
sidebar = ctk.CTkFrame(self, fg_color=T.TOOLBAR_BG,
|
|
corner_radius=0, width=180)
|
|
sidebar.grid(row=0, column=0, sticky="nsew")
|
|
sidebar.columnconfigure(0, weight=1)
|
|
sidebar.grid_propagate(False)
|
|
|
|
ctk.CTkLabel(sidebar, text="Search Prints",
|
|
font=T.FONT_BODY_BOLD, text_color=T.TEXT_PRIMARY).grid(
|
|
row=0, column=0, sticky="w", padx=14, pady=(16, 6))
|
|
|
|
self._search_var = ctk.StringVar()
|
|
self._search_var.trace_add("write", lambda *_: self._refresh_table())
|
|
search = ctk.CTkEntry(sidebar, textvariable=self._search_var,
|
|
placeholder_text="Name, notes, tracking…",
|
|
height=30, font=T.FONT_BODY,
|
|
fg_color=T.CARD_BG, border_color=T.BORDER,
|
|
border_width=1, corner_radius=6,
|
|
text_color=T.TEXT_PRIMARY,
|
|
placeholder_text_color=T.TEXT_SUBTLE)
|
|
search.grid(row=1, column=0, sticky="ew", padx=14, pady=(0, 16))
|
|
|
|
ctk.CTkFrame(sidebar, fg_color=T.DIVIDER, height=1).grid(
|
|
row=2, column=0, sticky="ew", padx=14)
|
|
|
|
ctk.CTkLabel(sidebar, text="Date Printed",
|
|
font=T.FONT_LABEL, text_color=T.TEXT_MUTED).grid(
|
|
row=3, column=0, sticky="w", padx=14, pady=(12, 4))
|
|
self._date_var = ctk.StringVar(value=_DATE_FILTERS[0])
|
|
ctk.CTkOptionMenu(sidebar, variable=self._date_var,
|
|
values=_DATE_FILTERS, height=28,
|
|
font=T.FONT_BODY, fg_color=T.CARD_BG,
|
|
button_color=T.ACCENT, button_hover_color=T.ACCENT_HOVER,
|
|
text_color=T.TEXT_PRIMARY,
|
|
dropdown_fg_color=T.CARD_BG,
|
|
dropdown_text_color=T.TEXT_PRIMARY,
|
|
dropdown_hover_color=T.CARD_BG_HOVER,
|
|
command=lambda _: self._refresh_table()).grid(
|
|
row=4, column=0, sticky="ew", padx=14, pady=(0, 10))
|
|
|
|
ctk.CTkLabel(sidebar, text="Status",
|
|
font=T.FONT_LABEL, text_color=T.TEXT_MUTED).grid(
|
|
row=5, column=0, sticky="w", padx=14, pady=(6, 4))
|
|
self._status_var = ctk.StringVar(value=_STATUS_FILTERS[0])
|
|
ctk.CTkOptionMenu(sidebar, variable=self._status_var,
|
|
values=_STATUS_FILTERS, height=28,
|
|
font=T.FONT_BODY, fg_color=T.CARD_BG,
|
|
button_color=T.ACCENT, button_hover_color=T.ACCENT_HOVER,
|
|
text_color=T.TEXT_PRIMARY,
|
|
dropdown_fg_color=T.CARD_BG,
|
|
dropdown_text_color=T.TEXT_PRIMARY,
|
|
dropdown_hover_color=T.CARD_BG_HOVER,
|
|
command=lambda _: self._refresh_table()).grid(
|
|
row=6, column=0, sticky="ew", padx=14, pady=(0, 10))
|
|
|
|
ctk.CTkLabel(sidebar, text="", text_color=T.TEXT_MUTED,
|
|
font=T.FONT_LABEL).grid(row=7, column=0, sticky="w",
|
|
padx=14, pady=(6, 4))
|
|
|
|
# ── Main area ──────────────────────────────────────────────────────────
|
|
|
|
def _build_main(self) -> None:
|
|
main = ctk.CTkFrame(self, fg_color=T.WINDOW_BG, corner_radius=0)
|
|
main.grid(row=0, column=1, sticky="nsew")
|
|
main.columnconfigure(0, weight=1)
|
|
main.rowconfigure(1, weight=1)
|
|
|
|
# Action toolbar
|
|
toolbar = ctk.CTkFrame(main, fg_color=T.PANEL_BG,
|
|
corner_radius=0, height=48,
|
|
border_color=T.BORDER, border_width=1)
|
|
toolbar.grid(row=0, column=0, sticky="ew")
|
|
toolbar.grid_propagate(False)
|
|
|
|
btn_kw = dict(
|
|
height=32, font=T.FONT_LABEL, corner_radius=6,
|
|
fg_color=T.NEUTRAL_BTN_BG, hover_color=T.NEUTRAL_BTN_HOVER,
|
|
text_color=T.TEXT_PRIMARY,
|
|
border_width=1, border_color=T.NEUTRAL_BTN_BORDER,
|
|
)
|
|
|
|
# USPS-only Stamps.com actions
|
|
if self._carrier == "usps":
|
|
ctk.CTkButton(toolbar, text="Refund", width=90,
|
|
command=lambda: self._open_stamps_page("online_reporting_refund"),
|
|
**btn_kw).pack(side="left", padx=(10, 4), pady=8)
|
|
ctk.CTkButton(toolbar, text="Schedule Pickup", width=130,
|
|
command=lambda: self._open_stamps_page("online_reporting_pickup"),
|
|
**btn_kw).pack(side="left", padx=4, pady=8)
|
|
ctk.CTkButton(toolbar, text="Create SCAN Form", width=140,
|
|
command=lambda: self._open_stamps_page("online_reporting_scan"),
|
|
**btn_kw).pack(side="left", padx=4, pady=8)
|
|
|
|
# Right side
|
|
if self._carrier == "usps":
|
|
ctk.CTkButton(toolbar, text="View on Stamps.com", width=155,
|
|
command=lambda: self._open_stamps_page("online_reporting_history"),
|
|
fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
|
|
text_color=T.ACCENT_TEXT,
|
|
height=32, font=T.FONT_LABEL, corner_radius=6,
|
|
border_width=1, border_color=T.ACCENT_BORDER).pack(
|
|
side="right", padx=(4, 10), pady=8)
|
|
ctk.CTkButton(toolbar, text="↑ Export CSV", width=100,
|
|
command=self._export_csv, **btn_kw).pack(
|
|
side="right", padx=4, pady=8)
|
|
ctk.CTkButton(toolbar, text="Backup…", width=90,
|
|
command=self._backup_history, **btn_kw).pack(
|
|
side="right", padx=(4, 0), pady=8)
|
|
|
|
# Count label
|
|
self._count_lbl = ctk.CTkLabel(toolbar, text="",
|
|
font=T.FONT_LABEL, text_color=T.TEXT_MUTED)
|
|
self._count_lbl.pack(side="right", padx=8)
|
|
|
|
# Table container
|
|
table_frame = ctk.CTkFrame(main, fg_color=T.PANEL_BG,
|
|
corner_radius=0)
|
|
table_frame.grid(row=1, column=0, sticky="nsew")
|
|
table_frame.columnconfigure(0, weight=1)
|
|
table_frame.rowconfigure(0, weight=1)
|
|
|
|
cols = [c[0] for c in _COLUMNS]
|
|
self._tree = ttk.Treeview(
|
|
table_frame,
|
|
columns=cols,
|
|
show="headings",
|
|
selectmode="extended",
|
|
)
|
|
|
|
for col_id, col_title, col_w in _COLUMNS:
|
|
self._tree.heading(col_id, text=col_title,
|
|
command=lambda c=col_id: self._sort(c))
|
|
self._tree.column(col_id, width=col_w, minwidth=60,
|
|
stretch=(col_id == "name"))
|
|
|
|
vsb = ttk.Scrollbar(table_frame, orient="vertical",
|
|
command=self._tree.yview)
|
|
hsb = ttk.Scrollbar(table_frame, orient="horizontal",
|
|
command=self._tree.xview)
|
|
self._tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
|
|
|
|
self._tree.grid(row=0, column=0, sticky="nsew")
|
|
vsb.grid(row=0, column=1, sticky="ns")
|
|
hsb.grid(row=1, column=0, sticky="ew")
|
|
|
|
self._tree.tag_configure("voided", foreground=_resolve(T.TEXT_MUTED))
|
|
self._tree.tag_configure("delivered",foreground=_resolve(T.NET_COLOR))
|
|
|
|
def _apply_treeview_style(self) -> None:
|
|
bg = _resolve(T.PANEL_BG)
|
|
fg = _resolve(T.TEXT_PRIMARY)
|
|
muted= _resolve(T.TEXT_MUTED)
|
|
sel = _resolve(T.ACCENT)
|
|
hdr = _resolve(T.TOOLBAR_BG)
|
|
|
|
style = ttk.Style(self)
|
|
style.theme_use("clam")
|
|
style.configure("Treeview",
|
|
background=bg, foreground=fg,
|
|
fieldbackground=bg, borderwidth=0,
|
|
rowheight=30, font=("Segoe UI", 11),
|
|
)
|
|
style.configure("Treeview.Heading",
|
|
background=hdr, foreground=muted, relief="flat",
|
|
font=("Segoe UI", 11, "bold"), padding=(6, 4),
|
|
)
|
|
style.map("Treeview",
|
|
background=[("selected", sel)],
|
|
foreground=[("selected", "#ffffff")],
|
|
)
|
|
style.configure("Vertical.TScrollbar",
|
|
background=hdr, troughcolor=bg, relief="flat")
|
|
style.configure("Horizontal.TScrollbar",
|
|
background=hdr, troughcolor=bg, relief="flat")
|
|
|
|
# ── Data ───────────────────────────────────────────────────────────────
|
|
|
|
def _refresh_table(self) -> None:
|
|
search = self._search_var.get().strip()
|
|
status = self._status_var.get()
|
|
date_f = self._date_var.get()
|
|
days = {"Today": 1, "Last 7 days": 7, "Last 30 days": 30}.get(date_f)
|
|
|
|
self._entries = self._history.filter(search=search, status=status, days=days,
|
|
carrier=self._carrier)
|
|
|
|
for row in self._tree.get_children():
|
|
self._tree.delete(row)
|
|
|
|
for e in self._entries:
|
|
amt = f"${e.amount:.2f}" if e.amount is not None else "—"
|
|
tag = "voided" if e.voided else ("delivered" if e.shipment_status == "Delivered" else "")
|
|
self._tree.insert("", "end",
|
|
iid=e.label_id,
|
|
values=(
|
|
e.date_printed_display,
|
|
e.recipient,
|
|
e.tracking_number or "—",
|
|
e.service or "—",
|
|
amt,
|
|
"Voided" if e.voided else e.shipment_status or "—",
|
|
e.notes or "—",
|
|
),
|
|
tags=(tag,),
|
|
)
|
|
|
|
n = len(self._entries)
|
|
self._count_lbl.configure(
|
|
text=f"{n} result{'s' if n != 1 else ''}")
|
|
|
|
def _sort(self, col: str) -> None:
|
|
items = [(self._tree.set(iid, col), iid)
|
|
for iid in self._tree.get_children()]
|
|
items.sort(key=lambda x: x[0])
|
|
for idx, (_, iid) in enumerate(items):
|
|
self._tree.move(iid, "", idx)
|
|
|
|
def _selected_entries(self) -> list[LabelEntry]:
|
|
sel = self._tree.selection()
|
|
by_id = {e.label_id: e for e in self._entries}
|
|
return [by_id[iid] for iid in sel if iid in by_id]
|
|
|
|
# ── Actions ────────────────────────────────────────────────────────────
|
|
|
|
def _open_stamps_page(self, url_type: str) -> None:
|
|
"""Fetch a single-use authenticated URL from SERA and open it in the browser."""
|
|
cfg = self._cfg
|
|
if not cfg or not getattr(cfg, "stamps_client_id", ""):
|
|
self._msg("Stamps.com",
|
|
"Stamps.com credentials are not configured yet.\n\n"
|
|
"Add stamps_client_id and stamps_refresh_token to config.json "
|
|
"once your developer application is approved.")
|
|
return
|
|
|
|
def _run() -> None:
|
|
try:
|
|
url = _get_hosted_url(cfg, url_type)
|
|
self.after(0, lambda: webbrowser.open(url))
|
|
except Exception as exc:
|
|
msg = str(exc)
|
|
self.after(0, lambda: self._msg("Stamps.com", msg))
|
|
|
|
threading.Thread(target=_run, daemon=True).start()
|
|
|
|
def _export_csv(self) -> None:
|
|
if not self._entries:
|
|
self._msg("Export", "No results to export.")
|
|
return
|
|
|
|
path = os.path.join(
|
|
tempfile.gettempdir(),
|
|
f"shipping_history_{len(self._entries)}_labels.csv",
|
|
)
|
|
with open(path, "w", newline="", encoding="utf-8") as f:
|
|
writer = csv.writer(f)
|
|
writer.writerow([
|
|
"Date Printed", "Recipient", "Tracking #", "Service",
|
|
"Amount", "Status", "Notes", "Date Delivered",
|
|
])
|
|
for e in self._entries:
|
|
amt = f"${e.amount:.2f}" if e.amount is not None else ""
|
|
writer.writerow([
|
|
e.date_printed_display,
|
|
e.recipient,
|
|
e.tracking_number,
|
|
e.service,
|
|
amt,
|
|
"Voided" if e.voided else e.shipment_status,
|
|
e.notes,
|
|
e.date_delivered_display,
|
|
])
|
|
try:
|
|
os.startfile(path)
|
|
except Exception:
|
|
pass
|
|
self._msg("Export", f"Exported {len(self._entries)} records.\n\n{path}")
|
|
|
|
def _backup_history(self) -> None:
|
|
import shutil
|
|
from tkinter import filedialog
|
|
src = self._history._path
|
|
if not src.exists():
|
|
self._msg("Backup", "No history file to back up yet.")
|
|
return
|
|
dest = filedialog.asksaveasfilename(
|
|
parent=self,
|
|
defaultextension=".json",
|
|
filetypes=[("JSON backup", "*.json"), ("All files", "*.*")],
|
|
initialfile="label_history_backup.json",
|
|
title="Save History Backup",
|
|
)
|
|
if dest:
|
|
shutil.copy2(src, dest)
|
|
self._msg("Backup", f"Backed up {len(self._entries)} records to:\n{dest}")
|
|
|
|
def _msg(self, title: str, text: str) -> None:
|
|
from tkinter import messagebox
|
|
messagebox.showinfo(title, text, parent=self)
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# API helpers
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
def _get_hosted_url(cfg, url_type: str) -> str:
|
|
"""Call GET /v1/url to get a single-use authenticated URL for a Stamps hosted page."""
|
|
import requests as _r
|
|
from .ui_shipping_tab import _get_stamps_token
|
|
|
|
token = _get_stamps_token(cfg)
|
|
base = ("https://api.testing.stampsendicia.com/sera"
|
|
if cfg.stamps_sandbox
|
|
else "https://api.stampsendicia.com/sera")
|
|
|
|
resp = _r.get(
|
|
f"{base}/v1/url",
|
|
params={"url_type": url_type},
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
timeout=10,
|
|
)
|
|
if not resp.ok:
|
|
raise RuntimeError(
|
|
f"Could not get Stamps.com URL (HTTP {resp.status_code}).")
|
|
|
|
url = resp.json().get("url", "")
|
|
if not url:
|
|
raise RuntimeError("Stamps.com returned an empty URL.")
|
|
return url
|