Add bookmark sharing (#311)

* Allow marking bookmarks as shared

* Add basic share view

* Ensure tag names in tag cloud are unique

* Filter shared bookmarks by user

* Add link for filtering by user

* Prevent n+1 queries when rendering bookmark list

* Prevent empty query params in return URL

* Fix user select template tag name

* Create shared bookmarks through API

* List shared bookmarks through API

* Show bookmark suggestions for shared view

* Show unique tags in search suggestions

* Sort user options

* Add bookmark sharing feature flag

* Add test for share setting default

* Simplify settings view
This commit is contained in:
Sascha Ißbrücker
2022-08-04 19:37:16 +02:00
committed by GitHub
parent e6718be53b
commit fec966f687
40 changed files with 1358 additions and 74 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, Tag from bookmarks.models import Bookmark, BookmarkFilters, Tag, User
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
from bookmarks.services.website_loader import load_website_metadata from bookmarks.services.website_loader import load_website_metadata
@@ -42,6 +42,16 @@ class BookmarkViewSet(viewsets.GenericViewSet,
data = serializer(page, many=True).data data = serializer(page, many=True).data
return self.get_paginated_response(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) @action(methods=['post'], detail=True)
def archive(self, request, pk): def archive(self, request, pk):
bookmark = self.get_object() bookmark = self.get_object()

View File

@@ -21,6 +21,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
'website_description', 'website_description',
'is_archived', 'is_archived',
'unread', 'unread',
'shared',
'tag_names', 'tag_names',
'date_added', 'date_added',
'date_modified' 'date_modified'
@@ -37,6 +38,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
description = serializers.CharField(required=False, allow_blank=True, default='') description = serializers.CharField(required=False, allow_blank=True, default='')
is_archived = serializers.BooleanField(required=False, default=False) is_archived = serializers.BooleanField(required=False, default=False)
unread = 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 # Override readonly tag_names property to allow passing a list of tag names to create/update
tag_names = TagListField(required=False, default=[]) tag_names = TagListField(required=False, default=[])
@@ -47,12 +49,13 @@ class BookmarkSerializer(serializers.ModelSerializer):
bookmark.description = validated_data['description'] bookmark.description = validated_data['description']
bookmark.is_archived = validated_data['is_archived'] bookmark.is_archived = validated_data['is_archived']
bookmark.unread = validated_data['unread'] bookmark.unread = validated_data['unread']
bookmark.shared = validated_data['shared']
tag_string = build_tag_string(validated_data['tag_names']) tag_string = build_tag_string(validated_data['tag_names'])
return create_bookmark(bookmark, tag_string, self.context['user']) return create_bookmark(bookmark, tag_string, self.context['user'])
def update(self, instance: Bookmark, validated_data): def update(self, instance: Bookmark, validated_data):
# Update fields if they were provided in the payload # 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: if key in validated_data:
setattr(instance, key, validated_data[key]) setattr(instance, key, validated_data[key])

View File

@@ -8,8 +8,9 @@
export let placeholder; export let placeholder;
export let value; export let value;
export let tags; export let tags;
export let mode = 'default'; export let mode = '';
export let apiClient; export let apiClient;
export let filters;
let isFocus = false; let isFocus = false;
let isOpen = false; let isOpen = false;
@@ -112,9 +113,12 @@
let bookmarks = [] let bookmarks = []
if (value && value.length >= 3) { if (value && value.length >= 3) {
const fetchedBookmarks = mode === 'archive' const path = mode ? `/${mode}` : ''
? await apiClient.getArchivedBookmarks(value, {limit: 5, offset: 0}) const suggestionFilters = {
: await apiClient.getBookmarks(value, {limit: 5, offset: 0}) ...filters,
q: value
}
const fetchedBookmarks = await apiClient.listBookmarks(suggestionFilters, {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

@@ -3,18 +3,19 @@ export class ApiClient {
this.baseUrl = baseUrl this.baseUrl = baseUrl
} }
getBookmarks(query, options = {limit: 100, offset: 0}) { listBookmarks(filters, options = {limit: 100, offset: 0, path: ''}) {
const encodedQuery = encodeURIComponent(query) const query = [
const url = `${this.baseUrl}bookmarks/?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}` `limit=${options.limit}`,
`offset=${options.offset}`,
return fetch(url) ]
.then(response => response.json()) Object.keys(filters).forEach(key => {
.then(data => data.results) const value = filters[key]
} if (value) {
query.push(`${key}=${encodeURIComponent(value)}`)
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}` const queryString = query.join('&')
const url = `${this.baseUrl}bookmarks${options.path}/?${queryString}`
return fetch(url) return fetch(url)
.then(response => response.json()) .then(response => response.json())

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ from typing import List
from django import forms from django import forms
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.handlers.wsgi import WSGIRequest
from django.db import models from django.db import models
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
@@ -54,6 +55,7 @@ class Bookmark(models.Model):
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True) web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
unread = models.BooleanField(default=False) unread = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False) is_archived = models.BooleanField(default=False)
shared = models.BooleanField(default=False)
date_added = models.DateTimeField() date_added = models.DateTimeField()
date_modified = models.DateTimeField() date_modified = models.DateTimeField()
date_accessed = models.DateTimeField(blank=True, null=True) date_accessed = models.DateTimeField(blank=True, null=True)
@@ -100,12 +102,19 @@ class BookmarkForm(forms.ModelForm):
description = forms.CharField(required=False, description = forms.CharField(required=False,
widget=forms.Textarea()) widget=forms.Textarea())
unread = forms.BooleanField(required=False) unread = forms.BooleanField(required=False)
shared = forms.BooleanField(required=False)
# Hidden field that determines whether to close window/tab after saving the bookmark # Hidden field that determines whether to close window/tab after saving the bookmark
auto_close = forms.CharField(required=False) auto_close = forms.CharField(required=False)
class Meta: class Meta:
model = Bookmark 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): class UserProfile(models.Model):
@@ -145,12 +154,13 @@ class UserProfile(models.Model):
default=BOOKMARK_LINK_TARGET_BLANK) default=BOOKMARK_LINK_TARGET_BLANK)
web_archive_integration = models.CharField(max_length=10, choices=WEB_ARCHIVE_INTEGRATION_CHOICES, blank=False, web_archive_integration = models.CharField(max_length=10, choices=WEB_ARCHIVE_INTEGRATION_CHOICES, blank=False,
default=WEB_ARCHIVE_INTEGRATION_DISABLED) default=WEB_ARCHIVE_INTEGRATION_DISABLED)
enable_sharing = models.BooleanField(default=False, null=False)
class UserProfileForm(forms.ModelForm): class UserProfileForm(forms.ModelForm):
class Meta: class Meta:
model = UserProfile 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()) @receiver(post_save, sender=get_user_model())

View File

@@ -1,3 +1,5 @@
from typing import Optional
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Q, Count, Aggregate, CharField, Value, BooleanField, QuerySet 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) .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 # Add aggregated tag info to bookmark instances
query_set = Bookmark.objects \ query_set = Bookmark.objects \
.annotate(tag_count=Count('tags'), .annotate(tag_count=Count('tags'),
@@ -35,7 +43,8 @@ def _base_bookmarks_query(user: User, query_string: str) -> QuerySet:
tag_projection=Value(True, BooleanField())) tag_projection=Value(True, BooleanField()))
# Filter for user # 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 # Split query into search terms and tags
query = _parse_query_string(query_string) 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() 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): def get_user_tags(user: User):
return Tag.objects.filter(owner=user).all() return Tag.objects.filter(owner=user).all()

View File

@@ -117,6 +117,7 @@ def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.title = from_bookmark.title to_bookmark.title = from_bookmark.title
to_bookmark.description = from_bookmark.description to_bookmark.description = from_bookmark.description
to_bookmark.unread = from_bookmark.unread to_bookmark.unread = from_bookmark.unread
to_bookmark.shared = from_bookmark.shared
def _update_website_metadata(bookmark: Bookmark): def _update_website_metadata(bookmark: Bookmark):

View File

@@ -14,7 +14,7 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Archived bookmarks</h2> <h2>Archived bookmarks</h2>
<div class="spacer"></div> <div class="spacer"></div>
{% bookmark_search query tags mode='archive' %} {% bookmark_search filters tags mode='archived' %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
</div> </div>

View File

@@ -54,21 +54,29 @@
</span> </span>
<span class="text-gray text-sm">|</span> <span class="text-gray text-sm">|</span>
{% endif %} {% endif %}
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}" {% if bookmark.owner == request.user %}
class="btn btn-link btn-sm">Edit</a> {# Bookmark owner actions #}
{% if bookmark.is_archived %} <a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}"
<button type="submit" name="unarchive" value="{{ bookmark.id }}" class="btn btn-link btn-sm">Edit</a>
class="btn btn-link btn-sm">Unarchive</button> {% if bookmark.is_archived %}
<button type="submit" name="unarchive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Unarchive</button>
{% else %}
<button type="submit" name="archive" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Archive</button>
{% endif %}
<button type="submit" name="remove" value="{{ bookmark.id }}"
class="btn btn-link btn-sm btn-confirmation">Remove</button>
{% if bookmark.unread %}
<span class="text-gray text-sm">|</span>
<button type="submit" name="mark_as_read" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Mark as read</button>
{% endif %}
{% else %} {% else %}
<button type="submit" name="archive" value="{{ bookmark.id }}" {# Shared bookmark actions #}
class="btn btn-link btn-sm">Archive</button> <span class="text-gray text-sm">Shared by
{% endif %} <a class="text-gray" href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a>
<button type="submit" name="remove" value="{{ bookmark.id }}" </span>
class="btn btn-link btn-sm btn-confirmation">Remove</button>
{% if bookmark.unread %}
<span class="text-gray text-sm">|</span>
<button type="submit" name="mark_as_read" value="{{ bookmark.id }}"
class="btn btn-link btn-sm">Mark as read</button>
{% endif %} {% endif %}
</div> </div>
</li> </li>

View File

@@ -75,6 +75,18 @@
Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them. Unread bookmarks can be filtered for, and marked as read after you had a chance to look at them.
</div> </div>
</div> </div>
{% if request.user.profile.enable_sharing %}
<div class="form-group">
<label for="{{ form.shared.id_for_label }}" class="form-checkbox">
{{ form.shared }}
<i class="form-icon"></i>
<span>Share</span>
</label>
<div class="form-input-hint">
Share this bookmark with other users.
</div>
</div>
{% endif %}
<br/> <br/>
<div class="form-group"> <div class="form-group">
{% if auto_close %} {% if auto_close %}

View File

@@ -14,7 +14,7 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Bookmarks</h2> <h2>Bookmarks</h2>
<div class="spacer"></div> <div class="spacer"></div>
{% bookmark_search query tags %} {% bookmark_search filters tags %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% include 'bookmarks/bulk_edit/toggle.html' %}
</div> </div>

View File

@@ -15,6 +15,11 @@
<li> <li>
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a> <a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
</li> </li>
{% if request.user.profile.enable_sharing %}
<li>
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
</li>
{% endif %}
<li> <li>
<a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a> <a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a>
</li> </li>
@@ -47,6 +52,11 @@
<li style="padding-left: 1rem"> <li style="padding-left: 1rem">
<a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a> <a href="{% url 'bookmarks:archived' %}" class="btn btn-link">Archived</a>
</li> </li>
{% if request.user.profile.enable_sharing %}
<li style="padding-left: 1rem">
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared</a>
</li>
{% endif %}
<li style="padding-left: 1rem"> <li style="padding-left: 1rem">
<a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a> <a href="{% url 'bookmarks:index' %}?q=!unread" class="btn btn-link">Unread</a>
</li> </li>

View File

@@ -3,10 +3,13 @@
<div class="input-group"> <div class="input-group">
<span id="search-input-wrap"> <span id="search-input-wrap">
<input type="search" class="form-input" name="q" placeholder="Search for words or #tags" <input type="search" class="form-input" name="q" placeholder="Search for words or #tags"
value="{{ query }}"> value="{{ filters.query }}">
</span> </span>
<input type="submit" value="Search" class="btn input-group-btn"> <input type="submit" value="Search" class="btn input-group-btn">
</div> </div>
{% if filters.user %}
<input type="hidden" name="user" value="{{ filters.user }}">
{% endif %}
</form> </form>
</div> </div>
@@ -15,6 +18,11 @@
window.addEventListener("load", function() { window.addEventListener("load", function() {
const currentTagsString = '{{ tags_string }}'; const currentTagsString = '{{ tags_string }}';
const currentTags = currentTagsString.split(' '); 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 apiClient = new linkding.ApiClient('{% url 'bookmarks:api-root' %}')
const wrapper = document.getElementById('search-input-wrap') const wrapper = document.getElementById('search-input-wrap')
const newWrapper = document.createElement('div') const newWrapper = document.createElement('div')
@@ -23,10 +31,11 @@
props: { props: {
name: 'q', name: 'q',
placeholder: 'Search for words or #tags', placeholder: 'Search for words or #tags',
value: '{{ query }}', value: '{{ filters.query }}',
tags: currentTags, tags: uniqueTags,
mode: '{{ mode }}', mode: '{{ mode }}',
apiClient apiClient,
filters,
} }
}) })
wrapper.parentElement.replaceChild(newWrapper, wrapper) wrapper.parentElement.replaceChild(newWrapper, wrapper)

View File

@@ -0,0 +1,48 @@
{% extends "bookmarks/layout.html" %}
{% load static %}
{% load shared %}
{% load bookmarks %}
{% block content %}
<div class="bookmarks-page columns">
{# Bookmark list #}
<section class="content-area column col-8 col-md-12">
<div class="content-area-header">
<h2>Shared bookmarks</h2>
<div class="spacer"></div>
{% bookmark_search filters tags mode='shared' %}
</div>
<form class="bookmark-actions" action="{% url 'bookmarks:action' %}?return_url={{ return_url }}"
method="post">
{% csrf_token %}
{% if empty %}
{% include 'bookmarks/empty_bookmarks.html' %}
{% else %}
{% bookmark_list bookmarks return_url link_target %}
{% endif %}
</form>
</section>
{# Filters #}
<section class="content-area column col-4 hide-md">
<div class="content-area-header">
<h2>User</h2>
</div>
<div>
{% user_select filters users %}
<br>
</div>
<div class="content-area-header">
<h2>Tags</h2>
</div>
{% tag_cloud tags %}
</section>
</div>
<script src="{% static "bundle.js" %}"></script>
<script src="{% static "shared.js" %}"></script>
{% endblock %}

View File

@@ -0,0 +1,29 @@
<form id="user-select" action="" method="get">
{% if filters.query %}
<input type="hidden" name="q" value="{{ filters.query }}">
{% endif %}
<div class="form-group">
<div class="d-flex">
<select name="user" 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>
<button type="submit" class="btn btn-link ml-2">Apply</button>
</noscript>
</div>
</div>
</form>
<script>
const form = document.getElementById('user-select');
const select = form.querySelector('select');
select.addEventListener('change', () => {
form.submit();
});
</script>

View File

@@ -47,6 +47,16 @@
case it goes offline or its content is modified. case it goes offline or its content is modified.
</div> </div>
</div> </div>
<div class="form-group">
<label for="{{ form.enable_sharing.id_for_label }}" class="form-checkbox">
{{ form.enable_sharing }}
<i class="form-icon"></i> Enable bookmark sharing
</label>
<div class="form-input-hint">
Allows to share bookmarks with other users, and to view shared bookmarks.
Disabling this feature will hide all previously shared bookmarks from other users.
</div>
</div>
<div class="form-group"> <div class="form-group">
<input type="submit" value="Save" class="btn btn-primary mt-2"> <input type="submit" value="Save" class="btn btn-primary mt-2">
</div> </div>

View File

@@ -3,14 +3,16 @@ from typing import List
from django import template from django import template
from django.core.paginator import Page 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 = template.Library()
@register.inclusion_tag('bookmarks/form.html', name='bookmark_form') @register.inclusion_tag('bookmarks/form.html', name='bookmark_form', takes_context=True)
def bookmark_form(form: BookmarkForm, cancel_url: str, bookmark_id: int = 0, auto_close: bool = False): def bookmark_form(context, form: BookmarkForm, cancel_url: str, bookmark_id: int = 0, auto_close: bool = False):
return { return {
'request': context['request'],
'form': form, 'form': form,
'auto_close': auto_close, 'auto_close': auto_close,
'bookmark_id': bookmark_id, 'bookmark_id': bookmark_id,
@@ -25,7 +27,13 @@ class TagGroup:
def create_tag_groups(tags: List[Tag]): 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 group = None
groups = [] 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) @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] tag_names = [tag.name for tag in tags]
tags_string = build_tag_string(tag_names, ' ') tags_string = build_tag_string(tag_names, ' ')
return { return {
'query': query, 'filters': filters,
'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)
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,
}

View File

@@ -32,6 +32,18 @@ def append_query_param(context, **kwargs):
return query.urlencode() 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') @register.filter(name='hash_tag')
def hash_tag(tag_name): def hash_tag(tag_name):
return '#' + tag_name return '#' + tag_name

View File

@@ -23,6 +23,7 @@ class BookmarkFactoryMixin:
def setup_bookmark(self, def setup_bookmark(self,
is_archived: bool = False, is_archived: bool = False,
unread: bool = False, unread: bool = False,
shared: bool = False,
tags=None, tags=None,
user: User = None, user: User = None,
url: str = '', url: str = '',
@@ -52,6 +53,7 @@ class BookmarkFactoryMixin:
owner=user, owner=user,
is_archived=is_archived, is_archived=is_archived,
unread=unread, unread=unread,
shared=shared,
web_archive_snapshot_url=web_archive_snapshot_url, web_archive_snapshot_url=web_archive_snapshot_url,
) )
bookmark.save() bookmark.save()
@@ -69,6 +71,14 @@ class BookmarkFactoryMixin:
tag.save() tag.save()
return tag 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): class LinkdingApiTestCase(APITestCase):
def get(self, url, expected_status_code=status.HTTP_200_OK): def get(self, url, expected_status_code=status.HTTP_200_OK):

View File

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

View File

@@ -21,6 +21,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
'title': 'edited title', 'title': 'edited title',
'description': 'edited description', 'description': 'edited description',
'unread': False, 'unread': False,
'shared': False,
} }
return {**form_data, **overrides} return {**form_data, **overrides}
@@ -37,20 +38,37 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.title, form_data['title']) self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description']) self.assertEqual(bookmark.description, form_data['description'])
self.assertEqual(bookmark.unread, form_data['unread']) self.assertEqual(bookmark.unread, form_data['unread'])
self.assertEqual(bookmark.shared, form_data['shared'])
self.assertEqual(bookmark.tags.count(), 2) self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.all()[0].name, 'editedtag1') self.assertEqual(bookmark.tags.all()[0].name, 'editedtag1')
self.assertEqual(bookmark.tags.all()[1].name, 'editedtag2') 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() bookmark = self.setup_bookmark()
form_data = self.create_form_data({'id': bookmark.id, 'unread': True}) form_data = self.create_form_data({'id': bookmark.id, 'unread': True})
self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data) self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
bookmark.refresh_from_db() bookmark.refresh_from_db()
self.assertTrue(bookmark.unread) 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): def test_should_prefill_bookmark_form_fields(self):
tag1 = self.setup_tag() tag1 = self.setup_tag()
tag2 = self.setup_tag() tag2 = self.setup_tag()
@@ -126,3 +144,32 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertNotEqual(bookmark.url, form_data['url']) self.assertNotEqual(bookmark.url, form_data['url'])
self.assertEqual(response.status_code, 404) 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('''
<label for="id_shared" class="form-checkbox">
<input type="checkbox" name="shared" id="id_shared">
<i class="form-icon"></i>
<span>Share</span>
</label>
''', 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('''
<label for="id_shared" class="form-checkbox">
<input type="checkbox" name="shared" id="id_shared">
<i class="form-icon"></i>
<span>Share</span>
</label>
''', html, count=1)

View File

@@ -1,3 +1,5 @@
import urllib.parse
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
@@ -156,3 +158,30 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.get(reverse('bookmarks:index')) response = self.client.get(reverse('bookmarks:index'))
self.assertVisibleBookmarks(response, visible_bookmarks, '_self') 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'''
<a href="{edit_url}?return_url={return_url}"
class="btn btn-link btn-sm">Edit</a>
''', 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'''
<a href="{edit_url}?return_url={return_url}"
class="btn btn-link btn-sm">Edit</a>
''', html)

View File

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

View File

@@ -20,6 +20,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
'title': 'test title', 'title': 'test title',
'description': 'test description', 'description': 'test description',
'unread': False, 'unread': False,
'shared': False,
'auto_close': '', 'auto_close': '',
} }
return {**form_data, **overrides} return {**form_data, **overrides}
@@ -37,6 +38,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.title, form_data['title']) self.assertEqual(bookmark.title, form_data['title'])
self.assertEqual(bookmark.description, form_data['description']) self.assertEqual(bookmark.description, form_data['description'])
self.assertEqual(bookmark.unread, form_data['unread']) self.assertEqual(bookmark.unread, form_data['unread'])
self.assertEqual(bookmark.shared, form_data['shared'])
self.assertEqual(bookmark.tags.count(), 2) self.assertEqual(bookmark.tags.count(), 2)
self.assertEqual(bookmark.tags.all()[0].name, 'tag1') self.assertEqual(bookmark.tags.all()[0].name, 'tag1')
self.assertEqual(bookmark.tags.all()[1].name, 'tag2') self.assertEqual(bookmark.tags.all()[1].name, 'tag2')
@@ -51,6 +53,16 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
bookmark = Bookmark.objects.first() bookmark = Bookmark.objects.first()
self.assertTrue(bookmark.unread) 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): def test_should_prefill_url_from_url_parameter(self):
response = self.client.get(reverse('bookmarks:new') + '?url=http://example.com') response = self.client.get(reverse('bookmarks:new') + '?url=http://example.com')
html = response.content.decode() html = response.content.decode()
@@ -98,3 +110,30 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.post(reverse('bookmarks:new'), form_data) response = self.client.post(reverse('bookmarks:new'), form_data)
self.assertRedirects(response, reverse('bookmarks:close')) 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('''
<label for="id_shared" class="form-checkbox">
<input type="checkbox" name="shared" id="id_shared">
<i class="form-icon"></i>
<span>Share</span>
</label>
''', 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('''
<label for="id_shared" class="form-checkbox">
<input type="checkbox" name="shared" id="id_shared">
<i class="form-icon"></i>
<span>Share</span>
</label>
''', html, count=1)

View File

@@ -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('''
<input type="hidden" name="user" value="john">
''', 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('''
<input type="hidden" name="user" value="john">
''', rendered_template, count=0)

View File

@@ -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'<a href="{bookmark.url}" target="{link_target}" rel="noopener" class="">{bookmark.resolved_title}</a>',
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'''
<option value="{user.username}" data-is-user-option>
{user.username}
</option>
''', html)
def assertInvisibleUserOptions(self, response, users: List[User]):
html = response.content.decode()
for user in users:
self.assertInHTML(f'''
<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):
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, '<ul class="bookmark-list">') # 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, '<ul class="bookmark-list">') # 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')

View File

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

View File

@@ -36,6 +36,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expectation['website_description'] = bookmark.website_description expectation['website_description'] = bookmark.website_description
expectation['is_archived'] = bookmark.is_archived expectation['is_archived'] = bookmark.is_archived
expectation['unread'] = bookmark.unread expectation['unread'] = bookmark.unread
expectation['shared'] = bookmark.shared
expectation['tag_names'] = tag_names expectation['tag_names'] = tag_names
expectation['date_added'] = bookmark.date_added.isoformat().replace('+00:00', 'Z') expectation['date_added'] = bookmark.date_added.isoformat().replace('+00:00', 'Z')
expectation['date_modified'] = bookmark.date_modified.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) expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1]) 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): def test_create_bookmark(self):
data = { data = {
'url': 'https://example.com/', 'url': 'https://example.com/',
@@ -71,6 +132,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
'description': 'Test description', 'description': 'Test description',
'is_archived': False, 'is_archived': False,
'unread': False, 'unread': False,
'shared': False,
'tag_names': ['tag1', 'tag2'] 'tag_names': ['tag1', 'tag2']
} }
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED) 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.assertEqual(bookmark.description, data['description'])
self.assertFalse(bookmark.is_archived, data['is_archived']) self.assertFalse(bookmark.is_archived, data['is_archived'])
self.assertFalse(bookmark.unread, data['unread']) self.assertFalse(bookmark.unread, data['unread'])
self.assertFalse(bookmark.shared, data['shared'])
self.assertEqual(bookmark.tags.count(), 2) 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'][0]).count(), 1)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).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', 'title': 'Updated title',
'description': 'Updated description', 'description': 'Updated description',
'unread': True, 'unread': True,
'shared': True,
'is_archived': True, 'is_archived': True,
'tag_names': ['tag1', 'tag2'] 'tag_names': ['tag1', 'tag2']
} }
@@ -103,6 +167,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
# Saving a duplicate bookmark should not modify archive flag - right? # Saving a duplicate bookmark should not modify archive flag - right?
self.assertFalse(bookmark.is_archived) self.assertFalse(bookmark.is_archived)
self.assertEqual(bookmark.unread, data['unread']) self.assertEqual(bookmark.unread, data['unread'])
self.assertEqual(bookmark.shared, data['shared'])
self.assertEqual(bookmark.tags.count(), 2) 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'][0]).count(), 1)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).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']) bookmark = Bookmark.objects.get(url=data['url'])
self.assertFalse(bookmark.unread) 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): def test_get_bookmark(self):
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK) 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) updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertEqual(updated_bookmark.unread, True) 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): def test_patch_bookmark(self):
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=[self.bookmark1.id])
@@ -224,6 +308,18 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.bookmark1.refresh_from_db() self.bookmark1.refresh_from_db()
self.assertFalse(self.bookmark1.unread) 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']} data = {'tag_names': ['updated-tag-1', 'updated-tag-2']}
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id]) url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK) 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): def test_can_only_access_own_bookmarks(self):
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)
inaccessible_shared_bookmark = self.setup_bookmark(user=other_user, shared=True)
self.setup_bookmark(user=other_user, is_archived=True) self.setup_bookmark(user=other_user, is_archived=True)
url = reverse('bookmarks:bookmark-list') url = reverse('bookmarks:bookmark-list')
@@ -273,14 +370,29 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
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)
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]) url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id])
self.put(url, {url: 'https://example.com/'}, expected_status_code=status.HTTP_404_NOT_FOUND) 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]) url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id])
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND) 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]) url = reverse('bookmarks:bookmark-archive', args=[inaccessible_bookmark.id])
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND) 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]) url = reverse('bookmarks:bookmark-unarchive', args=[inaccessible_bookmark.id])
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND) 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)

View File

@@ -2,9 +2,10 @@ from dateutil.relativedelta import relativedelta
from django.core.paginator import Paginator from django.core.paginator import Paginator
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 django.urls import reverse
from django.utils import timezone, formats 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 from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -41,9 +42,46 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
<span class="text-gray text-sm">|</span> <span class="text-gray text-sm">|</span>
''', html) ''', 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'''
<a href="{edit_url}?return_url=/test"
class="btn btn-link btn-sm">Edit</a>
''', html, count=count)
# Archive link
self.assertInHTML(f'''
<button type="submit" name="archive" value="{bookmark.id}"
class="btn btn-link btn-sm">Archive</button>
''', html, count=count)
# Delete link
self.assertInHTML(f'''
<button type="submit" name="remove" value="{bookmark.id}"
class="btn btn-link btn-sm btn-confirmation">Remove</button>
''', 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'''
<span class="text-gray text-sm">Shared by
<a class="text-gray" href="?user={bookmark.owner.username}">{bookmark.owner.username}</a>
</span>
''', html, count=count)
def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test') -> str:
rf = RequestFactory() rf = RequestFactory()
request = rf.get('/test') request = rf.get(url)
request.user = self.get_or_create_test_user() request.user = self.get_or_create_test_user()
paginator = Paginator(bookmarks, 10) paginator = Paginator(bookmarks, 10)
page = paginator.page(1) page = paginator.page(1)
@@ -51,12 +89,12 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
context = RequestContext(request, {'bookmarks': page, 'return_url': '/test'}) context = RequestContext(request, {'bookmarks': page, 'return_url': '/test'})
return template.render(context) 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( template = Template(
'{% load bookmarks %}' '{% load bookmarks %}'
'{% bookmark_list bookmarks return_url %}' '{% 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: def render_template_with_link_target(self, bookmarks: [Bookmark], link_target: str) -> str:
template = Template( template = Template(
@@ -147,3 +185,29 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
html = self.render_template_with_link_target([bookmark], '_self') html = self.render_template_with_link_target([bookmark], '_self')
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_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'''
<span class="text-gray text-sm">Shared by
<a class="text-gray" href="?q=foo&user={bookmark.owner.username}">{bookmark.owner.username}</a>
</span>
''', html)

View File

@@ -19,11 +19,12 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.get_or_create_test_user() self.get_or_create_test_user()
def test_create_should_update_existing_bookmark_with_same_url(self): 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', bookmark_data = Bookmark(url='https://example.com',
title='Updated Title', title='Updated Title',
description='Updated description', description='Updated description',
unread=True, unread=True,
shared=True,
is_archived=True) is_archived=True)
updated_bookmark = create_bookmark(bookmark_data, '', self.get_or_create_test_user()) 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.title, bookmark_data.title)
self.assertEqual(updated_bookmark.description, bookmark_data.description) self.assertEqual(updated_bookmark.description, bookmark_data.description)
self.assertEqual(updated_bookmark.unread, bookmark_data.unread) 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? # Saving a duplicate bookmark should not modify archive flag - right?
self.assertFalse(updated_bookmark.is_archived) self.assertFalse(updated_bookmark.is_archived)

View File

@@ -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'''
<a href="{reverse('bookmarks:shared')}" class="btn btn-link">Shared</a>
''', 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'''
<a href="{reverse('bookmarks:shared')}" class="btn btn-link">Shared</a>
''', html, count=2)

View File

@@ -572,3 +572,86 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
query = queries.query_archived_bookmark_tags(self.user, f'!untagged #{tag.name}') query = queries.query_archived_bookmark_tags(self.user, f'!untagged #{tag.name}')
self.assertCountEqual(list(query), []) 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]]])

View File

@@ -34,6 +34,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN, 'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF, 'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF,
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED, 'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
'enable_sharing': True,
} }
response = self.client.post(reverse('bookmarks:settings.general'), form_data) 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_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.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.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): def test_about_shows_version_info(self):
response = self.client.get(reverse('bookmarks:settings.general')) response = self.client.get(reverse('bookmarks:settings.general'))

View File

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

View File

@@ -10,3 +10,8 @@ class UserProfileTestCase(TestCase):
user = User.objects.create_user('testuser', 'test@example.com', 'password123') user = User.objects.create_user('testuser', 'test@example.com', 'password123')
profile = UserProfile.objects.all().filter(user_id=user.id).first() profile = UserProfile.objects.all().filter(user_id=user.id).first()
self.assertIsNotNone(profile) 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)

View File

@@ -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'''
<option value="{user.username}"
{'selected' if selected else ''}
data-is-user-option>
{user.username}
</option>
''', html)
def test_empty_option(self):
rendered_template = self.render_template('/test')
self.assertInHTML(f'''
<option value="">Everyone</option>
''', 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('''
<input type="hidden" name="q" value="foo">
''', 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('''
<input type="hidden" name="q" value="foo">
''', rendered_template, count=0)

View File

@@ -13,6 +13,7 @@ urlpatterns = [
# Bookmarks # Bookmarks
path('bookmarks', views.bookmarks.index, name='index'), path('bookmarks', views.bookmarks.index, name='index'),
path('bookmarks/archived', views.bookmarks.archived, name='archived'), 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/new', views.bookmarks.new, name='new'),
path('bookmarks/close', views.bookmarks.close, name='close'), path('bookmarks/close', views.bookmarks.close, name='close'),
path('bookmarks/<int:bookmark_id>/edit', views.bookmarks.edit, name='edit'), path('bookmarks/<int:bookmark_id>/edit', views.bookmarks.edit, name='edit'),

View File

@@ -1,13 +1,15 @@
import urllib.parse import urllib.parse
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.handlers.wsgi import WSGIRequest
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import QuerySet, prefetch_related_objects
from django.http import HttpResponseRedirect, Http404 from django.http import HttpResponseRedirect, Http404
from django.shortcuts import render 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, 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, \ from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks
from bookmarks.utils import get_safe_return_url from bookmarks.utils import get_safe_return_url
@@ -17,30 +19,48 @@ _default_page_size = 30
@login_required @login_required
def index(request): def index(request):
query_string = request.GET.get('q') filters = BookmarkFilters(request)
query_set = queries.query_bookmarks(request.user, query_string) query_set = queries.query_bookmarks(request.user, filters.query)
tags = queries.query_bookmark_tags(request.user, query_string) tags = queries.query_bookmark_tags(request.user, filters.query)
base_url = reverse('bookmarks:index') 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) return render(request, 'bookmarks/index.html', context)
@login_required @login_required
def archived(request): def archived(request):
query_string = request.GET.get('q') filters = BookmarkFilters(request)
query_set = queries.query_archived_bookmarks(request.user, query_string) query_set = queries.query_archived_bookmarks(request.user, filters.query)
tags = queries.query_archived_bookmark_tags(request.user, query_string) tags = queries.query_archived_bookmark_tags(request.user, filters.query)
base_url = reverse('bookmarks:archived') 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) 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') page = request.GET.get('page')
query_string = request.GET.get('q')
paginator = Paginator(query_set, _default_page_size) paginator = Paginator(query_set, _default_page_size)
bookmarks = paginator.get_page(page) 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 link_target = request.user.profile.bookmark_link_target
if request.GET.get('tag'): if request.GET.get('tag'):
@@ -51,17 +71,19 @@ def get_bookmark_view_context(request, query_set, tags, base_url):
return { return {
'bookmarks': bookmarks, 'bookmarks': bookmarks,
'tags': tags, 'tags': tags,
'query': query_string if query_string else '', 'filters': filters,
'empty': paginator.count == 0, 'empty': paginator.count == 0,
'return_url': return_url, 'return_url': return_url,
'link_target': link_target, '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 = {} url_query = {}
if query_string is not None: if filters.query:
url_query['q'] = query_string url_query['q'] = filters.query
if filters.user:
url_query['user'] = filters.user
if page is not None: if page is not None:
url_query['page'] = page url_query['page'] = page
url_params = urllib.parse.urlencode(url_query) url_params = urllib.parse.urlencode(url_query)