"""Planificador sintético: no se conecta a sistemas ni aplica cambios. Ejecutar con Python 3: python3 laboratorio-reconciliacion.py Las claves y estados son de este ejemplo; no son el esquema nativo de Moodle. """ from collections import Counter, defaultdict import copy import hashlib import json def fingerprint(value): return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest() def classify(source, target, known_people, complete): decisions = [] count = Counter(r['person'] for r in source) src = {r['person']: r for r in source} managed = defaultdict(list) for row in target: if row['origin'] != 'ACADEMIC_DEMO': decisions.append({'person': row['person'], 'decision': 'preserve-other-origin'}) else: managed[row['person']].append(row) for person in sorted(set(src) | set(managed)): if count[person] > 1 or len(managed.get(person, [])) > 1: decision = 'blocked-duplicate-key' elif person not in known_people: decision = 'blocked-unmapped-identity' elif person not in src: decision = 'review-missing-in-complete-source' if complete else 'blocked-incomplete-source' elif not managed.get(person): decision = 'propose-create' if src[person]['state'] == 'active' else 'no-active-target-required' elif src[person]['state'] == managed[person][0]['state']: decision = 'match' elif src[person]['state'] == 'suspended': decision = 'review-state-transition' else: decision = 'propose-state-adjustment' decisions.append({'person': person, 'decision': decision}) return sorted(decisions, key=lambda r: (r['person'], r['decision'])) def build_plan(source, target, known_people, complete): # El alcance forma parte de la huella; en una integración real necesita # comprobarse también contra filtros, paginación y contrato de extracción. return {'scope': 'ED_DEMO:alumnado:origen_ACADEMIC_DEMO', 'complete_source': complete, 'source_hash': fingerprint(source), 'target_hash': fingerprint(target), 'mapping_hash': fingerprint(sorted(known_people)), 'rules_version': 'demo-v1', 'decisions': classify(source, target, known_people, complete)} def plan_is_current(plan, source, target, complete, known_people, rules_version='demo-v1'): return (plan['source_hash'] == fingerprint(source) and plan['target_hash'] == fingerprint(target) and plan['complete_source'] == complete and plan['mapping_hash'] == fingerprint(sorted(known_people)) and plan['rules_version'] == rules_version) def main(): source = [{'person': person, 'state': status} for person, status in [ ('A', 'active'), ('B', 'active'), ('C', 'suspended'), ('X', 'active'), ('D', 'active'), ('D', 'active'), ('E', 'active')]] target = [{'person': p, 'state': s, 'origin': o} for p, s, o in [ ('A', 'suspended', 'ACADEMIC_DEMO'), ('C', 'active', 'ACADEMIC_DEMO'), ('Y', 'active', 'ACADEMIC_DEMO'), ('Z', 'active', 'COHORT_DEMO'), ('E', 'active', 'ACADEMIC_DEMO')]] known_people = set('ABCDEYZ') plan = build_plan(source, target, known_people, True) expected = {'A': 'propose-state-adjustment', 'B': 'propose-create', 'C': 'review-state-transition', 'D': 'blocked-duplicate-key', 'E': 'match', 'X': 'blocked-unmapped-identity', 'Y': 'review-missing-in-complete-source', 'Z': 'preserve-other-origin'} assert {r['person']: r['decision'] for r in plan['decisions']} == expected partial = build_plan(source, target, known_people, False) assert next(r['decision'] for r in partial['decisions'] if r['person'] == 'Y') == 'blocked-incomplete-source' assert plan_is_current(plan, source, target, True, known_people) changed = copy.deepcopy(target) changed[0]['state'] = 'active' assert not plan_is_current(plan, source, changed, True, known_people) assert not plan_is_current(plan, source, target, False, known_people) assert not plan_is_current(plan, source, target, True, known_people - {'B'}) assert not plan_is_current(plan, source, target, True, known_people, 'demo-v2') print(json.dumps({'scope': 'Conjuntos sintéticos de una edición, sin conexión ni escritura en Moodle.', 'source_rows': len(source), 'target_rows': len(target), 'complete_source_plan': plan, 'incomplete_source_plan': partial, 'changed_mapping_rejected': not plan_is_current(plan, source, target, True, known_people - {'B'}), 'changed_rules_rejected': not plan_is_current(plan, source, target, True, known_people, 'demo-v2'), 'stale_target_rejected': not plan_is_current(plan, source, changed, True, known_people), 'changed_completeness_rejected': not plan_is_current(plan, source, target, False, known_people)}, ensure_ascii=False, indent=2)) if __name__ == '__main__': main()