Files
2026-07-06 00:24:30 -05:00

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