| """Cohort β a FIXED set of customers the user curates by hand (owner item 6, 2026-07-26). |
| |
| The difference from the Data module, and the only one: **a Cohort's membership is not a query.** |
| |
| Data one row per customer across the whole scoped book. Which rows you see is whatever the |
| view's FILTER matches, so the set moves when the data moves. |
| Cohort a list of customer ids somebody chose. You can still filter, sort, group and add |
| columns exactly as on Data β but those only narrow what is DISPLAYED. The set itself |
| changes only when a person adds or removes a row. |
| |
| That distinction is the whole point: a formula list silently re-populates ("who is overdue |
| today"), a cohort does not ("the 40 accounts we agreed to call in Q3"). If a cohort could drift, |
| it would not be worth having β you could just save a view. |
| |
| Rows come from `modules.customer_data.pool()` β the SAME validated metric surface the Data module |
| renders, so a customer's numbers cannot differ between the two pages. This module owns only |
| MEMBERSHIP. |
| |
| Persistence: HF store key `customer_cohorts`, shaped {username: {cohort_id: {...}}}. Per-user |
| like the saved views it sits beside, and written through `store.update` (strict |
| read-modify-write β never get+put around a mutation; that is the restart-wipe bug). |
| |
| β WAVE 19 / OWNER RULING R9 β A COHORT BELONGS TO ITS DATABASE. "It should stay in Product |
| database β¦ for ANY database new/old": until relational features land, database functionalities |
| are purely independent. So this module is now SCOPE-PARAMETERIZED rather than restricted β one |
| bucket per topic (`<scope>_cohorts`), reached through `scoped(scope)`. |
| |
| β THE ID SPACES ARE WHY, not tidiness. A cohort stores the TOPIC's row ids: customer pids are |
| Odoo partner ids, product pids are CRC32 hashes of the SKU code, a user table's are its own |
| row numbers. One shared bucket and a hash collision silently puts a product in somebody's |
| customer list β the same argument `routes_products` makes about the overlay bucket, and the |
| reason wave 16 answered it with `with_cohorts=False` (a refusal) instead of a bucket. |
| |
| β THE CUSTOMER TOPIC KEEPS THE LEGACY KEY β zero migration. `customer` and `cohort` are ONE |
| topic (the Cohort surface is the customer table over hand-curated sets), so both resolve to |
| `customer_cohorts` and every shipped list is found exactly where it already lives. |
| |
| β `scoped('customer')` RETURNS THIS MODULE, deliberately, so the module-level functions stay |
| the customer implementation rather than becoming a second copy of it. Callers that patch |
| `modules.cohort.visible` (the gates do) keep working through the facade, and every existing |
| positional call site is byte-unaffected. |
| |
| TENANCY, STATED NOT HIDDEN (booked as DEBT, wave 19): this module writes through the MODULE-LEVEL |
| `core.store`, i.e. tenant #0's dataset repo β the same place `customer_docs` and the customer / |
| product table workspaces already live. It is safe rather than correct: the outer key is the |
| USERNAME, and `core/users.py`'s registry is ONE global bucket, so a username belongs to exactly |
| one tenant and no cross-tenant READ is reachable. What is wrong is RESIDENCY (tenant B's lists |
| sit in tenant #0's repo). Fixing it means threading the runtime handle through `EventCtx`, which |
| needs `routes_grid._ctx` β another session's file this wave. |
| |
| No validate(): there is no Odoo aggregate to reconcile a hand-picked list against. Its counts |
| derive from the rows it renders (rule 8b), and the metrics themselves are already validated by |
| the customers module they come from. |
| """ |
| import datetime as _dt |
| import sys as _sys |
|
|
| import core.store as store |
|
|
| KEY = 'customer_cohorts' |
|
|
| |
| |
| |
| LEGACY_SCOPES = frozenset({'', 'customer', 'cohort'}) |
|
|
| |
| |
| |
| MAX_MEMBERS = 5000 |
| MAX_COHORTS = 100 |
|
|
|
|
| def key_for(scope): |
| """The store bucket ONE topic's cohorts live in (R9). |
| |
| Customer (and its `cohort` surface) keep `customer_cohorts` β the legacy key, so no shipped |
| list moves. Every other database gets `<scope>_cohorts`, which for a user table is |
| `ut_<slug>_cohorts`: the same naming the table's own workspace bucket already uses |
| (`<scope>_table_workspace`), so one database's durable state reads as one family. |
| """ |
| s = str(scope or 'customer').strip().lower() |
| return KEY if s in LEGACY_SCOPES else f'{s}_cohorts' |
|
|
|
|
| def noun_for(scope): |
| """What ONE row of this topic is called β the "Added 12 customers to β¦" toast's noun. |
| |
| A hardcoded "customer" was the visible half of the leak this ruling closes: adding twelve |
| SKUs to a product list said "Added 12 customers", which is the surface telling the user the |
| wrong thing about where their list went. Unknown topics say "record" rather than guessing a |
| label from a slug β a user table is called whatever its creator named it, and inventing a |
| singular from `ut_supplier_quotes` would be worse than the honest generic. |
| """ |
| s = str(scope or 'customer').strip().lower() |
| if s in LEGACY_SCOPES: |
| return 'customer' |
| if s == 'product': |
| return 'product' |
| return 'record' |
|
|
|
|
| def _now(): |
| return _dt.datetime.now().strftime('%Y-%m-%d %H:%M') |
|
|
|
|
| def _all_for(key, username): |
| """{cohort_id: {name, members, note, created, updated}} for one user, from ONE bucket. |
| |
| Returns {} when the store is unavailable rather than raising: a cohort list that cannot load |
| should degrade to "no cohorts", never to a broken page. |
| """ |
| try: |
| return dict((store.get(key) or {}).get(username, {}) or {}) |
| except Exception: |
| return {} |
|
|
|
|
| def all_for(username): |
| """{cohort_id: {...}} for one user on the CUSTOMER book β see `_all_for`.""" |
| return _all_for(KEY, username) |
|
|
|
|
| def _clean_members(raw, allowed_pids=None): |
| """Ints only, de-duplicated, ORDER PRESERVED, and intersected with what this user may see. |
| |
| β The `allowed_pids` intersection is a PERMISSION boundary, not tidying. A cohort is stored |
| per user but the ids in it are customer ids: if an agent's scope narrows (or a cohort is ever |
| shared), replaying stored ids unchecked would render rows outside their book. Filtering on |
| READ means a scope change takes effect immediately, without rewriting anyone's stored data. |
| """ |
| out, seen = [], set() |
| for v in list(raw or [])[:MAX_MEMBERS * 2]: |
| try: |
| pid = int(v) |
| except (TypeError, ValueError): |
| continue |
| if pid in seen: |
| continue |
| if allowed_pids is not None and pid not in allowed_pids: |
| continue |
| seen.add(pid) |
| out.append(pid) |
| return out[:MAX_MEMBERS] |
|
|
|
|
| def _visible(key, username, allowed_pids=None): |
| """Every cohort in ONE bucket, with membership already scoped to what this user may open.""" |
| return {cid: {**c, 'members': _clean_members(c.get('members'), allowed_pids)} |
| for cid, c in _all_for(key, username).items()} |
|
|
|
|
| def visible(username, allowed_pids=None): |
| """Every CUSTOMER cohort, membership scoped to what this user may open.""" |
| return _visible(KEY, username, allowed_pids) |
|
|
|
|
| def new_id(name): |
| """A stable id from the name plus a suffix, so two cohorts called "Q3 calls" can coexist. |
| |
| β Topic-FREE on purpose. The id is a handle stored inside the topic's own bucket, and a saved |
| view's `cohortLock` names it β so encoding the topic in the string would only add a second |
| place for the two to disagree. Two databases may each hold a `cohort_q3_calls_101112`; they |
| are different objects in different buckets and neither read can reach the other. |
| """ |
| slug = ''.join(ch if ch.isalnum() else '_' for ch in str(name).lower()).strip('_')[:40] |
| return f"cohort_{slug or 'list'}_{_dt.datetime.now().strftime('%H%M%S')}" |
|
|
|
|
| def _create(key, username, name, members=(), note=''): |
| """Create a cohort in ONE bucket and return its id. Raises ValueError on a blank name or when |
| the per-user cap is reached β refusing loudly beats silently dropping somebody's list. |
| |
| The cap is PER BUCKET, i.e. per database, which is the honest reading of R9: a hundred |
| product lists must not be spent by a hundred customer lists somebody else's page created. |
| """ |
| name = str(name or '').strip()[:120] |
| if not name: |
| raise ValueError('a cohort needs a name') |
| existing = _all_for(key, username) |
| if len(existing) >= MAX_COHORTS: |
| raise ValueError(f'you already have {MAX_COHORTS} cohorts β delete one first') |
| cid = new_id(name) |
| record = {'name': name, 'members': _clean_members(members), 'note': str(note or '')[:2000], |
| 'created': _now(), 'updated': _now()} |
|
|
| def _up(data): |
| data.setdefault(username, {})[cid] = record |
| return data |
| store.update(key, _up) |
| return cid |
|
|
|
|
| def create(username, name, members=(), note=''): |
| """Create a CUSTOMER cohort and return its id β see `_create`.""" |
| return _create(KEY, username, name, members=members, note=note) |
|
|
|
|
| def _mutate(key, username, cohort_id, change): |
| def _up(data): |
| book = data.setdefault(username, {}) |
| c = book.get(cohort_id) |
| if c is None: |
| return data |
| change(c) |
| c['updated'] = _now() |
| return data |
| store.update(key, _up) |
|
|
|
|
| def _add_members(key, username, cohort_id, pids): |
| """Union new ids into a cohort, PRESERVING the existing order and appending the new ones. |
| |
| Additive on purpose: "add to list" from a view can be clicked twice, and the second click |
| must be a no-op rather than a reorder or a duplicate. |
| """ |
| def _change(c): |
| c['members'] = _clean_members(list(c.get('members') or []) + list(pids or [])) |
| _mutate(key, username, cohort_id, _change) |
|
|
|
|
| def add_members(username, cohort_id, pids): |
| """Union new ids into a CUSTOMER cohort β see `_add_members`.""" |
| _add_members(KEY, username, cohort_id, pids) |
|
|
|
|
| def _remove_members(key, username, cohort_id, pids): |
| drop = {int(p) for p in (pids or []) if str(p).lstrip('-').isdigit()} |
|
|
| def _change(c): |
| c['members'] = [p for p in _clean_members(c.get('members')) if p not in drop] |
| _mutate(key, username, cohort_id, _change) |
|
|
|
|
| def remove_members(username, cohort_id, pids): |
| _remove_members(KEY, username, cohort_id, pids) |
|
|
|
|
| def _rename(key, username, cohort_id, name): |
| name = str(name or '').strip()[:120] |
| if not name: |
| raise ValueError('a cohort needs a name') |
| _mutate(key, username, cohort_id, lambda c: c.__setitem__('name', name)) |
|
|
|
|
| def rename(username, cohort_id, name): |
| _rename(KEY, username, cohort_id, name) |
|
|
|
|
| def _set_note(key, username, cohort_id, note): |
| _mutate(key, username, cohort_id, lambda c: c.__setitem__('note', str(note or '')[:2000])) |
|
|
|
|
| def set_note(username, cohort_id, note): |
| _set_note(KEY, username, cohort_id, note) |
|
|
|
|
| def _delete(key, username, cohort_id): |
| def _up(data): |
| (data.get(username) or {}).pop(cohort_id, None) |
| return data |
| store.update(key, _up) |
|
|
|
|
| def delete(username, cohort_id): |
| _delete(KEY, username, cohort_id) |
|
|
|
|
| def rows_for(cohort, pool_rows): |
| """The cohort's rows, in MEMBERSHIP order, from an already-built pool. |
| |
| Membership order, not pool order: the user built this list, and the order they built it in is |
| information. Ids with no matching pool row are dropped silently β a customer can fall out of |
| the 24-month pool without the cohort being wrong, and showing a blank row would be worse than |
| showing fewer. `missing_count` lets the page say so honestly rather than hiding it. |
| """ |
| by_pid = {r['pid']: r for r in pool_rows} |
| members = _clean_members(cohort.get('members')) |
| rows = [by_pid[p] for p in members if p in by_pid] |
| return rows, len(members) - len(rows) |
|
|
|
|
| |
| class CohortStore: |
| """The cohort verbs for ONE non-customer database, closed over its bucket key. |
| |
| Same method names and same argument order as the module functions above, so a caller written |
| against `modules.cohort` reads identically whichever it holds β which is the point: the write |
| handler resolves the store once (`scoped(ctx.scope_key)`) and every branch below it stays the |
| code it already was. |
| """ |
|
|
| __slots__ = ('scope', 'key', 'noun') |
|
|
| def __init__(self, scope): |
| self.scope = str(scope or 'customer').strip().lower() |
| self.key = key_for(self.scope) |
| self.noun = noun_for(self.scope) |
|
|
| def __repr__(self): |
| return f'CohortStore({self.scope!r} -> {self.key!r})' |
|
|
| def all_for(self, username): |
| return _all_for(self.key, username) |
|
|
| def visible(self, username, allowed_pids=None): |
| return _visible(self.key, username, allowed_pids) |
|
|
| def create(self, username, name, members=(), note=''): |
| return _create(self.key, username, name, members=members, note=note) |
|
|
| def add_members(self, username, cohort_id, pids): |
| _add_members(self.key, username, cohort_id, pids) |
|
|
| def remove_members(self, username, cohort_id, pids): |
| _remove_members(self.key, username, cohort_id, pids) |
|
|
| def rename(self, username, cohort_id, name): |
| _rename(self.key, username, cohort_id, name) |
|
|
| def set_note(self, username, cohort_id, note): |
| _set_note(self.key, username, cohort_id, note) |
|
|
| def delete(self, username, cohort_id): |
| _delete(self.key, username, cohort_id) |
|
|
|
|
| def scoped(scope=None): |
| """The cohort store for ONE topic β THE resolution point (R9). |
| |
| β Returns THIS MODULE for the customer topic, not a `CohortStore(KEY)`. Two reasons, and both |
| are about not creating a second implementation of the shipped behaviour: |
| 1. the module-level functions ARE the customer store, so the legacy path keeps executing |
| exactly the code the gates measure β no wrapper to drift from; |
| 2. tests patch `modules.cohort.visible` (verify_grid_events' folder legs do), and a facade |
| that returned a bound object would silently ignore the patch, leaving a gate green over |
| code it is no longer exercising. |
| """ |
| if str(scope or 'customer').strip().lower() in LEGACY_SCOPES: |
| return _sys.modules[__name__] |
| return CohortStore(scope) |
|
|
|
|
| |
| |
| noun = 'customer' |
|
|