Files
redmine/redmine_ticket_contacts.py
T
Jason Thistlethwaite 9ee9eb3ec6 Organized
2026-10-17 20:50:35 -04:00

238 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""Durable local contact cache for the power-user Redmine client.
Reuses RedmineClient from redmine_contacts.py. Contacts live under:
<cache-dir>/contacts/<id>.json
<cache-dir>/contacts/manifest.json
This is intentionally thinner than redmine_contacts.py's full CRM CLI —
enough for sync + local search + show next to tickets.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
def now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
class ContactCache:
def __init__(self, cache_dir: Path):
self.cache_dir = Path(cache_dir)
self.contacts_dir = self.cache_dir / "contacts"
self.contacts_dir.mkdir(parents=True, exist_ok=True)
self.manifest_path = self.contacts_dir / "manifest.json"
def contact_path(self, contact_id: int | str) -> Path:
return self.contacts_dir / f"{int(contact_id)}.json"
def load_manifest(self) -> dict[str, Any]:
if not self.manifest_path.is_file():
return {"version": 1, "contacts": {}, "updated_at": None}
try:
data = json.loads(self.manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"version": 1, "contacts": {}, "updated_at": None}
if not isinstance(data, dict):
return {"version": 1, "contacts": {}, "updated_at": None}
data.setdefault("version", 1)
data.setdefault("contacts", {})
return data
def save_manifest(self, data: dict[str, Any]) -> None:
data = dict(data)
data["version"] = 1
data["updated_at"] = now_iso()
tmp = self.manifest_path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
tmp.replace(self.manifest_path)
def should_fetch(self, stub: dict[str, Any]) -> bool:
cid = stub.get("id")
if cid is None:
return False
path = self.contact_path(int(cid))
if not path.is_file():
return True
manifest = self.load_manifest()
entry = (manifest.get("contacts") or {}).get(str(int(cid))) or {}
return str(entry.get("updated_on") or "") != str(stub.get("updated_on") or "")
def write_contact(self, contact: dict[str, Any]) -> Path:
cid = contact.get("id")
if cid is None:
raise ValueError("contact missing id")
path = self.contact_path(int(cid))
path.write_text(json.dumps(contact, indent=2, sort_keys=True) + "\n", encoding="utf-8")
manifest = self.load_manifest()
contacts = manifest.setdefault("contacts", {})
contacts[str(int(cid))] = {
"id": int(cid),
"updated_on": contact.get("updated_on"),
"last_synced": now_iso(),
"email": primary_email(contact),
"name": contact_display_name(contact),
}
self.save_manifest(manifest)
return path
def load_contact(self, contact_id: int) -> dict[str, Any]:
path = self.contact_path(contact_id)
if not path.is_file():
raise FileNotFoundError(f"No cached contact #{contact_id} at {path}")
return json.loads(path.read_text(encoding="utf-8"))
def iter_contacts(self) -> Iterable[dict[str, Any]]:
for path in sorted(self.contacts_dir.glob("*.json")):
if path.name == "manifest.json":
continue
try:
yield json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
def primary_email(contact: dict[str, Any]) -> str:
for key in ("email", "primary_email"):
val = contact.get(key)
if val:
return str(val)
emails = contact.get("emails") or contact.get("address")
if isinstance(emails, list) and emails:
first = emails[0]
if isinstance(first, dict):
return str(first.get("address") or first.get("email") or "")
return str(first)
return ""
def contact_display_name(contact: dict[str, Any]) -> str:
if contact.get("name"):
return str(contact["name"])
first = contact.get("first_name") or contact.get("firstname") or ""
last = contact.get("last_name") or contact.get("lastname") or ""
full = f"{first} {last}".strip()
return full or primary_email(contact) or str(contact.get("id") or "")
def contact_id_from_issue(issue: dict[str, Any]) -> int | None:
"""Extract helpdesk contact id from a cached/enriched issue payload."""
helpdesk = issue.get("helpdesk_ticket")
if not isinstance(helpdesk, dict):
return None
if helpdesk.get("contact_id") is not None:
try:
return int(helpdesk["contact_id"])
except (TypeError, ValueError):
pass
contact = helpdesk.get("contact")
if isinstance(contact, dict) and contact.get("id") is not None:
try:
return int(contact["id"])
except (TypeError, ValueError):
return None
return None
def contact_text_blob(contact: dict[str, Any]) -> str:
parts = [
contact_display_name(contact),
primary_email(contact),
str(contact.get("company") or ""),
str(contact.get("tag_list") or contact.get("tags") or ""),
]
return " ".join(parts).lower()
def filter_contacts(
contacts: list[dict[str, Any]],
*,
query: str | None = None,
email: str | None = None,
company: str | None = None,
tag: str | None = None,
) -> list[dict[str, Any]]:
out = contacts
if email:
e = email.lower()
out = [c for c in out if e in primary_email(c).lower()]
if company:
co = company.lower()
out = [c for c in out if co in str(c.get("company") or "").lower()]
if tag:
t = tag.lower()
def has_tag(c: dict[str, Any]) -> bool:
raw = c.get("tag_list") or c.get("tags") or []
if isinstance(raw, str):
tags = [x.strip().lower() for x in raw.split(",")]
elif isinstance(raw, list):
tags = []
for item in raw:
if isinstance(item, dict):
tags.append(str(item.get("name") or "").lower())
else:
tags.append(str(item).lower())
else:
tags = []
return t in tags
out = [c for c in out if has_tag(c)]
if query:
q = query.lower()
out = [c for c in out if q in contact_text_blob(c)]
return out
def sync_contacts(
client: Any,
cache: ContactCache,
*,
page_size: int = 100,
max_contacts: int | None = None,
progress: bool = True,
dry_run: bool = False,
) -> dict[str, int]:
"""Fetch project contacts and write changed ones into the local cache."""
if progress:
print("Fetching contact list…", flush=True)
stubs = client.fetch_contacts(limit=page_size)
if max_contacts is not None:
stubs = stubs[: int(max_contacts)]
result = {"seen": len(stubs), "fetched": 0, "skipped_cached": 0, "written": 0}
for index, stub in enumerate(stubs, start=1):
cid = stub.get("id")
if cid is None:
continue
if not cache.should_fetch(stub):
result["skipped_cached"] += 1
if progress and (index == 1 or index % 50 == 0 or index == len(stubs)):
print(f" [{index}/{len(stubs)}] #{cid} skip-cached", flush=True)
continue
result["fetched"] += 1
if dry_run:
if progress:
print(f" [{index}/{len(stubs)}] #{cid} would-fetch", flush=True)
continue
# Prefer full contact payload when list stub is thin
try:
full = client.get_contact(int(cid))
except Exception:
full = stub
cache.write_contact(full)
result["written"] += 1
if progress and (index == 1 or index % 25 == 0 or index == len(stubs)):
print(f" [{index}/{len(stubs)}] #{cid} written", flush=True)
if progress:
print(
f"contacts sync done: seen={result['seen']} fetched={result['fetched']} "
f"skipped={result['skipped_cached']} written={result['written']}",
flush=True,
)
return result