Skip to content

Commit 02460c1

Browse files
authored
Merge pull request #917 from dannyvfilms/claude/list-filter-option-nhvrcc
Add list filter support to smart list rules
2 parents de66423 + 9bd17db commit 02460c1

8 files changed

Lines changed: 348 additions & 5 deletions

File tree

src/app/signals.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from django.apps import apps
99
from django.conf import settings
1010
from django.db import transaction
11-
from django.db.models.signals import post_delete, post_save, pre_save
11+
from django.db.models.signals import m2m_changed, post_delete, post_save, pre_save
1212
from django.db.utils import OperationalError
1313
from django.dispatch import receiver
1414
from django.utils import timezone
@@ -47,6 +47,7 @@
4747
Season,
4848
Sources,
4949
)
50+
from lists.models import CustomList, CustomListItem
5051
from lists.smart_rules import sync_smart_lists_for_item
5152

5253
logger = logging.getLogger(__name__)
@@ -393,6 +394,48 @@ def sync_smart_lists_on_item_tag_change(sender, instance, **kwargs):
393394
_sync_owner_smart_lists_for_items(owner, [item])
394395

395396

397+
@receiver([post_save, post_delete], sender=CustomListItem)
398+
def sync_smart_lists_on_list_membership_change(sender, instance, **kwargs):
399+
"""Re-evaluate referencing smart lists when a manual list's membership changes.
400+
401+
A smart list's "List" filter includes the full contents of any linked
402+
(non-smart) list. Membership changes on that manual list happen outside
403+
the normal media-tracking signals below, so they need their own hook.
404+
"""
405+
if kwargs.get("raw"):
406+
return
407+
custom_list = getattr(instance, "custom_list", None)
408+
item = getattr(instance, "item", None)
409+
if not custom_list or not item or custom_list.is_smart:
410+
return
411+
412+
owners = {custom_list.owner}
413+
owners.update(custom_list.collaborators.all())
414+
for owner in owners:
415+
_sync_owner_smart_lists_for_items(owner, [item])
416+
417+
418+
@receiver(m2m_changed, sender=CustomList.items.through)
419+
def sync_smart_lists_on_list_items_m2m_change(
420+
sender, instance, action, reverse, pk_set, **kwargs
421+
):
422+
"""Catch list-membership writes that bypass CustomListItem's save/delete.
423+
424+
`CustomList.items.add()` creates `CustomListItem` rows via the m2m
425+
manager's `bulk_create()`, which doesn't call `save()`, so
426+
`sync_smart_lists_on_list_membership_change` above never fires for it.
427+
`m2m_changed` fires regardless of how the through rows were written.
428+
"""
429+
if action != "post_add" or reverse or instance.is_smart or not pk_set:
430+
return
431+
432+
items = list(Item.objects.filter(id__in=pk_set))
433+
owners = {instance.owner}
434+
owners.update(instance.collaborators.all())
435+
for owner in owners:
436+
_sync_owner_smart_lists_for_items(owner, items)
437+
438+
396439
@receiver(post_save, sender=Item)
397440
def sync_smart_lists_on_watch_providers_change(
398441
sender, instance, update_fields=None, **kwargs

src/lists/imports/mdblist.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from app.models import Item, MediaTypes, Sources
1818
from app.providers import services
1919
from integrations.imports import helpers
20+
from lists.smart_rules import sync_smart_lists_for_item
2021

2122
# Shared MDBList client helpers live with the full-account importer; keep the
2223
# old private names so existing call sites and test patch targets still work.
@@ -173,6 +174,8 @@ def _sync_list(user, api_key, list_info):
173174
continue
174175
desired_item_ids.add(item.id)
175176

177+
changed_item_ids = set()
178+
176179
def _apply():
177180
with transaction.atomic():
178181
custom_list, _ = CustomList.objects.update_or_create(
@@ -184,22 +187,41 @@ def _apply():
184187
"description": list_info.get("description") or "",
185188
},
186189
)
187-
custom_list.customlistitem_set.exclude(
188-
item_id__in=desired_item_ids,
190+
removed_item_ids = set(
191+
custom_list.customlistitem_set.exclude(
192+
item_id__in=desired_item_ids,
193+
).values_list("item_id", flat=True),
194+
)
195+
custom_list.customlistitem_set.filter(
196+
item_id__in=removed_item_ids,
189197
).delete()
190198
existing_item_ids = set(
191199
custom_list.customlistitem_set.values_list("item_id", flat=True),
192200
)
201+
added_item_ids = desired_item_ids - existing_item_ids
193202
CustomListItem.objects.bulk_create(
194203
CustomListItem(
195204
custom_list=custom_list,
196205
item_id=item_id,
197206
added_by=user,
198207
)
199-
for item_id in desired_item_ids - existing_item_ids
208+
for item_id in added_item_ids
200209
)
210+
changed_item_ids.update(removed_item_ids, added_item_ids)
201211

202212
helpers.retry_on_lock(_apply)
213+
214+
# bulk_create() and the bulk .delete() above bypass CustomListItem's
215+
# save()/delete(), so the smart-list membership signal never fires for
216+
# them; resync explicitly for whatever actually changed.
217+
for item_id in changed_item_ids:
218+
try:
219+
sync_smart_lists_for_item(owner=user, item=Item(id=item_id))
220+
except Exception:
221+
logger.exception(
222+
"Failed to sync smart lists for item %s after MDBList list sync",
223+
item_id,
224+
)
203225
logger.info(
204226
"Synced MDBList list %s (%s) for %s: %s items, %s skipped",
205227
list_id,

src/lists/smart_rules.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"provider",
4242
"tag",
4343
"tag_mode",
44+
"list",
4445
)
4546

4647
TAG_MODE_CHOICES = {"and", "or", "not"}
@@ -72,6 +73,7 @@
7273
"provider": "",
7374
"tag": [],
7475
"tag_mode": "or",
76+
"list": [],
7577
}
7678

