Add sort option to bookmark list (#522)

* Rename BookmarkFilters to BookmarkSearch

* Refactor queries to accept BookmarkSearch

* Sort query by data added and title

* Ensure pagination respects search parameters

* Ensure tag cloud respects search parameters

* Ensure user select respects search parameters

* Ensure return url respects search options

* Fix passing search options to user select

* Fix BookmarkSearch initialization

* Extract common search form logic

* Ensure partial update respects search options

* Add sort UI

* Use custom ICU collation when sorting with SQLite

* Support sort in API
This commit is contained in:
Sascha Ißbrücker
2023-09-01 22:48:21 +02:00
committed by GitHub
parent 0c50906056
commit 0975914a86
35 changed files with 1026 additions and 361 deletions

View File

@@ -6,7 +6,7 @@ from rest_framework.routers import DefaultRouter
from bookmarks import queries from bookmarks import queries
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
from bookmarks.models import Bookmark, BookmarkFilters, Tag, User from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark, website_loader from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark, website_loader
from bookmarks.services.website_loader import WebsiteMetadata from bookmarks.services.website_loader import WebsiteMetadata
@@ -34,8 +34,8 @@ class BookmarkViewSet(viewsets.GenericViewSet,
user = self.request.user user = self.request.user
# For list action, use query set that applies search and tag projections # For list action, use query set that applies search and tag projections
if self.action == 'list': if self.action == 'list':
query_string = self.request.GET.get('q') search = BookmarkSearch.from_request(self.request)
return queries.query_bookmarks(user, user.profile, query_string) return queries.query_bookmarks(user, user.profile, search)
# For single entity actions use default query set without projections # For single entity actions use default query set without projections
return Bookmark.objects.all().filter(owner=user) return Bookmark.objects.all().filter(owner=user)
@@ -46,8 +46,8 @@ class BookmarkViewSet(viewsets.GenericViewSet,
@action(methods=['get'], detail=False) @action(methods=['get'], detail=False)
def archived(self, request): def archived(self, request):
user = request.user user = request.user
query_string = request.GET.get('q') search = BookmarkSearch.from_request(request)
query_set = queries.query_archived_bookmarks(user, user.profile, query_string) query_set = queries.query_archived_bookmarks(user, user.profile, search)
page = self.paginate_queryset(query_set) page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class() serializer = self.get_serializer_class()
data = serializer(page, many=True).data data = serializer(page, many=True).data
@@ -55,10 +55,10 @@ class BookmarkViewSet(viewsets.GenericViewSet,
@action(methods=['get'], detail=False) @action(methods=['get'], detail=False)
def shared(self, request): def shared(self, request):
filters = BookmarkFilters(request) search = BookmarkSearch.from_request(request)
user = User.objects.filter(username=filters.user).first() user = User.objects.filter(username=search.user).first()
public_only = not request.user.is_authenticated public_only = not request.user.is_authenticated
query_set = queries.query_shared_bookmarks(user, request.user_profile, filters.query, public_only) query_set = queries.query_shared_bookmarks(user, request.user_profile, search, public_only)
page = self.paginate_queryset(query_set) page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class() serializer = self.get_serializer_class()
data = serializer(page, many=True).data data = serializer(page, many=True).data

View File

@@ -1,5 +1,5 @@
from bookmarks import queries from bookmarks import queries
from bookmarks.models import Toast from bookmarks.models import BookmarkSearch, Toast
from bookmarks import utils from bookmarks import utils
@@ -17,7 +17,7 @@ def toasts(request):
def public_shares(request): def public_shares(request):
# Only check for public shares for anonymous users # Only check for public shares for anonymous users
if not request.user.is_authenticated: if not request.user.is_authenticated:
query_set = queries.query_shared_bookmarks(None, request.user_profile, '', True) query_set = queries.query_shared_bookmarks(None, request.user_profile, BookmarkSearch(), True)
has_public_shares = query_set.count() > 0 has_public_shares = query_set.count() > 0
return { return {
'has_public_shares': has_public_shares, 'has_public_shares': has_public_shares,

View File

@@ -50,6 +50,21 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bookmark('foo 2').get_by_text('Archive').click() self.locate_bookmark('foo 2').get_by_text('Archive').click()
self.assertVisibleBookmarks(['foo 1', 'foo 3', 'foo 4', 'foo 5']) self.assertVisibleBookmarks(['foo 1', 'foo 3', 'foo 4', 'foo 5'])
def test_partial_update_respects_sort(self):
self.setup_numbered_bookmarks(5, prefix='foo')
with sync_playwright() as p:
url = reverse('bookmarks:index') + '?sort=title_asc'
page = self.open(url, p)
first_item = page.locator('li[ld-bookmark-item]').first
expect(first_item).to_contain_text('foo 1')
first_item.get_by_text('Archive').click()
first_item = page.locator('li[ld-bookmark-item]').first
expect(first_item).to_contain_text('foo 2')
def test_partial_update_respects_page(self): def test_partial_update_respects_page(self):
# add a suffix, otherwise 'foo 1' also matches 'foo 10' # add a suffix, otherwise 'foo 1' also matches 'foo 10'
self.setup_numbered_bookmarks(50, prefix='foo', suffix='-') self.setup_numbered_bookmarks(50, prefix='foo', suffix='-')

View File

@@ -4,7 +4,7 @@ from django.contrib.syndication.views import Feed
from django.db.models import QuerySet from django.db.models import QuerySet
from django.urls import reverse from django.urls import reverse
from bookmarks.models import Bookmark, FeedToken from bookmarks.models import Bookmark, BookmarkSearch, FeedToken
from bookmarks import queries from bookmarks import queries
@@ -17,8 +17,8 @@ class FeedContext:
class BaseBookmarksFeed(Feed): class BaseBookmarksFeed(Feed):
def get_object(self, request, feed_key: str): def get_object(self, request, feed_key: str):
feed_token = FeedToken.objects.get(key__exact=feed_key) feed_token = FeedToken.objects.get(key__exact=feed_key)
query_string = request.GET.get('q') search = BookmarkSearch(query=request.GET.get('q', ''))
query_set = queries.query_bookmarks(feed_token.user, feed_token.user.profile, query_string) query_set = queries.query_bookmarks(feed_token.user, feed_token.user.profile, search)
return FeedContext(feed_token, query_set) return FeedContext(feed_token, query_set)
def item_title(self, item: Bookmark): def item_title(self, item: Bookmark):

View File

@@ -3,10 +3,10 @@ export class ApiClient {
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
} }
listBookmarks(filters, options = { limit: 100, offset: 0, path: "" }) { listBookmarks(search, options = { limit: 100, offset: 0, path: "" }) {
const query = [`limit=${options.limit}`, `offset=${options.offset}`]; const query = [`limit=${options.limit}`, `offset=${options.offset}`];
Object.keys(filters).forEach((key) => { Object.keys(search).forEach((key) => {
const value = filters[key]; const value = search[key];
if (value) { if (value) {
query.push(`${key}=${encodeURIComponent(value)}`); query.push(`${key}=${encodeURIComponent(value)}`);
} }

View File

@@ -10,7 +10,7 @@
export let tags; export let tags;
export let mode = ''; export let mode = '';
export let apiClient; export let apiClient;
export let filters; export let search;
export let linkTarget = '_blank'; export let linkTarget = '_blank';
let isFocus = false; let isFocus = false;
@@ -115,11 +115,11 @@
if (value && value.length >= 3) { if (value && value.length >= 3) {
const path = mode ? `/${mode}` : '' const path = mode ? `/${mode}` : ''
const suggestionFilters = { const suggestionSearch = {
...filters, ...search,
q: value q: value
} }
const fetchedBookmarks = await apiClient.listBookmarks(suggestionFilters, {limit: 5, offset: 0, path}) const fetchedBookmarks = await apiClient.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
bookmarks = fetchedBookmarks.map(bookmark => { bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
const label = clampText(fullLabel, 60) const label = clampText(fullLabel, 60)

View File

@@ -11,7 +11,7 @@ class Command(BaseCommand):
help = "Enable WAL journal mode when using an SQLite database" help = "Enable WAL journal mode when using an SQLite database"
def handle(self, *args, **options): def handle(self, *args, **options):
if settings.DATABASES['default']['ENGINE'] != 'django.db.backends.sqlite3': if not settings.USE_SQLITE:
return return
connection = connections['default'] connection = connections['default']

View File

@@ -124,10 +124,86 @@ class BookmarkForm(forms.ModelForm):
return self.instance and self.instance.notes return self.instance and self.instance.notes
class BookmarkFilters: class BookmarkSearch:
def __init__(self, request: WSGIRequest): SORT_ADDED_ASC = 'added_asc'
self.query = request.GET.get('q') or '' SORT_ADDED_DESC = 'added_desc'
self.user = request.GET.get('user') or '' SORT_TITLE_ASC = 'title_asc'
SORT_TITLE_DESC = 'title_desc'
params = ['q', 'user', 'sort']
defaults = {
'q': '',
'user': '',
'sort': SORT_ADDED_DESC,
}
def __init__(self,
q: str = defaults['q'],
query: str = defaults['q'], # alias for q
user: str = defaults['user'],
sort: str = defaults['sort']):
self.q = q or query
self.user = user
self.sort = sort
@property
def query(self):
return self.q
def is_modified(self, param):
value = self.__dict__[param]
return value and value != BookmarkSearch.defaults[param]
@property
def modified_params(self):
return [field for field in self.params if self.is_modified(field)]
@property
def query_params(self):
return {param: self.__dict__[param] for param in self.modified_params}
@staticmethod
def from_request(request: WSGIRequest):
initial_values = {}
for param in BookmarkSearch.params:
value = request.GET.get(param)
if value:
initial_values[param] = value
return BookmarkSearch(**initial_values)
class BookmarkSearchForm(forms.Form):
SORT_CHOICES = [
(BookmarkSearch.SORT_ADDED_ASC, 'Added ↑'),
(BookmarkSearch.SORT_ADDED_DESC, 'Added ↓'),
(BookmarkSearch.SORT_TITLE_ASC, 'Title ↑'),
(BookmarkSearch.SORT_TITLE_DESC, 'Title ↓'),
]
q = forms.CharField()
user = forms.ChoiceField()
sort = forms.ChoiceField(choices=SORT_CHOICES)
def __init__(self, search: BookmarkSearch, editable_fields: List[str] = None, users: List[User] = None):
super().__init__()
editable_fields = editable_fields or []
# set choices for user field if users are provided
if users:
user_choices = [(user.username, user.username) for user in users]
user_choices.insert(0, ('', 'Everyone'))
self.fields['user'].choices = user_choices
for param in search.params:
# set initial values for modified params
self.fields[param].initial = search.__dict__[param]
# Mark non-editable modified fields as hidden. That way, templates
# rendering a form can just loop over hidden_fields to ensure that
# all necessary search options are kept when submitting the form.
if search.is_modified(param) and param not in editable_fields:
self.fields[param].widget = forms.HiddenInput()
class UserProfile(models.Model): class UserProfile(models.Model):

View File

@@ -1,32 +1,35 @@
from typing import Optional from typing import Optional
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Q, QuerySet, Exists, OuterRef 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
from bookmarks.models import Bookmark, Tag, UserProfile from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
from bookmarks.utils import unique from bookmarks.utils import unique
def query_bookmarks(user: User, profile: UserProfile, query_string: str) -> QuerySet: def query_bookmarks(user: User, profile: UserProfile, search: BookmarkSearch) -> QuerySet:
return _base_bookmarks_query(user, profile, query_string) \ return _base_bookmarks_query(user, profile, search) \
.filter(is_archived=False) .filter(is_archived=False)
def query_archived_bookmarks(user: User, profile: UserProfile, query_string: str) -> QuerySet: def query_archived_bookmarks(user: User, profile: UserProfile, search: BookmarkSearch) -> QuerySet:
return _base_bookmarks_query(user, profile, query_string) \ return _base_bookmarks_query(user, profile, search) \
.filter(is_archived=True) .filter(is_archived=True)
def query_shared_bookmarks(user: Optional[User], profile: UserProfile, query_string: str, def query_shared_bookmarks(user: Optional[User], profile: UserProfile, search: BookmarkSearch,
public_only: bool) -> QuerySet: public_only: bool) -> QuerySet:
conditions = Q(shared=True) & Q(owner__profile__enable_sharing=True) conditions = Q(shared=True) & Q(owner__profile__enable_sharing=True)
if public_only: if public_only:
conditions = conditions & Q(owner__profile__enable_public_sharing=True) conditions = conditions & Q(owner__profile__enable_public_sharing=True)
return _base_bookmarks_query(user, profile, query_string).filter(conditions) return _base_bookmarks_query(user, profile, search).filter(conditions)
def _base_bookmarks_query(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet: def _base_bookmarks_query(user: Optional[User], profile: UserProfile, search: BookmarkSearch) -> QuerySet:
query_set = Bookmark.objects query_set = Bookmark.objects
# Filter for user # Filter for user
@@ -34,7 +37,7 @@ def _base_bookmarks_query(user: Optional[User], profile: UserProfile, query_stri
query_set = query_set.filter(owner=user) query_set = query_set.filter(owner=user)
# Split query into search terms and tags # Split query into search terms and tags
query = parse_query_string(query_string) query = parse_query_string(search.query)
# Filter for search terms and tags # Filter for search terms and tags
for term in query['search_terms']: for term in query['search_terms']:
@@ -67,38 +70,66 @@ def _base_bookmarks_query(user: Optional[User], profile: UserProfile, query_stri
) )
# Sort by date added # Sort by date added
query_set = query_set.order_by('-date_added') if search.sort == BookmarkSearch.SORT_ADDED_ASC:
query_set = query_set.order_by('date_added')
elif search.sort == BookmarkSearch.SORT_ADDED_DESC:
query_set = query_set.order_by('-date_added')
# Sort by title
if search.sort == BookmarkSearch.SORT_TITLE_ASC or search.sort == BookmarkSearch.SORT_TITLE_DESC:
# For the title, the resolved_title logic from the Bookmark entity needs
# to be replicated as there is no corresponding database field
query_set = query_set.annotate(
effective_title=Case(
When(Q(title__isnull=False) & ~Q(title__exact=''), then=Lower('title')),
When(Q(website_title__isnull=False) & ~Q(website_title__exact=''), then=Lower('website_title')),
default=Lower('url'),
output_field=CharField()
))
# For SQLite, if the ICU extension is loaded, use the custom collation
# loaded into the connection. This results in an improved sort order for
# unicode characters (umlauts, etc.)
if settings.USE_SQLITE and settings.USE_SQLITE_ICU_EXTENSION:
order_field = RawSQL('effective_title COLLATE ICU', ())
else:
order_field = 'effective_title'
if search.sort == BookmarkSearch.SORT_TITLE_ASC:
query_set = query_set.order_by(order_field)
elif search.sort == BookmarkSearch.SORT_TITLE_DESC:
query_set = query_set.order_by(order_field).reverse()
return query_set return query_set
def query_bookmark_tags(user: User, profile: UserProfile, query_string: str) -> QuerySet: def query_bookmark_tags(user: User, profile: UserProfile, search: BookmarkSearch) -> QuerySet:
bookmarks_query = query_bookmarks(user, profile, query_string) bookmarks_query = query_bookmarks(user, profile, search)
query_set = Tag.objects.filter(bookmark__in=bookmarks_query) query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct() return query_set.distinct()
def query_archived_bookmark_tags(user: User, profile: UserProfile, query_string: str) -> QuerySet: def query_archived_bookmark_tags(user: User, profile: UserProfile, search: BookmarkSearch) -> QuerySet:
bookmarks_query = query_archived_bookmarks(user, profile, query_string) bookmarks_query = query_archived_bookmarks(user, profile, search)
query_set = Tag.objects.filter(bookmark__in=bookmarks_query) query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct() return query_set.distinct()
def query_shared_bookmark_tags(user: Optional[User], profile: UserProfile, query_string: str, def query_shared_bookmark_tags(user: Optional[User], profile: UserProfile, search: BookmarkSearch,
public_only: bool) -> QuerySet: public_only: bool) -> QuerySet:
bookmarks_query = query_shared_bookmarks(user, profile, query_string, public_only) bookmarks_query = query_shared_bookmarks(user, profile, search, public_only)
query_set = Tag.objects.filter(bookmark__in=bookmarks_query) query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
return query_set.distinct() return query_set.distinct()
def query_shared_bookmark_users(profile: UserProfile, query_string: str, public_only: bool) -> QuerySet: def query_shared_bookmark_users(profile: UserProfile, search: BookmarkSearch, public_only: bool) -> QuerySet:
bookmarks_query = query_shared_bookmarks(None, profile, query_string, public_only) bookmarks_query = query_shared_bookmarks(None, profile, search, public_only)
query_set = User.objects.filter(bookmark__in=bookmarks_query) query_set = User.objects.filter(bookmark__in=bookmarks_query)

View File

@@ -1,14 +1,10 @@
from os import path from django.conf import settings
from django.contrib.auth import user_logged_in from django.contrib.auth import user_logged_in
from django.db.backends.signals import connection_created from django.db.backends.signals import connection_created
from django.dispatch import receiver from django.dispatch import receiver
from bookmarks.services import tasks from bookmarks.services import tasks
icu_extension_path = './libicu.so'
icu_extension_exists = path.exists(icu_extension_path)
@receiver(user_logged_in) @receiver(user_logged_in)
def user_logged_in(sender, request, user, **kwargs): def user_logged_in(sender, request, user, **kwargs):
@@ -19,9 +15,9 @@ def user_logged_in(sender, request, user, **kwargs):
def extend_sqlite(connection=None, **kwargs): def extend_sqlite(connection=None, **kwargs):
# Load ICU extension into Sqlite connection to support case-insensitive # Load ICU extension into Sqlite connection to support case-insensitive
# comparisons with unicode characters # comparisons with unicode characters
if connection.vendor == 'sqlite' and icu_extension_exists: if connection.vendor == 'sqlite' and settings.USE_SQLITE_ICU_EXTENSION:
connection.connection.enable_load_extension(True) connection.connection.enable_load_extension(True)
connection.connection.load_extension('./libicu') connection.connection.load_extension(settings.SQLITE_ICU_EXTENSION_PATH.rstrip('.so'))
with connection.cursor() as cursor: with connection.cursor() as cursor:
# Load an ICU collation for case-insensitive ordering. # Load an ICU collation for case-insensitive ordering.

View File

@@ -40,6 +40,41 @@
} }
} }
} }
// Group search options button with search button
.input-group input[type='submit'] {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.dropdown button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.dropdown {
margin-left: -1px;
}
// Search option menu styles
.dropdown {
.menu {
padding: $unit-4;
min-width: 220px;
}
&:focus-within {
.menu {
display: block;
}
}
.menu .actions {
margin-top: $unit-4;
display: flex;
justify-content: space-between;
}
}
} }
/* Bookmark list */ /* Bookmark list */

View File

@@ -15,13 +15,14 @@
<div class="content-area-header mb-0"> <div class="content-area-header mb-0">
<h2>Archived bookmarks</h2> <h2>Archived bookmarks</h2>
<div class="d-flex"> <div class="d-flex">
{% bookmark_search bookmark_list.filters tag_cloud.tags mode='archived' %} {% bookmark_search bookmark_list.search tag_cloud.tags mode='archived' %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
</div> </div>
</div> </div>
<form class="bookmark-actions" action="{% url 'bookmarks:archived.action' %}?q={{ bookmark_list.filters.query }}&return_url={{ bookmark_list.return_url }}" <form class="bookmark-actions"
method="post"> action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %} {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %}

View File

@@ -65,7 +65,7 @@
{% endif %} {% endif %}
{% if bookmark_item.is_editable %} {% if bookmark_item.is_editable %}
{# Bookmark owner actions #} {# Bookmark owner actions #}
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url }}">Edit</a> <a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
{% if bookmark_item.is_archived %} {% if bookmark_item.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}" <button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm">Unarchive class="btn btn-link btn-sm">Unarchive

View File

@@ -15,12 +15,13 @@
<div class="content-area-header mb-0"> <div class="content-area-header mb-0">
<h2>Bookmarks</h2> <h2>Bookmarks</h2>
<div class="d-flex"> <div class="d-flex">
{% bookmark_search bookmark_list.filters tag_cloud.tags %} {% bookmark_search bookmark_list.search tag_cloud.tags %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
</div> </div>
</div> </div>
<form class="bookmark-actions" action="{% url 'bookmarks:index.action' %}?q={{ bookmark_list.filters.query }}&return_url={{ bookmark_list.return_url }}" <form class="bookmark-actions"
action="{{ bookmark_list.action_url|safe }}"
method="post" autocomplete="off"> method="post" autocomplete="off">
{% csrf_token %} {% csrf_token %}
{% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %} {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %}

View File

@@ -1,15 +1,46 @@
{% load widget_tweaks %}
<div class="search"> <div class="search">
<form action="" method="get" role="search"> <form action="" method="get" role="search">
<div class="input-group"> <div class="d-flex">
<span id="search-input-wrap"> <div class="input-group">
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags" <span id="search-input-wrap">
value="{{ filters.query }}"> <input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
</span> value="{{ search.query }}">
<input type="submit" value="Search" class="btn input-group-btn"> </span>
<input type="submit" value="Search" class="btn input-group-btn">
</div>
<div class="search-options dropdown dropdown-right">
<button type="button" class="btn dropdown-toggle">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 10a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path>
<path d="M6 4v4"></path>
<path d="M6 12v8"></path>
<path d="M10 16a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path>
<path d="M12 4v10"></path>
<path d="M12 18v2"></path>
<path d="M16 7a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path>
<path d="M18 4v1"></path>
<path d="M18 9v11"></path>
</svg>
</button>
<div class="menu text-sm" tabindex="0">
<div class="form-group">
<label for="{{ form.sort.id_for_label }}" class="form-label">Sort by</label>
{{ form.sort|add_class:"form-select select-sm" }}
</div>
<div class="actions">
<button type="submit" class="btn btn-sm btn-primary">Apply</button>
</div>
</div>
</div>
</div> </div>
{% if filters.user %}
<input type="hidden" name="user" value="{{ filters.user }}"> {% for hidden_field in form.hidden_fields %}
{% endif %} {{ hidden_field }}
{% endfor %}
</form> </form>
</div> </div>
@@ -19,9 +50,9 @@
const currentTagsString = '{{ tags_string }}'; const currentTagsString = '{{ tags_string }}';
const currentTags = currentTagsString.split(' '); const currentTags = currentTagsString.split(' ');
const uniqueTags = [...new Set(currentTags)] const uniqueTags = [...new Set(currentTags)]
const filters = { const search = {
q: '{{ filters.query }}', q: '{{ search.query }}',
user: '{{ filters.user }}', user: '{{ search.user }}',
} }
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}') const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
const wrapper = document.getElementById('search-input-wrap') const wrapper = document.getElementById('search-input-wrap')
@@ -31,12 +62,12 @@
props: { props: {
name: 'q', name: 'q',
placeholder: 'Search for words or #tags', placeholder: 'Search for words or #tags',
value: '{{ filters.query }}', value: '{{ search.query|safe }}',
tags: uniqueTags, tags: uniqueTags,
mode: '{{ mode }}', mode: '{{ mode }}',
linkTarget: '{{ request.user_profile.bookmark_link_target }}', linkTarget: '{{ request.user_profile.bookmark_link_target }}',
apiClient, apiClient,
filters, search,
} }
}) })
wrapper.parentElement.replaceChild(newWrapper, wrapper) wrapper.parentElement.replaceChild(newWrapper, wrapper)

View File

@@ -13,10 +13,10 @@
<section class="content-area col-2"> <section class="content-area col-2">
<div class="content-area-header"> <div class="content-area-header">
<h2>Shared bookmarks</h2> <h2>Shared bookmarks</h2>
{% bookmark_search bookmark_list.filters tag_cloud.tags mode='shared' %} {% bookmark_search bookmark_list.search tag_cloud.tags mode='shared' %}
</div> </div>
<form class="bookmark-actions" action="{% url 'bookmarks:shared.action' %}?return_url={{ bookmark_list.return_url }}" <form class="bookmark-actions" action="{{ bookmark_list.action_url|safe }}"
method="post"> method="post">
{% csrf_token %} {% csrf_token %}
@@ -32,7 +32,7 @@
<h2>User</h2> <h2>User</h2>
</div> </div>
<div> <div>
{% user_select filters users %} {% user_select bookmark_list.search users %}
<br> <br>
</div> </div>
<div class="content-area-header"> <div class="content-area-header">

View File

@@ -1,19 +1,12 @@
{% load widget_tweaks %}
<form id="user-select" action="" method="get"> <form id="user-select" action="" method="get">
{% if filters.query %} {% for hidden_field in form.hidden_fields %}
<input type="hidden" name="q" value="{{ filters.query }}"> {{ hidden_field }}
{% endif %} {% endfor %}
<div class="form-group"> <div class="form-group">
<div class="d-flex"> <div class="d-flex">
<select name="user" class="form-select"> {{ form.user|add_class:"form-select" }}
<option value="">Everyone</option>
{% for user in users %}
<option value="{{ user.username }}"
{% if user.username == filters.user %}selected{% endif %}
data-is-user-option>
{{ user.username }}
</option>
{% endfor %}
</select>
<noscript> <noscript>
<button type="submit" class="btn btn-link ml-2">Apply</button> <button type="submit" class="btn btn-link ml-2">Apply</button>
</noscript> </noscript>

View File

@@ -2,7 +2,7 @@ from typing import List
from django import template from django import template
from bookmarks.models import BookmarkForm, BookmarkFilters, Tag, build_tag_string, User from bookmarks.models import BookmarkForm, BookmarkSearch, BookmarkSearchForm, Tag, build_tag_string, User
register = template.Library() register = template.Library()
@@ -19,21 +19,25 @@ def bookmark_form(context, form: BookmarkForm, cancel_url: str, bookmark_id: int
@register.inclusion_tag('bookmarks/search.html', name='bookmark_search', takes_context=True) @register.inclusion_tag('bookmarks/search.html', name='bookmark_search', takes_context=True)
def bookmark_search(context, filters: BookmarkFilters, tags: [Tag], mode: str = ''): def bookmark_search(context, search: BookmarkSearch, tags: [Tag], mode: str = ''):
tag_names = [tag.name for tag in tags] tag_names = [tag.name for tag in tags]
tags_string = build_tag_string(tag_names, ' ') tags_string = build_tag_string(tag_names, ' ')
form = BookmarkSearchForm(search, editable_fields=['q', 'sort'])
return { return {
'request': context['request'], 'request': context['request'],
'filters': filters, 'search': search,
'form': form,
'tags_string': tags_string, 'tags_string': tags_string,
'mode': mode, 'mode': mode,
} }
@register.inclusion_tag('bookmarks/user_select.html', name='user_select', takes_context=True) @register.inclusion_tag('bookmarks/user_select.html', name='user_select', takes_context=True)
def user_select(context, filters: BookmarkFilters, users: List[User]): def user_select(context, search: BookmarkSearch, users: List[User]):
sorted_users = sorted(users, key=lambda x: str.lower(x.username)) sorted_users = sorted(users, key=lambda x: str.lower(x.username))
form = BookmarkSearchForm(search, editable_fields=['user'], users=sorted_users)
return { return {
'filters': filters, 'search': search,
'users': sorted_users, 'users': sorted_users,
'form': form,
} }

View File

@@ -29,7 +29,7 @@ class BookmarkFactoryMixin:
tags=None, tags=None,
user: User = None, user: User = None,
url: str = '', url: str = '',
title: str = '', title: str = None,
description: str = '', description: str = '',
notes: str = '', notes: str = '',
website_title: str = '', website_title: str = '',
@@ -38,7 +38,7 @@ class BookmarkFactoryMixin:
favicon_file: str = '', favicon_file: str = '',
added: datetime = None, added: datetime = None,
): ):
if not title: if title is None:
title = get_random_string(length=32) title = get_random_string(length=32)
if tags is None: if tags is None:
tags = [] tags = []
@@ -81,6 +81,7 @@ class BookmarkFactoryMixin:
with_tags: bool = False, with_tags: bool = False,
user: User = None): user: User = None):
user = user or self.get_or_create_test_user() user = user or self.get_or_create_test_user()
bookmarks = []
if not prefix: if not prefix:
if archived: if archived:
@@ -105,7 +106,11 @@ class BookmarkFactoryMixin:
if with_tags: if with_tags:
tag_name = f'{tag_prefix} {i}{suffix}' tag_name = f'{tag_prefix} {i}{suffix}'
tags = [self.setup_tag(name=tag_name)] tags = [self.setup_tag(name=tag_name)]
self.setup_bookmark(url=url, title=title, is_archived=archived, shared=shared, tags=tags, user=user) bookmark = self.setup_bookmark(url=url, title=title, is_archived=archived, shared=shared, tags=tags,
user=user)
bookmarks.append(bookmark)
return bookmarks
def get_numbered_bookmark(self, title: str): def get_numbered_bookmark(self, title: str):
return Bookmark.objects.get(title=title) return Bookmark.objects.get(title=title)
@@ -128,6 +133,9 @@ class BookmarkFactoryMixin:
user.profile.save() user.profile.save()
return user return user
def get_random_string(self, length: int = 32):
return get_random_string(length=length)
class HtmlTestMixin: class HtmlTestMixin:
def make_soup(self, html: str): def make_soup(self, html: str):

View File

@@ -1,3 +1,4 @@
import urllib.parse
from typing import List from typing import List
from django.contrib.auth.models import User from django.contrib.auth.models import User
@@ -55,6 +56,21 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
for tag in tags: for tag in tags:
self.assertTrue(tag.name in selected_tags.text, msg=f'Selected tags do not contain: {tag.name}') self.assertTrue(tag.name in selected_tags.text, msg=f'Selected tags do not contain: {tag.name}')
def assertEditLink(self, response, url):
html = response.content.decode()
self.assertInHTML(f'''
<a href="{url}">Edit</a>
''', html)
def assertBulkActionForm(self, response, url: str):
html = collapse_whitespace(response.content.decode())
needle = collapse_whitespace(f'''
<form class="bookmark-actions"
action="{url}"
method="post" autocomplete="off">
''')
self.assertIn(needle, html)
def test_should_list_archived_and_user_owned_bookmarks(self): def test_should_list_archived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
visible_bookmarks = [ visible_bookmarks = [
@@ -219,6 +235,61 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertVisibleBookmarks(response, visible_bookmarks, '_self') self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
def test_edit_link_return_url_respects_search_options(self):
bookmark = self.setup_bookmark(title='foo', is_archived=True)
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
base_url = reverse('bookmarks:archived')
# without query params
return_url = urllib.parse.quote(base_url)
url = f'{edit_url}?return_url={return_url}'
response = self.client.get(base_url)
self.assertEditLink(response, url)
# with query
url_params = '?q=foo'
return_url = urllib.parse.quote(base_url + url_params)
url = f'{edit_url}?return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertEditLink(response, url)
# with query and sort and page
url_params = '?q=foo&sort=title_asc&page=2'
return_url = urllib.parse.quote(base_url + url_params)
url = f'{edit_url}?return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertEditLink(response, url)
def test_bulk_edit_respects_search_options(self):
action_url = reverse('bookmarks:archived.action')
base_url = reverse('bookmarks:archived')
# without params
return_url = urllib.parse.quote_plus(base_url)
url = f'{action_url}?return_url={return_url}'
response = self.client.get(base_url)
self.assertBulkActionForm(response, url)
# with query
url_params = '?q=foo'
return_url = urllib.parse.quote_plus(base_url + url_params)
url = f'{action_url}?q=foo&return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertBulkActionForm(response, url)
# with query and sort
url_params = '?q=foo&sort=title_asc'
return_url = urllib.parse.quote_plus(base_url + url_params)
url = f'{action_url}?q=foo&sort=title_asc&return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertBulkActionForm(response, url)
def test_allowed_bulk_actions(self): def test_allowed_bulk_actions(self):
url = reverse('bookmarks:archived') url = reverse('bookmarks:archived')
response = self.client.get(url) response = self.client.get(url)

View File

@@ -1,5 +1,5 @@
from typing import List
import urllib.parse import urllib.parse
from typing import List
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase
@@ -56,6 +56,21 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
for tag in tags: for tag in tags:
self.assertTrue(tag.name in selected_tags.text, msg=f'Selected tags do not contain: {tag.name}') self.assertTrue(tag.name in selected_tags.text, msg=f'Selected tags do not contain: {tag.name}')
def assertEditLink(self, response, url):
html = response.content.decode()
self.assertInHTML(f'''
<a href="{url}">Edit</a>
''', html)
def assertBulkActionForm(self, response, url: str):
html = collapse_whitespace(response.content.decode())
needle = collapse_whitespace(f'''
<form class="bookmark-actions"
action="{url}"
method="post" autocomplete="off">
''')
self.assertIn(needle, html)
def test_should_list_unarchived_and_user_owned_bookmarks(self): def test_should_list_unarchived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
visible_bookmarks = [ visible_bookmarks = [
@@ -220,30 +235,60 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertVisibleBookmarks(response, visible_bookmarks, '_self') self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
def test_edit_link_return_url_should_contain_query_params(self): def test_edit_link_return_url_respects_search_options(self):
bookmark = self.setup_bookmark(title='foo') bookmark = self.setup_bookmark(title='foo')
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
base_url = reverse('bookmarks:index')
# without query params # without query params
url = reverse('bookmarks:index') return_url = urllib.parse.quote(base_url)
response = self.client.get(url) url = f'{edit_url}?return_url={return_url}'
html = response.content.decode()
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
return_url = urllib.parse.quote_plus(url)
self.assertInHTML(f''' response = self.client.get(base_url)
<a href="{edit_url}?return_url={return_url}">Edit</a> self.assertEditLink(response, url)
''', html)
# with query params # with query
url = reverse('bookmarks:index') + '?q=foo&user=user' url_params = '?q=foo'
response = self.client.get(url) return_url = urllib.parse.quote(base_url + url_params)
html = response.content.decode() url = f'{edit_url}?return_url={return_url}'
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
return_url = urllib.parse.quote_plus(url)
self.assertInHTML(f''' response = self.client.get(base_url + url_params)
<a href="{edit_url}?return_url={return_url}">Edit</a> self.assertEditLink(response, url)
''', html)
# with query and sort and page
url_params = '?q=foo&sort=title_asc&page=2'
return_url = urllib.parse.quote(base_url + url_params)
url = f'{edit_url}?return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertEditLink(response, url)
def test_bulk_edit_respects_search_options(self):
action_url = reverse('bookmarks:index.action')
base_url = reverse('bookmarks:index')
# without params
return_url = urllib.parse.quote_plus(base_url)
url = f'{action_url}?return_url={return_url}'
response = self.client.get(base_url)
self.assertBulkActionForm(response, url)
# with query
url_params = '?q=foo'
return_url = urllib.parse.quote_plus(base_url + url_params)
url = f'{action_url}?q=foo&return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertBulkActionForm(response, url)
# with query and sort
url_params = '?q=foo&sort=title_asc'
return_url = urllib.parse.quote_plus(base_url + url_params)
url = f'{action_url}?q=foo&sort=title_asc&return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertBulkActionForm(response, url)
def test_allowed_bulk_actions(self): def test_allowed_bulk_actions(self):
url = reverse('bookmarks:index') url = reverse('bookmarks:index')

View File

@@ -0,0 +1,58 @@
from django.test import TestCase
from bookmarks.models import BookmarkSearch, BookmarkSearchForm
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
def test_initial_values(self):
# no params
search = BookmarkSearch()
form = BookmarkSearchForm(search)
self.assertEqual(form['q'].initial, '')
self.assertEqual(form['sort'].initial, BookmarkSearch.SORT_ADDED_DESC)
self.assertEqual(form['user'].initial, '')
# with params
search = BookmarkSearch(q='search query', sort=BookmarkSearch.SORT_ADDED_ASC, user='user123')
form = BookmarkSearchForm(search)
self.assertEqual(form['q'].initial, 'search query')
self.assertEqual(form['sort'].initial, BookmarkSearch.SORT_ADDED_ASC)
self.assertEqual(form['user'].initial, 'user123')
def test_user_options(self):
users = [
self.setup_user('user1'),
self.setup_user('user2'),
self.setup_user('user3'),
]
search = BookmarkSearch()
form = BookmarkSearchForm(search, users=users)
self.assertCountEqual(form['user'].field.choices, [
('', 'Everyone'),
('user1', 'user1'),
('user2', 'user2'),
('user3', 'user3'),
])
def test_hidden_fields(self):
# no modified params
search = BookmarkSearch()
form = BookmarkSearchForm(search)
self.assertEqual(len(form.hidden_fields()), 0)
# some modified params
search = BookmarkSearch(q='search query', sort=BookmarkSearch.SORT_ADDED_ASC)
form = BookmarkSearchForm(search)
self.assertCountEqual(form.hidden_fields(), [form['q'], form['sort']])
# all modified params
search = BookmarkSearch(q='search query', sort=BookmarkSearch.SORT_ADDED_ASC, user='user123')
form = BookmarkSearchForm(search)
self.assertCountEqual(form.hidden_fields(), [form['q'], form['sort'], form['user']])
# some modified params are editable fields
search = BookmarkSearch(q='search query', sort=BookmarkSearch.SORT_ADDED_ASC, user='user123')
form = BookmarkSearchForm(search, editable_fields=['q', 'user'])
self.assertCountEqual(form.hidden_fields(), [form['sort']])

View File

@@ -0,0 +1,59 @@
from unittest.mock import Mock
from bookmarks.models import BookmarkSearch
from django.test import TestCase
class BookmarkSearchModelTest(TestCase):
def test_from_request(self):
# no params
mock_request = Mock()
mock_request.GET = {}
search = BookmarkSearch.from_request(mock_request)
self.assertEqual(search.q, '')
self.assertEqual(search.sort, BookmarkSearch.SORT_ADDED_DESC)
self.assertEqual(search.user, '')
# some params
mock_request.GET = {
'q': 'search query',
'user': 'user123',
}
bookmark_search = BookmarkSearch.from_request(mock_request)
self.assertEqual(bookmark_search.q, 'search query')
self.assertEqual(bookmark_search.sort, BookmarkSearch.SORT_ADDED_DESC)
self.assertEqual(bookmark_search.user, 'user123')
# all params
mock_request.GET = {
'q': 'search query',
'user': 'user123',
'sort': BookmarkSearch.SORT_TITLE_ASC
}
search = BookmarkSearch.from_request(mock_request)
self.assertEqual(search.q, 'search query')
self.assertEqual(search.user, 'user123')
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
def test_modified_params(self):
# no params
bookmark_search = BookmarkSearch()
modified_params = bookmark_search.modified_params
self.assertEqual(len(modified_params), 0)
# params are default values
bookmark_search = BookmarkSearch(q='', sort=BookmarkSearch.SORT_ADDED_DESC, user='')
modified_params = bookmark_search.modified_params
self.assertEqual(len(modified_params), 0)
# some modified params
bookmark_search = BookmarkSearch(q='search query', sort=BookmarkSearch.SORT_ADDED_ASC)
modified_params = bookmark_search.modified_params
self.assertCountEqual(modified_params, ['q', 'sort'])
# all modified params
bookmark_search = BookmarkSearch(q='search query', sort=BookmarkSearch.SORT_ADDED_ASC, user='user123')
modified_params = bookmark_search.modified_params
self.assertCountEqual(modified_params, ['q', 'sort', 'user'])

View File

@@ -2,7 +2,7 @@ from django.db.models import QuerySet
from django.template import Template, RequestContext from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory from django.test import TestCase, RequestFactory
from bookmarks.models import BookmarkFilters, Tag from bookmarks.models import BookmarkSearch, Tag
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -12,31 +12,43 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin):
request = rf.get(url) request = rf.get(url)
request.user = self.get_or_create_test_user() request.user = self.get_or_create_test_user()
request.user_profile = self.get_or_create_test_user().profile request.user_profile = self.get_or_create_test_user().profile
filters = BookmarkFilters(request) search = BookmarkSearch.from_request(request)
context = RequestContext(request, { context = RequestContext(request, {
'request': request, 'request': request,
'filters': filters, 'search': search,
'tags': tags, 'tags': tags,
}) })
template_to_render = Template( template_to_render = Template(
'{% load bookmarks %}' '{% load bookmarks %}'
'{% bookmark_search filters tags %}' '{% bookmark_search search tags %}'
) )
return template_to_render.render(context) return template_to_render.render(context)
def test_render_hidden_inputs_for_filter_params(self): def assertHiddenInput(self, html: str, name: str, value: str = None):
# Should render hidden inputs if query param exists needle = f'<input type="hidden" name="{name}"'
url = '/test?q=foo&user=john' if value is not None:
needle += f' value="{value}"'
self.assertIn(needle, html)
def assertNoHiddenInput(self, html: str, name: str):
needle = f'<input type="hidden" name="{name}"'
self.assertNotIn(needle, html)
def test_hidden_inputs(self):
# Without params
url = '/test'
rendered_template = self.render_template(url) rendered_template = self.render_template(url)
self.assertInHTML(''' self.assertNoHiddenInput(rendered_template, 'user')
<input type="hidden" name="user" value="john"> self.assertNoHiddenInput(rendered_template, 'q')
''', rendered_template) self.assertNoHiddenInput(rendered_template, 'sort')
# Should not render hidden inputs if query param does not exist # With params
url = '/test?q=foo' url = '/test?q=foo&user=john&sort=title_asc'
rendered_template = self.render_template(url) rendered_template = self.render_template(url)
self.assertInHTML(''' self.assertHiddenInput(rendered_template, 'user', 'john')
<input type="hidden" name="user" value="john"> self.assertNoHiddenInput(rendered_template, 'q')
''', rendered_template, count=0) self.assertNoHiddenInput(rendered_template, 'sort')

View File

@@ -1,3 +1,4 @@
import urllib.parse
from typing import List from typing import List
from django.contrib.auth.models import User from django.contrib.auth.models import User
@@ -45,24 +46,25 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
def assertVisibleUserOptions(self, response, users: List[User]): def assertVisibleUserOptions(self, response, users: List[User]):
html = response.content.decode() html = response.content.decode()
self.assertContains(response, 'data-is-user-option', count=len(users))
user_options = [
'<option value="" selected="">Everyone</option>'
]
for user in users: for user in users:
self.assertInHTML(f''' user_options.append(f'<option value="{user.username}">{user.username}</option>')
<option value="{user.username}" data-is-user-option> user_select_html = f'''
{user.username} <select name="user" class="form-select" required="" id="id_user">
</option> {''.join(user_options)}
''', html) </select>
'''
def assertInvisibleUserOptions(self, response, users: List[User]): self.assertInHTML(user_select_html, html)
def assertEditLink(self, response, url):
html = response.content.decode() html = response.content.decode()
self.assertInHTML(f'''
for user in users: <a href="{url}">Edit</a>
self.assertInHTML(f''' ''', html)
<option value="{user.username}" data-is-user-option>
{user.username}
</option>
''', html, count=0)
def test_should_list_shared_bookmarks_from_all_users_that_have_sharing_enabled(self): def test_should_list_shared_bookmarks_from_all_users_that_have_sharing_enabled(self):
self.authenticate() self.authenticate()
@@ -267,41 +269,33 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
def test_should_list_users_with_shared_bookmarks_if_sharing_is_enabled(self): def test_should_list_users_with_shared_bookmarks_if_sharing_is_enabled(self):
self.authenticate() self.authenticate()
expected_visible_users = [ expected_visible_users = [
self.setup_user(enable_sharing=True), self.setup_user(name='user_a', enable_sharing=True),
self.setup_user(enable_sharing=True), self.setup_user(name='user_b', enable_sharing=True),
] ]
self.setup_bookmark(shared=True, user=expected_visible_users[0]) self.setup_bookmark(shared=True, user=expected_visible_users[0])
self.setup_bookmark(shared=True, user=expected_visible_users[1]) self.setup_bookmark(shared=True, user=expected_visible_users[1])
expected_invisible_users = [ self.setup_bookmark(shared=False, user=self.setup_user(enable_sharing=True))
self.setup_user(enable_sharing=True), self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=False))
self.setup_user(enable_sharing=False),
]
self.setup_bookmark(shared=False, user=expected_invisible_users[0])
self.setup_bookmark(shared=True, user=expected_invisible_users[1])
response = self.client.get(reverse('bookmarks:shared')) response = self.client.get(reverse('bookmarks:shared'))
self.assertVisibleUserOptions(response, expected_visible_users) self.assertVisibleUserOptions(response, expected_visible_users)
self.assertInvisibleUserOptions(response, expected_invisible_users)
def test_should_list_only_users_with_publicly_shared_bookmarks_without_login(self): def test_should_list_only_users_with_publicly_shared_bookmarks_without_login(self):
# users with public sharing enabled
expected_visible_users = [ expected_visible_users = [
self.setup_user(enable_sharing=True, enable_public_sharing=True), self.setup_user(name='user_a', enable_sharing=True, enable_public_sharing=True),
self.setup_user(enable_sharing=True, enable_public_sharing=True), self.setup_user(name='user_b', enable_sharing=True, enable_public_sharing=True),
] ]
self.setup_bookmark(shared=True, user=expected_visible_users[0]) self.setup_bookmark(shared=True, user=expected_visible_users[0])
self.setup_bookmark(shared=True, user=expected_visible_users[1]) self.setup_bookmark(shared=True, user=expected_visible_users[1])
expected_invisible_users = [ # users with public sharing disabled
self.setup_user(enable_sharing=True), self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=True))
self.setup_user(enable_sharing=True), self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=True))
]
self.setup_bookmark(shared=True, user=expected_invisible_users[0])
self.setup_bookmark(shared=True, user=expected_invisible_users[1])
response = self.client.get(reverse('bookmarks:shared')) response = self.client.get(reverse('bookmarks:shared'))
self.assertVisibleUserOptions(response, expected_visible_users) self.assertVisibleUserOptions(response, expected_visible_users)
self.assertInvisibleUserOptions(response, expected_invisible_users)
def test_should_open_bookmarks_in_new_page_by_default(self): def test_should_open_bookmarks_in_new_page_by_default(self):
self.authenticate() self.authenticate()
@@ -334,3 +328,44 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse('bookmarks:shared')) response = self.client.get(reverse('bookmarks:shared'))
self.assertVisibleBookmarks(response, visible_bookmarks, '_self') self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
def test_edit_link_return_url_respects_search_options(self):
self.authenticate()
user = self.get_or_create_test_user()
user.profile.enable_sharing = True
user.profile.save()
bookmark = self.setup_bookmark(title='foo', shared=True, user=user)
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
base_url = reverse('bookmarks:shared')
# without query params
return_url = urllib.parse.quote(base_url)
url = f'{edit_url}?return_url={return_url}'
response = self.client.get(base_url)
self.assertEditLink(response, url)
# with query
url_params = '?q=foo'
return_url = urllib.parse.quote(base_url + url_params)
url = f'{edit_url}?return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertEditLink(response, url)
# with query and user
url_params = f'?q=foo&user={user.username}'
return_url = urllib.parse.quote(base_url + url_params)
url = f'{edit_url}?return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertEditLink(response, url)
# with query and sort and page
url_params = '?q=foo&sort=title_asc&page=2'
return_url = urllib.parse.quote(base_url + url_params)
url = f'{edit_url}?return_url={return_url}'
response = self.client.get(base_url + url_params)
self.assertEditLink(response, url)

