diff --git a/bookmarks/api/routes.py b/bookmarks/api/routes.py
index f394f8a..0351976 100644
--- a/bookmarks/api/routes.py
+++ b/bookmarks/api/routes.py
@@ -6,7 +6,7 @@ from rest_framework.routers import DefaultRouter
from bookmarks import queries
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
-from bookmarks.models import Bookmark, Tag
+from bookmarks.models import Bookmark, BookmarkFilters, Tag, User
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
from bookmarks.services.website_loader import load_website_metadata
@@ -42,6 +42,16 @@ class BookmarkViewSet(viewsets.GenericViewSet,
data = serializer(page, many=True).data
return self.get_paginated_response(data)
+ @action(methods=['get'], detail=False)
+ def shared(self, request):
+ filters = BookmarkFilters(request)
+ user = User.objects.filter(username=filters.user).first()
+ query_set = queries.query_shared_bookmarks(user, filters.query)
+ page = self.paginate_queryset(query_set)
+ serializer = self.get_serializer_class()
+ data = serializer(page, many=True).data
+ return self.get_paginated_response(data)
+
@action(methods=['post'], detail=True)
def archive(self, request, pk):
bookmark = self.get_object()
diff --git a/bookmarks/api/serializers.py b/bookmarks/api/serializers.py
index 4e2be52..29769dc 100644
--- a/bookmarks/api/serializers.py
+++ b/bookmarks/api/serializers.py
@@ -21,6 +21,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
'website_description',
'is_archived',
'unread',
+ 'shared',
'tag_names',
'date_added',
'date_modified'
@@ -37,6 +38,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
description = serializers.CharField(required=False, allow_blank=True, default='')
is_archived = serializers.BooleanField(required=False, default=False)
unread = serializers.BooleanField(required=False, default=False)
+ shared = serializers.BooleanField(required=False, default=False)
# Override readonly tag_names property to allow passing a list of tag names to create/update
tag_names = TagListField(required=False, default=[])
@@ -47,12 +49,13 @@ class BookmarkSerializer(serializers.ModelSerializer):
bookmark.description = validated_data['description']
bookmark.is_archived = validated_data['is_archived']
bookmark.unread = validated_data['unread']
+ bookmark.shared = validated_data['shared']
tag_string = build_tag_string(validated_data['tag_names'])
return create_bookmark(bookmark, tag_string, self.context['user'])
def update(self, instance: Bookmark, validated_data):
# Update fields if they were provided in the payload
- for key in ['url', 'title', 'description', 'unread']:
+ for key in ['url', 'title', 'description', 'unread', 'shared']:
if key in validated_data:
setattr(instance, key, validated_data[key])
diff --git a/bookmarks/components/SearchAutoComplete.svelte b/bookmarks/components/SearchAutoComplete.svelte
index 6b1e9e9..c04ecc2 100644
--- a/bookmarks/components/SearchAutoComplete.svelte
+++ b/bookmarks/components/SearchAutoComplete.svelte
@@ -8,8 +8,9 @@
export let placeholder;
export let value;
export let tags;
- export let mode = 'default';
+ export let mode = '';
export let apiClient;
+ export let filters;
let isFocus = false;
let isOpen = false;
@@ -112,9 +113,12 @@
let bookmarks = []
if (value && value.length >= 3) {
- const fetchedBookmarks = mode === 'archive'
- ? await apiClient.getArchivedBookmarks(value, {limit: 5, offset: 0})
- : await apiClient.getBookmarks(value, {limit: 5, offset: 0})
+ const path = mode ? `/${mode}` : ''
+ const suggestionFilters = {
+ ...filters,
+ q: value
+ }
+ const fetchedBookmarks = await apiClient.listBookmarks(suggestionFilters, {limit: 5, offset: 0, path})
bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
const label = clampText(fullLabel, 60)
diff --git a/bookmarks/components/api.js b/bookmarks/components/api.js
index 06ed981..31641a5 100644
--- a/bookmarks/components/api.js
+++ b/bookmarks/components/api.js
@@ -3,18 +3,19 @@ export class ApiClient {
this.baseUrl = baseUrl
}
- getBookmarks(query, options = {limit: 100, offset: 0}) {
- const encodedQuery = encodeURIComponent(query)
- const url = `${this.baseUrl}bookmarks/?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
-
- return fetch(url)
- .then(response => response.json())
- .then(data => data.results)
- }
-
- getArchivedBookmarks(query, options = {limit: 100, offset: 0}) {
- const encodedQuery = encodeURIComponent(query)
- const url = `${this.baseUrl}bookmarks/archived/?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
+ listBookmarks(filters, options = {limit: 100, offset: 0, path: ''}) {
+ const query = [
+ `limit=${options.limit}`,
+ `offset=${options.offset}`,
+ ]
+ Object.keys(filters).forEach(key => {
+ const value = filters[key]
+ if (value) {
+ query.push(`${key}=${encodeURIComponent(value)}`)
+ }
+ })
+ const queryString = query.join('&')
+ const url = `${this.baseUrl}bookmarks${options.path}/?${queryString}`
return fetch(url)
.then(response => response.json())
diff --git a/bookmarks/migrations/0016_bookmark_shared.py b/bookmarks/migrations/0016_bookmark_shared.py
new file mode 100644
index 0000000..a780b44
--- /dev/null
+++ b/bookmarks/migrations/0016_bookmark_shared.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.14 on 2022-08-02 18:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('bookmarks', '0015_feedtoken'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='bookmark',
+ name='shared',
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/bookmarks/migrations/0017_userprofile_enable_sharing.py b/bookmarks/migrations/0017_userprofile_enable_sharing.py
new file mode 100644
index 0000000..bc676a2
--- /dev/null
+++ b/bookmarks/migrations/0017_userprofile_enable_sharing.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.14 on 2022-08-04 09:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('bookmarks', '0016_bookmark_shared'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='userprofile',
+ name='enable_sharing',
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/bookmarks/models.py b/bookmarks/models.py
index eb8f8df..4705a1f 100644
--- a/bookmarks/models.py
+++ b/bookmarks/models.py
@@ -5,6 +5,7 @@ from typing import List
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
+from django.core.handlers.wsgi import WSGIRequest
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
@@ -54,6 +55,7 @@ class Bookmark(models.Model):
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
unread = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False)
+ shared = models.BooleanField(default=False)
date_added = models.DateTimeField()
date_modified = models.DateTimeField()
date_accessed = models.DateTimeField(blank=True, null=True)
@@ -100,12 +102,19 @@ class BookmarkForm(forms.ModelForm):
description = forms.CharField(required=False,
widget=forms.Textarea())
unread = forms.BooleanField(required=False)
+ shared = forms.BooleanField(required=False)
# Hidden field that determines whether to close window/tab after saving the bookmark
auto_close = forms.CharField(required=False)
class Meta:
model = Bookmark
- fields = ['url', 'tag_string', 'title', 'description', 'unread', 'auto_close']
+ fields = ['url', 'tag_string', 'title', 'description', 'unread', 'shared', 'auto_close']
+
+
+class BookmarkFilters:
+ def __init__(self, request: WSGIRequest):
+ self.query = request.GET.get('q') or ''
+ self.user = request.GET.get('user') or ''
class UserProfile(models.Model):
@@ -145,12 +154,13 @@ class UserProfile(models.Model):
default=BOOKMARK_LINK_TARGET_BLANK)
web_archive_integration = models.CharField(max_length=10, choices=WEB_ARCHIVE_INTEGRATION_CHOICES, blank=False,
default=WEB_ARCHIVE_INTEGRATION_DISABLED)
+ enable_sharing = models.BooleanField(default=False, null=False)
class UserProfileForm(forms.ModelForm):
class Meta:
model = UserProfile
- fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration']
+ fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'enable_sharing']
@receiver(post_save, sender=get_user_model())
diff --git a/bookmarks/queries.py b/bookmarks/queries.py
index eaaa888..2dd8568 100644
--- a/bookmarks/queries.py
+++ b/bookmarks/queries.py
@@ -1,3 +1,5 @@
+from typing import Optional
+
from django.contrib.auth.models import User
from django.db.models import Q, Count, Aggregate, CharField, Value, BooleanField, QuerySet
@@ -27,7 +29,13 @@ def query_archived_bookmarks(user: User, query_string: str) -> QuerySet:
.filter(is_archived=True)
-def _base_bookmarks_query(user: User, query_string: str) -> QuerySet:
+def query_shared_bookmarks(user: Optional[User], query_string: str) -> QuerySet:
+ return _base_bookmarks_query(user, query_string) \
+ .filter(shared=True) \
+ .filter(owner__profile__enable_sharing=True)
+
+
+def _base_bookmarks_query(user: Optional[User], query_string: str) -> QuerySet:
# Add aggregated tag info to bookmark instances
query_set = Bookmark.objects \
.annotate(tag_count=Count('tags'),
@@ -35,7 +43,8 @@ def _base_bookmarks_query(user: User, query_string: str) -> QuerySet:
tag_projection=Value(True, BooleanField()))
# Filter for user
- query_set = query_set.filter(owner=user)
+ if user:
+ query_set = query_set.filter(owner=user)
# Split query into search terms and tags
query = _parse_query_string(query_string)
@@ -88,6 +97,22 @@ def query_archived_bookmark_tags(user: User, query_string: str) -> QuerySet:
return query_set.distinct()
+def query_shared_bookmark_tags(user: Optional[User], query_string: str) -> QuerySet:
+ bookmarks_query = query_shared_bookmarks(user, query_string)
+
+ query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
+
+ return query_set.distinct()
+
+
+def query_shared_bookmark_users(query_string: str) -> QuerySet:
+ bookmarks_query = query_shared_bookmarks(None, query_string)
+
+ query_set = User.objects.filter(bookmark__in=bookmarks_query)
+
+ return query_set.distinct()
+
+
def get_user_tags(user: User):
return Tag.objects.filter(owner=user).all()
diff --git a/bookmarks/services/bookmarks.py b/bookmarks/services/bookmarks.py
index 1fc842d..30394ce 100644
--- a/bookmarks/services/bookmarks.py
+++ b/bookmarks/services/bookmarks.py
@@ -117,6 +117,7 @@ def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.title = from_bookmark.title
to_bookmark.description = from_bookmark.description
to_bookmark.unread = from_bookmark.unread
+ to_bookmark.shared = from_bookmark.shared
def _update_website_metadata(bookmark: Bookmark):
diff --git a/bookmarks/templates/bookmarks/archive.html b/bookmarks/templates/bookmarks/archive.html
index 0c0c5cd..c0405ce 100644
--- a/bookmarks/templates/bookmarks/archive.html
+++ b/bookmarks/templates/bookmarks/archive.html
@@ -14,7 +14,7 @@
diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html
index 4554b93..c32f193 100644
--- a/bookmarks/templates/bookmarks/bookmark_list.html
+++ b/bookmarks/templates/bookmarks/bookmark_list.html
@@ -54,21 +54,29 @@
|
{% endif %}
- Edit
- {% if bookmark.is_archived %}
- Unarchive
+ {% if bookmark.owner == request.user %}
+ {# Bookmark owner actions #}
+ Edit
+ {% if bookmark.is_archived %}
+ Unarchive
+ {% else %}
+ Archive
+ {% endif %}
+ Remove
+ {% if bookmark.unread %}
+ |
+ Mark as read
+ {% endif %}
{% else %}
- Archive
- {% endif %}
- Remove
- {% if bookmark.unread %}
- |
- Mark as read
+ {# Shared bookmark actions #}
+ Shared by
+ {{ bookmark.owner.username }}
+
{% endif %}
diff --git a/bookmarks/templates/bookmarks/form.html b/bookmarks/templates/bookmarks/form.html
index bddbeb6..556141c 100644
--- a/bookmarks/templates/bookmarks/form.html
+++ b/bookmarks/templates/bookmarks/form.html
@@ -75,6 +75,18 @@
Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
+ {% if request.user.profile.enable_sharing %}
+
+ {% endif %}
@@ -15,6 +18,11 @@
window.addEventListener("load", function() {
const currentTagsString = '{{ tags_string }}';
const currentTags = currentTagsString.split(' ');
+ const uniqueTags = [...new Set(currentTags)]
+ const filters = {
+ q: '{{ filters.query }}',
+ user: '{{ filters.user }}',
+ }
const apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
const wrapper = document.getElementById('search-input-wrap')
const newWrapper = document.createElement('div')
@@ -23,10 +31,11 @@
props: {
name: 'q',
placeholder: 'Search for words or #tags',
- value: '{{ query }}',
- tags: currentTags,
+ value: '{{ filters.query }}',
+ tags: uniqueTags,
mode: '{{ mode }}',
- apiClient
+ apiClient,
+ filters,
}
})
wrapper.parentElement.replaceChild(newWrapper, wrapper)
diff --git a/bookmarks/templates/bookmarks/shared.html b/bookmarks/templates/bookmarks/shared.html
new file mode 100644
index 0000000..578a827
--- /dev/null
+++ b/bookmarks/templates/bookmarks/shared.html
@@ -0,0 +1,48 @@
+{% extends "bookmarks/layout.html" %}
+{% load static %}
+{% load shared %}
+{% load bookmarks %}
+
+{% block content %}
+
+
+
+ {# Bookmark list #}
+
+
+ {# Filters #}
+
+
+
+ {% user_select filters users %}
+
+
+
+ {% tag_cloud tags %}
+
+
+
+
+
+{% endblock %}
diff --git a/bookmarks/templates/bookmarks/user_select.html b/bookmarks/templates/bookmarks/user_select.html
new file mode 100644
index 0000000..1d1a46b
--- /dev/null
+++ b/bookmarks/templates/bookmarks/user_select.html
@@ -0,0 +1,29 @@
+
+
diff --git a/bookmarks/templates/settings/general.html b/bookmarks/templates/settings/general.html
index 06bd358..9728b1c 100644
--- a/bookmarks/templates/settings/general.html
+++ b/bookmarks/templates/settings/general.html
@@ -47,6 +47,16 @@
case it goes offline or its content is modified.
+
diff --git a/bookmarks/templatetags/bookmarks.py b/bookmarks/templatetags/bookmarks.py
index 49624e4..b5dc3c6 100644
--- a/bookmarks/templatetags/bookmarks.py
+++ b/bookmarks/templatetags/bookmarks.py
@@ -3,14 +3,16 @@ from typing import List
from django import template
from django.core.paginator import Page
-from bookmarks.models import BookmarkForm, Tag, build_tag_string
+from bookmarks.models import BookmarkForm, BookmarkFilters, Tag, build_tag_string, User
+from bookmarks.utils import unique
register = template.Library()
-@register.inclusion_tag('bookmarks/form.html', name='bookmark_form')
-def bookmark_form(form: BookmarkForm, cancel_url: str, bookmark_id: int = 0, auto_close: bool = False):
+@register.inclusion_tag('bookmarks/form.html', name='bookmark_form', takes_context=True)
+def bookmark_form(context, form: BookmarkForm, cancel_url: str, bookmark_id: int = 0, auto_close: bool = False):
return {
+ 'request': context['request'],
'form': form,
'auto_close': auto_close,
'bookmark_id': bookmark_id,
@@ -25,7 +27,13 @@ class TagGroup:
def create_tag_groups(tags: List[Tag]):
- sorted_tags = sorted(tags, key=lambda x: str.lower(x.name))
+ # Only display each tag name once, ignoring casing
+ # This covers cases where the tag cloud contains shared tags with duplicate names
+ # Also means that the cloud can not make assumptions that it will necessarily contain
+ # all tags of the current user
+ unique_tags = unique(tags, key=lambda x: str.lower(x.name))
+ # Ensure groups, as well as tags within groups, are ordered alphabetically
+ sorted_tags = sorted(unique_tags, key=lambda x: str.lower(x.name))
group = None
groups = []
@@ -61,11 +69,20 @@ def bookmark_list(context, bookmarks: Page, return_url: str, link_target: str =
@register.inclusion_tag('bookmarks/search.html', name='bookmark_search', takes_context=True)
-def bookmark_search(context, query: str, tags: [Tag], mode: str = 'default'):
+def bookmark_search(context, filters: BookmarkFilters, tags: [Tag], mode: str = ''):
tag_names = [tag.name for tag in tags]
tags_string = build_tag_string(tag_names, ' ')
return {
- 'query': query,
+ 'filters': filters,
'tags_string': tags_string,
'mode': mode,
}
+
+
+@register.inclusion_tag('bookmarks/user_select.html', name='user_select', takes_context=True)
+def user_select(context, filters: BookmarkFilters, users: List[User]):
+ sorted_users = sorted(users, key=lambda x: str.lower(x.username))
+ return {
+ 'filters': filters,
+ 'users': sorted_users,
+ }
diff --git a/bookmarks/templatetags/shared.py b/bookmarks/templatetags/shared.py
index 2eb13fd..65aea28 100644
--- a/bookmarks/templatetags/shared.py
+++ b/bookmarks/templatetags/shared.py
@@ -32,6 +32,18 @@ def append_query_param(context, **kwargs):
return query.urlencode()
+@register.simple_tag(takes_context=True)
+def replace_query_param(context, **kwargs):
+ query = context.request.GET.copy()
+
+ # Create query param or replace existing
+ for key in kwargs:
+ value = kwargs[key]
+ query.__setitem__(key, value)
+
+ return query.urlencode()
+
+
@register.filter(name='hash_tag')
def hash_tag(tag_name):
return '#' + tag_name
diff --git a/bookmarks/tests/helpers.py b/bookmarks/tests/helpers.py
index 2309dc1..058c2fc 100644
--- a/bookmarks/tests/helpers.py
+++ b/bookmarks/tests/helpers.py
@@ -23,6 +23,7 @@ class BookmarkFactoryMixin:
def setup_bookmark(self,
is_archived: bool = False,
unread: bool = False,
+ shared: bool = False,
tags=None,
user: User = None,
url: str = '',
@@ -52,6 +53,7 @@ class BookmarkFactoryMixin:
owner=user,
is_archived=is_archived,
unread=unread,
+ shared=shared,
web_archive_snapshot_url=web_archive_snapshot_url,
)
bookmark.save()
@@ -69,6 +71,14 @@ class BookmarkFactoryMixin:
tag.save()
return tag
+ def setup_user(self, name: str = None, enable_sharing: bool = False):
+ if not name:
+ name = get_random_string(length=32)
+ user = User.objects.create_user(name, 'user@example.com', 'password123')
+ user.profile.enable_sharing = enable_sharing
+ user.profile.save()
+ return user
+
class LinkdingApiTestCase(APITestCase):
def get(self, url, expected_status_code=status.HTTP_200_OK):
diff --git a/bookmarks/tests/test_bookmark_archived_view_performance.py b/bookmarks/tests/test_bookmark_archived_view_performance.py
new file mode 100644
index 0000000..72159ac
--- /dev/null
+++ b/bookmarks/tests/test_bookmark_archived_view_performance.py
@@ -0,0 +1,42 @@
+from django.contrib.auth.models import User
+from django.test import TransactionTestCase
+from django.test.utils import CaptureQueriesContext
+from django.urls import reverse
+from django.db import connections
+from django.db.utils import DEFAULT_DB_ALIAS
+
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class BookmarkArchivedViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
+
+ def setUp(self) -> None:
+ user = self.get_or_create_test_user()
+ self.client.force_login(user)
+
+ def get_connection(self):
+ return connections[DEFAULT_DB_ALIAS]
+
+ def test_should_not_increase_number_of_queries_per_bookmark(self):
+ # create initial bookmarks
+ num_initial_bookmarks = 10
+ for index in range(num_initial_bookmarks):
+ self.setup_bookmark(user=self.user, is_archived=True)
+
+ # capture number of queries
+ context = CaptureQueriesContext(self.get_connection())
+ with context:
+ response = self.client.get(reverse('bookmarks:archived'))
+ self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks)
+
+ number_of_queries = context.final_queries
+
+ # add more bookmarks
+ num_additional_bookmarks = 10
+ for index in range(num_additional_bookmarks):
+ self.setup_bookmark(user=self.user, is_archived=True)
+
+ # assert num queries doesn't increase
+ with self.assertNumQueries(number_of_queries):
+ response = self.client.get(reverse('bookmarks:archived'))
+ self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks + num_additional_bookmarks)
diff --git a/bookmarks/tests/test_bookmark_edit_view.py b/bookmarks/tests/test_bookmark_edit_view.py
index f137895..4a44037 100644
--- a/bookmarks/tests/test_bookmark_edit_view.py
+++ b/bookmarks/tests/test_bookmark_edit_view.py
@@ -21,6 +21,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
'title': 'edited title',
'description': 'edited description',
'unread': False,
+ 'shared': False,
}
return {**form_data, **overrides}
@@ -37,20 +38,37 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description'])
self.assertEqual(bookmark.unread, form_data['unread'])
+ self.assertEqual(bookmark.shared, form_data['shared'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.all()[0].name, 'editedtag1')
self.assertEqual(bookmark.tags.all()[1].name, 'editedtag2')
- def test_should_mark_bookmark_as_unread(self):
+ def test_should_edit_unread_state(self):
bookmark = self.setup_bookmark()
+
form_data = self.create_form_data({'id': bookmark.id, 'unread': True})
-
self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
-
bookmark.refresh_from_db()
-
self.assertTrue(bookmark.unread)
+ form_data = self.create_form_data({'id': bookmark.id, 'unread': False})
+ self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
+ bookmark.refresh_from_db()
+ self.assertFalse(bookmark.unread)
+
+ def test_should_edit_shared_state(self):
+ bookmark = self.setup_bookmark()
+
+ form_data = self.create_form_data({'id': bookmark.id, 'shared': True})
+ self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
+ bookmark.refresh_from_db()
+ self.assertTrue(bookmark.shared)
+
+ form_data = self.create_form_data({'id': bookmark.id, 'shared': False})
+ self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
+ bookmark.refresh_from_db()
+ self.assertFalse(bookmark.shared)
+
def test_should_prefill_bookmark_form_fields(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
@@ -126,3 +144,32 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertNotEqual(bookmark.url, form_data['url'])
self.assertEqual(response.status_code, 404)
+ def test_should_respect_share_profile_setting(self):
+ bookmark = self.setup_bookmark()
+
+ self.user.profile.enable_sharing = False
+ self.user.profile.save()
+ response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
+ html = response.content.decode()
+
+ self.assertInHTML('''
+
+
+
+ Share
+
+ ''', html, count=0)
+
+ self.user.profile.enable_sharing = True
+ self.user.profile.save()
+ response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
+ html = response.content.decode()
+
+ self.assertInHTML('''
+
+
+
+ Share
+
+ ''', html, count=1)
+
diff --git a/bookmarks/tests/test_bookmark_index_view.py b/bookmarks/tests/test_bookmark_index_view.py
index bfce3b6..b07bf4f 100644
--- a/bookmarks/tests/test_bookmark_index_view.py
+++ b/bookmarks/tests/test_bookmark_index_view.py
@@ -1,3 +1,5 @@
+import urllib.parse
+
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
@@ -156,3 +158,30 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse('bookmarks:index'))
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
+
+ def test_edit_link_return_url_should_contain_query_params(self):
+ bookmark = self.setup_bookmark(title='foo')
+
+ # without query params
+ url = reverse('bookmarks:index')
+ response = self.client.get(url)
+ html = response.content.decode()
+ edit_url = reverse('bookmarks:edit', args=[bookmark.id])
+ return_url = urllib.parse.quote_plus(url)
+
+ self.assertInHTML(f'''
+ Edit
+ ''', html)
+
+ # with query params
+ url = reverse('bookmarks:index') + '?q=foo&user=user'
+ response = self.client.get(url)
+ html = response.content.decode()
+ edit_url = reverse('bookmarks:edit', args=[bookmark.id])
+ return_url = urllib.parse.quote_plus(url)
+
+ self.assertInHTML(f'''
+ Edit
+ ''', html)
diff --git a/bookmarks/tests/test_bookmark_index_view_performance.py b/bookmarks/tests/test_bookmark_index_view_performance.py
new file mode 100644
index 0000000..f13f532
--- /dev/null
+++ b/bookmarks/tests/test_bookmark_index_view_performance.py
@@ -0,0 +1,42 @@
+from django.contrib.auth.models import User
+from django.test import TransactionTestCase
+from django.test.utils import CaptureQueriesContext
+from django.urls import reverse
+from django.db import connections
+from django.db.utils import DEFAULT_DB_ALIAS
+
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
+
+ def setUp(self) -> None:
+ user = self.get_or_create_test_user()
+ self.client.force_login(user)
+
+ def get_connection(self):
+ return connections[DEFAULT_DB_ALIAS]
+
+ def test_should_not_increase_number_of_queries_per_bookmark(self):
+ # create initial bookmarks
+ num_initial_bookmarks = 10
+ for index in range(num_initial_bookmarks):
+ self.setup_bookmark(user=self.user)
+
+ # capture number of queries
+ context = CaptureQueriesContext(self.get_connection())
+ with context:
+ response = self.client.get(reverse('bookmarks:index'))
+ self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks)
+
+ number_of_queries = context.final_queries
+
+ # add more bookmarks
+ num_additional_bookmarks = 10
+ for index in range(num_additional_bookmarks):
+ self.setup_bookmark(user=self.user)
+
+ # assert num queries doesn't increase
+ with self.assertNumQueries(number_of_queries):
+ response = self.client.get(reverse('bookmarks:index'))
+ self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks + num_additional_bookmarks)
diff --git a/bookmarks/tests/test_bookmark_new_view.py b/bookmarks/tests/test_bookmark_new_view.py
index ba2ce78..86b2596 100644
--- a/bookmarks/tests/test_bookmark_new_view.py
+++ b/bookmarks/tests/test_bookmark_new_view.py
@@ -20,6 +20,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
'title': 'test title',
'description': 'test description',
'unread': False,
+ 'shared': False,
'auto_close': '',
}
return {**form_data, **overrides}
@@ -37,6 +38,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description'])
self.assertEqual(bookmark.unread, form_data['unread'])
+ self.assertEqual(bookmark.shared, form_data['shared'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.all()[0].name, 'tag1')
self.assertEqual(bookmark.tags.all()[1].name, 'tag2')
@@ -51,6 +53,16 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark = Bookmark.objects.first()
self.assertTrue(bookmark.unread)
+ def test_should_create_new_shared_bookmark(self):
+ form_data = self.create_form_data({'shared': True})
+
+ self.client.post(reverse('bookmarks:new'), form_data)
+
+ self.assertEqual(Bookmark.objects.count(), 1)
+
+ bookmark = Bookmark.objects.first()
+ self.assertTrue(bookmark.shared)
+
def test_should_prefill_url_from_url_parameter(self):
response = self.client.get(reverse('bookmarks:new') + '?url=http://example.com')
html = response.content.decode()
@@ -98,3 +110,30 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.post(reverse('bookmarks:new'), form_data)
self.assertRedirects(response, reverse('bookmarks:close'))
+
+ def test_should_respect_share_profile_setting(self):
+ self.user.profile.enable_sharing = False
+ self.user.profile.save()
+ response = self.client.get(reverse('bookmarks:new'))
+ html = response.content.decode()
+
+ self.assertInHTML('''
+
+
+
+ Share
+
+ ''', html, count=0)
+
+ self.user.profile.enable_sharing = True
+ self.user.profile.save()
+ response = self.client.get(reverse('bookmarks:new'))
+ html = response.content.decode()
+
+ self.assertInHTML('''
+
+
+
+ Share
+
+ ''', html, count=1)
diff --git a/bookmarks/tests/test_bookmark_search_tag.py b/bookmarks/tests/test_bookmark_search_tag.py
new file mode 100644
index 0000000..d63784b
--- /dev/null
+++ b/bookmarks/tests/test_bookmark_search_tag.py
@@ -0,0 +1,40 @@
+from django.db.models import QuerySet
+from django.template import Template, RequestContext
+from django.test import TestCase, RequestFactory
+
+from bookmarks.models import BookmarkFilters, Tag
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin):
+ def render_template(self, url: str, tags: QuerySet[Tag] = Tag.objects.all()):
+ rf = RequestFactory()
+ request = rf.get(url)
+ filters = BookmarkFilters(request)
+ context = RequestContext(request, {
+ 'request': request,
+ 'filters': filters,
+ 'tags': tags,
+ })
+ template_to_render = Template(
+ '{% load bookmarks %}'
+ '{% bookmark_search filters tags %}'
+ )
+ return template_to_render.render(context)
+
+ def test_render_hidden_inputs_for_filter_params(self):
+ # Should render hidden inputs if query param exists
+ url = '/test?q=foo&user=john'
+ rendered_template = self.render_template(url)
+
+ self.assertInHTML('''
+
+ ''', rendered_template)
+
+ # Should not render hidden inputs if query param does not exist
+ url = '/test?q=foo'
+ rendered_template = self.render_template(url)
+
+ self.assertInHTML('''
+
+ ''', rendered_template, count=0)
diff --git a/bookmarks/tests/test_bookmark_shared_view.py b/bookmarks/tests/test_bookmark_shared_view.py
new file mode 100644
index 0000000..7881ec3
--- /dev/null
+++ b/bookmarks/tests/test_bookmark_shared_view.py
@@ -0,0 +1,255 @@
+from typing import List
+
+from django.contrib.auth.models import User
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.models import Bookmark, Tag, UserProfile
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin):
+
+ def setUp(self) -> None:
+ user = self.get_or_create_test_user()
+ self.client.force_login(user)
+
+ def assertBookmarkCount(self, html: str, bookmark: Bookmark, count: int, link_target: str = '_blank'):
+ self.assertInHTML(
+ f'{bookmark.resolved_title} ',
+ html, count=count
+ )
+
+ def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
+ html = response.content.decode()
+ self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks))
+
+ for bookmark in bookmarks:
+ self.assertBookmarkCount(html, bookmark, 1, link_target)
+
+ def assertInvisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
+ html = response.content.decode()
+
+ for bookmark in bookmarks:
+ self.assertBookmarkCount(html, bookmark, 0, link_target)
+
+ def assertVisibleTags(self, response, tags: [Tag]):
+ self.assertContains(response, 'data-is-tag-item', count=len(tags))
+
+ for tag in tags:
+ self.assertContains(response, tag.name)
+
+ def assertInvisibleTags(self, response, tags: [Tag]):
+ for tag in tags:
+ self.assertNotContains(response, tag.name)
+
+ def assertVisibleUserOptions(self, response, users: List[User]):
+ html = response.content.decode()
+ self.assertContains(response, 'data-is-user-option', count=len(users))
+
+ for user in users:
+ self.assertInHTML(f'''
+
+ {user.username}
+
+ ''', html)
+
+ def assertInvisibleUserOptions(self, response, users: List[User]):
+ html = response.content.decode()
+
+ for user in users:
+ self.assertInHTML(f'''
+
+ {user.username}
+
+ ''', html, count=0)
+
+ def test_should_list_shared_bookmarks_from_all_users_that_have_sharing_enabled(self):
+ user1 = self.setup_user(enable_sharing=True)
+ user2 = self.setup_user(enable_sharing=True)
+ user3 = self.setup_user(enable_sharing=True)
+ user4 = self.setup_user(enable_sharing=False)
+
+ visible_bookmarks = [
+ self.setup_bookmark(shared=True, user=user1),
+ self.setup_bookmark(shared=True, user=user2),
+ self.setup_bookmark(shared=True, user=user3),
+ ]
+ invisible_bookmarks = [
+ self.setup_bookmark(shared=False, user=user1),
+ self.setup_bookmark(shared=False, user=user2),
+ self.setup_bookmark(shared=False, user=user3),
+ self.setup_bookmark(shared=True, user=user4),
+ ]
+
+ response = self.client.get(reverse('bookmarks:shared'))
+
+ self.assertContains(response, '') # Should render list
+ self.assertVisibleBookmarks(response, visible_bookmarks)
+ self.assertInvisibleBookmarks(response, invisible_bookmarks)
+
+ def test_should_list_shared_bookmarks_from_selected_user(self):
+ user1 = self.setup_user(enable_sharing=True)
+ user2 = self.setup_user(enable_sharing=True)
+ user3 = self.setup_user(enable_sharing=True)
+
+ visible_bookmarks = [
+ self.setup_bookmark(shared=True, user=user1),
+ ]
+ invisible_bookmarks = [
+ self.setup_bookmark(shared=True, user=user2),
+ self.setup_bookmark(shared=True, user=user3),
+ ]
+
+ url = reverse('bookmarks:shared') + '?user=' + user1.username
+ response = self.client.get(url)
+
+ self.assertVisibleBookmarks(response, visible_bookmarks)
+ self.assertInvisibleBookmarks(response, invisible_bookmarks)
+
+ def test_should_list_bookmarks_matching_query(self):
+ user = self.setup_user(enable_sharing=True)
+ visible_bookmarks = [
+ self.setup_bookmark(shared=True, title='searchvalue', user=user),
+ self.setup_bookmark(shared=True, title='searchvalue', user=user),
+ self.setup_bookmark(shared=True, title='searchvalue', user=user)
+ ]
+ invisible_bookmarks = [
+ self.setup_bookmark(shared=True, user=user),
+ self.setup_bookmark(shared=True, user=user),
+ self.setup_bookmark(shared=True, user=user)
+ ]
+
+ response = self.client.get(reverse('bookmarks:shared') + '?q=searchvalue')
+
+ self.assertContains(response, '') # Should render list
+ self.assertVisibleBookmarks(response, visible_bookmarks)
+ self.assertInvisibleBookmarks(response, invisible_bookmarks)
+
+ def test_should_list_tags_for_shared_bookmarks_from_all_users_that_have_sharing_enabled(self):
+ user1 = self.setup_user(enable_sharing=True)
+ user2 = self.setup_user(enable_sharing=True)
+ user3 = self.setup_user(enable_sharing=True)
+ user4 = self.setup_user(enable_sharing=False)
+ visible_tags = [
+ self.setup_tag(user=user1),
+ self.setup_tag(user=user2),
+ self.setup_tag(user=user3),
+ ]
+ invisible_tags = [
+ self.setup_tag(user=user1),
+ self.setup_tag(user=user2),
+ self.setup_tag(user=user3),
+ self.setup_tag(user=user4),
+ ]
+
+ self.setup_bookmark(shared=True, user=user1, tags=[visible_tags[0]])
+ self.setup_bookmark(shared=True, user=user2, tags=[visible_tags[1]])
+ self.setup_bookmark(shared=True, user=user3, tags=[visible_tags[2]])
+
+ self.setup_bookmark(shared=False, user=user1, tags=[invisible_tags[0]])
+ self.setup_bookmark(shared=False, user=user2, tags=[invisible_tags[1]])
+ self.setup_bookmark(shared=False, user=user3, tags=[invisible_tags[2]])
+ self.setup_bookmark(shared=True, user=user4, tags=[invisible_tags[3]])
+
+ response = self.client.get(reverse('bookmarks:shared'))
+
+ self.assertVisibleTags(response, visible_tags)
+ self.assertInvisibleTags(response, invisible_tags)
+
+ def test_should_list_tags_for_shared_bookmarks_from_selected_user(self):
+ user1 = self.setup_user(enable_sharing=True)
+ user2 = self.setup_user(enable_sharing=True)
+ user3 = self.setup_user(enable_sharing=True)
+ visible_tags = [
+ self.setup_tag(user=user1),
+ ]
+ invisible_tags = [
+ self.setup_tag(user=user2),
+ self.setup_tag(user=user3),
+ ]
+
+ self.setup_bookmark(shared=True, user=user1, tags=[visible_tags[0]])
+ self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[0]])
+ self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[1]])
+
+ url = reverse('bookmarks:shared') + '?user=' + user1.username
+ response = self.client.get(url)
+
+ self.assertVisibleTags(response, visible_tags)
+ self.assertInvisibleTags(response, invisible_tags)
+
+ def test_should_list_tags_for_bookmarks_matching_query(self):
+ user1 = self.setup_user(enable_sharing=True)
+ user2 = self.setup_user(enable_sharing=True)
+ user3 = self.setup_user(enable_sharing=True)
+ visible_tags = [
+ self.setup_tag(user=user1),
+ self.setup_tag(user=user2),
+ self.setup_tag(user=user3),
+ ]
+ invisible_tags = [
+ self.setup_tag(user=user1),
+ self.setup_tag(user=user2),
+ self.setup_tag(user=user3),
+ ]
+
+ self.setup_bookmark(shared=True, user=user1, title='searchvalue', tags=[visible_tags[0]])
+ self.setup_bookmark(shared=True, user=user2, title='searchvalue', tags=[visible_tags[1]])
+ self.setup_bookmark(shared=True, user=user3, title='searchvalue', tags=[visible_tags[2]])
+
+ self.setup_bookmark(shared=True, user=user1, tags=[invisible_tags[0]])
+ self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])
+ self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[2]])
+
+ response = self.client.get(reverse('bookmarks:shared') + '?q=searchvalue')
+
+ self.assertVisibleTags(response, visible_tags)
+ self.assertInvisibleTags(response, invisible_tags)
+
+ def test_should_list_users_with_shared_bookmarks_if_sharing_is_enabled(self):
+ expected_visible_users = [
+ self.setup_user(enable_sharing=True),
+ self.setup_user(enable_sharing=True),
+ ]
+ self.setup_bookmark(shared=True, user=expected_visible_users[0])
+ self.setup_bookmark(shared=True, user=expected_visible_users[1])
+
+ expected_invisible_users = [
+ self.setup_user(enable_sharing=True),
+ 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'))
+ self.assertVisibleUserOptions(response, expected_visible_users)
+ self.assertInvisibleUserOptions(response, expected_invisible_users)
+
+
+def test_should_open_bookmarks_in_new_page_by_default(self):
+ visible_bookmarks = [
+ self.setup_bookmark(shared=True),
+ self.setup_bookmark(shared=True),
+ self.setup_bookmark(shared=True)
+ ]
+
+ response = self.client.get(reverse('bookmarks:shared'))
+
+ self.assertVisibleBookmarks(response, visible_bookmarks, '_blank')
+
+
+def test_should_open_bookmarks_in_same_page_if_specified_in_user_profile(self):
+ user = self.get_or_create_test_user()
+ user.profile.bookmark_link_target = UserProfile.BOOKMARK_LINK_TARGET_SELF
+ user.profile.save()
+
+ visible_bookmarks = [
+ self.setup_bookmark(shared=True),
+ self.setup_bookmark(shared=True),
+ self.setup_bookmark(shared=True)
+ ]
+
+ response = self.client.get(reverse('bookmarks:shared'))
+
+ self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
diff --git a/bookmarks/tests/test_bookmark_shared_view_performance.py b/bookmarks/tests/test_bookmark_shared_view_performance.py
new file mode 100644
index 0000000..f9aaceb
--- /dev/null
+++ b/bookmarks/tests/test_bookmark_shared_view_performance.py
@@ -0,0 +1,44 @@
+from django.contrib.auth.models import User
+from django.test import TransactionTestCase
+from django.test.utils import CaptureQueriesContext
+from django.urls import reverse
+from django.db import connections
+from django.db.utils import DEFAULT_DB_ALIAS
+
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
+
+ def setUp(self) -> None:
+ user = self.get_or_create_test_user()
+ self.client.force_login(user)
+
+ def get_connection(self):
+ return connections[DEFAULT_DB_ALIAS]
+
+ def test_should_not_increase_number_of_queries_per_bookmark(self):
+ # create initial users and bookmarks
+ num_initial_bookmarks = 10
+ for index in range(num_initial_bookmarks):
+ user = self.setup_user(enable_sharing=True)
+ self.setup_bookmark(user=user, shared=True)
+
+ # capture number of queries
+ context = CaptureQueriesContext(self.get_connection())
+ with context:
+ response = self.client.get(reverse('bookmarks:shared'))
+ self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks)
+
+ number_of_queries = context.final_queries
+
+ # add more users and bookmarks
+ num_additional_bookmarks = 10
+ for index in range(num_additional_bookmarks):
+ user = self.setup_user(enable_sharing=True)
+ self.setup_bookmark(user=user, shared=True)
+
+ # assert num queries doesn't increase
+ with self.assertNumQueries(number_of_queries):
+ response = self.client.get(reverse('bookmarks:shared'))
+ self.assertContains(response, 'data-is-bookmark-item', num_initial_bookmarks + num_additional_bookmarks)
diff --git a/bookmarks/tests/test_bookmarks_api.py b/bookmarks/tests/test_bookmarks_api.py
index caf34ea..485814d 100644
--- a/bookmarks/tests/test_bookmarks_api.py
+++ b/bookmarks/tests/test_bookmarks_api.py
@@ -36,6 +36,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expectation['website_description'] = bookmark.website_description
expectation['is_archived'] = bookmark.is_archived
expectation['unread'] = bookmark.unread
+ expectation['shared'] = bookmark.shared
expectation['tag_names'] = tag_names
expectation['date_added'] = bookmark.date_added.isoformat().replace('+00:00', 'Z')
expectation['date_modified'] = bookmark.date_modified.isoformat().replace('+00:00', 'Z')
@@ -64,6 +65,66 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1])
+ def test_list_shared_bookmarks(self):
+ user1 = self.setup_user(enable_sharing=True)
+ user2 = self.setup_user(enable_sharing=True)
+ user3 = self.setup_user(enable_sharing=True)
+ user4 = self.setup_user(enable_sharing=False)
+ shared_bookmarks = [
+ self.setup_bookmark(shared=True, user=user1),
+ self.setup_bookmark(shared=True, user=user2),
+ self.setup_bookmark(shared=True, user=user3),
+ ]
+ # Unshared bookmarks
+ self.setup_bookmark(shared=False, user=user1)
+ self.setup_bookmark(shared=False, user=user2)
+ self.setup_bookmark(shared=False, user=user3)
+ self.setup_bookmark(shared=True, user=user4)
+
+ response = self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
+ self.assertBookmarkListEqual(response.data['results'], shared_bookmarks)
+
+ def test_list_shared_bookmarks_should_filter_by_query_and_user(self):
+ # Search by query
+ user1 = self.setup_user(enable_sharing=True)
+ user2 = self.setup_user(enable_sharing=True)
+ user3 = self.setup_user(enable_sharing=True)
+ expected_bookmarks = [
+ self.setup_bookmark(title='searchvalue', shared=True, user=user1),
+ self.setup_bookmark(title='searchvalue', shared=True, user=user2),
+ self.setup_bookmark(title='searchvalue', shared=True, user=user3),
+ ]
+ self.setup_bookmark(shared=True, user=user1),
+ self.setup_bookmark(shared=True, user=user2),
+ self.setup_bookmark(shared=True, user=user3),
+
+ response = self.get(reverse('bookmarks:bookmark-shared') + '?q=searchvalue',
+ expected_status_code=status.HTTP_200_OK)
+ self.assertBookmarkListEqual(response.data['results'], expected_bookmarks)
+
+ # Search by user
+ user_search_user = self.setup_user(enable_sharing=True)
+ expected_bookmarks = [
+ self.setup_bookmark(shared=True, user=user_search_user),
+ self.setup_bookmark(shared=True, user=user_search_user),
+ self.setup_bookmark(shared=True, user=user_search_user),
+ ]
+ response = self.get(reverse('bookmarks:bookmark-shared') + '?user=' + user_search_user.username,
+ expected_status_code=status.HTTP_200_OK)
+ self.assertBookmarkListEqual(response.data['results'], expected_bookmarks)
+
+ # Search by query and user
+ combined_search_user = self.setup_user(enable_sharing=True)
+ expected_bookmarks = [
+ self.setup_bookmark(title='searchvalue', shared=True, user=combined_search_user),
+ self.setup_bookmark(title='searchvalue', shared=True, user=combined_search_user),
+ self.setup_bookmark(title='searchvalue', shared=True, user=combined_search_user),
+ ]
+ response = self.get(
+ reverse('bookmarks:bookmark-shared') + '?q=searchvalue&user=' + combined_search_user.username,
+ expected_status_code=status.HTTP_200_OK)
+ self.assertBookmarkListEqual(response.data['results'], expected_bookmarks)
+
def test_create_bookmark(self):
data = {
'url': 'https://example.com/',
@@ -71,6 +132,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
'description': 'Test description',
'is_archived': False,
'unread': False,
+ 'shared': False,
'tag_names': ['tag1', 'tag2']
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
@@ -80,6 +142,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.description, data['description'])
self.assertFalse(bookmark.is_archived, data['is_archived'])
self.assertFalse(bookmark.unread, data['unread'])
+ self.assertFalse(bookmark.shared, data['shared'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][0]).count(), 1)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
@@ -91,6 +154,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
'title': 'Updated title',
'description': 'Updated description',
'unread': True,
+ 'shared': True,
'is_archived': True,
'tag_names': ['tag1', 'tag2']
}
@@ -103,6 +167,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
# Saving a duplicate bookmark should not modify archive flag - right?
self.assertFalse(bookmark.is_archived)
self.assertEqual(bookmark.unread, data['unread'])
+ self.assertEqual(bookmark.shared, data['shared'])
self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][0]).count(), 1)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
@@ -159,6 +224,18 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
bookmark = Bookmark.objects.get(url=data['url'])
self.assertFalse(bookmark.unread)
+ def test_create_shared_bookmark(self):
+ data = {'url': 'https://example.com/', 'shared': True}
+ self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
+ bookmark = Bookmark.objects.get(url=data['url'])
+ self.assertTrue(bookmark.shared)
+
+ def test_create_bookmark_is_not_shared_by_default(self):
+ data = {'url': 'https://example.com/'}
+ self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
+ bookmark = Bookmark.objects.get(url=data['url'])
+ self.assertFalse(bookmark.shared)
+
def test_get_bookmark(self):
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK)
@@ -193,6 +270,13 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertEqual(updated_bookmark.unread, True)
+ def test_update_bookmark_shared_flag(self):
+ data = {'url': 'https://example.com/', 'shared': True}
+ url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
+ self.put(url, data, expected_status_code=status.HTTP_200_OK)
+ updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
+ self.assertEqual(updated_bookmark.shared, True)
+
def test_patch_bookmark(self):
data = {'url': 'https://example.com'}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
@@ -224,6 +308,18 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.bookmark1.refresh_from_db()
self.assertFalse(self.bookmark1.unread)
+ data = {'shared': True}
+ url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
+ self.patch(url, data, expected_status_code=status.HTTP_200_OK)
+ self.bookmark1.refresh_from_db()
+ self.assertTrue(self.bookmark1.shared)
+
+ data = {'shared': False}
+ url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
+ self.patch(url, data, expected_status_code=status.HTTP_200_OK)
+ self.bookmark1.refresh_from_db()
+ self.assertFalse(self.bookmark1.shared)
+
data = {'tag_names': ['updated-tag-1', 'updated-tag-2']}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
@@ -260,6 +356,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def test_can_only_access_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
inaccessible_bookmark = self.setup_bookmark(user=other_user)
+ inaccessible_shared_bookmark = self.setup_bookmark(user=other_user, shared=True)
self.setup_bookmark(user=other_user, is_archived=True)
url = reverse('bookmarks:bookmark-list')
@@ -273,14 +370,29 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id])
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
+ url = reverse('bookmarks:bookmark-detail', args=[inaccessible_shared_bookmark.id])
+ self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
+
url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id])
self.put(url, {url: 'https://example.com/'}, expected_status_code=status.HTTP_404_NOT_FOUND)
+ url = reverse('bookmarks:bookmark-detail', args=[inaccessible_shared_bookmark.id])
+ self.put(url, {url: 'https://example.com/'}, expected_status_code=status.HTTP_404_NOT_FOUND)
+
url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id])
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
+ url = reverse('bookmarks:bookmark-detail', args=[inaccessible_shared_bookmark.id])
+ self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
+
url = reverse('bookmarks:bookmark-archive', args=[inaccessible_bookmark.id])
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
+ url = reverse('bookmarks:bookmark-archive', args=[inaccessible_shared_bookmark.id])
+ self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
+
url = reverse('bookmarks:bookmark-unarchive', args=[inaccessible_bookmark.id])
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
+
+ url = reverse('bookmarks:bookmark-unarchive', args=[inaccessible_shared_bookmark.id])
+ self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
diff --git a/bookmarks/tests/test_bookmarks_list_tag.py b/bookmarks/tests/test_bookmarks_list_tag.py
index 5156afc..725946c 100644
--- a/bookmarks/tests/test_bookmarks_list_tag.py
+++ b/bookmarks/tests/test_bookmarks_list_tag.py
@@ -2,9 +2,10 @@ from dateutil.relativedelta import relativedelta
from django.core.paginator import Paginator
from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory
+from django.urls import reverse
from django.utils import timezone, formats
-from bookmarks.models import Bookmark, UserProfile
+from bookmarks.models import Bookmark, UserProfile, User
from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -41,9 +42,46 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
''', html)
- def render_template(self, bookmarks: [Bookmark], template: Template) -> str:
+ def assertBookmarkActions(self, html: str, bookmark: Bookmark):
+ self.assertBookmarkActionsCount(html, bookmark, count=1)
+
+ def assertNoBookmarkActions(self, html: str, bookmark: Bookmark):
+ self.assertBookmarkActionsCount(html, bookmark, count=0)
+
+ def assertBookmarkActionsCount(self, html: str, bookmark: Bookmark, count=1):
+ # Edit link
+ edit_url = reverse('bookmarks:edit', args=[bookmark.id])
+ self.assertInHTML(f'''
+ Edit
+ ''', html, count=count)
+ # Archive link
+ self.assertInHTML(f'''
+ Archive
+ ''', html, count=count)
+ # Delete link
+ self.assertInHTML(f'''
+ Remove
+ ''', html, count=count)
+
+ def assertShareInfo(self, html: str, bookmark: Bookmark):
+ self.assertShareInfoCount(html, bookmark, 1)
+
+ def assertNoShareInfo(self, html: str, bookmark: Bookmark):
+ self.assertShareInfoCount(html, bookmark, 0)
+
+ def assertShareInfoCount(self, html: str, bookmark: Bookmark, count=1):
+ self.assertInHTML(f'''
+ Shared by
+ {bookmark.owner.username}
+
+ ''', html, count=count)
+
+ def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test') -> str:
rf = RequestFactory()
- request = rf.get('/test')
+ request = rf.get(url)
request.user = self.get_or_create_test_user()
paginator = Paginator(bookmarks, 10)
page = paginator.page(1)
@@ -51,12 +89,12 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
context = RequestContext(request, {'bookmarks': page, 'return_url': '/test'})
return template.render(context)
- def render_default_template(self, bookmarks: [Bookmark]) -> str:
+ def render_default_template(self, bookmarks: [Bookmark], url: str = '/test') -> str:
template = Template(
'{% load bookmarks %}'
'{% bookmark_list bookmarks return_url %}'
)
- return self.render_template(bookmarks, template)
+ return self.render_template(bookmarks, template, url)
def render_template_with_link_target(self, bookmarks: [Bookmark], link_target: str) -> str:
template = Template(
@@ -147,3 +185,29 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
html = self.render_template_with_link_target([bookmark], '_self')
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_self')
+
+ def test_show_bookmark_actions_for_owned_bookmarks(self):
+ bookmark = self.setup_bookmark()
+ html = self.render_default_template([bookmark])
+
+ self.assertBookmarkActions(html, bookmark)
+ self.assertNoShareInfo(html, bookmark)
+
+ def test_show_share_info_for_non_owned_bookmarks(self):
+ other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
+ bookmark = self.setup_bookmark(user=other_user)
+ html = self.render_default_template([bookmark])
+
+ self.assertNoBookmarkActions(html, bookmark)
+ self.assertShareInfo(html, bookmark)
+
+ def test_share_info_user_link_keeps_query_params(self):
+ other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
+ bookmark = self.setup_bookmark(user=other_user)
+ html = self.render_default_template([bookmark], url='/test?q=foo')
+
+ self.assertInHTML(f'''
+ Shared by
+ {bookmark.owner.username}
+
+ ''', html)
diff --git a/bookmarks/tests/test_bookmarks_service.py b/bookmarks/tests/test_bookmarks_service.py
index 53e5879..23bb03e 100644
--- a/bookmarks/tests/test_bookmarks_service.py
+++ b/bookmarks/tests/test_bookmarks_service.py
@@ -19,11 +19,12 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.get_or_create_test_user()
def test_create_should_update_existing_bookmark_with_same_url(self):
- original_bookmark = self.setup_bookmark(url='https://example.com', unread=False)
+ original_bookmark = self.setup_bookmark(url='https://example.com', unread=False, shared=False)
bookmark_data = Bookmark(url='https://example.com',
title='Updated Title',
description='Updated description',
unread=True,
+ shared=True,
is_archived=True)
updated_bookmark = create_bookmark(bookmark_data, '', self.get_or_create_test_user())
@@ -32,6 +33,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(updated_bookmark.title, bookmark_data.title)
self.assertEqual(updated_bookmark.description, bookmark_data.description)
self.assertEqual(updated_bookmark.unread, bookmark_data.unread)
+ self.assertEqual(updated_bookmark.shared, bookmark_data.shared)
# Saving a duplicate bookmark should not modify archive flag - right?
self.assertFalse(updated_bookmark.is_archived)
diff --git a/bookmarks/tests/test_nav_menu.py b/bookmarks/tests/test_nav_menu.py
new file mode 100644
index 0000000..a8f5cd5
--- /dev/null
+++ b/bookmarks/tests/test_nav_menu.py
@@ -0,0 +1,30 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class NavMenuTestCase(TestCase, BookmarkFactoryMixin):
+
+ def setUp(self) -> None:
+ user = self.get_or_create_test_user()
+ self.client.force_login(user)
+
+ def test_should_respect_share_profile_setting(self):
+ self.user.profile.enable_sharing = False
+ self.user.profile.save()
+ response = self.client.get(reverse('bookmarks:index'))
+ html = response.content.decode()
+
+ self.assertInHTML(f'''
+ Shared
+ ''', html, count=0)
+
+ self.user.profile.enable_sharing = True
+ self.user.profile.save()
+ response = self.client.get(reverse('bookmarks:index'))
+ html = response.content.decode()
+
+ self.assertInHTML(f'''
+ Shared
+ ''', html, count=2)
diff --git a/bookmarks/tests/test_queries.py b/bookmarks/tests/test_queries.py
index e7f6bfd..ad244f5 100644
--- a/bookmarks/tests/test_queries.py
+++ b/bookmarks/tests/test_queries.py
@@ -572,3 +572,86 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
query = queries.query_archived_bookmark_tags(self.user, f'!untagged #{tag.name}')
self.assertCountEqual(list(query), [])
+
+ def test_query_shared_bookmarks(self):
+ user1 = self.setup_user(enable_sharing=True)
+ user2 = self.setup_user(enable_sharing=True)
+ user3 = self.setup_user(enable_sharing=True)
+ user4 = self.setup_user(enable_sharing=False)
+ tag = self.setup_tag()
+
+ shared_bookmarks = [
+ self.setup_bookmark(user=user1, shared=True, title='test title'),
+ self.setup_bookmark(user=user2, shared=True),
+ self.setup_bookmark(user=user3, shared=True, tags=[tag]),
+ ]
+
+ # Unshared bookmarks
+ self.setup_bookmark(user=user1, shared=False, title='test title'),
+ self.setup_bookmark(user=user2, shared=False),
+ self.setup_bookmark(user=user3, shared=False, tags=[tag]),
+ self.setup_bookmark(user=user4, shared=True, tags=[tag]),
+
+ # Should return shared bookmarks from all users
+ query_set = queries.query_shared_bookmarks(None, '')
+ self.assertQueryResult(query_set, [shared_bookmarks])
+
+ # Should respect search query
+ query_set = queries.query_shared_bookmarks(None, 'test title')
+ self.assertQueryResult(query_set, [[shared_bookmarks[0]]])
+
+ query_set = queries.query_shared_bookmarks(None, '#' + tag.name)
+ self.assertQueryResult(query_set, [[shared_bookmarks[2]]])
+
+ def test_query_shared_bookmark_tags(self):
+ user1 = self.setup_user(enable_sharing=True)
+ user2 = self.setup_user(enable_sharing=True)
+ user3 = self.setup_user(enable_sharing=True)
+ user4 = self.setup_user(enable_sharing=False)
+
+ shared_tags = [
+ self.setup_tag(user=user1),
+ self.setup_tag(user=user2),
+ self.setup_tag(user=user3),
+ ]
+
+ self.setup_bookmark(user=user1, shared=True, tags=[shared_tags[0]]),
+ self.setup_bookmark(user=user2, shared=True, tags=[shared_tags[1]]),
+ self.setup_bookmark(user=user3, shared=True, tags=[shared_tags[2]]),
+
+ self.setup_bookmark(user=user1, shared=False, tags=[self.setup_tag(user=user1)]),
+ self.setup_bookmark(user=user2, shared=False, tags=[self.setup_tag(user=user2)]),
+ 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)]),
+
+ query_set = queries.query_shared_bookmark_tags(None, '')
+
+ self.assertQueryResult(query_set, [shared_tags])
+
+ def test_query_shared_bookmark_users(self):
+ users_with_shared_bookmarks = [
+ self.setup_user(enable_sharing=True),
+ self.setup_user(enable_sharing=True),
+ ]
+ users_without_shared_bookmarks = [
+ self.setup_user(enable_sharing=True),
+ self.setup_user(enable_sharing=True),
+ self.setup_user(enable_sharing=False),
+ ]
+
+ # Shared bookmarks
+ self.setup_bookmark(user=users_with_shared_bookmarks[0], shared=True, title='test title'),
+ self.setup_bookmark(user=users_with_shared_bookmarks[1], shared=True),
+
+ # Unshared bookmarks
+ self.setup_bookmark(user=users_without_shared_bookmarks[0], shared=False, title='test title'),
+ self.setup_bookmark(user=users_without_shared_bookmarks[1], shared=False),
+ self.setup_bookmark(user=users_without_shared_bookmarks[2], shared=True),
+
+ # Should return users with shared bookmarks
+ query_set = queries.query_shared_bookmark_users('')
+ self.assertQueryResult(query_set, [users_with_shared_bookmarks])
+
+ # Should respect search query
+ query_set = queries.query_shared_bookmark_users('test title')
+ self.assertQueryResult(query_set, [[users_with_shared_bookmarks[0]]])
diff --git a/bookmarks/tests/test_settings_general_view.py b/bookmarks/tests/test_settings_general_view.py
index 08ff2c1..59c0043 100644
--- a/bookmarks/tests/test_settings_general_view.py
+++ b/bookmarks/tests/test_settings_general_view.py
@@ -34,6 +34,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF,
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
+ 'enable_sharing': True,
}
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
@@ -44,6 +45,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(self.user.profile.bookmark_date_display, form_data['bookmark_date_display'])
self.assertEqual(self.user.profile.bookmark_link_target, form_data['bookmark_link_target'])
self.assertEqual(self.user.profile.web_archive_integration, form_data['web_archive_integration'])
+ self.assertEqual(self.user.profile.enable_sharing, form_data['enable_sharing'])
def test_about_shows_version_info(self):
response = self.client.get(reverse('bookmarks:settings.general'))
diff --git a/bookmarks/tests/test_tag_cloud_tag.py b/bookmarks/tests/test_tag_cloud_tag.py
new file mode 100644
index 0000000..25b8014
--- /dev/null
+++ b/bookmarks/tests/test_tag_cloud_tag.py
@@ -0,0 +1,94 @@
+from typing import List
+
+from bs4 import BeautifulSoup
+from django.template import Template, RequestContext
+from django.test import TestCase, RequestFactory
+
+from bookmarks.models import Tag, User
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class TagCloudTagTest(TestCase, BookmarkFactoryMixin):
+ def make_soup(self, html: str):
+ return BeautifulSoup(html, features="html.parser")
+
+ def render_template(self, tags: List[Tag], url: str = '/test'):
+ rf = RequestFactory()
+ request = rf.get(url)
+ context = RequestContext(request, {
+ 'request': request,
+ 'tags': tags,
+ })
+ template_to_render = Template(
+ '{% load bookmarks %}'
+ '{% tag_cloud tags %}'
+ )
+ return template_to_render.render(context)
+
+ def assertTagGroups(self, rendered_template: str, groups: List[List[str]]):
+ soup = self.make_soup(rendered_template)
+ group_elements = soup.select('p.group')
+
+ self.assertEqual(len(group_elements), len(groups))
+
+ for group_index, tags in enumerate(groups, start=0):
+ group_element = group_elements[group_index]
+ link_elements = group_element.select('a')
+
+ self.assertEqual(len(link_elements), len(tags))
+
+ for tag_index, tag in enumerate(tags, start=0):
+ link_element = link_elements[tag_index]
+ self.assertEqual(link_element.text.strip(), tag)
+
+ def test_group_alphabetically(self):
+ tags = [
+ self.setup_tag(name='Cockatoo'),
+ self.setup_tag(name='Badger'),
+ self.setup_tag(name='Buffalo'),
+ self.setup_tag(name='Chihuahua'),
+ self.setup_tag(name='Alpaca'),
+ self.setup_tag(name='Coyote'),
+ self.setup_tag(name='Aardvark'),
+ self.setup_tag(name='Bumblebee'),
+ self.setup_tag(name='Armadillo'),
+ ]
+
+ rendered_template = self.render_template(tags)
+
+ self.assertTagGroups(rendered_template, [
+ [
+ 'Aardvark',
+ 'Alpaca',
+ 'Armadillo',
+ ],
+ [
+ 'Badger',
+ 'Buffalo',
+ 'Bumblebee',
+ ],
+ [
+ 'Chihuahua',
+ 'Cockatoo',
+ 'Coyote',
+ ],
+ ])
+
+ def test_no_duplicate_tag_names(self):
+ user1 = User.objects.create_user('user1', 'user1@example.com', 'password123')
+ user2 = User.objects.create_user('user2', 'user2@example.com', 'password123')
+ user3 = User.objects.create_user('user3', 'user3@example.com', 'password123')
+
+ tags = [
+ self.setup_tag(name='shared', user=user1),
+ self.setup_tag(name='shared', user=user2),
+ self.setup_tag(name='shared', user=user3),
+ ]
+
+ rendered_template = self.render_template(tags)
+
+ self.assertTagGroups(rendered_template, [
+ [
+ 'shared',
+ ],
+ ])
diff --git a/bookmarks/tests/test_user_profile_model.py b/bookmarks/tests/test_user_profile_model.py
index 431eb03..bb7fb3c 100644
--- a/bookmarks/tests/test_user_profile_model.py
+++ b/bookmarks/tests/test_user_profile_model.py
@@ -10,3 +10,8 @@ class UserProfileTestCase(TestCase):
user = User.objects.create_user('testuser', 'test@example.com', 'password123')
profile = UserProfile.objects.all().filter(user_id=user.id).first()
self.assertIsNotNone(profile)
+
+ def test_bookmark_sharing_is_disabled_by_default(self):
+ user = User.objects.create_user('testuser', 'test@example.com', 'password123')
+ profile = UserProfile.objects.all().filter(user_id=user.id).first()
+ self.assertFalse(profile.enable_sharing)
diff --git a/bookmarks/tests/test_user_select_tag.py b/bookmarks/tests/test_user_select_tag.py
new file mode 100644
index 0000000..60b1d68
--- /dev/null
+++ b/bookmarks/tests/test_user_select_tag.py
@@ -0,0 +1,76 @@
+from django.db.models import QuerySet
+from django.template import Template, RequestContext
+from django.test import TestCase, RequestFactory
+
+from bookmarks.models import BookmarkFilters, User
+from bookmarks.tests.helpers import BookmarkFactoryMixin
+
+
+class UserSelectTagTest(TestCase, BookmarkFactoryMixin):
+ def render_template(self, url: str, users: QuerySet[User] = User.objects.all()):
+ rf = RequestFactory()
+ request = rf.get(url)
+ filters = BookmarkFilters(request)
+ context = RequestContext(request, {
+ 'request': request,
+ 'filters': filters,
+ 'users': users,
+ })
+ template_to_render = Template(
+ '{% load bookmarks %}'
+ '{% user_select filters users %}'
+ )
+ return template_to_render.render(context)
+
+ def assertUserOption(self, html: str, user: User, selected: bool = False):
+ self.assertInHTML(f'''
+
+ {user.username}
+
+ ''', html)
+
+ def test_empty_option(self):
+ rendered_template = self.render_template('/test')
+
+ self.assertInHTML(f'''
+ Everyone
+ ''', rendered_template)
+
+ def test_render_user_options(self):
+ user1 = User.objects.create_user('user1', 'user1@example.com', 'password123')
+ user2 = User.objects.create_user('user2', 'user2@example.com', 'password123')
+ user3 = User.objects.create_user('user3', 'user3@example.com', 'password123')
+
+ rendered_template = self.render_template('/test', User.objects.all())
+
+ self.assertUserOption(rendered_template, user1)
+ self.assertUserOption(rendered_template, user2)
+ self.assertUserOption(rendered_template, user3)
+
+ def test_preselect_user_option(self):
+ user1 = User.objects.create_user('user1', 'user1@example.com', 'password123')
+ User.objects.create_user('user2', 'user2@example.com', 'password123')
+ User.objects.create_user('user3', 'user3@example.com', 'password123')
+
+ rendered_template = self.render_template('/test?user=user1', User.objects.all())
+
+ self.assertUserOption(rendered_template, user1, True)
+
+ def test_render_hidden_inputs_for_filter_params(self):
+ # Should render hidden inputs if query param exists
+ url = '/test?q=foo&user=john'
+ rendered_template = self.render_template(url)
+
+ self.assertInHTML('''
+
+ ''', rendered_template)
+
+ # Should not render hidden inputs if query param does not exist
+ url = '/test?user=john'
+ rendered_template = self.render_template(url)
+
+ self.assertInHTML('''
+
+ ''', rendered_template, count=0)
diff --git a/bookmarks/urls.py b/bookmarks/urls.py
index a0627a2..e25ed05 100644
--- a/bookmarks/urls.py
+++ b/bookmarks/urls.py
@@ -13,6 +13,7 @@ urlpatterns = [
# Bookmarks
path('bookmarks', views.bookmarks.index, name='index'),
path('bookmarks/archived', views.bookmarks.archived, name='archived'),
+ path('bookmarks/shared', views.bookmarks.shared, name='shared'),
path('bookmarks/new', views.bookmarks.new, name='new'),
path('bookmarks/close', views.bookmarks.close, name='close'),
path('bookmarks//edit', views.bookmarks.edit, name='edit'),
diff --git a/bookmarks/views/bookmarks.py b/bookmarks/views/bookmarks.py
index 614e5fd..3f1662e 100644
--- a/bookmarks/views/bookmarks.py
+++ b/bookmarks/views/bookmarks.py
@@ -1,13 +1,15 @@
import urllib.parse
from django.contrib.auth.decorators import login_required
+from django.core.handlers.wsgi import WSGIRequest
from django.core.paginator import Paginator
+from django.db.models import QuerySet, prefetch_related_objects
from django.http import HttpResponseRedirect, Http404
from django.shortcuts import render
from django.urls import reverse
from bookmarks import queries
-from bookmarks.models import Bookmark, BookmarkForm, build_tag_string
+from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, User, Tag, build_tag_string
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks
from bookmarks.utils import get_safe_return_url
@@ -17,30 +19,48 @@ _default_page_size = 30
@login_required
def index(request):
- query_string = request.GET.get('q')
- query_set = queries.query_bookmarks(request.user, query_string)
- tags = queries.query_bookmark_tags(request.user, query_string)
+ filters = BookmarkFilters(request)
+ query_set = queries.query_bookmarks(request.user, filters.query)
+ tags = queries.query_bookmark_tags(request.user, filters.query)
base_url = reverse('bookmarks:index')
- context = get_bookmark_view_context(request, query_set, tags, base_url)
+ context = get_bookmark_view_context(request, filters, query_set, tags, base_url)
return render(request, 'bookmarks/index.html', context)
@login_required
def archived(request):
- query_string = request.GET.get('q')
- query_set = queries.query_archived_bookmarks(request.user, query_string)
- tags = queries.query_archived_bookmark_tags(request.user, query_string)
+ filters = BookmarkFilters(request)
+ query_set = queries.query_archived_bookmarks(request.user, filters.query)
+ tags = queries.query_archived_bookmark_tags(request.user, filters.query)
base_url = reverse('bookmarks:archived')
- context = get_bookmark_view_context(request, query_set, tags, base_url)
+ context = get_bookmark_view_context(request, filters, query_set, tags, base_url)
return render(request, 'bookmarks/archive.html', context)
-def get_bookmark_view_context(request, query_set, tags, base_url):
+@login_required
+def shared(request):
+ filters = BookmarkFilters(request)
+ user = User.objects.filter(username=filters.user).first()
+ query_set = queries.query_shared_bookmarks(user, filters.query)
+ tags = queries.query_shared_bookmark_tags(user, filters.query)
+ users = queries.query_shared_bookmark_users(filters.query)
+ base_url = reverse('bookmarks:shared')
+ context = get_bookmark_view_context(request, filters, query_set, tags, base_url)
+ context['users'] = users
+ return render(request, 'bookmarks/shared.html', context)
+
+
+def get_bookmark_view_context(request: WSGIRequest,
+ filters: BookmarkFilters,
+ query_set: QuerySet[Bookmark],
+ tags: QuerySet[Tag],
+ base_url: str):
page = request.GET.get('page')
- query_string = request.GET.get('q')
paginator = Paginator(query_set, _default_page_size)
bookmarks = paginator.get_page(page)
- return_url = generate_return_url(base_url, page, query_string)
+ # Prefetch owner relation, this avoids n+1 queries when using the owner in templates
+ prefetch_related_objects(bookmarks.object_list, 'owner')
+ return_url = generate_return_url(base_url, page, filters)
link_target = request.user.profile.bookmark_link_target
if request.GET.get('tag'):
@@ -51,17 +71,19 @@ def get_bookmark_view_context(request, query_set, tags, base_url):
return {
'bookmarks': bookmarks,
'tags': tags,
- 'query': query_string if query_string else '',
+ 'filters': filters,
'empty': paginator.count == 0,
'return_url': return_url,
'link_target': link_target,
}
-def generate_return_url(base_url, page, query_string):
+def generate_return_url(base_url: str, page: int, filters: BookmarkFilters):
url_query = {}
- if query_string is not None:
- url_query['q'] = query_string
+ if filters.query:
+ url_query['q'] = filters.query
+ if filters.user:
+ url_query['user'] = filters.user
if page is not None:
url_query['page'] = page
url_params = urllib.parse.urlencode(url_query)