Spaces:
Sleeping
Sleeping
Tolani Akinola
commited on
Commit
·
c257b79
1
Parent(s):
7fd33de
Update streamlit_app.py
Browse filesnew architecture with lots of upgrade to the app prior to moving building to cursor to build ui ux
- streamlit_app.py +517 -269
streamlit_app.py
CHANGED
|
@@ -1,308 +1,556 @@
|
|
| 1 |
-
# --- Care Count
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
import requests
|
| 12 |
import pandas as pd
|
| 13 |
import streamlit as st
|
| 14 |
from PIL import Image, ImageOps, ImageEnhance
|
| 15 |
from supabase import create_client, Client
|
| 16 |
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
InferenceClient = None # we'll fall back to raw HTTP
|
| 21 |
-
|
| 22 |
-
# ------------------------ Page ------------------------
|
| 23 |
-
st.set_page_config(page_title="Care Count Inventory", layout="centered")
|
| 24 |
-
st.title("📦 Care Count Inventory")
|
| 25 |
-
st.caption("OCR-assisted inventory logging with Supabase (Hugging Face Inference API)")
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
SUPABASE_URL = get_secret("SUPABASE_URL")
|
| 33 |
-
SUPABASE_KEY = get_secret("SUPABASE_KEY")
|
| 34 |
if not SUPABASE_URL or not SUPABASE_KEY:
|
| 35 |
-
st.error("Missing Supabase creds. Add SUPABASE_URL & SUPABASE_KEY
|
| 36 |
st.stop()
|
| 37 |
|
| 38 |
sb: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
-
|
| 45 |
-
def _to_png_bytes(img: Image.Image) -> bytes:
|
| 46 |
-
b = io.BytesIO()
|
| 47 |
-
img.save(b, format="PNG")
|
| 48 |
-
return b.getvalue()
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
img = img.convert("RGB")
|
| 53 |
-
# Resize longest side to ~1024px (helps OCR without huge payloads)
|
| 54 |
-
w, h = img.size
|
| 55 |
-
scale = 1024 / max(w, h) if max(w, h) > 1024 else 1.0
|
| 56 |
-
if scale < 1.0:
|
| 57 |
-
img = img.resize((int(w * scale), int(h * scale)))
|
| 58 |
-
img = ImageOps.autocontrast(img, cutoff=2)
|
| 59 |
-
img = ImageEnhance.Brightness(img).enhance(1.12)
|
| 60 |
-
img = ImageEnhance.Contrast(img).enhance(1.08)
|
| 61 |
-
sharp = ImageEnhance.Sharpness(img).enhance(1.2)
|
| 62 |
-
return sharp
|
| 63 |
-
|
| 64 |
-
# ------------------------ OCR helpers ------------------------
|
| 65 |
-
@st.cache_resource(show_spinner=False)
|
| 66 |
-
def _hf_client():
|
| 67 |
-
if InferenceClient is None:
|
| 68 |
-
return None
|
| 69 |
try:
|
| 70 |
-
|
| 71 |
except Exception:
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
""
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
try:
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
-
def
|
| 92 |
-
"""
|
| 93 |
-
Fallback: raw HTTP to Inference API.
|
| 94 |
-
TrOCR (image-to-text) accepts base64 in JSON for Inference API.
|
| 95 |
-
"""
|
| 96 |
try:
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
return "", f"HTTP {r.status_code}: {r.text[:200]}"
|
| 112 |
-
|
| 113 |
-
out = r.json()
|
| 114 |
-
# TrOCR usually returns {"generated_text": "..."} or plain string
|
| 115 |
-
if isinstance(out, dict):
|
| 116 |
-
text = out.get("generated_text", "")
|
| 117 |
-
elif isinstance(out, list) and out and isinstance(out[0], dict):
|
| 118 |
-
text = out[0].get("generated_text", "")
|
| 119 |
-
else:
|
| 120 |
-
text = out if isinstance(out, str) else ""
|
| 121 |
-
return (text or "").strip(), None
|
| 122 |
-
except Exception as e:
|
| 123 |
-
return "", f"http error: {e}"
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
text, err2 = _ocr_via_http(pre)
|
| 136 |
-
if err2:
|
| 137 |
-
errors.append(err2)
|
| 138 |
-
|
| 139 |
-
name = normalize_from_text(text)
|
| 140 |
-
return {
|
| 141 |
-
"name": name if name else "Unknown",
|
| 142 |
-
"ocr_text": text,
|
| 143 |
-
"model": OCR_MODEL,
|
| 144 |
-
"errors": [e for e in errors if e],
|
| 145 |
-
}
|
| 146 |
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
"peanut butter", "jam", "jelly", "soda", "juice", "tea", "coffee",
|
| 154 |
-
"tuna", "chicken", "beef", "noodles", "flour", "sugar", "salt",
|
| 155 |
-
"deodorant", "antiperspirant", "detergent", "sanitizer", "oil"
|
| 156 |
-
]
|
| 157 |
-
|
| 158 |
-
def normalize_from_text(text: str) -> str:
|
| 159 |
-
"""
|
| 160 |
-
Very lightweight: try to pull a 'brand' (first capitalized word)
|
| 161 |
-
and a 'type' (any known type in the text). Fallback to the first 4-5 words.
|
| 162 |
-
"""
|
| 163 |
-
t = (text or "").strip()
|
| 164 |
-
if not t:
|
| 165 |
-
return ""
|
| 166 |
-
|
| 167 |
-
# candidate brand: first word that looks like Title Case or ALLCAPS
|
| 168 |
-
tokens = [w.strip(",.:;!|/\\-()[]{}") for w in t.split()]
|
| 169 |
-
brand = ""
|
| 170 |
-
for w in tokens[:8]: # look near the front
|
| 171 |
-
if len(w) >= 2 and (w.isupper() or (w[0].isupper() and w[1:].islower())):
|
| 172 |
-
brand = w
|
| 173 |
-
break
|
| 174 |
-
|
| 175 |
-
# candidate type: first match from TYPE_WORDS
|
| 176 |
-
low = t.lower()
|
| 177 |
-
found_type = ""
|
| 178 |
-
for k in TYPE_WORDS:
|
| 179 |
-
if k in low:
|
| 180 |
-
found_type = k
|
| 181 |
-
break
|
| 182 |
-
|
| 183 |
-
parts = [p for p in [brand, found_type] if p]
|
| 184 |
-
if parts:
|
| 185 |
-
return " ".join(parts).strip()
|
| 186 |
-
|
| 187 |
-
# Fallback: compress to a few words
|
| 188 |
-
return " ".join(tokens[:5]).strip()
|
| 189 |
-
|
| 190 |
-
# ------------------------ Volunteer login ------------------------
|
| 191 |
-
st.subheader("👤 Volunteer")
|
| 192 |
-
|
| 193 |
-
with st.form("vol_form", clear_on_submit=True):
|
| 194 |
-
username = st.text_input("Username")
|
| 195 |
-
full_name = st.text_input("Full name")
|
| 196 |
-
submitted = st.form_submit_button("Add / Continue")
|
| 197 |
-
if submitted:
|
| 198 |
-
if not (username and full_name):
|
| 199 |
-
st.error("Please fill both fields.")
|
| 200 |
-
else:
|
| 201 |
-
try:
|
| 202 |
-
existing = sb.table("volunteers").select("full_name").execute().data or []
|
| 203 |
-
names = {v["full_name"].strip().lower() for v in existing}
|
| 204 |
-
if full_name.strip().lower() not in names:
|
| 205 |
-
sb.table("volunteers").insert({"username": username, "full_name": full_name}).execute()
|
| 206 |
-
st.session_state["volunteer"] = username
|
| 207 |
-
st.session_state["volunteer_name"] = full_name
|
| 208 |
-
st.success(f"Welcome, {full_name}!")
|
| 209 |
-
except Exception as e:
|
| 210 |
-
st.error(f"Volunteer add/check failed: {e}")
|
| 211 |
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
-
c1, c2 = st.columns(
|
| 220 |
with c1:
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
with c2:
|
| 223 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
|
| 225 |
img_file = cam or up
|
| 226 |
if img_file:
|
| 227 |
img = Image.open(img_file).convert("RGB")
|
| 228 |
st.image(img, use_container_width=True)
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
t0 = time.time()
|
| 232 |
-
result = run_ocr(img)
|
| 233 |
-
st.success(f"🧠 Suggested: **{result['name']}** · ⏱️ {time.time()-t0:.2f}s")
|
| 234 |
-
with st.expander("🔎 Debug (OCR)"):
|
| 235 |
-
st.json(result)
|
| 236 |
-
st.session_state["scanned_item_name"] = result["name"]
|
| 237 |
-
|
| 238 |
-
# ------------------------ Add inventory item ------------------------
|
| 239 |
-
st.subheader("📥 Add inventory item")
|
| 240 |
-
|
| 241 |
-
item_name = st.text_input("Item name", value=st.session_state.get("scanned_item_name", ""))
|
| 242 |
-
quantity = st.number_input("Quantity", min_value=1, step=1, value=1)
|
| 243 |
-
category = st.text_input("Category (optional)")
|
| 244 |
-
expiry = st.date_input("Expiry date (optional)")
|
| 245 |
-
|
| 246 |
-
if st.button("✅ Log item"):
|
| 247 |
-
if not item_name.strip():
|
| 248 |
-
st.warning("Enter an item name.")
|
| 249 |
-
else:
|
| 250 |
try:
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
try:
|
| 263 |
-
|
| 264 |
-
"
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
try:
|
| 281 |
-
|
| 282 |
-
|
| 283 |
except Exception:
|
| 284 |
try:
|
| 285 |
-
return sb.table(
|
|
|
|
| 286 |
except Exception:
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
if
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
if
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Care Count (Volunteers → Visits → Items) ---
|
| 2 |
+
# Delightful Laurier-themed UX + enterprise data hygiene
|
| 3 |
+
# - OTP email login (Supabase)
|
| 4 |
+
# - Shift tracking & idle/8pm auto sign-out
|
| 5 |
+
# - Human-friendly visit_code (with DB trigger or safe fallback)
|
| 6 |
+
# - VLM-assisted item name
|
| 7 |
+
# - RPC ingest with validation/quarantine (fallback to direct insert)
|
| 8 |
+
# - Volunteer Impact card (today + lifetime)
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import os, io, time, base64, re, uuid, json
|
| 13 |
+
from datetime import datetime, timedelta
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
import pytz
|
| 17 |
import requests
|
| 18 |
import pandas as pd
|
| 19 |
import streamlit as st
|
| 20 |
from PIL import Image, ImageOps, ImageEnhance
|
| 21 |
from supabase import create_client, Client
|
| 22 |
|
| 23 |
+
# ------------------------ App config ------------------------
|
| 24 |
+
os.environ.setdefault("HOME", "/tmp")
|
| 25 |
+
os.environ["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
+
TZ = os.getenv("APP_TZ", "America/Toronto")
|
| 28 |
+
CUTOFF_HOUR = int(os.getenv("CUTOFF_HOUR", "20")) # 8pm local
|
| 29 |
+
INACTIVITY_MIN = int(os.getenv("INACTIVITY_MIN", "30"))
|
| 30 |
+
|
| 31 |
+
def local_now() -> datetime:
|
| 32 |
+
return datetime.now(pytz.timezone(TZ))
|
| 33 |
+
|
| 34 |
+
st.set_page_config(page_title="Care Count", layout="centered")
|
| 35 |
+
st.markdown("""
|
| 36 |
+
<style>
|
| 37 |
+
/* Laurier theme vibes */
|
| 38 |
+
:root { --cc-purple:#6d28d9; --cc-gold:#fde047; --cc-bg:#0b1420; --cc-panel:#0f1a2a; --cc-border:#1d2a44; }
|
| 39 |
+
.block-container { padding-top: 2rem; }
|
| 40 |
+
h1, h2, .stMarkdown h1, .stMarkdown h2 { letter-spacing:.2px }
|
| 41 |
+
.cc-pill { display:inline-block; padding:4px 10px; border-radius:999px; background:var(--cc-gold); color:#111827; font-weight:700; font-size:12px; }
|
| 42 |
+
.cc-hint { background:#10233b; border:1px solid #1f3b5b; color:#e6e8f0; padding:12px 16px; border-radius:10px; }
|
| 43 |
+
.cc-hero { background:#0f1a2a; border:1px solid #1f2a44; padding:16px 18px; border-radius:14px; }
|
| 44 |
+
.cc-btn-primary button { background:var(--cc-purple)!important; color:#fff!important; border:0!important; }
|
| 45 |
+
.cc-danger button { background:#7f1d1d!important; color:#fff!important; }
|
| 46 |
+
.status-card { display:flex; gap:16px; flex-wrap:wrap; }
|
| 47 |
+
.card { background:#0f1a2a; border:1px solid #1f2a44; border-radius:14px; padding:14px 16px; min-width:200px; }
|
| 48 |
+
.card h4 { margin:0 0 6px 0; font-size:0.95rem; color:#cbd5e1 }
|
| 49 |
+
.card .big { font-size:1.6rem; font-weight:700; color:#e5e7eb }
|
| 50 |
+
.small { color:#9aa3b2; font-size:12px }
|
| 51 |
+
</style>
|
| 52 |
+
""", unsafe_allow_html=True)
|
| 53 |
+
|
| 54 |
+
st.title("💜💛 Care Count")
|
| 55 |
+
st.caption("Thanks for showing up for the community today. Snap items, keep visits tidy, and help us understand the impact of your time.")
|
| 56 |
+
|
| 57 |
+
# ------------------------ Secrets & client ------------------------
|
| 58 |
+
def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
|
| 59 |
+
v = os.getenv(name)
|
| 60 |
+
if v: return v
|
| 61 |
+
try: return st.secrets.get(name, default)
|
| 62 |
+
except Exception: return default
|
| 63 |
|
| 64 |
SUPABASE_URL = get_secret("SUPABASE_URL")
|
| 65 |
+
SUPABASE_KEY = get_secret("SUPABASE_KEY") # anon key (RLS should be ON)
|
| 66 |
if not SUPABASE_URL or not SUPABASE_KEY:
|
| 67 |
+
st.error("Missing Supabase creds. Add SUPABASE_URL & SUPABASE_KEY.")
|
| 68 |
st.stop()
|
| 69 |
|
| 70 |
sb: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 71 |
|
| 72 |
+
# VLM provider
|
| 73 |
+
PROVIDER = (get_secret("PROVIDER", "nebius") or "nebius").strip().lower()
|
| 74 |
+
GEMMA_MODEL = (get_secret("GEMMA_MODEL", "google/gemma-3-27b-it") or "google/gemma-3-27b-it").strip()
|
| 75 |
+
NEBIUS_API_KEY = get_secret("NEBIUS_API_KEY")
|
| 76 |
+
NEBIUS_BASE_URL= get_secret("NEBIUS_BASE_URL", "https://api.nebius.ai/v1")
|
| 77 |
+
FEATH_API_KEY = get_secret("FEATHERLESS_API_KEY")
|
| 78 |
+
FEATH_BASE_URL = get_secret("FEATHERLESS_BASE_URL", "https://api.featherless.ai/v1")
|
| 79 |
|
| 80 |
+
st.caption(f"Provider: `{PROVIDER}` · Model: `{GEMMA_MODEL}` · TZ: `{TZ}`")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
+
# ------------------------ Light events/audit (non-blocking) ------------------------
|
| 83 |
+
def log_event(action: str, actor: Optional[str], details: dict):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
try:
|
| 85 |
+
sb.table("events").insert({"actor_email": actor, "action": action, "details": details}).execute()
|
| 86 |
except Exception:
|
| 87 |
+
pass
|
| 88 |
+
|
| 89 |
+
# ------------------------ Auth (Email OTP) ------------------------
|
| 90 |
+
def auth_block() -> tuple[bool, Optional[str]]:
|
| 91 |
+
if "auth_email" not in st.session_state:
|
| 92 |
+
st.session_state["auth_email"] = None
|
| 93 |
+
if "user_email" in st.session_state:
|
| 94 |
+
return True, st.session_state["user_email"]
|
| 95 |
+
|
| 96 |
+
st.subheader("Sign in")
|
| 97 |
+
with st.form("otp_request", clear_on_submit=False, border=False):
|
| 98 |
+
email = st.text_input("Email", value=st.session_state.get("auth_email") or "", autocomplete="email")
|
| 99 |
+
send = st.form_submit_button("Send login code")
|
| 100 |
+
if send:
|
| 101 |
+
if not email or "@" not in email:
|
| 102 |
+
st.error("Please enter a valid email.")
|
| 103 |
+
else:
|
| 104 |
+
try:
|
| 105 |
+
sb.auth.sign_in_with_otp({"email": email, "shouldCreateUser": True})
|
| 106 |
+
st.session_state["auth_email"] = email
|
| 107 |
+
st.success("We emailed you a one-time code. Enter it to continue.")
|
| 108 |
+
except Exception as e:
|
| 109 |
+
st.error(f"Could not send code: {e}")
|
| 110 |
+
|
| 111 |
+
if st.session_state.get("auth_email"):
|
| 112 |
+
with st.form("otp_verify", clear_on_submit=True, border=False):
|
| 113 |
+
code = st.text_input("Enter 6-digit code", max_chars=6)
|
| 114 |
+
ok = st.form_submit_button("Verify & start shift")
|
| 115 |
+
if ok:
|
| 116 |
+
try:
|
| 117 |
+
res = sb.auth.verify_otp({"email": st.session_state["auth_email"], "token": code, "type": "email"})
|
| 118 |
+
if res and res.user:
|
| 119 |
+
email = st.session_state["auth_email"]
|
| 120 |
+
# Idempotent upsert for volunteer & start shift
|
| 121 |
+
sb.table("volunteers").upsert({
|
| 122 |
+
"email": email,
|
| 123 |
+
"last_login_at": datetime.utcnow().isoformat(),
|
| 124 |
+
"shift_started_at": datetime.utcnow().isoformat(),
|
| 125 |
+
"shift_ended_at": None
|
| 126 |
+
}, on_conflict="email").execute()
|
| 127 |
+
st.session_state["user_email"] = email
|
| 128 |
+
st.session_state["shift_started"] = True
|
| 129 |
+
st.session_state["last_activity_at"] = local_now()
|
| 130 |
+
log_event("login", email, {"method":"otp"})
|
| 131 |
+
st.toast("Welcome back! Shift started. 💜", icon="✅")
|
| 132 |
+
return True, email
|
| 133 |
+
except Exception as e:
|
| 134 |
+
st.error(f"Verification failed: {e}")
|
| 135 |
+
return False, None
|
| 136 |
+
|
| 137 |
+
def end_shift(email: str, reason: str):
|
| 138 |
try:
|
| 139 |
+
sb.table("volunteers").update({"shift_ended_at": datetime.utcnow().isoformat()}).eq("email", email).execute()
|
| 140 |
+
log_event("shift_end", email, {"reason": reason})
|
| 141 |
+
except Exception:
|
| 142 |
+
pass
|
| 143 |
+
|
| 144 |
+
def guard_cutoff_and_idle(email: str):
|
| 145 |
+
now = local_now()
|
| 146 |
+
last = st.session_state.get("last_activity_at")
|
| 147 |
+
if last and (now - last).total_seconds() > INACTIVITY_MIN * 60:
|
| 148 |
+
end_shift(email, "inactivity")
|
| 149 |
+
st.session_state.clear()
|
| 150 |
+
st.info("You were logged out due to inactivity. Thank you for volunteering today!")
|
| 151 |
+
st.stop()
|
| 152 |
+
st.session_state["last_activity_at"] = now
|
| 153 |
+
|
| 154 |
+
cutoff = now.replace(hour=CUTOFF_HOUR, minute=0, second=0, microsecond=0)
|
| 155 |
+
if now >= cutoff:
|
| 156 |
+
end_shift(email, "cutoff_8pm")
|
| 157 |
+
st.session_state.clear()
|
| 158 |
+
st.info("We close the day at 8pm. Your shift has been ended. Thank you so much!")
|
| 159 |
+
st.stop()
|
| 160 |
+
|
| 161 |
+
signed_in, user_email = auth_block()
|
| 162 |
+
if not signed_in:
|
| 163 |
+
st.stop()
|
| 164 |
+
guard_cutoff_and_idle(user_email)
|
| 165 |
+
|
| 166 |
+
# Welcome banner (visceral, kind)
|
| 167 |
+
st.markdown(
|
| 168 |
+
f"""<div class="cc-hero">
|
| 169 |
+
<div class="small">Welcome,</div>
|
| 170 |
+
<div style="font-weight:800;font-size:1.15rem"> {user_email}</div>
|
| 171 |
+
<div class="small">Thank you for showing up for the community today. 💜💛</div>
|
| 172 |
+
</div>""",
|
| 173 |
+
unsafe_allow_html=True
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
# Sign-out button (prominent)
|
| 177 |
+
c_signout = st.columns([1,1,6])[1]
|
| 178 |
+
with c_signout:
|
| 179 |
+
st.markdown('<div class="cc-btn-primary">', unsafe_allow_html=True)
|
| 180 |
+
if st.button("🔒 Sign out"):
|
| 181 |
+
end_shift(user_email, "manual")
|
| 182 |
+
st.session_state.clear()
|
| 183 |
+
st.success("Signed out. See you next time!")
|
| 184 |
+
st.stop()
|
| 185 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 186 |
+
|
| 187 |
+
# ------------------------ Volunteer Impact card ------------------------
|
| 188 |
+
def fetch_volunteer_row(email: str) -> dict | None:
|
| 189 |
+
try:
|
| 190 |
+
return sb.table("volunteers").select("*").eq("email", email).single().execute().data
|
| 191 |
+
except Exception:
|
| 192 |
+
return None
|
| 193 |
|
| 194 |
+
def items_today(email: str) -> int:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
try:
|
| 196 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 197 |
+
# try partitioned table first
|
| 198 |
+
data = sb.table("visit_items_p").select("id,timestamp,volunteer") \
|
| 199 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 200 |
+
.eq("volunteer", email).execute().data
|
| 201 |
+
return len(data or [])
|
| 202 |
+
except Exception:
|
| 203 |
+
try:
|
| 204 |
+
data = sb.table("visit_items").select("id,timestamp,volunteer") \
|
| 205 |
+
.gte("timestamp", f"{day} 00:00:00").lte("timestamp", f"{day} 23:59:59") \
|
| 206 |
+
.eq("volunteer", email).execute().data
|
| 207 |
+
return len(data or [])
|
| 208 |
+
except Exception:
|
| 209 |
+
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
+
vrow = fetch_volunteer_row(user_email) or {}
|
| 212 |
+
shift_started_at = vrow.get("shift_started_at")
|
| 213 |
+
lifetime_hours = vrow.get("total_hours") # may not exist yet; handled below
|
| 214 |
+
mins_active = 0
|
| 215 |
+
try:
|
| 216 |
+
if shift_started_at:
|
| 217 |
+
t0 = datetime.fromisoformat(str(shift_started_at).replace("Z","+00:00"))
|
| 218 |
+
mins_active = max(0, int((datetime.utcnow() - t0).total_seconds() // 60))
|
| 219 |
+
except Exception:
|
| 220 |
+
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
+
st.markdown('<div class="status-card">', unsafe_allow_html=True)
|
| 223 |
+
st.markdown(f'<div class="card"><h4>Shift active</h4><div class="big">{mins_active} min</div><div class="small">since you signed in</div></div>', unsafe_allow_html=True)
|
| 224 |
+
st.markdown(f'<div class="card"><h4>Items you logged today</h4><div class="big">{items_today(user_email)}</div></div>', unsafe_allow_html=True)
|
| 225 |
+
if isinstance(lifetime_hours, (int, float)):
|
| 226 |
+
st.markdown(f'<div class="card"><h4>Lifetime hours</h4><div class="big">{round(float(lifetime_hours),1)}</div></div>', unsafe_allow_html=True)
|
| 227 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
| 229 |
+
# ------------------------ Image helpers ------------------------
|
| 230 |
+
def _to_png_bytes(img: Image.Image) -> bytes:
|
| 231 |
+
b = io.BytesIO(); img.save(b, format="PNG"); return b.getvalue()
|
| 232 |
|
| 233 |
+
def preprocess_for_label(img: Image.Image) -> Image.Image:
|
| 234 |
+
img = img.convert("RGB")
|
| 235 |
+
w, h = img.size
|
| 236 |
+
scale = 1024 / max(w, h) if max(w, h) > 1024 else 1.0
|
| 237 |
+
if scale < 1.0: img = img.resize((int(w * scale), int(h * scale)))
|
| 238 |
+
img = ImageOps.autocontrast(img, cutoff=2)
|
| 239 |
+
img = ImageEnhance.Brightness(img).enhance(1.06)
|
| 240 |
+
img = ImageEnhance.Contrast(img).enhance(1.05)
|
| 241 |
+
img = ImageEnhance.Sharpness(img).enhance(1.15)
|
| 242 |
+
return img
|
| 243 |
+
|
| 244 |
+
# ------------------------ VLM client ------------------------
|
| 245 |
+
SYSTEM_HINT = "You label item being held in the image for a food bank. Return ONLY the item name."
|
| 246 |
+
|
| 247 |
+
def _openai_style_chat(base_url: str, api_key: str, model_id: str, img_bytes: bytes) -> str:
|
| 248 |
+
b64 = base64.b64encode(img_bytes).decode("utf-8")
|
| 249 |
+
url = f"{base_url.rstrip('/')}/chat/completions"
|
| 250 |
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
| 251 |
+
payload = {
|
| 252 |
+
"model": model_id,
|
| 253 |
+
"temperature": 0,
|
| 254 |
+
"messages": [
|
| 255 |
+
{"role": "system", "content": SYSTEM_HINT},
|
| 256 |
+
{"role": "user", "content": [
|
| 257 |
+
{"type": "text", "text": "What is the name of the item in the picture? Return only the item name."},
|
| 258 |
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
| 259 |
+
]}
|
| 260 |
+
]
|
| 261 |
+
}
|
| 262 |
+
r = requests.post(url, json=payload, headers=headers, timeout=90)
|
| 263 |
+
if r.status_code != 200:
|
| 264 |
+
raise RuntimeError(f"LLM HTTP {r.status_code}: {r.text[:200]}")
|
| 265 |
+
data = r.json()
|
| 266 |
+
return (data["choices"][0]["message"]["content"] or "").strip()
|
| 267 |
+
|
| 268 |
+
def gemma_item_name(img_bytes: bytes) -> str:
|
| 269 |
+
if PROVIDER == "nebius":
|
| 270 |
+
if not NEBIUS_API_KEY: raise RuntimeError("NEBIUS_API_KEY missing")
|
| 271 |
+
return _openai_style_chat(NEBIUS_BASE_URL, NEBIUS_API_KEY, GEMMA_MODEL, img_bytes)
|
| 272 |
+
elif PROVIDER == "featherless":
|
| 273 |
+
if not FEATH_API_KEY: raise RuntimeError("FEATHERLESS_API_KEY missing")
|
| 274 |
+
return _openai_style_chat(FEATH_BASE_URL, FEATH_API_KEY, GEMMA_MODEL, img_bytes)
|
| 275 |
+
else:
|
| 276 |
+
raise RuntimeError(f"Unknown PROVIDER: {PROVIDER}")
|
| 277 |
+
|
| 278 |
+
# ------------------------ Normalization ------------------------
|
| 279 |
+
BRANDS = {
|
| 280 |
+
"whiskas","tetley","kellogg's","kelloggs","campbell's","campbells","heinz",
|
| 281 |
+
"nestle","kraft","general mills","cheerios","oreo","oreos","pringles","lays","doritos",
|
| 282 |
+
"ice river","green bottle","great value","wheat thins","vegetable thins","raid"
|
| 283 |
+
}
|
| 284 |
+
GENERIC_TYPES = {
|
| 285 |
+
"water","toothpaste","deodorant","antiperspirant","soap","shampoo","conditioner",
|
| 286 |
+
"lotion","tea","coffee","cereal","pasta","rice","beans","sauce","salsa","cleaner",
|
| 287 |
+
"peanut butter","jam","jelly","tuna","chicken","beef","flour","sugar","salt","oil",
|
| 288 |
+
"crackers","cookies","soup","insect killer","spray"
|
| 289 |
+
}
|
| 290 |
+
def normalize_item_name(s: str) -> str:
|
| 291 |
+
s = (s or "").strip()
|
| 292 |
+
if not s: return ""
|
| 293 |
+
low = re.sub(r"[®™]", "", s.lower())
|
| 294 |
+
for b in BRANDS: low = low.replace(b, "")
|
| 295 |
+
chosen = None
|
| 296 |
+
for t in GENERIC_TYPES:
|
| 297 |
+
if t in low: chosen = t; break
|
| 298 |
+
cleaned = " ".join(low.split())
|
| 299 |
+
return (chosen or cleaned.title())[:120]
|
| 300 |
+
|
| 301 |
+
def clean_text(v: Optional[str], maxlen: int = 120) -> Optional[str]:
|
| 302 |
+
if not v: return None
|
| 303 |
+
v = re.sub(r"\s+", " ", v).strip()
|
| 304 |
+
return v[:maxlen] if v else None
|
| 305 |
+
|
| 306 |
+
# ------------------------ Visit flow ------------------------
|
| 307 |
+
st.subheader("🪪 Anonymous Student Visit")
|
| 308 |
+
|
| 309 |
+
active_visit = st.session_state.get("active_visit")
|
| 310 |
+
|
| 311 |
+
def fallback_visit_code() -> str:
|
| 312 |
+
"""Readable visit_code if DB trigger isn't present."""
|
| 313 |
+
try:
|
| 314 |
+
day = local_now().strftime("%Y-%m-%d")
|
| 315 |
+
todays = sb.table("visits").select("id,started_at").gte("started_at", f"{day} 00:00:00") \
|
| 316 |
+
.lte("started_at", f"{day} 23:59:59").execute().data or []
|
| 317 |
+
seq = len(todays) + 1
|
| 318 |
+
except Exception:
|
| 319 |
+
seq = int(time.time()) % 1000
|
| 320 |
+
return f"V-{seq}-{local_now().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6]}"
|
| 321 |
|
| 322 |
+
c1, c2, c3 = st.columns(3)
|
| 323 |
with c1:
|
| 324 |
+
st.markdown('<div class="cc-btn-primary">', unsafe_allow_html=True)
|
| 325 |
+
if not active_visit and st.button("Start Visit"):
|
| 326 |
+
try:
|
| 327 |
+
payload = {
|
| 328 |
+
"visit_code": fallback_visit_code(), # DB trigger will override if present
|
| 329 |
+
"started_at": datetime.utcnow().isoformat(),
|
| 330 |
+
"ended_at": None,
|
| 331 |
+
"created_by": user_email
|
| 332 |
+
}
|
| 333 |
+
v = sb.table("visits").insert(payload).execute().data[0]
|
| 334 |
+
# If trigger generated code, keep that
|
| 335 |
+
if not v.get("visit_code"):
|
| 336 |
+
v["visit_code"] = payload["visit_code"]
|
| 337 |
+
st.session_state["active_visit"] = v
|
| 338 |
+
st.success(f"Visit #{v['id']} started · code: **{v['visit_code']}**")
|
| 339 |
+
log_event("visit_start", user_email, {"visit_id": v["id"], "visit_code": v["visit_code"]})
|
| 340 |
+
except Exception as e:
|
| 341 |
+
st.error(f"Could not start visit: {e}")
|
| 342 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 343 |
+
|
| 344 |
with c2:
|
| 345 |
+
if active_visit and st.button("End Visit (Checkout)"):
|
| 346 |
+
try:
|
| 347 |
+
sb.table("visits").update({"ended_at": datetime.utcnow().isoformat()}) \
|
| 348 |
+
.eq("id", active_visit["id"]).execute()
|
| 349 |
+
st.success("Visit checked out. Ready for the next student.")
|
| 350 |
+
log_event("visit_end", user_email, {"visit_id": active_visit["id"]})
|
| 351 |
+
st.session_state.pop("active_visit", None)
|
| 352 |
+
except Exception as e:
|
| 353 |
+
st.error(f"Could not end visit: {e}")
|
| 354 |
+
|
| 355 |
+
with c3:
|
| 356 |
+
if st.session_state.get("active_visit"):
|
| 357 |
+
v = st.session_state["active_visit"]
|
| 358 |
+
st.caption(f"Active visit_id: {v['id']} · code: {v.get('visit_code','')}")
|
| 359 |
+
else:
|
| 360 |
+
st.caption("No active visit.")
|
| 361 |
+
|
| 362 |
+
# ------------------------ Identify item ------------------------
|
| 363 |
+
st.subheader("📸 Identify item from image")
|
| 364 |
+
|
| 365 |
+
c4, c5 = st.columns(2)
|
| 366 |
+
with c4: cam = st.camera_input("Use your phone or webcam")
|
| 367 |
+
with c5: up = st.file_uploader("…or upload an image", type=["png","jpg","jpeg"])
|
| 368 |
|
| 369 |
img_file = cam or up
|
| 370 |
if img_file:
|
| 371 |
img = Image.open(img_file).convert("RGB")
|
| 372 |
st.image(img, use_container_width=True)
|
| 373 |
+
if st.button("🔍 Ask model for item name"):
|
| 374 |
+
t0, raw = time.time(), ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
try:
|
| 376 |
+
pre = preprocess_for_label(img); raw = gemma_item_name(_to_png_bytes(pre))
|
| 377 |
+
except Exception as e:
|
| 378 |
+
st.error(f"Provider error: {e}"); log_event("vlm_error", user_email, {"error": str(e)})
|
| 379 |
+
norm = normalize_item_name(raw)
|
| 380 |
+
if raw: st.success(f"🧠 Model: **{raw}**")
|
| 381 |
+
st.info(f"✨ Normalized: **{norm or '(unknown)'}** · ⏱️ {time.time()-t0:.2f}s")
|
| 382 |
+
st.session_state["scanned_item_name"] = norm or raw or ""
|
| 383 |
+
st.session_state["last_activity_at"] = local_now()
|
| 384 |
+
|
| 385 |
+
# ------------------------ Log item ------------------------
|
| 386 |
+
st.subheader("📬 Log item to current visit")
|
| 387 |
+
|
| 388 |
+
item_name = st.text_input("Item name", value=st.session_state.get("scanned_item_name",""))
|
| 389 |
+
quantity = st.number_input("Quantity", min_value=1, max_value=9999, step=1, value=1)
|
| 390 |
+
category = st.text_input("Category (optional)")
|
| 391 |
+
unit = st.text_input("Unit (optional, e.g., 500 mL, 1 L, 250 g)")
|
| 392 |
+
barcode = st.text_input("Barcode (optional)")
|
| 393 |
+
|
| 394 |
+
def deterministic_ingest_id(v_id: int, email: str, name: str, qty: int, ts_iso: str) -> str:
|
| 395 |
+
key = f"visit_items::{v_id}::{email}::{name}::{qty}::{ts_iso}"
|
| 396 |
+
return str(uuid.uuid5(uuid.NAMESPACE_URL, key))
|
| 397 |
+
|
| 398 |
+
def try_rpc_ingest(email: str, v_id: int, name: str, qty: int,
|
| 399 |
+
category: Optional[str], unit: Optional[str],
|
| 400 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> tuple[bool,str]:
|
| 401 |
+
"""
|
| 402 |
+
Call the RPC safe_ingest_visit_item if it exists.
|
| 403 |
+
Returns (ok, msg). If function is missing, raises to trigger fallback.
|
| 404 |
+
"""
|
| 405 |
+
try:
|
| 406 |
+
res = sb.rpc("safe_ingest_visit_item", {
|
| 407 |
+
"p_email": email,
|
| 408 |
+
"p_visit_id": v_id,
|
| 409 |
+
"p_item_name": name,
|
| 410 |
+
"p_qty": qty,
|
| 411 |
+
"p_category": category,
|
| 412 |
+
"p_unit": unit,
|
| 413 |
+
"p_barcode": barcode,
|
| 414 |
+
"p_ts": ts_iso,
|
| 415 |
+
"p_ingest_id": ingest_id
|
| 416 |
+
}).execute()
|
| 417 |
+
# RPC returns a setof (ok boolean, msg text) — normalize
|
| 418 |
+
rows = res.data if isinstance(res.data, list) else []
|
| 419 |
+
if rows:
|
| 420 |
+
r0 = rows[0]
|
| 421 |
+
ok = bool(r0.get("ok", False))
|
| 422 |
+
msg = str(r0.get("msg", ""))
|
| 423 |
+
return ok, msg or ("ok" if ok else "failed")
|
| 424 |
+
# Some PostgREST setups return {} — treat as ok
|
| 425 |
+
return True, "ok"
|
| 426 |
+
except Exception as e:
|
| 427 |
+
# If function missing (42883) or not exposed, re-raise to use fallback
|
| 428 |
+
raise e
|
| 429 |
+
|
| 430 |
+
def fallback_direct_insert(email: str, v_id: int, name: str, qty: int,
|
| 431 |
+
category: Optional[str], unit: Optional[str],
|
| 432 |
+
barcode: Optional[str], ts_iso: str, ingest_id: str) -> None:
|
| 433 |
+
"""Write into visit_items_p if present, else visit_items (keeps app working)."""
|
| 434 |
+
payload = {
|
| 435 |
+
"visit_id": v_id,
|
| 436 |
+
"timestamp": ts_iso,
|
| 437 |
+
"volunteer": email,
|
| 438 |
+
"item_name": name,
|
| 439 |
+
"category": category,
|
| 440 |
+
"unit": unit,
|
| 441 |
+
"qty": qty,
|
| 442 |
+
"barcode": barcode,
|
| 443 |
+
"weather_type": None,
|
| 444 |
+
"temp_c": None,
|
| 445 |
+
"ingest_id": ingest_id
|
| 446 |
+
}
|
| 447 |
+
try:
|
| 448 |
+
sb.table("visit_items_p").insert(payload).execute()
|
| 449 |
+
except Exception:
|
| 450 |
+
# legacy table fallback
|
| 451 |
+
payload.pop("ingest_id", None)
|
| 452 |
+
sb.table("visit_items").insert(payload).execute()
|
| 453 |
+
|
| 454 |
+
st.markdown('<div class="cc-btn-primary">', unsafe_allow_html=True)
|
| 455 |
+
save_disabled = not st.session_state.get("active_visit")
|
| 456 |
+
if st.button("✅ Save Item to Visit", disabled=save_disabled):
|
| 457 |
+
v = st.session_state.get("active_visit")
|
| 458 |
+
if not v:
|
| 459 |
+
st.warning("Start a visit first, then save items.")
|
| 460 |
+
else:
|
| 461 |
+
name_clean = clean_text(item_name, 120)
|
| 462 |
+
if not name_clean:
|
| 463 |
+
st.warning("Item name is required.")
|
| 464 |
+
else:
|
| 465 |
+
ts_iso = datetime.utcnow().isoformat()
|
| 466 |
+
ingest_id = deterministic_ingest_id(int(v["id"]), user_email, name_clean, int(quantity), ts_iso)
|
| 467 |
try:
|
| 468 |
+
ok, msg = try_rpc_ingest(
|
| 469 |
+
email=user_email, v_id=int(v["id"]), name=name_clean, qty=int(quantity),
|
| 470 |
+
category=clean_text(category, 80), unit=clean_text(unit, 40),
|
| 471 |
+
barcode=clean_text(barcode, 64), ts_iso=ts_iso, ingest_id=ingest_id
|
| 472 |
+
)
|
| 473 |
+
if ok:
|
| 474 |
+
st.success("Item logged ✅")
|
| 475 |
+
else:
|
| 476 |
+
st.warning(f"Ingest said: {msg}. (Will try direct insert.)")
|
| 477 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 478 |
+
clean_text(category,80), clean_text(unit,40),
|
| 479 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 480 |
+
st.success("Item logged ✅ (fallback)")
|
| 481 |
+
except Exception as e:
|
| 482 |
+
# RPC missing or failed → direct insert
|
| 483 |
+
try:
|
| 484 |
+
fallback_direct_insert(user_email, int(v["id"]), name_clean, int(quantity),
|
| 485 |
+
clean_text(category,80), clean_text(unit,40),
|
| 486 |
+
clean_text(barcode,64), ts_iso, ingest_id)
|
| 487 |
+
st.success("Item logged ✅ (fallback)")
|
| 488 |
+
except Exception as e2:
|
| 489 |
+
st.error(f"Ingest failed: {e2}")
|
| 490 |
+
st.session_state["last_activity_at"] = local_now()
|
| 491 |
+
st.session_state["scanned_item_name"] = name_clean
|
| 492 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 493 |
+
|
| 494 |
+
# ------------------------ Visit items view + delete ------------------------
|
| 495 |
+
def load_items_for_visit(visit_id: int) -> list[dict]:
|
| 496 |
+
# Prefer partitioned table
|
| 497 |
try:
|
| 498 |
+
return sb.table("visit_items_p").select("*").eq("visit_id", visit_id) \
|
| 499 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 500 |
except Exception:
|
| 501 |
try:
|
| 502 |
+
return sb.table("visit_items").select("*").eq("visit_id", visit_id) \
|
| 503 |
+
.order("timestamp", desc=True).limit(500).execute().data or []
|
| 504 |
except Exception:
|
| 505 |
+
return []
|
| 506 |
+
|
| 507 |
+
def delete_item(table: str, item_id: int):
|
| 508 |
+
try:
|
| 509 |
+
sb.table(table).delete().eq("id", item_id).execute()
|
| 510 |
+
except Exception as e:
|
| 511 |
+
raise e
|
| 512 |
+
|
| 513 |
+
if st.session_state.get("active_visit"):
|
| 514 |
+
st.subheader("🧾 Items in this visit")
|
| 515 |
+
rows = load_items_for_visit(int(st.session_state["active_visit"]["id"]))
|
| 516 |
+
if rows:
|
| 517 |
+
df = pd.DataFrame(rows)
|
| 518 |
+
st.dataframe(df, use_container_width=True)
|
| 519 |
+
with st.expander("🗑️ Delete an item (if mis-logged)"):
|
| 520 |
+
ids = [r["id"] for r in rows if "id" in r]
|
| 521 |
+
choice = st.selectbox("Choose item id", ids) if ids else None
|
| 522 |
+
st.markdown('<div class="cc-danger">', unsafe_allow_html=True)
|
| 523 |
+
if st.button("Delete selected", disabled=not bool(ids)):
|
| 524 |
+
if choice is None:
|
| 525 |
+
st.warning("Pick an id.")
|
| 526 |
+
else:
|
| 527 |
+
# try both tables safely
|
| 528 |
+
try:
|
| 529 |
+
delete_item("visit_items_p", int(choice))
|
| 530 |
+
except Exception:
|
| 531 |
+
delete_item("visit_items", int(choice))
|
| 532 |
+
st.success("Deleted.")
|
| 533 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 534 |
+
else:
|
| 535 |
+
st.caption("No items logged for this visit yet.")
|
| 536 |
+
|
| 537 |
+
# ------------------------ Analytics (light) ------------------------
|
| 538 |
+
st.subheader("📈 Today")
|
| 539 |
+
try:
|
| 540 |
+
daily = sb.table("v_daily_activity").select("*").execute().data
|
| 541 |
+
today_str = local_now().strftime("%Y-%m-%d")
|
| 542 |
+
today = next((r for r in daily if str(r.get("day",""))[:10]==today_str), None)
|
| 543 |
+
visits = int(today["visits"]) if today and "visits" in today else 0
|
| 544 |
+
items = int(today["items"]) if today and "items" in today else 0
|
| 545 |
+
st.markdown(f"**Visits:** {visits} · **Items:** {items}")
|
| 546 |
+
except Exception:
|
| 547 |
+
st.caption("Analytics view unavailable yet.")
|
| 548 |
+
|
| 549 |
+
# ------------------------ Gentle reminder ------------------------
|
| 550 |
+
st.markdown(
|
| 551 |
+
"""<div class="cc-hint">
|
| 552 |
+
💡 When you’re done, please <b>End Visit</b> and <b>Sign out</b>.<br>
|
| 553 |
+
We’ll auto-sign you out after inactivity or at 8pm local time. 💜
|
| 554 |
+
</div>""",
|
| 555 |
+
unsafe_allow_html=True
|
| 556 |
+
)
|