11"""Reconcile proposed User items against the ones already stored.
22
33Pure logic: given proposed users and an index of existing items, classify each
4- into NEW, GAP_FILL, CONFLICT or UNCHANGED and record the per-field differences.
4+ into NEW, GAP_FILL, CONFLICT or UNCHANGED and record the per-field differences
5+ (including REMOVE diffs for disabled optional fields and protected fields).
56The interactive layer consumes this plan; nothing here does IO.
67"""
78
89from __future__ import annotations
910
1011from collections import Counter
1112from dataclasses import dataclass , field
12- from typing import Any , Dict , List , Optional
13+ from typing import Any , Dict , FrozenSet , List , Optional
1314
15+ from .config import PROTECTED_FIELDS
1416from .existing import ExistingUsers
1517from .mapping import ProposedUser
1618
1719NEW = "new"
1820GAP_FILL = "gap_fill"
1921CONFLICT = "conflict"
22+ REMOVE = "remove"
2023UNCHANGED = "unchanged"
2124
2225# username is the match key and is never reconciled as a value.
2326SCALAR_FIELDS = ("label" , "first_name" , "surname" , "orcid" )
2427SET_FIELDS = ("emails" , "websites" , "organizations" )
2528URL_FIELDS = ("orcid" , "websites" )
2629RECONCILED_FIELDS = SCALAR_FIELDS + SET_FIELDS
30+ # Fields that are only written when opted in; removed from existing when disabled.
31+ OPTIONAL_FIELDS = ("websites" , "organizations" )
2732
2833
2934def _is_empty (value : Any ) -> bool :
@@ -76,6 +81,12 @@ def _existing_orgs(entity: Any) -> Any:
7681 return None
7782
7883
84+ def _existing_has_iri (entity : Any , name : str ) -> bool :
85+ """True if the loaded entity carries a relation IRI for ``name``."""
86+ iris = getattr (entity , "__iris__" , None )
87+ return isinstance (iris , dict ) and bool (iris .get (name ))
88+
89+
7990def existing_fields (entity : Any ) -> Dict [str , Any ]:
8091 """Normalized comparable field view of a loaded User entity."""
8192 return {
@@ -109,7 +120,7 @@ class FieldDiff:
109120 name : str
110121 existing : Any
111122 proposed : Any
112- status : str # GAP_FILL or CONFLICT
123+ status : str # GAP_FILL, CONFLICT or REMOVE
113124
114125
115126@dataclass
@@ -131,20 +142,37 @@ def _compare(existing_val: Any, proposed_val: Any) -> Optional[str]:
131142 return UNCHANGED if existing_val == proposed_val else CONFLICT
132143
133144
134- def reconcile_user (proposed : ProposedUser , existing : Optional [Any ]) -> UserChange :
145+ def reconcile_user (
146+ proposed : ProposedUser ,
147+ existing : Optional [Any ],
148+ enabled_fields : FrozenSet [str ] = frozenset (),
149+ prune : bool = False ,
150+ ) -> UserChange :
135151 if existing is None :
136152 return UserChange (proposed = proposed , existing = None , category = NEW )
137153 ef = existing_fields (existing )
138154 pf = proposed_fields (proposed )
139155 diffs : List [FieldDiff ] = []
140- has_conflict = has_gap = False
156+ has_conflict = has_change = False
141157 for name in RECONCILED_FIELDS :
158+ if name in OPTIONAL_FIELDS and name not in enabled_fields :
159+ # Disabled optional field: remove any existing value only when pruning.
160+ if prune and not _is_empty (ef [name ]):
161+ diffs .append (FieldDiff (name , ef [name ], None , REMOVE ))
162+ has_change = True
163+ continue
142164 status = _compare (ef [name ], pf [name ])
143165 if status in (GAP_FILL , CONFLICT ):
144166 diffs .append (FieldDiff (name , ef [name ], pf [name ], status ))
145167 has_conflict = has_conflict or status == CONFLICT
146- has_gap = has_gap or status == GAP_FILL
147- category = CONFLICT if has_conflict else GAP_FILL if has_gap else UNCHANGED
168+ has_change = has_change or status == GAP_FILL
169+ # Protected fields are stripped from existing items only when pruning.
170+ if prune :
171+ for name in PROTECTED_FIELDS :
172+ if _existing_has_iri (existing , name ):
173+ diffs .append (FieldDiff (name , "set" , None , REMOVE ))
174+ has_change = True
175+ category = CONFLICT if has_conflict else GAP_FILL if has_change else UNCHANGED
148176 return UserChange (
149177 proposed = proposed , existing = existing , category = category , diffs = diffs
150178 )
@@ -171,7 +199,13 @@ def counts(self) -> Dict[str, int]:
171199
172200
173201def reconcile (
174- proposed_users : List [ProposedUser ], existing : ExistingUsers
202+ proposed_users : List [ProposedUser ],
203+ existing : ExistingUsers ,
204+ enabled_fields : FrozenSet [str ] = frozenset (),
205+ prune : bool = False ,
175206) -> ReconcilePlan :
176- changes = [reconcile_user (p , existing .get (p .username )) for p in proposed_users ]
207+ changes = [
208+ reconcile_user (p , existing .get (p .username ), enabled_fields , prune )
209+ for p in proposed_users
210+ ]
177211 return ReconcilePlan (changes = changes )
0 commit comments