Repository navigation
Select search results (per page or all matching) and add them to a collection in one step #317
Description
Motivation
From the September 2026 ADIT/RADIS brainstorming, under ADIT-RADIS interoperability: "Reports are selectable, select all and also be able to select all on all other pages and then put it into a collection". The intended journey is search -> collection -> ADIT batch transfer file (TODO.md:47). The transfer-file part is a separate issue; this one covers getting a search cohort into a collection without clicking one report at a time.
Current behaviour
- Reports enter a collection one per request: the per-report "Collections" button (
radis/collections/apps.py:27) opens_collection_select.html, andCollectionSelectView.postadds exactly one report (radis/collections/views.py:184-211). - A result card has no checkbox (
radis/search/templates/search/_result_document.html:1-24); there is notype="checkbox"anywhere inradis/. - The result list and pagination are rendered inside the search GET form (
search/search.html:12-36, include at:22).x-ignore-empty-inputsonly disables empty inputs on submit (radis/core/static/core/core.js:29,:36-45), so a named checked checkbox in a card would be submitted as a query parameter. ReportDocumentcarriesdocument_idbut not the report pk;full_reportdoesReport.objects.getper access (radis/search/site.py:10-25) and is accessed twice per card (_result_document.html:11,:22).SearchViewrebuilds aSearchfrom the validated GET form withoffset/limit(radis/search/views.py:52,:66-82), passestotal_relationto the template (:88) and paginatesmin(total_count, max_results)(:92-93)._search_results.html:4-10shows "More than N reports found" when the count is a floor.- The PG provider's fused union is FTS top
HYBRID_FTS_MAX_RESULTS(10,000) by rank plus vector topHYBRID_VECTOR_TOP_K(100) (radis/pgsearch/providers.py:398,:416;radis/settings/base.py:541-542), i.e. at most ~10,100 ids, withtotal_relation = "at_least"when either slice is full (providers.py:425-432).SearchProvider.max_resultsis 10,000 (radis/pgsearch/apps.py:168-176). This is the Extraction jobs silently process at most ~10,100 reports regardless of how many match #290 ceiling. _fuse_hybridalready returns report pks in ranked order (providers.py:424,:434) and its filter query enforces the group scope (providers.py:354,:200).search()withlimit=Nonereturns every fused id (:451-454) but then hydrates headlines for all of them;retrieve()yields document_ids without headlines (:538-544) but is only exposed onExtractionRetrievalProvider(apps.py:178-185).SearchProviderhasname,search,max_resultsonly (search/site.py:92-104).- Pagination is a full GET, so any client-side selection is lost on page change or filter change.
- Nearest UX precedent: ADIT's per-row
selected_studiescheckboxes with a server-side cap for non-staff (adit/selective_transfer/templates/selective_transfer/_query_results_table.html:24-29,consumers.py:380-381), no select-all.
Requested behaviour
- A checkbox on each result card and a selection bar above the list with: select all on this page, select all matching, selected count, "Add to collection".
- "Add to collection" opens a dialog listing the user's collections (last used preselected) with the create-and-add form from the sibling issue.
- The result toast states how many reports were added, how many were already in the collection, and which group's results were used.
- "Select all matching" must never silently add a subset. Either it is refused when
total_relation == "at_least"("more than 10,000 match, refine your search"), or the bar and toast say explicitly "the first 10,000 of more than 10,000 by rank". - Changing the query or filters resets the selection; the bar makes clear that ticks do not survive pagination.
Open questions
- Cap semantics under Extraction jobs silently process at most ~10,100 reports regardless of how many match #290: refuse on
at_least(Extraction jobs silently process at most ~10,100 reports regardless of how many match #290 direction 2, with the "refine your search" wording ofextractions/forms.py:159-164) or truncate with a notice? A truncated cohort depends on RRF ranking and embedding state at that instant, so the same query can give a different collection tomorrow, which matters for the research use case. Also note the union can hold 10,100 ids whilemax_resultsis 10,000. Open; recommendation below. - Group scope: the bulk add should be limited to
groups=active_groupas the search is, even though collections stay unfiltered on read (docs/user-docs/admin-guide.md:160). Open, but the security-critical direction is clear. - Should the bar also appear on other report lists (collection detail, subscription inbox, notes), or only on search results?
- After Allow admins to withdraw and restore reports #308: skip withdrawn reports silently (
reports.live()), or count them in the toast? - Is a lower cap than
max_resultswanted for collections (a setting), and should staff be exempt as in ADIT's 10-study rule? - Should the bar be a registry (
register_search_bulk_action, mirroringregister_report_panel_buttonatradis/reports/site.py:86-90) so the ADIT export can plug in? WithCollectionExportView(radis/collections/views.py:106) already emitting Study Instance UID and Accession Number (utils/exporters.py:34-35), the export may never need a second bulk action on the search page. Open; a registry is not needed for the first PR.
Implementation notes
Client: put an Alpine component in search.js (the search page loads search_layout.html:9, not collections.js) owning selected and allMatching. Checkboxes must not join the search GET: either unnamed with x-model, or carrying form="report-selection" pointing at a small form outside the search form; the second needs no JS for the id list (hx-include="#report-selection input:checked"). Add report_id to ReportDocument, populated from record.report_id in radis/pgsearch/utils/document_utils.py:13-31 (ReportSearchIndex.report is a OneToOneField, pgsearch/models.py:20), which also removes the two full_report lookups per card. PR #292 does not touch document_utils.py or search/site.py; it adds projection columns to pgsearch/models.py but leaves the report field unchanged.
Server: CollectionBulkAddView (POST). Explicit mode: Report.objects.filter(pk__in=report_ids, groups=active_group) (.live() after #308), then collection.reports.add(*pks) -- Django 6.1.1 (uv.lock:773-774) skips already-linked ids, so no SuspiciousOperation on duplicates. All-matching mode: validate radis.search.forms.SearchForm from the posted fields exactly as SearchView does (hx-include of query, language, modalities, study_date_*, study_description, patient_sex, age_from, age_till, labels; ExtractionSearchPreviewView does the same for the extraction wizard's own form with 1--prefixed names, radis/extractions/views.py:329-363 and _search_preview_form_section.html:6), build Search(offset=0, limit=None, filters=SearchFilters(group=active_group.pk, ...)), and get the ranked pks. Sources for the ids, cheapest first: (i) search_provider.search(limit=None) and read documents -- works today but hydrates up to 10,000 headlines, avoid; (ii) import extraction_retrieval_provider.retrieve from radis.extractions.site -- no new interface, but a cross-app import and a document_id -> pk round trip; (iii) an optional retrieve_ids: Callable[[Search], list[int]] | None = None on SearchProvider, registered by pgsearch with a thin wrapper over _fuse_hybrid -- must have a default, or the existing test stubs that build SearchProvider with only name, search and max_results break (radis/search/tests/test_views.py:68, :157, :218, :361). Recommendation: (iii). Synchronous is fine: the fusion is the same cost as one page load and the insert is bounded by the cap; no Procrastinate job unless #290 lifts the ceiling. Respond with an empty body to #htmx-dialog (closes the modal, adit_radis_shared/common/static/common/common.js:47-51) plus a toast event (common.js:88-93); HttpResponseClientRefresh (already used at collections/views.py:94, :239) is the simplest way to refresh the count badges rather than one collectionsOfReportChanged_<id> per card.
Cap: recommend refusing when total_relation == "at_least" for the first version and deferring truncation to whatever #290 decides. TODO.md:8 is the maintainer's own note on presenting max_results to the user.
Tests: both modes; a report outside the active group is rejected (mirror radis/search/tests/test_views_e2e.py:341); refusal or truncation on at_least; duplicates; withdrawn reports after #308; an e2e test through the real provider. Update user-guide.md:69 and :86-91.
Alternatives not recommended now: persisting ticks across pages client-side (more JS, still needs all-matching for large cohorts); a background CollectionImportJob (only if #290 uncaps retrieval).
Related
- Allow creating a new collection from the report's Collections dialog #316 -- the bulk dialog reuses its form.
- Collection views accept any report id regardless of group membership #315 -- must be closed before the bulk path.
- Extraction jobs silently process at most ~10,100 reports regardless of how many match #290 -- cap semantics; do not restate, defer to it.
- Allow admins to withdraw and restore reports #308 --
reports.live()scope for the bulk add. - Make the FTS candidate query single-table with a search projection #292 -- rewrites
pgsearch/providers.py;search()/retrieve()keep their shape on that branch, but coordinate theSearchProviderchange. - Searching with filters but no query text silently shows the help card instead of results or an error #332: all-matching inherits its behaviour for filter-only searches, and the cap will be hit far more often once it lands.
- Export a collection (and optionally the current search results) as an ADIT batch transfer file #311; TODO.md:47.
Activity