Skip to content

Commit a1931aa

Browse files
committed
added notification mechanism
1 parent ef18fbf commit a1931aa

6 files changed

Lines changed: 182 additions & 17 deletions

File tree

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ _ds_ plugin which takes in ... as input files and
99
creates ... as output files.
1010

1111
## Abstract
12-
1312
...
1413

1514
## Installation

base_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def pacs_pull(self):
1010
pass
1111

1212
@abstractmethod
13-
def anonymize(self, dicom_dir: str, send_params: dict, pv_id: int):
13+
def anonymize(self, dicom_dir: str, send_params: dict, pv_id: int, series_dat: str):
1414
pass
1515

1616
@abstractmethod

chrisClient.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,13 @@ def pacs_pull(self):
8787
def pacs_push(self):
8888
pass # Placeholder for PACS push implementation
8989

90-
def anonymize(self, dicom_dir: str, send_params: dict, pv_id: int):
90+
async def anonymize(self, dicom_dir: str, send_params: dict, pv_id: int, series_data: str):
9191
"""
9292
Run the anonymization pipeline for a given DICOM directory and push results to specified neuro locations.
9393
"""
94+
d_series = json.loads(series_data)
95+
d_series['Folder Name'] = send_params['folder_name']
96+
d_series['SeriesDescription'] = dicom_dir.split('/')[-1]
9497
dsdir_inst_id = self.run_dicomdir_plugin(dicom_dir, pv_id)
9598

9699
plugin_params = {
@@ -121,10 +124,13 @@ def anonymize(self, dicom_dir: str, send_params: dict, pv_id: int):
121124
}
122125

