Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bookmarks/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@ class AdminBookmarkBundle(admin.ModelAdmin):
"any_tags",
"all_tags",
"excluded_tags",
"filter_shared",
"filter_unread",
"date_created",
)
search_fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
Expand Down
2 changes: 2 additions & 0 deletions bookmarks/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ class Meta:
"any_tags",
"all_tags",
"excluded_tags",
"filter_unread",
"filter_shared",
"order",
"date_created",
"date_modified",
Expand Down
20 changes: 19 additions & 1 deletion bookmarks/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,28 @@ class BookmarkBundleForm(forms.ModelForm):
any_tags = forms.CharField(required=False, widget=TagAutocomplete)
all_tags = forms.CharField(required=False, widget=TagAutocomplete)
excluded_tags = forms.CharField(required=False, widget=TagAutocomplete)
filter_unread = forms.ChoiceField(
choices=BookmarkBundle.FILTER_UNREAD_CHOICES,
required=False,
widget=FormSelect,
)
filter_shared = forms.ChoiceField(
choices=BookmarkBundle.FILTER_SHARED_CHOICES,
required=False,
widget=FormSelect,
)

class Meta:
model = BookmarkBundle
fields = ["name", "search", "any_tags", "all_tags", "excluded_tags"]
fields = [
"name",
"search",
"any_tags",
"all_tags",
"excluded_tags",
"filter_unread",
"filter_shared",
]

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs, error_class=FormErrorList)
Expand Down
30 changes: 30 additions & 0 deletions bookmarks/migrations/0054_bookmarkbundle_filter_shared_and_more.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 6.0 on 2026-02-28 09:05

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0053_migrate_api_tokens"),
]

operations = [
migrations.AddField(
model_name="bookmarkbundle",
name="filter_shared",
field=models.CharField(
choices=[("off", "All"), ("yes", "Shared"), ("no", "Unshared")],
default="off",
max_length=3,
),
),
migrations.AddField(
model_name="bookmarkbundle",
name="filter_unread",
field=models.CharField(
choices=[("off", "All"), ("yes", "Unread"), ("no", "Read")],
default="off",
max_length=3,
),
),
]
26 changes: 26 additions & 0 deletions bookmarks/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,37 @@ def bookmark_asset_deleted(sender, instance, **kwargs):


class BookmarkBundle(models.Model):
FILTER_STATE_OFF = "off"
FILTER_STATE_YES = "yes"
FILTER_STATE_NO = "no"
FILTER_UNREAD_CHOICES = [
(FILTER_STATE_OFF, "All"),
(FILTER_STATE_YES, "Unread"),
(FILTER_STATE_NO, "Read"),
]
FILTER_SHARED_CHOICES = [
(FILTER_STATE_OFF, "All"),
(FILTER_STATE_YES, "Shared"),
(FILTER_STATE_NO, "Unshared"),
]

name = models.CharField(max_length=256, blank=False)
search = models.CharField(max_length=256, blank=True)
any_tags = models.CharField(max_length=1024, blank=True)
all_tags = models.CharField(max_length=1024, blank=True)
excluded_tags = models.CharField(max_length=1024, blank=True)
filter_unread = models.CharField(
max_length=3,
choices=FILTER_UNREAD_CHOICES,
blank=False,
default=FILTER_STATE_OFF,
)
filter_shared = models.CharField(
max_length=3,
choices=FILTER_SHARED_CHOICES,
blank=False,
default=FILTER_STATE_OFF,
)
order = models.IntegerField(null=False, default=0)
date_created = models.DateTimeField(auto_now_add=True, null=False)
date_modified = models.DateTimeField(auto_now=True, null=False)
Expand Down
10 changes: 10 additions & 0 deletions bookmarks/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,16 @@ def _filter_bundle(query_set: QuerySet, bundle: BookmarkBundle) -> QuerySet:
Exists(Bookmark.objects.filter(tag_conditions, id=OuterRef("id")))
)

if bundle.filter_unread == BookmarkBundle.FILTER_STATE_YES:
query_set = query_set.filter(unread=True)
elif bundle.filter_unread == BookmarkBundle.FILTER_STATE_NO:
query_set = query_set.filter(unread=False)

if bundle.filter_shared == BookmarkBundle.FILTER_STATE_YES:
query_set = query_set.filter(shared=True)
elif bundle.filter_shared == BookmarkBundle.FILTER_STATE_NO:
query_set = query_set.filter(shared=False)

return query_set


