Skip to content

Add include/exclude mode to mass transfer filters#336

Merged
samuelvkwong merged 5 commits intomainfrom
mass-transfer-exclude-filters
May 8, 2026
Merged

Add include/exclude mode to mass transfer filters#336
samuelvkwong merged 5 commits intomainfrom
mass-transfer-exclude-filters

Conversation

@NumericalAdvantage
Copy link
Copy Markdown
Collaborator

@NumericalAdvantage NumericalAdvantage commented Apr 23, 2026

Summary

  • Each entry in filters_json can now set mode: "include" | "exclude" (default include). A series is kept if it matches at least one include filter and no exclude filter.
  • Exclude matching reuses the existing per-criterion logic via a new _series_matches_filter helper and does not issue its own C-FIND queries — all checks run client-side against the DiscoveredSeries already collected by the include pass, so excludes add zero PACS round-trips.
  • The old series-level pruning branches inside _discover_series are replaced by a single call to the helper, which is also used for the post-filter that applies excludes.

Validation

  • At least one filter must have mode=include (an all-excludes job is rejected).
  • An exclude filter must specify at least one criterion (an empty one would match everything).
  • apply_institution_on_study is documented as include-only and silently ignored on excludes; institution is always evaluated at series level on the DiscoveredSeries.

Why this approach

C-FIND has no anti-match operator, so any exclusion logic has to run client-side. DiscoveredSeries already carries every field the existing filter criteria reference (modality, institution, descriptions, series number, age from patient_birth_date+study_datetime, instance count), so excludes can be implemented as a pure post-filter without new queries, without a new schema change, and without touching the transfer phase.

Test plan

  • ruff check passes
  • pyright passes
  • Unit tests added for _series_matches_filter (modality, institution, series number, age strict vs permissive, check_institution=False, min_instances)
  • Integration tests added for _discover_series exclude behavior (single-exclude removes, multi-exclude OR semantics, exclude-by-institution, and a call-count assertion that excludes issue no extra C-FINDs)
  • Form validation tests added for exclude-accepted, all-excludes-rejected, empty-exclude-rejected, unknown-mode-rejected
  • Existing _discover_series tests still pass (the refactor is behavior-preserving for include-only filters)
  • Manual: load a job with an exclude filter in the UI and verify the discovery result in the job detail

Summary by CodeRabbit

  • New Features

    • Added filter modes ("include" and "exclude") to control series matching; include filters add, exclude filters remove matched series.
  • Bug Fixes / Validation

    • Inputs now reject configurations with no include-mode filter present.
    • Exclude filters must specify at least one matching criterion to be valid.
  • Documentation

    • Updated example JSON and help text to explain mode semantics and application order.
  • Tests

    • Expanded tests covering include/exclude behavior and validation.

Each filter in filters_json now supports an optional mode field
("include" or "exclude", default "include"). After the include
discovery loop builds its DiscoveredSeries set, any series that
matches at least one exclude filter is dropped.

Exclude filters reuse the existing per-criterion matching logic
via a new _series_matches_filter helper and do not issue their
own C-FIND queries — all criteria are evaluated client-side
against the DiscoveredSeries already in memory.

Validation rejects jobs with no include filter and exclude
filters with no criteria. apply_institution_on_study is
documented as include-only and silently ignored on excludes.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

Warning

Rate limit exceeded

@samuelvkwong has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 59 minutes and 22 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 61d80509-a38c-4b25-b771-ffb0779a7b0f

📥 Commits

Reviewing files that changed from the base of the PR and between 6012b4a and 9bff823.

📒 Files selected for processing (2)
  • adit/mass_transfer/processors.py
  • adit/mass_transfer/tests/test_processor.py
📝 Walkthrough

Walkthrough

The PR adds include/exclude semantics to mass-transfer filters: FilterSchema and FilterSpec gain a mode ("include"/"exclude", default "include"). Exclude filters are validated to require at least one matching criterion. Discovery now applies include filters first and then removes series that match any exclude filters via a centralized matching predicate.