7779
MAX_RATING = 10.0
@@ -242,6 +244,22 @@ def _payload_getlist(payload, key: str) -> list[str]:
242244
return []
243245

244246

247+
def _valid_linked_list_ids(owner, raw_values: list[str]) -> list[int]:
248+
"""Return ids from raw_values that are non-smart lists owner can access."""
249+
candidate_ids = {int(value) for value in raw_values if str(value).strip().isdigit()}
250+
if not candidate_ids or not owner:
251+
return []
252+
253+
from lists.models import CustomList
254+
255+
return list(
256+
CustomList.objects.filter(id__in=candidate_ids, is_smart=False)
257+
.filter(Q(owner=owner) | Q(collaborators=owner))
258+
.distinct()
259+
.values_list("id", flat=True),
260+
)
261+
262+
245263
def get_available_media_types(owner) -> list[str]:
246264
"""Return enabled media types that can participate in smart rules."""
247265
if owner and hasattr(owner, "get_enabled_media_types"):
@@ -368,6 +386,8 @@ def normalize_rule_payload(payload, owner):
368386
seen_tags.add(key)
369387
deduped_tags.append(value)
370388

389+
list_ids = _valid_linked_list_ids(owner, _payload_getlist(payload, "list"))
390+
371391
return {
372392
"media_types": normalized_media_types,
373393
"status": normalized_statuses,
@@ -396,6 +416,7 @@ def normalize_rule_payload(payload, owner):
396416
"provider": str(_payload_get(payload, "provider", "") or "").strip(),
397417
"tag": deduped_tags,
398418
"tag_mode": tag_mode,
419+
"list": list_ids,
399420
}
400421

401422

