Files
Jason Thistlethwaite 9ee9eb3ec6 Organized
2026-10-17 20:50:35 -04:00

311 lines
9.9 KiB
Python

"""Map Redmine contact payloads to the target CRM schema and enrich missing data."""
from __future__ import annotations
import logging
import re
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
_EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
_PHONE_RE = re.compile(r"[\d+\-().\s]{7,}")
def _coalesce(*values: Any) -> Any:
"""Return the first non-empty value."""
for value in values:
if value:
return value
return None
def _to_iso(ts: Optional[str]) -> Optional[str]:
"""Normalize Redmine timestamps to ISO-8601 in UTC."""
if not ts:
return None
try:
# Redmine uses ISO-ish strings; this accepts several common formats.
parsed = datetime.fromisoformat(ts.replace("Z", "+00:00"))
parsed = parsed.astimezone(timezone.utc)
return parsed.isoformat()
except ValueError as exc:
logger.debug("Could not parse timestamp %r: %s", ts, exc)
return ts
def _normalize_name(
first: Optional[str],
last: Optional[str],
middle: Optional[str],
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
first = (first or "").strip() or None
middle = (middle or "").strip() or None
last = (last or "").strip() or None
return first, middle, last
def _build_full_name(
first: Optional[str],
last: Optional[str],
middle: Optional[str],
company: Optional[str],
is_company: bool,
) -> str:
if is_company and company:
return company
parts = [p for p in (first, middle, last) if p]
if parts:
return " ".join(parts)
if company:
return company
return "Unknown"
def _classify_phone(raw: str) -> str:
raw = re.sub(r"[^\d+]", "", raw)
# Heuristic: mobile numbers are often longer and start with a country code or specific prefixes.
if raw.startswith("+") and len(raw) >= 11:
return "mobile"
# North American mobile indicators are unreliable; default to work.
return "work"
def _normalize_phone(raw: Optional[str]) -> Optional[str]:
if not raw:
return None
digits = re.sub(r"[^\d+]", "", raw)
return digits if digits else None
def _extract_emails_from_text(text: Optional[str]) -> List[str]:
if not text:
return []
return list(dict.fromkeys(_EMAIL_RE.findall(text)))
def _company_from_email(email: Optional[str]) -> Optional[str]:
"""Heuristic: derive a company display name from an email domain."""
if not email or "@" not in email:
return None
domain = email.split("@")[1].strip().lower()
if not domain:
return None
# Strip common TLDs and subdomain noise.
name = domain.split(":")[0]
tokens = name.split(".")
if tokens[0] in ("www", "mail", "smtp", "contact", "info"):
tokens = tokens[1:]
if len(tokens) >= 2:
tokens = tokens[:-1]
core = tokens[0] if tokens else domain.split(".")[0]
title = core.replace("-", " ").title()
if title.lower() in ("gmail", "yahoo", "hotmail", "outlook", "aol"):
return None
return f"{title} Inc"
def _parse_address(addr: Optional[Dict[str, Any]]) -> Dict[str, Optional[str]]:
if not addr:
return {
"street": None,
"city": None,
"region": None,
"country": None,
"postal_code": None,
}
return {
"street": _coalesce(addr.get("street")),
"city": _coalesce(addr.get("city")),
"region": _coalesce(addr.get("state")),
"country": _coalesce(addr.get("country")),
"postal_code": _coalesce(addr.get("zip")),
}
def _normalize_tags(tag_list: Any) -> List[str]:
tags: List[str] = []
if isinstance(tag_list, str):
tag_list = [t.strip() for t in tag_list.split(",")]
if isinstance(tag_list, list):
for tag in tag_list:
if tag:
normalized = str(tag).strip().lower()
if normalized and normalized not in tags:
tags.append(normalized)
# Always mark origin.
if "from-redmine" not in tags:
tags.append("from-redmine")
return sorted(tags)
def _extract_custom(
custom_fields: Optional[List[Dict[str, Any]]],
keys: List[str],
) -> Optional[str]:
"""Return the value of the first custom field matching one of keys (case-insensitive)."""
if not custom_fields:
return None
key_set = {k.lower() for k in keys}
for field in custom_fields:
name = (field.get("name") or "").strip().lower()
if name in key_set:
value = field.get("value")
if value:
return str(value).strip()
return None
def _merge_notes(
background: Optional[str],
custom_fields: Optional[List[Dict[str, Any]]],
contact_url: str,
) -> str:
parts = []
if background:
parts.append(str(background).strip())
if custom_fields:
for field in custom_fields:
name = field.get("name")
value = field.get("value")
if name and value:
# Skip fields already promoted to typed CRM fields (e.g. LinkedIn).
if name.lower() in {"linkedin"}:
continue
parts.append(f"{name}: {value}")
parts.append(f"Redmine source: {contact_url}")
return "\n\n".join(parts)
class EnrichmentResult:
"""Result of enrichment: data plus metadata."""
def __init__(self) -> None:
self.company_source: Optional[str] = None
self.email_source: Optional[str] = None
self.confidence_issues: List[str] = []
def enrich(
mapped: Dict[str, Any],
raw: Dict[str, Any],
*,
enable_domain_lookup: bool = True,
confidence_threshold: float = 0.7,
) -> Tuple[Dict[str, Any], EnrichmentResult]:
"""Apply enrichment rules to a mapped CRM record."""
result = EnrichmentResult()
# Email fallback: scan background and custom fields.
if not mapped.get("emails"):
candidates = _extract_emails_from_text(raw.get("background"))
if candidates:
mapped["emails"] = [{"value": candidates[0], "type": "work"}]
result.email_source = "background"
if not mapped.get("emails"):
result.confidence_issues.append("No email found; record may not be upsertable")
# Company enrichment.
if not mapped.get("company") and mapped.get("emails"):
email = mapped["emails"][0]["value"]
if enable_domain_lookup:
company = _company_from_email(email)
if company:
mapped["company"] = company
result.company_source = "domain_heuristic"
if not mapped.get("company"):
result.confidence_issues.append("Company unknown")
if mapped.get("company") and not mapped.get("emails"):
# Company but no email: confidence low.
result.confidence_issues.append("Company available but no email")
# Phone normalization.
phones = mapped.get("phones") or []
normalized_phones: List[Dict[str, str]] = []
seen: set[str] = set()
for phone in phones:
value = _normalize_phone(phone.get("value"))
if value and value not in seen:
seen.add(value)
normalized_phones.append({
"value": value,
"type": phone.get("type", "work"),
})
mapped["phones"] = normalized_phones
# Confidence gate: not a hard failure, but logged.
if result.confidence_issues:
logger.info(
"Enrichment confidence issues for Redmine contact %s: %s",
mapped.get("redmine_contact_id"),
"; ".join(result.confidence_issues),
)
mapped["enriched_at"] = datetime.now(timezone.utc).isoformat()
return mapped, result
def map_contact(raw: Dict[str, Any], *, base_url: str) -> Dict[str, Any]:
"""Map a Redmine contact payload to the normalized CRM schema."""
contact_id = raw.get("id")
first_name, middle_name, last_name = _normalize_name(
raw.get("first_name"),
raw.get("last_name"),
raw.get("middle_name"),
)
company = _coalesce(raw.get("company"))
is_company = bool(raw.get("is_company"))
full_name = _build_full_name(
first_name, last_name, middle_name, company, is_company
)
emails: List[Dict[str, str]] = []
primary_email = _coalesce(raw.get("email"))
if primary_email:
emails.append({"value": primary_email, "type": "work"})
phones: List[Dict[str, str]] = []
raw_phone = _coalesce(raw.get("phone"))
if raw_phone:
phones.append({"value": raw_phone, "type": _classify_phone(raw_phone)})
urls: List[Dict[str, str]] = []
website = _coalesce(raw.get("website"))
if website:
urls.append({"value": website, "type": "work"})
linkedin = _extract_custom(raw.get("custom_fields"), ["linkedin", "linked_in"])
if linkedin:
urls.append({"value": linkedin, "type": "linkedin"})
contact_url = f"{base_url.rstrip('/')}/contacts/{contact_id}"
return {
"crm_id": None,
"redmine_contact_id": contact_id,
"full_name": full_name,
"first_name": first_name,
"middle_name": middle_name,
"last_name": last_name,
"company": company,
"title": _coalesce(raw.get("job_title")),
"emails": emails,
"phones": phones,
"urls": urls,
"website": website,
"birthday": _to_iso(raw.get("birthday")),
"address": _parse_address(raw.get("address_attributes")),
"notes": _merge_notes(raw.get("background"), raw.get("custom_fields"), contact_url),
"tags": _normalize_tags(raw.get("tag_list")),
"source": "redmine",
"synced_at": datetime.now(timezone.utc).isoformat(),
"enriched_at": None,
"last_redmine_update": _to_iso(raw.get("updated_at")),
"is_company": is_company,
"project_id": raw.get("project_id"),
"assigned_to_id": raw.get("assigned_to_id"),
}