Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
113 commits
Select commit Hold shift + click to select a range
bf3a45c
Add mass transfer app and processing
NumericalAdvantage Feb 3, 2026
ea4d781
Add mass transfer opt-out pseudonymization
NumericalAdvantage Feb 3, 2026
1a28368
Ensure mass transfer exports are cleaned on failure
NumericalAdvantage Feb 3, 2026
687dc29
Improve mass transfer filter and date inputs
NumericalAdvantage Feb 3, 2026
4a5a0f1
Fix CI image build and refine mass transfer form layout
NumericalAdvantage Feb 3, 2026
b5fb532
Document dcm2niix dependency and adjust mass transfer form layout
NumericalAdvantage Feb 3, 2026
c747e22
Fix mass transfer study time range and worker env
NumericalAdvantage Feb 6, 2026
61a1b98
Route mass transfer via dicom queue and scope filters
NumericalAdvantage Feb 8, 2026
0efaa85
Enforce filter names and tweak UI spacing
NumericalAdvantage Feb 8, 2026
1086dbf
Fix duplicate studies in _find_studies recursive time-window split
NumericalAdvantage Feb 16, 2026
281303d
Fix pyright type errors
NumericalAdvantage Feb 18, 2026
f4925c2
Address code review feedback for mass transfer PR
NumericalAdvantage Feb 18, 2026
deccb11
Use rslave mount propagation so containers see NAS mounts
NumericalAdvantage Feb 19, 2026
a3c7413
split up tasks into processing and querying to manage creation of tas…
NumericalAdvantage Feb 19, 2026
8f5444a
Collapse two-phase mass transfer into single-phase with dedicated worker
NumericalAdvantage Feb 28, 2026
253a1d3
Add migration for export_cleaned field on MassTransferVolume
NumericalAdvantage Feb 23, 2026
268f078
Include stdout in dcm2niix conversion error messages
NumericalAdvantage Feb 23, 2026
3c028e8
Add three-mode anonymization with longitudinal linking
NumericalAdvantage Feb 28, 2026
8662207
Fix DIMSE connection leak on abandoned generators and enable job canc…
NumericalAdvantage Feb 28, 2026
31bd1e3
Remove MassTransferAssociation model, show user failed transfers with…
NumericalAdvantage Mar 1, 2026
d96a036
Add persistent DIMSE connection mode to avoid per-operation associati…
NumericalAdvantage Mar 4, 2026
ff1d71f
Rewrite mass transfer processing with deferred insertion, patient-cen…
NumericalAdvantage Mar 5, 2026
9a86256
Use per-study random pseudonyms in non-linking mode to prevent patien…
NumericalAdvantage Mar 5, 2026
3e53c06
Prefer C-MOVE over C-GET for mass transfer
NumericalAdvantage Mar 5, 2026
ccd8bd9
Revert C-MOVE preference, keep pseudonymized UID fields, and detect e…
NumericalAdvantage Mar 7, 2026
e9370fd
Include study time in folder name
NumericalAdvantage Mar 7, 2026
9e61dd5
Update spec with pseudonymized UID fields, folder name format, and cl…
NumericalAdvantage Mar 7, 2026
05c5a94
Re-add pseudonymized UID columns via migration 0010
NumericalAdvantage Mar 7, 2026
d6ac90e
Add compressed transfer syntaxes to storage presentation contexts for…
NumericalAdvantage Mar 8, 2026
3cdd321
Pass compressed transfer syntaxes when adding C-GET storage contexts
NumericalAdvantage Mar 9, 2026
a23f260
Fix C-GET reliability: presentation context split, dead association c…
NumericalAdvantage Mar 9, 2026
1dc05e1
Pace C-GET requests and retry 0-image responses from IMPAX
NumericalAdvantage Mar 9, 2026
402b904
Reduce C-GET retry from 5 exponential to 1 quick retry
NumericalAdvantage Mar 10, 2026
7362344
Fix stale C-FIND responses on persistent DIMSE connections
NumericalAdvantage Mar 12, 2026
fd5c99b
Add job-identifying parent folder to mass transfer output path
samuelvkwong Mar 16, 2026
f2ce957
Merge branch 'main' into masstransfer
samuelvkwong Mar 16, 2026
71c0a01
Override rslave mount propagation in dev compose
samuelvkwong Mar 16, 2026
b91af72
Add inline JSON filters, volume table, DICOM sidecar, and UI improvem…
NumericalAdvantage Mar 16, 2026
70ca1ee
Fix CI failure by adding missing mass_transfer_worker Docker image tag
samuelvkwong Mar 16, 2026
b339598
Fix CI: add missing mass_transfer_worker image tag, add type hint and…
NumericalAdvantage Mar 16, 2026
77cdac8
Fix ruff lint errors: line length, import sorting, unused import
NumericalAdvantage Mar 16, 2026
58c4135
Merge branch 'main' into masstransfer
NumericalAdvantage Mar 19, 2026
7c2dfcf
Fix pyright type errors in mass transfer module
NumericalAdvantage Mar 19, 2026
2b43ab4
Delegate compute_pseudonym to dicognito's IDAnonymizer
NumericalAdvantage Mar 19, 2026
5e63ec3
Vendor CodeMirror and jsonlint instead of loading from CDN
NumericalAdvantage Mar 19, 2026
8d301f1
Revert "Vendor CodeMirror and jsonlint instead of loading from CDN"
NumericalAdvantage Mar 19, 2026
ac9c2a1
Subclass TransferJob to add trial protocol fields
NumericalAdvantage Mar 19, 2026
982a1bd
Remove unused MASS_TRANSFER_EXPORT_BASE_DIR env variable
NumericalAdvantage Mar 19, 2026
04d7aaa
Remove redundant MOUNT_DIR volume from dev compose
NumericalAdvantage Mar 19, 2026
8f9afd5
Remove persistent DIMSE mode, use manual connection management
NumericalAdvantage Mar 22, 2026
d430772
Address PR review feedback: simplify models, refactor processor, vend…
NumericalAdvantage Mar 22, 2026
b9920b9
Generate fresh 0001_initial.py migration for mass transfer
NumericalAdvantage Mar 22, 2026
9c8a4b1
Fix random pseudonym duplication bug, update and add test cases
NumericalAdvantage Mar 22, 2026
934c8c5
Serve CodeMirror from vendored static files, not CDN
NumericalAdvantage Mar 22, 2026
9fe4214
Implement pseudonymization UI per medihack's spec
NumericalAdvantage Mar 22, 2026
d81b4d1
Remove duplicate pseudonym_salt field definition that overrode help text
NumericalAdvantage Mar 22, 2026
02d59be
Clear pseudonym_salt when pseudonymize is unchecked
NumericalAdvantage Mar 22, 2026
04184b9
Replace associations CSV export with full volume CSV export
samuelvkwong Mar 25, 2026
14d9ac9
Add missing django-codemirror dependency
samuelvkwong Mar 25, 2026
d787e72
Fix mass_transfer form tests by granting user access to DICOM nodes
samuelvkwong Mar 25, 2026
62c8ae6
Add script to convert CSV filters to mass transfer JSON format
samuelvkwong Mar 25, 2026
474d8d1
Fix pyright and ruff linting errors
samuelvkwong Mar 25, 2026
578b4ad
Add staging environment for testing worker scaling on Docker Swarm
samuelvkwong Mar 25, 2026
f7cab69
Remove short hash from mass transfer study folder names
samuelvkwong Mar 26, 2026
f7c97aa
Add stack-deploy-staging CLI command for Docker Swarm staging deployment
samuelvkwong Mar 26, 2026
82993fd
Fix stack-deploy-staging by removing pull_policy and building images …
samuelvkwong Mar 26, 2026
16051f1
Replace profiles with deploy.replicas: 0 for Swarm compatibility
samuelvkwong Mar 26, 2026
28d65ab
Add image reference for init service in staging
samuelvkwong Mar 26, 2026
d9e8a0c
Add stack-rm-staging CLI command for Docker Swarm staging teardown
samuelvkwong Mar 26, 2026
d228522
Use separate session cookie name for staging to prevent dev/staging l…
samuelvkwong Mar 26, 2026
c7a0c61
Re-add auto_close flag for persistent DIMSE associations
NumericalAdvantage Mar 27, 2026
832bf6d
Merge branch 'main' into masstransfer
medihack Mar 27, 2026
dcd9057
Rename DICOM sidecar helpers to metadata and apply formatting
medihack Mar 27, 2026
6600340
Explicit association management per medihack's request
NumericalAdvantage Mar 27, 2026
10367c8
Move source/destination from job to task
NumericalAdvantage Mar 27, 2026
c3e516b
Fix tests for source/destination move and sidecar rename
NumericalAdvantage Mar 27, 2026
67ce954
Use auto_connect + auto_close=False for association management
NumericalAdvantage Mar 27, 2026
1609647
Make CSV export available for all mass transfer jobs
NumericalAdvantage Mar 27, 2026
1d7a4b0
Add pseudonym salt to CSV header and job detail page
NumericalAdvantage Mar 27, 2026
3866eea
Abort association on generator abandonment in connect_to_server
NumericalAdvantage Mar 27, 2026
cb38b3c
Add warning log on DIMSE generator abandonment and fully consume seri…
medihack Mar 27, 2026
7a43d1b
Suppress UserWarning for invalid DT VR in frame reference datetime test
medihack Mar 27, 2026
c14192c
Refactor _transfer_grouped_series into smaller focused methods
medihack Mar 27, 2026
f01872a
Fix flaky test by tracking sentinel in wado_retrieve
medihack Mar 27, 2026
6764c29
Add missing ON DELETE SET NULL constraint for mass_transfer queued_jo…
medihack Mar 27, 2026
d2b81f6
Allow DICOM server as mass transfer destination
medihack Mar 28, 2026
21d8f95
Add dark mode support for CodeMirror in mass transfer form
medihack Mar 28, 2026
bfd9943
Refactor mass transfer processor to create PENDING volumes before tra…
medihack Mar 29, 2026
0e4eaff
Move mass transfer task queueing to background job
medihack Mar 29, 2026
41bf368
Add 24-hour safety timeout for mass transfer task processing
medihack Mar 29, 2026
a1c4003
Address PR review findings for mass transfer module
medihack Mar 29, 2026
5a100f8
Use DICOM-style UIDs in mass transfer processor test fixtures
medihack Mar 29, 2026
2156528
Avoid pebble subprocess DB leaks in queue pending tasks tests
medihack Mar 29, 2026
4a67599
Simplify mass transfer folder naming helpers
medihack Mar 29, 2026
cb5c0b3
Replace dicognito-based pseudonym generation with own SHA-256 algorithm
medihack Mar 29, 2026
21178bd
Auto-reconnect DIMSE on service switch and add mass transfer acceptan…
medihack Mar 30, 2026
d20cee2
Close stale DB connection in queue_mass_transfer_tasks worker
medihack Mar 30, 2026
1a8a8d2
Merge remote-tracking branch 'origin/masstransfer' into add-staging-e…
samuelvkwong Mar 30, 2026
a70ff7f
Add environment variables for staging replica scaling
samuelvkwong Mar 30, 2026
3452466
Add scrollbar for long filters and fix cancel FK violation
NumericalAdvantage Mar 30, 2026
ad6818b
Fix pyright error: queued_job_id type annotation allows None
NumericalAdvantage Mar 30, 2026
4c5e242
Merge branch 'main' into masstransfer
samuelvkwong Mar 30, 2026
d38eebf
Merge branch 'masstransfer' into add-staging-environment
samuelvkwong Mar 30, 2026
6e3a191
Add min_number_of_series_related_instances filter for mass transfer
samuelvkwong Mar 30, 2026
409053b
Add --min-series-instances arg to csv_to_mass_transfer_filters script
samuelvkwong Mar 30, 2026
2eb85d5
Merge branch 'masstransfer' into add-staging-environment
samuelvkwong Mar 30, 2026
50adf2c
Add --delimiter arg to csv_to_mass_transfer_filters script
samuelvkwong Mar 30, 2026
962904d
Add MASS_TRANSFER_WORKER_REPLICAS to example.env
samuelvkwong Mar 31, 2026
9e167e2
Merge remote-tracking branch 'origin/masstransfer' into add-staging-e…
samuelvkwong Mar 31, 2026
c93cf1f
Remove STAGING_DEPLOYMENT.md
samuelvkwong Mar 31, 2026
8a6e9d5
Merge branch 'main' into add-staging-environment
samuelvkwong Apr 2, 2026
1279ecf
Add mass transfer worker to observability config example
samuelvkwong Apr 2, 2026
a541702
Merge branch 'main' into add-staging-environment
samuelvkwong Apr 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ jobs:
adit_dev-web:latest
adit_dev-default_worker:latest
adit_dev-dicom_worker:latest
adit_dev-mass_transfer_worker:latest
adit_dev-receiver:latest
cache-from: type=gha
cache-to: type=gha,mode=max
Expand Down
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,6 @@
"HTML (EEx)",
"HTML (Eex)",
"plist"
]
],
"containers.containers.label": "ContainerName"
}
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ Key variables in `.env` (see `example.env`):
- **Type Checking**: pyright in basic mode (migrations excluded)
- **Linting**: Ruff with E, F, I, DJ rules

