"""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)