loopable / platform /core /record_comments.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
ea7b176 verified
Raw
History Blame Contribute Delete
6.8 kB
"""Durable comments attached to records, ONE BUCKET PER DATABASE.
The service is deliberately unaware of HTTP and of the concrete store backend.
It receives a tenant-bound ``TenantRuntime`` handle, so the same logical key can
never cross a tenant boundary. Odoo remains read-only: comments live only in
AIOS's writable tenant store.
⭐ WAVE 19 (owner item 12) — THE BUCKET IS PER TOPIC. Every entry here is keyed by a bare pid,
and a pid only identifies a row INSIDE one database: customer pids are Odoo partner ids, product
pids are CRC32 hashes of SKU codes, a user table's are its own row numbers. One shared bucket
therefore had two failure modes, and the quiet one is why this changed:
* loud — a product record asked the customer endpoint about its hash, which is not in the
caller's book, and the panel said "that customer is not in your book" on a screen
showing a product. This is what the owner reported.
* quiet — a hash that lands on a real partner id answers 200, and the comment is filed
against somebody's customer, readable by everyone who can open that customer.
``key_for(scope)`` gives each database its own bucket. THE CUSTOMER TOPIC KEEPS THE LEGACY KEY,
so nothing that has been written moves; ``STORE_KEY`` stays exported under its old name and value
for the same reason.
"""
from __future__ import annotations
import datetime as dt
import uuid
STORE_KEY = "customer_record_comments"
MAX_BODY = 4000
MAX_PER_RECORD = 500
#: The scope names that mean THE CUSTOMER BOOK — mirrors ``modules.cohort.LEGACY_SCOPES``.
#: Restated rather than imported: ``core/`` does not import ``modules/`` (ARCHITECTURE §1's
#: one-directional rule), and this file has stayed on the right side of that line.
LEGACY_SCOPES = frozenset({"", "customer", "cohort"})
def key_for(scope=None):
"""The comments bucket ONE topic's records live in. Customer keeps the shipped key."""
s = str(scope or "customer").strip().lower()
return STORE_KEY if s in LEGACY_SCOPES else f"{s}_record_comments"
class CommentsUnavailable(RuntimeError):
"""The tenant store cannot currently provide durable comments."""
class CommentForbidden(RuntimeError):
"""The caller tried to remove another user's comment."""
def _now_iso():
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _record_key(pid):
return str(int(pid))
def _clean_comment(raw):
if not isinstance(raw, dict):
return None
cid = str(raw.get("id") or "").strip()
body = str(raw.get("body") or "").strip()
author_key = str(raw.get("authorKey") or "").strip().lower()
author = str(raw.get("author") or author_key).strip()
created_at = str(raw.get("createdAt") or "").strip()
if not cid or not body or not author_key or not created_at:
return None
return {
"id": cid,
"body": body[:MAX_BODY],
"authorKey": author_key,
"author": author[:120] or author_key,
"createdAt": created_at,
}
def _read(runtime, scope=None):
if not runtime.available():
raise CommentsUnavailable("the tenant store is unavailable")
try:
data = runtime.get(key_for(scope))
except Exception as exc:
raise CommentsUnavailable("comments could not be read") from exc
return data if isinstance(data, dict) else {}
def list_comments(runtime, pid, scope=None):
"""Return one record's comments oldest-first, dropping malformed legacy entries."""
rows = _read(runtime, scope).get(_record_key(pid), [])
if not isinstance(rows, list):
return []
cleaned = [comment for raw in rows if (comment := _clean_comment(raw)) is not None]
cleaned.sort(key=lambda comment: (comment["createdAt"], comment["id"]))
return cleaned
def add_comment(runtime, pid, body, author_key, author_name="", now=None, comment_id=None,
scope=None):
"""Append one server-authored comment atomically and synchronously."""
text = str(body or "").strip()
if not text:
raise ValueError("write a comment before posting")
if len(text) > MAX_BODY:
raise ValueError(f"comments may be at most {MAX_BODY} characters")
uname = str(author_key or "").strip().lower()
if not uname:
raise ValueError("a signed-in author is required")
comment = {
"id": str(comment_id or uuid.uuid4().hex),
"body": text,
"authorKey": uname,
"author": str(author_name or uname).strip()[:120] or uname,
"createdAt": str(now or _now_iso()),
}
if not runtime.available():
raise CommentsUnavailable("the tenant store is unavailable")
def _append(data):
if not isinstance(data, dict):
data = {}
key = _record_key(pid)
rows = data.get(key)
if not isinstance(rows, list):
rows = []
if len(rows) >= MAX_PER_RECORD:
raise ValueError(f"a record may hold at most {MAX_PER_RECORD} comments")
rows.append(comment)
data[key] = rows
return data
try:
runtime.update(key_for(scope), _append, flush="sync")
except ValueError:
raise
except Exception as exc:
raise CommentsUnavailable("the comment was not saved") from exc
return dict(comment)
def delete_comment(runtime, pid, comment_id, actor_key, admin=False, scope=None):
"""Delete a comment owned by ``actor_key`` (or by an administrator)."""
cid = str(comment_id or "").strip()
actor = str(actor_key or "").strip().lower()
if not cid:
return False
if not runtime.available():
raise CommentsUnavailable("the tenant store is unavailable")
state = {"found": False, "forbidden": False}
def _delete(data):
if not isinstance(data, dict):
return {}
key = _record_key(pid)
rows = data.get(key)
if not isinstance(rows, list):
return data
kept = []
for raw in rows:
comment = _clean_comment(raw)
if comment is None or comment["id"] != cid:
kept.append(raw)
continue
state["found"] = True
if not admin and comment["authorKey"] != actor:
state["forbidden"] = True
kept.append(raw)
if kept:
data[key] = kept
else:
data.pop(key, None)
return data
try:
runtime.update(key_for(scope), _delete, flush="sync")
except Exception as exc:
raise CommentsUnavailable("the comment was not deleted") from exc
if state["forbidden"]:
raise CommentForbidden("only the author or an administrator may delete this comment")
return bool(state["found"])