Expand Down
14 changes: 14 additions & 0 deletions bookmarks/templates/bundles/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@
None of these tags must be present in a bookmark to match.
{% endformhelp %}
</div>
<div class="form-group">
{% formlabel form.filter_unread "Reading State" %}
{% formfield form.filter_unread has_help=True %}
{% formhelp form.filter_unread %}
Limit matches to unread or read bookmarks.
{% endformhelp %}
</div>
<div class="form-group">
{% formlabel form.filter_shared "Sharing State" %}
{% formfield form.filter_shared has_help=True %}
{% formhelp form.filter_shared %}
Limit matches to shared or unshared bookmarks.
{% endformhelp %}
</div>
<div class="form-footer d-flex mt-4">
<input type="submit"
name="save"
Expand Down
4 changes: 4 additions & 0 deletions bookmarks/tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ def setup_bundle(
any_tags: str = "",
all_tags: str = "",
excluded_tags: str = "",
filter_unread: str = BookmarkBundle.FILTER_STATE_OFF,
filter_shared: str = BookmarkBundle.FILTER_STATE_OFF,
order: int = 0,
):
if user is None:
Expand All @@ -193,6 +195,8 @@ def setup_bundle(
any_tags=any_tags,
all_tags=all_tags,
excluded_tags=excluded_tags,
filter_unread=filter_unread,
filter_shared=filter_shared,
order=order,
)
bundle.save()
Expand Down
12 changes: 12 additions & 0 deletions bookmarks/tests/test_bundles_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ def assertBundle(self, bundle: BookmarkBundle, data: dict):
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.filter_unread, data["filter_unread"])
self.assertEqual(bundle.filter_shared, data["filter_shared"])
self.assertEqual(bundle.order, data["order"])
self.assertEqual(
bundle.date_created.isoformat().replace("+00:00", "Z"), data["date_created"]
Expand Down Expand Up @@ -71,6 +73,8 @@ def test_bundle_detail(self):
any_tags="tag1 tag2",
all_tags="required-tag",
excluded_tags="excluded-tag",
filter_unread=BookmarkBundle.FILTER_STATE_YES,
filter_shared=BookmarkBundle.FILTER_STATE_NO,
order=5,
)

Expand Down Expand Up @@ -102,6 +106,8 @@ def test_create_bundle(self):
"any_tags": "tag1 tag2",
"all_tags": "required-tag",
"excluded_tags": "excluded-tag",
"filter_unread": BookmarkBundle.FILTER_STATE_YES,
"filter_shared": BookmarkBundle.FILTER_STATE_NO,
}

url = reverse("linkding:bundle-list")
Expand All @@ -115,6 +121,8 @@ def test_create_bundle(self):
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.filter_unread, bundle_data["filter_unread"])
self.assertEqual(bundle.filter_shared, bundle_data["filter_shared"])
self.assertEqual(bundle.owner, self.user)
self.assertEqual(bundle.order, 0)

Expand Down Expand Up @@ -201,6 +209,8 @@ def test_update_bundle_put(self):
"any_tags": "updated-tag1 updated-tag2",
"all_tags": "required-updated-tag",
"excluded_tags": "excluded-updated-tag",
"filter_unread": BookmarkBundle.FILTER_STATE_YES,
"filter_shared": BookmarkBundle.FILTER_STATE_NO,
"order": 5,
}

Expand All @@ -213,6 +223,8 @@ def test_update_bundle_put(self):
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.filter_unread, updated_data["filter_unread"])
self.assertEqual(bundle.filter_shared, updated_data["filter_shared"])
self.assertEqual(bundle.order, updated_data["order"])

self.assertBundle(bundle, response.data)
Expand Down
31 changes: 31 additions & 0 deletions bookmarks/tests/test_bundles_edit_view.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.test import TestCase
from django.urls import reverse

from bookmarks.models import BookmarkBundle
from bookmarks.tests.helpers import BookmarkFactoryMixin


Expand All @@ -18,6 +19,8 @@ def create_form_data(self, overrides=None):
"any_tags": "tag1 tag2",
"all_tags": "required-tag",
"excluded_tags": "excluded-tag",
"filter_unread": BookmarkBundle.FILTER_STATE_YES,
"filter_shared": BookmarkBundle.FILTER_STATE_NO,
}
return {**form_data, **overrides}

Expand All @@ -38,6 +41,8 @@ def test_should_edit_bundle(self):
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.filter_unread, updated_data["filter_unread"])
self.assertEqual(bundle.filter_shared, updated_data["filter_shared"])

def test_should_render_edit_form_with_prefilled_fields(self):
bundle = self.setup_bundle(
Expand All @@ -46,6 +51,8 @@ def test_should_render_edit_form_with_prefilled_fields(self):
any_tags="tag1 tag2 tag3",
all_tags="required-tag all-tag",
excluded_tags="excluded-tag banned-tag",
filter_unread=BookmarkBundle.FILTER_STATE_YES,
filter_shared=BookmarkBundle.FILTER_STATE_NO,
)