123126
pipe = Pipeline(self.api_base, self.token)
124-
d_ret = pipe.run_pipeline(
127+
d_ret = await pipe.run_pipeline(
125128
previous_inst=dsdir_inst_id,
126129
pipeline_name="DICOM anonymization, niftii conversion, and push to neuro tree v20250326",
127-
pipeline_params=plugin_params
130+
pipeline_params=plugin_params,
131+
recipients=send_params['recipients'],
132+
smtp_server=send_params['smtp_server'],
133+
series_data=json.dumps(d_series)
128134
)
129135
return d_ret
130136

dy_regiFlow.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import os
1414
import pfdcm
1515
import copy
16+
import asyncio
1617

1718
LOG = logger.debug
1819

@@ -27,7 +28,7 @@
2728
logger.remove()
2829
logger.add(sys.stderr, format=logger_format)
2930

30-
__version__ = '1.0.9'
31+
__version__ = '1.1.0'
3132

3233
DISPLAY_TITLE = r"""
3334
_ _ _______ _
@@ -126,6 +127,18 @@
126127
action="store_true",
127128
default=False,
128129
)
130+
parser.add_argument(
131+
'--recipients',
132+
default='',
133+
type=str,
134+
help='comma separated valid email recipient addresses'
135+
)
136+
parser.add_argument(
137+
'--SMTPServer',
138+
default='mailsmtp4.childrenshospital.org',
139+
type=str,
140+
help='valid email server'
141+
)
129142
parser.add_argument('-V', '--version', action='version',
130143
version=f'%(prog)s {__version__}')
131144

@@ -179,7 +192,7 @@ def main(options: Namespace, inputdir: Path, outputdir: Path):
179192
raise Exception(f"Cannot verify registration for empty pacs data.")
180193

181194
retry_table = create_hash_table(data, 5)
182-
registration_errors = check_registration(options, retry_table, cube_cl)
195+
registration_errors = asyncio.run(check_registration(options, retry_table, cube_cl))
183196

184197
if registration_errors:
185198
LOG(f"ERROR while running pipelines.")
@@ -221,10 +234,14 @@ def create_hash_table(retrieve_data: dict, retry: int) -> dict:
221234
retry_table[series["SeriesInstanceUID"]]["SeriesInstanceUID"] = series["SeriesInstanceUID"]
222235
retry_table[series["SeriesInstanceUID"]]["StudyInstanceUID"] = series["StudyInstanceUID"]
223236
retry_table[series["SeriesInstanceUID"]]["AccessionNumber"] = series["AccessionNumber"]
237+
retry_table[series["SeriesInstanceUID"]]["PatientID"] = series["PatientID"]
238+
retry_table[series["SeriesInstanceUID"]]["StudyDate"] = series["StudyDate"]
239+
retry_table[series["SeriesInstanceUID"]]["Modality"] = series["Modality"]
240+
224241
return retry_table
225242

226243
# Recursive method to check on registration and then run anonymization pipeline
227-
def check_registration(options: Namespace, retry_table: dict, client: PACSClient, contains_errors: bool=False):
244+
async def check_registration(options: Namespace, retry_table: dict, client: PACSClient, contains_errors: bool=False):
228245
# null check
229246
if len(retry_table) == 0:
230247
return contains_errors
@@ -266,18 +283,21 @@ def check_registration(options: Namespace, retry_table: dict, client: PACSClient
266283
"neuro_dcm_location": options.neuroDicomLocation,
267284
"neuro_anon_location": options.neuroAnonLocation,
268285
"neuro_nifti_location": options.neuroNiftiLocation,
269-
"folder_name": options.folderName
286+
"folder_name": options.folderName,
287+
"recipients": options.recipients,
288+
"smtp_server": options.SMTPServer
270289
}
271290
dicom_dir = client.get_pacs_files({'SeriesInstanceUID': series_instance})
291+
series_data = json.dumps(retry_table[series_instance])
272292

273293
# create ChRIS Client Object
274294
cube_con = ChrisClient(options.CUBEurl, options.CUBEtoken)
275-
d_ret = cube_con.anonymize(dicom_dir, send_params, options.pluginInstanceID)
295+
d_ret = await cube_con.anonymize(dicom_dir, send_params, options.pluginInstanceID, series_data)
276296
if d_ret.get('error'):
277297
contains_errors = True
278298
clone_retry_table.pop(series_instance)
279299

280-
check_registration(options, clone_retry_table, client, contains_errors)
300+
await check_registration(options, clone_retry_table, client, contains_errors)
281301
return contains_errors
282302

283303
if __name__ == '__main__':

pipeline.py

Lines changed: 144 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
from requests.exceptions import RequestException, Timeout, HTTPError
55
from tenacity import retry, wait_exponential, stop_after_attempt, retry_if_exception_type
66
from loguru import logger
7-
7+
import time
8+
import asyncio
9+
from urllib.parse import urlencode
10+
import pandas as pd
811

912
def transform_plugin_data(nested_data_list: list[dict]) -> list[dict]:
1013
"""Flatten nested plugin data into a list of dictionaries."""
@@ -107,23 +110,152 @@ def get_pipeline_id(self, name: str) -> int:
107110
return field.get("value")
108111
return -1
109112

113+
def get_pipeline_total_pipings(self, pipeline_id: int) -> int:
114+
"""Get the total number of plugin pipings in the given pipeline."""
115+
logger.info(f"Fetching pipeline plugin piping list.")
116+
response = self.make_request("GET", f"/pipelines/{pipeline_id}/pipings/?limit=100")
117+
return len(response)
118+
119+
110120
def get_pipeline_parameters(self, pipeline_id: int) -> list[dict]:
111121
"""Get default parameters for a pipeline."""
112122
logger.info(f"Fetching default parameters for pipeline with ID: {pipeline_id}")
113123
response = self.make_request("GET", f"/pipelines/{pipeline_id}/parameters/?limit=1000")
114124
return transform_plugin_data(response)
115125

116-
def post_workflow(self, pipeline_id: int, previous_id: int, params: list[dict]) -> dict:
126+
def post_workflow(self, pipeline_id: int, previous_id: int, params: list[dict]) -> int:
117127
"""
118128
Trigger a pipeline workflow in CUBE.
119129
"""
120130
payload = {
121131
"previous_plugin_inst_id": previous_id,
122132
"nodes_info": json.dumps(params)
123133
}
124-
return self.post_request(f"/pipelines/{pipeline_id}/workflows/", json=payload)
134+
response = self.post_request(f"/pipelines/{pipeline_id}/workflows/", json=payload)
135+
for item in response:
136+
for field in item.get("data", []):
137+
if field.get("name") == "id":
138+
return field.get("value")
139+
return -1
140+
141+
async def get_workflow_status(self, workflow_id: int) -> dict:
142+
loop = asyncio.get_running_loop()
143+
return await loop.run_in_executor(None, self._get_workflow_status, workflow_id)
144+
145+
def _get_workflow_status(self, workflow_id: int) -> dict:
146+
"""
147+
1. Get workflow details for a given workflow id.
148+
2. Check for errored jobs
149+
3. return total jobs (finished + errored + cancelled)
150+
"""
151+
finished_jobs = 0
152+
errored_jobs = 0
153+
cancelled_jobs = 0
154+
created_jobs = 0
155+
waiting_jobs = 0
156+
scheduled_jobs = 0
157+
started_jobs = 0
158+
registering_jobs = 0
159+
160+
logger.info(f"Fetching workflow details for ID: {workflow_id}")
161+
response = self.make_request("GET", f"/pipelines/workflows/{workflow_id}/")
162+
for item in response:
163+
for field in item.get("data", []):
164+
if field.get("name") == "finished_jobs":
165+
finished_jobs = field.get("value")
166+
if field.get("name") == "errored_jobs":
167+
errored_jobs = field.get("value")
168+
if field.get("name") == "cancelled_jobs":
169+
cancelled_jobs = field.get("value")
170+
if field.get("name") == "created_jobs":
171+
created_jobs = field.get("value")
172+
if field.get("name") == "waiting_jobs":
173+
waiting_jobs = field.get("value")
174+
if field.get("name") == "scheduled_jobs":
175+
scheduled_jobs = field.get("value")
176+
if field.get("name") == "started_jobs":
177+
started_jobs = field.get("value")
178+
if field.get("name") == "registering_jobs":
179+
registering_jobs = field.get("value")
180+
181+
182+
return {
183+
"finished_jobs": finished_jobs,
184+
"total_jobs": finished_jobs + errored_jobs + cancelled_jobs + created_jobs + waiting_jobs + scheduled_jobs + started_jobs + registering_jobs,
185+
"workflow_failed": (errored_jobs > 0)
186+
}
187+
188+
async def monitor_pipeline(self, workflow_id, total_jobs, pv_inst, rcpts, smtp, series_data):
189+
while True:
190+
status = self._get_workflow_status(workflow_id)
191+
if status["workflow_failed"]:
192+
logger.error("Pipeline failed.")
193+
self.run_notification_plugin(pv_inst, "Pipeline failed with errors", rcpts, smtp, series_data)
194+
break
195+
if status["finished_jobs"] >= total_jobs:
196+
logger.info("Pipeline complete.")
197+
break
198+
if status["total_jobs"] < total_jobs:
199+
self.run_notification_plugin(pv_inst, "Nodes deleted in pipeline", rcpts, smtp, series_data)
200+
break
201+
time.sleep(20)
202+
203+
def run_notification_plugin(self, pv_id: int, msg: str, rcpts: str, smtp: str, series_data: str) -> int:
204+
"""
205+
Run the pl-notification plugin.
206+
"""
207+
d_series = json.loads(series_data)
208+
email_content = (f"An error occurred while running anonymization pipeline on the following data: "
209+
f"\nMRN: {d_series['PatientID']} "
210+
f"\nStudyDate: {d_series['StudyDate']}"
211+
f"\nModality: {d_series['Modality']}"
212+
f"\nSeriesDescription: {d_series['SeriesDescription']}"
213+
f"\nFolder Name: {d_series['Folder Name']}"
214+
f"\n\nKindly login to ChRIS to access the logs for more details.")
215+
216+
try:
217+
plugin_id = self._get_plugin_id({"name": "pl-notification", "version": "0.1.0"})
218+
instance_id = self._create_plugin_instance(plugin_id, {
219+
"previous_id": pv_id,
220+
"content": email_content,
221+
"title": "pipeline-error",
222+
"rcpt": rcpts,
223+
"sender": "noreply@fnndsc.org",
224+
"mail_server": smtp
225+
})
226+
return int(instance_id)
227+
except Exception as ex:
228+
logger.error(f"Error occurred while creating notification instance {ex}")
229+
230+
def _create_plugin_instance(self, plugin_id: str, params: dict):
231+
"""
232+
Create a plugin instance and return its ID.
233+
"""
234+
response = self.post_request( f"/plugins/{plugin_id}/instances/", json=params)
235+
236+
for item in response:
237+
for field in item.get("data", []):
238+
if field.get("name") == "id":
239+
return field.get("value")
240+
241+
raise RuntimeError("Plugin instance could not be scheduled.")
242+
243+
def _get_plugin_id(self, params: dict):
244+
"""
245+
Fetch plugin ID by search parameters.
246+
"""
247+
query_string = urlencode(params)
248+
response = self.make_request("GET", f"/plugins/search/?{query_string}")
249+
250+
for item in response:
251+
for field in item.get("data", []):
252+
if field.get("name") == "id":
253+
return field.get("value")
254+
255+
raise RuntimeError(f"No plugin found with matching criteria: {params}")
256+
125257

126-
def run_pipeline(self, pipeline_name: str, previous_inst: int, pipeline_params: dict):
258+
async def run_pipeline(self, pipeline_name: str, previous_inst: int, pipeline_params: dict, recipients: str, smtp_server: str, series_data: str):
127259
"""
128260
Full workflow to:
129261
1. Fetch pipeline ID
@@ -133,12 +265,19 @@ def run_pipeline(self, pipeline_name: str, previous_inst: int, pipeline_params:
133265
"""
134266
try:
135267
pipeline_id = self.get_pipeline_id(pipeline_name)
268+
total_jobs = self.get_pipeline_total_pipings(pipeline_id)
136269
default_params = self.get_pipeline_parameters(pipeline_id)
137270
nodes_info = compute_workflow_nodes_info(default_params, include_all_defaults=True)
138271
updated_params = update_plugin_parameters(nodes_info, pipeline_params)
139-
workflow = self.post_workflow(pipeline_id=pipeline_id, previous_id=previous_inst, params=updated_params)
272+
workflow_id = self.post_workflow(pipeline_id=pipeline_id, previous_id=previous_inst, params=updated_params)
273+
#self.run_notification_plugin(previous_inst)
274+
275+
# Start this in the background (not awaited)
276+
asyncio.create_task(self.monitor_pipeline(workflow_id, total_jobs, previous_inst, recipients, smtp_server, series_data))
277+
140278
logger.info(f"Workflow posted successfully")
141279
return {"status": "Pipeline running"}
280+
142281
except Exception as ex:
143282
logger.error(f"Running pipeline failed due to: {ex}")
144283
return {"status": "Failed", "error": str(ex)}

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
chris_plugin==0.4.0
22
python-chrisclient==2.11.1
33
loguru
4-
tenacity
4+
tenacity
5+
pandas

0 commit comments

Comments
 (0)