Compare commits

...

2 Commits

Author SHA1 Message Date
Daniel Federschmidt
573b6f5411 Filter bundles by reading or sharing state (#1308)
* add filtering by reading or sharing state

* fix migration

* add tests

* format

* fix model references

* replace hard-coded strings in tests

---------

Co-authored-by: dfederschmidt <daniel@federschmidt.org>
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2026-02-28 10:54:09 +01:00
Alice
460b435110 Set empty alt-text on previews for better screenreader experience (#1309)
Co-authored-by: WheresAlice <wheresalice@noreply.users.github.com>
2026-02-28 09:59:49 +01:00
14 changed files with 302 additions and 2 deletions

View File

@@ -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"]

View File

@@ -44,6 +44,8 @@ class BookmarkBundleSerializer(serializers.ModelSerializer):
"any_tags",
"all_tags",
"excluded_tags",
"filter_unread",
"filter_shared",
"order",
"date_created",
"date_modified",

View File

@@ -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)

View File

@@ -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,
),
),
]

View File

@@ -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)

View File

@@ -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

View File

@@ -157,6 +157,7 @@
{% if bookmark_item.preview_image_file %}
<img class="preview-image"
src="{% static bookmark_item.preview_image_file %}"
alt=""
loading="lazy" />
{% else %}
<div class="preview-image placeholder">

View File

@@ -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"

View File

@@ -179,6 +179,8 @@ class BookmarkFactoryMixin:
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:
@@ -193,6 +195,8 @@ class BookmarkFactoryMixin:
any_tags=any_tags,
all_tags=all_tags,
excluded_tags=excluded_tags,
filter_unread=filter_unread,
filter_shared=filter_shared,
order=order,
)
bundle.save()

View File

@@ -13,6 +13,8 @@ class BundlesApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
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"]
@@ -71,6 +73,8 @@ class BundlesApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
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,
)
@@ -102,6 +106,8 @@ class BundlesApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
"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")
@@ -115,6 +121,8 @@ class BundlesApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
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)
@@ -201,6 +209,8 @@ class BundlesApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
"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,
}
@@ -213,6 +223,8 @@ class BundlesApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
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)

View File

@@ -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
@@ -18,6 +19,8 @@ class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
"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}
@@ -38,6 +41,8 @@ class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
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(
@@ -46,6 +51,8 @@ class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
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]))
@@ -95,6 +102,30 @@ class BundleEditViewTestCase(TestCase, BookmarkFactoryMixin):
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",

View File

@@ -21,6 +21,8 @@ class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
"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}
@@ -38,6 +40,8 @@ class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
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"))

View File

@@ -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
@@ -93,6 +94,68 @@ class BundlePreviewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
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()

View File

@@ -6,7 +6,7 @@ from django.test import TestCase
from django.utils import timezone
from bookmarks import queries
from bookmarks.models import BookmarkSearch, UserProfile
from bookmarks.models import BookmarkBundle, BookmarkSearch, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, random_sentence
from bookmarks.utils import unique
@@ -1501,6 +1501,89 @@ class QueriesBasicTestCase(TestCase, BookmarkFactoryMixin):
)
self.assertQueryResult(query, [matching_bookmarks])
def test_query_bookmarks_with_bundle_filter_unread(self):
unread_bookmarks = [
self.setup_bookmark(unread=True),
self.setup_bookmark(unread=True),
]
read_bookmarks = [
self.setup_bookmark(unread=False),
self.setup_bookmark(unread=False),
]
# Filter unread
bundle = self.setup_bundle(filter_unread=BookmarkBundle.FILTER_STATE_YES)
query = queries.query_bookmarks(
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
)
self.assertQueryResult(query, [unread_bookmarks])
# Filter read
bundle = self.setup_bundle(filter_unread=BookmarkBundle.FILTER_STATE_NO)
query = queries.query_bookmarks(
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
)
self.assertQueryResult(query, [read_bookmarks])
# Filter off
bundle = self.setup_bundle(filter_unread=BookmarkBundle.FILTER_STATE_OFF)
query = queries.query_bookmarks(
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
)
self.assertQueryResult(query, [unread_bookmarks, read_bookmarks])
def test_query_bookmarks_with_bundle_filter_shared(self):
shared_bookmarks = [
self.setup_bookmark(shared=True),
self.setup_bookmark(shared=True),
]
unshared_bookmarks = [
self.setup_bookmark(shared=False),
self.setup_bookmark(shared=False),
]
# Filter shared
bundle = self.setup_bundle(filter_shared=BookmarkBundle.FILTER_STATE_YES)
query = queries.query_bookmarks(
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
)
self.assertQueryResult(query, [shared_bookmarks])
# Filter unshared
bundle = self.setup_bundle(filter_shared=BookmarkBundle.FILTER_STATE_NO)
query = queries.query_bookmarks(
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
)
self.assertQueryResult(query, [unshared_bookmarks])
# Filter off
bundle = self.setup_bundle(filter_shared=BookmarkBundle.FILTER_STATE_OFF)
query = queries.query_bookmarks(
self.user, self.profile, BookmarkSearch(q="", bundle=bundle)
)
self.assertQueryResult(query, [shared_bookmarks, unshared_bookmarks])
def test_query_bookmarks_with_bundle_unread_shared_filters_combined(self):
bundle = self.setup_bundle(
search="python",
filter_unread=BookmarkBundle.FILTER_STATE_YES,
filter_shared=BookmarkBundle.FILTER_STATE_NO,
)
matching_bookmarks = [
self.setup_bookmark(title="Python Tutorial", unread=True, shared=False),
]
# Bookmarks that should not match
self.setup_bookmark(title="Python Guide", unread=False, shared=False)
self.setup_bookmark(title="Python Docs", unread=True, shared=True)
self.setup_bookmark(title="Java Guide", unread=True, shared=False)
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")