feat(intake): connected Google sheet via service account + skip-existing bulk stage

The intake sheet is read straight from the stored Google service account (discotag@…
inventory-sheets, migrated out of WowPlatter's wp_rmp_disc_google_service into the vault) — a
signed JWT (cryptography, no new dep) → Sheets API → private sheet, no public-share needed. Uses
the saved column_mappings (timestamp/release_id/media/sleeve/price/comment, 1-indexed). SKU from
the sheet's locale timestamp '2025-01-30, 14:01:02' → 20250130140102 (matches migrated stock).

Bulk import (both connected + paste-URL paths) now SKIPS SKUs already in inventory, so importing
the 34,752-row form log only stages the genuinely-new rows (live: 2,006 new / 32,746 skipped).
Preview shows new-of-total. Endpoints: GET/POST /admin/intake/gsheet/{preview,import}.

Also: .dockerignore now excludes disc_images/ (volume-mounted, was 916MB) — build context
918MB→1MB, deploy 13min(hung)→24s. The COPY . . was baking the image store into every image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-24 10:32:31 +10:00
parent 88172924eb
commit 25127b1b72
3 changed files with 169 additions and 23 deletions

View File

@ -5,6 +5,7 @@ import io
import json
import re
import secrets
import time
import httpx
from fastapi import APIRouter, Depends, Query
@ -314,7 +315,8 @@ def _detect(headers):
def _sku_from_ts(ts: str) -> str | None:
m = re.match(r"(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})", ts or "")
# separator between date and time is T, space, or ", " depending on the sheet's locale format
m = re.match(r"(\d{4})-(\d{2})-(\d{2})[T,\s]+(\d{2}):(\d{2}):(\d{2})", ts or "")
if m:
return "".join(m.groups()) # YYYYMMDDHHMMSS — matches WowPlatter / migrated stock
m = re.match(r"(\d{1,2})/(\d{1,2})/(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})", ts or "")
@ -324,20 +326,18 @@ def _sku_from_ts(ts: str) -> str | None:
return None
def _parse_sheet(text_csv):
rows = list(csv.reader(io.StringIO(text_csv)))
if not rows:
return [], {}
col = _detect(rows[0])
if "release_id" not in col:
return [], col
def _parse_rows(rows, col):
"""rows[0] is the header (skipped); col maps our field → 0-indexed column. Shared by the
public-CSV path (col from header detection) and the service-account path (col from the
stored Google column_mappings)."""
out = []
for r in rows[1:]:
def g(k):
i = col.get(k)
return r[i].strip() if i is not None and i < len(r) and r[i] else None
v = r[i] if i is not None and i < len(r) else None
return v.strip() if isinstance(v, str) and v.strip() else None
rid = g("release_id")
if not rid or not rid.isdigit():
if not rid or not str(rid).isdigit():
continue
price = g("price")
try:
@ -348,7 +348,17 @@ def _parse_sheet(text_csv):
out.append({"release_id": int(rid), "sku": sku, "price": price,
"media": g("media") or "VG+", "sleeve": g("sleeve"),
"notes": g("notes")})
return out, col
return out
def _parse_sheet(text_csv):
rows = list(csv.reader(io.StringIO(text_csv)))
if not rows:
return [], {}
col = _detect(rows[0])
if "release_id" not in col:
return [], col
return _parse_rows(rows, col), col
async def _fetch_sheet(url):
@ -377,27 +387,137 @@ async def sheet_preview(body: SheetIn, ident=Depends(require_token), db=Depends(
return {"ok": True, "total": len(rows), "columns": col, "sample": rows[:10]}
@router.post("/sheet/import")
async def sheet_import(body: SheetIn, ident=Depends(require_token), db=Depends(get_db)):
csv_text, err = await _fetch_sheet(body.url)
if err:
return {"ok": False, "error": err}
rows, _ = _parse_sheet(csv_text)
staged = 0
async def _bulk_stage(db, store_id, rows):
"""Stage sheet rows, SKIPPING any SKU already in inventory — so re-importing a 34k form-log
only touches genuinely-new stock (the bulk is already-migrated). Rows without a derived SKU
always stage (can't dedup them). Returns (staged, skipped)."""
skus = [r["sku"] for r in rows if r["sku"]]
existing = set()
if skus:
existing = {x[0] for x in (await db.execute(
text("SELECT sku FROM inventory WHERE sku = ANY(:s)"), {"s": skus}))}
staged = skipped = 0
for row in rows:
await _stage(db, ident["store_id"], row["release_id"], row["sku"], row["media"],
if row["sku"] and row["sku"] in existing:
skipped += 1
continue
await _stage(db, store_id, row["release_id"], row["sku"], row["media"],
row["sleeve"], row["price"], row["notes"])
staged += 1
if staged % 25 == 0:
await db.commit()
await asyncio.sleep(0.2) # ponytail: gentle on the Discogs API for cache-miss enrich
await db.commit()
return {"ok": True, "staged": staged}
return staged, skipped
@router.post("/sheet/import")
async def sheet_import(body: SheetIn, ident=Depends(require_token), db=Depends(get_db)):
csv_text, err = await _fetch_sheet(body.url)
if err:
return {"ok": False, "error": err}
rows, _ = _parse_sheet(csv_text)
staged, skipped = await _bulk_stage(db, ident["store_id"], rows)
return {"ok": True, "staged": staged, "skipped": skipped}
# --- 3b. the CONNECTED Google sheet via service account (private, no public share) ---
_gtoken = {}
async def _google_token(db):
"""Service-account access token for the Sheets API (signed JWT → OAuth token). Cached ~hour."""
if _gtoken.get("exp", 0) > time.time() + 60:
return _gtoken["tok"]
email = await vault.get_secret(db, "google_sa_email")
pem = await vault.get_secret(db, "google_sa_private_key")
if not (email and pem):
return None
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
now = int(time.time())
b64 = lambda d: base64.urlsafe_b64encode(json.dumps(d, separators=(",", ":")).encode()).rstrip(b"=")
seg = b64({"alg": "RS256", "typ": "JWT"}) + b"." + b64(
{"iss": email, "scope": "https://www.googleapis.com/auth/spreadsheets.readonly",
"aud": "https://oauth2.googleapis.com/token", "iat": now, "exp": now + 3600})
key = serialization.load_pem_private_key(pem.encode(), password=None)
sig = base64.urlsafe_b64encode(key.sign(seg, padding.PKCS1v15(), hashes.SHA256())).rstrip(b"=")
async with httpx.AsyncClient(timeout=20) as c:
r = await c.post("https://oauth2.googleapis.com/token", data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": (seg + b"." + sig).decode()})
if r.status_code != 200:
return None
j = r.json()
_gtoken.update(tok=j["access_token"], exp=now + j.get("expires_in", 3600))
return _gtoken["tok"]
# WowPlatter's stored column_mappings keys → our fields
_GCOL = {"release_id": "release_id", "media_condition": "media", "sleeve_condition": "sleeve",
"price": "price", "comment": "notes", "timestamp": "timestamp", "sku": "sku"}
async def _configured_sheet(db):
"""Read the connected sheet via the service account → (items, error). Uses the stored
column map (1-indexed) when present, else header auto-detect."""
tok = await _google_token(db)
if not tok:
return None, "Google service account not connected (save its creds in Connections)"
sid = await vault.get_secret(db, "google_sheet_id")
if not sid:
return None, "no google_sheet_id saved"
name = await vault.get_secret(db, "google_sheet_name") or "Sheet1"
raw = await vault.get_secret(db, "google_column_map")
colmap = {}
if raw:
for gk, idx in json.loads(raw).items():
ours = _GCOL.get(gk)
if ours and str(idx).isdigit():
colmap[ours] = int(idx) - 1
async with httpx.AsyncClient(timeout=30) as c:
r = await c.get(f"https://sheets.googleapis.com/v4/spreadsheets/{sid}/values/{name}",
headers={"Authorization": "Bearer " + tok})
if r.status_code != 200:
return None, f"Sheets API HTTP {r.status_code}"
values = r.json().get("values", [])
if not values:
return [], None
return _parse_rows(values, colmap or _detect(values[0])), None
async def _count_new(db, rows):
skus = [r["sku"] for r in rows if r["sku"]]
existing = set()
if skus:
existing = {x[0] for x in (await db.execute(
text("SELECT sku FROM inventory WHERE sku = ANY(:s)"), {"s": skus}))}
return sum(1 for r in rows if not (r["sku"] and r["sku"] in existing))
@router.get("/gsheet/preview")
async def gsheet_preview(ident=Depends(require_token), db=Depends(get_db)):
rows, err = await _configured_sheet(db)
if err:
return {"ok": False, "error": err}
return {"ok": True, "total": len(rows), "new": await _count_new(db, rows), "sample": rows[:10],
"sheet": await vault.get_secret(db, "google_sheet_name") or "Sheet1"}
@router.post("/gsheet/import")
async def gsheet_import(ident=Depends(require_token), db=Depends(get_db)):
rows, err = await _configured_sheet(db)
if err:
return {"ok": False, "error": err}
staged, skipped = await _bulk_stage(db, ident["store_id"], rows)
return {"ok": True, "staged": staged, "skipped": skipped}
def _selfcheck():
# SKU from a Google-Form timestamp (both ISO and en-AU slash formats) → YYYYMMDDHHMMSS
assert _sku_from_ts("2025-01-30T14:01:02.821Z") == "20250130140102"
assert _sku_from_ts("2025-01-30, 14:01:02") == "20250130140102" # Sheet1's locale format
assert _sku_from_ts("30/01/2025 14:01:02") == "20250130140102"
assert _sku_from_ts("") is None
# CSV export URL derivation (id + gid)

View File

@ -32,6 +32,11 @@ KNOWN = {
"square_location_id": "Square location ID (e.g. S5D3EN3YM2AN8)",
"square_environment": "Square environment — production or sandbox",
"bridge_key": "WordPress bridge shared secret — the WP plugin sends this to post back orders",
"google_sa_email": "Google service-account email (…@…iam.gserviceaccount.com) — reads the intake sheet",
"google_sa_private_key": "Google service-account private key (PEM) — signs the Sheets API token",
"google_sheet_id": "Intake Google Sheet ID (from its URL)",
"google_sheet_name": "Intake sheet/tab name (e.g. Sheet1)",
"google_column_map": "Intake sheet column map JSON (Google-Form positions)",
}

View File

@ -627,12 +627,33 @@ async function inkStageAll(id){
// --- 3. Google Sheet (public CSV) ---
function inkSheet(){
inkBody('<div class="card"><label>Google Sheet URL <span class="muted" style="font-weight:400">(Share → anyone with link can view)</span></label>'
inkBody('<div class="card"><div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">'
+'<div><strong>📋 Connected sheet</strong><div class="muted" style="font-size:12px">Your private intake sheet, read via the Google service account — uses the saved column map.</div></div>'
+'<button onclick="inkGPreview()">Preview connected sheet</button></div>'
+'<div id="ik_gsheet" style="margin-top:10px"></div></div>'
+'<div class="card"><label>…or paste a Google Sheet URL <span class="muted" style="font-weight:400">(Share → anyone with link can view)</span></label>'
+'<input id="ik_url" placeholder="https://docs.google.com/spreadsheets/d/…" style="width:100%">'
+'<div style="margin-top:10px"><button onclick="inkPreview()">Preview</button></div>'
+'<div class="muted" style="font-size:12px;margin-top:8px">Auto-detects columns: release id · media/sleeve condition · price · comment · timestamp (→ SKU). Your Google-Form responses sheet works as-is.</div></div>'
+'<div class="muted" style="font-size:12px;margin-top:8px">Auto-detects columns: release id · media/sleeve condition · price · comment · timestamp (→ SKU).</div></div>'
+'<div id="ik_sheet"></div>');
}
function inkSampleTable(rows){
return '<table style="width:100%;font-size:12px;border-collapse:collapse"><tr style="text-align:left;color:var(--mut)"><th>release_id</th><th>sku</th><th>media</th><th>sleeve</th><th>price</th><th>notes</th></tr>'
+rows.map(r=>`<tr style="border-top:1px solid var(--line)"><td>${r.release_id}</td><td>${escapeH(r.sku||'auto')}</td><td>${escapeH(r.media||'')}</td><td>${escapeH(r.sleeve||'')}</td><td>${r.price??''}</td><td>${escapeH(r.notes||'')}</td></tr>`).join('')+'</table>';
}
async function inkGPreview(){
$('#ik_gsheet').innerHTML='<div class="muted">reading connected sheet…</div>';
const d=await(await fetch('/admin/intake/gsheet/preview',{headers:hdr()})).json();
if(!d.ok){ $('#ik_gsheet').innerHTML='<span style="color:var(--warn)">'+escapeH(d.error||'failed')+'</span>'; return; }
$('#ik_gsheet').innerHTML=`<div style="margin-bottom:8px"><strong>${d.new}</strong> new of ${d.total} rows in <em>${escapeH(d.sheet)}</em> <span class="muted">(already-stocked SKUs are skipped)</span></div>`
+inkSampleTable(d.sample)
+'<div style="margin-top:12px"><button onclick="inkGImport()">Import '+d.new+' new rows</button> <span id="ik_gimp" class="muted"></span></div>';
}
async function inkGImport(){
const m=$('#ik_gimp'); m.textContent='importing… (enriching new titles, please wait)';
const d=await(await fetch('/admin/intake/gsheet/import',{method:'POST',headers:hdr()})).json();
m.innerHTML=d.ok?`<span style="color:var(--ok)">✓ staged ${d.staged} new</span>${d.skipped?` · ${d.skipped} already stocked`:''}`:'<span style="color:var(--warn)">'+escapeH(d.error||'failed')+'</span>';
}
async function inkPreview(){
const url=$('#ik_url').value.trim(); if(!url)return;
$('#ik_sheet').innerHTML='<div class="muted" style="padding:12px">reading sheet…</div>';
@ -648,7 +669,7 @@ async function inkPreview(){
async function inkImport(){
const url=$('#ik_sheet').dataset.url, m=$('#ik_imp'); m.textContent='importing… (enriching new titles, please wait)';
const d=await(await fetch('/admin/intake/sheet/import',{method:'POST',headers:hdr(),body:JSON.stringify({url})})).json();
m.innerHTML = d.ok ? `<span style="color:var(--ok)">✓ staged ${d.staged} rows</span>` : '<span style="color:var(--warn)">'+escapeH(d.error||'failed')+'</span>';
m.innerHTML = d.ok ? `<span style="color:var(--ok)">✓ staged ${d.staged} new</span>${d.skipped?` · ${d.skipped} already stocked`:''}` : '<span style="color:var(--warn)">'+escapeH(d.error||'failed')+'</span>';
}
// ---------- Crates ----------