Remove Google Sheets mirror entirely (#13)
Test backoffice (smb-crm) / test (push) Has been cancelled
Test backoffice (smb-crm) / test (push) Has been cancelled
Postgres is now the sole source of truth: delete sheets.py and import_from_sheets.py, strip mirror_entity/mirror_async/_mirror_worker and POST /api/sync from app.py, drop the tab/mirror keys from db.py's TABLES. Re-point n8n/renewal-reminder.json at the CRM's own HTTP API (GET /api/clients, POST /api/activity_log) instead of the Sheets nodes, and drop SHEET_ID/GOOGLE_SA_JSON from deploy env/compose and requests from requirements.txt (PyJWT stays — still used by booking_api.py). Updates docs/README/playbooks accordingly and closes the old #5 (atomic mirror) as moot. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+1
-92
@@ -1,16 +1,12 @@
|
||||
"""smb-crm back-office service.
|
||||
|
||||
Postgres is the source of truth. This serves the operator dashboard + a small
|
||||
JSON API (read now; add/edit/delete and the DB->Sheets mirror layered on next).
|
||||
Postgres is the source of truth for the operator dashboard + a small JSON API.
|
||||
Browser access is gated by Caddy basic-auth on onboard.mivanchenko.de; the
|
||||
machine-to-machine ingest path (n8n) is gated by the CRM_API_TOKEN header.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import queue
|
||||
import threading
|
||||
import traceback
|
||||
from datetime import datetime, date, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -20,7 +16,6 @@ from waitress import serve
|
||||
import booking_db as bdb
|
||||
import db
|
||||
import owner_mail
|
||||
from sheets import Sheets
|
||||
from booking_api import bp as booking_bp
|
||||
from public_booking import bp as public_booking_bp
|
||||
from manage_booking import bp as manage_booking_bp
|
||||
@@ -47,69 +42,6 @@ CRM_TOKEN = os.environ.get("CRM_API_TOKEN", "")
|
||||
# the basic-auth header, so the feed is gated by this query token instead).
|
||||
ICS_TOKEN = os.environ.get("ICS_TOKEN", "")
|
||||
|
||||
# DB -> Sheets one-way mirror. Postgres is the source of truth; the Sheet is a
|
||||
# best-effort projection. A mirror failure never fails the DB write.
|
||||
SH = None
|
||||
try:
|
||||
SH = Sheets(os.environ["GOOGLE_SA_JSON"], os.environ["SHEET_ID"])
|
||||
except Exception: # noqa: BLE001
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def _cell(v):
|
||||
if v is None:
|
||||
return ""
|
||||
if isinstance(v, bool):
|
||||
return "TRUE" if v else "FALSE"
|
||||
if isinstance(v, datetime):
|
||||
return v.strftime("%Y-%m-%d %H:%M")
|
||||
if isinstance(v, date):
|
||||
return v.strftime("%Y-%m-%d")
|
||||
if isinstance(v, Decimal):
|
||||
f = float(v)
|
||||
return str(int(f)) if f == int(f) else str(f)
|
||||
s = str(v)
|
||||
return ("'" + s) if s[:1] in "=+-@" else s # neutralise formula injection
|
||||
|
||||
|
||||
def mirror_entity(entity):
|
||||
spec = db.TABLES[entity]
|
||||
if SH is None or spec.get("mirror") is False:
|
||||
return 0
|
||||
cols = spec["cols"]
|
||||
order = LIST_ORDER.get(entity, spec["pk"])
|
||||
with db.connect() as conn, conn.cursor() as cur:
|
||||
cur.execute(f"SELECT {', '.join(cols)} FROM {entity} ORDER BY {order}")
|
||||
rows = cur.fetchall()
|
||||
grid = [[_cell(r[c]) for c in cols] for r in rows]
|
||||
SH.overwrite(spec["tab"], cols, grid)
|
||||
return len(grid)
|
||||
|
||||
|
||||
# Serialize all mirror writes through one worker so concurrent mutations can't
|
||||
# race on the shared Sheets client / token.
|
||||
_mirror_q = queue.Queue()
|
||||
|
||||
|
||||
def _mirror_worker():
|
||||
while True:
|
||||
entity = _mirror_q.get()
|
||||
try:
|
||||
mirror_entity(entity)
|
||||
except Exception: # noqa: BLE001
|
||||
print(f"[mirror] {entity} sync failed:", flush=True)
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
_mirror_q.task_done()
|
||||
|
||||
|
||||
threading.Thread(target=_mirror_worker, daemon=True).start()
|
||||
|
||||
|
||||
def mirror_async(entity):
|
||||
if SH is not None and db.TABLES[entity].get("mirror") is not False:
|
||||
_mirror_q.put(entity)
|
||||
|
||||
# Static dashboard, with the CRM token injected so the (basic-auth-gated)
|
||||
# operator page can call the token-protected mutation endpoints.
|
||||
with open(os.path.join(os.path.dirname(__file__), "static", "index.html")) as _f:
|
||||
@@ -235,8 +167,6 @@ def add_entity(entity):
|
||||
[row.get(c) for c in cols])
|
||||
log_activity(cur, row.get("client_id"), f"add {entity}", f"{pk}={row.get(pk)}")
|
||||
conn.commit()
|
||||
mirror_async(entity)
|
||||
mirror_async("activity_log")
|
||||
return jsonify({"added": row.get(pk)}), 201
|
||||
|
||||
|
||||
@@ -262,8 +192,6 @@ def edit_entity(entity, ident):
|
||||
log_activity(cur, ident if entity == "clients" else None,
|
||||
f"edit {entity}", f"{pk}={ident}: {', '.join(setcols)}")
|
||||
conn.commit()
|
||||
mirror_async(entity)
|
||||
mirror_async("activity_log")
|
||||
return jsonify({"updated": ident, "fields": setcols})
|
||||
|
||||
|
||||
@@ -281,8 +209,6 @@ def delete_entity(entity, ident):
|
||||
log_activity(cur, ident if entity == "clients" else None,
|
||||
f"delete {entity}", f"{pk}={ident}")
|
||||
conn.commit()
|
||||
mirror_async(entity)
|
||||
mirror_async("activity_log")
|
||||
return jsonify({"deleted": ident})
|
||||
|
||||
|
||||
@@ -313,26 +239,9 @@ def trigger_owner_password_reset(user_id):
|
||||
log_activity(cur, user["client_id"], "owner password reset",
|
||||
f"user_id={user_id} (operator-triggered)")
|
||||
conn.commit()
|
||||
mirror_async("activity_log")
|
||||
return jsonify({"sent": user_id})
|
||||
|
||||
|
||||
@app.post("/api/sync")
|
||||
def sync_all():
|
||||
"""Full DB -> Sheets resync of every tab (manual / alignment)."""
|
||||
if not authed():
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
if SH is None:
|
||||
return jsonify({"error": "sheets unavailable"}), 503
|
||||
out = {}
|
||||
for entity in db.TABLES:
|
||||
try:
|
||||
out[entity] = mirror_entity(entity)
|
||||
except Exception as e: # noqa: BLE001
|
||||
out[entity] = f"error: {e}"
|
||||
return jsonify({"synced": out})
|
||||
|
||||
|
||||
def _ics_dt(v):
|
||||
if isinstance(v, datetime):
|
||||
u = v if v.tzinfo else v.replace(tzinfo=timezone.utc)
|
||||
|
||||
+5
-12
@@ -1,8 +1,8 @@
|
||||
"""Postgres access + the table/column contract shared across the app.
|
||||
|
||||
The TABLES map is the single definition of which entities exist, their columns,
|
||||
and their primary key. Read/CRUD endpoints, the importer and the Sheets mirror
|
||||
all derive from it so they can never drift apart.
|
||||
and their primary key. Read/CRUD endpoints all derive from it so they can
|
||||
never drift apart.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
@@ -13,10 +13,9 @@ from psycopg.rows import dict_row
|
||||
|
||||
DATABASE_URL = os.environ["DATABASE_URL"]
|
||||
|
||||
# entity -> (sheet tab, primary key, ordered columns)
|
||||
# entity -> (primary key, ordered columns)
|
||||
TABLES = {
|
||||
"clients": {
|
||||
"tab": "Clients",
|
||||
"pk": "client_id",
|
||||
"cols": ["client_id", "business_name", "owner_name", "email", "phone",
|
||||
"niche", "tier", "status", "domain", "stack_notes", "vault_ref",
|
||||
@@ -28,7 +27,6 @@ TABLES = {
|
||||
"bools": [],
|
||||
},
|
||||
"leads": {
|
||||
"tab": "Leads",
|
||||
"pk": "lead_id",
|
||||
"cols": ["lead_id", "received_at", "client_id", "source", "name",
|
||||
"contact", "service_interest", "message", "status", "notified"],
|
||||
@@ -38,7 +36,6 @@ TABLES = {
|
||||
"bools": ["notified"],
|
||||
},
|
||||
"projects": {
|
||||
"tab": "Projects",
|
||||
"pk": "project_id",
|
||||
"cols": ["project_id", "client_id", "deliverable", "tier", "checklist",
|
||||
"go_live_date", "status"],
|
||||
@@ -48,7 +45,6 @@ TABLES = {
|
||||
"bools": [],
|
||||
},
|
||||
"bookings": {
|
||||
"tab": "Bookings",
|
||||
"pk": "booking_id",
|
||||
"cols": ["booking_id", "created_at", "client_id", "customer_name",
|
||||
"customer_contact", "service", "start_time", "end_time",
|
||||
@@ -59,7 +55,6 @@ TABLES = {
|
||||
"bools": [],
|
||||
},
|
||||
"invoices": {
|
||||
"tab": "Invoices",
|
||||
"pk": "invoice_id",
|
||||
"cols": ["invoice_id", "client_id", "issued_date", "due_date",
|
||||
"amount_eur", "period", "status", "paid_date"],
|
||||
@@ -69,7 +64,6 @@ TABLES = {
|
||||
"bools": [],
|
||||
},
|
||||
"activity_log": {
|
||||
"tab": "Activity Log",
|
||||
"pk": "id",
|
||||
"cols": ["ts", "workflow", "client_id", "action", "detail", "result"],
|
||||
"dates": [],
|
||||
@@ -78,14 +72,13 @@ TABLES = {
|
||||
"bools": [],
|
||||
},
|
||||
"credentials": {
|
||||
# No "tab": never mirrored to Sheets (see "mirror" below) — secrets stay in Postgres only.
|
||||
# Secrets stay in Postgres only.
|
||||
"pk": "cred_id",
|
||||
"cols": ["cred_id", "client_id", "label", "username", "secret", "notes", "created_at"],
|
||||
"dates": [],
|
||||
"timestamps": ["created_at"],
|
||||
"numbers": [],
|
||||
"bools": [],
|
||||
"mirror": False,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -94,7 +87,7 @@ def connect():
|
||||
return psycopg.connect(DATABASE_URL, row_factory=dict_row)
|
||||
|
||||
|
||||
# ---- coercion: Sheet strings / JSON values -> typed Python for Postgres ----
|
||||
# ---- coercion: JSON request values -> typed Python for Postgres ----
|
||||
|
||||
def parse_date(v):
|
||||
if v in (None, ""):
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"""One-time (idempotent) seed of Postgres from the existing Google Sheets.
|
||||
|
||||
This is the ONLY place the Sheet is treated as authoritative — to bootstrap the
|
||||
DB. After this, Postgres is the source of truth and Sheets is a downstream
|
||||
mirror. Safe to re-run: keyed tables upsert by primary key; activity_log is
|
||||
replaced wholesale.
|
||||
|
||||
Run inside the container: docker compose exec smb-crm python import_from_sheets.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import db
|
||||
from sheets import Sheets
|
||||
|
||||
|
||||
def upsert(cur, entity, records):
|
||||
spec = db.TABLES[entity]
|
||||
cols = spec["cols"]
|
||||
pk = spec["pk"]
|
||||
n = 0
|
||||
for rec in records:
|
||||
row = db.coerce_row(entity, rec)
|
||||
if entity != "activity_log" and not row.get(pk):
|
||||
continue # skip rows without a primary key
|
||||
placeholders = ", ".join(["%s"] * len(cols))
|
||||
collist = ", ".join(cols)
|
||||
if entity == "activity_log":
|
||||
cur.execute(f"INSERT INTO {entity} ({collist}) VALUES ({placeholders})",
|
||||
[row[c] for c in cols])
|
||||
else:
|
||||
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in cols if c != pk)
|
||||
cur.execute(
|
||||
f"INSERT INTO {entity} ({collist}) VALUES ({placeholders}) "
|
||||
f"ON CONFLICT ({pk}) DO UPDATE SET {updates}",
|
||||
[row[c] for c in cols])
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def main():
|
||||
sh = Sheets(os.environ["GOOGLE_SA_JSON"], os.environ["SHEET_ID"])
|
||||
with db.connect() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("TRUNCATE activity_log RESTART IDENTITY")
|
||||
for entity, spec in db.TABLES.items():
|
||||
if spec.get("mirror") is False:
|
||||
continue # e.g. credentials — never lived in the Sheet, nothing to import
|
||||
records = sh.read_records(spec["tab"])
|
||||
n = upsert(cur, entity, records)
|
||||
print(f" {entity:<13} <- {spec['tab']:<13} {n} row(s)")
|
||||
conn.commit()
|
||||
print("import complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Best-effort SMTP email sending (#18).
|
||||
|
||||
Mirrors app.py's DB -> Sheets mirror pattern: a single background worker
|
||||
thread drains a queue, and a send failure is logged, never raised back to the
|
||||
caller -- booking creation must succeed even if the mail relay is down.
|
||||
A single background worker thread drains a queue, and a send failure is
|
||||
logged, never raised back to the caller -- booking creation must succeed even
|
||||
if the mail relay is down.
|
||||
"""
|
||||
import os
|
||||
import queue
|
||||
|
||||
@@ -8,7 +8,6 @@ Flask==3.0.3
|
||||
psycopg[binary]==3.2.1
|
||||
waitress==3.0.0
|
||||
PyJWT[crypto]==2.9.0
|
||||
requests==2.32.3
|
||||
# python:3.12-slim has no system IANA tz database; zoneinfo (used by
|
||||
# availability.py for Europe/Berlin) falls back to this package for it.
|
||||
tzdata==2024.1
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
"""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)
|
||||
@@ -74,7 +74,7 @@
|
||||
<h1>🗂️ smb-crm · Back Office</h1>
|
||||
<div class="hgroup">
|
||||
<a class="btn ghost" href="/">📝 Onboarding-Formular</a>
|
||||
<span class="src">Quelle: Postgres (Sheets = Spiegel) · <span id="now"></span></span>
|
||||
<span class="src">Quelle: Postgres · <span id="now"></span></span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Unit tests for mailer.py's SMTP call shape (#18). Calls _send_now directly
|
||||
rather than going through the background-thread queue, so assertions are
|
||||
synchronous -- the queue itself is just plumbing, already covered indirectly
|
||||
by app.py's identical Sheets-mirror pattern.
|
||||
synchronous -- the queue itself is just plumbing.
|
||||
"""
|
||||
from email.message import EmailMessage
|
||||
|
||||
|
||||
Reference in New Issue
Block a user