From 41f79e35a0a75cc208a371318d7dcf21f285b7ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Sun, 1 Oct 2023 21:22:44 +0200 Subject: [PATCH] Allow saving search preferences (#540) * Add indicator for modified filters * Rename shared filter values * Add update search preferences handler * Separate search and preferences forms * Properly initialize bookmark search from get or post * Add tests for applying search preferences * Implement saving search preferences * Remove bookmark search query alias * Use search preferences as default * Only show save button for authenticated users * Only show modified indicator if preferences are modified * Fix overriding search preferences * Add missing migration --- bookmarks/api/routes.py | 6 +- bookmarks/feeds.py | 2 +- .../0025_userprofile_search_preferences.py | 18 ++ bookmarks/models.py | 67 +++-- bookmarks/queries.py | 2 +- bookmarks/styles/bookmark-page.scss | 2 +- bookmarks/styles/spectre.scss | 1 + bookmarks/templates/bookmarks/search.html | 91 ++++--- bookmarks/templatetags/bookmarks.py | 10 +- bookmarks/tests/helpers.py | 8 +- bookmarks/tests/test_bookmark_action_view.py | 45 ++-- .../tests/test_bookmark_archived_view.py | 255 ++++++++++++------ bookmarks/tests/test_bookmark_index_view.py | 253 +++++++++++------ bookmarks/tests/test_bookmark_search_form.py | 8 +- bookmarks/tests/test_bookmark_search_model.py | 137 ++++++++-- bookmarks/tests/test_bookmark_search_tag.py | 216 +++++++++++++-- bookmarks/tests/test_bookmark_shared_view.py | 205 +++++++++++--- bookmarks/tests/test_bookmarks_api.py | 4 +- bookmarks/tests/test_queries.py | 150 +++++------ bookmarks/tests/test_user_select_tag.py | 6 +- bookmarks/views/bookmarks.py | 37 ++- bookmarks/views/partials/contexts.py | 13 +- 22 files changed, 1094 insertions(+), 442 deletions(-) create mode 100644 bookmarks/migrations/0025_userprofile_search_preferences.py diff --git a/bookmarks/api/routes.py b/bookmarks/api/routes.py index f114f072..d8ff0b5e 100644 --- a/bookmarks/api/routes.py +++ b/bookmarks/api/routes.py @@ -34,7 +34,7 @@ def get_queryset(self): user = self.request.user # For list action, use query set that applies search and tag projections if self.action == 'list': - search = BookmarkSearch.from_request(self.request) + search = BookmarkSearch.from_request(self.request.GET) return queries.query_bookmarks(user, user.profile, search) # For single entity actions use default query set without projections @@ -46,7 +46,7 @@ def get_serializer_context(self): @action(methods=['get'], detail=False) def archived(self, request): user = request.user - search = BookmarkSearch.from_request(request) + search = BookmarkSearch.from_request(request.GET) query_set = queries.query_archived_bookmarks(user, user.profile, search) page = self.paginate_queryset(query_set) serializer = self.get_serializer_class() @@ -55,7 +55,7 @@ def archived(self, request): @action(methods=['get'], detail=False) def shared(self, request): - search = BookmarkSearch.from_request(request) + search = BookmarkSearch.from_request(request.GET) user = User.objects.filter(username=search.user).first() public_only = not request.user.is_authenticated query_set = queries.query_shared_bookmarks(user, request.user_profile, search, public_only) diff --git a/bookmarks/feeds.py b/bookmarks/feeds.py index 92fdde6f..3ebc98c5 100644 --- a/bookmarks/feeds.py +++ b/bookmarks/feeds.py @@ -17,7 +17,7 @@ class FeedContext: class BaseBookmarksFeed(Feed): def get_object(self, request, feed_key: str): feed_token = FeedToken.objects.get(key__exact=feed_key) - search = BookmarkSearch(query=request.GET.get('q', '')) + search = BookmarkSearch(q=request.GET.get('q', '')) query_set = queries.query_bookmarks(feed_token.user, feed_token.user.profile, search) return FeedContext(feed_token, query_set) diff --git a/bookmarks/migrations/0025_userprofile_search_preferences.py b/bookmarks/migrations/0025_userprofile_search_preferences.py new file mode 100644 index 00000000..8886492f --- /dev/null +++ b/bookmarks/migrations/0025_userprofile_search_preferences.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.9 on 2023-09-30 10:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookmarks', '0024_userprofile_enable_public_sharing'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='search_preferences', + field=models.JSONField(default=dict), + ), + ] diff --git a/bookmarks/models.py b/bookmarks/models.py index ddcf4ecc..2da99d71 100644 --- a/bookmarks/models.py +++ b/bookmarks/models.py @@ -5,10 +5,10 @@ from django import forms from django.contrib.auth import get_user_model from django.contrib.auth.models import User -from django.core.handlers.wsgi import WSGIRequest from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver +from django.http import QueryDict from bookmarks.utils import unique from bookmarks.validators import BookmarkURLValidator @@ -130,15 +130,16 @@ class BookmarkSearch: SORT_TITLE_ASC = 'title_asc' SORT_TITLE_DESC = 'title_desc' - FILTER_SHARED_OFF = '' - FILTER_SHARED_SHARED = 'shared' - FILTER_SHARED_UNSHARED = 'unshared' + FILTER_SHARED_OFF = 'off' + FILTER_SHARED_SHARED = 'yes' + FILTER_SHARED_UNSHARED = 'no' - FILTER_UNREAD_OFF = '' + FILTER_UNREAD_OFF = 'off' FILTER_UNREAD_YES = 'yes' FILTER_UNREAD_NO = 'no' params = ['q', 'user', 'sort', 'shared', 'unread'] + preferences = ['sort', 'shared', 'unread'] defaults = { 'q': '', 'user': '', @@ -148,43 +149,59 @@ class BookmarkSearch: } def __init__(self, - q: str = defaults['q'], - query: str = defaults['q'], # alias for q - user: str = defaults['user'], - sort: str = defaults['sort'], - shared: str = defaults['shared'], - unread: str = defaults['unread']): - self.q = q or query - self.user = user - self.sort = sort - self.shared = shared - self.unread = unread - - @property - def query(self): - return self.q + q: str = None, + user: str = None, + sort: str = None, + shared: str = None, + unread: str = None, + preferences: dict = None): + if not preferences: + preferences = {} + self.defaults = {**BookmarkSearch.defaults, **preferences} + + self.q = q or self.defaults['q'] + self.user = user or self.defaults['user'] + self.sort = sort or self.defaults['sort'] + self.shared = shared or self.defaults['shared'] + self.unread = unread or self.defaults['unread'] def is_modified(self, param): value = self.__dict__[param] - return value and value != BookmarkSearch.defaults[param] + return value != self.defaults[param] @property def modified_params(self): return [field for field in self.params if self.is_modified(field)] + @property + def modified_preferences(self): + return [preference for preference in self.preferences if self.is_modified(preference)] + + @property + def has_modifications(self): + return len(self.modified_params) > 0 + + @property + def has_modified_preferences(self): + return len(self.modified_preferences) > 0 + @property def query_params(self): return {param: self.__dict__[param] for param in self.modified_params} + @property + def preferences_dict(self): + return {preference: self.__dict__[preference] for preference in self.preferences} + @staticmethod - def from_request(request: WSGIRequest): + def from_request(query_dict: QueryDict, preferences: dict = None): initial_values = {} for param in BookmarkSearch.params: - value = request.GET.get(param) + value = query_dict.get(param) if value: initial_values[param] = value - return BookmarkSearch(**initial_values) + return BookmarkSearch(**initial_values, preferences=preferences) class BookmarkSearchForm(forms.Form): @@ -214,6 +231,7 @@ class BookmarkSearchForm(forms.Form): def __init__(self, search: BookmarkSearch, editable_fields: List[str] = None, users: List[User] = None): super().__init__() editable_fields = editable_fields or [] + self.editable_fields = editable_fields # set choices for user field if users are provided if users: @@ -282,6 +300,7 @@ class UserProfile(models.Model): enable_favicons = models.BooleanField(default=False, null=False) display_url = models.BooleanField(default=False, null=False) permanent_notes = models.BooleanField(default=False, null=False) + search_preferences = models.JSONField(default=dict, null=False) class UserProfileForm(forms.ModelForm): diff --git a/bookmarks/queries.py b/bookmarks/queries.py index 63d7895b..dedeab6b 100644 --- a/bookmarks/queries.py +++ b/bookmarks/queries.py @@ -37,7 +37,7 @@ def _base_bookmarks_query(user: Optional[User], profile: UserProfile, search: Bo query_set = query_set.filter(owner=user) # Split query into search terms and tags - query = parse_query_string(search.query) + query = parse_query_string(search.q) # Filter for search terms and tags for term in query['search_terms']: diff --git a/bookmarks/styles/bookmark-page.scss b/bookmarks/styles/bookmark-page.scss index 1fb92fc8..8f9721af 100644 --- a/bookmarks/styles/bookmark-page.scss +++ b/bookmarks/styles/bookmark-page.scss @@ -13,7 +13,7 @@ } } -.bookmarks-page #search { +.bookmarks-page .search-container { flex: 1 1 0; display: flex; justify-content: flex-end; diff --git a/bookmarks/styles/spectre.scss b/bookmarks/styles/spectre.scss index 86fcaa32..1cb8b349 100644 --- a/bookmarks/styles/spectre.scss +++ b/bookmarks/styles/spectre.scss @@ -21,6 +21,7 @@ @import "../../node_modules/spectre.css/src/media"; // Components +@import "../../node_modules/spectre.css/src/badges"; @import "../../node_modules/spectre.css/src/dropdowns"; @import "../../node_modules/spectre.css/src/empty"; @import "../../node_modules/spectre.css/src/menus"; diff --git a/bookmarks/templates/bookmarks/search.html b/bookmarks/templates/bookmarks/search.html index 0ed20cb0..4b91d442 100644 --- a/bookmarks/templates/bookmarks/search.html +++ b/bookmarks/templates/bookmarks/search.html @@ -1,13 +1,16 @@ {% load widget_tweaks %} - - {% for hidden_field in form.hidden_fields %} - {{ hidden_field }} - {% endfor %} - {# Replace search input with auto-complete component #}