Organized

This commit is contained in:
Jason Thistlethwaite
2026-10-17 20:50:35 -04:00
parent bd26c8894f
commit 9ee9eb3ec6
130 changed files with 24172 additions and 140 deletions
+230
View File
@@ -0,0 +1,230 @@
"""CRM upsert client supporting idempotency by Redmine ID and email fallback."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional
logger = logging.getLogger(__name__)
class CrmError(Exception):
"""Base exception for CRM errors."""
def __init__(
self,
message: str,
*,
redmine_id: Optional[int] = None,
crm_id: Optional[str] = None,
) -> None:
super().__init__(message)
self.redmine_id = redmine_id
self.crm_id = crm_id
@dataclass(frozen=True, slots=True)
class UpsertResult:
crm_id: str
created: bool
updated: bool
skipped_unchanged: bool
redmine_contact_id: int
class CrmClient:
"""Generic CRM upsert wrapper.
Provides primitives for local testing; real CRM adapters should subclass
and implement _find_by_redmine_id, _find_by_email, _create, and _update.
"""
# Shared simulated CRM store for the in-memory adapter.
_store: Dict[str, Dict[str, Any]] = {}
def __init__(
self,
*,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
find_by_redmine_id: Optional[Callable[[int], Optional[Dict[str, Any]]]] = None,
find_by_email: Optional[Callable[[str], Optional[Dict[str, Any]]]] = None,
create: Optional[Callable[[Dict[str, Any]], str]] = None,
update: Optional[Callable[[str, Dict[str, Any]], None]] = None,
) -> None:
self.api_key = api_key
self.base_url = base_url
self._find_by_redmine_id_fn = find_by_redmine_id
self._find_by_email_fn = find_by_email
self._create_fn = create
self._update_fn = update
# --- Extensibility hooks (override in subclasses) -----------------------
def _find_by_redmine_id(self, redmine_id: int) -> Optional[Dict[str, Any]]:
if self._find_by_redmine_id_fn:
return self._find_by_redmine_id_fn(redmine_id)
for record in self._store.values():
if record.get("redmine_contact_id") == redmine_id:
return record
return None
def _find_by_email(self, email: str) -> Optional[Dict[str, Any]]:
if self._find_by_email_fn:
return self._find_by_email_fn(email)
normalized = email.lower().strip()
for record in self._store.values():
for entry in record.get("emails", []):
if entry.get("value", "").lower().strip() == normalized:
return record
return None
def _create(self, record: Dict[str, Any]) -> str:
if self._create_fn:
return self._create_fn(record)
crm_id = f"crm::{record['redmine_contact_id']}"
self._store[crm_id] = record.copy()
return crm_id
def _update(self, crm_id: str, record: Dict[str, Any]) -> None:
if self._update_fn:
self._update_fn(crm_id, record)
return
self._store[crm_id] = record.copy()
# --- Public API ---------------------------------------------------------
def _normalize_email(self, email: Optional[str]) -> Optional[str]:
if not email:
return None
return email.lower().strip()
def _has_changed(
self,
existing: Dict[str, Any],
candidate: Dict[str, Any],
) -> bool:
"""Return True if meaningful Redmine data changed."""
if not existing:
return True
candidate_ts = candidate.get("last_redmine_update")
existing_ts = existing.get("last_redmine_update")
if candidate_ts and existing_ts:
return candidate_ts != existing_ts
# Fallback to structural comparison for fields we care about.
keys = {
"full_name",
"company",
"title",
"emails",
"phones",
"urls",
"address",
"notes",
"tags",
"birthday",
}
for key in keys:
if existing.get(key) != candidate.get(key):
return True
return False
def upsert(self, record: Dict[str, Any]) -> UpsertResult:
"""Idempotently upsert a mapped Redmine contact into the CRM."""
redmine_id = record.get("redmine_contact_id")
if redmine_id is None:
raise CrmError("Record missing redmine_contact_id")
existing = self._find_by_redmine_id(redmine_id)
fallback_email: Optional[str] = None
if not existing and record.get("emails"):
fallback_email = self._normalize_email(record["emails"][0]["value"])
if fallback_email:
existing = self._find_by_email(fallback_email)
if existing:
crm_id = existing["crm_id"]
if not crm_id:
raise CrmError(
"Existing CRM record missing crm_id",
redmine_id=redmine_id,
)
if not self._has_changed(existing, record):
logger.info(
"CRM record %s unchanged for Redmine contact %s; skipping",
crm_id,
redmine_id,
)
return UpsertResult(
crm_id=crm_id,
created=False,
updated=False,
skipped_unchanged=True,
redmine_contact_id=redmine_id,
)
record["crm_id"] = crm_id
if fallback_email and not existing.get("redmine_contact_id"):
# If matched by email, attach Redmine ID to close the loop.
logger.info(
"Matched Redmine contact %s by email %s; updating CRM record %s",
redmine_id,
fallback_email,
crm_id,
)
self._update(crm_id, record)
return UpsertResult(
crm_id=crm_id,
created=False,
updated=True,
skipped_unchanged=False,
redmine_contact_id=redmine_id,
)
crm_id = self._create(record)
record["crm_id"] = crm_id
self._store[crm_id] = record.copy()
return UpsertResult(
crm_id=crm_id,
created=True,
updated=False,
skipped_unchanged=False,
redmine_contact_id=redmine_id,
)
def sync_batch(
self,
records: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""Sync a batch and return aggregated stats."""
stats = {
"processed": 0,
"created": 0,
"updated": 0,
"skipped": 0,
"errors": 0,
"error_records": [],
}
for record in records:
stats["processed"] += 1
try:
result = self.upsert(record)
if result.created:
stats["created"] += 1
elif result.updated:
stats["updated"] += 1
else:
stats["skipped"] += 1
except CrmError as exc:
stats["errors"] += 1
stats["error_records"].append(
{
"redmine_contact_id": exc.redmine_id,
"crm_id": exc.crm_id,
"reason": str(exc),
}
)
logger.warning("CRM upsert failed: %s", exc)
logger.info("CRM sync batch stats: %s", stats)
return stats