@@ -836,6 +857,21 @@ def _resolve_tag_id_sets(
836857
return set().union(*per_tag_id_sets), None
837858

838859

860+
def _resolve_list_membership_item_ids(list_ids: list[int]) -> set[int]:
861+
"""Return the union of item ids belonging to the given linked lists."""
862+
if not list_ids:
863+
return set()
864+
865+
from lists.models import CustomListItem
866+
867+
return set(
868+
CustomListItem.objects.filter(custom_list_id__in=list_ids).values_list(
869+
"item_id",
870+
flat=True,
871+
),
872+
)
873+
874+
839875
def collect_matching_item_ids(
840876
owner,
841877
normalized_rules: dict,
@@ -981,6 +1017,8 @@ def _tag_filter_excludes(item_id: int) -> bool:
9811017
continue
9821018
matched_ids.add(item.id)
9831019

1020+
matched_ids |= _resolve_list_membership_item_ids(normalized_rules.get("list") or [])
1021+
9841022
return matched_ids
9851023

9861024

@@ -995,6 +1033,16 @@ def item_matches_rules(
9951033
if not owner or not item:
9961034
return False
9971035

1036+
list_ids = normalized_rules.get("list") or []
1037+
if list_ids:
1038+
from lists.models import CustomListItem
1039+
1040+
if CustomListItem.objects.filter(
1041+
custom_list_id__in=list_ids,
1042+
item_id=item.id,
1043+
).exists():
1044+
return True
1045+
9981046
target_media_types = _target_media_types(
9991047
owner, normalized_rules.get("media_types", [])
10001048
)
@@ -1151,6 +1199,7 @@ def build_rule_filter_data(
11511199
*,
11521200
include_collection_only_untracked: bool = False,
11531201
precomputed_tags: list[str] | None = None,
1202+
include_list_options: bool = True,
11541203
):
11551204
"""Build menu options for smart-rule filters from matched candidate media."""
11561205
target_media_types = _target_media_types(owner, media_types)
@@ -1345,4 +1394,15 @@ def build_rule_filter_data(
13451394
.order_by("name")
13461395
)
13471396

1397+
filter_data["lists"] = []
1398+
if include_list_options:
1399+
from lists.models import CustomList
1400+
1401+
filter_data["lists"] = [
1402+
{"id": custom_list.id, "label": custom_list.name}
1403+
for custom_list in CustomList.objects.get_user_lists(owner)
1404+
.filter(is_smart=False)
1405+
.order_by("name")
1406+
]
1407+
13481408
return filter_data

src/lists/tests/test_models.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,119 @@ def test_smart_rules_support_multi_status_and_tag_modes(self):
304304
expected_ids,
305305
)
306306

307+
def test_normalize_rule_payload_restricts_list_filter_to_accessible_manual_lists(
308+
self,
309+
):
310+
"""The list filter should only accept non-smart lists owner can access."""
311+
inaccessible_list = CustomList.objects.create(
312+
name="Not Mine",
313+
owner=self.other_user,
314+
)
315+
smart_target = CustomList.objects.create(
316+
name="Smart Target",
317+
owner=self.user,
318+
is_smart=True,
319+
)
320+
321+
rules = smart_rules.normalize_rule_payload(
322+
{
323+
"list": [
324+
self.list1.id,
325+
self.list2.id,
326+
inaccessible_list.id,
327+
smart_target.id,
328+
],
329+
},
330+
self.user,
331+
)
332+
333+
self.assertCountEqual(rules["list"], [self.list1.id, self.list2.id])
334+
335+
def test_collect_matching_item_ids_unions_linked_list_contents(self):
336+
"""List filter should include linked lists' items even if other filters fail."""
337+
linked_item = Item.objects.create(
338+
title="Untracked Linked Game",
339+
media_id="link-1",
340+
media_type=MediaTypes.GAME.value,
341+
source=Sources.IGDB.value,
342+
)
343+
CustomListItem.objects.create(
344+
custom_list=self.list1,
345+
item=linked_item,
346+
added_by=self.user,
347+
)
348+
other_item = Item.objects.create(
349+
title="Unrelated Movie",
350+
media_id="link-2",
351+
media_type=MediaTypes.MOVIE.value,
352+
source=Sources.TMDB.value,
353+
)
354+
Movie.objects.create(
355+
item=other_item, user=self.user, status=Status.COMPLETED.value
356+
)
357+
358+
rules = smart_rules.normalize_rule_payload(
359+
{
360+
"media_types": [MediaTypes.MOVIE.value],
361+
"genre": "Nonexistent Genre",
362+
"list": [self.list1.id],
363+
},
364+
self.user,
365+
)
366+
367+
matched_ids = smart_rules.collect_matching_item_ids(self.user, rules)
368+
369+
self.assertIn(linked_item.id, matched_ids)
370+
self.assertNotIn(other_item.id, matched_ids)
371+
372+
def test_item_matches_rules_short_circuits_for_linked_list_membership(self):
373+
"""A linked list's item should match regardless of other active filters."""
374+
linked_item = Item.objects.create(
375+
title="Linked Game",
376+
media_id="link-3",
377+
media_type=MediaTypes.GAME.value,
378+
source=Sources.IGDB.value,
379+
)
380+
CustomListItem.objects.create(
381+
custom_list=self.list1,
382+
item=linked_item,
383+
added_by=self.user,
384+
)
385+
386+
rules = smart_rules.normalize_rule_payload(
387+
{"genre": "Nonexistent Genre", "list": [self.list1.id]},
388+
self.user,
389+
)
390+
391+
self.assertTrue(smart_rules.item_matches_rules(self.user, linked_item, rules))
392+
393+
def test_manual_list_membership_change_syncs_referencing_smart_lists(self):
394+
"""Adding/removing an item on a linked manual list should resync smart lists."""
395+
linked_item = Item.objects.create(
396+
title="Freshly Linked Game",
397+
media_id="link-4",
398+
media_type=MediaTypes.GAME.value,
399+
source=Sources.IGDB.value,
400+
)
401+
smart_list = CustomList.objects.create(
402+
name="All Owned Games",
403+
owner=self.user,
404+
is_smart=True,
405+
smart_filters={"list": [self.list1.id]},
406+
)
407+
408+
membership = CustomListItem.objects.create(
409+
custom_list=self.list1,
410+
item=linked_item,
411+
added_by=self.user,
412+
)
413+
414+
self.assertTrue(smart_list.items.filter(id=linked_item.id).exists())
415+
416+
membership.delete()
417+
418+
self.assertFalse(smart_list.items.filter(id=linked_item.id).exists())
419+
307420
def test_smart_list_collection_filter_uses_episode_collection_for_tv(self):
308421
"""Collected TV rules should match when related episodes are collected."""
309422
tv_item = Item.objects.create(

0 commit comments

Comments
 (0)