File size: 8,109 Bytes
c14ceee | 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 | """core/perm_migrate.py β R1's one-time migration: `bus`/`agent` become a PERMANENT FILTER.
Owner ruling R1: BU access stops being its own field and becomes an ordinary permanent filter
under the permissioning engine, declared and edited exactly like "Agent is Tara". This module
builds that filter from the legacy record and β the part that matters β PROVES the swap is a
no-op before anything relies on it.
β WHY THE PROOF IS ABOUT VALUES AND NOT ABOUT ROWS (C-PERM amendment 3).
`modules/customer_data._pool_build` passes `team_id` into `cust._cust_rev` three times (YTD, LY,
LTM) and into `cust._cadence_bulk`. It therefore decides what `rev`, `ly`, `ltm`, `aov`,
`est_missed` and the derived `status` MEAN β it is not a row filter. Swap it for a post-filter
and a Fisch-only user keeps a plausible row LIST while every number on it quietly becomes
Fisch+Royal, worst for `dba = Both` customers, who are precisely the ones a BU filter admits.
A pid-set reconciliation reports GREEN on that. So `reconcile()` compares VALUES.
The pushdown survives as `perm_scope.derive_pool_scope` β a DERIVATION OF the filter rather than
a wall beside it β so a correctly migrated record produces the IDENTICAL pool call, and the
reconciliation below should be exact rather than merely close. If it is not exact, that is a
finding for the owner, not a tolerance to widen.
"""
import core.perm_scope as perm_scope
import core.perms as perms
MODULE = 'customer_data'
#: `_dba_attrs` emits exactly {'Fisch','Royal','Both'} (blank = no brand attributable in 24
#: months). A BU grant admits its own brand AND the dual-brand customers: a Both customer IS a
#: Fisch customer. Mirrors `perm_scope._DBA_TEAM`, inverted.
#:
#: β TWO VALUES, AND THE COLUMN VOCABULARY HAS NO "IS ANY OF". `FILTER_OPS` is single-valued by
#: design (`eq`, `neq`, `contains`, β¦) β `anyOf` belongs to the COHORT vocabulary and is
#: deliberately DISJOINT from it, so a column leaf carrying `anyOf` is refused by every engine
#: (`filter_eval` returns False, `clean_filter_tree` drops it). This migration's first draft
#: emitted exactly that and `reconcile()` caught it as two lost customers. So the BU condition
#: is an OR GROUP of `eq` leaves, which is what the condition builder itself produces.
_TEAM_DBA = {5: ('Fisch', 'Both'), 6: ('Royal', 'Both')}
def filter_for(rec):
"""The permanent filter equivalent to this record's legacy `bus` + `agent`, or None when the
record was unrestricted (an admin, or `bus:'all'` with no agent link).
AND-conjoined at the root, and top-level by construction β which is also what makes it
pushdown-derivable (`derive_pool_scope` reads top-level AND leaves only).
"""
nodes = []
team_id = perms.scope_team_id(rec)
if team_id in _TEAM_DBA:
nodes.append({'conj': 'or',
'children': [{'colId': 'dba', 'op': 'eq', 'value': v, 'value2': ''}
for v in _TEAM_DBA[team_id]]})
agent = perms.scope_agent(rec)
if agent:
nodes.append({'colId': 'agent', 'op': 'eq', 'value': str(agent), 'value2': ''})
if not nodes:
return None
return {'conj': 'and', 'nodes': nodes}
def perms_for(rec, modules=(MODULE,)):
"""The `perms` block this legacy record becomes.
β ACCESS IS COPIED FROM THE OLD GRANT, NOT ASSUMED. `may_open` is what decided reachability
before this wave, so migrating a record must not widen it β and on a migrated record an
undeclared module DENIES (amendment 4), so every module the user could reach has to be
declared here or the migration is a lockout.
"""
out = {}
for key in modules:
out[key] = {'access': bool(perms.may_open(rec, key)),
'filter': filter_for(rec),
'hiddenFields': []}
return out
def plan(registry_dict, modules=(MODULE,)):
"""`{username: perms_block}` for every record that is not already migrated.
Admins are included and get an all-access block with no filter: they bypass `perm_scope`
anyway, but leaving them un-migrated would keep the legacy reader alive for the one account
most likely to be inspected when something looks wrong.
"""
out = {}
for uname, rec in (registry_dict or {}).items():
if not isinstance(rec, dict) or perm_scope.is_migrated(rec):
continue
out[uname] = perms_for(rec, modules)
return out
#: The row members whose meaning depends on `team_id` β the ones amendment 3 is about. A
#: reconciliation that skipped these would be the pid-only gate that cannot see the leak.
VALUE_KEYS = ('rev', 'ly', 'ltm', 'aov', 'est_missed', 'status', 'orders', 'last_order')
def reconcile(rec, pool_fn, modules=(MODULE,)):
"""Prove the migrated record sees EXACTLY what the legacy record saw.
`pool_fn(agent, team_id) -> rows` is injected so this is testable without Odoo and so the
caller decides whether to hit the live builder or a fixture.
Returns `{ok, legacy_scope, derived_scope, lost, gained, value_diffs}`:
* `lost` β pids the legacy wall admitted and the migrated one drops. Expected to be
EMPTY. One narrow class is known and must be REPORTED rather than tolerated:
`_dba_attrs` uses a 731-day window while `_cust_rev`'s LY window can reach a
little further back, so a customer whose only in-scope revenue predates that
window carries a BLANK `dba` and fails a `dba` leaf. Blank is the honest "no
brand attributable" state, so such a customer is NAMED here, never rounded
away.
* `gained` β pids the migrated wall admits and the legacy one did not. Must be empty: it
is the widening direction, and there is no acceptable non-empty value.
* `value_diffs` β per-pid disagreements on VALUE_KEYS. The assertion amendment 3 exists
for; a non-empty list means the pushdown was not applied and the numbers are
consolidated.
"""
legacy_scope = (perms.scope_agent(rec), perms.scope_team_id(rec))
migrated = dict(rec)
migrated['perms'] = perms_for(rec, modules)
migrated['perms_v'] = perm_scope.PERMS_VERSION
team_id, agent = perm_scope.derive_pool_scope(migrated, MODULE)
derived_scope = (agent, team_id)
legacy_rows = pool_fn(legacy_scope[0], legacy_scope[1])
derived_rows = pool_fn(agent, team_id)
fields = _fields_of(legacy_rows)
kept = perm_scope.apply_row_scope(derived_rows, migrated, MODULE, fields)
legacy_by = {r.get('pid'): r for r in legacy_rows}
kept_by = {r.get('pid'): r for r in kept}
lost = sorted(set(legacy_by) - set(kept_by))
gained = sorted(set(kept_by) - set(legacy_by))
value_diffs = []
for pid in sorted(set(legacy_by) & set(kept_by)):
a, b = legacy_by[pid], kept_by[pid]
for k in VALUE_KEYS:
if k in a and a.get(k) != b.get(k):
value_diffs.append({'pid': pid, 'key': k,
'legacy': a.get(k), 'migrated': b.get(k)})
return {'ok': not lost and not gained and not value_diffs,
'legacy_scope': legacy_scope, 'derived_scope': derived_scope,
'lost': lost, 'gained': gained, 'value_diffs': value_diffs,
'names': {r.get('pid'): r.get('customer') for r in legacy_rows
if r.get('pid') in set(lost)}}
def _fields_of(rows):
"""A field list good enough to EVALUATE with, inferred from the assembled rows.
The permanent filters this module builds name `dba` (text) and `agent` (text) only, so the
inference is exact for them. It is deliberately not a schema: `permits()` needs types to
dispatch comparisons, and inferring `text` for an unknown key would be a guess with
consequences β so anything not recognised is left out, which makes its leaf unanswerable and
therefore DENYING rather than silently mistyped.
"""
return [{'key': 'dba', 'type': 'text'}, {'key': 'agent', 'type': 'text'}]
|