Repository navigation
Create a batch transfer directly from batch query results without re-uploading the exported file #419
Description
Motivation
From the September 2026 ADIT/RADIS brainstorming: the batch query -> batch transfer workflow is a file round trip. The user downloads the result xlsx, adds or fills a Pseudonym column if none was given at query time, deletes rows for studies or patients that should not be transferred, splits the file if it exceeds the 500-row limit, and uploads it again while re-selecting source and destination. Nothing needed to create the transfer is missing in ADIT: every BatchQueryResult already holds patient_id, study_uid, series_uid and the pseudonym piped from the query task, and the source node is on the task. Marked LOW PRIO in the document.
This is the lighter alternative to #152 (merge the apps), which medihack left open as unsure because batch query is useful on its own. It keeps batch query standalone and adds a bridge.
Current behaviour
- The export always contains every result of every task; there is no selection (
adit/batch_query/utils/exporters.py:11,:23-25). The results page offers only Download (adit/batch_query/templates/batch_query/batch_query_result_list.html:20-32). - The pseudonym from the query file is copied onto every result of the task explicitly to pipe it through to a batch transfer (
adit/batch_query/models.py:104,:190;adit/batch_query/processors.py:194,:233; commit bda0c99, 2021-01-27). Nothing consumes it yet except the export. - Each
BatchQueryTaskcarries its own source (adit/batch_query/forms.py:112-117,adit/batch_query/processors.py:22-24). - On the transfer side,
BatchTransferFileParser.parsegroups rows byStudyInstanceUIDinto one task, and a row withoutSeriesInstanceUIDmakes the task transfer the whole study (adit/batch_transfer/parsers.py:47-72).BatchTransferTaskSerializerenforces pseudonym-required and the cross-row checks (adit/batch_transfer/serializers.py:12-34,:70-73). The form stamps source and destination on every task (adit/batch_transfer/forms.py:152-160); TODOs atforms.py:155-156andserializers.py:43-44say per-row source/destination from the file is planned. - Failed query tasks produce no rows (
adit/batch_query/processors.py:32-43). A PatientName+BirthDate query matching several patients exports all their studies with only an "Indistinct patients" warning (:81-88,:171-178,:208-215), so the user removes the wrong patient's rows by hand. MAX_BATCH_QUERY_SIZE = 1000limits query tasks,MAX_BATCH_TRANSFER_SIZE = 500limits rows before grouping (adit/settings/base.py:510,:514;adit/core/parsers.py:62-63), so an export can easily exceed the transfer limit.- Selective transfer already creates tasks from checkbox-selected rows without a file (
adit/selective_transfer/templates/selective_transfer/_query_results_table.html:22-30,adit/selective_transfer/consumers.py:375-399), but it applies one job-level pseudonym to all studies and caps non-staff at 10 (:380-381). Neither fits multi-patient batch results. - DICOM node access is scoped to the user's active group (
adit/core/models.py:64); the batch transfer form re-checks both nodes at submit time (adit/batch_transfer/forms.py:112-122).
Proposal
Options, cheapest first:
A -- "Transfer all results of this job" button. No selection UI. Removes the round trip for the clean case; multi-study and indistinct-patient cases still need the file path.
B -- "Create batch transfer" from selected results (recommended). A checkbox column on the batch query results table plus a file-less transfer form. Details below.
C -- Full merge (#152, TODO.md:82-89). One app with QUERYING/READY states and a cache of found studies. Largest change; contradicts the comment on #152.
Shape of B:
- Put the creation logic in
batch_transfer, not in abatch_queryview: either a view inbatch_transferthat takes a batch query job id and selected result pks, or a "from batch query job" option onBatchTransferJobCreateViewwithbatch_filerequired only when no job is chosen. Both reusepermission_required = "batch_transfer.add_batchtransferjob"andBatchTransferLockedMixin(adit/batch_transfer/views.py:53-57) and avoidbatch_queryimportingbatch_transfer(there are no imports in either direction today). Factor the core ("build aBatchTransferJobfrom a list of {patient_id, study_uid, series_uids, pseudonym, lines} dicts") into a service function; a RADIS -> ADIT bridge (openradx/radis TODO.md:47) would need exactly that. - Form:
BatchTransferJobFormminusbatch_fileandsource.project_name/project_descriptioncan be prefilled from theBatchQueryJob(adit/batch_query/models.py:28-29).ethics_application_idcannot: it exists only onBatchTransferJob(adit/batch_transfer/models.py:20) and is required by default (adit/settings/base.py:525,forms.py:87-88). - Source comes from the selected results' query tasks and is shown read-only; refuse if they span more than one source. Re-check
source.is_accessible_by_user(user, "source")and the chosen destination (adit/core/models.py:107-111) at creation time: the query may have run under another active group, access may have been revoked, or staff may be acting on another user's job. - Build one dict per selected result with
series_uids = [series_uid]or[]andlines = result.query.lines(BatchTransferTask.lineshas no default,adit/batch_transfer/models.py:34), validate withBatchTransferTaskSerializer(many=True, can_transfer_unpseudonymized=...), group by study with the step extracted fromparsers.py:47-72so parser and service share it, bulk_create with source/destination, then finish likeDicomJobCreateView.form_valid(adit/core/views.py:109-121,START_BATCH_TRANSFER_UNVERIFIEDatadit/settings/base.py:412) and redirect to the new job. - Missing pseudonyms: show the serializer error with the offending rows and tell the user to re-run the query with a
Pseudonymcolumn. Do not copy the single job-level pseudonym from selective transfer. - Only offer the action once the query job is in a terminal status (the set
is_restartablechecks,adit/core/models.py:354-360); results are bulk-created per task (processors.py:43), so a job in progress has partial results. - Results table:
BatchQueryResultTableshows only task_id, patient_id, study_date_time, study_description, modalities, image_count (adit/batch_query/tables.py:31-38). Add pseudonym and series columns so the user sees what will be transferred. Selection across a paginated, filtered table needs an explicit "all results of task N" (filtertask_idexists,adit/batch_query/filters.py:22) and "all results of the job" mode;batch_query.jsholds only preference hooks, so select-all is new code. - Keep the owner-or-staff gating of the results views (
adit/batch_query/views.py:170-174); the batch_transfer permission alone must not widen who sees patient names and birth dates.
Open questions
- Bridge (this issue) or merge (Merge Batch Query and Batch Transfer #152)? Worth settling before B is built; the sample-file/help-text fix in the companion issue is independent of it.
- Missing pseudonyms: reject with a hint (smallest), a per-row pseudonym input on the results page (close to the wontfix in Add web xlsx editor to batch transfer and batch query view #140), or server-side generation reusing
compute_pseudonym(adit/core/utils/pseudonymizer.py:13-25, as mass transfer does with a salt or a random per-study seed,adit/mass_transfer/processors.py:419-431)? The last changes who controls the pseudonym mapping; a policy decision. - Should
MAX_BATCH_TRANSFER_SIZEcount selected results (as the file path does) or resulting tasks after grouping? Series-level results inflate row counts; counting after grouping seems more useful. - Selection granularity: per result row only, or also per task and whole job?
- Let the user override the source, or always use the query task's source?
- Record the origin
BatchQueryJobon theBatchTransferJob? An FK means a migration and a cross-app model dependency; a text field or nothing avoids both.
Implementation notes
- No migration is needed for B without an origin FK. A per-row exclude flag on
BatchQueryResultwould need one. - Tests: unit test for the extracted grouping function; a view test posting selected pks that asserts created tasks, the pseudonym-required error, mixed-source refusal and foreign-source refusal. Factories exist (
BatchQueryResultFactory,adit/batch_query/factories.py:41;BatchTransferTaskFactory,adit/batch_transfer/factories.py:22). - Not executed end to end; the round trip was established by reading exporter, parser and serializer.
- TODO.md:96-97 says multiple patients per PatientName+BirthDate are not allowed;
processors.py:81-88and:171-178do allow it with a warning, so that note is stale.
Related
- Merge Batch Query and Batch Transfer #152 -- merge batch query and batch transfer; this is the alternative that keeps batch query standalone.
- Add web xlsx editor to batch transfer and batch query view #140 -- web xlsx editor, wontfix; option 2 above deliberately stops short of it.
- Export also dicom task results that did not succeed #20 and TODO.md:11-19 -- export changes; handled in the companion sample-file/help-text issue.
- Add series level search and transfer to SelectiveTransfer #141 -- series-level transfer in selective transfer; B gives a series-level transfer path via batch query results.
- Preliminarily exclude modalities before selective transfer/download #251 -- modalities excluded when a pseudonym is given; a results-to-transfer table has the same display question.
- openradx/radis TODO.md:47 (export collections to ADIT) depends on the four-column batch file contract staying stable; B does not change it.
Activity