18e346d67b
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>
99 lines
3.5 KiB
Python
99 lines
3.5 KiB
Python
"""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)
|