"""End-to-end pipeline: fetch Redmine contacts, enrich them, and upsert to CRM. This module wires three existing building blocks together: * redmine_crm.redmine_client for extraction * contact_enrichment for deterministic transformation * redmine_crm.crm_client for idempotent loading When run as a script it provides a cron-compatible CLI. It can also be imported and called programmatically. """ from __future__ import annotations import argparse import json import logging import os import sys from datetime import datetime, timezone from pathlib import Path from typing import Any from redmine_crm.crm_client import CrmClient from redmine_crm.redmine_client import RedmineClient from redmine_crm.redmine_client import RedmineNotFoundError import contact_enrichment logger = logging.getLogger(__name__) DEFAULT_BASE_URL = "http://192.168.50.170" DEFAULT_PROJECT_ID = 1 # "customer-service" numeric project id common in this Redmine instance DEFAULT_STATE_FILE = Path(".cache/redmine_contact_sync/state.json") DEFAULT_LOG_LEVEL = "INFO" def _utc_now_iso() -> str: return datetime.now(timezone.utc).isoformat() class SyncMetrics: """Lightweight in-memory metrics collector for a single sync run.""" def __init__(self) -> None: self.started_at = _utc_now_iso() self.finished_at: str | None = None self.fetched = 0 self.enriched = 0 self.skipped = 0 self.created = 0 self.updated = 0 self.unchanged = 0 self.errors = 0 def to_dict(self) -> dict[str, Any]: return { "started_at": self.started_at, "finished_at": self.finished_at, "fetched": self.fetched, "enriched": self.enriched, "skipped": self.skipped, "created": self.created, "updated": self.updated, "unchanged": self.unchanged, "errors": self.errors, } class FileSyncState: """Persist the last successful sync timestamp and last run metrics.""" def __init__(self, path: Path) -> None: self.path = path self._data: dict[str, Any] = {} self.load() def load(self) -> None: if self.path.exists(): try: self._data = json.loads(self.path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as exc: logger.warning("Could not read sync state from %s: %s", self.path, exc) self._data = {} else: self._data = {} def save(self) -> None: self.path.parent.mkdir(parents=True, exist_ok=True) self.path.write_text(json.dumps(self._data, indent=2, sort_keys=True), encoding="utf-8") @property def last_sync_timestamp(self) -> str | None: return self._data.get("last_sync_timestamp") @property def last_metrics(self) -> dict[str, Any] | None: return self._data.get("last_metrics") def record_run(self, timestamp: str, metrics: SyncMetrics) -> None: self._data["last_sync_timestamp"] = timestamp self._data["last_metrics"] = metrics.to_dict() self.save() def _configure_logging(level: str) -> None: logging.basicConfig( level=getattr(logging, level.upper(), logging.INFO), format="%(asctime)s %(levelname)s %(name)s: %(message)s", stream=sys.stderr, ) def _redmine_client_from_args(args: argparse.Namespace) -> RedmineClient: api_key = os.getenv("REDMINE_API_KEY") if not api_key: raise RuntimeError("Set REDMINE_API_KEY in the environment.") return RedmineClient( base_url=args.base_url, api_key=api_key, project_id=args.project_id, ) def _enrichment_config_for_args(args: argparse.Namespace) -> contact_enrichment.EnrichmentConfig: domain_map: dict[str, str] = {} if args.domain_to_company: for item in args.domain_to_company: if "=" not in item: raise ValueError(f"Invalid --domain-to-company value: {item!r} (expected domain=company)") domain, company = item.split("=", 1) domain_map[domain.strip()] = company.strip() config = contact_enrichment.EnrichmentConfig( source=args.source, confidence_threshold=args.confidence_threshold, default_country_code=args.default_country_code, domain_to_company=domain_map, ) if args.external_lookup: # Expect a module.path:callable string, e.g. mymodule.plugins:lookup_company mod_name, _, func_name = args.external_lookup.partition(":") if not mod_name or not func_name: raise ValueError("--external-lookup must be module.path:callable") from importlib import import_module module = import_module(mod_name) config.external_lookup = getattr(module, func_name) return config def run_pipeline( *, redmine: RedmineClient, crm: CrmClient, enrichment_config: contact_enrichment.EnrichmentConfig | None = None, state: FileSyncState | None = None, batch_size: int = 100, dry_run: bool = False, ) -> dict[str, Any]: """Run the full fetch -> enrich -> upsert pipeline. Returns a dict containing final metrics and the timestamp of this run. """ metrics = SyncMetrics() enrichment_config = enrichment_config or contact_enrichment.EnrichmentConfig() state = state or FileSyncState(DEFAULT_STATE_FILE) logger.info("Starting Redmine -> CRM sync; dry_run=%s last_sync=%s", dry_run, state.last_sync_timestamp) # Extract raw_contacts = redmine.iter_all_contacts( updated_since=state.last_sync_timestamp, batch_size=batch_size, ) metrics.fetched = len(raw_contacts) logger.info("Fetched %d Redmine contacts", metrics.fetched) # Transform enrichment_results = contact_enrichment.build_sync_payload(raw_contacts, enrichment_config) records = enrichment_results["records"] skipped = enrichment_results["skipped"] metrics.enriched = len(records) metrics.skipped = len(skipped) logger.info("Enriched %d contacts, skipped %d", metrics.enriched, metrics.skipped) for item in skipped: logger.info("Skipped Redmine contact %s: %s", item.get("redmine_contact_id"), item.get("reason")) # Load (skipped during dry runs so real CRM adapters can also be avoided) if dry_run: logger.info("Dry run: skipping CRM upsert") else: stats = crm.sync_batch(records) metrics.created = stats.get("created", 0) metrics.updated = stats.get("updated", 0) metrics.unchanged = stats.get("skipped", 0) metrics.errors = stats.get("errors", 0) if stats.get("error_records"): for err in stats["error_records"]: logger.warning("CRM upsert error for Redmine contact %s: %s", err.get("redmine_contact_id"), err.get("reason")) metrics.finished_at = _utc_now_iso() result = { "timestamp": metrics.finished_at, "dry_run": dry_run, "metrics": metrics.to_dict(), "skipped_records": skipped, } if not dry_run: state.record_run(result["timestamp"], metrics) return result def build_argument_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Sync Redmine CRM contacts into a target CRM.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument("--base-url", default=os.getenv("REDMINE_BASE_URL", DEFAULT_BASE_URL), help="Redmine base URL.") parser.add_argument( "--project-id", type=int, default=int(os.getenv("REDMINE_PROJECT_ID", str(DEFAULT_PROJECT_ID))), help="Redmine numeric project id for contact filtering.", ) parser.add_argument("--state-file", type=Path, default=DEFAULT_STATE_FILE, help="Path to the persistent sync state file.") parser.add_argument("--batch-size", type=int, default=100, help="Redmine API page size.") parser.add_argument("--dry-run", action="store_true", help="Fetch and enrich but do not write to CRM.") parser.add_argument("--log-level", default=DEFAULT_LOG_LEVEL, choices=("DEBUG", "INFO", "WARNING", "ERROR")) parser.add_argument("--source", default="redmine", help="Value to set in the source field.") parser.add_argument("--confidence-threshold", type=float, default=0.7, help="Minimum enrichment confidence.") parser.add_argument("--default-country-code", default="US", help="Default country code for phone normalization.") parser.add_argument( "--domain-to-company", action="append", metavar="DOMAIN=COMPANY", help="Override company mapping for a specific email domain. Repeatable.", ) parser.add_argument( "--external-lookup", help="Optional module.path:callable providing external company lookup.", ) return parser def main(argv: list[str] | None = None) -> int: parser = build_argument_parser() args = parser.parse_args(argv) _configure_logging(args.log_level) redmine = _redmine_client_from_args(args) enrichment_config = _enrichment_config_for_args(args) # The default in-memory CRM client is useful for local testing and dry runs. crm = CrmClient() state = FileSyncState(args.state_file) try: result = run_pipeline( redmine=redmine, crm=crm, enrichment_config=enrichment_config, state=state, batch_size=args.batch_size, dry_run=args.dry_run, ) except Exception as exc: metrics = SyncMetrics() try: result = _handle_not_found_as_empty(exc, metrics) if not args.dry_run: state.record_run(result["timestamp"], metrics) except Exception: logger.exception("Pipeline failed: %s", exc) return 1 print(json.dumps(result, indent=2, sort_keys=True)) return 0 def _handle_not_found_as_empty(exc: Exception, metrics: SyncMetrics) -> dict[str, Any]: """Treat a 404 from Redmine contacts as an empty sync (plugin may be absent).""" if isinstance(exc, RedmineNotFoundError): logger.warning("Redmine contacts endpoint returned 404; treating as empty sync.") metrics.finished_at = _utc_now_iso() return { "timestamp": metrics.finished_at, "dry_run": False, "metrics": metrics.to_dict(), "skipped_records": [], } raise if __name__ == "__main__": raise SystemExit(main())