From 549554cc17ab395b4c73c3e9bf0fc28603fd6dc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Thu, 19 Jun 2025 22:19:29 +0200 Subject: [PATCH] Add REST API for bookmark bundles (#1100) * Add bundles API * Add docs --- bookmarks/api/routes.py | 32 ++- bookmarks/api/serializers.py | 45 ++++- bookmarks/tests/test_bundles_api.py | 303 ++++++++++++++++++++++++++++ bookmarks/urls.py | 1 + docs/src/content/docs/api.md | 109 ++++++++++ 5 files changed, 487 insertions(+), 3 deletions(-) create mode 100644 bookmarks/tests/test_bundles_api.py 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//assets// # 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/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") 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//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// +``` + +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// +PATCH /api/bundles// +``` + +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// +``` + +Deletes a bundle by ID. + ### User **Profile**