### Assertions

- Use `assert` for internal programming error checks (preconditions, invariants). Do not replace with `ValueError` or similar — this app is never run with `python -O`.

### Django Field Conventions

- Text/char fields: use `blank=True` alone (not `null=True`)
Expand Down
13 changes: 9 additions & 4 deletions adit/core/management/commands/cleanup_jobs_and_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,24 @@
from adit.batch_query.models import BatchQueryJob, BatchQueryTask
from adit.batch_transfer.models import BatchTransferJob, BatchTransferTask
from adit.core.models import DicomJob, DicomTask
from adit.mass_transfer.models import MassTransferJob, MassTransferTask
from adit.selective_transfer.models import SelectiveTransferJob, SelectiveTransferTask


class Command(BaseCommand):
help = "Cleanup all DICOM jobs and tasks that are stuck."

def cleanup_tasks(self, model: type[DicomTask]):
job_model: type[DicomJob] | None = model._meta.get_field("job").related_model # type: ignore[assignment]
assert job_model is not None
job_ids = set()

message = "Unexpected crash while processing this task."
task_log = "The worker crashed unexpectedly and this task was manually set to failed."

tasks_in_progress = model.objects.filter(status=model.Status.IN_PROGRESS).all()
for task in tasks_in_progress:
task.status = SelectiveTransferTask.Status.FAILURE
task.status = model.Status.FAILURE
task.message = message
task.log = task_log
task.save()
Expand All @@ -27,14 +30,14 @@ def cleanup_tasks(self, model: type[DicomTask]):
tasks_pending = model.objects.filter(Q(status=model.Status.PENDING)).all()
for task in tasks_pending:
if task.queued_job_id is None:
task.status = SelectiveTransferTask.Status.FAILURE
task.status = model.Status.FAILURE
task.message = message
task.log = task_log
task.save()
job_ids.add(task.job_id)

