Initial commit
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user