Files
upstream-mirror db69de1c11
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
snapshot of https://github.com/frappe/crm.git (develop, 2026-09-18; blobs > 131072B omitted)
2026-09-18 01:36:01 +05:00

266 lines
8.3 KiB
Python

# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
"""Background worker + the single run-writer.
``run_enrichment`` is the enqueued job: it runs the pipeline (streaming progress
over realtime), writes a ``CRM Enrichment Run`` history record, applies the mapper
to the origin document, and publishes a terminal event. It NEVER raises to the
worker -- every failure is recorded on the Run, logged via ``frappe.log_error`` and
reported over realtime.
``write_run`` is the ONLY place that persists run history, so the storage choice
(standalone Run doctype today) stays swappable.
"""
from __future__ import annotations
import frappe
from frappe.utils.telemetry import capture
from .config import _setting, auto_enrich_enabled_for, get_config, get_settings
from .mapper import apply_to_document
from .pipeline import PROGRESS_STEPS
from .pipeline import run as run_pipeline
# Realtime event the frontend (Phase 6) subscribes to.
PROGRESS_EVENT = "domain_enrichment_progress"
# Total number of pipeline progress steps (mirrors pipeline.PROGRESS_STEPS).
TOTAL_STEPS = len(PROGRESS_STEPS)
def _publish(reference_doctype, reference_name, status, message="", step=0, payload=None, user=None):
"""Emit a single progress/terminal event. Mirrors crm/api/whatsapp.py shape:
always carries the reference, plus step/total/message/status and an optional
payload (terminal events put filled_fields/notes/result.flat() in the payload).
Never raises -- realtime is best-effort."""
try:
frappe.publish_realtime(
PROGRESS_EVENT,
{
"reference_doctype": reference_doctype,
"reference_name": reference_name,
"step": step,
"total": TOTAL_STEPS,
"message": message,
"status": status,
"payload": payload or {},
},
user=user,
)
except Exception:
pass
def write_run(
reference_doctype: str,
reference_name: str,
website: str,
status: str,
result=None,
started_on=None,
notes: str = "",
):
"""Persist exactly one ``CRM Enrichment Run`` from an ``EnrichmentResult``.
The single point of run-history writing -- storage stays swappable behind it.
When ``result`` is given the summary fields + ``raw_json`` (full ``to_dict()``)
are populated; otherwise a bare Run (status/website) is written.
"""
doc = frappe.new_doc("CRM Enrichment Run")
doc.reference_doctype = reference_doctype
doc.reference_name = reference_name
doc.source_website = website
doc.status = status
doc.started_on = started_on or frappe.utils.now_datetime()
if status in ("Completed", "Failed"):
doc.finished_on = frappe.utils.now_datetime()
if notes:
doc.notes = notes
if result is not None:
# extract_social_profiles pre-seeds an empty entry per configured rule; only
# summarise the networks that were actually found.
social = ", ".join(sorted(k for k, v in result.social_profiles.items() if v.value))
doc.company_name = result.company_name.value or ""
doc.industry = result.industry.value or ""
doc.industry_confidence = result.industry_confidence or 0
doc.emails_found = len(result.emails)
doc.phones_found = len(result.phones)
doc.social_profiles = social
doc.raw_json = frappe.as_json(result.to_dict())
if not notes and result.notes:
doc.notes = "\n".join(result.notes)
doc.insert(ignore_permissions=True)
return doc.name
def enqueue_enrichment(
reference_doctype: str, reference_name: str, website: str, user: str, trigger: str = "manual"
) -> dict:
"""Enqueue one ``run_enrichment`` job (long queue, per-doc ``job_id`` +
``deduplicate``, after commit). The single place the job is enqueued -- shared by
the manual (``api.enrich``/``api.retry``) and auto (``after_insert``) paths so a
manual click and an auto-fire never double-run, and the enqueue options stay in
one spot. ``enqueue_after_commit`` matters for the ``after_insert`` caller, whose
transaction has not committed yet; it is harmless for the already-saved paths.
Reads only the Settings Single for the timeout bound (cheap, framework-cached) --
it never assembles the full config.
"""
s = get_settings()
timeout = int(_setting(s, "request_timeout")) * int(_setting(s, "max_pages")) + 60
job_id = f"domain-enrich-{reference_doctype}-{reference_name}"
frappe.enqueue(
"crm.domain_enrichment.tasks.run_enrichment",
queue="long",
timeout=timeout,
job_id=job_id,
deduplicate=True,
enqueue_after_commit=True,
reference_doctype=reference_doctype,
reference_name=reference_name,
website=website,
user=user,
trigger=trigger,
)
return {"queued": True, "job_id": job_id, "website": website}
def auto_enrich_on_create(doc, method=None):
"""Auto-enqueue enrichment for a new CRM record.
Called from the ``after_insert`` of the CRM Lead / CRM Deal / CRM Organization
controllers (the trigger stays visible where the record lives, rather than as a
doc_events hook). Best-effort and never raises into the save. Fires only when the
feature is enabled, ``auto_enrich`` is on, the doctype is enabled, and the record
has a website. Reuses the same per-doc ``job_id``
+ ``deduplicate`` as the manual ``api.enrich`` path, so a manual click and the
auto-fire never double-run. A new Deal created with a website is therefore enriched
alongside its Organization -- each crawls independently and writes its own fields.
"""
try:
# Cheap Settings-only gate -- runs on every Lead/Deal/Org insert, so it must
# not assemble the full config (Rules/Mappings). The worker builds that later.
if not auto_enrich_enabled_for(doc.doctype):
return
website = (doc.get("website") or "").strip()
if not website:
return
enqueue_enrichment(doc.doctype, doc.name, website, frappe.session.user, trigger="auto")
except Exception:
frappe.log_error(title="Domain Enrichment: auto_enrich_on_create failed")
def run_enrichment(
reference_doctype: str,
reference_name: str,
website: str,
user: str | None = None,
trigger: str = "manual",
):
"""Enqueued worker: crawl, map onto the origin doc, write a Run, stream progress.
Never raises to the worker. On success the mapped origin doc is saved (a normal
permission-respecting save -- it is the doc the user triggered enrichment on; no
related records are touched, that is Phase 5). On any exception the Run is marked
Failed, logged, and an error event is published.
"""
user = user or frappe.session.user
started_on = frappe.utils.now_datetime()
def progress(step_index, message=""):
_publish(
reference_doctype,
reference_name,
status="running",
message=message,
step=step_index,
user=user,
)
try:
cfg = get_config()
_publish(reference_doctype, reference_name, status="running", message="Starting", step=0, user=user)
result = run_pipeline(website, cfg=cfg, progress=progress)
doc = frappe.get_doc(reference_doctype, reference_name)
doc.check_permission("write")
filled_fields = apply_to_document(doc, result, cfg)
if filled_fields:
doc.save()
write_run(
reference_doctype,
reference_name,
website,
status="Completed",
result=result,
started_on=started_on,
)
_publish(
reference_doctype,
reference_name,
status="completed",
message="Completed",
step=TOTAL_STEPS - 1,
payload={
"filled_fields": filled_fields,
"notes": result.notes,
**result.flat(),
},
user=user,
)
capture(
"enrichment_run_completed",
"crm",
properties={
"doctype": reference_doctype,
"trigger": trigger,
"status": "success",
},
)
except Exception:
# Discard any partial writes (e.g. a doc.save() that fired side effects before a
# later step threw). execute_job commits when this function returns normally, so
# without the rollback those partials would be committed alongside the Failed run.
frappe.db.rollback()
frappe.log_error(
title="Domain Enrichment: run_enrichment failed",
message=frappe.get_traceback(),
)
try:
write_run(
reference_doctype,
reference_name,
website,
status="Failed",
started_on=started_on,
notes=frappe.utils.cstr(frappe.get_traceback())[:1000],
)
except Exception:
frappe.log_error(title="Domain Enrichment: could not write Failed run")
_publish(
reference_doctype,
reference_name,
status="error",
message=frappe._("Enrichment failed. Check the error log."),
step=0,
user=user,
)
capture(
"enrichment_run_completed",
"crm",
properties={
"doctype": reference_doctype,
"trigger": trigger,
"status": "failure",
},
)