Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
  • Loading branch information
Roy Wiggins authored and Roy Wiggins committed Dec 12, 2024
2 parents ef91249 + 046481e commit 6b2818c
Show file tree
Hide file tree
Showing 7 changed files with 148 additions and 4 deletions.
2 changes: 2 additions & 0 deletions app/common/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class mercure_rule:
ACTION = "action"
ACTION_TRIGGER = "action_trigger"
STUDY_TRIGGER_CONDITION = "study_trigger_condition"
STUDY_FORCE_COMPLETION_ACTION = "study_force_completion_action"
STUDY_TRIGGER_CONDITION_TIMEOUT = "timeout"
STUDY_TRIGGER_CONDITION_RECEIVED_SERIES = "received_series"
STUDY_TRIGGER = "study_trigger"
Expand All @@ -92,6 +93,7 @@ class mercure_study:
COMPLETE_TRIGGER = "complete_trigger"
COMPLETE_REQUIRED_SERIES = "complete_required_series"
COMPLETE_FORCE = "complete_force"
COMPLETE_FORCE_ACTION = "complete_force_action"


class mercure_info:
Expand Down
2 changes: 2 additions & 0 deletions app/common/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ class Rule(BaseModel, Compat):
action: Literal["route", "both", "process", "discard", "notification"] = "route"
action_trigger: Literal["series", "study"] = "series"
study_trigger_condition: Literal["timeout", "received_series"] = "timeout"
study_force_completion_action: Literal["discard", "proceed", "ignore"] = "discard"
study_trigger_series: str = ""
priority: Literal["normal", "urgent", "offpeak"] = "normal"
processing_module: Union[str, List[str]] = ""
Expand Down Expand Up @@ -345,6 +346,7 @@ class TaskStudy(BaseModel, Compat):
received_series: Optional[List[str]]
received_series_uid: Optional[List[str]]
complete_force: bool = False
complete_force_action: Optional[str] = "discard"


class TaskProcessing(BaseModel, Compat):
Expand Down
1 change: 1 addition & 0 deletions app/routing/generate_taskfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ def add_study(
received_series=[tags_list.get("SeriesDescription", mercure_options.INVALID)],
received_series_uid=[tags_list.get("SeriesInstanceUID", mercure_options.INVALID)],
complete_force=False,
complete_force_action=config.mercure.rules[applied_rule].study_force_completion_action,
)

return study_info
Expand Down
65 changes: 61 additions & 4 deletions app/routing/route_studies.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from pathlib import Path
from typing import Dict, Union

import pyfakefs

# App-specific includes
import common.config as config
import common.helper as helper
Expand All @@ -34,10 +36,15 @@ def route_studies(pending_series: Dict[str, float]) -> None:
# TODO: Handle studies that exceed the "force completion" timeout in the "CONDITION_RECEIVED_SERIES" mode
studies_ready = {}
with os.scandir(config.mercure.studies_folder) as it:
it = list(it) # type: ignore
for entry in it:
if entry.is_dir() and not is_study_locked(entry.path) and is_study_complete(entry.path, pending_series):
modificationTime = entry.stat().st_mtime
studies_ready[entry.name] = modificationTime
if entry.is_dir() and not is_study_locked(entry.path):
if is_study_complete(entry.path, pending_series):
modificationTime = entry.stat().st_mtime
studies_ready[entry.name] = modificationTime
else:
if not check_force_study_timeout(Path(entry.path)):
logger.error(f"Error during checking force study timeout for study {entry.path}")
logger.debug(f"Studies ready for processing: {studies_ready}")
# Process all complete studies
for dir_entry in sorted(studies_ready):
Expand Down Expand Up @@ -161,6 +168,54 @@ def check_study_timeout(task: TaskHasStudy, pending_series: Dict[str, float]) ->
return False


def check_force_study_timeout(folder: Path) -> bool:
"""
Checks if the duration since the creation of the study exceeds the force study completion timeout
"""
try:
logger.debug("Checking force study timeout")

with open(folder / mercure_names.TASKFILE, "r") as json_file:
task: TaskHasStudy = TaskHasStudy(**json.load(json_file))

study = task.study
creation_string = study.creation_time
if not creation_string:
logger.error(f"Missing creation time in task file in study folder {folder}", task.id) # handle_error
return False
logger.debug(f"Creation time: {creation_string}, now is: {datetime.now()}")

creation_time = datetime.strptime(creation_string, "%Y-%m-%d %H:%M:%S")
if datetime.now() > creation_time + timedelta(seconds=config.mercure.study_forcecomplete_trigger):
logger.info(f"Force timeout met for study {folder}")
if not study.complete_force_action or study.complete_force_action == "ignore":
return True
elif study.complete_force_action == "proceed":
logger.info(f"Forcing study completion for study {folder}")
(folder / mercure_names.FORCE_COMPLETE).touch()
elif study.complete_force_action == "discard":
logger.info(f"Moving folder to discard: {folder.name}")
lock_file = Path(folder / mercure_names.LOCK)
try:
lock = helper.FileLock(lock_file)
except:
logger.error(f"Unable to lock study for removal {lock_file}") # handle_error
return False
if not move_study_folder(task.id, folder.name, "DISCARD"):
logger.error(f"Error during moving study to discard folder {study}", task.id) # handle_error
return False
if not remove_study_folder(None, folder.name, lock):
logger.error(f"Unable to delete study folder {lock_file}") # handle_error
return False
else:
logger.debug("Force timeout not met.")
return True

except Exception:
logger.error(f"Could not check force study timeout for study {folder}") # handle_error
return False


