From bb796c9bdb9c31f26302cb575c8703d6b3bd8286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Fri, 30 May 2025 10:24:19 +0200 Subject: [PATCH] Add date filters for REST API (#1080) * Add modified_since query parameter * Add added_since parameter * update date_modified when assets change --- bookmarks/models.py | 10 +++- bookmarks/queries.py | 17 ++++++ bookmarks/services/assets.py | 9 ++- bookmarks/tests/test_assets_service.py | 63 ++++++++++++++++++++- bookmarks/tests/test_queries.py | 76 ++++++++++++++++++++++++++ docs/src/content/docs/api.md | 2 + 6 files changed, 172 insertions(+), 5 deletions(-) diff --git a/bookmarks/models.py b/bookmarks/models.py index f92475f..d411c9a 100644 --- a/bookmarks/models.py +++ b/bookmarks/models.py @@ -171,7 +171,7 @@ class BookmarkSearch: FILTER_UNREAD_YES = "yes" FILTER_UNREAD_NO = "no" - params = ["q", "user", "sort", "shared", "unread"] + params = ["q", "user", "sort", "shared", "unread", "modified_since", "added_since"] preferences = ["sort", "shared", "unread"] defaults = { "q": "", @@ -179,6 +179,8 @@ class BookmarkSearch: "sort": SORT_ADDED_DESC, "shared": FILTER_SHARED_OFF, "unread": FILTER_UNREAD_OFF, + "modified_since": None, + "added_since": None, } def __init__( @@ -188,6 +190,8 @@ class BookmarkSearch: sort: str = None, shared: str = None, unread: str = None, + modified_since: str = None, + added_since: str = None, preferences: dict = None, ): if not preferences: @@ -199,6 +203,8 @@ class BookmarkSearch: self.sort = sort or self.defaults["sort"] self.shared = shared or self.defaults["shared"] self.unread = unread or self.defaults["unread"] + self.modified_since = modified_since or self.defaults["modified_since"] + self.added_since = added_since or self.defaults["added_since"] def is_modified(self, param): value = self.__dict__[param] @@ -268,6 +274,8 @@ class BookmarkSearchForm(forms.Form): sort = forms.ChoiceField(choices=SORT_CHOICES) shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect) unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect) + modified_since = forms.CharField(required=False) + added_since = forms.CharField(required=False) def __init__( self, diff --git a/bookmarks/queries.py b/bookmarks/queries.py index 8fd6a08..f661f9f 100644 --- a/bookmarks/queries.py +++ b/bookmarks/queries.py @@ -2,6 +2,7 @@ from typing import Optional from django.conf import settings from django.contrib.auth.models import User +from django.core.exceptions import ValidationError from django.db.models import Q, QuerySet, Exists, OuterRef, Case, When, CharField from django.db.models.expressions import RawSQL from django.db.models.functions import Lower @@ -44,6 +45,22 @@ def _base_bookmarks_query( if user: query_set = query_set.filter(owner=user) + # Filter by modified_since if provided + if search.modified_since: + try: + query_set = query_set.filter(date_modified__gt=search.modified_since) + except ValidationError: + # If the date format is invalid, ignore the filter + pass + + # Filter by added_since if provided + if search.added_since: + try: + query_set = query_set.filter(date_added__gt=search.added_since) + except ValidationError: + # If the date format is invalid, ignore the filter + pass + # Split query into search terms and tags query = parse_query_string(search.q) diff --git a/bookmarks/services/assets.py b/bookmarks/services/assets.py index 4409c61..a6e745d 100644 --- a/bookmarks/services/assets.py +++ b/bookmarks/services/assets.py @@ -53,6 +53,7 @@ def create_snapshot(asset: BookmarkAsset): asset.save() asset.bookmark.latest_snapshot = asset + asset.bookmark.date_modified = timezone.now() asset.bookmark.save() except Exception as error: asset.status = BookmarkAsset.STATUS_FAILURE @@ -75,6 +76,7 @@ def upload_snapshot(bookmark: Bookmark, html: bytes): asset.save() asset.bookmark.latest_snapshot = asset + asset.bookmark.date_modified = timezone.now() asset.bookmark.save() return asset @@ -100,6 +102,10 @@ def upload_asset(bookmark: Bookmark, upload_file: UploadedFile): asset.file = filename asset.file_size = upload_file.size asset.save() + + asset.bookmark.date_modified = timezone.now() + asset.bookmark.save() + logger.info( f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}" ) @@ -128,9 +134,10 @@ def remove_asset(asset: BookmarkAsset): ) bookmark.latest_snapshot = latest - bookmark.save() asset.delete() + bookmark.date_modified = timezone.now() + bookmark.save() def _generate_asset_filename( diff --git a/bookmarks/tests/test_assets_service.py b/bookmarks/tests/test_assets_service.py index 31acd12..97029fa 100644 --- a/bookmarks/tests/test_assets_service.py +++ b/bookmarks/tests/test_assets_service.py @@ -55,7 +55,12 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin): self.assertIsNone(asset.id) def test_create_snapshot(self): - bookmark = self.setup_bookmark(url="https://example.com") + initial_modified = timezone.datetime( + 2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc + ) + bookmark = self.setup_bookmark( + url="https://example.com", modified=initial_modified + ) asset = assets.create_snapshot_asset(bookmark) asset.save() asset.date_created = timezone.datetime( @@ -91,6 +96,9 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin): self.assertEqual(asset.file, expected_filename) self.assertTrue(asset.gzip) + # should update bookmark modified date + bookmark.refresh_from_db() + def test_create_snapshot_failure(self): bookmark = self.setup_bookmark(url="https://example.com") asset = assets.create_snapshot_asset(bookmark) @@ -120,7 +128,12 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin): self.assertTrue(saved_file.endswith("aaaa.html.gz")) def test_upload_snapshot(self): - bookmark = self.setup_bookmark(url="https://example.com") + initial_modified = timezone.datetime( + 2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc + ) + bookmark = self.setup_bookmark( + url="https://example.com", modified=initial_modified + ) asset = assets.upload_snapshot(bookmark, self.html_content.encode()) # should create gzip file in asset folder @@ -145,6 +158,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin): self.assertEqual(asset.file, saved_file_name) self.assertTrue(asset.gzip) + # should update bookmark modified date + bookmark.refresh_from_db() + self.assertGreater(bookmark.date_modified, initial_modified) + def test_upload_snapshot_failure(self): bookmark = self.setup_bookmark(url="https://example.com") @@ -173,7 +190,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin): @disable_logging def test_upload_asset(self): - bookmark = self.setup_bookmark() + initial_modified = timezone.datetime( + 2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc + ) + bookmark = self.setup_bookmark(modified=initial_modified) file_content = b"test content" upload_file = SimpleUploadedFile( "test_file.txt", file_content, content_type="text/plain" @@ -204,6 +224,10 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin): self.assertEqual(asset.file_size, len(file_content)) self.assertFalse(asset.gzip) + # should update bookmark modified date + bookmark.refresh_from_db() + self.assertGreater(bookmark.date_modified, initial_modified) + @disable_logging def test_upload_asset_truncates_asset_file_name(self): # Create a bookmark with a very long URL @@ -409,3 +433,36 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin): # Verify that latest_snapshot hasn't changed self.assertEqual(bookmark.latest_snapshot, latest_asset) + + @disable_logging + def test_remove_asset(self): + initial_modified = timezone.datetime( + 2025, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc + ) + bookmark = self.setup_bookmark(modified=initial_modified) + file_content = b"test content for removal" + upload_file = SimpleUploadedFile( + "test_remove_file.txt", file_content, content_type="text/plain" + ) + + asset = assets.upload_asset(bookmark, upload_file) + asset_filepath = os.path.join(self.assets_dir, asset.file) + + # Verify asset and file exist + self.assertTrue(BookmarkAsset.objects.filter(id=asset.id).exists()) + self.assertTrue(os.path.exists(asset_filepath)) + + bookmark.date_modified = initial_modified + bookmark.save() + + # Remove the asset + assets.remove_asset(asset) + + # Verify asset is removed from DB + self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists()) + # Verify file is removed from disk + self.assertFalse(os.path.exists(asset_filepath)) + + # Verify bookmark modified date is updated + bookmark.refresh_from_db() + self.assertGreater(bookmark.date_modified, initial_modified) diff --git a/bookmarks/tests/test_queries.py b/bookmarks/tests/test_queries.py index e1b1908..01e2d0c 100644 --- a/bookmarks/tests/test_queries.py +++ b/bookmarks/tests/test_queries.py @@ -1211,3 +1211,79 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin): query = queries.query_bookmarks(self.user, self.profile, search) self.assertEqual(list(query), sorted_bookmarks) + + def test_query_bookmarks_filter_modified_since(self): + # Create bookmarks with different modification dates + older_bookmark = self.setup_bookmark(title="old bookmark") + recent_bookmark = self.setup_bookmark(title="recent bookmark") + + # Modify date field on bookmark directly to test modified_since + older_bookmark.date_modified = timezone.datetime( + 2025, 1, 1, tzinfo=datetime.timezone.utc + ) + older_bookmark.save() + recent_bookmark.date_modified = timezone.datetime( + 2025, 5, 15, tzinfo=datetime.timezone.utc + ) + recent_bookmark.save() + + # Test with date between the two bookmarks + search = BookmarkSearch(modified_since="2025-03-01T00:00:00Z") + query = queries.query_bookmarks(self.user, self.profile, search) + self.assertCountEqual(list(query), [recent_bookmark]) + + # Test with date before both bookmarks + search = BookmarkSearch(modified_since="2024-12-31T00:00:00Z") + query = queries.query_bookmarks(self.user, self.profile, search) + self.assertCountEqual(list(query), [older_bookmark, recent_bookmark]) + + # Test with date after both bookmarks + search = BookmarkSearch(modified_since="2025-05-16T00:00:00Z") + query = queries.query_bookmarks(self.user, self.profile, search) + self.assertCountEqual(list(query), []) + + # Test with no modified_since - should return all bookmarks + search = BookmarkSearch() + query = queries.query_bookmarks(self.user, self.profile, search) + self.assertCountEqual(list(query), [older_bookmark, recent_bookmark]) + + # Test with invalid date format - should be ignored + search = BookmarkSearch(modified_since="invalid-date") + query = queries.query_bookmarks(self.user, self.profile, search) + self.assertCountEqual(list(query), [older_bookmark, recent_bookmark]) + + def test_query_bookmarks_filter_added_since(self): + # Create bookmarks with different dates + older_bookmark = self.setup_bookmark( + title="old bookmark", + added=timezone.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc), + ) + recent_bookmark = self.setup_bookmark( + title="recent bookmark", + added=timezone.datetime(2025, 5, 15, tzinfo=datetime.timezone.utc), + ) + + # Test with date between the two bookmarks + search = BookmarkSearch(added_since="2025-03-01T00:00:00Z") + query = queries.query_bookmarks(self.user, self.profile, search) + self.assertCountEqual(list(query), [recent_bookmark]) + + # Test with date before both bookmarks + search = BookmarkSearch(added_since="2024-12-31T00:00:00Z") + query = queries.query_bookmarks(self.user, self.profile, search) + self.assertCountEqual(list(query), [older_bookmark, recent_bookmark]) + + # Test with date after both bookmarks + search = BookmarkSearch(added_since="2025-05-16T00:00:00Z") + query = queries.query_bookmarks(self.user, self.profile, search) + self.assertCountEqual(list(query), []) + + # Test with no added_since - should return all bookmarks + search = BookmarkSearch() + query = queries.query_bookmarks(self.user, self.profile, search) + self.assertCountEqual(list(query), [older_bookmark, recent_bookmark]) + + # Test with invalid date format - should be ignored + search = BookmarkSearch(added_since="invalid-date") + query = queries.query_bookmarks(self.user, self.profile, search) + self.assertCountEqual(list(query), [older_bookmark, recent_bookmark]) diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md index 309204b..5b41b16 100644 --- a/docs/src/content/docs/api.md +++ b/docs/src/content/docs/api.md @@ -35,6 +35,8 @@ Parameters: - `q` - Filters results using a search phrase using the same logic as through the UI - `limit` - Limits the max. number of results. Default is `100`. - `offset` - Index from which to start returning results +- `modified_since` - Filter results to only include bookmarks modified after the specified date (format: ISO 8601, e.g. "2025-01-01T00:00:00Z") +- `added_since` - Filter results to only include bookmarks added after the specified date (format: ISO 8601, e.g. "2025-05-29T00:00:00Z") Example response: