Skip to content

Select search results (per page or all matching) and add them to a collection in one step #317

Open

Description

@samuelvkwong

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, and CollectionSelectView.post adds 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 no type="checkbox" anywhere in radis/.
  • The result list and pagination are rendered inside the search GET form (search/search.html:12-36, include at :22). x-ignore-empty-inputs only 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.
  • ReportDocument carries document_id but not the report pk; full_report does Report.objects.get per access (radis/search/site.py:10-25) and is accessed twice per card (_result_document.html:11, :22).
  • SearchView rebuilds a Search from the validated GET form with offset/limit (radis/search/views.py:52, :66-82), passes total_relation to the template (:88) and paginates min(total_count, max_results) (:92-93). _search_results.html:4-10 shows "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 top HYBRID_VECTOR_TOP_K (100) (radis/pgsearch/providers.py:398, :416; radis/settings/base.py:541-542), i.e. at most ~10,100 ids, with total_relation = "at_least" when either slice is full (providers.py:425-432). SearchProvider.max_results is 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_hybrid already returns report pks in ranked order (providers.py:424, :434) and its filter query enforces the group scope (providers.py:354, :200). search() with limit=None returns 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 on ExtractionRetrievalProvider (apps.py:178-185). SearchProvider has name, search, max_results only (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_studies checkboxes 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

  1. 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 of extractions/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 while max_results is 10,000. Open; recommendation below.
  2. Group scope: the bulk add should be limited to groups=active_group as 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.
  3. Should the bar also appear on other report lists (collection detail, subscription inbox, notes), or only on search results?
  4. After Allow admins to withdraw and restore reports #308: skip withdrawn reports silently (reports.live()), or count them in the toast?
  5. Is a lower cap than max_results wanted for collections (a setting), and should staff be exempt as in ADIT's 10-study rule?
  6. Should the bar be a registry (register_search_bulk_action, mirroring register_report_panel_button at radis/reports/site.py:86-90) so the ADIT export can plug in? With CollectionExportView (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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions