Project

General

Profile

Feature #149 » nsdl_sync_scheduled_job.py

Python script: """ - Akshath Shettigara, 10/15/2026 04:35 PM

 
"""
NSDL Sync scheduled job -- fires the daily trigger call to AWS Phase B's
/sez/deliver-pending endpoint for this site's own client code.

This deliberately does NOT go through the 'Canofy API Call' / 'API
Configuration - Canofy' automatic-dispatch flow (canofy_api_call.py's
after_insert() -> _process_api_call()) -- that controller only ever acts
when "EWAY" appears in the linked API config's name/service_name
(hardcoded in _process_api_call()), so it would silently do nothing for an
NSDL config, and making it dispatch a new integration type would mean
editing that shared controller. Per instruction, nothing already in the
app is to be changed.

Instead, this reuses the two things that ARE genuinely generic:
- frappe.integrations.utils.make_post_request -- the actual shared
outbound-HTTP-call helper (this is what e_way_bill_request.py's own
execute_masters_india_api() calls internally).
- The 'API Configuration - Canofy' doctype as a config store, read the
same way e_way_bill_request.py's get_api_config() does (reimplemented
locally below rather than imported, so this module has zero dependency
on e-way-bill-specific code).

This file creates the 'NSDL Sync Run' doc itself, BEFORE calling the AWS
endpoint, as the parent record (Build Spec Phase 7) -- Phase B's Lambda
then UPDATES that same doc (never creates a second one) via the
sync_run_name passed through in the request body.

The AWS call is invoked ASYNCHRONOUSLY (the API Gateway integration sets
X-Amz-Invocation-Type: Event) -- Phase B's real work (an NSDL Playwright
login + throttled PDF downloads) routinely runs well past API Gateway's
hard 29s synchronous integration cap, so this call only confirms AWS
accepted the trigger. It does NOT return delivery counts; those arrive
later when Phase B itself updates the NSDL Sync Run doc.

--- One-time setup required per site (MANUAL, not done by this job) ---
1. Create one "API Configuration - Canofy" record named exactly
NSDL_SYNC_API_CONFIG_NAME below (or change the constant to match
whatever name you give it), with:
url = the deployed SezApiDeliverPendingUrl template
output (from `sam deploy`'s Outputs, AWS side)
api_key_header_key = "x-api-key"
api_secret = the real API Gateway key value
(aws apigateway get-api-key --api-key <SezApiKeyId>
--include-value)
headers = {"X-Frappe-Client": "<this site's client_name>"}
-- must match the client_name key used in the AWS
Secrets Manager CLIENTS block, NOT necessarily this
site's display name. Deliberately stored here (the
doctype's existing "Additional Static Headers"
field) rather than in site_config.json, so the
entire integration -- endpoint, credentials, and
this site's own identity -- lives in one place a
System Manager can edit from the UI, with no
server/file access needed.
2. Register this job in hooks.py:
scheduler_events = {
"daily": [
"canofy_tcs.import_documents.nsdl_sync.nsdl_sync_scheduled_job.trigger_nsdl_sync"
]
}
(adjust the dotted path to wherever this file actually lands in the app --
"Import_documents" is the module named for the NSDL Sync Run doctype;
this job would naturally live alongside it.)
"""

from __future__ import annotations

import json

import frappe
from frappe.integrations.utils import make_post_request

NSDL_SYNC_API_CONFIG_NAME = "NSDL SEZ DELIVER PENDING"


def trigger_nsdl_sync():
"""Entry point for hooks.py's scheduler_events. Each site only ever
triggers its own client's delivery, once per day."""
try:
api_config = _get_api_config(NSDL_SYNC_API_CONFIG_NAME)
except frappe.DoesNotExistError:
frappe.log_error(
title="NSDL Sync misconfigured",
message=f"'{NSDL_SYNC_API_CONFIG_NAME}' API Configuration - Canofy record not found "
f"-- cannot trigger NSDL sync.",
)
return

# client_name lives in this same record's "Additional Static Headers"
# field (see module docstring's setup step 1) rather than
# site_config.json -- one less place to configure, and editable from
# Frappe's own UI.
client_name = json.loads(api_config.get("headers") or "{}").get("X-Frappe-Client")
if not client_name:
frappe.log_error(
title="NSDL Sync misconfigured",
message=f"'{NSDL_SYNC_API_CONFIG_NAME}' API Configuration - Canofy record's Additional "
f"Static Headers is missing an 'X-Frappe-Client' entry -- cannot trigger NSDL sync.",
)
return

sync_run = frappe.get_doc(
{
"doctype": "NSDL Sync Run",
"run_date": frappe.utils.nowdate(),
"client_name": client_name,
"status": "Triggered",
}
)
sync_run.insert(ignore_permissions=True)

try:
headers = _get_request_headers(api_config, client_name)

# Async invocation (X-Amz-Invocation-Type: Event, set by the AWS-side
# API Gateway mapping) -- this call only confirms AWS ACCEPTED the
# trigger, it does not carry the actual delivery outcome. Phase B
# updates this same doc itself once it finishes (see
# update_audit_doc() on the AWS side); a failure to invoke at all
# is caught here and separately covered by the DLQ + CloudWatch
# alarm on the AWS side, per Blueprint SS11.
make_post_request(
api_config["url"],
headers=headers,
data=json.dumps({"sync_run_name": sync_run.name}),
)

except Exception as e:
error_msg = str(e)
sync_run.db_set({"status": "Failed", "error_message": error_msg})
frappe.log_error(title="NSDL Sync trigger failed", message=error_msg)


def _get_api_config(linked_api: str) -> dict:
"""Mirrors the existing get_api_config() convention in
canofy_tcs/e_way_bill/doctype/e_way_bill_request/e_way_bill_request.py,
reimplemented locally so this module has no import dependency on
e-way-bill-specific code."""
api_doc = frappe.get_doc("API Configuration - Canofy", linked_api)
return {
"url": api_doc.url,
"headers": api_doc.headers,
"api_key_header_key": api_doc.api_key_header_key,
"api_secret": api_doc.get_password("api_secret"),
}


def _get_request_headers(api_config: dict, client_name: str) -> dict:
headers = json.loads(api_config.get("headers") or "{}")
headers[api_config.get("api_key_header_key")] = api_config.get("api_secret")
# Already present in `headers` (that's where trigger_nsdl_sync just read
# client_name from) -- reassigned explicitly anyway so this function's
# output is correct even if ever called with a client_name from
# elsewhere.
headers["X-Frappe-Client"] = client_name
headers["Content-Type"] = "application/json"
return headers
(3-3/3)