Files
WebChecker/views/bid_tracker_view.py
T
2026-04-24 07:15:48 -04:00

762 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
views/bid_tracker_view.py — Bid / Opportunity Follow-Up Tracker
Any logged-in user can:
- View all tracked bids and their latest status
- Add a new bid (URL + metadata)
- Post updates on any bid visible to everyone
- Edit or delete their own bids (admins can edit/delete any bid)
Layout:
Left pane : filterable bid list with status badges
Right pane : detail panel — metadata + scrollable update timeline
"""
import os
import logging
import tkinter as tk
from tkinter import ttk
import webbrowser
from utils.ui_helpers import (
COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL,
show_error, show_info, confirm_delete,
)
logger = logging.getLogger("bid_tracker_view")
# Status display config: (label, colour_key)
STATUS_META = {
"open": ("🟢 Open", "success"),
"monitoring": ("🔵 Monitoring", "accent"),
"awarded": ("🏆 Awarded", "warning"),
"no_bid": ("⛔ No Bid", "danger"),
"cancelled": ("🚫 Cancelled", "text_dim"),
}
class BidTrackerView(ttk.Frame):
def __init__(self, parent, current_user: dict):
super().__init__(parent)
self.current_user = current_user
self._is_admin = current_user.get("role") == "admin"
self._selected_bid_id = None
self._build_ui()
self._load_bids()
# ── Layout ────────────────────────────────────────────────────────────────
def _build_ui(self):
C = COLOURS
# Page header
hdr = ttk.Frame(self)
hdr.pack(fill="x", pady=(0, 10))
ttk.Label(hdr, text="📌 Bid Tracker",
style="Heading.TLabel").pack(side="left")
ttk.Label(hdr,
text="Track potential opportunities and follow up on updates.",
style="Dim.TLabel").pack(side="left", padx=(12, 0))
# Toolbar
toolbar = tk.Frame(self, bg=C["surface"], pady=8, padx=12)
toolbar.pack(fill="x", pady=(0, 8))
tk.Button(
toolbar, text=" Add Bid",
command=self._open_add_dialog,
bg=C["accent"], fg=C["white"],
activebackground=C["accent_hover"], activeforeground=C["white"],
relief="flat", font=FONT_BOLD, cursor="hand2",
padx=12, pady=5,
).pack(side="left")
tk.Button(
toolbar, text="↻ Refresh",
command=self._load_bids,
bg=C["surface2"], fg=C["text"],
activebackground=C["accent"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=5,
).pack(side="left", padx=(6, 0))
# Status filter
tk.Label(toolbar, text="Filter:",
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL).pack(side="left", padx=(16, 4))
self._filter_var = tk.StringVar(value="All")
filter_cb = ttk.Combobox(
toolbar, textvariable=self._filter_var,
values=["All"] + [v[0] for v in STATUS_META.values()],
state="readonly", width=14,
)
filter_cb.pack(side="left")
filter_cb.bind("<<ComboboxSelected>>", lambda _: self._load_bids())
# Search
tk.Label(toolbar, text="Search:",
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL).pack(side="left", padx=(16, 4))
self._search_var = tk.StringVar()
self._search_var.trace_add("write", lambda *_: self._load_bids())
tk.Entry(
toolbar, textvariable=self._search_var,
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT, width=22,
).pack(side="left", ipady=4)
# Two-pane split
pane = tk.PanedWindow(self, orient="horizontal",
bg=C["border"], sashwidth=4, sashrelief="flat")
pane.pack(fill="both", expand=True)
left = tk.Frame(pane, bg=C["bg"])
pane.add(left, minsize=300, width=360)
self._build_bid_list(left)
right = tk.Frame(pane, bg=C["bg"])
pane.add(right, minsize=380)
self._build_detail_panel(right)
def _build_bid_list(self, parent):
C = COLOURS
# Treeview
cols = ("Title", "Status", "Due Date", "Updates")
self._tree = ttk.Treeview(
parent, columns=cols, show="headings",
selectmode="browse",
)
col_widths = {"Title": 160, "Status": 90, "Due Date": 80, "Updates": 60}
for col in cols:
self._tree.heading(col, text=col)
self._tree.column(col, width=col_widths[col],
anchor="w" if col == "Title" else "center")
# Colour tags per status
self._tree.tag_configure("open", foreground=C["success"])
self._tree.tag_configure("monitoring", foreground=C["accent"])
self._tree.tag_configure("awarded", foreground=C["warning"])
self._tree.tag_configure("no_bid", foreground=C["danger"])
self._tree.tag_configure("cancelled", foreground=C["text_dim"])
vsb = ttk.Scrollbar(parent, orient="vertical",
command=self._tree.yview)
self._tree.configure(yscrollcommand=vsb.set)
vsb.pack(side="right", fill="y")
self._tree.pack(side="left", fill="both", expand=True)
self._tree.bind("<<TreeviewSelect>>", self._on_bid_select)
self._tree.bind("<Double-1>", lambda _: self._open_edit_dialog())
def _build_detail_panel(self, parent):
C = COLOURS
# Action toolbar (right pane)
act = tk.Frame(parent, bg=C["surface"], pady=8, padx=12)
act.pack(fill="x")
self._edit_btn = tk.Button(
act, text="✎ Edit Bid",
command=self._open_edit_dialog,
bg=C["surface2"], fg=C["text"],
activebackground=C["accent"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=4,
)
self._edit_btn.pack(side="left", padx=(0, 4))
self._del_btn = tk.Button(
act, text="✕ Delete",
command=self._delete_bid,
bg=C["surface2"], fg=C["danger"],
activebackground=C["danger"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=4,
)
self._del_btn.pack(side="left")
self._open_btn = tk.Button(
act, text="↗ Open URL",
command=self._open_url,
bg=C["surface2"], fg=C["accent"],
activebackground=C["accent"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=4,
)
self._open_btn.pack(side="right")
# Metadata card
self._meta_frame = tk.Frame(parent, bg=C["surface"], padx=14, pady=10)
self._meta_frame.pack(fill="x", pady=(0, 4))
self._meta_title = tk.Label(
self._meta_frame, text="Select a bid to view details.",
bg=C["surface"], fg=C["text"],
font=FONT_BOLD, wraplength=480, justify="left",
)
self._meta_title.pack(anchor="w")
self._meta_sub = tk.Label(
self._meta_frame, text="",
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL, wraplength=480, justify="left",
)
self._meta_sub.pack(anchor="w", pady=(2, 0))
self._meta_notes = tk.Label(
self._meta_frame, text="",
bg=C["surface"], fg=C["text"],
font=FONT_SMALL, wraplength=480, justify="left",
)
self._meta_notes.pack(anchor="w", pady=(4, 0))
# ── Updates section ───────────────────────────────────────────────
upd_hdr = tk.Frame(parent, bg=C["bg"], pady=4, padx=12)
upd_hdr.pack(fill="x")
tk.Label(upd_hdr, text="📝 Updates",
bg=C["bg"], fg=C["text"],
font=FONT_BOLD).pack(side="left")
# Post update entry
compose = tk.Frame(parent, bg=C["surface"], padx=12, pady=8)
compose.pack(fill="x")
self._update_txt = tk.Text(
compose, height=3, wrap="word",
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT,
)
self._update_txt.pack(fill="x", pady=(0, 4))
# Placeholder behaviour
self._update_txt.insert("1.0", "Write an update, e.g. 'Amendment 1 issued — due date extended to...'")
self._update_txt.config(fg=C["text_dim"])
self._update_txt.bind("<FocusIn>", self._on_update_focus_in)
self._update_txt.bind("<FocusOut>", self._on_update_focus_out)
tk.Button(
compose, text="📤 Post Update",
command=self._post_update,
bg=C["accent"], fg=C["white"],
activebackground=C["accent_hover"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=10, pady=4,
).pack(anchor="e")
# Scrollable update timeline
timeline_frame = tk.Frame(parent, bg=C["bg"])
timeline_frame.pack(fill="both", expand=True, padx=4, pady=(4, 0))
vsb = ttk.Scrollbar(timeline_frame, orient="vertical")
vsb.pack(side="right", fill="y")
self._update_canvas = tk.Canvas(
timeline_frame, bg=C["bg"],
highlightthickness=0,
yscrollcommand=vsb.set,
)
self._update_canvas.pack(side="left", fill="both", expand=True)
vsb.config(command=self._update_canvas.yview)
self._update_inner = tk.Frame(self._update_canvas, bg=C["bg"])
self._canvas_window = self._update_canvas.create_window(
(0, 0), window=self._update_inner, anchor="nw")
self._update_inner.bind("<Configure>", self._on_inner_configure)
self._update_canvas.bind("<Configure>", self._on_canvas_configure)
# Mousewheel scrolling
self._update_canvas.bind("<Enter>",
lambda _: self._update_canvas.bind_all(
"<MouseWheel>", self._on_mousewheel))
self._update_canvas.bind("<Leave>",
lambda _: self._update_canvas.unbind_all("<MouseWheel>"))
self._set_detail_buttons_state("disabled")
# ── Canvas / scroll helpers ───────────────────────────────────────────────
def _on_inner_configure(self, event=None):
self._update_canvas.configure(
scrollregion=self._update_canvas.bbox("all"))
def _on_canvas_configure(self, event):
self._update_canvas.itemconfig(
self._canvas_window, width=event.width)
def _on_mousewheel(self, event):
self._update_canvas.yview_scroll(
int(-1 * (event.delta / 120)), "units")
# ── Placeholder helpers ───────────────────────────────────────────────────
def _on_update_focus_in(self, event=None):
C = COLOURS
if self._update_txt.cget("fg") == C["text_dim"]:
self._update_txt.delete("1.0", "end")
self._update_txt.config(fg=C["text"])
def _on_update_focus_out(self, event=None):
C = COLOURS
if not self._update_txt.get("1.0", "end-1c").strip():
self._update_txt.delete("1.0", "end")
self._update_txt.insert("1.0",
"Write an update, e.g. 'Amendment 1 issued — due date extended to...'")
self._update_txt.config(fg=C["text_dim"])
def _get_update_text(self) -> str:
C = COLOURS
txt = self._update_txt.get("1.0", "end-1c").strip()
if self._update_txt.cget("fg") == C["text_dim"]:
return ""
return txt
# ── Data loading ─────────────────────────────────────────────────────────
def _load_bids(self, *_):
from models import get_all_bids
# Map display label back to DB key for filter
filter_label = self._filter_var.get()
status_key = ""
for k, (label, _) in STATUS_META.items():
if label == filter_label:
status_key = k
break
search = self._search_var.get().lower().strip()
self._tree.delete(*self._tree.get_children())
try:
bids = get_all_bids(status_filter=status_key)
except Exception as e:
show_error(f"Failed to load bids:\n{e}")
return
for bid in bids:
if search and search not in (bid.get("title") or "").lower() \
and search not in (bid.get("source") or "").lower() \
and search not in (bid.get("solicitation_number") or "").lower():
continue
status = bid.get("status", "open")
label, _ = STATUS_META.get(status, (status, "text"))
due = str(bid.get("due_date") or "—")
upd_count = bid.get("update_count", 0)
self._tree.insert(
"", "end", iid=str(bid["id"]), tags=(status,),
values=(bid["title"], label, due, upd_count),
)
# Restore selection if still present
if self._selected_bid_id and str(self._selected_bid_id) in \
self._tree.get_children():
self._tree.selection_set(str(self._selected_bid_id))
self._tree.see(str(self._selected_bid_id))
else:
self._selected_bid_id = None
self._clear_detail()
def _on_bid_select(self, event=None):
sel = self._tree.selection()
if not sel:
return
self._selected_bid_id = int(sel[0])
self._load_detail(self._selected_bid_id)
self._set_detail_buttons_state("normal")
def _load_detail(self, bid_id: int):
from models import get_bid, get_bid_updates
C = COLOURS
bid = get_bid(bid_id)
if not bid:
return
status = bid.get("status", "open")
label, colour_key = STATUS_META.get(status, (status, "text"))
due = str(bid.get("due_date") or "Not specified")
sol = bid.get("solicitation_number") or "—"
src = bid.get("source") or "—"
added_by = bid.get("added_by_username") or "—"
added_at = str(bid.get("created_at") or "")[:16]
self._meta_title.config(text=bid["title"])
self._meta_sub.config(
text=(f"{label} | Due: {due} | Sol #: {sol} | "
f"Source: {src} | Added by: {added_by} ({added_at})")
)
notes = bid.get("notes") or ""
self._meta_notes.config(
text=f"Notes: {notes}" if notes else "",
)
# Hide edit/delete for non-owners unless admin
is_owner = bid.get("added_by") == self.current_user["id"]
can_edit = self._is_admin or is_owner
state = "normal" if can_edit else "disabled"
self._edit_btn.config(state=state)
self._del_btn.config(state=state)
# Render update timeline
for widget in self._update_inner.winfo_children():
widget.destroy()
updates = get_bid_updates(bid_id)
if not updates:
tk.Label(
self._update_inner,
text="No updates yet. Be the first to post one.",
bg=C["bg"], fg=C["text_dim"],
font=FONT_SMALL,
).pack(anchor="w", padx=8, pady=8)
else:
for upd in updates:
self._render_update_card(upd)
self._update_canvas.yview_moveto(0)
def _render_update_card(self, upd: dict):
C = COLOURS
card = tk.Frame(self._update_inner, bg=C["surface"],
padx=10, pady=8)
card.pack(fill="x", padx=4, pady=(0, 4))
# Header row
hdr = tk.Frame(card, bg=C["surface"])
hdr.pack(fill="x")
poster = upd.get("posted_by_full_name") or upd.get("posted_by_username") or "Unknown"
dt_str = str(upd.get("created_at") or "")[:16]
tk.Label(
hdr, text=f"👤 {poster}",
bg=C["surface"], fg=C["text"],
font=FONT_BOLD,
).pack(side="left")
tk.Label(
hdr, text=dt_str,
bg=C["surface"], fg=C["text_dim"],
font=FONT_SMALL,
).pack(side="left", padx=(8, 0))
# Delete button — own update or admin
is_own = upd.get("user_id") == self.current_user["id"]
if is_own or self._is_admin:
tk.Button(
hdr, text="✕",
command=lambda uid=upd["id"]: self._delete_update(uid),
bg=C["surface"], fg=C["text_dim"],
activebackground=C["danger"], activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
).pack(side="right")
# Content
tk.Label(
card, text=upd.get("content", ""),
bg=C["surface"], fg=C["text"],
font=FONT, wraplength=460, justify="left", anchor="w",
).pack(fill="x", pady=(4, 0))
def _clear_detail(self):
self._meta_title.config(text="Select a bid to view details.")
self._meta_sub.config(text="")
self._meta_notes.config(text="")
for widget in self._update_inner.winfo_children():
widget.destroy()
self._set_detail_buttons_state("disabled")
def _set_detail_buttons_state(self, state: str):
self._edit_btn.config(state=state)
self._del_btn.config(state=state)
self._open_btn.config(state=state)
# ── Actions ───────────────────────────────────────────────────────────────
def _open_url(self):
if not self._selected_bid_id:
return
from models import get_bid
bid = get_bid(self._selected_bid_id)
if bid and bid.get("url"):
try:
webbrowser.open(bid["url"])
except Exception as e:
show_error(f"Could not open URL:\n{e}")
def _open_add_dialog(self):
BidDialog(self, self.current_user,
bid_data=None, on_save=self._load_bids)
def _open_edit_dialog(self):
if not self._selected_bid_id:
show_error("Please select a bid to edit.")
return
from models import get_bid
bid = get_bid(self._selected_bid_id)
if not bid:
show_error("Bid not found.")
return
# Enforce ownership
is_owner = bid.get("added_by") == self.current_user["id"]
if not self._is_admin and not is_owner:
show_error("You can only edit bids you added.")
return
BidDialog(self, self.current_user,
bid_data=bid, on_save=self._after_edit)
def _after_edit(self):
"""Reload list and refresh detail panel after an edit."""
self._load_bids()
if self._selected_bid_id:
self._load_detail(self._selected_bid_id)
def _delete_bid(self):
if not self._selected_bid_id:
return
from models import get_bid, delete_bid
bid = get_bid(self._selected_bid_id)
if not bid:
return
is_owner = bid.get("added_by") == self.current_user["id"]
if not self._is_admin and not is_owner:
show_error("You can only delete bids you added.")
return
if confirm_delete(bid["title"]):
try:
delete_bid(self.current_user["id"], self._selected_bid_id)
self._selected_bid_id = None
self._load_bids()
self._clear_detail()
show_info("Bid deleted.")
except Exception as e:
show_error(f"Delete failed:\n{e}")
def _post_update(self):
if not self._selected_bid_id:
show_error("Please select a bid first.")
return
content = self._get_update_text()
if not content:
show_error("Please write an update before posting.")
return
try:
from models import add_bid_update
add_bid_update(self.current_user["id"],
self._selected_bid_id, content)
# Clear the text box and reset placeholder
self._update_txt.delete("1.0", "end")
self._on_update_focus_out()
# Refresh the update timeline
self._load_detail(self._selected_bid_id)
# Refresh update count in the tree
self._load_bids()
if self._selected_bid_id and \
str(self._selected_bid_id) in self._tree.get_children():
self._tree.selection_set(str(self._selected_bid_id))
except Exception as e:
show_error(f"Failed to post update:\n{e}")
def _delete_update(self, update_id: int):
from tkinter import messagebox
if not messagebox.askyesno(
"Delete Update",
"Are you sure you want to delete this update?"):
return
try:
from models import delete_bid_update
delete_bid_update(self.current_user["id"], update_id)
self._load_detail(self._selected_bid_id)
self._load_bids()
if self._selected_bid_id and \
str(self._selected_bid_id) in self._tree.get_children():
self._tree.selection_set(str(self._selected_bid_id))
except Exception as e:
show_error(f"Failed to delete update:\n{e}")
# ── Add / Edit Bid Dialog ─────────────────────────────────────────────────────
class BidDialog(tk.Toplevel):
"""Modal dialog for adding or editing a bid/opportunity."""
def __init__(self, parent, current_user: dict,
bid_data, on_save):
super().__init__(parent)
self.current_user = current_user
self.bid_data = bid_data
self.on_save = on_save
self.is_edit = bid_data is not None
self.title("Edit Bid" if self.is_edit else "Add Bid")
self.configure(bg=COLOURS["bg"])
self.resizable(False, False)
self.grab_set()
self._build_ui()
self._centre()
def _centre(self):
self.update_idletasks()
w, h = 560, 520
x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}")
def _build_ui(self):
C = COLOURS
ttk.Label(self,
text="Edit Opportunity" if self.is_edit else "Add Opportunity",
style="Heading.TLabel").pack(anchor="w", padx=24, pady=(20, 4))
form = ttk.Frame(self)
form.pack(fill="x", padx=24, pady=8)
form.columnconfigure(1, weight=1)
def lbl(text, row):
ttk.Label(form, text=text).grid(
row=row, column=0, sticky="w", padx=(0, 12), pady=6)
def entry(row, show=None):
var = tk.StringVar()
e = tk.Entry(form, textvariable=var,
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT, show=show or "")
e.grid(row=row, column=1, sticky="ew", ipady=5, pady=6)
return var
# Row 0 — Title
lbl("Title *", 0)
self._title_var = entry(0)
# Row 1 — URL
lbl("URL *", 1)
self._url_var = entry(1)
# Row 2 — Source
lbl("Source", 2)
self._source_var = entry(2)
# Row 3 — Solicitation #
lbl("Solicitation #", 3)
self._sol_var = entry(3)
# Row 4 — Status
lbl("Status", 4)
from models import BID_STATUSES
self._status_var = tk.StringVar(value="open")
status_frame = tk.Frame(form, bg=C["bg"])
status_frame.grid(row=4, column=1, sticky="w", pady=6)
for s in BID_STATUSES:
label, _ = STATUS_META.get(s, (s, "text"))
tk.Radiobutton(
status_frame, text=label,
variable=self._status_var, value=s,
bg=C["bg"], fg=C["text"],
activebackground=C["bg"], activeforeground=C["accent"],
selectcolor=C["surface2"],
font=FONT_SMALL, cursor="hand2",
).pack(side="left", padx=(0, 8))
# Row 5 — Due date
lbl("Due Date", 5)
self._due_var = tk.StringVar()
tk.Entry(form, textvariable=self._due_var,
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT, width=14).grid(
row=5, column=1, sticky="w", ipady=5, pady=6)
ttk.Label(form, text="YYYY-MM-DD",
style="Dim.TLabel").grid(
row=5, column=1, sticky="e", pady=6)
# Row 6 — Notes
lbl("Notes", 6)
notes_frame = tk.Frame(form, bg=C["surface2"])
notes_frame.grid(row=6, column=1, sticky="ew", pady=6)
notes_vsb = ttk.Scrollbar(notes_frame, orient="vertical")
notes_vsb.pack(side="right", fill="y")
self._notes_txt = tk.Text(
notes_frame, height=4, wrap="word",
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT,
yscrollcommand=notes_vsb.set,
)
self._notes_txt.pack(fill="both", expand=True, padx=4, pady=4)
notes_vsb.config(command=self._notes_txt.yview)
# Pre-populate when editing
if self.is_edit:
d = self.bid_data
self._title_var.set(d.get("title") or "")
self._url_var.set(d.get("url") or "")
self._source_var.set(d.get("source") or "")
self._sol_var.set(d.get("solicitation_number") or "")
self._status_var.set(d.get("status") or "open")
self._due_var.set(str(d.get("due_date") or ""))
self._notes_txt.insert("1.0", d.get("notes") or "")
ttk.Separator(self, orient="horizontal").pack(fill="x", padx=24, pady=10)
btn_frame = ttk.Frame(self)
btn_frame.pack(fill="x", padx=24, pady=(0, 20))
ttk.Button(btn_frame, text="Save",
command=self._save).pack(side="right", padx=(6, 0))
ttk.Button(btn_frame, text="Cancel", style="Ghost.TButton",
command=self.destroy).pack(side="right")
def _save(self):
title = self._title_var.get().strip()
url = self._url_var.get().strip()
source = self._source_var.get().strip()
sol = self._sol_var.get().strip()
status = self._status_var.get()
due_raw = self._due_var.get().strip()
notes = self._notes_txt.get("1.0", "end-1c").strip()
if not title:
show_error("Title is required.")
return
if not url:
show_error("URL is required.")
return
# Normalise URL
if "://" not in url:
url = "https://" + url
# Validate due date if provided
due_date = None
if due_raw:
import datetime
try:
due_date = datetime.date.fromisoformat(due_raw)
except ValueError:
show_error("Due date must be in YYYY-MM-DD format, e.g. 2025-12-31")
return
try:
if self.is_edit:
from models import update_bid
update_bid(
self.current_user["id"], self.bid_data["id"],
title, url, source, sol, status, due_date, notes,
)
show_info("Bid updated successfully.")
else:
from models import create_bid
create_bid(
self.current_user["id"],
title, url, source, sol, status, due_date, notes,
)
show_info("Bid added successfully.")
self.on_save()
self.destroy()
except Exception as e:
show_error(f"Save failed:\n{e}")