for job_id in job_ids:
job = SelectiveTransferJob.objects.get(id=job_id)
job = job_model.objects.get(id=job_id)
job.post_process(suppress_email=True)

def cleanup_jobs(self, model: type[DicomJob]):
Expand All @@ -45,7 +48,7 @@ def cleanup_jobs(self, model: type[DicomJob]):
).all()

for job in jobs:
job.status = SelectiveTransferJob.Status.FAILURE
job.status = model.Status.FAILURE
job.message = message
job.save()

Expand All @@ -65,5 +68,7 @@ def handle(self, *args, **options):
self.cleanup_jobs(BatchQueryJob)
self.cleanup_tasks(BatchTransferTask)
self.cleanup_jobs(BatchTransferJob)
self.cleanup_tasks(MassTransferTask)
self.cleanup_jobs(MassTransferJob)

self.stdout.write("Done")
18 changes: 18 additions & 0 deletions adit/core/migrations/0016_add_max_search_results_to_dicomserver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2026-03-09 12:00

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0015_delete_queuedtask'),
]

operations = [
migrations.AddField(
model_name='dicomserver',
name='max_search_results',
field=models.PositiveIntegerField(default=200),
),
]
21 changes: 21 additions & 0 deletions adit/core/migrations/0017_review_fixes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 6.0.3 on 2026-03-29 13:52