View File

@@ -15,15 +15,6 @@ from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin): class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.tag1 = self.setup_tag()
self.tag2 = self.setup_tag()
self.bookmark1 = self.setup_bookmark(tags=[self.tag1, self.tag2], notes='Test notes')
self.bookmark2 = self.setup_bookmark()
self.bookmark3 = self.setup_bookmark(tags=[self.tag2])
self.archived_bookmark1 = self.setup_bookmark(is_archived=True, tags=[self.tag1, self.tag2])
self.archived_bookmark2 = self.setup_bookmark(is_archived=True)
def authenticate(self): def authenticate(self):
self.api_token = Token.objects.get_or_create(user=self.get_or_create_test_user())[0] self.api_token = Token.objects.get_or_create(user=self.get_or_create_test_user())[0]
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.api_token.key) self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.api_token.key)
@@ -56,29 +47,64 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def test_list_bookmarks(self): def test_list_bookmarks(self):
self.authenticate() self.authenticate()
bookmarks = self.setup_numbered_bookmarks(5)
response = self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK) response = self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1, self.bookmark2, self.bookmark3]) self.assertBookmarkListEqual(response.data['results'], bookmarks)
def test_list_bookmarks_does_not_return_archived_bookmarks(self):
self.authenticate()
bookmarks = self.setup_numbered_bookmarks(5)
self.setup_numbered_bookmarks(5, archived=True)
response = self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], bookmarks)
def test_list_bookmarks_should_filter_by_query(self): def test_list_bookmarks_should_filter_by_query(self):
self.authenticate() self.authenticate()
search_value = self.get_random_string()
bookmarks = self.setup_numbered_bookmarks(5, prefix=search_value)
self.setup_numbered_bookmarks(5)
response = self.get(reverse('bookmarks:bookmark-list') + '?q=#' + self.tag1.name, response = self.get(reverse('bookmarks:bookmark-list') + '?q=' + search_value,
expected_status_code=status.HTTP_200_OK) expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1]) self.assertBookmarkListEqual(response.data['results'], bookmarks)
def test_list_bookmarks_should_respect_sort(self):
self.authenticate()
bookmarks = self.setup_numbered_bookmarks(5)
bookmarks.reverse()
response = self.get(reverse('bookmarks:bookmark-list') + '?sort=title_desc',
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], bookmarks)
def test_list_archived_bookmarks_does_not_return_unarchived_bookmarks(self): def test_list_archived_bookmarks_does_not_return_unarchived_bookmarks(self):
self.authenticate() self.authenticate()
self.setup_numbered_bookmarks(5)
archived_bookmarks = self.setup_numbered_bookmarks(5, archived=True)
response = self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_200_OK) response = self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1, self.archived_bookmark2]) self.assertBookmarkListEqual(response.data['results'], archived_bookmarks)
def test_list_archived_bookmarks_should_filter_by_query(self): def test_list_archived_bookmarks_should_filter_by_query(self):
self.authenticate() 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)
response = self.get(reverse('bookmarks:bookmark-archived') + '?q=#' + self.tag1.name, response = self.get(reverse('bookmarks:bookmark-archived') + '?q=' + search_value,
expected_status_code=status.HTTP_200_OK) expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1]) 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)
bookmarks.reverse()
response = self.get(reverse('bookmarks:bookmark-archived') + '?sort=title_desc',
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], bookmarks)
def test_list_shared_bookmarks(self): def test_list_shared_bookmarks(self):
self.authenticate() self.authenticate()
@@ -158,6 +184,16 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expected_status_code=status.HTTP_200_OK) expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], expected_bookmarks) self.assertBookmarkListEqual(response.data['results'], expected_bookmarks)
def test_list_shared_bookmarks_should_respect_sort(self):
self.authenticate()
user = self.setup_user(enable_sharing=True)
bookmarks = self.setup_numbered_bookmarks(5, shared=True, user=user)
bookmarks.reverse()
response = self.get(reverse('bookmarks:bookmark-shared') + '?sort=title_desc',
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], bookmarks)
def test_create_bookmark(self): def test_create_bookmark(self):
self.authenticate() self.authenticate()
@@ -295,34 +331,38 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def test_get_bookmark(self): def test_get_bookmark(self):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark()
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK) response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual([response.data], [self.bookmark1]) self.assertBookmarkListEqual([response.data], [bookmark])
def test_update_bookmark(self): def test_update_bookmark(self):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark()
data = {'url': 'https://example.com/'} data = {'url': 'https://example.com/updated'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK) self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id) updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.url, data['url']) self.assertEqual(updated_bookmark.url, data['url'])
def test_update_bookmark_fails_without_required_fields(self): def test_update_bookmark_fails_without_required_fields(self):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark()
data = {'title': 'https://example.com/'} data = {'title': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST) self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
def test_update_bookmark_with_minimal_payload_clears_all_fields(self): def test_update_bookmark_with_minimal_payload_clears_all_fields(self):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark()
data = {'url': 'https://example.com/'} data = {'url': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK) self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id) updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.url, data['url']) self.assertEqual(updated_bookmark.url, data['url'])
self.assertEqual(updated_bookmark.title, '') self.assertEqual(updated_bookmark.title, '')
self.assertEqual(updated_bookmark.description, '') self.assertEqual(updated_bookmark.description, '')
@@ -331,112 +371,119 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def test_update_bookmark_unread_flag(self): def test_update_bookmark_unread_flag(self):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark()
data = {'url': 'https://example.com/', 'unread': True} data = {'url': 'https://example.com/', 'unread': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK) self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id) updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.unread, True) self.assertEqual(updated_bookmark.unread, True)
def test_update_bookmark_shared_flag(self): def test_update_bookmark_shared_flag(self):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark()
data = {'url': 'https://example.com/', 'shared': True} data = {'url': 'https://example.com/', 'shared': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK) self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id) updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.shared, True) self.assertEqual(updated_bookmark.shared, True)
def test_patch_bookmark(self): def test_patch_bookmark(self):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark()
data = {'url': 'https://example.com'} data = {'url': 'https://example.com'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db() bookmark.refresh_from_db()
self.assertEqual(self.bookmark1.url, data['url']) self.assertEqual(bookmark.url, data['url'])
data = {'title': 'Updated title'} data = {'title': 'Updated title'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db() bookmark.refresh_from_db()
self.assertEqual(self.bookmark1.title, data['title']) self.assertEqual(bookmark.title, data['title'])
data = {'description': 'Updated description'} data = {'description': 'Updated description'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db() bookmark.refresh_from_db()
self.assertEqual(self.bookmark1.description, data['description']) self.assertEqual(bookmark.description, data['description'])
data = {'notes': 'Updated notes'} data = {'notes': 'Updated notes'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db() bookmark.refresh_from_db()
self.assertEqual(self.bookmark1.notes, data['notes']) self.assertEqual(bookmark.notes, data['notes'])
data = {'unread': True} data = {'unread': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db() bookmark.refresh_from_db()
self.assertTrue(self.bookmark1.unread) self.assertTrue(bookmark.unread)
data = {'unread': False} data = {'unread': False}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db() bookmark.refresh_from_db()
self.assertFalse(self.bookmark1.unread) self.assertFalse(bookmark.unread)
data = {'shared': True} data = {'shared': True}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db() bookmark.refresh_from_db()
self.assertTrue(self.bookmark1.shared) self.assertTrue(bookmark.shared)
data = {'shared': False} data = {'shared': False}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db() bookmark.refresh_from_db()
self.assertFalse(self.bookmark1.shared) self.assertFalse(bookmark.shared)
data = {'tag_names': ['updated-tag-1', 'updated-tag-2']} data = {'tag_names': ['updated-tag-1', 'updated-tag-2']}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) self.patch(url, data, expected_status_code=status.HTTP_200_OK)
self.bookmark1.refresh_from_db() bookmark.refresh_from_db()
tag_names = [tag.name for tag in self.bookmark1.tags.all()] tag_names = [tag.name for tag in bookmark.tags.all()]
self.assertListEqual(tag_names, ['updated-tag-1', 'updated-tag-2']) self.assertListEqual(tag_names, ['updated-tag-1', 'updated-tag-2'])
def test_patch_with_empty_payload_does_not_modify_bookmark(self): def test_patch_with_empty_payload_does_not_modify_bookmark(self):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark()
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.patch(url, {}, expected_status_code=status.HTTP_200_OK) self.patch(url, {}, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id) updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.url, self.bookmark1.url) self.assertEqual(updated_bookmark.url, bookmark.url)
self.assertEqual(updated_bookmark.title, self.bookmark1.title) self.assertEqual(updated_bookmark.title, bookmark.title)
self.assertEqual(updated_bookmark.description, self.bookmark1.description) self.assertEqual(updated_bookmark.description, bookmark.description)
self.assertListEqual(updated_bookmark.tag_names, self.bookmark1.tag_names) self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names)
def test_delete_bookmark(self): def test_delete_bookmark(self):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark()
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT) self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
self.assertEqual(len(Bookmark.objects.filter(id=self.bookmark1.id)), 0) self.assertEqual(len(Bookmark.objects.filter(id=bookmark.id)), 0)
def test_archive(self): def test_archive(self):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark()
url = reverse('bookmarks:bookmark-archive', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-archive', args=[bookmark.id])
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT) self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
bookmark = Bookmark.objects.get(id=self.bookmark1.id) bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertTrue(bookmark.is_archived) self.assertTrue(bookmark.is_archived)
def test_unarchive(self): def test_unarchive(self):
self.authenticate() self.authenticate()
bookmark = self.setup_bookmark(is_archived=True)
url = reverse('bookmarks:bookmark-unarchive', args=[self.archived_bookmark1.id]) url = reverse('bookmarks:bookmark-unarchive', args=[bookmark.id])
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT) self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
bookmark = Bookmark.objects.get(id=self.archived_bookmark1.id) bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertFalse(bookmark.is_archived) self.assertFalse(bookmark.is_archived)
def test_check_returns_no_bookmark_if_url_is_not_bookmarked(self): def test_check_returns_no_bookmark_if_url_is_not_bookmarked(self):
@@ -509,6 +556,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def test_can_only_access_own_bookmarks(self): def test_can_only_access_own_bookmarks(self):
self.authenticate() self.authenticate()
self.setup_bookmark()
self.setup_bookmark(is_archived=True)
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
inaccessible_bookmark = self.setup_bookmark(user=other_user) inaccessible_bookmark = self.setup_bookmark(user=other_user)
@@ -517,11 +566,11 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
url = reverse('bookmarks:bookmark-list') url = reverse('bookmarks:bookmark-list')
response = self.get(url, expected_status_code=status.HTTP_200_OK) response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 3) self.assertEqual(len(response.data['results']), 1)
url = reverse('bookmarks:bookmark-archived') url = reverse('bookmarks:bookmark-archived')
response = self.get(url, expected_status_code=status.HTTP_200_OK) response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 2) self.assertEqual(len(response.data['results']), 1)
url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id]) url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id])
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND) self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)