response = self.client.get(reverse("linkding:bundles.edit", args=[bundle.id]))
Expand Down Expand Up @@ -95,6 +102,30 @@ def test_should_render_edit_form_with_prefilled_fields(self):
html,
)

self.assertInHTML(
"""
<select name="filter_unread" class="form-select"
aria-describedby="id_filter_unread_help" id="id_filter_unread">
<option value="off">All</option>
<option value="yes" selected>Unread</option>
<option value="no">Read</option>
</select>
""",
html,
)

self.assertInHTML(
"""
<select name="filter_shared" class="form-select"
aria-describedby="id_filter_shared_help" id="id_filter_shared">
<option value="off">All</option>
<option value="yes">Shared</option>
<option value="no" selected>Unshared</option>
</select>
""",
html,
)

def test_should_return_422_with_invalid_form(self):
bundle = self.setup_bundle(
name="Test Bundle",
Expand Down
4 changes: 4 additions & 0 deletions bookmarks/tests/test_bundles_new_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ def create_form_data(self, overrides=None):
"any_tags": "tag1 tag2",
"all_tags": "required-tag",
"excluded_tags": "excluded-tag",
"filter_unread": BookmarkBundle.FILTER_STATE_YES,
"filter_shared": BookmarkBundle.FILTER_STATE_NO,
}
return {**form_data, **overrides}

Expand All @@ -38,6 +40,8 @@ def test_should_create_new_bundle(self):
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.assertEqual(bundle.filter_unread, form_data["filter_unread"])
self.assertEqual(bundle.filter_shared, form_data["filter_shared"])

self.assertRedirects(response, reverse("linkding:bundles.index"))

Expand Down
63 changes: 63 additions & 0 deletions bookmarks/tests/test_bundles_preview_view.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.test import TestCase
from django.urls import reverse

from bookmarks.models import BookmarkBundle
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin


Expand Down Expand Up @@ -93,6 +94,68 @@ def test_preview_ignores_archived_bookmarks(self):
self.assertContains(response, active_bookmark.title)
self.assertNotContains(response, archived_bookmark.title)

def test_preview_with_filter_unread(self):
unread_bookmark = self.setup_bookmark(title="Unread Bookmark", unread=True)
read_bookmark = self.setup_bookmark(title="Read Bookmark", unread=False)

# Filter unread
response = self.client.get(
reverse("linkding:bundles.preview"),
{"filter_unread": BookmarkBundle.FILTER_STATE_YES},
)
self.assertContains(response, "Found 1 bookmarks matching this bundle")
self.assertContains(response, unread_bookmark.title)
self.assertNotContains(response, read_bookmark.title)

# Filter read
response = self.client.get(
reverse("linkding:bundles.preview"),
{"filter_unread": BookmarkBundle.FILTER_STATE_NO},
)
self.assertContains(response, "Found 1 bookmarks matching this bundle")
self.assertNotContains(response, unread_bookmark.title)
self.assertContains(response, read_bookmark.title)

# Filter off
response = self.client.get(
reverse("linkding:bundles.preview"),
{"filter_unread": BookmarkBundle.FILTER_STATE_OFF},
)
self.assertContains(response, "Found 2 bookmarks matching this bundle")
self.assertContains(response, unread_bookmark.title)
self.assertContains(response, read_bookmark.title)

def test_preview_with_filter_shared(self):
shared_bookmark = self.setup_bookmark(title="Shared Bookmark", shared=True)
unshared_bookmark = self.setup_bookmark(title="Unshared Bookmark", shared=False)

# Filter shared
response = self.client.get(
reverse("linkding:bundles.preview"),
{"filter_shared": BookmarkBundle.FILTER_STATE_YES},
)
self.assertContains(response, "Found 1 bookmarks matching this bundle")
self.assertContains(response, shared_bookmark.title)
self.assertNotContains(response, unshared_bookmark.title)

# Filter unshared
response = self.client.get(
reverse("linkding:bundles.preview"),
{"filter_shared": BookmarkBundle.FILTER_STATE_NO},
)
self.assertContains(response, "Found 1 bookmarks matching this bundle")
self.assertNotContains(response, shared_bookmark.title)
self.assertContains(response, unshared_bookmark.title)

# Filter off
response = self.client.get(
reverse("linkding:bundles.preview"),
{"filter_shared": BookmarkBundle.FILTER_STATE_OFF},
)
self.assertContains(response, "Found 2 bookmarks matching this bundle")
self.assertContains(response, shared_bookmark.title)
self.assertContains(response, unshared_bookmark.title)

def test_preview_requires_authentication(self):
self.client.logout()

Expand Down
Loading