def check_study_series(task: TaskHasStudy, required_series: str) -> bool:
"""
Checks if all series required for study completion have been received
Expand Down Expand Up @@ -305,7 +360,7 @@ def move_study_folder(task_id: Union[str, None], study: str, destination: str) -
"""
logger.debug(f"Move_study_folder {study} to {destination}")
source_folder = config.mercure.studies_folder + "/" + study
destination_folder = config.mercure.discard_folder
destination_folder = None
if destination == "PROCESSING":
destination_folder = config.mercure.processing_folder
elif destination == "SUCCESS":
Expand All @@ -314,6 +369,8 @@ def move_study_folder(task_id: Union[str, None], study: str, destination: str) -
destination_folder = config.mercure.error_folder
elif destination == "OUTGOING":
destination_folder = config.mercure.outgoing_folder
elif destination == "DISCARD":
destination_folder = config.mercure.discard_folder
else:
logger.error(f"Unknown destination {destination} requested for {study}", task_id) # handle_error
return False
Expand Down
53 changes: 53 additions & 0 deletions app/tests/test_studies.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,56 @@ def test_route_study_series_trigger(fs: FakeFilesystem, mercure_config, mocked):
# assert ( outgoing / study_uid).exists()

# assert list((outgoing / study_uid).glob("**/*.dcm")) != []


@pytest.mark.parametrize("force_complete_action", ["ignore", "proceed", "discard"])
def test_route_study_force_complete(fs: FakeFilesystem, mercure_config, mocked, force_complete_action):
"""
Test that a study exceeding the force completion timeout is handled according to the action specified.
"""
config = mercure_config(
{
"series_complete_trigger": 10,
"study_complete_trigger": 30,
"study_forcecomplete_trigger": 60,
"rules": {
"route_study": Rule(
rule="True",
action="route",
study_trigger_condition="received_series",
study_trigger_series=" 'test_series_complete' and 'test_series_missing' ",
target="test_target_2",
action_trigger="study",
study_force_completion_action=force_complete_action,
).dict(),
},
}
)
study_uid = str(uuid.uuid4())
series_uid = str(uuid.uuid4())
series_description = "test_series_complete"
out_path = Path(config.outgoing_folder)
discard_path = Path(config.discard_folder)

with freeze_time("2020-01-01 00:00:00") as frozen_time:
# Create the initial series.
create_series(mocked, fs, config, study_uid, series_uid, series_description)
frozen_time.tick(delta=timedelta(seconds=11))
# Run the router as the first series completes to generate a study task.
router.run_router()
frozen_time.tick(delta=timedelta(seconds=61))
# Run router after force complete trigger.
router.run_router()

if force_complete_action == "ignore":
# ensure that the study is not routed
assert list(out_path.glob("**/*")) == []
assert list(discard_path.glob("**/*")) == []
elif force_complete_action == "proceed":
# run router again to proceed with the force complete file.
router.run_router()
assert list(out_path.glob("**/*")) != []
assert list(discard_path.glob("**/*")) == []
elif force_complete_action == "discard":
assert list(discard_path.glob("**/*")) != []
assert list(out_path.glob("**/*")) == []
1 change: 1 addition & 0 deletions app/webinterface/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ async def rules_edit_post(request) -> Response:
action_trigger=form.get("action_trigger", "series"),
study_trigger_condition=form.get("study_trigger_condition", "timeout"),
study_trigger_series=form.get("study_trigger_series", ""),
study_force_completion_action=form.get("study_force_completion_action", ""),
priority=form.get("priority", "normal"),
processing_module=processing_module,
processing_settings=new_processing_settings,
Expand Down
28 changes: 28 additions & 0 deletions app/webinterface/templates/rules_edit.html
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,27 @@ <h1 class="title is-4">Edit Rule - {{rule}}</h1>
</div>
</div>
</div>
<div class="field" id="force_study_completion_field">
<label class="label">Force Completion Action</label>
<div class="select">
<div class="control">
<select name="study_force_completion_action" id="study_force_completion_action">
<option value="discard" {% if
rules[rule]['study_force_completion_action']=='discard' %}selected{% endif%}>
Discard
</option>
<option value="proceed" {% if
rules[rule]['study_force_completion_action']=='proceed' %}selected{% endif%}>
Proceed with Received Series
</option>
<option value="ignore" {% if
rules[rule]['study_force_completion_action']=='ignore' %}selected{% endif%}>
Ignore Timeout and Wait
</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label">Priority</label>
<div class="select">
Expand Down Expand Up @@ -689,12 +710,17 @@ <h1 class="title is-4">Edit Rule - {{rule}}</h1>

$('#action_trigger').change(function () {
var action_trigger = $(this).children("option:selected").val();
var study_trigger = $('#study_trigger_condition').children("option:selected").val();

if (action_trigger == 'series') {
$('#study_trigger_field').hide();
$("#force_study_completion_field").hide();
$('#study_trigger_series').prop('required', false);
} else {
$('#study_trigger_field').show();
if (study_trigger == 'received_series') {
$("#force_study_completion_field").show();
}
}
});

Expand All @@ -704,9 +730,11 @@ <h1 class="title is-4">Edit Rule - {{rule}}</h1>
if (study_trigger == 'timeout') {
$('#study_trigger_series_section').hide();
$('#study_trigger_series').prop('required', false);
$("#force_study_completion_field").hide();
} else {
$('#study_trigger_series_section').show();
$('#study_trigger_series').prop('required', true);
$("#force_study_completion_field").show();
}
});

Expand Down

0 comments on commit 6b2818c

Please sign in to comment.