File size: 6,803 Bytes
ea7b176 c14ceee ea7b176 c14ceee ea7b176 c14ceee ea7b176 c14ceee ea7b176 c14ceee ea7b176 c14ceee ea7b176 c14ceee ea7b176 c14ceee ea7b176 c14ceee ea7b176 c14ceee ea7b176 c14ceee | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | """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"])
|