{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}
diff --git a/bookmarks/templates/shared/messages.html b/bookmarks/templates/shared/messages.html
new file mode 100644
index 0000000..fce07fc
--- /dev/null
+++ b/bookmarks/templates/shared/messages.html
@@ -0,0 +1,9 @@
+{% if messages %}
+
+ {% for message in messages %}
+
+ {{ message }}
+
+ {% endfor %}
+
+{% endif %}
diff --git a/bookmarks/templatetags/pagination.py b/bookmarks/templatetags/pagination.py
index bb46e1e..9f0245e 100644
--- a/bookmarks/templatetags/pagination.py
+++ b/bookmarks/templatetags/pagination.py
@@ -13,18 +13,21 @@ register = template.Library()
"bookmarks/pagination.html", name="pagination", takes_context=True
)
def pagination(context, page: Page):
+ request = context["request"]
+ base_url = request.build_absolute_uri(request.path)
+
# remove page number and details from query parameters
- query_params = context["request"].GET.copy()
+ query_params = request.GET.copy()
query_params.pop("page", None)
query_params.pop("details", None)
prev_link = (
- _generate_link(query_params, page.previous_page_number())
+ _generate_link(base_url, query_params, page.previous_page_number())
if page.has_previous()
else None
)
next_link = (
- _generate_link(query_params, page.next_page_number())
+ _generate_link(base_url, query_params, page.next_page_number())
if page.has_next()
else None
)
@@ -37,7 +40,7 @@ def pagination(context, page: Page):
if page_number == -1:
page_links.append(None)
else:
- link = _generate_link(query_params, page_number)
+ link = _generate_link(base_url, query_params, page_number)
page_links.append(
{
"active": page_number == page.number,
@@ -92,6 +95,7 @@ def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
return reduce(append_page, visible_pages, [])
-def _generate_link(query_params: QueryDict, page_number: int) -> str:
+def _generate_link(base_url: str, query_params: QueryDict, page_number: int) -> str:
+ query_params = query_params.copy()
query_params["page"] = page_number
- return query_params.urlencode()
+ return f"{base_url}?{query_params.urlencode()}"
diff --git a/bookmarks/tests/helpers.py b/bookmarks/tests/helpers.py
index 5607287..7db175b 100644
--- a/bookmarks/tests/helpers.py
+++ b/bookmarks/tests/helpers.py
@@ -17,7 +17,7 @@ from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.test import APITestCase
-from bookmarks.models import Bookmark, BookmarkAsset, Tag, User
+from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Tag, User
class BookmarkFactoryMixin:
@@ -166,6 +166,33 @@ class BookmarkFactoryMixin:
def get_numbered_bookmark(self, title: str):
return Bookmark.objects.get(title=title)
+ def setup_bundle(
+ self,
+ user: User = None,
+ name: str = None,
+ search: str = "",
+ any_tags: str = "",
+ all_tags: str = "",
+ excluded_tags: str = "",
+ order: int = 0,
+ ):
+ if user is None:
+ user = self.get_or_create_test_user()
+ if not name:
+ name = get_random_string(length=32)
+ bundle = BookmarkBundle(
+ name=name,
+ owner=user,
+ date_created=timezone.now(),
+ search=search,
+ any_tags=any_tags,
+ all_tags=all_tags,
+ excluded_tags=excluded_tags,
+ order=order,
+ )
+ bundle.save()
+ return bundle
+
def setup_asset(
self,
bookmark: Bookmark,
@@ -239,7 +266,7 @@ class BookmarkFactoryMixin:
user.profile.save()
return user
- def get_tags_from_bookmarks(self, bookmarks: [Bookmark]):
+ def get_tags_from_bookmarks(self, bookmarks: list[Bookmark]):
all_tags = []
for bookmark in bookmarks:
all_tags = all_tags + list(bookmark.tags.all())
diff --git a/bookmarks/tests/test_bookmark_action_view.py b/bookmarks/tests/test_bookmark_action_view.py
index ef523f7..29fd3a0 100644
--- a/bookmarks/tests/test_bookmark_action_view.py
+++ b/bookmarks/tests/test_bookmark_action_view.py
@@ -844,6 +844,26 @@ class BookmarkActionViewTestCase(
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
+ def test_index_action_bulk_select_across_respects_bundle(self):
+ self.setup_numbered_bookmarks(3, prefix="foo")
+ self.setup_numbered_bookmarks(3, prefix="bar")
+
+ self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
+
+ bundle = self.setup_bundle(search="foo")
+
+ self.client.post(
+ reverse("linkding:bookmarks.index.action") + f"?bundle={bundle.id}",
+ {
+ "bulk_action": ["bulk_delete"],
+ "bulk_execute": [""],
+ "bulk_select_across": ["on"],
+ },
+ )
+
+ self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
+ self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
+
def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):
self.setup_bulk_edit_scope_test_data()
@@ -889,6 +909,26 @@ class BookmarkActionViewTestCase(
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
+ def test_archived_action_bulk_select_across_respects_bundle(self):
+ self.setup_numbered_bookmarks(3, prefix="foo", archived=True)
+ self.setup_numbered_bookmarks(3, prefix="bar", archived=True)
+
+ self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
+
+ bundle = self.setup_bundle(search="foo")
+
+ self.client.post(
+ reverse("linkding:bookmarks.archived.action") + f"?bundle={bundle.id}",
+ {
+ "bulk_action": ["bulk_delete"],
+ "bulk_execute": [""],
+ "bulk_select_across": ["on"],
+ },
+ )
+
+ self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
+ self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
+
def test_shared_action_bulk_select_across_not_supported(self):
self.setup_bulk_edit_scope_test_data()
diff --git a/bookmarks/tests/test_bookmark_archived_view.py b/bookmarks/tests/test_bookmark_archived_view.py
index c871d8b..00b9c7b 100644
--- a/bookmarks/tests/test_bookmark_archived_view.py
+++ b/bookmarks/tests/test_bookmark_archived_view.py
@@ -9,7 +9,6 @@ from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
BookmarkListTestMixin,
TagCloudTestMixin,
- collapse_whitespace,
)
@@ -60,7 +59,23 @@ class BookmarkArchivedViewTestCase(
)
response = self.client.get(reverse("linkding:bookmarks.archived") + "?q=foo")
- html = collapse_whitespace(response.content.decode())
+
+ self.assertVisibleBookmarks(response, visible_bookmarks)
+ self.assertInvisibleBookmarks(response, invisible_bookmarks)
+
+ def test_should_list_bookmarks_matching_bundle(self):
+ visible_bookmarks = self.setup_numbered_bookmarks(
+ 3, prefix="foo", archived=True
+ )
+ invisible_bookmarks = self.setup_numbered_bookmarks(
+ 3, prefix="bar", archived=True
+ )
+
+ bundle = self.setup_bundle(search="foo")
+
+ response = self.client.get(
+ reverse("linkding:bookmarks.archived") + f"?bundle={bundle.id}"
+ )
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -105,6 +120,26 @@ class BookmarkArchivedViewTestCase(
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
+ def test_should_list_tags_for_bookmarks_matching_bundle(self):
+ visible_bookmarks = self.setup_numbered_bookmarks(
+ 3, with_tags=True, archived=True, prefix="foo", tag_prefix="foo"
+ )
+ invisible_bookmarks = self.setup_numbered_bookmarks(
+ 3, with_tags=True, archived=True, prefix="bar", tag_prefix="bar"
+ )
+
+ visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
+ invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
+
+ bundle = self.setup_bundle(search="foo")
+
+ response = self.client.get(
+ reverse("linkding:bookmarks.archived") + f"?bundle={bundle.id}"
+ )
+
+ self.assertVisibleTags(response, visible_tags)
+ self.assertInvisibleTags(response, invisible_tags)
+
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
user_profile = self.user.profile
user_profile.search_preferences = {
@@ -515,3 +550,20 @@ class BookmarkArchivedViewTestCase(
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNone(feed)
+
+ def test_hide_bundles_when_enabled_in_profile(self):
+ # visible by default
+ response = self.client.get(reverse("linkding:bookmarks.archived"))
+ html = response.content.decode()
+
+ self.assertInHTML('
Bundles
', html)
+
+ # hidden when disabled in profile
+ user_profile = self.get_or_create_test_user().profile
+ user_profile.hide_bundles = True
+ user_profile.save()
+
+ response = self.client.get(reverse("linkding:bookmarks.archived"))
+ html = response.content.decode()
+
+ self.assertInHTML('
Bundles
', html, count=0)
diff --git a/bookmarks/tests/test_bookmark_details_modal.py b/bookmarks/tests/test_bookmark_details_modal.py
index c99c58d..8c69876 100644
--- a/bookmarks/tests/test_bookmark_details_modal.py
+++ b/bookmarks/tests/test_bookmark_details_modal.py
@@ -585,10 +585,10 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset_item = self.find_asset(asset_list, asset)
self.assertIsNotNone(asset_item)
- asset_icon = asset_item.select_one(".asset-icon svg")
+ asset_icon = asset_item.select_one(".list-item-icon svg")
self.assertIsNotNone(asset_icon)
- asset_text = asset_item.select_one(".asset-text span")
+ asset_text = asset_item.select_one(".list-item-text span")
self.assertIsNotNone(asset_text)
self.assertIn(asset.display_name, asset_text.text)
@@ -687,11 +687,11 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, pending_asset)
- asset_text = asset_item.select_one(".asset-text span")
+ asset_text = asset_item.select_one(".list-item-text span")
self.assertIn("(queued)", asset_text.text)
asset_item = self.find_asset(soup, failed_asset)
- asset_text = asset_item.select_one(".asset-text span")
+ asset_text = asset_item.select_one(".list-item-text span")
self.assertIn("(failed)", asset_text.text)
def test_asset_file_size(self):
@@ -703,15 +703,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
soup = self.get_index_details_modal(bookmark)
asset_item = self.find_asset(soup, asset1)
- asset_text = asset_item.select_one(".asset-text")
+ asset_text = asset_item.select_one(".list-item-text")
self.assertEqual(asset_text.text.strip(), asset1.display_name)
asset_item = self.find_asset(soup, asset2)
- asset_text = asset_item.select_one(".asset-text")
+ asset_text = asset_item.select_one(".list-item-text")
self.assertIn("53.4\xa0KB", asset_text.text)
asset_item = self.find_asset(soup, asset3)
- asset_text = asset_item.select_one(".asset-text")
+ asset_text = asset_item.select_one(".list-item-text")
self.assertIn("11.0\xa0MB", asset_text.text)
def test_asset_actions_visibility(self):
diff --git a/bookmarks/tests/test_bookmark_index_view.py b/bookmarks/tests/test_bookmark_index_view.py
index 8884e03..a82d014 100644
--- a/bookmarks/tests/test_bookmark_index_view.py
+++ b/bookmarks/tests/test_bookmark_index_view.py
@@ -34,6 +34,21 @@ class BookmarkIndexViewTestCase(
self.assertIsNotNone(form)
self.assertEqual(form.attrs["action"], url)
+ def assertVisibleBundles(self, soup, bundles):
+ bundle_list = soup.select_one("ul.bundle-menu")
+ self.assertIsNotNone(bundle_list)
+
+ list_items = bundle_list.select("li.bundle-menu-item")
+ self.assertEqual(len(list_items), len(bundles))
+
+ for index, list_item in enumerate(list_items):
+ bundle = bundles[index]
+ link = list_item.select_one("a")
+ href = link.attrs["href"]
+
+ self.assertEqual(bundle.name, list_item.text.strip())
+ self.assertEqual(f"?bundle={bundle.id}", href)
+
def test_should_list_unarchived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user(
"otheruser", "otheruser@example.com", "password123"
@@ -58,6 +73,19 @@ class BookmarkIndexViewTestCase(
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
+ def test_should_list_bookmarks_matching_bundle(self):
+ visible_bookmarks = self.setup_numbered_bookmarks(3, prefix="foo")
+ invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix="bar")
+
+ bundle = self.setup_bundle(search="foo")
+
+ response = self.client.get(
+ reverse("linkding:bookmarks.index") + f"?bundle={bundle.id}"
+ )
+
+ self.assertVisibleBookmarks(response, visible_bookmarks)
+ self.assertInvisibleBookmarks(response, invisible_bookmarks)
+
def test_should_list_tags_for_unarchived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user(
"otheruser", "otheruser@example.com", "password123"
@@ -96,6 +124,26 @@ class BookmarkIndexViewTestCase(
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
+ def test_should_list_tags_for_bookmarks_matching_bundle(self):
+ visible_bookmarks = self.setup_numbered_bookmarks(
+ 3, with_tags=True, prefix="foo", tag_prefix="foo"
+ )
+ invisible_bookmarks = self.setup_numbered_bookmarks(
+ 3, with_tags=True, prefix="bar", tag_prefix="bar"
+ )
+
+ visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
+ invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
+
+ bundle = self.setup_bundle(search="foo")
+
+ response = self.client.get(
+ reverse("linkding:bookmarks.index") + f"?bundle={bundle.id}"
+ )
+
+ self.assertVisibleTags(response, visible_tags)
+ self.assertInvisibleTags(response, invisible_tags)
+
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
user_profile = self.user.profile
user_profile.search_preferences = {
@@ -494,3 +542,43 @@ class BookmarkIndexViewTestCase(
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNone(feed)
+
+ def test_list_bundles(self):
+ books = self.setup_bundle(name="Books bundle", order=3)
+ music = self.setup_bundle(name="Music bundle", order=1)
+ tools = self.setup_bundle(name="Tools bundle", order=2)
+ response = self.client.get(reverse("linkding:bookmarks.index"))
+ html = response.content.decode()
+ soup = self.make_soup(html)
+
+ self.assertVisibleBundles(soup, [music, tools, books])
+
+ def test_list_bundles_only_shows_user_owned_bundles(self):
+ user_bundles = [self.setup_bundle(), self.setup_bundle(), self.setup_bundle()]
+ other_user = self.setup_user()
+ self.setup_bundle(user=other_user)
+ self.setup_bundle(user=other_user)
+ self.setup_bundle(user=other_user)
+
+ response = self.client.get(reverse("linkding:bookmarks.index"))
+ html = response.content.decode()
+ soup = self.make_soup(html)
+
+ self.assertVisibleBundles(soup, user_bundles)
+
+ def test_hide_bundles_when_enabled_in_profile(self):
+ # visible by default
+ response = self.client.get(reverse("linkding:bookmarks.index"))
+ html = response.content.decode()
+
+ self.assertInHTML('
Bundles
', html)
+
+ # hidden when disabled in profile
+ user_profile = self.get_or_create_test_user().profile
+ user_profile.hide_bundles = True
+ user_profile.save()
+
+ response = self.client.get(reverse("linkding:bookmarks.index"))
+ html = response.content.decode()
+
+ self.assertInHTML('
Bundles
', html, count=0)
diff --git a/bookmarks/tests/test_bookmark_search_form.py b/bookmarks/tests/test_bookmark_search_form.py
index b516b6e..2143617 100644
--- a/bookmarks/tests/test_bookmark_search_form.py
+++ b/bookmarks/tests/test_bookmark_search_form.py
@@ -11,21 +11,25 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
form = BookmarkSearchForm(search)
self.assertEqual(form["q"].initial, "")
self.assertEqual(form["user"].initial, "")
+ self.assertEqual(form["bundle"].initial, None)
self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_DESC)
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_OFF)
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_OFF)
# with params
+ bundle = self.setup_bundle()
search = BookmarkSearch(
q="search query",
sort=BookmarkSearch.SORT_ADDED_ASC,
user="user123",
+ bundle=bundle,
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES,
)
form = BookmarkSearchForm(search)
self.assertEqual(form["q"].initial, "search query")
self.assertEqual(form["user"].initial, "user123")
+ self.assertEqual(form["bundle"].initial, bundle.id)
self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_ASC)
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_SHARED)
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_YES)
@@ -61,17 +65,26 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
self.assertCountEqual(form.hidden_fields(), [form["q"], form["sort"]])
# all modified params
+ bundle = self.setup_bundle()
search = BookmarkSearch(
q="search query",
sort=BookmarkSearch.SORT_ADDED_ASC,
user="user123",
+ bundle=bundle,
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES,
)
form = BookmarkSearchForm(search)
self.assertCountEqual(
form.hidden_fields(),
- [form["q"], form["sort"], form["user"], form["shared"], form["unread"]],
+ [
+ form["q"],
+ form["sort"],
+ form["user"],
+ form["bundle"],
+ form["shared"],
+ form["unread"],
+ ],
)
# some modified params are editable fields
diff --git a/bookmarks/tests/test_bookmark_search_model.py b/bookmarks/tests/test_bookmark_search_model.py
index 69caddf..a2bb8c7 100644
--- a/bookmarks/tests/test_bookmark_search_model.py
+++ b/bookmarks/tests/test_bookmark_search_model.py
@@ -2,16 +2,23 @@ from django.http import QueryDict
from django.test import TestCase
from bookmarks.models import BookmarkSearch
+from bookmarks.tests.helpers import BookmarkFactoryMixin
-class BookmarkSearchModelTest(TestCase):
+class MockRequest:
+ def __init__(self, user):
+ self.user = user
+
+
+class BookmarkSearchModelTest(TestCase, BookmarkFactoryMixin):
def test_from_request(self):
# no params
query_dict = QueryDict()
- search = BookmarkSearch.from_request(query_dict)
+ search = BookmarkSearch.from_request(None, query_dict)
self.assertEqual(search.q, "")
self.assertEqual(search.user, "")
+ self.assertEqual(search.bundle, None)
self.assertEqual(search.sort, BookmarkSearch.SORT_ADDED_DESC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
@@ -19,7 +26,7 @@ class BookmarkSearchModelTest(TestCase):
# some params
query_dict = QueryDict("q=search query&user=user123")
- bookmark_search = BookmarkSearch.from_request(query_dict)
+ bookmark_search = BookmarkSearch.from_request(None, query_dict)
self.assertEqual(bookmark_search.q, "search query")
self.assertEqual(bookmark_search.user, "user123")
self.assertEqual(bookmark_search.sort, BookmarkSearch.SORT_ADDED_DESC)
@@ -27,13 +34,16 @@ class BookmarkSearchModelTest(TestCase):
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
# all params
+ bundle = self.setup_bundle()
+ request = MockRequest(self.get_or_create_test_user())
query_dict = QueryDict(
- "q=search query&sort=title_asc&user=user123&shared=yes&unread=yes"
+ f"q=search query&sort=title_asc&user=user123&bundle={bundle.id}&shared=yes&unread=yes"
)
- search = BookmarkSearch.from_request(query_dict)
+ search = BookmarkSearch.from_request(request, query_dict)
self.assertEqual(search.q, "search query")
self.assertEqual(search.user, "user123")
+ self.assertEqual(search.bundle, bundle)
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_SHARED)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_YES)
@@ -45,7 +55,7 @@ class BookmarkSearchModelTest(TestCase):
}
query_dict = QueryDict("q=search query")
- search = BookmarkSearch.from_request(query_dict, preferences)
+ search = BookmarkSearch.from_request(None, query_dict, preferences)
self.assertEqual(search.q, "search query")
self.assertEqual(search.user, "")
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
@@ -60,13 +70,110 @@ class BookmarkSearchModelTest(TestCase):
}
query_dict = QueryDict("sort=title_desc&shared=no&unread=off")
- search = BookmarkSearch.from_request(query_dict, preferences)
+ search = BookmarkSearch.from_request(None, query_dict, preferences)
self.assertEqual(search.q, "")
self.assertEqual(search.user, "")
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_DESC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_UNSHARED)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
+ def test_from_request_ignores_invalid_bundle_param(self):
+ self.setup_bundle()
+
+ # bundle does not exist
+ request = MockRequest(self.get_or_create_test_user())
+ query_dict = QueryDict("bundle=99999")
+ search = BookmarkSearch.from_request(request, query_dict)
+ self.assertIsNone(search.bundle)
+
+ # bundle belongs to another user
+ other_user = self.setup_user()
+ bundle = self.setup_bundle(user=other_user)
+ query_dict = QueryDict(f"bundle={bundle.id}")
+ search = BookmarkSearch.from_request(request, query_dict)
+ self.assertIsNone(search.bundle)
+
+ def test_query_params(self):
+ # no params
+ search = BookmarkSearch()
+ self.assertEqual(search.query_params, {})
+
+ # params are default values
+ search = BookmarkSearch(
+ q="", sort=BookmarkSearch.SORT_ADDED_DESC, user="", bundle=None, shared=""
+ )
+ self.assertEqual(search.query_params, {})
+
+ # some modified params
+ search = BookmarkSearch(q="search query", sort=BookmarkSearch.SORT_ADDED_ASC)
+ self.assertEqual(
+ search.query_params,
+ {"q": "search query", "sort": BookmarkSearch.SORT_ADDED_ASC},
+ )
+
+ # all modified params
+ bundle = self.setup_bundle()
+ search = BookmarkSearch(
+ q="search query",
+ sort=BookmarkSearch.SORT_ADDED_ASC,
+ user="user123",
+ bundle=bundle,
+ shared=BookmarkSearch.FILTER_SHARED_SHARED,
+ unread=BookmarkSearch.FILTER_UNREAD_YES,
+ )
+ self.assertEqual(
+ search.query_params,
+ {
+ "q": "search query",
+ "sort": BookmarkSearch.SORT_ADDED_ASC,
+ "user": "user123",
+ "bundle": bundle.id,
+ "shared": BookmarkSearch.FILTER_SHARED_SHARED,
+ "unread": BookmarkSearch.FILTER_UNREAD_YES,
+ },
+ )
+
+ # preferences are not query params if they match default
+ preferences = {
+ "sort": BookmarkSearch.SORT_TITLE_ASC,
+ "unread": BookmarkSearch.FILTER_UNREAD_YES,
+ }
+ search = BookmarkSearch(preferences=preferences)
+ self.assertEqual(search.query_params, {})
+
+ # param is not a query param if it matches the preference
+ preferences = {
+ "sort": BookmarkSearch.SORT_TITLE_ASC,
+ "unread": BookmarkSearch.FILTER_UNREAD_YES,
+ }
+ search = BookmarkSearch(
+ sort=BookmarkSearch.SORT_TITLE_ASC,
+ unread=BookmarkSearch.FILTER_UNREAD_YES,
+ preferences=preferences,
+ )
+ self.assertEqual(search.query_params, {})
+
+ # overriding preferences is a query param
+ preferences = {
+ "sort": BookmarkSearch.SORT_TITLE_ASC,
+ "shared": BookmarkSearch.FILTER_SHARED_SHARED,
+ "unread": BookmarkSearch.FILTER_UNREAD_YES,
+ }
+ search = BookmarkSearch(
+ sort=BookmarkSearch.SORT_TITLE_DESC,
+ shared=BookmarkSearch.FILTER_SHARED_UNSHARED,
+ unread=BookmarkSearch.FILTER_UNREAD_OFF,
+ preferences=preferences,
+ )
+ self.assertEqual(
+ search.query_params,
+ {
+ "sort": BookmarkSearch.SORT_TITLE_DESC,
+ "shared": BookmarkSearch.FILTER_SHARED_UNSHARED,
+ "unread": BookmarkSearch.FILTER_UNREAD_OFF,
+ },
+ )
+
def test_modified_params(self):
# no params
bookmark_search = BookmarkSearch()
@@ -88,16 +195,18 @@ class BookmarkSearchModelTest(TestCase):
self.assertCountEqual(modified_params, ["q", "sort"])
# all modified params
+ bundle = self.setup_bundle()
bookmark_search = BookmarkSearch(
q="search query",
sort=BookmarkSearch.SORT_ADDED_ASC,
user="user123",
+ bundle=bundle,
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES,
)
modified_params = bookmark_search.modified_params
self.assertCountEqual(
- modified_params, ["q", "sort", "user", "shared", "unread"]
+ modified_params, ["q", "sort", "user", "bundle", "shared", "unread"]
)
# preferences are not modified params
@@ -180,7 +289,10 @@ class BookmarkSearchModelTest(TestCase):
)
# only returns preferences
- bookmark_search = BookmarkSearch(q="search query", user="user123")
+ bundle = self.setup_bundle()
+ bookmark_search = BookmarkSearch(
+ q="search query", user="user123", bundle=bundle
+ )
self.assertEqual(
bookmark_search.preferences_dict,
{
diff --git a/bookmarks/tests/test_bookmark_search_tag.py b/bookmarks/tests/test_bookmark_search_tag.py
index eb77675..3ab8a9a 100644
--- a/bookmarks/tests/test_bookmark_search_tag.py
+++ b/bookmarks/tests/test_bookmark_search_tag.py
@@ -12,7 +12,7 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
request = rf.get(url)
request.user = self.get_or_create_test_user()
request.user_profile = self.get_or_create_test_user().profile
- search = BookmarkSearch.from_request(request.GET)
+ search = BookmarkSearch.from_request(request, request.GET)
context = RequestContext(
request,
{
diff --git a/bookmarks/tests/test_bookmark_shared_view.py b/bookmarks/tests/test_bookmark_shared_view.py
index e7746c6..f512ccd 100644
--- a/bookmarks/tests/test_bookmark_shared_view.py
+++ b/bookmarks/tests/test_bookmark_shared_view.py
@@ -114,6 +114,24 @@ class BookmarkSharedViewTestCase(
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
+ def test_should_list_bookmarks_matching_bundle(self):
+ self.authenticate()
+ user = self.setup_user(enable_sharing=True)
+
+ visible_bookmarks = self.setup_numbered_bookmarks(
+ 3, shared=True, user=user, prefix="foo"
+ )
+ invisible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user)
+
+ bundle = self.setup_bundle(search="foo")
+
+ response = self.client.get(
+ reverse("linkding:bookmarks.shared") + f"?bundle={bundle.id}"
+ )
+
+ self.assertVisibleBookmarks(response, visible_bookmarks)
+ self.assertInvisibleBookmarks(response, invisible_bookmarks)
+
def test_should_list_only_publicly_shared_bookmarks_without_login(self):
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
user2 = self.setup_user(enable_sharing=True)
@@ -224,6 +242,45 @@ class BookmarkSharedViewTestCase(
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
+ def test_should_list_tags_for_bookmarks_matching_bundle(self):
+ self.authenticate()
+ user1 = self.setup_user(enable_sharing=True)
+ user2 = self.setup_user(enable_sharing=True)
+ user3 = self.setup_user(enable_sharing=True)
+ visible_tags = [
+ self.setup_tag(user=user1),
+ self.setup_tag(user=user2),
+ self.setup_tag(user=user3),
+ ]
+ invisible_tags = [
+ self.setup_tag(user=user1),
+ self.setup_tag(user=user2),
+ self.setup_tag(user=user3),
+ ]
+
+ self.setup_bookmark(
+ shared=True, user=user1, title="searchvalue", tags=[visible_tags[0]]
+ )
+ self.setup_bookmark(
+ shared=True, user=user2, title="searchvalue", tags=[visible_tags[1]]
+ )
+ self.setup_bookmark(
+ shared=True, user=user3, title="searchvalue", tags=[visible_tags[2]]
+ )
+
+ self.setup_bookmark(shared=True, user=user1, tags=[invisible_tags[0]])
+ self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])
+ self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[2]])
+
+ bundle = self.setup_bundle(search="searchvalue")
+
+ response = self.client.get(
+ reverse("linkding:bookmarks.shared") + f"?bundle={bundle.id}"
+ )
+
+ self.assertVisibleTags(response, visible_tags)
+ self.assertInvisibleTags(response, invisible_tags)
+
def test_should_list_only_tags_for_publicly_shared_bookmarks_without_login(self):
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
user2 = self.setup_user(enable_sharing=True)
diff --git a/bookmarks/tests/test_bookmarks_api.py b/bookmarks/tests/test_bookmarks_api.py
index 68a4510..5f22d03 100644
--- a/bookmarks/tests/test_bookmarks_api.py
+++ b/bookmarks/tests/test_bookmarks_api.py
@@ -143,6 +143,19 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
)
self.assertBookmarkListEqual(response.data["results"], bookmarks)
+ def test_list_bookmarks_should_filter_by_bundle(self):
+ self.authenticate()
+ search_value = self.get_random_string()
+ bookmarks = self.setup_numbered_bookmarks(5, prefix=search_value)
+ self.setup_numbered_bookmarks(5)
+ bundle = self.setup_bundle(search=search_value)
+
+ response = self.get(
+ reverse("linkding:bookmark-list") + f"?bundle={bundle.id}",
+ expected_status_code=status.HTTP_200_OK,
+ )
+ self.assertBookmarkListEqual(response.data["results"], bookmarks)
+
def test_list_bookmarks_filter_unread(self):
self.authenticate()
unread_bookmarks = self.setup_numbered_bookmarks(5, unread=True)
@@ -250,6 +263,21 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
)
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
+ def test_list_archived_bookmarks_should_filter_by_bundle(self):
+ self.authenticate()
+ search_value = self.get_random_string()
+ archived_bookmarks = self.setup_numbered_bookmarks(
+ 5, archived=True, prefix=search_value
+ )
+ self.setup_numbered_bookmarks(5, archived=True)
+ bundle = self.setup_bundle(search=search_value)
+
+ response = self.get(
+ reverse("linkding:bookmark-archived") + f"?bundle={bundle.id}",
+ expected_status_code=status.HTTP_200_OK,
+ )
+ self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
+
def test_list_archived_bookmarks_should_respect_sort(self):
self.authenticate()
bookmarks = self.setup_numbered_bookmarks(5, archived=True)
diff --git a/bookmarks/tests/test_bookmarks_list_template.py b/bookmarks/tests/test_bookmarks_list_template.py
index 802ae45..cf5d60d 100644
--- a/bookmarks/tests/test_bookmarks_list_template.py
+++ b/bookmarks/tests/test_bookmarks_list_template.py
@@ -10,7 +10,7 @@ from django.urls import reverse
from django.utils import timezone, formats
from bookmarks.middlewares import LinkdingMiddleware
-from bookmarks.models import Bookmark, UserProfile, User
+from bookmarks.models import Bookmark, BookmarkSearch, UserProfile, User
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from bookmarks.views import contexts
@@ -46,7 +46,6 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
title="View snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
{label_content}
-
|
""",
html,
)
@@ -266,6 +265,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
contexts.BookmarkListContext
] = contexts.ActiveBookmarkListContext,
user: User | AnonymousUser = None,
+ is_preview: bool = False,
) -> str:
rf = RequestFactory()
request = rf.get(url)
@@ -273,7 +273,10 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
middleware = LinkdingMiddleware(lambda r: HttpResponse())
middleware(request)
- bookmark_list_context = context_type(request)
+ search = BookmarkSearch.from_request(request, request.GET)
+ bookmark_list_context = context_type(request, search)
+ if is_preview:
+ bookmark_list_context.is_preview = True
context = RequestContext(request, {"bookmark_list": bookmark_list_context})
template = Template("{% include 'bookmarks/bookmark_list.html' %}")
@@ -1047,3 +1050,21 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
soup = self.make_soup(html)
bookmarks = soup.select("li[ld-bookmark-item]")
self.assertEqual(10, len(bookmarks))
+
+ def test_no_actions_rendered_when_is_preview(self):
+ bookmark = self.setup_bookmark()
+ bookmark.date_added = timezone.now() - relativedelta(days=8)
+ bookmark.web_archive_snapshot_url = "https://example.com"
+ bookmark.save()
+
+ html = self.render_template(is_preview=True)
+
+ # Verify no actions are rendered
+ self.assertNoViewLink(html, bookmark)
+ self.assertNoBookmarkActions(html, bookmark)
+ self.assertMarkAsReadButton(html, bookmark, count=0)
+ self.assertUnshareButton(html, bookmark, count=0)
+ self.assertNotesToggle(html, count=0)
+
+ # But date should still be rendered
+ self.assertWebArchiveLink(html, "1 week ago", bookmark.web_archive_snapshot_url)
diff --git a/bookmarks/tests/test_bundles_edit_view.py b/bookmarks/tests/test_bundles_edit_view.py
new file mode 100644
index 0000000..45e5e8a
--- /dev/null
+++ b/bookmarks/tests/test_bundles_edit_view.py
@@ -0,0 +1,122 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
+
+ def setUp(self) -> None:
+ user = self.get_or_create_test_user()
+ self.client.force_login(user)
+
+ def create_form_data(self, overrides=None):
+ if overrides is None:
+ overrides = {}
+ form_data = {
+ "name": "Test Bundle",
+ "search": "test search",
+ "any_tags": "tag1 tag2",
+ "all_tags": "required-tag",
+ "excluded_tags": "excluded-tag",
+ }
+ return {**form_data, **overrides}
+
+ def test_should_edit_bundle(self):
+ bundle = self.setup_bundle()
+
+ updated_data = self.create_form_data()
+
+ response = self.client.post(
+ reverse("linkding:bundles.edit", args=[bundle.id]), updated_data
+ )
+
+ self.assertRedirects(response, reverse("linkding:bundles.index"))
+
+ 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"])
+
+ def test_should_render_edit_form_with_prefilled_fields(self):
+ bundle = self.setup_bundle(
+ name="Test Bundle",
+ search="test search terms",
+ any_tags="tag1 tag2 tag3",
+ all_tags="required-tag all-tag",
+ excluded_tags="excluded-tag banned-tag",
+ )
+
+ response = self.client.get(reverse("linkding:bundles.edit", args=[bundle.id]))
+
+ self.assertEqual(response.status_code, 200)
+ html = response.content.decode()
+
+ self.assertInHTML(
+ f'
',
+ html,
+ )
+
+ self.assertInHTML(
+ f'
',
+ html,
+ )
+
+ self.assertInHTML(
+ f'
',
+ html,
+ )
+
+ self.assertInHTML(
+ f'
',
+ html,
+ )
+
+ self.assertInHTML(
+ f'
',
+ html,
+ )
+
+ def test_should_return_422_with_invalid_form(self):
+ bundle = self.setup_bundle(
+ name="Test Bundle",
+ search="test search",
+ any_tags="tag1 tag2",
+ all_tags="required-tag",
+ excluded_tags="excluded-tag",
+ )
+
+ invalid_data = self.create_form_data({"name": ""})
+
+ response = self.client.post(
+ reverse("linkding:bundles.edit", args=[bundle.id]), invalid_data
+ )
+
+ self.assertEqual(response.status_code, 422)
+
+ def test_should_not_allow_editing_other_users_bundles(self):
+ other_user = self.setup_user(name="otheruser")
+ other_users_bundle = self.setup_bundle(user=other_user)
+
+ response = self.client.get(
+ reverse("linkding:bundles.edit", args=[other_users_bundle.id])
+ )
+ self.assertEqual(response.status_code, 404)
+
+ updated_data = self.create_form_data()
+ response = self.client.post(
+ reverse("linkding:bundles.edit", args=[other_users_bundle.id]), updated_data
+ )
+ self.assertEqual(response.status_code, 404)
diff --git a/bookmarks/tests/test_bundles_index_view.py b/bookmarks/tests/test_bundles_index_view.py
new file mode 100644
index 0000000..57d19f3
--- /dev/null
+++ b/bookmarks/tests/test_bundles_index_view.py
@@ -0,0 +1,198 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.models import BookmarkBundle
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class BundleIndexViewTestCase(TestCase, BookmarkFactoryMixin):
+
+ def setUp(self) -> None:
+ user = self.get_or_create_test_user()
+ self.client.force_login(user)
+
+ def test_render_bundle_list(self):
+ bundles = [
+ self.setup_bundle(name="Bundle 1"),
+ self.setup_bundle(name="Bundle 2"),
+ self.setup_bundle(name="Bundle 3"),
+ ]
+
+ response = self.client.get(reverse("linkding:bundles.index"))
+
+ self.assertEqual(response.status_code, 200)
+ html = response.content.decode()
+
+ for bundle in bundles:
+ expected_list_item = f"""
+
+
+
+ {bundle.name}
+
+
+
+ """
+
+ self.assertInHTML(expected_list_item, html)
+
+ def test_renders_user_owned_bundles_only(self):
+ user_bundle = self.setup_bundle(name="User Bundle")
+
+ other_user = self.setup_user(name="otheruser")
+ other_user_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
+
+ response = self.client.get(reverse("linkding:bundles.index"))
+
+ self.assertEqual(response.status_code, 200)
+ html = response.content.decode()
+
+ self.assertInHTML(f'
{user_bundle.name}', html)
+ self.assertNotIn(other_user_bundle.name, html)
+
+ def test_empty_state(self):
+ response = self.client.get(reverse("linkding:bundles.index"))
+
+ self.assertEqual(response.status_code, 200)
+ html = response.content.decode()
+
+ self.assertInHTML('
You have no bundles yet
', html)
+ self.assertInHTML(
+ '
Create your first bundle to get started
',
+ html,
+ )
+
+ def test_add_new_button(self):
+ response = self.client.get(reverse("linkding:bundles.index"))
+
+ self.assertEqual(response.status_code, 200)
+ html = response.content.decode()
+
+ self.assertInHTML(
+ f'
Add new bundle',
+ html,
+ )
+
+ def test_remove_bundle(self):
+ bundle = self.setup_bundle(name="Test Bundle")
+
+ response = self.client.post(
+ reverse("linkding:bundles.action"),
+ {"remove_bundle": str(bundle.id)},
+ )
+
+ self.assertEqual(response.status_code, 302)
+ self.assertRedirects(response, reverse("linkding:bundles.index"))
+
+ self.assertFalse(BookmarkBundle.objects.filter(id=bundle.id).exists())
+
+ def test_remove_other_user_bundle(self):
+ other_user = self.setup_user(name="otheruser")
+ other_user_bundle = self.setup_bundle(name="Other User Bundle", user=other_user)
+
+ response = self.client.post(
+ reverse("linkding:bundles.action"),
+ {"remove_bundle": str(other_user_bundle.id)},
+ )
+
+ self.assertEqual(response.status_code, 404)
+ self.assertTrue(BookmarkBundle.objects.filter(id=other_user_bundle.id).exists())
+
+ def assertBundleOrder(self, expected_bundles, user=None):
+ if user is None:
+ user = self.user
+ actual_bundles = BookmarkBundle.objects.filter(owner=user).order_by("order")
+ self.assertEqual(len(actual_bundles), len(expected_bundles))
+ for i, bundle in enumerate(expected_bundles):
+ self.assertEqual(actual_bundles[i].id, bundle.id)
+ self.assertEqual(actual_bundles[i].order, i)
+
+ def move_bundle(self, bundle: BookmarkBundle, position: int):
+ return self.client.post(
+ reverse("linkding:bundles.action"),
+ {"move_bundle": str(bundle.id), "move_position": position},
+ )
+
+ def test_move_bundle(self):
+ bundle1 = self.setup_bundle(name="Bundle 1", order=0)
+ bundle2 = self.setup_bundle(name="Bundle 2", order=1)
+ bundle3 = self.setup_bundle(name="Bundle 3", order=2)
+
+ self.move_bundle(bundle1, 1)
+ self.assertBundleOrder([bundle2, bundle1, bundle3])
+
+ self.move_bundle(bundle1, 0)
+ self.assertBundleOrder([bundle1, bundle2, bundle3])
+
+ self.move_bundle(bundle1, 2)
+ self.assertBundleOrder([bundle2, bundle3, bundle1])
+
+ self.move_bundle(bundle1, 2)
+ self.assertBundleOrder([bundle2, bundle3, bundle1])
+
+ def test_move_bundle_response(self):
+ bundle1 = self.setup_bundle(name="Bundle 1", order=0)
+ self.setup_bundle(name="Bundle 2", order=1)
+
+ response = self.move_bundle(bundle1, 1)
+
+ self.assertEqual(response.status_code, 302)
+ self.assertRedirects(response, reverse("linkding:bundles.index"))
+
+ def test_can_only_move_user_owned_bundles(self):
+ other_user = self.setup_user()
+ other_user_bundle1 = self.setup_bundle(user=other_user)
+ self.setup_bundle(user=other_user)
+
+ response = self.move_bundle(other_user_bundle1, 1)
+ self.assertEqual(response.status_code, 404)
+
+ def test_move_bundle_only_affects_own_bundles(self):
+ user_bundle1 = self.setup_bundle(name="User Bundle 1", order=0)
+ user_bundle2 = self.setup_bundle(name="User Bundle 2", order=1)
+
+ other_user = self.setup_user(name="otheruser")
+ other_user_bundle = self.setup_bundle(
+ name="Other User Bundle", user=other_user, order=0
+ )
+
+ # Move user bundle
+ self.move_bundle(user_bundle1, 1)
+ self.assertBundleOrder([user_bundle2, user_bundle1], user=self.user)
+
+ # Check that other user's bundle is unaffected
+ self.assertBundleOrder([other_user_bundle], user=other_user)
+
+ def test_remove_non_existing_bundle(self):
+ non_existent_id = 99999
+
+ response = self.client.post(
+ reverse("linkding:bundles.action"),
+ {"remove_bundle": str(non_existent_id)},
+ )
+
+ self.assertEqual(response.status_code, 404)
+
+ def test_post_without_action(self):
+ bundle = self.setup_bundle(name="Test Bundle")
+
+ response = self.client.post(reverse("linkding:bundles.action"), {})
+
+ self.assertEqual(response.status_code, 302)
+ self.assertRedirects(response, reverse("linkding:bundles.index"))
+
+ self.assertTrue(BookmarkBundle.objects.filter(id=bundle.id).exists())
diff --git a/bookmarks/tests/test_bundles_new_view.py b/bookmarks/tests/test_bundles_new_view.py
new file mode 100644
index 0000000..db39963
--- /dev/null
+++ b/bookmarks/tests/test_bundles_new_view.py
@@ -0,0 +1,77 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.models import BookmarkBundle
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin):
+
+ def setUp(self) -> None:
+ user = self.get_or_create_test_user()
+ self.client.force_login(user)
+
+ def create_form_data(self, overrides=None):
+ if overrides is None:
+ overrides = {}
+ form_data = {
+ "name": "Test Bundle",
+ "search": "test search",
+ "any_tags": "tag1 tag2",
+ "all_tags": "required-tag",
+ "excluded_tags": "excluded-tag",
+ }
+ return {**form_data, **overrides}
+
+ def test_should_create_new_bundle(self):
+ form_data = self.create_form_data()
+
+ response = self.client.post(reverse("linkding:bundles.new"), form_data)
+
+ self.assertEqual(BookmarkBundle.objects.count(), 1)
+
+ bundle = BookmarkBundle.objects.first()
+ self.assertEqual(bundle.owner, self.user)
+ self.assertEqual(bundle.name, form_data["name"])
+ self.assertEqual(bundle.search, form_data["search"])
+ self.assertEqual(bundle.any_tags, form_data["any_tags"])
+ self.assertEqual(bundle.all_tags, form_data["all_tags"])
+ self.assertEqual(bundle.excluded_tags, form_data["excluded_tags"])
+
+ self.assertRedirects(response, reverse("linkding:bundles.index"))
+
+ def test_should_increment_order_for_subsequent_bundles(self):
+ # Create first bundle
+ form_data_1 = self.create_form_data({"name": "Bundle 1"})
+ self.client.post(reverse("linkding:bundles.new"), form_data_1)
+ bundle1 = BookmarkBundle.objects.get(name="Bundle 1")
+ self.assertEqual(bundle1.order, 0)
+
+ # Create second bundle
+ form_data_2 = self.create_form_data({"name": "Bundle 2"})
+ self.client.post(reverse("linkding:bundles.new"), form_data_2)
+ bundle2 = BookmarkBundle.objects.get(name="Bundle 2")
+ self.assertEqual(bundle2.order, 1)
+
+ # Create another bundle with a higher order
+ self.setup_bundle(order=5)
+
+ # Create third bundle
+ form_data_3 = self.create_form_data({"name": "Bundle 3"})
+ self.client.post(reverse("linkding:bundles.new"), form_data_3)
+ bundle3 = BookmarkBundle.objects.get(name="Bundle 3")
+ self.assertEqual(bundle3.order, 6)
+
+ def test_incrementing_order_ignores_other_user_bookmark(self):
+ other_user = self.setup_user()
+ self.setup_bundle(user=other_user, order=10)
+
+ form_data = self.create_form_data({"name": "Bundle 1"})
+ self.client.post(reverse("linkding:bundles.new"), form_data)
+ bundle1 = BookmarkBundle.objects.get(name="Bundle 1")
+ self.assertEqual(bundle1.order, 0)
+
+ def test_should_return_422_with_invalid_form(self):
+ form_data = self.create_form_data({"name": ""})
+ response = self.client.post(reverse("linkding:bundles.new"), form_data)
+ self.assertEqual(response.status_code, 422)
diff --git a/bookmarks/tests/test_bundles_preview_view.py b/bookmarks/tests/test_bundles_preview_view.py
new file mode 100644
index 0000000..3eb6a0a
--- /dev/null
+++ b/bookmarks/tests/test_bundles_preview_view.py
@@ -0,0 +1,116 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
+
+
+class BundlePreviewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
+
+ def setUp(self) -> None:
+ user = self.get_or_create_test_user()
+ self.client.force_login(user)
+
+ def test_preview_empty_bundle(self):
+ bookmark1 = self.setup_bookmark(title="Test Bookmark 1")
+ bookmark2 = self.setup_bookmark(title="Test Bookmark 2")
+
+ response = self.client.get(reverse("linkding:bundles.preview"))
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Found 2 bookmarks matching this bundle")
+ self.assertContains(response, bookmark1.title)
+ self.assertContains(response, bookmark2.title)
+ self.assertNotContains(response, "No bookmarks match the current bundle")
+
+ def test_preview_with_search_terms(self):
+ bookmark1 = self.setup_bookmark(title="Python Programming")
+ bookmark2 = self.setup_bookmark(title="JavaScript Tutorial")
+ bookmark3 = self.setup_bookmark(title="Django Framework")
+
+ response = self.client.get(
+ reverse("linkding:bundles.preview"), {"search": "python"}
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Found 1 bookmarks matching this bundle")
+ self.assertContains(response, bookmark1.title)
+ self.assertNotContains(response, bookmark2.title)
+ self.assertNotContains(response, bookmark3.title)
+
+ def test_preview_no_matching_bookmarks(self):
+ bookmark = self.setup_bookmark(title="Python Guide")
+
+ response = self.client.get(
+ reverse("linkding:bundles.preview"), {"search": "nonexistent"}
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "No bookmarks match the current bundle")
+ self.assertNotContains(response, bookmark.title)
+
+ def test_preview_renders_bookmark(self):
+ tag = self.setup_tag(name="test-tag")
+ bookmark = self.setup_bookmark(
+ title="Test Bookmark",
+ description="Test description",
+ url="https://example.com/test",
+ tags=[tag],
+ )
+
+ response = self.client.get(reverse("linkding:bundles.preview"))
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, bookmark.title)
+ self.assertContains(response, bookmark.description)
+ self.assertContains(response, bookmark.url)
+ self.assertContains(response, "#test-tag")
+
+ def test_preview_renders_bookmark_in_preview_mode(self):
+ tag = self.setup_tag(name="test-tag")
+ self.setup_bookmark(
+ title="Test Bookmark",
+ description="Test description",
+ url="https://example.com/test",
+ tags=[tag],
+ )
+
+ response = self.client.get(reverse("linkding:bundles.preview"))
+ soup = self.make_soup(response.content.decode())
+
+ list_item = soup.select_one("li[ld-bookmark-item]")
+ actions = list_item.select(".actions > *")
+ self.assertEqual(len(actions), 1)
+
+ def test_preview_ignores_archived_bookmarks(self):
+ active_bookmark = self.setup_bookmark(title="Active Bookmark")
+ archived_bookmark = self.setup_bookmark(
+ title="Archived Bookmark", is_archived=True
+ )
+
+ response = self.client.get(reverse("linkding:bundles.preview"))
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Found 1 bookmarks matching this bundle")
+ self.assertContains(response, active_bookmark.title)
+ self.assertNotContains(response, archived_bookmark.title)
+
+ def test_preview_requires_authentication(self):
+ self.client.logout()
+
+ response = self.client.get(reverse("linkding:bundles.preview"), follow=True)
+
+ self.assertRedirects(
+ response, f"/login/?next={reverse('linkding:bundles.preview')}"
+ )
+
+ def test_preview_only_shows_user_bookmarks(self):
+ other_user = self.setup_user()
+ own_bookmark = self.setup_bookmark(title="Own Bookmark")
+ other_bookmark = self.setup_bookmark(title="Other Bookmark", user=other_user)
+
+ response = self.client.get(reverse("linkding:bundles.preview"))
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Found 1 bookmarks matching this bundle")
+ self.assertContains(response, own_bookmark.title)
+ self.assertNotContains(response, other_bookmark.title)
diff --git a/bookmarks/tests/test_pagination_tag.py b/bookmarks/tests/test_pagination_tag.py
index 19ec728..ce4530d 100644
--- a/bookmarks/tests/test_pagination_tag.py
+++ b/bookmarks/tests/test_pagination_tag.py
@@ -32,7 +32,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
)
def assertPrevLink(self, html: str, page_number: int, href: str = None):
- href = href if href else "?page={0}".format(page_number)
+ href = href if href else "http://testserver/test?page={0}".format(page_number)
self.assertInHTML(
"""
@@ -55,7 +55,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
)
def assertNextLink(self, html: str, page_number: int, href: str = None):
- href = href if href else "?page={0}".format(page_number)
+ href = href if href else "http://testserver/test?page={0}".format(page_number)
self.assertInHTML(
"""
@@ -76,7 +76,7 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
href: str = None,
):
active_class = "active" if active else ""
- href = href if href else "?page={0}".format(page_number)
+ href = href if href else "http://testserver/test?page={0}".format(page_number)
self.assertInHTML(
"""
@@ -164,20 +164,38 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
rendered_template = self.render_template(
100, 10, 2, url="/test?q=cake&sort=title_asc&page=2"
)
- self.assertPrevLink(rendered_template, 1, href="?q=cake&sort=title_asc&page=1")
- self.assertPageLink(
- rendered_template, 1, False, href="?q=cake&sort=title_asc&page=1"
+ self.assertPrevLink(
+ rendered_template,
+ 1,
+ href="http://testserver/test?q=cake&sort=title_asc&page=1",
)
self.assertPageLink(
- rendered_template, 2, True, href="?q=cake&sort=title_asc&page=2"
+ rendered_template,
+ 1,
+ False,
+ href="http://testserver/test?q=cake&sort=title_asc&page=1",
+ )
+ self.assertPageLink(
+ rendered_template,
+ 2,
+ True,
+ href="http://testserver/test?q=cake&sort=title_asc&page=2",
+ )
+ self.assertNextLink(
+ rendered_template,
+ 3,
+ href="http://testserver/test?q=cake&sort=title_asc&page=3",
)
- self.assertNextLink(rendered_template, 3, href="?q=cake&sort=title_asc&page=3")
def test_removes_details_parameter(self):
rendered_template = self.render_template(
100, 10, 2, url="/test?details=1&page=2"
)
- self.assertPrevLink(rendered_template, 1, href="?page=1")
- self.assertPageLink(rendered_template, 1, False, href="?page=1")
- self.assertPageLink(rendered_template, 2, True, href="?page=2")
- self.assertNextLink(rendered_template, 3, href="?page=3")
+ self.assertPrevLink(rendered_template, 1, href="http://testserver/test?page=1")
+ self.assertPageLink(
+ rendered_template, 1, False, href="http://testserver/test?page=1"
+ )
+ self.assertPageLink(
+ rendered_template, 2, True, href="http://testserver/test?page=2"
+ )
+ self.assertNextLink(rendered_template, 3, href="http://testserver/test?page=3")
diff --git a/bookmarks/tests/test_queries.py b/bookmarks/tests/test_queries.py
index 01e2d0c..7516aa7 100644
--- a/bookmarks/tests/test_queries.py
+++ b/bookmarks/tests/test_queries.py
@@ -153,7 +153,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(tags=[tag1, tag2, self.setup_tag()]),
]
- def assertQueryResult(self, query: QuerySet, item_lists: [[any]]):
+ def assertQueryResult(self, query: QuerySet, item_lists: list[list]):
expected_items = []
for item_list in item_lists:
expected_items = expected_items + item_list
@@ -1287,3 +1287,267 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
search = BookmarkSearch(added_since="invalid-date")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [older_bookmark, recent_bookmark])
+
+ def test_query_bookmarks_with_bundle_search_terms(self):
+ bundle = self.setup_bundle(search="search_term_A search_term_B")
+
+ matching_bookmarks = [
+ self.setup_bookmark(
+ title="search_term_A content", description="search_term_B also here"
+ ),
+ self.setup_bookmark(url="http://example.com/search_term_A/search_term_B"),
+ ]
+
+ # Bookmarks that should not match
+ self.setup_bookmark(title="search_term_A only")
+ self.setup_bookmark(description="search_term_B only")
+ self.setup_bookmark(title="unrelated content")
+
+ query = queries.query_bookmarks(
+ self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
+ )
+ self.assertQueryResult(query, [matching_bookmarks])
+
+ def test_query_bookmarks_with_search_and_bundle_search_terms(self):
+ bundle = self.setup_bundle(search="bundle_term_B")
+ search = BookmarkSearch(q="search_term_A", bundle=bundle)
+
+ matching_bookmarks = [
+ self.setup_bookmark(
+ title="search_term_A content", description="bundle_term_B also here"
+ )
+ ]
+
+ # Bookmarks that should not match
+ self.setup_bookmark(title="search_term_A only")
+ self.setup_bookmark(description="bundle_term_B only")
+ self.setup_bookmark(title="unrelated content")
+
+ query = queries.query_bookmarks(self.user, self.profile, search)
+ self.assertQueryResult(query, [matching_bookmarks])
+
+ def test_query_bookmarks_with_bundle_any_tags(self):
+ bundle = self.setup_bundle(any_tags="bundleTag1 bundleTag2")
+
+ tag1 = self.setup_tag(name="bundleTag1")
+ tag2 = self.setup_tag(name="bundleTag2")
+ other_tag = self.setup_tag(name="otherTag")
+
+ matching_bookmarks = [
+ self.setup_bookmark(tags=[tag1]),
+ self.setup_bookmark(tags=[tag2]),
+ self.setup_bookmark(tags=[tag1, tag2]),
+ ]
+
+ # Bookmarks that should not match
+ self.setup_bookmark(tags=[other_tag])
+ self.setup_bookmark()
+
+ query = queries.query_bookmarks(
+ self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
+ )
+ self.assertQueryResult(query, [matching_bookmarks])
+
+ def test_query_bookmarks_with_search_tags_and_bundle_any_tags(self):
+ bundle = self.setup_bundle(any_tags="bundleTagA bundleTagB")
+ search = BookmarkSearch(q="#searchTag1 #searchTag2", bundle=bundle)
+
+ search_tag1 = self.setup_tag(name="searchTag1")
+ search_tag2 = self.setup_tag(name="searchTag2")
+ bundle_tag_a = self.setup_tag(name="bundleTagA")
+ bundle_tag_b = self.setup_tag(name="bundleTagB")
+ other_tag = self.setup_tag(name="otherTag")
+
+ matching_bookmarks = [
+ self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_a]),
+ self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_b]),
+ self.setup_bookmark(
+ tags=[search_tag1, search_tag2, bundle_tag_a, bundle_tag_b]
+ ),
+ ]
+
+ # Bookmarks that should not match
+ self.setup_bookmark(tags=[search_tag1, search_tag2, other_tag])
+ self.setup_bookmark(tags=[search_tag1, search_tag2])
+ self.setup_bookmark(tags=[search_tag1, bundle_tag_a])
+ self.setup_bookmark(tags=[search_tag2, bundle_tag_b])
+ self.setup_bookmark(tags=[bundle_tag_a])
+ self.setup_bookmark(tags=[bundle_tag_b])
+ self.setup_bookmark(tags=[bundle_tag_a, bundle_tag_b])
+ self.setup_bookmark(tags=[other_tag])
+ self.setup_bookmark()
+
+ query = queries.query_bookmarks(self.user, self.profile, search)
+ self.assertQueryResult(query, [matching_bookmarks])
+
+ def test_query_bookmarks_with_bundle_all_tags(self):
+ bundle = self.setup_bundle(all_tags="bundleTag1 bundleTag2")
+
+ tag1 = self.setup_tag(name="bundleTag1")
+ tag2 = self.setup_tag(name="bundleTag2")
+ other_tag = self.setup_tag(name="otherTag")
+
+ matching_bookmarks = [self.setup_bookmark(tags=[tag1, tag2])]
+
+ # Bookmarks that should not match
+ self.setup_bookmark(tags=[tag1])
+ self.setup_bookmark(tags=[tag2])
+ self.setup_bookmark(tags=[tag1, other_tag])
+ self.setup_bookmark(tags=[other_tag])
+ self.setup_bookmark()
+
+ query = queries.query_bookmarks(
+ self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
+ )
+ self.assertQueryResult(query, [matching_bookmarks])
+
+ def test_query_bookmarks_with_search_tags_and_bundle_all_tags(self):
+ bundle = self.setup_bundle(all_tags="bundleTagA bundleTagB")
+ search = BookmarkSearch(q="#searchTag1 #searchTag2", bundle=bundle)
+
+ search_tag1 = self.setup_tag(name="searchTag1")
+ search_tag2 = self.setup_tag(name="searchTag2")
+ bundle_tag_a = self.setup_tag(name="bundleTagA")
+ bundle_tag_b = self.setup_tag(name="bundleTagB")
+ other_tag = self.setup_tag(name="otherTag")
+
+ matching_bookmarks = [
+ self.setup_bookmark(
+ tags=[search_tag1, search_tag2, bundle_tag_a, bundle_tag_b]
+ )
+ ]
+
+ # Bookmarks that should not match
+ self.setup_bookmark(tags=[search_tag1, search_tag2, bundle_tag_a])
+ self.setup_bookmark(tags=[search_tag1, bundle_tag_a, bundle_tag_b])
+ self.setup_bookmark(tags=[search_tag1, search_tag2])
+ self.setup_bookmark(tags=[bundle_tag_a, bundle_tag_b])
+ self.setup_bookmark(tags=[search_tag1, bundle_tag_a])
+ self.setup_bookmark(tags=[other_tag])
+ self.setup_bookmark()
+
+ query = queries.query_bookmarks(self.user, self.profile, search)
+ self.assertQueryResult(query, [matching_bookmarks])
+
+ def test_query_bookmarks_with_bundle_excluded_tags(self):
+ bundle = self.setup_bundle(excluded_tags="excludeTag1 excludeTag2")
+
+ exclude_tag1 = self.setup_tag(name="excludeTag1")
+ exclude_tag2 = self.setup_tag(name="excludeTag2")
+ keep_tag = self.setup_tag(name="keepTag")
+ keep_other_tag = self.setup_tag(name="keepOtherTag")
+
+ matching_bookmarks = [
+ self.setup_bookmark(tags=[keep_tag]),
+ self.setup_bookmark(tags=[keep_other_tag]),
+ self.setup_bookmark(tags=[keep_tag, keep_other_tag]),
+ self.setup_bookmark(),
+ ]
+
+ # Bookmarks that should not be returned
+ self.setup_bookmark(tags=[exclude_tag1])
+ self.setup_bookmark(tags=[exclude_tag2])
+ self.setup_bookmark(tags=[exclude_tag1, keep_tag])
+ self.setup_bookmark(tags=[exclude_tag2, keep_tag])
+ self.setup_bookmark(tags=[exclude_tag1, exclude_tag2])
+ self.setup_bookmark(tags=[exclude_tag1, exclude_tag2, keep_tag])
+
+ query = queries.query_bookmarks(
+ self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
+ )
+ self.assertQueryResult(query, [matching_bookmarks])
+
+ def test_query_bookmarks_with_bundle_combined_tags(self):
+ bundle = self.setup_bundle(
+ any_tags="anyTagA anyTagB",
+ all_tags="allTag1 allTag2",
+ excluded_tags="excludedTag",
+ )
+
+ any_tag_a = self.setup_tag(name="anyTagA")
+ any_tag_b = self.setup_tag(name="anyTagB")
+ all_tag_1 = self.setup_tag(name="allTag1")
+ all_tag_2 = self.setup_tag(name="allTag2")
+ other_tag = self.setup_tag(name="otherTag")
+ excluded_tag = self.setup_tag(name="excludedTag")
+
+ matching_bookmarks = [
+ self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2]),
+ self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2]),
+ self.setup_bookmark(tags=[any_tag_a, any_tag_b, all_tag_1, all_tag_2]),
+ self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2, other_tag]),
+ self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2, other_tag]),
+ ]
+
+ # Bookmarks that should not match
+ self.setup_bookmark(tags=[any_tag_a, all_tag_1])
+ self.setup_bookmark(tags=[any_tag_b, all_tag_2])
+ self.setup_bookmark(tags=[any_tag_a, any_tag_b, all_tag_1])
+ self.setup_bookmark(tags=[all_tag_1, all_tag_2])
+ self.setup_bookmark(tags=[all_tag_1, all_tag_2, other_tag])
+ self.setup_bookmark(tags=[any_tag_a])
+ self.setup_bookmark(tags=[any_tag_b])
+ self.setup_bookmark(tags=[all_tag_1])
+ self.setup_bookmark(tags=[all_tag_2])
+ self.setup_bookmark(tags=[any_tag_a, all_tag_1, all_tag_2, excluded_tag])
+ self.setup_bookmark(tags=[any_tag_b, all_tag_1, all_tag_2, excluded_tag])
+ self.setup_bookmark(tags=[other_tag])
+ self.setup_bookmark()
+
+ query = queries.query_bookmarks(
+ self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
+ )
+ self.assertQueryResult(query, [matching_bookmarks])
+
+ def test_query_archived_bookmarks_with_bundle(self):
+ bundle = self.setup_bundle(any_tags="bundleTag1 bundleTag2")
+
+ tag1 = self.setup_tag(name="bundleTag1")
+ tag2 = self.setup_tag(name="bundleTag2")
+ other_tag = self.setup_tag(name="otherTag")
+
+ matching_bookmarks = [
+ self.setup_bookmark(is_archived=True, tags=[tag1]),
+ self.setup_bookmark(is_archived=True, tags=[tag2]),
+ self.setup_bookmark(is_archived=True, tags=[tag1, tag2]),
+ ]
+
+ # Bookmarks that should not match
+ self.setup_bookmark(is_archived=True, tags=[other_tag])
+ self.setup_bookmark(is_archived=True)
+ self.setup_bookmark(tags=[tag1]),
+ self.setup_bookmark(tags=[tag2]),
+ self.setup_bookmark(tags=[tag1, tag2]),
+
+ query = queries.query_archived_bookmarks(
+ self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
+ )
+ self.assertQueryResult(query, [matching_bookmarks])
+
+ def test_query_shared_bookmarks_with_bundle(self):
+ user1 = self.setup_user(enable_sharing=True)
+ user2 = self.setup_user(enable_sharing=True)
+
+ bundle = self.setup_bundle(any_tags="bundleTag1 bundleTag2")
+
+ tag1 = self.setup_tag(name="bundleTag1")
+ tag2 = self.setup_tag(name="bundleTag2")
+ other_tag = self.setup_tag(name="otherTag")
+
+ matching_bookmarks = [
+ self.setup_bookmark(user=user1, shared=True, tags=[tag1]),
+ self.setup_bookmark(user=user2, shared=True, tags=[tag2]),
+ self.setup_bookmark(user=user1, shared=True, tags=[tag1, tag2]),
+ ]
+
+ # Bookmarks that should not match
+ self.setup_bookmark(user=user1, shared=True, tags=[other_tag])
+ self.setup_bookmark(user=user2, shared=True)
+ self.setup_bookmark(user=user1, shared=False, tags=[tag1]),
+ self.setup_bookmark(user=user2, shared=False, tags=[tag2]),
+ self.setup_bookmark(user=user1, shared=False, tags=[tag1, tag2]),
+
+ query = queries.query_shared_bookmarks(
+ None, self.profile, BookmarkSearch(q="", bundle=bundle), False
+ )
+ self.assertQueryResult(query, [matching_bookmarks])
diff --git a/bookmarks/tests/test_settings_general_view.py b/bookmarks/tests/test_settings_general_view.py
index a99a04a..ad250bb 100644
--- a/bookmarks/tests/test_settings_general_view.py
+++ b/bookmarks/tests/test_settings_general_view.py
@@ -48,6 +48,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"items_per_page": "30",
"sticky_pagination": False,
"collapse_side_panel": False,
+ "hide_bundles": False,
}
return {**form_data, **overrides}
@@ -119,6 +120,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"items_per_page": "10",
"sticky_pagination": True,
"collapse_side_panel": True,
+ "hide_bundles": True,
}
response = self.client.post(
reverse("linkding:settings.update"), form_data, follow=True
@@ -199,6 +201,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(
self.user.profile.collapse_side_panel, form_data["collapse_side_panel"]
)
+ self.assertEqual(self.user.profile.hide_bundles, form_data["hide_bundles"])
self.assertSuccessMessage(html, "Profile updated")
diff --git a/bookmarks/tests/test_tag_cloud_template.py b/bookmarks/tests/test_tag_cloud_template.py
index 04cad07..5736cc6 100644
--- a/bookmarks/tests/test_tag_cloud_template.py
+++ b/bookmarks/tests/test_tag_cloud_template.py
@@ -6,7 +6,7 @@ from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory
from bookmarks.middlewares import LinkdingMiddleware
-from bookmarks.models import UserProfile
+from bookmarks.models import BookmarkSearch, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
from bookmarks.views import contexts
@@ -24,7 +24,10 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
middleware = LinkdingMiddleware(lambda r: HttpResponse())
middleware(request)
- tag_cloud_context = context_type(request)
+ search = BookmarkSearch.from_request(
+ request, request.GET, request.user_profile.search_preferences
+ )
+ tag_cloud_context = context_type(request, search)
context = RequestContext(request, {"tag_cloud": tag_cloud_context})
template_to_render = Template("{% include 'bookmarks/tag_cloud.html' %}")
return template_to_render.render(context)
diff --git a/bookmarks/tests/test_toasts_view.py b/bookmarks/tests/test_toasts_view.py
index 9d4d93a..4144c8d 100644
--- a/bookmarks/tests/test_toasts_view.py
+++ b/bookmarks/tests/test_toasts_view.py
@@ -38,7 +38,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse("linkding:bookmarks.index"))
# Should render toasts container
- self.assertContains(response, '')
+ self.assertContains(response, '
')
# Should render two toasts
self.assertContains(response, '
', count=2)
@@ -50,7 +50,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse("linkding:bookmarks.index"))
# Should not render toasts container
- self.assertContains(response, '
', count=0)
+ self.assertContains(response, '
', count=0)
# Should not render toasts
self.assertContains(response, '
', count=0)
@@ -66,7 +66,7 @@ class ToastsViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse("linkding:bookmarks.index"))
# Should not render toasts container
- self.assertContains(response, '
', count=0)
+ self.assertContains(response, '
', count=0)
# Should not render toasts
self.assertContains(response, '
', count=0)
diff --git a/bookmarks/tests/test_user_select_tag.py b/bookmarks/tests/test_user_select_tag.py
index ed2980f..94b09c8 100644
--- a/bookmarks/tests/test_user_select_tag.py
+++ b/bookmarks/tests/test_user_select_tag.py
@@ -12,7 +12,7 @@ class UserSelectTagTest(TestCase, BookmarkFactoryMixin):
request = rf.get(url)
request.user = self.get_or_create_test_user()
request.user_profile = self.get_or_create_test_user().profile
- search = BookmarkSearch.from_request(request.GET)
+ search = BookmarkSearch.from_request(request, request.GET)
context = RequestContext(
request,
{
diff --git a/bookmarks/tests_e2e/e2e_test_bundle_preview.py b/bookmarks/tests_e2e/e2e_test_bundle_preview.py
new file mode 100644
index 0000000..44e5b6e
--- /dev/null
+++ b/bookmarks/tests_e2e/e2e_test_bundle_preview.py
@@ -0,0 +1,50 @@
+from django.urls import reverse
+from playwright.sync_api import sync_playwright, expect
+
+from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase
+
+
+class BookmarkItemE2ETestCase(LinkdingE2ETestCase):
+ def test_update_preview_on_filter_changes(self):
+ group1 = self.setup_numbered_bookmarks(3, prefix="foo")
+ group2 = self.setup_numbered_bookmarks(3, prefix="bar")
+
+ with sync_playwright() as p:
+ # shows all bookmarks initially
+ page = self.open(reverse("linkding:bundles.new"), p)
+
+ expect(
+ page.get_by_text(f"Found 6 bookmarks matching this bundle")
+ ).to_be_visible()
+ self.assertVisibleBookmarks(group1 + group2)
+
+ # filter by group1
+ search = page.get_by_label("Search")
+ search.fill("foo")
+
+ expect(
+ page.get_by_text(f"Found 3 bookmarks matching this bundle")
+ ).to_be_visible()
+ self.assertVisibleBookmarks(group1)
+
+ # filter by group2
+ search.fill("bar")
+
+ expect(
+ page.get_by_text(f"Found 3 bookmarks matching this bundle")
+ ).to_be_visible()
+ self.assertVisibleBookmarks(group2)
+
+ # filter by invalid group
+ search.fill("invalid")
+
+ expect(
+ page.get_by_text(f"No bookmarks match the current bundle")
+ ).to_be_visible()
+ self.assertVisibleBookmarks([])
+
+ def assertVisibleBookmarks(self, bookmarks):
+ self.assertEqual(len(bookmarks), self.count_bookmarks())
+
+ for bookmark in bookmarks:
+ expect(self.locate_bookmark(bookmark.title)).to_be_visible()
diff --git a/bookmarks/tests_e2e/helpers.py b/bookmarks/tests_e2e/helpers.py
index 48ce248..cdfce86 100644
--- a/bookmarks/tests_e2e/helpers.py
+++ b/bookmarks/tests_e2e/helpers.py
@@ -49,6 +49,10 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
return bookmark_tags.filter(has_text=title)
+ def count_bookmarks(self):
+ bookmark_tags = self.page.locator("li[ld-bookmark-item]")
+ return bookmark_tags.count()
+
def locate_details_modal(self):
return self.page.locator(".modal.bookmark-details")
diff --git a/bookmarks/urls.py b/bookmarks/urls.py
index be0273a..ff963c9 100644
--- a/bookmarks/urls.py
+++ b/bookmarks/urls.py
@@ -43,6 +43,12 @@ urlpatterns = [
views.assets.read,
name="assets.read",
),
+ # Bundles
+ path("bundles", views.bundles.index, name="bundles.index"),
+ path("bundles/action", views.bundles.action, name="bundles.action"),
+ path("bundles/new", views.bundles.new, name="bundles.new"),
+ path("bundles//edit", views.bundles.edit, name="bundles.edit"),
+ path("bundles/preview", views.bundles.preview, name="bundles.preview"),
# Settings
path("settings", views.settings.general, name="settings.index"),
path("settings/general", views.settings.general, name="settings.general"),
diff --git a/bookmarks/views/__init__.py b/bookmarks/views/__init__.py
index 802057b..397cda5 100644
--- a/bookmarks/views/__init__.py
+++ b/bookmarks/views/__init__.py
@@ -1,6 +1,7 @@
from .assets import *
from .auth import *
from .bookmarks import *
+from . import bundles
from .settings import *
from .toasts import *
from .health import health
diff --git a/bookmarks/views/access.py b/bookmarks/views/access.py
index a22070e..0228d4a 100644
--- a/bookmarks/views/access.py
+++ b/bookmarks/views/access.py
@@ -1,6 +1,6 @@
from django.http import Http404
-from bookmarks.models import Bookmark, BookmarkAsset, Toast
+from bookmarks.models import Bookmark, BookmarkAsset, BookmarkBundle, Toast
from bookmarks.type_defs import HttpRequest
@@ -32,6 +32,13 @@ def bookmark_write(request: HttpRequest, bookmark_id: int | str):
raise Http404("Bookmark does not exist")
+def bundle_write(request: HttpRequest, bundle_id: int | str):
+ try:
+ return BookmarkBundle.objects.get(pk=bundle_id, owner=request.user)
+ except BookmarkBundle.DoesNotExist:
+ raise Http404("Bundle does not exist")
+
+
def asset_read(request: HttpRequest, asset_id: int | str):
try:
asset = BookmarkAsset.objects.get(pk=asset_id)
diff --git a/bookmarks/views/bookmarks.py b/bookmarks/views/bookmarks.py
index b19411f..7b4556c 100644
--- a/bookmarks/views/bookmarks.py
+++ b/bookmarks/views/bookmarks.py
@@ -42,8 +42,12 @@ def index(request: HttpRequest):
if request.method == "POST":
return search_action(request)
- bookmark_list = contexts.ActiveBookmarkListContext(request)
- tag_cloud = contexts.ActiveTagCloudContext(request)
+ search = BookmarkSearch.from_request(
+ request, request.GET, request.user_profile.search_preferences
+ )
+ bookmark_list = contexts.ActiveBookmarkListContext(request, search)
+ bundles = contexts.BundlesContext(request)
+ tag_cloud = contexts.ActiveTagCloudContext(request, search)
bookmark_details = contexts.get_details_context(
request, contexts.ActiveBookmarkDetailsContext
)
@@ -54,6 +58,7 @@ def index(request: HttpRequest):
{
"page_title": "Bookmarks - Linkding",
"bookmark_list": bookmark_list,
+ "bundles": bundles,
"tag_cloud": tag_cloud,
"details": bookmark_details,
},
@@ -65,8 +70,12 @@ def archived(request: HttpRequest):
if request.method == "POST":
return search_action(request)
- bookmark_list = contexts.ArchivedBookmarkListContext(request)
- tag_cloud = contexts.ArchivedTagCloudContext(request)
+ search = BookmarkSearch.from_request(
+ request, request.GET, request.user_profile.search_preferences
+ )
+ bookmark_list = contexts.ArchivedBookmarkListContext(request, search)
+ bundles = contexts.BundlesContext(request)
+ tag_cloud = contexts.ArchivedTagCloudContext(request, search)
bookmark_details = contexts.get_details_context(
request, contexts.ArchivedBookmarkDetailsContext
)
@@ -77,6 +86,7 @@ def archived(request: HttpRequest):
{
"page_title": "Archived bookmarks - Linkding",
"bookmark_list": bookmark_list,
+ "bundles": bundles,
"tag_cloud": tag_cloud,
"details": bookmark_details,
},
@@ -87,8 +97,11 @@ def shared(request: HttpRequest):
if request.method == "POST":
return search_action(request)
- bookmark_list = contexts.SharedBookmarkListContext(request)
- tag_cloud = contexts.SharedTagCloudContext(request)
+ search = BookmarkSearch.from_request(
+ request, request.GET, request.user_profile.search_preferences
+ )
+ bookmark_list = contexts.SharedBookmarkListContext(request, search)
+ tag_cloud = contexts.SharedTagCloudContext(request, search)
bookmark_details = contexts.get_details_context(
request, contexts.SharedBookmarkDetailsContext
)
@@ -132,13 +145,13 @@ def search_action(request: HttpRequest):
if "save" in request.POST:
if not request.user.is_authenticated:
return HttpResponseForbidden()
- search = BookmarkSearch.from_request(request.POST)
+ search = BookmarkSearch.from_request(request, request.POST)
request.user_profile.search_preferences = search.preferences_dict
request.user_profile.save()
# redirect to base url including new query params
search = BookmarkSearch.from_request(
- request.POST, request.user_profile.search_preferences
+ request, request.POST, request.user_profile.search_preferences
)
base_url = request.path
query_params = search.query_params
@@ -248,7 +261,9 @@ def update_state(request: HttpRequest, bookmark_id: int | str):
@login_required
def index_action(request: HttpRequest):
- search = BookmarkSearch.from_request(request.GET)
+ search = BookmarkSearch.from_request(
+ request, request.GET, request.user_profile.search_preferences
+ )
query = queries.query_bookmarks(request.user, request.user_profile, search)
response = handle_action(request, query)
@@ -263,7 +278,9 @@ def index_action(request: HttpRequest):
@login_required
def archived_action(request: HttpRequest):
- search = BookmarkSearch.from_request(request.GET)
+ search = BookmarkSearch.from_request(
+ request, request.GET, request.user_profile.search_preferences
+ )
query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
response = handle_action(request, query)
diff --git a/bookmarks/views/bundles.py b/bookmarks/views/bundles.py
new file mode 100644
index 0000000..062db8d
--- /dev/null
+++ b/bookmarks/views/bundles.py
@@ -0,0 +1,109 @@
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.db.models import Max
+from django.http import HttpRequest, HttpResponseRedirect
+from django.shortcuts import render
+from django.urls import reverse
+
+from bookmarks.models import BookmarkBundle, BookmarkBundleForm, BookmarkSearch
+from bookmarks.views import access
+from bookmarks.views.contexts import ActiveBookmarkListContext
+
+
+@login_required
+def index(request: HttpRequest):
+ bundles = BookmarkBundle.objects.filter(owner=request.user).order_by("order")
+ context = {"bundles": bundles}
+ return render(request, "bundles/index.html", context)
+
+
+@login_required
+def action(request: HttpRequest):
+ if "remove_bundle" in request.POST:
+ remove_bundle_id = request.POST.get("remove_bundle")
+ bundle = access.bundle_write(request, remove_bundle_id)
+ bundle_name = bundle.name
+ bundle.delete()
+ messages.success(request, f"Bundle '{bundle_name}' removed successfully.")
+
+ elif "move_bundle" in request.POST:
+ bundle_id = request.POST.get("move_bundle")
+ move_position = int(request.POST.get("move_position"))
+ bundle_to_move = access.bundle_write(request, bundle_id)
+ user_bundles = list(
+ BookmarkBundle.objects.filter(owner=request.user).order_by("order")
+ )
+
+ if move_position != user_bundles.index(bundle_to_move):
+ user_bundles.remove(bundle_to_move)
+ user_bundles.insert(move_position, bundle_to_move)
+ for bundle_index, bundle in enumerate(user_bundles):
+ bundle.order = bundle_index
+
+ BookmarkBundle.objects.bulk_update(user_bundles, ["order"])
+
+ return HttpResponseRedirect(reverse("linkding:bundles.index"))
+
+
+def _handle_edit(request: HttpRequest, template: str, bundle: BookmarkBundle = None):
+ form_data = request.POST if request.method == "POST" else None
+ form = BookmarkBundleForm(form_data, instance=bundle)
+
+ if request.method == "POST":
+ if form.is_valid():
+ instance = form.save(commit=False)
+ instance.owner = request.user
+
+ if bundle is None: # New bundle
+ max_order_result = BookmarkBundle.objects.filter(
+ owner=request.user
+ ).aggregate(Max("order", default=-1))
+ instance.order = max_order_result["order__max"] + 1
+
+ instance.save()
+ messages.success(request, "Bundle saved successfully.")
+ return HttpResponseRedirect(reverse("linkding:bundles.index"))
+
+ status = 422 if request.method == "POST" and not form.is_valid() else 200
+ bookmark_list = _get_bookmark_list_preview(request, bundle)
+ context = {"form": form, "bundle": bundle, "bookmark_list": bookmark_list}
+
+ return render(request, template, context, status=status)
+
+
+@login_required
+def new(request: HttpRequest):
+ return _handle_edit(request, "bundles/new.html")
+
+
+@login_required
+def edit(request: HttpRequest, bundle_id: int):
+ bundle = access.bundle_write(request, bundle_id)
+
+ return _handle_edit(request, "bundles/edit.html", bundle)
+
+
+@login_required
+def preview(request: HttpRequest):
+ bookmark_list = _get_bookmark_list_preview(request)
+ context = {"bookmark_list": bookmark_list}
+ return render(request, "bundles/preview.html", context)
+
+
+def _get_bookmark_list_preview(
+ request: HttpRequest, bundle: BookmarkBundle | None = None
+):
+ if request.method == "GET" and bundle:
+ preview_bundle = bundle
+ else:
+ form_data = (
+ request.POST.copy() if request.method == "POST" else request.GET.copy()
+ )
+ form_data["name"] = "Preview Bundle" # Set dummy name for form validation
+ form = BookmarkBundleForm(form_data)
+ preview_bundle = form.save(commit=False)
+
+ search = BookmarkSearch(bundle=preview_bundle)
+ bookmark_list = ActiveBookmarkListContext(request, search)
+ bookmark_list.is_preview = True
+ return bookmark_list
diff --git a/bookmarks/views/contexts.py b/bookmarks/views/contexts.py
index 86aec9e..cace23f 100644
--- a/bookmarks/views/contexts.py
+++ b/bookmarks/views/contexts.py
@@ -13,6 +13,7 @@ from bookmarks import utils
from bookmarks.models import (
Bookmark,
BookmarkAsset,
+ BookmarkBundle,
BookmarkSearch,
User,
UserProfile,
@@ -178,15 +179,13 @@ class BookmarkItem:
class BookmarkListContext:
request_context = RequestContext
- def __init__(self, request: HttpRequest) -> None:
+ def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:
request_context = self.request_context(request)
user = request.user
user_profile = request.user_profile
self.request = request
- self.search = BookmarkSearch.from_request(
- self.request.GET, user_profile.search_preferences
- )
+ self.search = search
query_set = request_context.get_bookmark_query_set(self.search)
page_number = request.GET.get("page")
@@ -219,6 +218,7 @@ class BookmarkListContext:
self.show_preview_images = user_profile.enable_preview_images
self.show_notes = user_profile.permanent_notes
self.collapse_side_panel = user_profile.collapse_side_panel
+ self.is_preview = False
@staticmethod
def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):
@@ -315,14 +315,12 @@ class TagGroup:
class TagCloudContext:
request_context = RequestContext
- def __init__(self, request: HttpRequest) -> None:
+ def __init__(self, request: HttpRequest, search: BookmarkSearch) -> None:
request_context = self.request_context(request)
user_profile = request.user_profile
self.request = request
- self.search = BookmarkSearch.from_request(
- self.request.GET, user_profile.search_preferences
- )
+ self.search = search
query_set = request_context.get_tag_query_set(self.search)
tags = list(query_set)
@@ -461,3 +459,23 @@ def get_details_context(
return None
return context_type(request, bookmark)
+
+
+class BundlesContext:
+ def __init__(self, request: HttpRequest) -> None:
+ self.request = request
+ self.user = request.user
+ self.user_profile = request.user_profile
+
+ self.bundles = (
+ BookmarkBundle.objects.filter(owner=self.user).order_by("order").all()
+ )
+ self.is_empty = len(self.bundles) == 0
+
+ selected_bundle_id = (
+ int(request.GET.get("bundle")) if request.GET.get("bundle") else None
+ )
+ self.selected_bundle = next(
+ (bundle for bundle in self.bundles if bundle.id == selected_bundle_id),
+ None,
+ )
diff --git a/bookmarks/views/partials.py b/bookmarks/views/partials.py
index 8930a66..34a4eed 100644
--- a/bookmarks/views/partials.py
+++ b/bookmarks/views/partials.py
@@ -1,3 +1,4 @@
+from bookmarks.models import BookmarkSearch
from bookmarks.views import contexts, turbo
@@ -14,8 +15,11 @@ def render_bookmark_update(request, bookmark_list, tag_cloud, details):
def active_bookmark_update(request):
- bookmark_list = contexts.ActiveBookmarkListContext(request)
- tag_cloud = contexts.ActiveTagCloudContext(request)
+ search = BookmarkSearch.from_request(
+ request, request.GET, request.user_profile.search_preferences
+ )
+ bookmark_list = contexts.ActiveBookmarkListContext(request, search)
+ tag_cloud = contexts.ActiveTagCloudContext(request, search)
details = contexts.get_details_context(
request, contexts.ActiveBookmarkDetailsContext
)
@@ -23,8 +27,11 @@ def active_bookmark_update(request):
def archived_bookmark_update(request):
- bookmark_list = contexts.ArchivedBookmarkListContext(request)
- tag_cloud = contexts.ArchivedTagCloudContext(request)
+ search = BookmarkSearch.from_request(
+ request, request.GET, request.user_profile.search_preferences
+ )
+ bookmark_list = contexts.ArchivedBookmarkListContext(request, search)
+ tag_cloud = contexts.ArchivedTagCloudContext(request, search)
details = contexts.get_details_context(
request, contexts.ArchivedBookmarkDetailsContext
)
@@ -32,8 +39,11 @@ def archived_bookmark_update(request):
def shared_bookmark_update(request):
- bookmark_list = contexts.SharedBookmarkListContext(request)
- tag_cloud = contexts.SharedTagCloudContext(request)
+ search = BookmarkSearch.from_request(
+ request, request.GET, request.user_profile.search_preferences
+ )
+ bookmark_list = contexts.SharedBookmarkListContext(request, search)
+ tag_cloud = contexts.SharedTagCloudContext(request, search)
details = contexts.get_details_context(
request, contexts.SharedBookmarkDetailsContext
)