Repository: sissbruecker/linkding
Pull request #1100 (2025-06-19 20:13:45)

Add REST API for bookmark bundles


Patch:
diff --git a/bookmarks/api/routes.py b/bookmarks/api/routes.py
index c63d15d..e20603d 100644
--- a/bookmarks/api/routes.py
+++ b/bookmarks/api/routes.py
@@ -16,8 +16,16 @@ from bookmarks.api.serializers import (
     BookmarkAssetSerializer,
     TagSerializer,
     UserProfileSerializer,
+    BookmarkBundleSerializer,
+)
+from bookmarks.models import (
+    Bookmark,
+    BookmarkAsset,
+    BookmarkSearch,
+    Tag,
+    User,
+    BookmarkBundle,
 )
-from bookmarks.models import Bookmark, BookmarkAsset, BookmarkSearch, Tag, User
 from bookmarks.services import assets, bookmarks, auto_tagging, website_loader
 from bookmarks.type_defs import HttpRequest
 from bookmarks.views import access
@@ -264,6 +272,25 @@ class UserViewSet(viewsets.GenericViewSet):
         return Response(UserProfileSerializer(request.user.profile).data)
 
 
+class BookmarkBundleViewSet(
+    viewsets.GenericViewSet,
+    mixins.ListModelMixin,
+    mixins.RetrieveModelMixin,
+    mixins.CreateModelMixin,
+    mixins.UpdateModelMixin,
+    mixins.DestroyModelMixin,
+):
+    request: HttpRequest
+    serializer_class = BookmarkBundleSerializer
+
+    def get_queryset(self):
+        user = self.request.user
+        return BookmarkBundle.objects.filter(owner=user).order_by("order")
+
+    def get_serializer_context(self):
+        return {"user": self.request.user}
+
+
 # DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
 # Instead create separate routers for each view set and manually register them in urls.py
 # The default router is only used to allow reversing a URL for the API root
@@ -278,5 +305,8 @@ tag_router.register("", TagViewSet, basename="tag")
 user_router = SimpleRouter()
 user_router.register("", UserViewSet, basename="user")
 
+bundle_router = SimpleRouter()
+bundle_router.register("", BookmarkBundleViewSet, basename="bundle")
+
 bookmark_asset_router = SimpleRouter()
 bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")
diff --git a/bookmarks/api/serializers.py b/bookmarks/api/serializers.py
index 6abedcc..7acca98 100644
--- a/bookmarks/api/serializers.py
+++ b/bookmarks/api/serializers.py
@@ -1,9 +1,16 @@
-from django.db.models import prefetch_related_objects
+from django.db.models import Max, prefetch_related_objects
 from django.templatetags.static import static
 from rest_framework import serializers
 from rest_framework.serializers import ListSerializer
 
-from bookmarks.models import Bookmark, BookmarkAsset, Tag, build_tag_string, UserProfile
+from bookmarks.models import (
+    Bookmark,
+    BookmarkAsset,
+    Tag,
+    build_tag_string,
+    UserProfile,
+    BookmarkBundle,
+)
 from bookmarks.services import bookmarks
 from bookmarks.services.tags import get_or_create_tag
 from bookmarks.services.wayback import generate_fallback_webarchive_url
@@ -27,6 +34,40 @@ class EmtpyField(serializers.ReadOnlyField):
         return None
 
 
+class BookmarkBundleSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = BookmarkBundle
+        fields = [
+            "id",
+            "name",
+            "search",
+            "any_tags",
+            "all_tags",
+            "excluded_tags",
+            "order",
+            "date_created",
+            "date_modified",
+        ]
+        read_only_fields = [
+            "id",
+            "date_created",
+            "date_modified",
+        ]
+
+    def create(self, validated_data):
+        # Set owner to the authenticated user
+        validated_data["owner"] = self.context["user"]
+
+        # Set order to the next available position if not provided
+        if "order" not in validated_data:
+            max_order = BookmarkBundle.objects.filter(
+                owner=self.context["user"]
+            ).aggregate(Max("order", default=-1))["order__max"]
+            validated_data["order"] = max_order + 1
+
+        return super().create(validated_data)
+
+
 class BookmarkSerializer(serializers.ModelSerializer):
     class Meta:
         model = Bookmark
diff --git a/bookmarks/urls.py b/bookmarks/urls.py
index ff963c9..338af5b 100644
--- a/bookmarks/urls.py
+++ b/bookmarks/urls.py
@@ -70,6 +70,7 @@ urlpatterns = [
         include(api_routes.bookmark_asset_router.urls),
     ),
     path("api/tags/", include(api_routes.tag_router.urls)),
+    path("api/bundles/", include(api_routes.bundle_router.urls)),
     path("api/user/", include(api_routes.user_router.urls)),
     # Feeds
     path("feeds/<str:feed_key>/all", feeds.AllBookmarksFeed(), name="feeds.all"),
diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md
index 5b41b16..ce43b2e 100644
--- a/docs/src/content/docs/api.md
+++ b/docs/src/content/docs/api.md
@@ -355,6 +355,115 @@ Example payload:
 }
 ```
 
+### Bundles
+
+**List**
+
+```
+GET /api/bundles/
+```
+
+List bundles.
+
+Parameters:
+
+- `limit` - Limits the max. number of results. Default is `100`.
+- `offset` - Index from which to start returning results
+
+Example response:
+
+```json
+{
+  "count": 3,
+  "next": null,
+  "previous": null,
+  "results": [
+    {
+      "id": 1,
+      "name": "Work Resources",
+      "search": "productivity tools",
+      "any_tags": "work productivity",
+      "all_tags": "",
+      "excluded_tags": "personal",
+      "order": 0,
+      "date_created": "2020-09-26T09:46:23.006313Z",
+      "date_modified": "2020-09-26T16:01:14.275335Z"
+    },
+    {
+      "id": 2,
+      "name": "Tech Articles",
+      "search": "",
+      "any_tags": "programming development",
+      "all_tags": "",
+      "excluded_tags": "outdated",
+      "order": 1,
+      "date_created": "2020-09-27T10:15:30.123456Z",
+      "date_modified": "2020-09-27T10:15:30.123456Z"
+    },
+    ...
+  ]
+}
+```
+
+**Retrieve**
+
+```
+GET /api/bundles/<id>/
+```
+
+Retrieves a single bundle by ID.
+
+**Create**
+
+```
+POST /api/bundles/
+```
+
+Creates a new bundle. If no `order` is specified, the bundle will be automatically assigned the next available order position.
+
+Example payload:
+
+```json
+{
+  "name": "My Bundle",
+  "search": "search terms",
+  "any_tags": "tag1 tag2",
+  "all_tags": "required-tag",
+  "excluded_tags": "excluded-tag",
+  "order": 5
+}
+```
+
+**Update**
+
+```
+PUT /api/bundles/<id>/
+PATCH /api/bundles/<id>/
+```
+
+Updates a bundle.
+When using `PUT`, all fields except read-only ones should be provided.
+When using `PATCH`, only the fields that should be updated need to be provided.
+
+Example payload:
+
+```json
+{
+  "name": "Updated Bundle Name",
+  "search": "updated search terms",
+  "any_tags": "new-tag1 new-tag2",
+  "order": 10
+}
+```
+
+**Delete**
+
+```
+DELETE /api/bundles/<id>/
+```
+
+Deletes a bundle by ID.
+
 ### User
 
 **Profile**


Test patch:
diff --git a/bookmarks/tests/test_bundles_api.py b/bookmarks/tests/test_bundles_api.py
new file mode 100644
index 0000000..3a14b01
--- /dev/null
+++ b/bookmarks/tests/test_bundles_api.py
@@ -0,0 +1,303 @@
+from django.urls import reverse
+from rest_framework import status
+
+from bookmarks.models import BookmarkBundle
+from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
+
+
+class BundlesApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
+    def assertBundle(self, bundle: BookmarkBundle, data: dict):
+        self.assertEqual(bundle.id, data["id"])
+        self.assertEqual(bundle.name, data["name"])
+        self.assertEqual(bundle.search, data["search"])
+        self.assertEqual(bundle.any_tags, data["any_tags"])
+        self.assertEqual(bundle.all_tags, data["all_tags"])
+        self.assertEqual(bundle.excluded_tags, data["excluded_tags"])
+        self.assertEqual(bundle.order, data["order"])
+        self.assertEqual(
+            bundle.date_created.isoformat().replace("+00:00", "Z"), data["date_created"]
+        )
+        self.assertEqual(
+            bundle.date_modified.isoformat().replace("+00:00", "Z"),
+            data["date_modified"],
+        )
+
+    def test_bundle_list(self):
+        self.authenticate()
+
+        bundles = [
+            self.setup_bundle(name="Bundle 1", order=0),
+            self.setup_bundle(name="Bundle 2", order=1),
+            self.setup_bundle(name="Bundle 3", order=2),
+        ]
+
+        url = reverse("linkding:bundle-list")
+        response = self.get(url, expected_status_code=status.HTTP_200_OK)
+
+        self.assertEqual(len(response.data["results"]), 3)
+        self.assertBundle(bundles[0], response.data["results"][0])
+        self.assertBundle(bundles[1], response.data["results"][1])
+        self.assertBundle(bundles[2], response.data["results"][2])
+
+    def test_bundle_list_only_returns_own_bundles(self):
+        self.authenticate()
+
+        user_bundles = [
+            self.setup_bundle(name="User Bundle 1"),
+            self.setup_bundle(name="User Bundle 2"),
+        ]
+
+        other_user = self.setup_user()
+        self.setup_bundle(name="Other User Bundle 1", user=other_user)
+        self.setup_bundle(name="Other User Bundle 2", user=other_user)
+
+        url = reverse("linkding:bundle-list")
+        response = self.get(url, expected_status_code=status.HTTP_200_OK)
+
+        self.assertEqual(len(response.data["results"]), 2)
+        self.assertBundle(user_bundles[0], response.data["results"][0])
+        self.assertBundle(user_bundles[1], response.data["results"][1])
+
+    def test_bundle_list_requires_authentication(self):
+        url = reverse("linkding:bundle-list")
+        self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
+
+    def test_bundle_detail(self):
+        self.authenticate()
+
+        bundle = self.setup_bundle(
+            name="Test Bundle",
+            search="test search",
+            any_tags="tag1 tag2",
+            all_tags="required-tag",
+            excluded_tags="excluded-tag",
+            order=5,
+        )
+
+        url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
+        response = self.get(url, expected_status_code=status.HTTP_200_OK)
+
+        self.assertBundle(bundle, response.data)
+
+    def test_bundle_detail_only_returns_own_bundles(self):
+        self.authenticate()
+
+        other_user = self.setup_user()
+        other_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
+
+        url = reverse("linkding:bundle-detail", kwargs={"pk": other_bundle.id})
+        self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
+
+    def test_bundle_detail_requires_authentication(self):
+        bundle = self.setup_bundle()
+        url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
+        self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
+
+    def test_create_bundle(self):
+        self.authenticate()
+
+        bundle_data = {
+            "name": "New Bundle",
+            "search": "test search",
+            "any_tags": "tag1 tag2",
+            "all_tags": "required-tag",
+            "excluded_tags": "excluded-tag",
+        }
+
+        url = reverse("linkding:bundle-list")
+        response = self.post(
+            url, bundle_data, expected_status_code=status.HTTP_201_CREATED
+        )
+
+        bundle = BookmarkBundle.objects.get(id=response.data["id"])
+        self.assertEqual(bundle.name, bundle_data["name"])
+        self.assertEqual(bundle.search, bundle_data["search"])
+        self.assertEqual(bundle.any_tags, bundle_data["any_tags"])
+        self.assertEqual(bundle.all_tags, bundle_data["all_tags"])
+        self.assertEqual(bundle.excluded_tags, bundle_data["excluded_tags"])
+        self.assertEqual(bundle.owner, self.user)
+        self.assertEqual(bundle.order, 0)
+
+        self.assertBundle(bundle, response.data)
+
+    def test_create_bundle_auto_increments_order(self):
+        self.authenticate()
+
+        self.setup_bundle(name="Existing Bundle", order=2)
+
+        bundle_data = {"name": "New Bundle", "search": "test search"}
+
+        url = reverse("linkding:bundle-list")
+        response = self.post(
+            url, bundle_data, expected_status_code=status.HTTP_201_CREATED
+        )
+
+        bundle = BookmarkBundle.objects.get(id=response.data["id"])
+        self.assertEqual(bundle.order, 3)
+
+    def test_create_bundle_with_custom_order(self):
+        self.authenticate()
+
+        bundle_data = {"name": "New Bundle", "order": 10}
+
+        url = reverse("linkding:bundle-list")
+        response = self.post(
+            url, bundle_data, expected_status_code=status.HTTP_201_CREATED
+        )
+
+        bundle = BookmarkBundle.objects.get(id=response.data["id"])
+        self.assertEqual(bundle.order, 10)
+
+    def test_create_bundle_requires_name(self):
+        self.authenticate()
+
+        bundle_data = {"search": "test search"}
+
+        url = reverse("linkding:bundle-list")
+        self.post(url, bundle_data, expected_status_code=status.HTTP_400_BAD_REQUEST)
+
+    def test_create_bundle_fields_can_be_empty(self):
+        self.authenticate()
+
+        bundle_data = {
+            "name": "Minimal Bundle",
+            "search": "",
+            "any_tags": "",
+            "all_tags": "",
+            "excluded_tags": "",
+        }
+
+        url = reverse("linkding:bundle-list")
+        response = self.post(
+            url, bundle_data, expected_status_code=status.HTTP_201_CREATED
+        )
+
+        bundle = BookmarkBundle.objects.get(id=response.data["id"])
+        self.assertEqual(bundle.name, "Minimal Bundle")
+        self.assertEqual(bundle.search, "")
+        self.assertEqual(bundle.any_tags, "")
+        self.assertEqual(bundle.all_tags, "")
+        self.assertEqual(bundle.excluded_tags, "")
+
+    def test_create_bundle_requires_authentication(self):
+        bundle_data = {"name": "New Bundle"}
+
+        url = reverse("linkding:bundle-list")
+        self.post(url, bundle_data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
+
+    def test_update_bundle_put(self):
+        self.authenticate()
+
+        bundle = self.setup_bundle(
+            name="Original Bundle",
+            search="original search",
+            any_tags="original-tag",
+            order=1,
+        )
+
+        updated_data = {
+            "name": "Updated Bundle",
+            "search": "updated search",
+            "any_tags": "updated-tag1 updated-tag2",
+            "all_tags": "required-updated-tag",
+            "excluded_tags": "excluded-updated-tag",
+            "order": 5,
+        }
+
+        url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
+        response = self.put(url, updated_data, expected_status_code=status.HTTP_200_OK)
+
+        bundle.refresh_from_db()
+        self.assertEqual(bundle.name, updated_data["name"])
+        self.assertEqual(bundle.search, updated_data["search"])
+        self.assertEqual(bundle.any_tags, updated_data["any_tags"])
+        self.assertEqual(bundle.all_tags, updated_data["all_tags"])
+        self.assertEqual(bundle.excluded_tags, updated_data["excluded_tags"])
+        self.assertEqual(bundle.order, updated_data["order"])
+
+        self.assertBundle(bundle, response.data)
+
+    def test_update_bundle_patch(self):
+        self.authenticate()
+
+        bundle = self.setup_bundle(
+            name="Original Bundle", search="original search", any_tags="original-tag"
+        )
+
+        updated_data = {
+            "name": "Partially Updated Bundle",
+            "search": "partially updated search",
+        }
+
+        url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
+        response = self.patch(
+            url, updated_data, expected_status_code=status.HTTP_200_OK
+        )
+
+        bundle.refresh_from_db()
+        self.assertEqual(bundle.name, updated_data["name"])
+        self.assertEqual(bundle.search, updated_data["search"])
+        self.assertEqual(bundle.any_tags, "original-tag")  # Should remain unchanged
+
+        self.assertBundle(bundle, response.data)
+
+    def test_update_bundle_only_allows_own_bundles(self):
+        self.authenticate()
+
+        other_user = self.setup_user()
+        other_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
+
+        updated_data = {"name": "Updated Bundle"}
+
+        url = reverse("linkding:bundle-detail", kwargs={"pk": other_bundle.id})
+        self.put(url, updated_data, expected_status_code=status.HTTP_404_NOT_FOUND)
+
+    def test_update_bundle_requires_authentication(self):
+        bundle = self.setup_bundle()
+        updated_data = {"name": "Updated Bundle"}
+
+        url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
+        self.put(url, updated_data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
+
+    def test_delete_bundle(self):
+        self.authenticate()
+
+        bundle = self.setup_bundle(name="Bundle to Delete")
+
+        url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
+        self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
+
+        self.assertFalse(BookmarkBundle.objects.filter(id=bundle.id).exists())
+
+    def test_delete_bundle_only_allows_own_bundles(self):
+        self.authenticate()
+
+        other_user = self.setup_user()
+        other_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
+
+        url = reverse("linkding:bundle-detail", kwargs={"pk": other_bundle.id})
+        self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
+
+        self.assertTrue(BookmarkBundle.objects.filter(id=other_bundle.id).exists())
+
+    def test_delete_bundle_requires_authentication(self):
+        bundle = self.setup_bundle()
+        url = reverse("linkding:bundle-detail", kwargs={"pk": bundle.id})
+        self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
+
+        self.assertTrue(BookmarkBundle.objects.filter(id=bundle.id).exists())
+
+    def test_bundles_ordered_by_order_field(self):
+        self.authenticate()
+
+        self.setup_bundle(name="Third Bundle", order=2)
+        self.setup_bundle(name="First Bundle", order=0)
+        self.setup_bundle(name="Second Bundle", order=1)
+
+        url = reverse("linkding:bundle-list")
+        response = self.get(url, expected_status_code=status.HTTP_200_OK)
+
+        self.assertEqual(len(response.data["results"]), 3)
+        self.assertEqual(response.data["results"][0]["name"], "First Bundle")
+        self.assertEqual(response.data["results"][1]["name"], "Second Bundle")
+        self.assertEqual(response.data["results"][2]["name"], "Third Bundle")