View File

@@ -55,7 +55,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
# Edit link # Edit link
edit_url = reverse('bookmarks:edit', args=[bookmark.id]) edit_url = reverse('bookmarks:edit', args=[bookmark.id])
self.assertInHTML(f''' self.assertInHTML(f'''
<a href="{edit_url}?return_url=%2Fbookmarks">Edit</a> <a href="{edit_url}?return_url=/bookmarks">Edit</a>
''', html, count=count) ''', html, count=count)
# Archive link # Archive link
self.assertInHTML(f''' self.assertInHTML(f'''

View File

@@ -113,9 +113,9 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
self.assertPageLink(rendered_template, page_number, page_number == current_page, expected_occurrences) self.assertPageLink(rendered_template, page_number, page_number == current_page, expected_occurrences)
self.assertTruncationIndicators(rendered_template, 1) self.assertTruncationIndicators(rendered_template, 1)
def test_extend_existing_query(self): def test_respects_search_parameters(self):
rendered_template = self.render_template(100, 10, 2, url='/test?q=cake') 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&page=1') self.assertPrevLink(rendered_template, 1, href='?q=cake&sort=title_asc&page=1')
self.assertPageLink(rendered_template, 1, False, href='?q=cake&page=1') self.assertPageLink(rendered_template, 1, False, href='?q=cake&sort=title_asc&page=1')
self.assertPageLink(rendered_template, 2, True, href='?q=cake&page=2') self.assertPageLink(rendered_template, 2, True, href='?q=cake&sort=title_asc&page=2')
self.assertNextLink(rendered_template, 3, href='?q=cake&page=3') self.assertNextLink(rendered_template, 3, href='?q=cake&sort=title_asc&page=3')

View File

@@ -3,9 +3,10 @@ import operator
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db.models import QuerySet from django.db.models import QuerySet
from django.test import TestCase from django.test import TestCase
from django.utils import timezone
from bookmarks import queries from bookmarks import queries
from bookmarks.models import Bookmark, UserProfile from bookmarks.models import Bookmark, BookmarkSearch, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin, random_sentence from bookmarks.tests.helpers import BookmarkFactoryMixin, random_sentence
from bookmarks.utils import unique from bookmarks.utils import unique
@@ -163,7 +164,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmarks_should_return_all_for_empty_query(self): def test_query_bookmarks_should_return_all_for_empty_query(self):
self.setup_bookmark_search_data() self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.user, self.profile, '') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query=''))
self.assertQueryResult(query, [ self.assertQueryResult(query, [
self.other_bookmarks, self.other_bookmarks,
self.term1_bookmarks, self.term1_bookmarks,
@@ -178,7 +179,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmarks_should_search_single_term(self): def test_query_bookmarks_should_search_single_term(self):
self.setup_bookmark_search_data() self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.user, self.profile, 'term1') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='term1'))
self.assertQueryResult(query, [ self.assertQueryResult(query, [
self.term1_bookmarks, self.term1_bookmarks,
self.term1_term2_bookmarks, self.term1_term2_bookmarks,
@@ -188,35 +189,35 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmarks_should_search_multiple_terms(self): def test_query_bookmarks_should_search_multiple_terms(self):
self.setup_bookmark_search_data() self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.user, self.profile, 'term2 term1') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='term2 term1'))
self.assertQueryResult(query, [self.term1_term2_bookmarks]) self.assertQueryResult(query, [self.term1_term2_bookmarks])
def test_query_bookmarks_should_search_single_tag(self): def test_query_bookmarks_should_search_single_tag(self):
self.setup_bookmark_search_data() self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.user, self.profile, '#tag1') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='#tag1'))
self.assertQueryResult(query, [self.tag1_bookmarks, self.tag1_tag2_bookmarks, self.term1_tag1_bookmarks]) self.assertQueryResult(query, [self.tag1_bookmarks, self.tag1_tag2_bookmarks, self.term1_tag1_bookmarks])
def test_query_bookmarks_should_search_multiple_tags(self): def test_query_bookmarks_should_search_multiple_tags(self):
self.setup_bookmark_search_data() self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.user, self.profile, '#tag1 #tag2') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='#tag1 #tag2'))
self.assertQueryResult(query, [self.tag1_tag2_bookmarks]) self.assertQueryResult(query, [self.tag1_tag2_bookmarks])
def test_query_bookmarks_should_search_multiple_tags_ignoring_casing(self): def test_query_bookmarks_should_search_multiple_tags_ignoring_casing(self):
self.setup_bookmark_search_data() self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.user, self.profile, '#Tag1 #TAG2') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='#Tag1 #TAG2'))
self.assertQueryResult(query, [self.tag1_tag2_bookmarks]) self.assertQueryResult(query, [self.tag1_tag2_bookmarks])
def test_query_bookmarks_should_search_terms_and_tags_combined(self): def test_query_bookmarks_should_search_terms_and_tags_combined(self):
self.setup_bookmark_search_data() self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.user, self.profile, 'term1 #tag1') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='term1 #tag1'))
self.assertQueryResult(query, [self.term1_tag1_bookmarks]) self.assertQueryResult(query, [self.term1_tag1_bookmarks])
@@ -226,7 +227,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.profile.tag_search = UserProfile.TAG_SEARCH_STRICT self.profile.tag_search = UserProfile.TAG_SEARCH_STRICT
self.profile.save() self.profile.save()
query = queries.query_bookmarks(self.user, self.profile, 'tag1') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='tag1'))
self.assertQueryResult(query, [self.tag1_as_term_bookmarks]) self.assertQueryResult(query, [self.tag1_as_term_bookmarks])
def test_query_bookmarks_in_lax_mode_should_search_tags_as_terms(self): def test_query_bookmarks_in_lax_mode_should_search_tags_as_terms(self):
@@ -235,7 +236,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.profile.tag_search = UserProfile.TAG_SEARCH_LAX self.profile.tag_search = UserProfile.TAG_SEARCH_LAX
self.profile.save() self.profile.save()
query = queries.query_bookmarks(self.user, self.profile, 'tag1') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='tag1'))
self.assertQueryResult(query, [ self.assertQueryResult(query, [
self.tag1_bookmarks, self.tag1_bookmarks,
self.tag1_as_term_bookmarks, self.tag1_as_term_bookmarks,
@@ -243,17 +244,17 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.term1_tag1_bookmarks self.term1_tag1_bookmarks
]) ])
query = queries.query_bookmarks(self.user, self.profile, 'tag1 term1') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='tag1 term1'))
self.assertQueryResult(query, [ self.assertQueryResult(query, [
self.term1_tag1_bookmarks, self.term1_tag1_bookmarks,
]) ])
query = queries.query_bookmarks(self.user, self.profile, 'tag1 tag2') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='tag1 tag2'))
self.assertQueryResult(query, [ self.assertQueryResult(query, [
self.tag1_tag2_bookmarks, self.tag1_tag2_bookmarks,
]) ])
query = queries.query_bookmarks(self.user, self.profile, 'tag1 #tag2') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='tag1 #tag2'))
self.assertQueryResult(query, [ self.assertQueryResult(query, [
self.tag1_tag2_bookmarks, self.tag1_tag2_bookmarks,
]) ])
@@ -261,28 +262,28 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmarks_should_return_no_matches(self): def test_query_bookmarks_should_return_no_matches(self):
self.setup_bookmark_search_data() self.setup_bookmark_search_data()
query = queries.query_bookmarks(self.user, self.profile, 'term3') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='term3'))
self.assertQueryResult(query, []) self.assertQueryResult(query, [])
query = queries.query_bookmarks(self.user, self.profile, 'term1 term3') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='term1 term3'))
self.assertQueryResult(query, []) self.assertQueryResult(query, [])
query = queries.query_bookmarks(self.user, self.profile, 'term1 #tag2') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='term1 #tag2'))
self.assertQueryResult(query, []) self.assertQueryResult(query, [])
query = queries.query_bookmarks(self.user, self.profile, '#tag3') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='#tag3'))
self.assertQueryResult(query, []) self.assertQueryResult(query, [])
# Unused tag # Unused tag
query = queries.query_bookmarks(self.user, self.profile, '#unused_tag1') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='#unused_tag1'))
self.assertQueryResult(query, []) self.assertQueryResult(query, [])
# Unused tag combined with tag that is used # Unused tag combined with tag that is used
query = queries.query_bookmarks(self.user, self.profile, '#tag1 #unused_tag1') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='#tag1 #unused_tag1'))
self.assertQueryResult(query, []) self.assertQueryResult(query, [])
# Unused tag combined with term that is used # Unused tag combined with term that is used
query = queries.query_bookmarks(self.user, self.profile, 'term1 #unused_tag1') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='term1 #unused_tag1'))
self.assertQueryResult(query, []) self.assertQueryResult(query, [])
def test_query_bookmarks_should_not_return_archived_bookmarks(self): def test_query_bookmarks_should_not_return_archived_bookmarks(self):
@@ -292,7 +293,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True) self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True) self.setup_bookmark(is_archived=True)
query = queries.query_bookmarks(self.user, self.profile, '') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query=''))
self.assertQueryResult(query, [[bookmark1, bookmark2]]) self.assertQueryResult(query, [[bookmark1, bookmark2]])
@@ -303,7 +304,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark() self.setup_bookmark()
self.setup_bookmark() self.setup_bookmark()
query = queries.query_archived_bookmarks(self.user, self.profile, '') query = queries.query_archived_bookmarks(self.user, self.profile, BookmarkSearch(query=''))
self.assertQueryResult(query, [[bookmark1, bookmark2]]) self.assertQueryResult(query, [[bookmark1, bookmark2]])
@@ -318,7 +319,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=other_user) self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user) self.setup_bookmark(user=other_user)
query = queries.query_bookmarks(self.user, self.profile, '') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query=''))
self.assertQueryResult(query, [owned_bookmarks]) self.assertQueryResult(query, [owned_bookmarks])
@@ -333,7 +334,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, user=other_user) self.setup_bookmark(is_archived=True, user=other_user)
self.setup_bookmark(is_archived=True, user=other_user) self.setup_bookmark(is_archived=True, user=other_user)
query = queries.query_archived_bookmarks(self.user, self.profile, '') query = queries.query_archived_bookmarks(self.user, self.profile, BookmarkSearch(query=''))
self.assertQueryResult(query, [owned_bookmarks]) self.assertQueryResult(query, [owned_bookmarks])
@@ -343,7 +344,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(tags=[tag]) self.setup_bookmark(tags=[tag])
self.setup_bookmark(tags=[tag]) self.setup_bookmark(tags=[tag])
query = queries.query_bookmarks(self.user, self.profile, '!untagged') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='!untagged'))
self.assertCountEqual(list(query), [untagged_bookmark]) self.assertCountEqual(list(query), [untagged_bookmark])
def test_query_bookmarks_untagged_should_be_combinable_with_search_terms(self): def test_query_bookmarks_untagged_should_be_combinable_with_search_terms(self):
@@ -352,7 +353,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(title='term2') self.setup_bookmark(title='term2')
self.setup_bookmark(tags=[tag]) self.setup_bookmark(tags=[tag])
query = queries.query_bookmarks(self.user, self.profile, '!untagged term1') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='!untagged term1'))
self.assertCountEqual(list(query), [untagged_bookmark]) self.assertCountEqual(list(query), [untagged_bookmark])
def test_query_bookmarks_untagged_should_not_be_combinable_with_tags(self): def test_query_bookmarks_untagged_should_not_be_combinable_with_tags(self):
@@ -361,7 +362,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(tags=[tag]) self.setup_bookmark(tags=[tag])
self.setup_bookmark(tags=[tag]) self.setup_bookmark(tags=[tag])
query = queries.query_bookmarks(self.user, self.profile, f'!untagged #{tag.name}') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query=f'!untagged #{tag.name}'))
self.assertCountEqual(list(query), []) self.assertCountEqual(list(query), [])
def test_query_archived_bookmarks_untagged_should_return_untagged_bookmarks_only(self): def test_query_archived_bookmarks_untagged_should_return_untagged_bookmarks_only(self):
@@ -370,7 +371,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, tags=[tag]) self.setup_bookmark(is_archived=True, tags=[tag])
self.setup_bookmark(is_archived=True, tags=[tag]) self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmarks(self.user, self.profile, '!untagged') query = queries.query_archived_bookmarks(self.user, self.profile, BookmarkSearch(query='!untagged'))
self.assertCountEqual(list(query), [untagged_bookmark]) self.assertCountEqual(list(query), [untagged_bookmark])
def test_query_archived_bookmarks_untagged_should_be_combinable_with_search_terms(self): def test_query_archived_bookmarks_untagged_should_be_combinable_with_search_terms(self):
@@ -379,7 +380,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, title='term2') self.setup_bookmark(is_archived=True, title='term2')
self.setup_bookmark(is_archived=True, tags=[tag]) self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmarks(self.user, self.profile, '!untagged term1') query = queries.query_archived_bookmarks(self.user, self.profile, BookmarkSearch(query='!untagged term1'))
self.assertCountEqual(list(query), [untagged_bookmark]) self.assertCountEqual(list(query), [untagged_bookmark])
def test_query_archived_bookmarks_untagged_should_not_be_combinable_with_tags(self): def test_query_archived_bookmarks_untagged_should_not_be_combinable_with_tags(self):
@@ -388,7 +389,8 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, tags=[tag]) self.setup_bookmark(is_archived=True, tags=[tag])
self.setup_bookmark(is_archived=True, tags=[tag]) self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmarks(self.user, self.profile, f'!untagged #{tag.name}') query = queries.query_archived_bookmarks(self.user, self.profile,
BookmarkSearch(query=f'!untagged #{tag.name}'))
self.assertCountEqual(list(query), []) self.assertCountEqual(list(query), [])
def test_query_bookmarks_unread_should_return_unread_bookmarks_only(self): def test_query_bookmarks_unread_should_return_unread_bookmarks_only(self):
@@ -401,7 +403,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark() self.setup_bookmark()
self.setup_bookmark() self.setup_bookmark()
query = queries.query_bookmarks(self.user, self.profile, '!unread') query = queries.query_bookmarks(self.user, self.profile, BookmarkSearch(query='!unread'))
self.assertCountEqual(list(query), unread_bookmarks) self.assertCountEqual(list(query), unread_bookmarks)
def test_query_archived_bookmarks_unread_should_return_unread_bookmarks_only(self): def test_query_archived_bookmarks_unread_should_return_unread_bookmarks_only(self):
@@ -414,13 +416,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True) self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True) self.setup_bookmark(is_archived=True)
query = queries.query_archived_bookmarks(self.user, self.profile, '!unread') query = queries.query_archived_bookmarks(self.user, self.profile, BookmarkSearch(query='!unread'))
self.assertCountEqual(list(query), unread_bookmarks) self.assertCountEqual(list(query), unread_bookmarks)
def test_query_bookmark_tags_should_return_all_tags_for_empty_query(self): def test_query_bookmark_tags_should_return_all_tags_for_empty_query(self):
self.setup_tag_search_data() self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, self.profile, '') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query=''))
self.assertQueryResult(query, [ self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.other_bookmarks), self.get_tags_from_bookmarks(self.other_bookmarks),
@@ -435,7 +437,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_search_single_term(self): def test_query_bookmark_tags_should_search_single_term(self):
self.setup_tag_search_data() self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, self.profile, 'term1') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='term1'))
self.assertQueryResult(query, [ self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.term1_bookmarks), self.get_tags_from_bookmarks(self.term1_bookmarks),
@@ -446,7 +448,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_search_multiple_terms(self): def test_query_bookmark_tags_should_search_multiple_terms(self):
self.setup_tag_search_data() self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, self.profile, 'term2 term1') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='term2 term1'))
self.assertQueryResult(query, [ self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.term1_term2_bookmarks), self.get_tags_from_bookmarks(self.term1_term2_bookmarks),
@@ -455,7 +457,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_search_single_tag(self): def test_query_bookmark_tags_should_search_single_tag(self):
self.setup_tag_search_data() self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, self.profile, '#tag1') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='#tag1'))
self.assertQueryResult(query, [ self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_bookmarks), self.get_tags_from_bookmarks(self.tag1_bookmarks),
@@ -466,7 +468,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_search_multiple_tags(self): def test_query_bookmark_tags_should_search_multiple_tags(self):
self.setup_tag_search_data() self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, self.profile, '#tag1 #tag2') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='#tag1 #tag2'))
self.assertQueryResult(query, [ self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks), self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
@@ -475,7 +477,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_search_multiple_tags_ignoring_casing(self): def test_query_bookmark_tags_should_search_multiple_tags_ignoring_casing(self):
self.setup_tag_search_data() self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, self.profile, '#Tag1 #TAG2') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='#Tag1 #TAG2'))
self.assertQueryResult(query, [ self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks), self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
@@ -484,7 +486,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_search_term_and_tag_combined(self): def test_query_bookmark_tags_should_search_term_and_tag_combined(self):
self.setup_tag_search_data() self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 #tag1') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='term1 #tag1'))
self.assertQueryResult(query, [ self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks), self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),
@@ -496,7 +498,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.profile.tag_search = UserProfile.TAG_SEARCH_STRICT self.profile.tag_search = UserProfile.TAG_SEARCH_STRICT
self.profile.save() self.profile.save()
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='tag1'))
self.assertQueryResult(query, self.get_tags_from_bookmarks(self.tag1_as_term_bookmarks)) self.assertQueryResult(query, self.get_tags_from_bookmarks(self.tag1_as_term_bookmarks))
def test_query_bookmark_tags_in_lax_mode_should_search_tags_as_terms(self): def test_query_bookmark_tags_in_lax_mode_should_search_tags_as_terms(self):
@@ -505,7 +507,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.profile.tag_search = UserProfile.TAG_SEARCH_LAX self.profile.tag_search = UserProfile.TAG_SEARCH_LAX
self.profile.save() self.profile.save()
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='tag1'))
self.assertQueryResult(query, [ self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_bookmarks), self.get_tags_from_bookmarks(self.tag1_bookmarks),
self.get_tags_from_bookmarks(self.tag1_as_term_bookmarks), self.get_tags_from_bookmarks(self.tag1_as_term_bookmarks),
@@ -513,17 +515,17 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks) self.get_tags_from_bookmarks(self.term1_tag1_bookmarks)
]) ])
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1 term1') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='tag1 term1'))
self.assertQueryResult(query, [ self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks), self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),
]) ])
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1 tag2') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='tag1 tag2'))
self.assertQueryResult(query, [ self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks), self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
]) ])
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1 #tag2') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='tag1 #tag2'))
self.assertQueryResult(query, [ self.assertQueryResult(query, [
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks), self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
]) ])
@@ -531,28 +533,28 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
def test_query_bookmark_tags_should_return_no_matches(self): def test_query_bookmark_tags_should_return_no_matches(self):
self.setup_tag_search_data() self.setup_tag_search_data()
query = queries.query_bookmark_tags(self.user, self.profile, 'term3') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='term3'))
self.assertQueryResult(query, []) self.assertQueryResult(query, [])
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 term3') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='term1 term3'))
self.assertQueryResult(query, []) self.assertQueryResult(query, [])
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 #tag2') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='term1 #tag2'))
self.assertQueryResult(query, []) self.assertQueryResult(query, [])
query = queries.query_bookmark_tags(self.user, self.profile, '#tag3') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='#tag3'))
self.assertQueryResult(query, []) self.assertQueryResult(query, [])
# Unused tag # Unused tag
query = queries.query_bookmark_tags(self.user, self.profile, '#unused_tag1') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='#unused_tag1'))
self.assertQueryResult(query, []) self.assertQueryResult(query, [])
# Unused tag combined with tag that is used # Unused tag combined with tag that is used
query = queries.query_bookmark_tags(self.user, self.profile, '#tag1 #unused_tag1') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='#tag1 #unused_tag1'))
self.assertQueryResult(query, []) self.assertQueryResult(query, [])
# Unused tag combined with term that is used # Unused tag combined with term that is used
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 #unused_tag1') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='term1 #unused_tag1'))
self.assertQueryResult(query, []) self.assertQueryResult(query, [])
def test_query_bookmark_tags_should_return_tags_for_unarchived_bookmarks_only(self): def test_query_bookmark_tags_should_return_tags_for_unarchived_bookmarks_only(self):
@@ -562,7 +564,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark() self.setup_bookmark()
self.setup_bookmark(is_archived=True, tags=[tag2]) self.setup_bookmark(is_archived=True, tags=[tag2])
query = queries.query_bookmark_tags(self.user, self.profile, '') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query=''))
self.assertQueryResult(query, [[tag1]]) self.assertQueryResult(query, [[tag1]])
@@ -572,7 +574,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(tags=[tag]) self.setup_bookmark(tags=[tag])
self.setup_bookmark(tags=[tag]) self.setup_bookmark(tags=[tag])
query = queries.query_bookmark_tags(self.user, self.profile, '') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query=''))
self.assertQueryResult(query, [[tag]]) self.assertQueryResult(query, [[tag]])
@@ -583,7 +585,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark() self.setup_bookmark()
self.setup_bookmark(is_archived=True, tags=[tag2]) self.setup_bookmark(is_archived=True, tags=[tag2])
query = queries.query_archived_bookmark_tags(self.user, self.profile, '') query = queries.query_archived_bookmark_tags(self.user, self.profile, BookmarkSearch(query=''))
self.assertQueryResult(query, [[tag2]]) self.assertQueryResult(query, [[tag2]])
@@ -593,7 +595,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, tags=[tag]) self.setup_bookmark(is_archived=True, tags=[tag])
self.setup_bookmark(is_archived=True, tags=[tag]) self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmark_tags(self.user, self.profile, '') query = queries.query_archived_bookmark_tags(self.user, self.profile, BookmarkSearch(query=''))
self.assertQueryResult(query, [[tag]]) self.assertQueryResult(query, [[tag]])
@@ -608,7 +610,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)]) self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])
self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)]) self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])
query = queries.query_bookmark_tags(self.user, self.profile, '') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query=''))
self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)]) self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)])
@@ -623,7 +625,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)]) self.setup_bookmark(is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)])
self.setup_bookmark(is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)]) self.setup_bookmark(is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)])
query = queries.query_archived_bookmark_tags(self.user, self.profile, '') query = queries.query_archived_bookmark_tags(self.user, self.profile, BookmarkSearch(query=''))
self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)]) self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)])
@@ -634,13 +636,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(title='term1', tags=[tag]) self.setup_bookmark(title='term1', tags=[tag])
self.setup_bookmark(tags=[tag]) self.setup_bookmark(tags=[tag])
query = queries.query_bookmark_tags(self.user, self.profile, '!untagged') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='!untagged'))
self.assertCountEqual(list(query), []) self.assertCountEqual(list(query), [])
query = queries.query_bookmark_tags(self.user, self.profile, '!untagged term1') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query='!untagged term1'))
self.assertCountEqual(list(query), []) self.assertCountEqual(list(query), [])
query = queries.query_bookmark_tags(self.user, self.profile, f'!untagged #{tag.name}') query = queries.query_bookmark_tags(self.user, self.profile, BookmarkSearch(query=f'!untagged #{tag.name}'))
self.assertCountEqual(list(query), []) self.assertCountEqual(list(query), [])
def test_query_archived_bookmark_tags_untagged_should_never_return_any_tags(self): def test_query_archived_bookmark_tags_untagged_should_never_return_any_tags(self):
@@ -650,13 +652,14 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, title='term1', tags=[tag]) self.setup_bookmark(is_archived=True, title='term1', tags=[tag])
self.setup_bookmark(is_archived=True, tags=[tag]) self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmark_tags(self.user, self.profile, '!untagged') query = queries.query_archived_bookmark_tags(self.user, self.profile, BookmarkSearch(query='!untagged'))
self.assertCountEqual(list(query), []) self.assertCountEqual(list(query), [])
query = queries.query_archived_bookmark_tags(self.user, self.profile, '!untagged term1') query = queries.query_archived_bookmark_tags(self.user, self.profile, BookmarkSearch(query='!untagged term1'))
self.assertCountEqual(list(query), []) self.assertCountEqual(list(query), [])
query = queries.query_archived_bookmark_tags(self.user, self.profile, f'!untagged #{tag.name}') query = queries.query_archived_bookmark_tags(self.user, self.profile,
BookmarkSearch(query=f'!untagged #{tag.name}'))
self.assertCountEqual(list(query), []) self.assertCountEqual(list(query), [])
def test_query_shared_bookmarks(self): def test_query_shared_bookmarks(self):
@@ -679,14 +682,14 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=user4, shared=True, tags=[tag]), self.setup_bookmark(user=user4, shared=True, tags=[tag]),
# Should return shared bookmarks from all users # Should return shared bookmarks from all users
query_set = queries.query_shared_bookmarks(None, self.profile, '', False) query_set = queries.query_shared_bookmarks(None, self.profile, BookmarkSearch(query=''), False)
self.assertQueryResult(query_set, [shared_bookmarks]) self.assertQueryResult(query_set, [shared_bookmarks])
# Should respect search query # Should respect search query
query_set = queries.query_shared_bookmarks(None, self.profile, 'test title', False) query_set = queries.query_shared_bookmarks(None, self.profile, BookmarkSearch(query='test title'), False)
self.assertQueryResult(query_set, [[shared_bookmarks[0]]]) self.assertQueryResult(query_set, [[shared_bookmarks[0]]])
query_set = queries.query_shared_bookmarks(None, self.profile, '#' + tag.name, False) query_set = queries.query_shared_bookmarks(None, self.profile, BookmarkSearch(query=f'#{tag.name}'), False)
self.assertQueryResult(query_set, [[shared_bookmarks[2]]]) self.assertQueryResult(query_set, [[shared_bookmarks[2]]])
def test_query_publicly_shared_bookmarks(self): def test_query_publicly_shared_bookmarks(self):
@@ -696,7 +699,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
bookmark1 = self.setup_bookmark(user=user1, shared=True) bookmark1 = self.setup_bookmark(user=user1, shared=True)
self.setup_bookmark(user=user2, shared=True) self.setup_bookmark(user=user2, shared=True)
query_set = queries.query_shared_bookmarks(None, self.profile, '', True) query_set = queries.query_shared_bookmarks(None, self.profile, BookmarkSearch(query=''), True)
self.assertQueryResult(query_set, [[bookmark1]]) self.assertQueryResult(query_set, [[bookmark1]])
def test_query_shared_bookmark_tags(self): def test_query_shared_bookmark_tags(self):
@@ -720,7 +723,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=user3, shared=False, tags=[self.setup_tag(user=user3)]), self.setup_bookmark(user=user3, shared=False, tags=[self.setup_tag(user=user3)]),
self.setup_bookmark(user=user4, shared=True, tags=[self.setup_tag(user=user4)]), self.setup_bookmark(user=user4, shared=True, tags=[self.setup_tag(user=user4)]),
query_set = queries.query_shared_bookmark_tags(None, self.profile, '', False) query_set = queries.query_shared_bookmark_tags(None, self.profile, BookmarkSearch(query=''), False)
self.assertQueryResult(query_set, [shared_tags]) self.assertQueryResult(query_set, [shared_tags])
@@ -734,7 +737,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=user1, shared=True, tags=[tag1]), self.setup_bookmark(user=user1, shared=True, tags=[tag1]),
self.setup_bookmark(user=user2, shared=True, tags=[tag2]), self.setup_bookmark(user=user2, shared=True, tags=[tag2]),
query_set = queries.query_shared_bookmark_tags(None, self.profile, '', True) query_set = queries.query_shared_bookmark_tags(None, self.profile, BookmarkSearch(query=''), True)
self.assertQueryResult(query_set, [[tag1]]) self.assertQueryResult(query_set, [[tag1]])
@@ -759,11 +762,11 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=users_without_shared_bookmarks[2], shared=True), self.setup_bookmark(user=users_without_shared_bookmarks[2], shared=True),
# Should return users with shared bookmarks # Should return users with shared bookmarks
query_set = queries.query_shared_bookmark_users(self.profile, '', False) query_set = queries.query_shared_bookmark_users(self.profile, BookmarkSearch(query=''), False)
self.assertQueryResult(query_set, [users_with_shared_bookmarks]) self.assertQueryResult(query_set, [users_with_shared_bookmarks])
# Should respect search query # Should respect search query
query_set = queries.query_shared_bookmark_users(self.profile, 'test title', False) query_set = queries.query_shared_bookmark_users(self.profile, BookmarkSearch(query='test title'), False)
self.assertQueryResult(query_set, [[users_with_shared_bookmarks[0]]]) self.assertQueryResult(query_set, [[users_with_shared_bookmarks[0]]])
def test_query_publicly_shared_bookmark_users(self): def test_query_publicly_shared_bookmark_users(self):
@@ -773,5 +776,91 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(user=user1, shared=True) self.setup_bookmark(user=user1, shared=True)
self.setup_bookmark(user=user2, shared=True) self.setup_bookmark(user=user2, shared=True)
query_set = queries.query_shared_bookmark_users(self.profile, '', True) query_set = queries.query_shared_bookmark_users(self.profile, BookmarkSearch(query=''), True)
self.assertQueryResult(query_set, [[user1]]) self.assertQueryResult(query_set, [[user1]])
def test_sorty_by_date_added_asc(self):
search = BookmarkSearch(sort=BookmarkSearch.SORT_ADDED_ASC)
bookmarks = [
self.setup_bookmark(added=timezone.datetime(2020, 1, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2021, 2, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2022, 3, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2023, 4, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2022, 5, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2021, 6, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2020, 7, 1, tzinfo=timezone.utc)),
]
sorted_bookmarks = sorted(bookmarks, key=lambda b: b.date_added)
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertEqual(list(query), sorted_bookmarks)
def test_sorty_by_date_added_desc(self):
search = BookmarkSearch(sort=BookmarkSearch.SORT_ADDED_DESC)
bookmarks = [
self.setup_bookmark(added=timezone.datetime(2020, 1, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2021, 2, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2022, 3, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2023, 4, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2022, 5, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2021, 6, 1, tzinfo=timezone.utc)),
self.setup_bookmark(added=timezone.datetime(2020, 7, 1, tzinfo=timezone.utc)),
]
sorted_bookmarks = sorted(bookmarks, key=lambda b: b.date_added, reverse=True)
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertEqual(list(query), sorted_bookmarks)
def setup_title_sort_data(self):
# lots of combinations to test effective title logic
bookmarks = [
self.setup_bookmark(title='a_1_1'),
self.setup_bookmark(title='A_1_2'),
self.setup_bookmark(title='b_1_1'),
self.setup_bookmark(title='B_1_2'),
self.setup_bookmark(title='', website_title='a_2_1'),
self.setup_bookmark(title='', website_title='A_2_2'),
self.setup_bookmark(title='', website_title='b_2_1'),
self.setup_bookmark(title='', website_title='B_2_2'),
self.setup_bookmark(title='', website_title='', url='a_3_1'),
self.setup_bookmark(title='', website_title='', url='A_3_2'),
self.setup_bookmark(title='', website_title='', url='b_3_1'),
self.setup_bookmark(title='', website_title='', url='B_3_2'),
self.setup_bookmark(title='a_4_1', website_title='0'),
self.setup_bookmark(title='A_4_2', website_title='0'),
self.setup_bookmark(title='b_4_1', website_title='0'),
self.setup_bookmark(title='B_4_2', website_title='0'),
self.setup_bookmark(title='a_5_1', url='0'),
self.setup_bookmark(title='A_5_2', url='0'),
self.setup_bookmark(title='b_5_1', url='0'),
self.setup_bookmark(title='B_5_2', url='0'),
self.setup_bookmark(title='', website_title='a_6_1', url='0'),
self.setup_bookmark(title='', website_title='A_6_2', url='0'),
self.setup_bookmark(title='', website_title='b_6_1', url='0'),
self.setup_bookmark(title='', website_title='B_6_2', url='0'),
self.setup_bookmark(title='a_7_1', website_title='0', url='0'),
self.setup_bookmark(title='A_7_2', website_title='0', url='0'),
self.setup_bookmark(title='b_7_1', website_title='0', url='0'),
self.setup_bookmark(title='B_7_2', website_title='0', url='0'),
]
return bookmarks
def test_sort_by_title_asc(self):
search = BookmarkSearch(sort=BookmarkSearch.SORT_TITLE_ASC)
bookmarks = self.setup_title_sort_data()
sorted_bookmarks = sorted(bookmarks, key=lambda b: b.resolved_title.lower())
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertEqual(list(query), sorted_bookmarks)
def test_sort_by_title_desc(self):
search = BookmarkSearch(sort=BookmarkSearch.SORT_TITLE_DESC)
bookmarks = self.setup_title_sort_data()
sorted_bookmarks = sorted(bookmarks, key=lambda b: b.resolved_title.lower(), reverse=True)
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertEqual(list(query), sorted_bookmarks)

