File size: 16,382 Bytes
c3e4cb4 4d80995 c3e4cb4 4d80995 c3e4cb4 4d80995 c3e4cb4 | 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 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 | """view_templates.py β WAVE 23 item 7 (ruling R10, contract C12): the PLATFORM-CURATED
template registry.
WHAT A TEMPLATE IS, precisely, because the word is overloaded: a named bundle of SAVED VIEWS
that a user applies to a database they already have. It does NOT create a table, it does not add
columns, and it does not connect anything. It is the answer to "this database has 30 columns and
I do not know which twelve matter for chasing money" β a starting layout, authored once by the
platform, applied by whoever wants it.
β THE ONE RULE THAT DECIDES EVERY DESIGN CHOICE HERE β `_seed_wave17.py`'s :9-20 law:
**only VIEWS are shared; `fields` and `overlays` are strictly per-user.** So a SHARED view that
filtered on a user-created column would, for every other account, name a column that does not
exist β and an unknown column is an INACTIVE leaf in the tri-state filter engine, which is
IGNORED, which WIDENS. A "Collections focus" that quietly showed the whole customer book to
everyone but its author, with nothing going red.
Two consequences, both load-bearing:
1. **Applying writes the CALLING USER's own views** (`save_view(..., shared=False)`), never
shared ones. A template is a convenience, not an administrative act, and the moment it
wrote into the shared bucket it would need an admin wall and a name-collision policy across
the tenant.
2. **Every column a template names is checked against the TARGET's live contract before
anything is written**, and a template whose columns are missing is REFUSED with them named
β never applied partially, never applied with the offending leaf dropped. That check is
also what makes eligibility honest: `source` below is a label for grouping, the COLUMNS are
the gate, and the two cannot disagree because only one of them is consulted.
IDEMPOTENT BY PINNED ID. Every view carries `tpl_<template>_<suffix>`, so re-applying updates
the same views instead of minting "Collections focus 2". `save_view` renames on collision, which
would otherwise turn a second click into a second copy.
Pure-ish by construction: `TEMPLATES` and every builder are data, so `verify_home.py` can assert
their shape without a store.
"""
#: The topic a template is FOR. A coarse grouping label used to filter the picker's list; the
#: real gate is `missing_columns` below (a table whose contract has the columns can take the
#: template, whatever we called its source).
SOURCES = ('odoo_customer', 'odoo_product', 'instagram', 'any')
#: The workspace bucket a topic's views live in. `<key>_table_workspace` for user tables
#: (`core/user_tables.py:211` builds the same string when it deletes them), and the two built-ins
#: keep the names their modules have always used.
_WS_KEYS = {
'customer_data': 'customer_table_workspace',
'product_data': 'product_table_workspace',
}
def workspace_key(table_key):
"""The `core.table_store` key for a page key, or None if it is not a table at all."""
key = str(table_key or '')
if key in _WS_KEYS:
return _WS_KEYS[key]
if key.startswith('ut_'):
return f'{key}_table_workspace'
return None
def _view(view_id, name, note, visible, *, mode='grid', stack_field=None, sorts=(),
filters=(), group_by=None, conj='and'):
"""One saved view, in the shape `table_store.save_view` stores and the grid reads.
β THE SHAPE IS COPIED FROM `_seed_wave17._view` ON PURPOSE, field for field, including the
keys that look inert (`widths`, `memberPids`, `rowHeightMode`). The grid's own cleaner fills
defaults for what is missing, but a view assembled from a DIFFERENT skeleton is a second
definition of what a view is β and the two drift on the day one of them gains a key.
`permissions.edit` is absent: `table_store.is_shared` reads it, and a view with no
permissions block is PERSONAL, which is the only thing this file is allowed to write.
β THE MODE LIVES AT `config.display.mode`, AND THE FIRST DRAFT PUT IT AT
`config.displayMode` β A KEY NOTHING READS. The gate caught nothing, because it asserted the
VALUE against `aios_grid.DISPLAY_MODES` and never the PATH: `"kanban" in DISPLAY_MODES` is
true wherever you happen to have written it. The Instagram "Review board" would have been
created as a plain GRID under a name promising a board β the view exists, the filter works,
the kanban never happens, and nothing goes red. Exactly the silent-drop class as a dropped
filter leaf, one field over. Corrected against `aios_grid._clean_display`, which is now RUN
over every template in `verify_home` rather than consulted for a vocabulary.
β AND `grid` STORES NOTHING. `_clean_display` returns None for it by design ("grid is the
absent default, so storing it would be a second way to say nothing"), so writing
`display: {'mode': 'grid'}` would be a key the cleaner strips on the next save β a value that
exists until something touches it.
"""
display = None
if mode != 'grid':
display = {'mode': mode}
if stack_field:
# The kanban lane column. `_clean_display` accepts any ref that NAMES a real field
# and leaves what the mode means to the client, which degrades a wrong-typed ref to
# its own default rather than erroring.
display['stackField'] = stack_field
return {
'id': view_id, 'name': name, 'kind': 'custom', 'locked': False, 'note': note,
'config': {
'filters': list(filters), 'filterConj': conj, 'sorts': list(sorts),
'groupBy': group_by, 'colorBy': None, 'rowHeightMode': 'short',
'order': list(visible), 'visible': list(visible), 'widths': {},
'memberPids': [], **({'display': display} if display else {}),
},
}
# ββ THE REGISTRY ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
#
# β FIRST BATCH (C12). Every column named below was checked against the live contract when this
# file was written β `aios_grid_fields.json` for customer, `aios_grid.product_fields()` for
# product, `automation_engine.CANDIDATE_FIELDS` for the Instagram candidate pool β but the check
# that MATTERS runs at apply time, against the target the user actually picked, because a
# contract can move and a ut_ table's columns are whatever its owner made.
TEMPLATES = [
{
'key': 'collections_focus',
'label': 'Collections focus',
'desc': 'Everyone with money past due, largest first, with the aging buckets beside it.',
'source': 'odoo_customer',
'views': [
_view(
'tpl_collections_focus_overdue', 'Past due',
'Customers with an overdue balance, largest first. The four aging columns sum '
'to AR overdue; a balance inside the grace period counts as open, not overdue.',
['customer', 'agent', 'ar_overdue', 'ar_aged_1_30', 'ar_aged_31_60',
'ar_aged_61_90', 'ar_aged_90_plus', 'ar_open', 'days_to_pay',
'payment_terms', 'last_order'],
sorts=[{'colId': 'ar_overdue', 'dir': 'desc'}],
filters=[{'colId': 'ar_overdue', 'op': 'gt', 'value': '0'}],
),
_view(
'tpl_collections_focus_worst', 'Over 90 days',
'The part of the book that is no longer a payment-terms conversation.',
['customer', 'agent', 'ar_aged_90_plus', 'ar_overdue', 'ar_exposure',
'days_to_pay', 'last_order'],
sorts=[{'colId': 'ar_aged_90_plus', 'dir': 'desc'}],
filters=[{'colId': 'ar_aged_90_plus', 'op': 'gt', 'value': '0'}],
),
],
},
{
'key': 'dba_missing',
'label': 'DBA is empty',
# The owner's own example (C12). One view, one job, and it is a DATA-QUALITY view: the
# brand a customer buys from decides which BU-scoped account can see them at all, so a
# blank DBA is a customer nobody's book contains.
'desc': 'Customers with no brand recorded β they fall outside every scoped book.',
'source': 'odoo_customer',
'alert': True,
'views': [
_view(
'tpl_dba_missing_blank', 'DBA is empty',
'Customers whose DBA is blank. The brand decides which scoped account sees a '
'customer, so a blank one is invisible to every book except an unscoped view.',
# β SORTED BY OPEN RECEIVABLE, NOT BY SALES. This view first named `sales_ltm`,
# which DOES NOT EXIST β the customer contract carries no LTM sales column (the
# 32 keys in `aios_grid_fields.json`; `verify_home` caught it against the live
# contract on the first run). `ar_open` is the nearest real column that answers
# the same question β "which of these matters most" β and it answers it in
# money that is actually outstanding rather than in history.
['customer', 'dba', 'agent', 'ar_open', 'last_order', 'days_since'],
sorts=[{'colId': 'ar_open', 'dir': 'desc'}],
# β `isEmpty`, and it is the one op that is safe here. A `select` column's blank
# is not the empty string in every row shape, and `eq ''` would miss the nulls β
# `isEmpty`/`isNotEmpty` are evaluated type-independently BEFORE any coercion
# (the same reason `_seed_wave17`'s buy list leans on them).
filters=[{'colId': 'dba', 'op': 'isEmpty'}],
),
],
},
{
'key': 'buy_list_companion',
'label': 'Buy-list companion',
'desc': 'What is running out, and what it costs to bring in β beside the buy list.',
'source': 'odoo_product',
'views': [
_view(
'tpl_buy_list_companion_cover', 'Cover gap',
'SKUs whose days of supply is already shorter than their supplier lead time. '
'Stock columns are consolidated across the one physical warehouse, so this is '
'empty for a BU-scoped account.',
['code', 'product', 'cover_gap_d', 'dos', 'lead_days', 'supplier', 'on_hand',
'qty_ltm', 'first_cost', 'origin_country'],
sorts=[{'colId': 'cover_gap_d', 'dir': 'asc'}],
# The same four leaves the wave-17 seed uses, and for the same reason: a bare
# `dos < lead_days` reads a SKU with NO days-of-supply as `0 < 30` (the client
# engine's `toNum(null)` is 0) and puts every never-selling product on the list.
filters=[{'colId': 'dos', 'op': 'isNotEmpty'},
{'colId': 'lead_days', 'op': 'isNotEmpty'},
{'colId': 'lead_days', 'op': 'gt', 'value': '0'},
{'colId': 'dos', 'op': 'lt',
'rhs': {'kind': 'field', 'colId': 'lead_days'}}],
),
_view(
'tpl_buy_list_companion_nosupplier', 'No supplier on file',
'SKUs we cannot reorder because nobody is recorded as selling them to us.',
['code', 'product', 'supplier', 'on_hand', 'qty_ltm', 'first_cost'],
sorts=[{'colId': 'qty_ltm', 'dir': 'desc'}],
filters=[{'colId': 'supplier', 'op': 'isEmpty'}],
),
],
},
{
'key': 'ig_candidates',
'label': 'Candidates review',
'desc': 'Discovered profiles as a review board, plus the ones worth reading first.',
'source': 'instagram',
'views': [
_view(
'tpl_ig_candidates_board', 'Review board',
'Discovered profiles grouped by category. Move a card to say what you '
'decided about it β the automation never decides for you.',
# β WAVE 26 (R6): `tracked` was here and the column is DELETED. The owner
# retired it because the per-automation stage field already tracks a record's
# progress, and two progress columns that can disagree is worse than one. A
# template naming a column its contract no longer has is a template that ships
# broken β `verify_home` caught this one, which is the only reason it is not live.
['handle', 'full_name', 'followers', 'avg_engagement', 'verified', 'category',
'profile_url'],
# `stackField`, NOT `groupBy` β the kanban lane column is a `display` ref, while
# `groupBy` is the GRID's row grouping. They are two different features that both
# sound like "group by", and only one of them makes lanes.
mode='kanban', stack_field='category',
sorts=[{'colId': 'followers', 'dir': 'desc'}],
),
_view(
'tpl_ig_candidates_engaged', 'High engagement',
'Profiles whose audience actually responds, biggest first. Engagement, not '
'follower count, is what a small account can be good at.',
['handle', 'full_name', 'avg_engagement', 'followers', 'category', 'bio',
'external_url'],
sorts=[{'colId': 'avg_engagement', 'dir': 'desc'}],
filters=[{'colId': 'avg_engagement', 'op': 'gt', 'value': '0'}],
),
],
},
]
def get(key):
"""One template by key, or None."""
return next((t for t in TEMPLATES if t['key'] == str(key)), None)
def columns_named(template):
"""Every column key a template's views reference β visible, filtered, sorted, grouped, and
the right-hand side of a field-vs-field comparison.
β THE `rhs` IS THE ONE PEOPLE FORGET, and `_seed_wave17` learned it the expensive way:
`clean_filter_tree` KEEPS a leaf whose rhs names an unknown column β it drops the rhs and
leaves the rule β so `dos < lead_days` silently becomes `dos < ""`. The leaf count is
unchanged, so a check that counted leaves would pass while the view answered a different
question. Naming the rhs here is what makes the eligibility test see it.
"""
out = set()
for view in template.get('views') or ():
cfg = view.get('config') or {}
out.update(cfg.get('visible') or ())
for s in cfg.get('sorts') or ():
if isinstance(s, dict) and s.get('colId'):
out.add(s['colId'])
if cfg.get('groupBy'):
out.add(cfg['groupBy'])
for f in cfg.get('filters') or ():
if not isinstance(f, dict):
continue
if f.get('colId'):
out.add(f['colId'])
rhs = f.get('rhs')
if isinstance(rhs, dict) and rhs.get('colId'):
out.add(rhs['colId'])
return out
def missing_columns(template, field_keys):
"""The columns this template needs that the target does not have. Empty β it can be applied.
THE ELIGIBILITY TEST AND THE REFUSAL TEST ARE THE SAME FUNCTION, deliberately: a picker that
offered a template the apply door would then refuse is a control that lies, and two separate
predicates is how those two answers drift apart.
"""
return sorted(columns_named(template) - set(field_keys or ()))
def offer(field_keys, source=None):
"""Every template that CAN be applied to a target with these columns.
`source` narrows further when the caller knows it (the picker passes the target's own), but
a template whose columns are all present is offered regardless of label β the columns are
the contract and the label is a grouping.
"""
out = []
for t in TEMPLATES:
if missing_columns(t, field_keys):
continue
if source and t['source'] not in ('any', source):
continue
out.append({'key': t['key'], 'label': t['label'], 'desc': t['desc'],
'source': t['source'], 'views': len(t.get('views') or ()),
'alert': bool(t.get('alert'))})
return out
|