From a19a234404f25f44bb5b70faf37163aee375b4a9 Mon Sep 17 00:00:00 2001 From: olioby Date: Sun, 1 Mar 2026 12:45:24 -0500 Subject: [PATCH 1/6] initial draft for bulk adding media to list --- src/lists/urls.py | 2 + src/lists/views.py | 40 +++++++++- src/templates/app/components/media_card.html | 20 ++++- .../app/components/media_table_items.html | 9 +++ src/templates/app/media_list.html | 75 ++++++++++++++++++- .../lists/components/bulk_fill_lists.html | 24 ++++++ 6 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 src/templates/lists/components/bulk_fill_lists.html 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..be805f8ac 100644 --- a/src/lists/views.py +++ b/src/lists/views.py @@ -3,7 +3,7 @@ from django.contrib import messages from django.core.paginator import Paginator from django.db.models import Count, F, OuterRef, Q, Subquery -from django.http import Http404 +from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.views.decorators.http import require_GET, require_POST @@ -318,3 +318,41 @@ 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, id=custom_list_id) + + if custom_list.user_can_edit(request.user): + CustomListItem.objects.bulk_create( + [CustomListItem(custom_list=custom_list, item_id=i) for i in item_ids], + ignore_conflicts=True, + ) + logger.info("%d items bulk added to %s.", len(item_ids), custom_list) + + response = HttpResponse() + response["HX-Trigger"] = "bulkAddSuccess" + return response + + messages.error(request, "You do not have permission to edit this list.") + return helpers.redirect_back(request) 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 }}< {% if not forloop.last %},{% endif %} {% endfor %} - } }"> + } }" @bulkaddsuccess.window="bulkListsOpen = false; $store.bulk.clear()">
+ + +
+ + + + + + +
+ + selected + + + + +
+ + +
+
+
+
+
+ {% if not media_list %}
@@ -157,6 +227,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..e50bca47b --- /dev/null +++ b/src/templates/lists/components/bulk_fill_lists.html @@ -0,0 +1,24 @@ + +
+
+

+ Add items to list +

+ +
+
    + {% for custom_list in custom_lists %} +
  • + {{ custom_list.name }} + +
  • + {% empty %} +
  • You haven't created any lists yet.
  • + {% endfor %} +
+
From c0711d4f52459d1c1228c23b34b980ba46f395e0 Mon Sep 17 00:00:00 2001 From: olioby Date: Sun, 1 Mar 2026 12:53:19 -0500 Subject: [PATCH 2/6] add csrf token to bulk add request --- src/templates/app/media_list.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/templates/app/media_list.html b/src/templates/app/media_list.html index af406176c..67c07beaf 100644 --- a/src/templates/app/media_list.html +++ b/src/templates/app/media_list.html @@ -163,8 +163,9 @@

{{ media_type|media_type_readable_plural }}< - + + From a5e9df80f6099463ffdbf44c9693a7ce67fb98ad Mon Sep 17 00:00:00 2001 From: olioby Date: Sun, 1 Mar 2026 13:08:37 -0500 Subject: [PATCH 3/6] fix success response --- src/lists/views.py | 7 +++---- src/templates/app/media_list.html | 2 +- src/templates/lists/components/bulk_fill_lists.html | 3 ++- .../lists/components/bulk_fill_lists_success.html | 7 +++++++ 4 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 src/templates/lists/components/bulk_fill_lists_success.html diff --git a/src/lists/views.py b/src/lists/views.py index be805f8ac..2e48a3262 100644 --- a/src/lists/views.py +++ b/src/lists/views.py @@ -3,7 +3,7 @@ from django.contrib import messages from django.core.paginator import Paginator from django.db.models import Count, F, OuterRef, Q, Subquery -from django.http import Http404, HttpResponse +from django.http import Http404 from django.shortcuts import get_object_or_404, redirect, render from django.views.decorators.http import require_GET, require_POST @@ -350,9 +350,8 @@ def bulk_list_add(request): ) logger.info("%d items bulk added to %s.", len(item_ids), custom_list) - response = HttpResponse() - response["HX-Trigger"] = "bulkAddSuccess" - return response + logger.info("%d items bulk added to %s.", len(item_ids), custom_list) + return render(request, "lists/components/bulk_fill_lists_success.html") messages.error(request, "You do not have permission to edit this list.") return helpers.redirect_back(request) diff --git a/src/templates/app/media_list.html b/src/templates/app/media_list.html index 67c07beaf..46c86a1ec 100644 --- a/src/templates/app/media_list.html +++ b/src/templates/app/media_list.html @@ -41,7 +41,7 @@