View File

@@ -101,6 +101,18 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
], ],
]) ])
def test_tag_url_respects_search_options(self):
tag = self.setup_tag(name='tag1')
self.setup_bookmark(tags=[tag], title='term1')
rendered_template = self.render_template(url='/test?q=term1&sort=title_asc&page=2')
self.assertInHTML('''
<a href="?q=term1+%23tag1&sort=title_asc&page=2" class="mr-2" data-is-tag-item>
<span class="highlight-char">t</span><span>ag1</span>
</a>
''', rendered_template)
def test_selected_tags(self): def test_selected_tags(self):
tags = [ tags = [
self.setup_tag(name='tag1'), self.setup_tag(name='tag1'),
@@ -191,7 +203,7 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
</a> </a>
''', rendered_template, count=1) ''', rendered_template, count=1)
def test_selected_tag_url_keeps_other_search_terms(self): def test_selected_tag_url_keeps_other_query_terms(self):
tag = self.setup_tag(name='tag1') tag = self.setup_tag(name='tag1')
self.setup_bookmark(tags=[tag], title='term1', description='term2') self.setup_bookmark(tags=[tag], title='term1', description='term2')
@@ -204,6 +216,19 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
</a> </a>
''', rendered_template) ''', rendered_template)
def test_selected_tag_url_respects_search_options(self):
tag = self.setup_tag(name='tag1')
self.setup_bookmark(tags=[tag], title='term1', description='term2')
rendered_template = self.render_template(url='/test?q=term1 %23tag1 term2&sort=title_asc&page=2')
self.assertInHTML('''
<a href="?q=term1+term2&sort=title_asc&page=2"
class="text-bold mr-2">
<span>-tag1</span>
</a>
''', rendered_template)
def test_selected_tags_are_excluded_from_groups(self): def test_selected_tags_are_excluded_from_groups(self):
tags = [ tags = [
self.setup_tag(name='tag1'), self.setup_tag(name='tag1'),

View File

@@ -2,7 +2,7 @@ from django.db.models import QuerySet
from django.template import Template, RequestContext from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory from django.test import TestCase, RequestFactory
from bookmarks.models import BookmarkFilters, User from bookmarks.models import BookmarkSearch, User
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -12,32 +12,42 @@ class UserSelectTagTest(TestCase, BookmarkFactoryMixin):
request = rf.get(url) request = rf.get(url)
request.user = self.get_or_create_test_user() request.user = self.get_or_create_test_user()
request.user_profile = self.get_or_create_test_user().profile request.user_profile = self.get_or_create_test_user().profile
filters = BookmarkFilters(request) search = BookmarkSearch.from_request(request)
context = RequestContext(request, { context = RequestContext(request, {
'request': request, 'request': request,
'filters': filters, 'search': search,
'users': users, 'users': users,
}) })
template_to_render = Template( template_to_render = Template(
'{% load bookmarks %}' '{% load bookmarks %}'
'{% user_select filters users %}' '{% user_select search users %}'
) )
return template_to_render.render(context) return template_to_render.render(context)
def assertUserOption(self, html: str, user: User, selected: bool = False): def assertUserOption(self, html: str, user: User, selected: bool = False):
self.assertInHTML(f''' self.assertInHTML(f'''
<option value="{user.username}" <option value="{user.username}" {'selected' if selected else ''}>
{'selected' if selected else ''}
data-is-user-option>
{user.username} {user.username}
</option> </option>
''', html) ''', html)
def assertHiddenInput(self, html: str, name: str, value: str = None):
needle = f'<input type="hidden" name="{name}"'
if value is not None:
needle += f' value="{value}"'
self.assertIn(needle, html)
def assertNoHiddenInput(self, html: str, name: str):
needle = f'<input type="hidden" name="{name}"'
self.assertNotIn(needle, html)
def test_empty_option(self): def test_empty_option(self):
rendered_template = self.render_template('/test') rendered_template = self.render_template('/test')
self.assertInHTML(f''' self.assertInHTML(f'''
<option value="">Everyone</option> <option value="" selected="">Everyone</option>
''', rendered_template) ''', rendered_template)
def test_render_user_options(self): def test_render_user_options(self):
@@ -60,19 +70,19 @@ class UserSelectTagTest(TestCase, BookmarkFactoryMixin):
self.assertUserOption(rendered_template, user1, True) self.assertUserOption(rendered_template, user1, True)
def test_render_hidden_inputs_for_filter_params(self): def test_hidden_inputs(self):
# Should render hidden inputs if query param exists # Without params
url = '/test?q=foo&user=john' url = '/test'
rendered_template = self.render_template(url) rendered_template = self.render_template(url)
self.assertInHTML(''' self.assertNoHiddenInput(rendered_template, 'user')
<input type="hidden" name="q" value="foo"> self.assertNoHiddenInput(rendered_template, 'q')
''', rendered_template) self.assertNoHiddenInput(rendered_template, 'sort')
# Should not render hidden inputs if query param does not exist # With params
url = '/test?user=john' url = '/test?q=foo&user=john&sort=title_asc'
rendered_template = self.render_template(url) rendered_template = self.render_template(url)
self.assertInHTML(''' self.assertNoHiddenInput(rendered_template, 'user')
<input type="hidden" name="q" value="foo"> self.assertHiddenInput(rendered_template, 'q', 'foo')
''', rendered_template, count=0) self.assertHiddenInput(rendered_template, 'sort', 'title_asc')

View File

@@ -5,7 +5,7 @@ from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from bookmarks import queries from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, build_tag_string from bookmarks.models import Bookmark, BookmarkForm, BookmarkSearch, build_tag_string
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \ from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks, mark_bookmarks_as_read, \ unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks, mark_bookmarks_as_read, \
mark_bookmarks_as_unread, share_bookmarks, unshare_bookmarks mark_bookmarks_as_unread, share_bookmarks, unshare_bookmarks
@@ -36,11 +36,11 @@ def archived(request):
def shared(request): def shared(request):
filters = BookmarkFilters(request) search = BookmarkSearch.from_request(request)
bookmark_list = contexts.SharedBookmarkListContext(request) bookmark_list = contexts.SharedBookmarkListContext(request)
tag_cloud = contexts.SharedTagCloudContext(request) tag_cloud = contexts.SharedTagCloudContext(request)
public_only = not request.user.is_authenticated public_only = not request.user.is_authenticated
users = queries.query_shared_bookmark_users(request.user_profile, filters.query, public_only) users = queries.query_shared_bookmark_users(request.user_profile, search, public_only)
return render(request, 'bookmarks/shared.html', { return render(request, 'bookmarks/shared.html', {
'bookmark_list': bookmark_list, 'bookmark_list': bookmark_list,
'tag_cloud': tag_cloud, 'tag_cloud': tag_cloud,
@@ -169,15 +169,15 @@ def mark_as_read(request, bookmark_id: int):
@login_required @login_required
def index_action(request): def index_action(request):
filters = BookmarkFilters(request) search = BookmarkSearch.from_request(request)
query = queries.query_bookmarks(request.user, request.user_profile, filters.query) query = queries.query_bookmarks(request.user, request.user_profile, search)
return action(request, query) return action(request, query)
@login_required @login_required
def archived_action(request): def archived_action(request):
filters = BookmarkFilters(request) search = BookmarkSearch.from_request(request)
query = queries.query_archived_bookmarks(request.user, request.user_profile, filters.query) query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
return action(request, query) return action(request, query)

View File

@@ -7,8 +7,8 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from bookmarks import queries from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkFilters, User, UserProfile, Tag
from bookmarks import utils from bookmarks import utils
from bookmarks.models import Bookmark, BookmarkSearch, BookmarkSearchForm, User, UserProfile, Tag
DEFAULT_PAGE_SIZE = 30 DEFAULT_PAGE_SIZE = 30
@@ -55,7 +55,7 @@ class BookmarkItem:
class BookmarkListContext: class BookmarkListContext:
def __init__(self, request: WSGIRequest) -> None: def __init__(self, request: WSGIRequest) -> None:
self.request = request self.request = request
self.filters = BookmarkFilters(self.request) self.search = BookmarkSearch.from_request(self.request)
user = request.user user = request.user
user_profile = request.user_profile user_profile = request.user_profile
@@ -71,29 +71,37 @@ class BookmarkListContext:
self.is_empty = paginator.count == 0 self.is_empty = paginator.count == 0
self.bookmarks_page = bookmarks_page self.bookmarks_page = bookmarks_page
self.bookmarks_total = paginator.count self.bookmarks_total = paginator.count
self.return_url = self.generate_return_url(page_number) self.return_url = self.generate_return_url(self.search, self.get_base_url(), page_number)
self.action_url = self.generate_action_url(self.search, self.get_base_action_url(), self.return_url)
self.link_target = user_profile.bookmark_link_target self.link_target = user_profile.bookmark_link_target
self.date_display = user_profile.bookmark_date_display self.date_display = user_profile.bookmark_date_display
self.show_url = user_profile.display_url self.show_url = user_profile.display_url
self.show_favicons = user_profile.enable_favicons self.show_favicons = user_profile.enable_favicons
self.show_notes = user_profile.permanent_notes self.show_notes = user_profile.permanent_notes
def generate_return_url(self, page: int): @staticmethod
base_url = self.get_base_url() def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):
url_query = {} query_params = search.query_params
if self.filters.query:
url_query['q'] = self.filters.query
if self.filters.user:
url_query['user'] = self.filters.user
if page is not None: if page is not None:
url_query['page'] = page query_params['page'] = page
url_params = urllib.parse.urlencode(url_query) query_string = urllib.parse.urlencode(query_params)
return_url = base_url if url_params == '' else base_url + '?' + url_params
return urllib.parse.quote_plus(return_url) return base_url if query_string == '' else base_url + '?' + query_string
@staticmethod
def generate_action_url(search: BookmarkSearch, base_action_url: str, return_url: str):
query_params = search.query_params
query_params['return_url'] = return_url
query_string = urllib.parse.urlencode(query_params)
return base_action_url if query_string == '' else base_action_url + '?' + query_string
def get_base_url(self): def get_base_url(self):
raise Exception(f'Must be implemented by subclass') raise Exception(f'Must be implemented by subclass')
def get_base_action_url(self):
raise Exception(f'Must be implemented by subclass')
def get_bookmark_query_set(self): def get_bookmark_query_set(self):
raise Exception(f'Must be implemented by subclass') raise Exception(f'Must be implemented by subclass')
@@ -102,32 +110,41 @@ class ActiveBookmarkListContext(BookmarkListContext):
def get_base_url(self): def get_base_url(self):
return reverse('bookmarks:index') return reverse('bookmarks:index')
def get_base_action_url(self):
return reverse('bookmarks:index.action')
def get_bookmark_query_set(self): def get_bookmark_query_set(self):
return queries.query_bookmarks(self.request.user, return queries.query_bookmarks(self.request.user,
self.request.user_profile, self.request.user_profile,
self.filters.query) self.search)
class ArchivedBookmarkListContext(BookmarkListContext): class ArchivedBookmarkListContext(BookmarkListContext):
def get_base_url(self): def get_base_url(self):
return reverse('bookmarks:archived') return reverse('bookmarks:archived')
def get_base_action_url(self):
return reverse('bookmarks:archived.action')
def get_bookmark_query_set(self): def get_bookmark_query_set(self):
return queries.query_archived_bookmarks(self.request.user, return queries.query_archived_bookmarks(self.request.user,
self.request.user_profile, self.request.user_profile,
self.filters.query) self.search)
class SharedBookmarkListContext(BookmarkListContext): class SharedBookmarkListContext(BookmarkListContext):
def get_base_url(self): def get_base_url(self):
return reverse('bookmarks:shared') return reverse('bookmarks:shared')
def get_base_action_url(self):
return reverse('bookmarks:shared.action')
def get_bookmark_query_set(self): def get_bookmark_query_set(self):
user = User.objects.filter(username=self.filters.user).first() user = User.objects.filter(username=self.search.user).first()
public_only = not self.request.user.is_authenticated public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmarks(user, return queries.query_shared_bookmarks(user,
self.request.user_profile, self.request.user_profile,
self.filters.query, self.search,
public_only) public_only)
@@ -159,7 +176,7 @@ class TagGroup:
class TagCloudContext: class TagCloudContext:
def __init__(self, request: WSGIRequest) -> None: def __init__(self, request: WSGIRequest) -> None:
self.request = request self.request = request
self.filters = BookmarkFilters(self.request) self.search = BookmarkSearch.from_request(self.request)
query_set = self.get_tag_query_set() query_set = self.get_tag_query_set()
tags = list(query_set) tags = list(query_set)
@@ -179,7 +196,7 @@ class TagCloudContext:
raise Exception(f'Must be implemented by subclass') raise Exception(f'Must be implemented by subclass')
def get_selected_tags(self, tags: List[Tag]): def get_selected_tags(self, tags: List[Tag]):
parsed_query = queries.parse_query_string(self.filters.query) parsed_query = queries.parse_query_string(self.search.query)
tag_names = parsed_query['tag_names'] tag_names = parsed_query['tag_names']
if self.request.user_profile.tag_search == UserProfile.TAG_SEARCH_LAX: if self.request.user_profile.tag_search == UserProfile.TAG_SEARCH_LAX:
tag_names = tag_names + parsed_query['search_terms'] tag_names = tag_names + parsed_query['search_terms']
@@ -192,21 +209,21 @@ class ActiveTagCloudContext(TagCloudContext):
def get_tag_query_set(self): def get_tag_query_set(self):
return queries.query_bookmark_tags(self.request.user, return queries.query_bookmark_tags(self.request.user,
self.request.user_profile, self.request.user_profile,
self.filters.query) self.search)
class ArchivedTagCloudContext(TagCloudContext): class ArchivedTagCloudContext(TagCloudContext):
def get_tag_query_set(self): def get_tag_query_set(self):
return queries.query_archived_bookmark_tags(self.request.user, return queries.query_archived_bookmark_tags(self.request.user,
self.request.user_profile, self.request.user_profile,
self.filters.query) self.search)
class SharedTagCloudContext(TagCloudContext): class SharedTagCloudContext(TagCloudContext):
def get_tag_query_set(self): def get_tag_query_set(self):
user = User.objects.filter(username=self.filters.user).first() user = User.objects.filter(username=self.search.user).first()
public_only = not self.request.user.is_authenticated public_only = not self.request.user.is_authenticated
return queries.query_shared_bookmark_tags(user, return queries.query_shared_bookmark_tags(user,
self.request.user_profile, self.request.user_profile,
self.filters.query, self.search,
public_only) public_only)

View File

@@ -12,7 +12,7 @@ from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from bookmarks.models import UserProfileForm, FeedToken from bookmarks.models import BookmarkSearch, UserProfileForm, FeedToken
from bookmarks.queries import query_bookmarks from bookmarks.queries import query_bookmarks
from bookmarks.services import exporter, tasks from bookmarks.services import exporter, tasks
from bookmarks.services import importer from bookmarks.services import importer
@@ -136,7 +136,7 @@ def bookmark_import(request):
def bookmark_export(request): def bookmark_export(request):
# noinspection PyBroadException # noinspection PyBroadException
try: try:
bookmarks = list(query_bookmarks(request.user, request.user_profile, '')) bookmarks = list(query_bookmarks(request.user, request.user_profile, BookmarkSearch()))
# Prefetch tags to prevent n+1 queries # Prefetch tags to prevent n+1 queries
prefetch_related_objects(bookmarks, 'tags') prefetch_related_objects(bookmarks, 'tags')
file_content = exporter.export_netscape_html(bookmarks) file_content = exporter.export_netscape_html(bookmarks)

View File

@@ -231,6 +231,10 @@ DATABASES = {
'default': default_database 'default': default_database
} }
SQLITE_ICU_EXTENSION_PATH = './libicu.so'
USE_SQLITE = default_database['ENGINE'] == 'django.db.backends.sqlite3'
USE_SQLITE_ICU_EXTENSION = USE_SQLITE and os.path.exists(SQLITE_ICU_EXTENSION_PATH)
# Favicons # Favicons
LD_DEFAULT_FAVICON_PROVIDER = 'https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={url}&size=32' LD_DEFAULT_FAVICON_PROVIDER = 'https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url={url}&size=32'
LD_FAVICON_PROVIDER = os.getenv('LD_FAVICON_PROVIDER', LD_DEFAULT_FAVICON_PROVIDER) LD_FAVICON_PROVIDER = os.getenv('LD_FAVICON_PROVIDER', LD_DEFAULT_FAVICON_PROVIDER)