er-smart-sync 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cdip_connector/__init__.py +2 -0
- cdip_connector/core/__init__.py +1 -0
- cdip_connector/core/schemas.py +3 -0
- er_smart_sync/__init__.py +9 -0
- er_smart_sync/_version.py +24 -0
- er_smart_sync/choices.py +497 -0
- er_smart_sync/cli.py +1227 -0
- er_smart_sync/config.py +71 -0
- er_smart_sync/defaults.py +154 -0
- er_smart_sync/protocols.py +41 -0
- er_smart_sync/smart_to_er.py +334 -0
- er_smart_sync/smart_to_er_v2.py +357 -0
- er_smart_sync/state.py +10 -0
- er_smart_sync/synchronizer.py +1314 -0
- er_smart_sync/utils.py +19 -0
- er_smart_sync-0.2.0.dist-info/METADATA +85 -0
- er_smart_sync-0.2.0.dist-info/RECORD +21 -0
- er_smart_sync-0.2.0.dist-info/WHEEL +5 -0
- er_smart_sync-0.2.0.dist-info/entry_points.txt +2 -0
- er_smart_sync-0.2.0.dist-info/licenses/LICENSE +200 -0
- er_smart_sync-0.2.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Compatibility shim: re-export from gundi_core.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
er_smart_sync/choices.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""SMART → EarthRanger Choice records.
|
|
2
|
+
|
|
3
|
+
Owns the choices layer required by ER v2 event types:
|
|
4
|
+
|
|
5
|
+
- Pure helpers: ``sanitize_choice_value``, ``derive_choice_field``,
|
|
6
|
+
``event_type_value_for``.
|
|
7
|
+
- Plan-record dataclasses: ``ChoiceOption``, ``ChoiceSet``, ``ChoicesStats``.
|
|
8
|
+
- DM walker: ``build_choice_sets``.
|
|
9
|
+
- Upsert algorithm: ``upsert_choices``.
|
|
10
|
+
|
|
11
|
+
See ``docs/superpowers/specs/2026-05-13-er-v2-choices-population-design.md``
|
|
12
|
+
for full design rationale.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import logging
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from pydantic import parse_obj_as
|
|
23
|
+
from smartconnect.models import Attribute, Category, CategoryAttribute
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sanitize_choice_value(option_key: str) -> str:
|
|
29
|
+
"""Map a SMART option key to a ``^\\w+$`` string.
|
|
30
|
+
|
|
31
|
+
SMART keys may contain ``.`` (TREE leaf paths), accents, apostrophes,
|
|
32
|
+
spaces. The Choice DB column requires letters/digits/underscores only.
|
|
33
|
+
|
|
34
|
+
This rule is **load-bearing**: changing it later requires backfilling
|
|
35
|
+
historical event records that store the resolved value string.
|
|
36
|
+
"""
|
|
37
|
+
sanitized = re.sub(r"[^A-Za-z0-9]+", "_", option_key).strip("_").lower()
|
|
38
|
+
return sanitized or "_"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def derive_choice_field(event_type_value: str, attr_key: str) -> str:
|
|
42
|
+
"""Derive a stable Choice.field name.
|
|
43
|
+
|
|
44
|
+
Returns ``et{8hex}_{sanitized_attr_key}``. Total length ≤ 40 chars
|
|
45
|
+
(truncated if needed; collisions vanishingly rare since SMART attr keys
|
|
46
|
+
are well under 28 chars in practice).
|
|
47
|
+
"""
|
|
48
|
+
digest = hashlib.sha256(event_type_value.encode("utf-8")).hexdigest()[:8]
|
|
49
|
+
sanitized = sanitize_choice_value(attr_key)
|
|
50
|
+
field = f"et{digest}_{sanitized}"
|
|
51
|
+
if len(field) > 40:
|
|
52
|
+
field = field[:40]
|
|
53
|
+
return field
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def event_type_value_for(
|
|
57
|
+
*,
|
|
58
|
+
category_path: str,
|
|
59
|
+
ca_uuid: str,
|
|
60
|
+
cm: dict | None,
|
|
61
|
+
) -> str:
|
|
62
|
+
"""Compute the event-type ``value`` string.
|
|
63
|
+
|
|
64
|
+
Mirrors the scheme used by ``smart_to_er_v2._build_one``:
|
|
65
|
+
|
|
66
|
+
- Without CM: ``{ca_uuid}_{path_underscored}`` lowercased.
|
|
67
|
+
- With CM: ``{ca_uuid}_{cm_uuid}_{path_underscored}`` lowercased.
|
|
68
|
+
|
|
69
|
+
Caller passes ``category_path`` already resolved (``cat.path`` when no CM,
|
|
70
|
+
``cat.hkeyPath`` when CM is present — same rule the existing builder
|
|
71
|
+
uses).
|
|
72
|
+
"""
|
|
73
|
+
path_underscored = category_path.replace(".", "_")
|
|
74
|
+
if cm:
|
|
75
|
+
value = f"{ca_uuid}_{cm['cm_uuid']}_{path_underscored}"
|
|
76
|
+
else:
|
|
77
|
+
value = f"{ca_uuid}_{path_underscored}"
|
|
78
|
+
return value.lower()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class ChoiceOption:
|
|
83
|
+
"""A single option in a choice set, with its activity flag."""
|
|
84
|
+
|
|
85
|
+
value: str
|
|
86
|
+
display: str
|
|
87
|
+
is_active: bool = True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class ChoiceSet:
|
|
92
|
+
"""The plan for one ER ``Choice.field`` worth of records."""
|
|
93
|
+
|
|
94
|
+
field: str
|
|
95
|
+
options: tuple[ChoiceOption, ...]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class ChoicesStats:
|
|
100
|
+
"""Per-run counters for the choices upsert phase."""
|
|
101
|
+
|
|
102
|
+
created: int = 0
|
|
103
|
+
updated: int = 0
|
|
104
|
+
unchanged: int = 0
|
|
105
|
+
deactivated: int = 0
|
|
106
|
+
errored: int = 0
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# Attribute types that bear choice options. Other types (TEXT, NUMERIC, etc.)
|
|
110
|
+
# never produce ChoiceSets.
|
|
111
|
+
_CHOICE_TYPES = {"LIST", "MLIST", "TREE"}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def build_choice_sets(
|
|
115
|
+
*,
|
|
116
|
+
dm: dict,
|
|
117
|
+
cm: dict | None = None,
|
|
118
|
+
ca_uuid: str,
|
|
119
|
+
) -> list[ChoiceSet]:
|
|
120
|
+
"""Walk a SMART data model and emit one ChoiceSet per (event_type, choice attr).
|
|
121
|
+
|
|
122
|
+
Mirrors the structure of ``smart_to_er_v2.build_event_types_v2`` so that
|
|
123
|
+
field names line up byte-for-byte. Does not produce event types; only
|
|
124
|
+
the choices plan.
|
|
125
|
+
"""
|
|
126
|
+
source = cm if cm else dm
|
|
127
|
+
cats = parse_obj_as(list[Category], source.get("categories") or [])
|
|
128
|
+
cat_paths = [cat.path for cat in cats]
|
|
129
|
+
attributes = parse_obj_as(list[Attribute], dm.get("attributes") or [])
|
|
130
|
+
|
|
131
|
+
result: list[ChoiceSet] = []
|
|
132
|
+
for cat in cats:
|
|
133
|
+
# Only leaf-or-CM-driven categories emit event types; only those need choices.
|
|
134
|
+
is_leaf = _is_leaf_node(cat_paths, cat.path)
|
|
135
|
+
is_active = bool(cm) or (cat.is_active and is_leaf)
|
|
136
|
+
if not is_active:
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
# Compute the same event_type_value the v2 builder will use.
|
|
140
|
+
path_for_value = cat.hkeyPath if cm else cat.path
|
|
141
|
+
et_value = event_type_value_for(
|
|
142
|
+
category_path=path_for_value,
|
|
143
|
+
ca_uuid=ca_uuid,
|
|
144
|
+
cm=cm,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Collect attributes from this category plus inherited (non-CM only).
|
|
148
|
+
path_components = path_for_value.split(".")
|
|
149
|
+
leaf_attrs = list(cat.attributes)
|
|
150
|
+
if not cm:
|
|
151
|
+
leaf_attrs.extend(_inherited_attributes(cats, path_components))
|
|
152
|
+
|
|
153
|
+
attribute_configs = cm.get("attributes") if cm else None
|
|
154
|
+
|
|
155
|
+
for cat_attr in leaf_attrs:
|
|
156
|
+
attribute = next(
|
|
157
|
+
(a for a in attributes if a.key == cat_attr.key),
|
|
158
|
+
None,
|
|
159
|
+
)
|
|
160
|
+
if attribute is None or attribute.type not in _CHOICE_TYPES:
|
|
161
|
+
continue
|
|
162
|
+
options = list(attribute.options or [])
|
|
163
|
+
if not options:
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
options_cfg = _options_config_for(attribute_configs, cat_attr.key)
|
|
167
|
+
if options_cfg is not None:
|
|
168
|
+
choice_options = _options_from_cm_config(options, options_cfg)
|
|
169
|
+
else:
|
|
170
|
+
if attribute.type == "TREE":
|
|
171
|
+
options = _leaf_options(options)
|
|
172
|
+
choice_options = tuple(
|
|
173
|
+
ChoiceOption(
|
|
174
|
+
value=sanitize_choice_value(o.key),
|
|
175
|
+
display=o.display,
|
|
176
|
+
is_active=True,
|
|
177
|
+
)
|
|
178
|
+
for o in options
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if not choice_options:
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
result.append(
|
|
185
|
+
ChoiceSet(
|
|
186
|
+
field=derive_choice_field(et_value, cat_attr.key),
|
|
187
|
+
options=choice_options,
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _is_leaf_node(node_paths: list[str], cur_node: str) -> bool:
|
|
195
|
+
prefix = f"{cur_node}."
|
|
196
|
+
return not any(p.startswith(prefix) for p in node_paths)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _inherited_attributes(
|
|
200
|
+
cats: list[Category], path_components: list[str]
|
|
201
|
+
) -> list[CategoryAttribute]:
|
|
202
|
+
inherited: list[CategoryAttribute] = []
|
|
203
|
+
parent_path = ""
|
|
204
|
+
for component in path_components[:-1]:
|
|
205
|
+
parent_path = component if not parent_path else f"{parent_path}.{component}"
|
|
206
|
+
parent_cat = next((c for c in cats if c.path == parent_path), None)
|
|
207
|
+
if parent_cat:
|
|
208
|
+
inherited.extend(parent_cat.attributes)
|
|
209
|
+
return inherited
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _options_config_for(attribute_configs: list | None, key: str) -> list | None:
|
|
213
|
+
if not attribute_configs:
|
|
214
|
+
return None
|
|
215
|
+
cfg = next((c for c in attribute_configs if c.get("key") == key), None)
|
|
216
|
+
return cfg.get("options") if cfg else None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _options_from_cm_config(
|
|
220
|
+
options: list, options_config: list
|
|
221
|
+
) -> tuple[ChoiceOption, ...]:
|
|
222
|
+
"""Build ChoiceOptions in CM order. Options the CM marks isActive=False
|
|
223
|
+
appear with is_active=False; options the CM omits entirely are dropped."""
|
|
224
|
+
by_key = {o.key: o for o in options}
|
|
225
|
+
result: list[ChoiceOption] = []
|
|
226
|
+
for opt_cfg in options_config:
|
|
227
|
+
key = opt_cfg.get("key")
|
|
228
|
+
if not key:
|
|
229
|
+
continue
|
|
230
|
+
original = by_key.get(key)
|
|
231
|
+
if not original:
|
|
232
|
+
logger.warning("CM references unknown option key %s", key)
|
|
233
|
+
continue
|
|
234
|
+
result.append(
|
|
235
|
+
ChoiceOption(
|
|
236
|
+
value=sanitize_choice_value(original.key),
|
|
237
|
+
display=original.display,
|
|
238
|
+
is_active=bool(opt_cfg.get("isActive")),
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
return tuple(result)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _leaf_options(options: list) -> list:
|
|
245
|
+
"""For TREE option sets: keep only leaves (no children)."""
|
|
246
|
+
keys = [o.key for o in options]
|
|
247
|
+
return [o for o in options if _is_leaf_node(keys, o.key)]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ER's Choice DB model is shared across content types; for event types we
|
|
251
|
+
# always POST with model="activity.event" (the serializer default).
|
|
252
|
+
_CHOICE_MODEL = "activity.event"
|
|
253
|
+
|
|
254
|
+
# Path prefix for the choices REST endpoint. Versionless in ER.
|
|
255
|
+
_CHOICES_PATH = "choices"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def upsert_choices(
|
|
259
|
+
*,
|
|
260
|
+
er_client,
|
|
261
|
+
choice_sets: list[ChoiceSet],
|
|
262
|
+
) -> ChoicesStats:
|
|
263
|
+
"""Upsert each ChoiceSet against ER's Choices API.
|
|
264
|
+
|
|
265
|
+
Returns a ChoicesStats counter dataclass. Per-option HTTP failures are
|
|
266
|
+
logged and counted (no raise) — per-set processing is independent, an
|
|
267
|
+
error in one ChoiceSet does not block subsequent sets.
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
ValueError: when the same ``ChoiceSet.field`` appears twice in
|
|
271
|
+
``choice_sets`` with different options. This is a builder bug;
|
|
272
|
+
two ChoiceSets with the same field MUST have identical options.
|
|
273
|
+
"""
|
|
274
|
+
stats = ChoicesStats()
|
|
275
|
+
seen_fields: dict[str, ChoiceSet] = {}
|
|
276
|
+
|
|
277
|
+
total = len(choice_sets)
|
|
278
|
+
logger.info("Upserting %d choice set(s)", total)
|
|
279
|
+
|
|
280
|
+
for idx, cs in enumerate(choice_sets, start=1):
|
|
281
|
+
# Deduplicate: same field, identical options is fine; same field,
|
|
282
|
+
# different options is a builder bug (raises ValueError, see docstring).
|
|
283
|
+
if cs.field in seen_fields:
|
|
284
|
+
if seen_fields[cs.field].options != cs.options:
|
|
285
|
+
raise ValueError(
|
|
286
|
+
f"ChoiceSet field {cs.field!r} appears twice with "
|
|
287
|
+
f"different options; this is a builder bug."
|
|
288
|
+
)
|
|
289
|
+
logger.debug(
|
|
290
|
+
"Choice set %d/%d field=%s (duplicate, skipping)",
|
|
291
|
+
idx, total, cs.field,
|
|
292
|
+
)
|
|
293
|
+
continue
|
|
294
|
+
seen_fields[cs.field] = cs
|
|
295
|
+
|
|
296
|
+
logger.info(
|
|
297
|
+
"Choice set %d/%d field=%s (%d options)",
|
|
298
|
+
idx, total, cs.field, len(cs.options),
|
|
299
|
+
)
|
|
300
|
+
try:
|
|
301
|
+
_upsert_one_set(er_client=er_client, cs=cs, stats=stats)
|
|
302
|
+
except Exception:
|
|
303
|
+
# Unexpected error escaping _upsert_one_set (per-option HTTP errors
|
|
304
|
+
# are already caught and counted inside it). Count as one failed
|
|
305
|
+
# set rather than len(cs.options): the per-option counters may
|
|
306
|
+
# already reflect some succeeded options before the catastrophic
|
|
307
|
+
# exception, so adding len(options) would over-count.
|
|
308
|
+
logger.exception(
|
|
309
|
+
"Failed to upsert ChoiceSet",
|
|
310
|
+
extra=dict(field=cs.field),
|
|
311
|
+
)
|
|
312
|
+
stats.errored += 1
|
|
313
|
+
|
|
314
|
+
logger.info(
|
|
315
|
+
"Choices done: created=%d updated=%d unchanged=%d "
|
|
316
|
+
"deactivated=%d errored=%d",
|
|
317
|
+
stats.created, stats.updated, stats.unchanged,
|
|
318
|
+
stats.deactivated, stats.errored,
|
|
319
|
+
)
|
|
320
|
+
return stats
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _upsert_one_set(*, er_client, cs: ChoiceSet, stats: ChoicesStats) -> None:
|
|
324
|
+
existing = _fetch_existing(er_client=er_client, field=cs.field)
|
|
325
|
+
existing_by_value: dict[str, dict] = {r["value"]: r for r in existing}
|
|
326
|
+
planned_values: set[str] = set()
|
|
327
|
+
|
|
328
|
+
for ordernum, planned in enumerate(cs.options):
|
|
329
|
+
planned_values.add(planned.value)
|
|
330
|
+
existing_record = existing_by_value.get(planned.value)
|
|
331
|
+
if existing_record is None:
|
|
332
|
+
_create_choice(
|
|
333
|
+
er_client=er_client,
|
|
334
|
+
cs_field=cs.field,
|
|
335
|
+
option=planned,
|
|
336
|
+
ordernum=ordernum,
|
|
337
|
+
stats=stats,
|
|
338
|
+
)
|
|
339
|
+
else:
|
|
340
|
+
_maybe_patch_choice(
|
|
341
|
+
er_client=er_client,
|
|
342
|
+
existing=existing_record,
|
|
343
|
+
planned=planned,
|
|
344
|
+
ordernum=ordernum,
|
|
345
|
+
stats=stats,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# Orphan handling: active records not in the plan get soft-deactivated.
|
|
349
|
+
for record in existing:
|
|
350
|
+
if record["value"] in planned_values:
|
|
351
|
+
continue
|
|
352
|
+
if not record.get("is_active"):
|
|
353
|
+
continue
|
|
354
|
+
try:
|
|
355
|
+
from .synchronizer import _retry # local import avoids circular dependency
|
|
356
|
+
|
|
357
|
+
_retry(
|
|
358
|
+
er_client._patch,
|
|
359
|
+
path=f"{_CHOICES_PATH}/{record['id']}",
|
|
360
|
+
payload={"is_active": False},
|
|
361
|
+
)
|
|
362
|
+
stats.deactivated += 1
|
|
363
|
+
except Exception as e:
|
|
364
|
+
logger.exception(
|
|
365
|
+
"Failed to deactivate orphan choice",
|
|
366
|
+
extra=dict(id=record.get("id"), error=str(e)),
|
|
367
|
+
)
|
|
368
|
+
stats.errored += 1
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _fetch_existing(*, er_client, field: str) -> list[dict]:
|
|
372
|
+
"""List all existing Choice records for (model=activity.event, field=...).
|
|
373
|
+
|
|
374
|
+
ER's choices endpoint uses django-filter's ``AllValuesMultipleFilter`` on
|
|
375
|
+
``field=``, which validates the value against the set of `field` values
|
|
376
|
+
that already exist in the database. On a fresh tenant where no Choice
|
|
377
|
+
has our derived field name yet, the filter returns 400 — we interpret
|
|
378
|
+
that as "no existing records for this field" and return an empty list.
|
|
379
|
+
|
|
380
|
+
Also passes ``max_retries=0`` to skip ERClient's default 5-retry loop;
|
|
381
|
+
400s from missing-field filtering don't fix themselves on retry, and
|
|
382
|
+
upserts have their own _retry wrapper for transient failures.
|
|
383
|
+
"""
|
|
384
|
+
from erclient.er_errors import ERClientException
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
page = er_client._get(
|
|
388
|
+
path=_CHOICES_PATH,
|
|
389
|
+
params={
|
|
390
|
+
"model": _CHOICE_MODEL,
|
|
391
|
+
"field": field,
|
|
392
|
+
"include_inactive": True,
|
|
393
|
+
"page_size": 200,
|
|
394
|
+
},
|
|
395
|
+
max_retries=0,
|
|
396
|
+
)
|
|
397
|
+
except ERClientException as e:
|
|
398
|
+
if "is not one of the available choices" in str(e):
|
|
399
|
+
return []
|
|
400
|
+
raise
|
|
401
|
+
|
|
402
|
+
results: list[dict] = []
|
|
403
|
+
while True:
|
|
404
|
+
if isinstance(page, dict) and "results" in page:
|
|
405
|
+
results.extend(page["results"])
|
|
406
|
+
next_url = page.get("next")
|
|
407
|
+
if not next_url:
|
|
408
|
+
break
|
|
409
|
+
page = er_client._get(path=next_url, max_retries=0)
|
|
410
|
+
elif isinstance(page, list):
|
|
411
|
+
results.extend(page)
|
|
412
|
+
break
|
|
413
|
+
else:
|
|
414
|
+
break
|
|
415
|
+
return results
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _create_choice(
|
|
419
|
+
*,
|
|
420
|
+
er_client,
|
|
421
|
+
cs_field: str,
|
|
422
|
+
option: ChoiceOption,
|
|
423
|
+
ordernum: int,
|
|
424
|
+
stats: ChoicesStats,
|
|
425
|
+
) -> None:
|
|
426
|
+
payload = {
|
|
427
|
+
"model": _CHOICE_MODEL,
|
|
428
|
+
"field": cs_field,
|
|
429
|
+
"value": option.value,
|
|
430
|
+
"display": option.display,
|
|
431
|
+
"ordernum": ordernum,
|
|
432
|
+
"is_active": option.is_active,
|
|
433
|
+
}
|
|
434
|
+
try:
|
|
435
|
+
from .synchronizer import _retry # local import avoids circular dependency
|
|
436
|
+
|
|
437
|
+
_retry(
|
|
438
|
+
er_client._post,
|
|
439
|
+
path=_CHOICES_PATH,
|
|
440
|
+
payload=payload,
|
|
441
|
+
)
|
|
442
|
+
stats.created += 1
|
|
443
|
+
except Exception as e:
|
|
444
|
+
logger.exception(
|
|
445
|
+
"Failed to POST choice",
|
|
446
|
+
extra=dict(field=cs_field, value=option.value, error=str(e)),
|
|
447
|
+
)
|
|
448
|
+
stats.errored += 1
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _maybe_patch_choice(
|
|
452
|
+
*,
|
|
453
|
+
er_client,
|
|
454
|
+
existing: dict,
|
|
455
|
+
planned: ChoiceOption,
|
|
456
|
+
ordernum: int,
|
|
457
|
+
stats: ChoicesStats,
|
|
458
|
+
) -> None:
|
|
459
|
+
changes: dict = {}
|
|
460
|
+
if existing.get("display") != planned.display:
|
|
461
|
+
changes["display"] = planned.display
|
|
462
|
+
if existing.get("ordernum") != ordernum:
|
|
463
|
+
changes["ordernum"] = ordernum
|
|
464
|
+
if existing.get("is_active") != planned.is_active:
|
|
465
|
+
changes["is_active"] = planned.is_active
|
|
466
|
+
|
|
467
|
+
if not changes:
|
|
468
|
+
stats.unchanged += 1
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
is_deactivation = (
|
|
472
|
+
"is_active" in changes
|
|
473
|
+
and existing.get("is_active") is True
|
|
474
|
+
and planned.is_active is False
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
path = f"{_CHOICES_PATH}/{existing['id']}"
|
|
478
|
+
try:
|
|
479
|
+
from .synchronizer import _retry # local import avoids circular dependency
|
|
480
|
+
|
|
481
|
+
_retry(
|
|
482
|
+
er_client._patch,
|
|
483
|
+
path=path,
|
|
484
|
+
payload=changes,
|
|
485
|
+
)
|
|
486
|
+
except Exception as e:
|
|
487
|
+
logger.exception(
|
|
488
|
+
"Failed to PATCH choice",
|
|
489
|
+
extra=dict(id=existing.get("id"), error=str(e)),
|
|
490
|
+
)
|
|
491
|
+
stats.errored += 1
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
if is_deactivation:
|
|
495
|
+
stats.deactivated += 1
|
|
496
|
+
else:
|
|
497
|
+
stats.updated += 1
|