Changes

Mass-transfer filter flow

Layer / File(s) Summary
Data Shape
adit/mass_transfer/forms.py, adit/mass_transfer/processors.py
FilterSchema and FilterSpec gain mode: Literal["include","exclude"] = "include"; FilterSpec.from_dict reads and validates mode.
Validation Rules
adit/mass_transfer/forms.py
Added post-validation to FilterSchema to reject mode: "exclude" filters that have no matching criteria; MassTransferJobForm.clean_filters_json rejects inputs with no include-mode filter. UI example JSON and filters_json help text updated to document semantics and that apply_institution_on_study is ignored for exclude filters.
Core Matching
adit/mass_transfer/processors.py
Introduced _series_matches_filter implementing modality, institution (optionally skipped), study/series description regex, exact series-number, patient age bounds (computed from birth date + study datetime, with age_permissive controlling unknown-birth-date behavior), and minimum related-instance checks.
Discovery / Wiring
adit/mass_transfer/processors.py
Refactored _discover_series to split filters into include/exclude groups, require ≥1 include, drive PACS queries from include filters only, evaluate series against include specs (using apply_institution_on_study to control institution checks), then post-filter found series by removing any that match exclude specs (exclude matching runs with age_permissive=True so unknown birth dates can be excluded). Exclude filters do not trigger extra PACS C-FIND queries.
Tests
adit/mass_transfer/tests/test_forms.py, adit/mass_transfer/tests/test_processor.py
Added tests for form validation of mode and exclude-empty checks; tests for FilterSpec.from_dict mode behavior and invalid modes; unit tests for _series_matches_filter (modality/description/series-number/institution/age/instance-count) and _discover_series behavior with exclude filters, multiple excludes (OR semantics), institution handling, age-permissive exclude behavior, and PACS query counts.
sequenceDiagram
    actor User
    participant Form as FilterSchema
    participant Processor as _discover_series
    participant PacsDB as PACS Database
    participant Matcher as _series_matches_filter

    User->>Form: Submit filters with modes
    Form->>Form: Validate mode and require criteria for excludes
    Form->>Processor: Pass validated FilterSpecs

    Processor->>PacsDB: Query studies & series (include filters only)
    PacsDB-->>Processor: Return studies & series

    loop For each discovered series
        Processor->>Matcher: Evaluate include specs (control institution/age behavior)
        alt Matches an include
            Matcher-->>Processor: Accept series
        else
            Matcher-->>Processor: Skip series
        end
    end

    loop Post-filter by exclude specs
        Processor->>Matcher: Evaluate exclude specs (age_permissive=True)
        alt Matches any exclude
            Matcher-->>Processor: Mark for removal
            Processor->>Processor: Remove matched series
        end
    end

    Processor-->>User: Return final filtered series list
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

A rabbit sorts the filters tight,
Include to fetch, exclude to bite,
First we gather, then we prune,
Hop by hop, the lists attune,
Series tidy, moonlit night 🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding include/exclude mode capability to mass transfer filters, which is the core feature across all modified files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch mass-transfer-exclude-filters

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces "exclude" filters to the mass transfer system, allowing users to filter out specific DICOM series after an initial inclusion. Key changes include updates to the FilterSchema and FilterSpec models to support a mode field, new validation logic to ensure at least one include filter is present, and the centralization of filtering logic into a _series_matches_filter helper function. Feedback was provided to use a Literal type for the mode field in the processor to improve type safety and maintain consistency with the form schema.

