Remove Google Sheets mirror entirely (#13)
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:
2026-08-04 11:29:57 +02:00
parent 16500c4392
commit 319218ce21
18 changed files with 76 additions and 451 deletions
+1 -92
View File
@@ -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)