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.
@@ -0,0 +1,2 @@
1
+ # Compatibility shim: smartconnect-client v1.11.0 imports from cdip_connector,
2
+ # which was renamed to gundi_core.
@@ -0,0 +1 @@
1
+ # Compatibility shim: re-export from gundi_core.
@@ -0,0 +1,3 @@
1
+ # Compatibility shim: smartconnect-client v1.11.0 imports from
2
+ # cdip_connector.core.schemas, which was renamed to gundi_core.schemas.
3
+ from gundi_core.schemas import * # noqa: F401,F403
@@ -0,0 +1,9 @@
1
+ from .config import EarthRangerConfig, SmartConnectConfig, SyncConfig
2
+ from .synchronizer import ERSmartSynchronizer
3
+
4
+ __all__ = [
5
+ "ERSmartSynchronizer",
6
+ "SyncConfig",
7
+ "SmartConnectConfig",
8
+ "EarthRangerConfig",
9
+ ]
@@ -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
@@ -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