loopable / platform /core /view_templates.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
4d80995 verified
Raw
History Blame Contribute Delete
16.4 kB
"""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