| """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. |
| """ |
|
|
| |
| |
| |
| SOURCES = ('odoo_customer', 'odoo_product', 'instagram', 'any') |
|
|
| |
| |
| |
| _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: |
| |
| |
| |
| 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 {}), |
| }, |
| } |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| 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', |
| |
| |
| |
| '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.', |
| |
| |
| |
| |
| |
| |
| ['customer', 'dba', 'agent', 'ar_open', 'last_order', 'days_since'], |
| sorts=[{'colId': 'ar_open', 'dir': 'desc'}], |
| |
| |
| |
| |
| 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'}], |
| |
| |
| |
| 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.', |
| |
| |
| |
| |
| |
| ['handle', 'full_name', 'followers', 'avg_engagement', 'verified', 'category', |
| 'profile_url'], |
| |
| |
| |
| 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 |
|
|