import django.core.validators
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("core", "0016_add_max_search_results_to_dicomserver"),
]

operations = [
migrations.AlterField(
model_name="dicomserver",
name="max_search_results",
field=models.PositiveIntegerField(
default=200, validators=[django.core.validators.MinValueValidator(1)]
),
),
]
8 changes: 7 additions & 1 deletion adit/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ class DicomServer(DicomNode):
dicomweb_stow_prefix = models.CharField(blank=True, max_length=2000)
dicomweb_authorization_header = models.CharField(blank=True, max_length=2000)

# C-FIND result limit before recursive time-window splitting
max_search_results = models.PositiveIntegerField(
default=200, validators=[MinValueValidator(1)]
)

objects: DicomNodeManager["DicomServer"] = DicomNodeManager["DicomServer"]()


Expand Down Expand Up @@ -391,7 +396,7 @@ class Status(models.TextChoices):
job = models.ForeignKey(DicomJob, on_delete=models.CASCADE, related_name="tasks")
source_id: int
source = models.ForeignKey(DicomNode, related_name="+", on_delete=models.PROTECT)
queued_job_id: int
queued_job_id: int | None
queued_job = models.OneToOneField(
ProcrastinateJob, null=True, on_delete=models.SET_NULL, related_name="+"
)
Expand All @@ -417,6 +422,7 @@ def __str__(self) -> str:

def get_absolute_url(self) -> str: ...


def queue_pending_task(self) -> None:
"""Queues a dicom task."""
assert self.status == DicomTask.Status.PENDING
Expand Down
75 changes: 52 additions & 23 deletions adit/core/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,32 +53,30 @@ def backup_db(*args, **kwargs):
call_command("dbbackup", "--clean", "-v 2")


@app.task(
queue="dicom",
pass_context=True,
# TODO: Increase the priority slightly when it will be retried
# See https://github.com/procrastinate-org/procrastinate/issues/1096
#
# Two-level retry strategy:
# 1. Network layer (Stamina): Fast retries for transient failures (5-10 attempts)
# - Applied at DIMSE/DICOMweb connector level
# - Handles: connection timeouts, HTTP 503, temporary server unavailability
# 2. Task layer (Procrastinate): Slow retries for complete operation failures
# - Applied here (max_attempts below)
# - Only triggers after network-level retries are exhausted
# - Retries the entire task
retry=RetryStrategy(
max_attempts=settings.DICOM_TASK_MAX_ATTEMPTS,
wait=settings.DICOM_TASK_RETRY_WAIT,
linear_wait=settings.DICOM_TASK_LINEAR_WAIT,
retry_exceptions={RetriableDicomError},
),
DICOM_TASK_RETRY_STRATEGY = RetryStrategy(
max_attempts=settings.DICOM_TASK_MAX_ATTEMPTS,
wait=settings.DICOM_TASK_RETRY_WAIT,
linear_wait=settings.DICOM_TASK_LINEAR_WAIT,
retry_exceptions={RetriableDicomError},
)
def process_dicom_task(context: JobContext, model_label: str, task_id: int):


def _run_dicom_task(
context: JobContext,
model_label: str,
task_id: int,
*,
process_timeout: int | None = None,
):
assert context.job

dicom_task = get_dicom_task(model_label, task_id)
assert dicom_task.status == DicomTask.Status.PENDING
# The assertion status == PENDING assumed that tasks always arrive fresh,
# but in reality a retried task can arrive in a half-finished state.
# A task may still be IN_PROGRESS if the worker was killed before the
# finally block could update its status. Accept both PENDING and
# IN_PROGRESS so the retry can proceed.
assert dicom_task.status in (DicomTask.Status.PENDING, DicomTask.Status.IN_PROGRESS)

# When the first DICOM task of a job is processed then the status of the
# job switches from PENDING to IN_PROGRESS
Expand All @@ -96,7 +94,7 @@ def process_dicom_task(context: JobContext, model_label: str, task_id: int):

logger.info(f"Processing of {dicom_task} started.")

@concurrent.process(timeout=settings.DICOM_TASK_PROCESS_TIMEOUT, daemon=True)
@concurrent.process(timeout=process_timeout, daemon=True)
def _process_dicom_task(model_label: str, task_id: int) -> ProcessingResult:
dicom_task = get_dicom_task(model_label, task_id)
processor = get_dicom_processor(dicom_task)
Expand All @@ -121,11 +119,18 @@ def _monitor_task(context: JobContext, future: ProcessFuture) -> None:
dicom_task.log = result["log"]
ensure_db_connection()

except futures.CancelledError:
dicom_task.status = DicomTask.Status.CANCELED
dicom_task.message = "Task was canceled."
ensure_db_connection()


except futures.TimeoutError:
dicom_task.message = "Task was aborted due to timeout."
dicom_task.status = DicomTask.Status.FAILURE
ensure_db_connection()


Comment on lines +122 to 133
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Canceled tasks now bypass the cleanup hook.

This is the only terminal failure path in _run_dicom_task() that skips cleanup_on_failure(). A user-canceled transfer can therefore leave partial DB/file state behind even though timeout, retriable, and generic failures clean up.

🛠️ Suggested fix
     except futures.CancelledError:
         dicom_task.status = DicomTask.Status.CANCELED
         dicom_task.message = "Task was canceled."
         ensure_db_connection()
+        dicom_task.cleanup_on_failure()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@adit/core/tasks.py` around lines 122 - 132, The CancelledError handler in
_run_dicom_task currently sets dicom_task.status and message but never calls
DicomTask.cleanup_on_failure(), leaving partial state; update the except
futures.CancelledError block to set dicom_task.status =
DicomTask.Status.CANCELED and dicom_task.message = "Task was canceled.", call
ensure_db_connection(), and then call dicom_task.cleanup_on_failure() (mirroring
the TimeoutError path) so that canceled transfers run the same cleanup logic.

except RetriableDicomError as err:
logger.exception("Retriable error occurred during %s.", dicom_task)

Expand All @@ -146,6 +151,7 @@ def _monitor_task(context: JobContext, future: ProcessFuture) -> None:
dicom_task.message = str(err)

ensure_db_connection()

raise err

except Exception as err:
Expand All @@ -162,6 +168,7 @@ def _monitor_task(context: JobContext, future: ProcessFuture) -> None:

ensure_db_connection()


finally:
dicom_task.end = timezone.now()
dicom_task.save()
Expand All @@ -176,3 +183,25 @@ def _monitor_task(context: JobContext, future: ProcessFuture) -> None:

# TODO: https://github.com/procrastinate-org/procrastinate/issues/1106
db.close_old_connections()


@app.task(
queue="dicom",
pass_context=True,
# TODO: Increase the priority slightly when it will be retried
# See https://github.com/procrastinate-org/procrastinate/issues/1096
#
# Two-level retry strategy:
# 1. Network layer (Stamina): Fast retries for transient failures (5-10 attempts)
# - Applied at DIMSE/DICOMweb connector level
# - Handles: connection timeouts, HTTP 503, temporary server unavailability
# 2. Task layer (Procrastinate): Slow retries for complete operation failures
# - Applied here (max_attempts below)
# - Only triggers after network-level retries are exhausted
# - Retries the entire task
retry=DICOM_TASK_RETRY_STRATEGY,
)
def process_dicom_task(context: JobContext, model_label: str, task_id: int):
_run_dicom_task(
context, model_label, task_id, process_timeout=settings.DICOM_TASK_PROCESS_TIMEOUT
)
6 changes: 6 additions & 0 deletions adit/core/templates/core/home.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ <h1 class="display-4 d-flex align-items-center">
<dd>
Transfer or download multiple studies specified in a batch file.
</dd>
<dt>
<a href="{% url 'mass_transfer_job_create' %}">Mass Transfer</a>
</dt>
<dd>
Transfer large volumes of imaging data over a time range using reusable filters.
</dd>
<dt>
<a href="{% url 'dicom_explorer_form' %}">DICOM Explorer</a>
</dt>
Expand Down
65 changes: 65 additions & 0 deletions adit/core/tests/utils/test_dimse_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,68 @@ def test_abort_connection_with_no_connection(self):

# Assert
assert connector.assoc is None

def test_service_switch_closes_and_reopens_connection(self, mocker):
"""Test that switching services (e.g. C-FIND -> C-GET) closes the old connection
and opens a new one with the correct presentation contexts."""
server = DicomServerFactory.create()
connector = DimseConnector(server, auto_connect=True)

associate_mock = mocker.patch("adit.core.utils.dimse_connector.AE.associate")

# First association for C-FIND
find_assoc = create_association_mock()
find_assoc.is_alive.return_value = True
find_assoc.send_c_find.return_value = DicomTestHelper.create_successful_c_find_responses(
[{"PatientID": "12345", "QueryRetrieveLevel": "STUDY"}]
)

# Second association for C-GET
get_assoc = create_association_mock()
get_assoc.is_alive.return_value = True
get_assoc.send_c_get.return_value = DicomTestHelper.create_successful_c_get_response()

associate_mock.side_effect = [find_assoc, get_assoc]

# Act: perform a C-FIND
query = QueryDataset.create(
PatientID="12345",
StudyInstanceUID="1.2.3.4.5",
QueryRetrieveLevel="STUDY",
)
list(connector.send_c_find(query))

# After C-FIND with auto_close, connection is closed
assert connector.assoc is None

# Now open a persistent connection for C-FIND, then switch to C-GET
connector.auto_close = False

# Reset the mock for the persistent connections
find_assoc2 = create_association_mock()
find_assoc2.is_alive.return_value = True
find_assoc2.send_c_find.return_value = DicomTestHelper.create_successful_c_find_responses(
[{"PatientID": "12345", "QueryRetrieveLevel": "STUDY"}]
)

get_assoc2 = create_association_mock()
get_assoc2.is_alive.return_value = True
get_assoc2.send_c_get.return_value = DicomTestHelper.create_successful_c_get_response()

associate_mock.side_effect = [find_assoc2, get_assoc2]

# C-FIND with auto_close=False keeps connection open
list(connector.send_c_find(query))
assert connector.assoc is find_assoc2
assert connector._current_service == "C-FIND"

# C-GET should close the C-FIND connection and open a new one
store_handler = MagicMock()
store_errors = []
connector.send_c_get(query, store_handler, store_errors)

# The C-FIND association should have been released (closed)
assert find_assoc2.release.called
# The connector should now be on the C-GET association
assert connector.assoc is get_assoc2
assert connector._current_service == "C-GET"
Loading