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)
|
||||
|
||||
Reference in New Issue
Block a user