Comment thread adit/mass_transfer/processors.py Outdated
Built from a plain dict from the job's filters_json field.
"""

mode: str = "include"
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.

medium

For better type safety and consistency with the FilterSchema defined in forms.py, consider using a Literal for the mode field. This helps static analysis tools like pyright (which is used in this repository) to catch invalid values.

Suggested change
mode: str = "include"
mode: Literal["include", "exclude"] = "include"

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@adit/mass_transfer/processors.py`:
- Around line 796-801: Add a processor-side guard in _discover_series() to
enforce the include-filter invariant: after computing include_filters = [mf for
mf in filters if mf.mode != "exclude"] and exclude_filters = [...], check that
include_filters is not empty (i.e., filters_json isn't all-exclude) and if it
is, raise a clear exception (e.g., ValidationError or ValueError) with a message
referencing filters_json/filters so the job fails fast instead of returning no
series; this complements clean_filters_json() and prevents jobs created outside
the form from silently succeeding.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8029f450-d991-40ff-a286-ef58422292f7

📥 Commits

Reviewing files that changed from the base of the PR and between 0f67ddd and 65fc462.

📒 Files selected for processing (4)
  • adit/mass_transfer/forms.py
  • adit/mass_transfer/processors.py
  • adit/mass_transfer/tests/test_forms.py
  • adit/mass_transfer/tests/test_processor.py

Comment thread adit/mass_transfer/processors.py
- Type FilterSpec.mode as Literal["include", "exclude"] to match
  FilterSchema and let pyright catch invalid values.
- Validate mode in FilterSpec.from_dict so jobs persisted outside
  the form (e.g. admin, direct ORM) can't smuggle in an unknown
  mode string.
- Add a _discover_series guard that raises DicomError when no
  include filter is present, turning a silent empty-result into
  a loud failure.
Comment thread adit/mass_transfer/processors.py
Comment thread adit/mass_transfer/processors.py
Comment thread adit/mass_transfer/processors.py
samuelvkwong and others added 2 commits May 7, 2026 15:14
Flip age_permissive in both call sites so an unverifiable
PatientBirthDate fails inclusion and matches exclusion — in either
direction, a series we can't age-check is dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
adit/mass_transfer/processors.py (1)

801-890: ⚡ Quick win

Add structured logs for include/exclude discovery transitions.

This block introduces important state transitions (filter split, post-exclude pruning) but currently emits no structured telemetry. Please add structured log events with include/exclude counts and prune totals.

Proposed logging points
         include_filters = [mf for mf in filters if mf.mode != "exclude"]
         exclude_filters = [mf for mf in filters if mf.mode == "exclude"]
+        logger.info(
+            "Mass transfer discovery filter split",
+            extra={
+                "task_id": self.mass_task.pk,
+                "include_filter_count": len(include_filters),
+                "exclude_filter_count": len(exclude_filters),
+            },
+        )

         if not include_filters:
             raise DicomError("Mass transfer requires at least one include filter.")
@@
-        if not exclude_filters:
-            return list(found.values())
+        if not exclude_filters:
+            logger.info(
+                "Mass transfer discovery complete",
+                extra={
+                    "task_id": self.mass_task.pk,
+                    "discovered_series_count": len(found),
+                    "excluded_series_count": 0,
+                },
+            )
+            return list(found.values())

-        return [
+        filtered = [
             series
             for series in found.values()
             if not any(
                 _series_matches_filter(series, mf, age_permissive=True) for mf in exclude_filters
             )
         ]
+        logger.info(
+            "Mass transfer discovery complete",
+            extra={
+                "task_id": self.mass_task.pk,
+                "discovered_series_count": len(found),
+                "excluded_series_count": len(found) - len(filtered),
+            },
+        )
+        return filtered

As per coding guidelines, **/*.{js,ts,py}: Use structured logging for agent actions and state transitions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adit/mass_transfer/processors.py` around lines 801 - 890, The code splits
filters into include_filters and exclude_filters and builds found
(DiscoveredSeries) but lacks structured logging; add structured logs (using the
module's standard logger) after the split to emit counts for include_filters and
exclude_filters, and another structured event before returning to report total
discovered, excluded_count (pruned by exclude_filters) and final_count; place
the first log immediately after building include_filters/exclude_filters and the
second just before the final return where found is filtered (reference symbols:
include_filters, exclude_filters, found, DiscoveredSeries,
_series_matches_filter) and include filter identifiers (e.g. mf names or modes)
and numeric counts in the log payload.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@adit/mass_transfer/processors.py`:
- Around line 801-803: There is a dangerous case where exclude_filters (built
from filters in the existing code) may have no criteria and cause
_series_matches_filter(...) to return True for all series; update
_discover_series to detect any exclude filter with empty criteria and fail fast:
iterate the exclude_filters list (same objects created where
include_filters/exclude_filters are derived) and if any filter has no criteria
(e.g., empty attributes/conditions field used by _series_matches_filter), raise
a clear ValueError (or similar exception) naming the offending filter(s) instead
of proceeding; ensure the guard is checked before the pruning loop that calls
_series_matches_filter so the code in _discover_series (and the pruning logic
around _series_matches_filter) cannot silently remove all series.

---

Nitpick comments:
In `@adit/mass_transfer/processors.py`:
- Around line 801-890: The code splits filters into include_filters and
exclude_filters and builds found (DiscoveredSeries) but lacks structured
logging; add structured logs (using the module's standard logger) after the
split to emit counts for include_filters and exclude_filters, and another
structured event before returning to report total discovered, excluded_count
(pruned by exclude_filters) and final_count; place the first log immediately
after building include_filters/exclude_filters and the second just before the
final return where found is filtered (reference symbols: include_filters,
exclude_filters, found, DiscoveredSeries, _series_matches_filter) and include
filter identifiers (e.g. mf names or modes) and numeric counts in the log
payload.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 522f1d54-31a4-48ef-8419-291bcc4cd903

📥 Commits

Reviewing files that changed from the base of the PR and between 76888ad and 6012b4a.

📒 Files selected for processing (2)
  • adit/mass_transfer/processors.py
  • adit/mass_transfer/tests/test_processor.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • adit/mass_transfer/tests/test_processor.py

Comment on lines +801 to +803
include_filters = [mf for mf in filters if mf.mode != "exclude"]
exclude_filters = [mf for mf in filters if mf.mode == "exclude"]

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 | ⚡ Quick win

Enforce non-empty exclude criteria in processor-level runtime checks.

If an exclude filter with no criteria bypasses form validation, _series_matches_filter(...) returns True for all series, and Line 887 will prune everything silently. Add a fail-fast guard for empty exclude filters in _discover_series().

Proposed fix
         include_filters = [mf for mf in filters if mf.mode != "exclude"]
         exclude_filters = [mf for mf in filters if mf.mode == "exclude"]

         if not include_filters:
             raise DicomError("Mass transfer requires at least one include filter.")
+        for mf in exclude_filters:
+            has_criterion = any(
+                [
+                    bool(mf.modality),
+                    bool(mf.institution_name),
+                    bool(mf.study_description),
+                    bool(mf.series_description),
+                    mf.series_number is not None,
+                    mf.min_age is not None,
+                    mf.max_age is not None,
+                    mf.min_number_of_series_related_instances is not None,
+                ]
+            )
+            if not has_criterion:
+                raise DicomError("Exclude filters must specify at least one criterion.")

         found: dict[str, DiscoveredSeries] = {}

Also applies to: 887-889

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@adit/mass_transfer/processors.py` around lines 801 - 803, There is a
dangerous case where exclude_filters (built from filters in the existing code)
may have no criteria and cause _series_matches_filter(...) to return True for
all series; update _discover_series to detect any exclude filter with empty
criteria and fail fast: iterate the exclude_filters list (same objects created
where include_filters/exclude_filters are derived) and if any filter has no
criteria (e.g., empty attributes/conditions field used by
_series_matches_filter), raise a clear ValueError (or similar exception) naming
the offending filter(s) instead of proceeding; ensure the guard is checked
before the pruning loop that calls _series_matches_filter so the code in
_discover_series (and the pruning logic around _series_matches_filter) cannot
silently remove all series.

@samuelvkwong samuelvkwong merged commit 79d6e0d into main May 8, 2026
2 checks passed
@samuelvkwong samuelvkwong deleted the mass-transfer-exclude-filters branch May 8, 2026 21:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants