| """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 |
|
|
| |
| |
| |
| 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"]) |
|
|
|
|