diff --git a/src/lists/tests/test_views.py b/src/lists/tests/test_views.py index e0b2fc8ef..02ac43400 100644 --- a/src/lists/tests/test_views.py +++ b/src/lists/tests/test_views.py @@ -907,3 +907,132 @@ def test_list_item_toggle_template_context(self): ) self.assertEqual(response.status_code, 200) self.assertFalse(response.context["has_item"]) # Item was removed + + +class BulkListsModalTests(TestCase): + """Tests for the bulk_lists_modal view.""" + + def setUp(self): + """Set up test data.""" + self.client = Client() + self.credentials = {"username": "test", "password": "12345"} + self.user = get_user_model().objects.create_user(**self.credentials) + self.other_credentials = {"username": "other", "password": "12345"} + self.other_user = get_user_model().objects.create_user(**self.other_credentials) + + self.list = CustomList.objects.create(name="My List", owner=self.user) + + def test_returns_user_lists(self): + """Modal returns lists owned by the user.""" + self.client.login(**self.credentials) + response = self.client.get(reverse("bulk_lists_modal")) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "lists/components/bulk_fill_lists.html") + self.assertIn(self.list, response.context["custom_lists"]) + + def test_excludes_other_users_lists(self): + """Modal does not return lists belonging to other users.""" + other_list = CustomList.objects.create(name="Other List", owner=self.other_user) + self.client.login(**self.credentials) + response = self.client.get(reverse("bulk_lists_modal")) + self.assertNotIn(other_list, response.context["custom_lists"]) + + def test_includes_collaborated_lists(self): + """Modal includes lists the user collaborates on.""" + collab_list = CustomList.objects.create( + name="Collab List", owner=self.other_user + ) + collab_list.collaborators.add(self.user) + self.client.login(**self.credentials) + response = self.client.get(reverse("bulk_lists_modal")) + self.assertIn(collab_list, response.context["custom_lists"]) + + +class BulkListAddTests(TestCase): + """Tests for the bulk_list_add view.""" + + def setUp(self): + """Set up test data.""" + self.client = Client() + self.credentials = {"username": "test", "password": "12345"} + self.user = get_user_model().objects.create_user(**self.credentials) + self.other_credentials = {"username": "other", "password": "12345"} + self.other_user = get_user_model().objects.create_user(**self.other_credentials) + + self.list = CustomList.objects.create(name="My List", owner=self.user) + + self.item1 = Item.objects.create( + media_id=1, + source=Sources.TMDB.value, + media_type=MediaTypes.MOVIE.value, + title="Movie 1", + image="http://example.com/1.jpg", + ) + self.item2 = Item.objects.create( + media_id=2, + source=Sources.TMDB.value, + media_type=MediaTypes.MOVIE.value, + title="Movie 2", + image="http://example.com/2.jpg", + ) + + def test_bulk_add_items(self): + """Owner can bulk add multiple items to a list.""" + self.client.login(**self.credentials) + response = self.client.post( + reverse("bulk_list_add"), + { + "item_ids": [self.item1.id, self.item2.id], + "custom_list_id": self.list.id, + }, + ) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed( + response, "lists/components/bulk_fill_lists_success.html" + ) + self.assertIn(self.item1, self.list.items.all()) + self.assertIn(self.item2, self.list.items.all()) + + def test_bulk_add_collaborator(self): + """Collaborator can bulk add items to a list.""" + self.list.collaborators.add(self.other_user) + self.client.login(**self.other_credentials) + response = self.client.post( + reverse("bulk_list_add"), + {"item_ids": [self.item1.id], "custom_list_id": self.list.id}, + ) + self.assertEqual(response.status_code, 200) + self.assertIn(self.item1, self.list.items.all()) + + def test_bulk_add_ignores_duplicates(self): + """Adding already-present items does not raise an error.""" + self.list.items.add(self.item1) + self.client.login(**self.credentials) + response = self.client.post( + reverse("bulk_list_add"), + { + "item_ids": [self.item1.id, self.item2.id], + "custom_list_id": self.list.id, + }, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(self.list.items.count(), 2) + + def test_bulk_add_unauthorized(self): + """User with no access to the list gets a 403.""" + self.client.login(**self.other_credentials) + response = self.client.post( + reverse("bulk_list_add"), + {"item_ids": [self.item1.id], "custom_list_id": self.list.id}, + ) + self.assertEqual(response.status_code, 404) + self.assertNotIn(self.item1, self.list.items.all()) + + def test_bulk_add_nonexistent_list(self): + """Posting to a nonexistent list returns 404.""" + self.client.login(**self.credentials) + response = self.client.post( + reverse("bulk_list_add"), + {"item_ids": [self.item1.id], "custom_list_id": 99999}, + ) + self.assertEqual(response.status_code, 404) diff --git a/src/lists/urls.py b/src/lists/urls.py index 2496f8a33..f5f5b9a71 100644 --- a/src/lists/urls.py +++ b/src/lists/urls.py @@ -24,4 +24,6 @@ path("list/edit", views.edit, name="list_edit"), path("list/delete", views.delete, name="list_delete"), path("list_item_toggle", views.list_item_toggle, name="list_item_toggle"), + path("bulk_lists_modal", views.bulk_lists_modal, name="bulk_lists_modal"), + path("bulk_list_add", views.bulk_list_add, name="bulk_list_add"), ] diff --git a/src/lists/views.py b/src/lists/views.py index 62ddf8485..a5a6a6a3f 100644 --- a/src/lists/views.py +++ b/src/lists/views.py @@ -318,3 +318,42 @@ def list_item_toggle(request): "lists/components/list_item_button.html", {"custom_list": custom_list, "item": item, "has_item": has_item}, ) + + +@require_GET +def bulk_lists_modal(request): + """Return the modal showing all custom lists for bulk adding items.""" + custom_lists = ( + CustomList.objects.filter(Q(owner=request.user) | Q(collaborators=request.user)) + .distinct() + .order_by("name") + ) + return render( + request, + "lists/components/bulk_fill_lists.html", + {"custom_lists": custom_lists}, + ) + + +@require_POST +def bulk_list_add(request): + """Add multiple items to a custom list at once.""" + item_ids = request.POST.getlist("item_ids") + custom_list_id = request.POST["custom_list_id"] + + custom_list = get_object_or_404( + CustomList.objects.filter( + Q(owner=request.user) | Q(collaborators=request.user), + id=custom_list_id, + ).distinct(), # To prevent duplicates, when user is owner and collaborator + ) + + items = [CustomListItem(custom_list=custom_list, item_id=i) for i in item_ids] + + CustomListItem.objects.bulk_create( + items, + ignore_conflicts=True, + ) + logger.info("%d items bulk added to %s.", len(item_ids), custom_list) + + return render(request, "lists/components/bulk_fill_lists_success.html") diff --git a/src/templates/app/components/media_card.html b/src/templates/app/components/media_card.html index 2062dc606..b2d2e2efb 100644 --- a/src/templates/app/components/media_card.html +++ b/src/templates/app/components/media_card.html @@ -1,8 +1,23 @@ {% load app_tags %}
+ data-bulk-id="{{ item.id }}" + x-data="{ trackOpen: false, listsOpen: false, historyOpen: false }" + :class="$store.bulk.active && $store.bulk.ids.includes({{ item.id }}) ? 'ring-2 ring-indigo-500' : ''">
+ {# Bulk select overlay — covers the card when bulk mode is active #} +
+
+ +
+
+ {{ title }} {% endif %} -
+
diff --git a/src/templates/app/components/media_table_items.html b/src/templates/app/components/media_table_items.html index 021f9a1b3..82d2d8229 100644 --- a/src/templates/app/components/media_table_items.html +++ b/src/templates/app/components/media_table_items.html @@ -2,8 +2,17 @@ {% for media in media_list %} + + + {{ media.item }}{{ media_type|media_type_readable_plural }} -
{{ media_type|media_type_readable_plural }}<
+ + +
+ + + + +
+ + selected + + + + +
+ + +
+
+
+
+
+ {% if not media_list %}
@@ -157,6 +228,7 @@

No {{ media_type|media_type_readable_plur + diff --git a/src/templates/lists/components/bulk_fill_lists.html b/src/templates/lists/components/bulk_fill_lists.html new file mode 100644 index 000000000..03c027e1e --- /dev/null +++ b/src/templates/lists/components/bulk_fill_lists.html @@ -0,0 +1,25 @@ + +
+
+

+ Add items to list +

+ +
+
    + {% for custom_list in custom_lists %} +
  • + {{ custom_list.name }} + +
  • + {% empty %} +
  • You haven't created any lists yet.
  • + {% endfor %} +
+
diff --git a/src/templates/lists/components/bulk_fill_lists_success.html b/src/templates/lists/components/bulk_fill_lists_success.html new file mode 100644 index 000000000..d5c2d3949 --- /dev/null +++ b/src/templates/lists/components/bulk_fill_lists_success.html @@ -0,0 +1,7 @@ +
+
+ {% include "app/icons/states/completed.svg" with classes="w-10 h-10 text-emerald-400" %} +

Added to list!

+
+
Title Score