Back office: Postgres source-of-truth + read dashboard
Stand up the DB-first CRM backbone (architecture pivot: Postgres is the source of truth, Google Sheets becomes a one-way downstream mirror). - backoffice/ stack: smb-db (Postgres 16) + smb-crm (Flask/waitress service). - Schema mirrors the six Sheet tabs (clients, leads, projects, activity_log, bookings, invoices) with typed columns + updated_at triggers. - Service-account Sheets client (PyJWT) for the one-time import + future mirror. - import_from_sheets.py: idempotent seed of Postgres from the live Sheets. - Read dashboard (Leads & Clients tables) at onboard.mivanchenko.de/crm, behind the existing Caddy basic-auth; JSON API reads straight from Postgres. Deployed + verified: import seeded DB, dashboard/API live, no-auth blocked, onboarding form unaffected. Add/edit/delete + DB->Sheets sync + n8n ingest swap are the next steps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
"""Minimal Google Sheets v4 client using a service-account JWT.
|
||||
|
||||
Used for (a) the one-time import of existing Sheet rows into Postgres and
|
||||
(b) the one-way DB -> Sheets mirror sync. Postgres stays the source of truth;
|
||||
nothing here treats the Sheet as authoritative except the explicit import.
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
|
||||
SHEETS_API = "https://sheets.googleapis.com/v4/spreadsheets"
|
||||
TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
SCOPE = "https://www.googleapis.com/auth/spreadsheets"
|
||||
|
||||
# gid map for the live workbook (tab name -> gid), used when clearing/sizing.
|
||||
TAB_GIDS = {
|
||||
"Clients": 470934735,
|
||||
"Leads": 571719114,
|
||||
"Bookings": 946084008,
|
||||
"Projects": 619112786,
|
||||
"Invoices": 413377229,
|
||||
"Activity Log": 170940481,
|
||||
}
|
||||
|
||||
|
||||
class Sheets:
|
||||
def __init__(self, sa_json_path, sheet_id):
|
||||
with open(sa_json_path) as f:
|
||||
self.sa = json.load(f)
|
||||
self.sheet_id = sheet_id
|
||||
self._tok = None
|
||||
self._exp = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _token(self):
|
||||
with self._lock:
|
||||
now = int(time.time())
|
||||
if self._tok and now < self._exp - 60:
|
||||
return self._tok
|
||||
claim = {
|
||||
"iss": self.sa["client_email"],
|
||||
"scope": SCOPE,
|
||||
"aud": TOKEN_URL,
|
||||
"iat": now,
|
||||
"exp": now + 3600,
|
||||
}
|
||||
assertion = jwt.encode(claim, self.sa["private_key"], algorithm="RS256")
|
||||
r = requests.post(TOKEN_URL, data={
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
"assertion": assertion,
|
||||
}, timeout=30)
|
||||
r.raise_for_status()
|
||||
self._tok = r.json()["access_token"]
|
||||
self._exp = now + 3600
|
||||
return self._tok
|
||||
|
||||
def _headers(self):
|
||||
return {"Authorization": "Bearer " + self._token()}
|
||||
|
||||
def read(self, a1_range):
|
||||
"""Return raw 2D list of cell values for an A1 range (e.g. "Leads!A1:Z")."""
|
||||
url = f"{SHEETS_API}/{self.sheet_id}/values/{requests.utils.quote(a1_range)}"
|
||||
r = requests.get(url, headers=self._headers(), timeout=30)
|
||||
r.raise_for_status()
|
||||
return r.json().get("values", [])
|
||||
|
||||
def read_records(self, tab):
|
||||
"""Read a whole tab as a list of dicts keyed by the header row."""
|
||||
rows = self.read(f"{tab}!A1:Z")
|
||||
if not rows:
|
||||
return []
|
||||
header = rows[0]
|
||||
out = []
|
||||
for raw in rows[1:]:
|
||||
if not any(c.strip() for c in raw):
|
||||
continue
|
||||
rec = {header[i]: (raw[i] if i < len(raw) else "") for i in range(len(header))}
|
||||
out.append(rec)
|
||||
return out
|
||||
|
||||
def overwrite(self, tab, header, rows):
|
||||
"""Replace a tab's contents with header + rows (the DB->Sheets mirror).
|
||||
|
||||
Clears the existing value range, then writes the new grid starting at A1.
|
||||
Postgres is the source of truth; this projects it onto the Sheet.
|
||||
"""
|
||||
# clear current values (keeps formatting / the tab itself)
|
||||
clr = f"{SHEETS_API}/{self.sheet_id}/values/{requests.utils.quote(tab + '!A1:Z')}:clear"
|
||||
requests.post(clr, headers=self._headers(), timeout=30).raise_for_status()
|
||||
body = {"values": [header] + rows}
|
||||
url = (f"{SHEETS_API}/{self.sheet_id}/values/"
|
||||
f"{requests.utils.quote(tab + '!A1')}?valueInputOption=RAW")
|
||||
r = requests.put(url, headers=self._headers(), json=body, timeout=60)
|
||||
r.raise_for_status()
|
||||
return len(rows)
|
||||
Reference in New Issue
Block a user