Make blacklist override whitelist
All checks were successful
Test and build Docker image / docker (push) Successful in 30s

Change filter evaluation so blacklist matches take precedence over whitelist matches. Updated evaluator logic to skip entries when blacklist and whitelist both match, adjusted related branches to reflect the new decision flow, and updated a feeds.py comment to clarify the combined decision. Also updated blacklist/whitelist templates copy to reflect the new precedence and adjusted tests to expect blacklist-wins behavior.
This commit is contained in:
Joakim Hellsén 2026-05-04 22:55:53 +02:00
commit d85bc16904
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
7 changed files with 24 additions and 34 deletions

View file

@ -732,7 +732,7 @@ def send_to_discord(reader: Reader | None = None, feed: Feed | None = None, *, d
else:
logger.warning("No entry link found for feed %s, falling back to regular processing", entry.feed.url)
# Send the entry to Discord as it is not blacklisted or feed has a whitelist.
# Send the entry to Discord because the combined blacklist/whitelist decision allowed it.
execute_webhook(webhook, entry, reader=effective_reader)
# If we only want to send one entry, we will break the loop. This is used when testing this function.

View file

@ -121,7 +121,7 @@ def evaluate_entry_filters(
) -> EntryFilterDecision:
"""Evaluate one entry against blacklist and whitelist settings.
Whitelist matches take precedence over blacklist matches.
Blacklist matches take precedence over whitelist matches.
Args:
entry: The entry to evaluate.
@ -140,10 +140,20 @@ def evaluate_entry_filters(
has_blacklist_filters: bool = has_filter_values(normalized_blacklist_values)
has_whitelist_filters: bool = has_filter_values(normalized_whitelist_values)
if whitelist_match and blacklist_match:
if blacklist_match and whitelist_match:
return EntryFilterDecision(
should_send=True,
reason=f"Sent because {whitelist_match.description}; whitelist overrides blacklist.",
should_send=False,
reason=f"Skipped because {blacklist_match.description}; blacklist overrides whitelist.",
blacklist_match=blacklist_match,
whitelist_match=whitelist_match,
has_blacklist_filters=has_blacklist_filters,
has_whitelist_filters=has_whitelist_filters,
)
if blacklist_match:
return EntryFilterDecision(
should_send=False,
reason=f"Skipped because {blacklist_match.description}.",
blacklist_match=blacklist_match,
whitelist_match=whitelist_match,
has_blacklist_filters=has_blacklist_filters,
@ -160,16 +170,6 @@ def evaluate_entry_filters(
has_whitelist_filters=has_whitelist_filters,
)
if has_whitelist_filters and blacklist_match:
return EntryFilterDecision(
should_send=False,
reason=f"Skipped because {blacklist_match.description} and no whitelist rule matched.",
blacklist_match=blacklist_match,
whitelist_match=whitelist_match,
has_blacklist_filters=has_blacklist_filters,
has_whitelist_filters=has_whitelist_filters,
)
if has_whitelist_filters:
return EntryFilterDecision(
should_send=False,
@ -180,16 +180,6 @@ def evaluate_entry_filters(
has_whitelist_filters=has_whitelist_filters,
)
if blacklist_match:
return EntryFilterDecision(
should_send=False,
reason=f"Skipped because {blacklist_match.description}.",
blacklist_match=blacklist_match,
whitelist_match=whitelist_match,
has_blacklist_filters=has_blacklist_filters,
has_whitelist_filters=has_whitelist_filters,
)
return EntryFilterDecision(
should_send=True,
reason="Sent because no active filter blocked it.",

View file

@ -22,7 +22,7 @@
<p class="mb-2">
Plain text matching is case-insensitive and partial, so <code>orld</code> matches <code>World of Warcraft</code>.
</p>
<p class="mb-2">Whitelist matches still win. If an entry matches both, the preview keeps it as sent.</p>
<p class="mb-2">Blacklist matches win. If an entry matches both, the preview keeps it as skipped.</p>
<p class="mb-0">Keep the left side for editing and the right side for checking what gets removed.</p>
</div>
</div>

View file

@ -20,7 +20,7 @@
<p class="mb-2">
Plain text matching is case-insensitive and partial, so <code>orld</code> matches <code>World of Warcraft</code>.
</p>
<p class="mb-2">When an entry matches both lists, whitelist still wins and the preview shows it as sent.</p>
<p class="mb-2">When an entry matches both lists, blacklist wins and the preview shows it as skipped.</p>
<p class="mb-0">Saved blacklist rules remain active while you preview whitelist edits.</p>
</div>
</div>

View file

@ -207,8 +207,8 @@ def test_regex_should_be_skipped() -> None:
assert entry_should_be_skipped(reader, first_entry[0]) is False, f"Entry should not be skipped: {first_entry[0]}"
def test_whitelist_match_overrides_blacklist_match() -> None:
"""A whitelist hit should beat a blacklist hit in the final decision."""
def test_blacklist_match_overrides_whitelist_match() -> None:
"""A blacklist hit should beat a whitelist hit in the final decision."""
reader: Reader = get_reader()
reader.add_feed(feed_url)
@ -232,10 +232,10 @@ def test_whitelist_match_overrides_blacklist_match() -> None:
whitelist_values=get_filter_values_from_reader(reader, feed, "whitelist"),
)
assert decision.should_send is True, "Whitelist match should override blacklist match"
assert decision.should_send is False, "Blacklist match should override whitelist match"
assert decision.blacklist_match is not None, "Expected a blacklist match"
assert decision.whitelist_match is not None, "Expected a whitelist match"
assert "whitelist overrides blacklist" in decision.reason
assert "blacklist overrides whitelist" in decision.reason
def test_blacklist_substring_match_on_title() -> None:

View file

@ -283,7 +283,7 @@ def test_blacklist_preview_does_not_persist_unsaved_rules() -> None:
reader.delete_tag(feed_url, "blacklist_title")
def test_whitelist_preview_shows_precedence_over_blacklist() -> None:
def test_whitelist_preview_shows_blacklist_precedence() -> None:
reader: Reader = ensure_preview_feed_exists()
reader.set_tag(feed_url, "blacklist_title", "fvnnnfnfdnfdnfd") # pyright: ignore[reportArgumentType]
@ -297,8 +297,8 @@ def test_whitelist_preview_shows_precedence_over_blacklist() -> None:
)
assert response.status_code == 200, f"/whitelist_preview failed: {response.text}"
assert "whitelist overrides blacklist" in response.text
assert "Sent" in response.text
assert "blacklist overrides whitelist" in response.text
assert "Skipped" in response.text
finally:
with contextlib.suppress(Exception):
reader.delete_tag(feed_url, "blacklist_title")

View file

@ -188,8 +188,8 @@ def test_regex_should_be_sent() -> None:
assert should_be_sent(reader, first_entry[0]) is False, "Entry should not be sent"
def test_active_whitelist_blocks_non_matching_blacklisted_entry() -> None:
"""An active whitelist should block non-matching entries even if blacklist also matches."""
def test_blacklist_blocks_when_active_whitelist_misses() -> None:
"""A blacklist hit should block when an active whitelist does not match."""
reader: Reader = get_reader()
reader.add_feed(feed_url)
@ -213,10 +213,10 @@ def test_active_whitelist_blocks_non_matching_blacklisted_entry() -> None:
whitelist_values=get_filter_values_from_reader(reader, feed, "whitelist"),
)
assert decision.should_send is False, "Entry should be skipped when whitelist is active but does not match"
assert decision.should_send is False, "Entry should be skipped when blacklist matches"
assert decision.blacklist_match is not None, "Expected a blacklist match"
assert decision.whitelist_match is None, "Expected whitelist to miss"
assert "no whitelist rule matched" in decision.reason
assert "blacklist text match on title" in decision.reason
def test_whitelist_substring_match_on_title() -> None: