Skip to content

Commit 5a9246c

Browse files
committed
updated pipeline module
1 parent f5b51d7 commit 5a9246c

4 files changed

Lines changed: 209 additions & 22 deletions

File tree

chrisClient.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ def pacs_pull(self):
8585
def pacs_push(self):
8686
pass
8787

88-
def anonymize(self, dicom_dir: str, tag_struct: str, send_params: dict, pv_id: int):
88+
async def anonymize(self, dicom_dir: str, tag_struct: str, send_params: dict, pv_id: int):
8989
"""
9090
Run the anonymization pipeline for a given DICOM directory and push results to specified Orthanc instance
9191
"""
@@ -105,8 +105,14 @@ def anonymize(self, dicom_dir: str, tag_struct: str, send_params: dict, pv_id: i
105105
}
106106
}
107107
pipe = Pipeline(self.api_base, self.token)
108-
d_ret = pipe.workflow_schedule(dsdir_inst_id, "DICOM anonymization and Orthanc push 20241217",
109-
plugin_params)
108+
d_ret = await pipe.run_pipeline(
109+
previous_inst=dsdir_inst_id,
110+
pipeline_name="DICOM anonymization and Orthanc push 20241217",
111+
pipeline_params=plugin_params,
112+
recipients=send_params['recipients'],
113+
smtp_server=send_params['smtp_server'],
114+
series_data={}
115+
)
110116
return d_ret
111117

112118
def run_dicomdir_plugin(self, dicom_dir: str, pv_id: int) -> int:

pipeline.py

Lines changed: 175 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,183 @@ 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 get_feed_id_from_plugin_inst(self, plugin_inst: int) -> int:
127+
"""Get feed_id from a given plugin instance"""
128+
logger.info(f"Fetching feed id for plugin instance with ID: {plugin_inst}")
129+
response = self.make_request("GET", f"/plugins/instances/{plugin_inst}/")
130+
for item in response:
131+
for field in item.get("data", []):
132+
if field.get("name") == "feed_id":
133+
return field.get("value")
134+
return -1
135+
136+
def get_feed_details_from_id(self, feed_id: int) -> dict:
137+
"""Get feed details given a feed id"""
138+
feed_details = {}
139+
140+
logger.info(f"Getting feed details for ID: {feed_id}")
141+
response = self.make_request("GET", f"/{feed_id}/")
142+
for item in response:
143+
for field in item.get("data", []):
144+
if field.get("name") == "creation_date":
145+
feed_details["date"] = field.get("value")
146+
if field.get("name") == "name":
147+
feed_details["name"] = field.get("value")
148+
if field.get("name") == "owner_username":
149+
feed_details["owner"] = field.get("value")
150+
151+
return feed_details
152+
153+
def post_workflow(self, pipeline_id: int, previous_id: int, params: list[dict]) -> int:
117154
"""
118155
Trigger a pipeline workflow in CUBE.
119156
"""
120157
payload = {
121158
"previous_plugin_inst_id": previous_id,
122159
"nodes_info": json.dumps(params)
123160
}
124-
return self.post_request(f"/pipelines/{pipeline_id}/workflows/", json=payload)
161+
response = self.post_request(f"/pipelines/{pipeline_id}/workflows/", json=payload)
162+
for item in response:
163+
for field in item.get("data", []):
164+
if field.get("name") == "id":
165+
return field.get("value")
166+
return -1
167+
168+
async def get_workflow_status(self, workflow_id: int) -> dict:
169+
loop = asyncio.get_running_loop()
170+
return await loop.run_in_executor(None, self._get_workflow_status, workflow_id)
171+
172+
def _get_workflow_status(self, workflow_id: int) -> dict:
173+
"""
174+
1. Get workflow details for a given workflow id.
175+
2. Check for errored jobs
176+
3. return total jobs (finished + errored + cancelled)
177+
"""
178+
finished_jobs = 0
179+
errored_jobs = 0
180+
cancelled_jobs = 0
181+
created_jobs = 0
182+
waiting_jobs = 0
183+
scheduled_jobs = 0
184+
started_jobs = 0
185+
registering_jobs = 0
186+
187+
logger.info(f"Fetching workflow details for ID: {workflow_id}")
188+
response = self.make_request("GET", f"/pipelines/workflows/{workflow_id}/")
189+
for item in response:
190+
for field in item.get("data", []):
191+
if field.get("name") == "finished_jobs":
192+
finished_jobs = field.get("value")
193+
if field.get("name") == "errored_jobs":
194+
errored_jobs = field.get("value")
195+
if field.get("name") == "cancelled_jobs":
196+
cancelled_jobs = field.get("value")
197+
if field.get("name") == "created_jobs":
198+
created_jobs = field.get("value")
199+
if field.get("name") == "waiting_jobs":
200+
waiting_jobs = field.get("value")
201+
if field.get("name") == "scheduled_jobs":
202+
scheduled_jobs = field.get("value")
203+
if field.get("name") == "started_jobs":
204+
started_jobs = field.get("value")
205+
if field.get("name") == "registering_jobs":
206+
registering_jobs = field.get("value")
207+
208+
209+
return {
210+
"finished_jobs": finished_jobs,
211+
"total_jobs": finished_jobs + errored_jobs + cancelled_jobs + created_jobs + waiting_jobs + scheduled_jobs + started_jobs + registering_jobs,
212+
"workflow_failed": (errored_jobs > 0)
213+
}
214+
215+
async def monitor_pipeline(self, workflow_id, total_jobs, pv_inst, rcpts, smtp, series_data):
216+
while True:
217+
status = self._get_workflow_status(workflow_id)
218+
if status["workflow_failed"]:
219+
logger.error("Pipeline failed.")
220+
self.run_notification_plugin(pv_inst, "Pipeline failed with errors", rcpts, smtp, series_data)
221+
break
222+
if status["finished_jobs"] >= total_jobs:
223+
logger.info("Pipeline complete.")
224+
break
225+
if status["total_jobs"] < total_jobs:
226+
self.run_notification_plugin(pv_inst, "Nodes deleted in pipeline", rcpts, smtp, series_data)
227+
break
228+
time.sleep(20)
125229

126-
def run_pipeline(self, pipeline_name: str, previous_inst: int, pipeline_params: dict):
230+
def run_notification_plugin(self, pv_id: int, msg: str, rcpts: str, smtp: str, series_data: str) -> int:
231+
"""
232+
Run the pl-notification plugin.
233+
"""
234+
feed_id = self.get_feed_id_from_plugin_inst(pv_id)
235+
feed_details = self.get_feed_details_from_id(feed_id)
236+
d_series = json.loads(series_data)
237+
email_content = (f"An error occurred while running anonymization pipeline on the following data: "
238+
f"\nFeed Name: {feed_details['name']}"
239+
f"\nDate: {feed_details['date']}"
240+
f"\nMRN: {d_series['PatientID']} "
241+
f"\nStudyDate: {d_series['StudyDate']}"
242+
f"\nModality: {d_series['Modality']}"
243+
f"\nSeriesDescription: {d_series['SeriesDescription']}"
244+
f"\nFolder Name: {d_series['Folder Name']}"
245+
f"\n\nKindly login to ChRIS as *{feed_details['owner']}* to access the logs for more details.")
246+
247+
try:
248+
plugin_id = self._get_plugin_id({"name": "pl-notification", "version": "0.1.0"})
249+
instance_id = self._create_plugin_instance(plugin_id, {
250+
"previous_id": pv_id,
251+
"content": email_content,
252+
"title": msg,
253+
"rcpt": rcpts,
254+
"sender": "noreply@fnndsc.org",
255+
"mail_server": smtp
256+
})
257+
return int(instance_id)
258+
except Exception as ex:
259+
logger.error(f"Error occurred while creating notification instance {ex}")
260+
261+
def _create_plugin_instance(self, plugin_id: str, params: dict):
262+
"""
263+
Create a plugin instance and return its ID.
264+
"""
265+
response = self.post_request( f"/plugins/{plugin_id}/instances/", json=params)
266+
267+
for item in response:
268+
for field in item.get("data", []):
269+
if field.get("name") == "id":
270+
return field.get("value")
271+
272+
raise RuntimeError("Plugin instance could not be scheduled.")
273+
274+
def _get_plugin_id(self, params: dict):
275+
"""
276+
Fetch plugin ID by search parameters.
277+
"""
278+
query_string = urlencode(params)
279+
response = self.make_request("GET", f"/plugins/search/?{query_string}")
280+
281+
for item in response:
282+
for field in item.get("data", []):
283+
if field.get("name") == "id":
284+
return field.get("value")
285+
286+
raise RuntimeError(f"No plugin found with matching criteria: {params}")
287+
288+
289+
async def run_pipeline(self, pipeline_name: str, previous_inst: int, pipeline_params: dict, recipients: str, smtp_server: str, series_data: str):
127290
"""
128291
Full workflow to:
129292
1. Fetch pipeline ID
@@ -133,12 +296,19 @@ def run_pipeline(self, pipeline_name: str, previous_inst: int, pipeline_params:
133296
"""
134297
try:
135298
pipeline_id = self.get_pipeline_id(pipeline_name)
299+
total_jobs = self.get_pipeline_total_pipings(pipeline_id)
136300
default_params = self.get_pipeline_parameters(pipeline_id)
137301
nodes_info = compute_workflow_nodes_info(default_params, include_all_defaults=True)
138302
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)
303+
workflow_id = self.post_workflow(pipeline_id=pipeline_id, previous_id=previous_inst, params=updated_params)
304+
#self.run_notification_plugin(previous_inst)
305+
306+
# Start this in the background (not awaited)
307+
asyncio.create_task(self.monitor_pipeline(workflow_id, total_jobs, previous_inst, recipients, smtp_server, series_data))
308+
140309
logger.info(f"Workflow posted successfully")
141310
return {"status": "Pipeline running"}
311+
142312
except Exception as ex:
143313
logger.error(f"Running pipeline failed due to: {ex}")
144314
return {"status": "Failed", "error": str(ex)}

reg_chxr.py

Lines changed: 23 additions & 13 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

@@ -70,11 +71,6 @@
7071
default="",
7172
help="CUBE/ChRIS auth token"
7273
)
73-
parser.add_argument(
74-
"--CUBEuser",
75-
default="chris",
76-
help="CUBE/ChRIS username"
77-
)
7874
parser.add_argument(
7975
'--inputJSONfile',
8076
default='',
@@ -141,6 +137,18 @@
141137
action="store_true",
142138
default=False,
143139
)
140+
parser.add_argument(
141+
'--recipients',
142+
default='',
143+
type=str,
144+
help='comma separated valid email recipient addresses'
145+
)
146+
parser.add_argument(
147+
'--SMTPServer',
148+
default='mailsmtp4.childrenshospital.org',
149+
type=str,
150+
help='valid email server'
151+
)
144152
parser.add_argument('-V', '--version', action='version',
145153
version=f'%(prog)s {__version__}')
146154

@@ -194,7 +202,7 @@ def main(options: Namespace, inputdir: Path, outputdir: Path):
194202
raise Exception(f"Cannot verify registration for empty pacs data.")
195203

196204
retry_table = create_hash_table(data, 5)
197-
registration_errors = check_registration(options, retry_table, cube_cl)
205+
registration_errors = asyncio.run(check_registration(options, retry_table, cube_cl))
198206

199207
if registration_errors:
200208
LOG(f"ERROR while running pipelines.")
@@ -240,7 +248,7 @@ def create_hash_table(retrieve_data: dict, retry: int) -> dict:
240248
return retry_table
241249

242250
# Recursive method to check on registration and then run anonymization pipeline
243-
def check_registration(options: Namespace, retry_table: dict, client: PACSClient, contains_errors: bool=False):
251+
async def check_registration(options: Namespace, retry_table: dict, client: PACSClient, contains_errors: bool=False):
244252
# null check
245253
if len(retry_table) == 0:
246254
return contains_errors
@@ -279,21 +287,23 @@ def check_registration(options: Namespace, retry_table: dict, client: PACSClient
279287
if registered_series_count:
280288
LOG(f"Series {series_instance} successfully registered to CUBE.")
281289
send_params = {
282-
"neuro_dcm_location": options.neuroDicomLocation,
283-
"neuro_anon_location": options.neuroAnonLocation,
284-
"neuro_nifti_location": options.neuroNiftiLocation,
285-
"folder_name": options.folderName
290+
"url": options.orthancUrl,
291+
"username": options.orthancUsername,
292+
"password": options.orthancPassword,
293+
"aec": options.pushToRemote,
294+
"recipients": options.recipients,
295+
"smtp_server": options.SMTPServer
286296
}
287297
dicom_dir = client.get_pacs_files({'SeriesInstanceUID': series_instance})
288298

289299
# create ChRIS Client Object
290300
cube_con = ChrisClient(options.CUBEurl, options.CUBEtoken)
291-
d_ret = cube_con.anonymize(dicom_dir, send_params, options.pluginInstanceID)
301+
d_ret = await cube_con.anonymize(dicom_dir, options.tagStruct, send_params, options.pluginInstanceID)
292302
if d_ret.get('error'):
293303
contains_errors = True
294304
clone_retry_table.pop(series_instance)
295305

296-
check_registration(options, clone_retry_table, client, contains_errors)
306+
await check_registration(options, clone_retry_table, client, contains_errors)
297307
return contains_errors
298308

299309

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)