{{ media_type|media_type_readable_plural }}< {% if not forloop.last %},{% endif %} {% endfor %} - } }" @bulkaddsuccess.window="bulkListsOpen = false; $store.bulk.clear()"> + } }"> diff --git a/src/templates/lists/components/bulk_fill_lists.html b/src/templates/lists/components/bulk_fill_lists.html index e50bca47b..03c027e1e 100644 --- a/src/templates/lists/components/bulk_fill_lists.html +++ b/src/templates/lists/components/bulk_fill_lists.html @@ -15,7 +15,8 @@

hx-post="{% url 'bulk_list_add' %}" hx-include="#bulk-ids-form" hx-vals='{"custom_list_id": "{{ custom_list.id }}"}' - hx-swap="none">Add + hx-target="#bulk-lists-modal-content" + hx-swap="innerHTML">Add {% empty %}
  • You haven't created any lists yet.
  • 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!

    +
    +
    From ce10886e8d98a47a5632bc9c89a4303bc81a6d3f Mon Sep 17 00:00:00 2001 From: olioby Date: Sun, 1 Mar 2026 13:09:41 -0500 Subject: [PATCH 4/6] remove duplicate log line --- src/lists/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lists/views.py b/src/lists/views.py index 2e48a3262..a3a887c88 100644 --- a/src/lists/views.py +++ b/src/lists/views.py @@ -350,7 +350,6 @@ def bulk_list_add(request): ) logger.info("%d items bulk added to %s.", len(item_ids), custom_list) - logger.info("%d items bulk added to %s.", len(item_ids), custom_list) return render(request, "lists/components/bulk_fill_lists_success.html") messages.error(request, "You do not have permission to edit this list.") From e3b47acd8d5b8c1e5e60ff56b7dfc819cc976b39 Mon Sep 17 00:00:00 2001 From: olioby Date: Sun, 1 Mar 2026 13:33:42 -0500 Subject: [PATCH 5/6] add tests --- src/lists/tests/test_views.py | 129 ++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/src/lists/tests/test_views.py b/src/lists/tests/test_views.py index e0b2fc8ef..03374d42c 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, 403) + 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) From bdbb8c23bafb3e99b29daa1bf989bfd13e34974b Mon Sep 17 00:00:00 2001 From: olioby Date: Sun, 1 Mar 2026 22:49:20 -0500 Subject: [PATCH 6/6] improve bulk list add endpoint and fix test --- src/lists/tests/test_views.py | 2 +- src/lists/views.py | 23 +++++++++++++---------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/lists/tests/test_views.py b/src/lists/tests/test_views.py index 03374d42c..02ac43400 100644 --- a/src/lists/tests/test_views.py +++ b/src/lists/tests/test_views.py @@ -1025,7 +1025,7 @@ def test_bulk_add_unauthorized(self): reverse("bulk_list_add"), {"item_ids": [self.item1.id], "custom_list_id": self.list.id}, ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 404) self.assertNotIn(self.item1, self.list.items.all()) def test_bulk_add_nonexistent_list(self): diff --git a/src/lists/views.py b/src/lists/views.py index a3a887c88..a5a6a6a3f 100644 --- a/src/lists/views.py +++ b/src/lists/views.py @@ -341,16 +341,19 @@ def bulk_list_add(request): item_ids = request.POST.getlist("item_ids") custom_list_id = request.POST["custom_list_id"] - custom_list = get_object_or_404(CustomList, id=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 + ) - if custom_list.user_can_edit(request.user): - CustomListItem.objects.bulk_create( - [CustomListItem(custom_list=custom_list, item_id=i) for i in item_ids], - ignore_conflicts=True, - ) - logger.info("%d items bulk added to %s.", len(item_ids), custom_list) + items = [CustomListItem(custom_list=custom_list, item_id=i) for i in item_ids] - return render(request, "lists/components/bulk_fill_lists_success.html") + CustomListItem.objects.bulk_create( + items, + ignore_conflicts=True, + ) + logger.info("%d items bulk added to %s.", len(item_ids), custom_list) - messages.error(request, "You do not have permission to edit this list.") - return helpers.redirect_back(request) + return render(request, "lists/components/bulk_fill_lists_success.html")

    Title Score