Compare commits

...

5 Commits

Author SHA1 Message Date
Sascha Ißbrücker
fd2770efd8 Bump version 2022-08-04 20:58:02 +02:00
Sascha Ißbrücker
dd5e65ecd7 Display selected tags in tag cloud (#307)
* Add links to remove tags from current query

* Display selected tags in tag cloud

* Add tag cloud tests

* Fix tag cloud in archive

* Add tests for bookmark views

* Expose parse query string

* Improve tag cloud tests

* Cleanup

* Fix rebase issues

* Ignore casing when removing tags from query

Co-authored-by: Jon Hauris <jonp@hauris.org>
2022-08-04 20:31:24 +02:00
Sascha Ißbrücker
fec966f687 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
2022-08-04 19:37:16 +02:00
Sascha Ißbrücker
e6718be53b Update unread flag when saving duplicate URL (#306)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2022-07-26 00:13:41 +02:00
Sascha Ißbrücker
3ac35677d8 Update CHANGELOG.md 2022-07-24 01:06:36 +02:00
46 changed files with 1692 additions and 145 deletions

View File

@@ -1,5 +1,26 @@
# Changelog # Changelog
## v1.12.0 (23/07/2022)
### What's Changed
* Add read it later functionality by @sissbruecker in https://github.com/sissbruecker/linkding/pull/304
* Add RSS feeds by @sissbruecker in https://github.com/sissbruecker/linkding/pull/305
* Add bookmarklet to community by @ukcuddlyguy in https://github.com/sissbruecker/linkding/pull/293
* Shorten and simplify example bookmarklet in documentation by @FunctionDJ in https://github.com/sissbruecker/linkding/pull/297
* Fix typo by @kianmeng in https://github.com/sissbruecker/linkding/pull/295
* Bump django from 3.2.13 to 3.2.14 by @dependabot in https://github.com/sissbruecker/linkding/pull/294
* Bump svelte from 3.46.4 to 3.49.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/299
* Bump terser from 5.5.1 to 5.14.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/302
### New Contributors
* @ukcuddlyguy made their first contribution in https://github.com/sissbruecker/linkding/pull/293
* @FunctionDJ made their first contribution in https://github.com/sissbruecker/linkding/pull/297
* @kianmeng made their first contribution in https://github.com/sissbruecker/linkding/pull/295
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.11.1...v1.12.0
---
## v1.11.1 (03/07/2022) ## v1.11.1 (03/07/2022)
### What's Changed ### What's Changed

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,10 +43,11 @@ 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)
# Filter for search terms and tags # Filter for search terms and tags
for term in query['search_terms']: for term in query['search_terms']:
@@ -88,11 +97,27 @@ 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()
def _parse_query_string(query_string): def parse_query_string(query_string):
# Sanitize query params # Sanitize query params
if not query_string: if not query_string:
query_string = '' query_string = ''

View File

@@ -116,6 +116,8 @@ def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_us
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark): 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.shared = from_bookmark.shared
def _update_website_metadata(bookmark: Bookmark): def _update_website_metadata(bookmark: Bookmark):

View File

@@ -91,8 +91,18 @@ ul.bookmark-list {
.tag-cloud { .tag-cloud {
a, a:visited:hover { .selected-tags {
color: $alternative-color; margin-bottom: 0.8rem;
a, a:visited:hover {
color: $error-color;
}
}
.unselected-tags {
a, a:visited:hover {
color: $alternative-color;
}
} }
.group { .group {

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>
@@ -36,7 +36,7 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
{% tag_cloud tags %} {% tag_cloud tags selected_tags %}
</section> </section>
</div> </div>

View File

@@ -15,7 +15,7 @@
{% if bookmark.tag_names %} {% if bookmark.tag_names %}
<span> <span>
{% for tag_name in bookmark.tag_names %} {% for tag_name in bookmark.tag_names %}
<a href="?{% append_query_param q=tag_name|hash_tag %}">{{ tag_name|hash_tag }}</a> <a href="?{% append_to_query_param q=tag_name|hash_tag %}">{{ tag_name|hash_tag }}</a>
{% endfor %} {% endfor %}
</span> </span>
{% endif %} {% endif %}
@@ -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>
@@ -36,7 +36,7 @@
<div class="content-area-header"> <div class="content-area-header">
<h2>Tags</h2> <h2>Tags</h2>
</div> </div>
{% tag_cloud tags %} {% tag_cloud tags selected_tags %}
</section> </section>
</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 selected_tags %}
</section>
</div>
<script src="{% static "bundle.js" %}"></script>
<script src="{% static "shared.js" %}"></script>
{% endblock %}

View File

@@ -1,23 +1,35 @@
{% load shared %} {% load shared %}
<div class="tag-cloud"> <div class="tag-cloud">
{% for group in groups %} {% if has_selected_tags %}
<p class="group"> <p class="selected-tags">
{% for tag in group.tags %} {% for tag in selected_tags %}
{# Highlight first char of first tag in group #} <a href="?{% remove_from_query_param q=tag.name|hash_tag %}"
{% if forloop.counter == 1 %} class="text-bold mr-2">
<a href="?{% append_query_param q=tag.name|hash_tag %}" <span>-{{ tag.name }}</span>
class="mr-2" data-is-tag-item> </a>
<span class="highlight-char">{{ tag.name|first_char }}</span><span>{{ tag.name|remaining_chars:1 }}</span>
</a>
{% else %}
{# Render remaining tags normally #}
<a href="?{% append_query_param q=tag.name|hash_tag %}"
class="mr-2" data-is-tag-item>
<span>{{ tag.name }}</span>
</a>
{% endif %}
{% endfor %} {% endfor %}
</p> </p>
{% endfor %} {% endif %}
<div class="unselected-tags">
{% for group in groups %}
<p class="group">
{% for tag in group.tags %}
{# Highlight first char of first tag in group #}
{% if forloop.counter == 1 %}
<a href="?{% append_to_query_param q=tag.name|hash_tag %}"
class="mr-2" data-is-tag-item>
<span class="highlight-char">{{ tag.name|first_char }}</span><span>{{ tag.name|remaining_chars:1 }}</span>
</a>
{% else %}
{# Render remaining tags normally #}
<a href="?{% append_to_query_param q=tag.name|hash_tag %}"
class="mr-2" data-is-tag-item>
<span>{{ tag.name }}</span>
</a>
{% endif %}
{% endfor %}
</p>
{% endfor %}
</div>
</div> </div>

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

@@ -1,16 +1,18 @@
from typing import List from typing import List, Set
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,
@@ -24,7 +26,8 @@ class TagGroup:
self.char = char self.char = char
def create_tag_groups(tags: List[Tag]): def create_tag_groups(tags: Set[Tag]):
# Ensure groups, as well as tags within groups, are ordered alphabetically
sorted_tags = sorted(tags, key=lambda x: str.lower(x.name)) sorted_tags = sorted(tags, key=lambda x: str.lower(x.name))
group = None group = None
groups = [] groups = []
@@ -43,10 +46,21 @@ def create_tag_groups(tags: List[Tag]):
@register.inclusion_tag('bookmarks/tag_cloud.html', name='tag_cloud', takes_context=True) @register.inclusion_tag('bookmarks/tag_cloud.html', name='tag_cloud', takes_context=True)
def tag_cloud(context, tags: List[Tag]): def tag_cloud(context, tags: List[Tag], selected_tags: List[Tag]):
groups = create_tag_groups(tags) # 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))
unique_selected_tags = unique(selected_tags, key=lambda x: str.lower(x.name))
has_selected_tags = len(unique_selected_tags) > 0
unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags)
groups = create_tag_groups(unselected_tags)
return { return {
'groups': groups, 'groups': groups,
'selected_tags': unique_selected_tags,
'has_selected_tags': has_selected_tags,
} }
@@ -61,11 +75,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

@@ -17,7 +17,7 @@ def update_query_string(context, **kwargs):
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def append_query_param(context, **kwargs): def append_to_query_param(context, **kwargs):
query = context.request.GET.copy() query = context.request.GET.copy()
# Append to or create query param # Append to or create query param
@@ -32,6 +32,34 @@ def append_query_param(context, **kwargs):
return query.urlencode() return query.urlencode()
@register.simple_tag(takes_context=True)
def remove_from_query_param(context, **kwargs):
query = context.request.GET.copy()
# Remove item from query param
for key in kwargs:
if query.__contains__(key):
value = query.__getitem__(key)
parts = value.split()
part_to_remove = kwargs[key]
updated_parts = [part for part in parts if str.lower(part) != str.lower(part_to_remove)]
updated_value = ' '.join(updated_parts)
query.__setitem__(key, updated_value)
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

@@ -2,6 +2,7 @@ import random
import logging import logging
from typing import List from typing import List
from bs4 import BeautifulSoup
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils import timezone from django.utils import timezone
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
@@ -23,6 +24,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 +54,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 +72,19 @@ 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 HtmlTestMixin:
def make_soup(self, html: str):
return BeautifulSoup(html, features="html.parser")
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

@@ -1,18 +1,20 @@
from typing import List
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from bookmarks.models import Bookmark, Tag, UserProfile from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin): class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def setUp(self) -> None: def setUp(self) -> None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
self.client.force_login(user) self.client.force_login(user)
def assertVisibleBookmarks(self, response, bookmarks: [Bookmark], link_target: str = '_blank'): def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode() html = response.content.decode()
self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks)) self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks))
@@ -22,7 +24,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin):
html html
) )
def assertInvisibleBookmarks(self, response, bookmarks: [Bookmark], link_target: str = '_blank'): def assertInvisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode() html = response.content.decode()
for bookmark in bookmarks: for bookmark in bookmarks:
@@ -32,16 +34,27 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin):
count=0 count=0
) )
def assertVisibleTags(self, response, tags: [Tag]): def assertVisibleTags(self, response, tags: List[Tag]):
self.assertContains(response, 'data-is-tag-item', count=len(tags)) self.assertContains(response, 'data-is-tag-item', count=len(tags))
for tag in tags: for tag in tags:
self.assertContains(response, tag.name) self.assertContains(response, tag.name)
def assertInvisibleTags(self, response, tags: [Tag]): def assertInvisibleTags(self, response, tags: List[Tag]):
for tag in tags: for tag in tags:
self.assertNotContains(response, tag.name) self.assertNotContains(response, tag.name)
def assertSelectedTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
selected_tags = soup.select('p.selected-tags')[0]
self.assertIsNotNone(selected_tags)
tag_list = selected_tags.select('a')
self.assertEqual(len(tag_list), len(tags))
for tag in tags:
self.assertTrue(tag.name in selected_tags.text, msg=f'Selected tags do not contain: {tag.name}')
def test_should_list_archived_and_user_owned_bookmarks(self): def test_should_list_archived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
visible_bookmarks = [ visible_bookmarks = [
@@ -119,7 +132,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin):
self.setup_tag(), self.setup_tag(),
] ]
self.setup_bookmark(is_archived=True, tags=[visible_tags[0]], title='searchvalue'), self.setup_bookmark(is_archived=True, tags=[visible_tags[0]], title='searchvalue')
self.setup_bookmark(is_archived=True, tags=[visible_tags[1]], title='searchvalue') self.setup_bookmark(is_archived=True, tags=[visible_tags[1]], title='searchvalue')
self.setup_bookmark(is_archived=True, tags=[visible_tags[2]], title='searchvalue') self.setup_bookmark(is_archived=True, tags=[visible_tags[2]], title='searchvalue')
self.setup_bookmark(is_archived=True, tags=[invisible_tags[0]]) self.setup_bookmark(is_archived=True, tags=[invisible_tags[0]])
@@ -131,6 +144,20 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertVisibleTags(response, visible_tags) self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags) self.assertInvisibleTags(response, invisible_tags)
def test_should_display_selected_tags_from_query(self):
tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(is_archived=True, tags=tags)
response = self.client.get(reverse('bookmarks:archived') + f'?q=%23{tags[0].name}+%23{tags[1].name}')
self.assertSelectedTags(response, [tags[0], tags[1]])
def test_should_open_bookmarks_in_new_page_by_default(self): def test_should_open_bookmarks_in_new_page_by_default(self):
visible_bookmarks = [ visible_bookmarks = [
self.setup_bookmark(is_archived=True), self.setup_bookmark(is_archived=True),

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,18 +1,21 @@
from typing import List
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
from bookmarks.models import Bookmark, Tag, UserProfile from bookmarks.models import Bookmark, Tag, UserProfile
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin): class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def setUp(self) -> None: def setUp(self) -> None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
self.client.force_login(user) self.client.force_login(user)
def assertVisibleBookmarks(self, response, bookmarks: [Bookmark], link_target: str = '_blank'): def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode() html = response.content.decode()
self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks)) self.assertContains(response, 'data-is-bookmark-item', count=len(bookmarks))
@@ -22,7 +25,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin):
html html
) )
def assertInvisibleBookmarks(self, response, bookmarks: [Bookmark], link_target: str = '_blank'): def assertInvisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
html = response.content.decode() html = response.content.decode()
for bookmark in bookmarks: for bookmark in bookmarks:
@@ -32,16 +35,27 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin):
count=0 count=0
) )
def assertVisibleTags(self, response, tags: [Tag]): def assertVisibleTags(self, response, tags: List[Tag]):
self.assertContains(response, 'data-is-tag-item', count=len(tags)) self.assertContains(response, 'data-is-tag-item', count=len(tags))
for tag in tags: for tag in tags:
self.assertContains(response, tag.name) self.assertContains(response, tag.name)
def assertInvisibleTags(self, response, tags: [Tag]): def assertInvisibleTags(self, response, tags: List[Tag]):
for tag in tags: for tag in tags:
self.assertNotContains(response, tag.name) self.assertNotContains(response, tag.name)
def assertSelectedTags(self, response, tags: List[Tag]):
soup = self.make_soup(response.content.decode())
selected_tags = soup.select('p.selected-tags')[0]
self.assertIsNotNone(selected_tags)
tag_list = selected_tags.select('a')
self.assertEqual(len(tag_list), len(tags))
for tag in tags:
self.assertTrue(tag.name in selected_tags.text, msg=f'Selected tags do not contain: {tag.name}')
def test_should_list_unarchived_and_user_owned_bookmarks(self): def test_should_list_unarchived_and_user_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123') other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
visible_bookmarks = [ visible_bookmarks = [
@@ -119,7 +133,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin):
self.setup_tag(), self.setup_tag(),
] ]
self.setup_bookmark(tags=[visible_tags[0]], title='searchvalue'), self.setup_bookmark(tags=[visible_tags[0]], title='searchvalue')
self.setup_bookmark(tags=[visible_tags[1]], title='searchvalue') self.setup_bookmark(tags=[visible_tags[1]], title='searchvalue')
self.setup_bookmark(tags=[visible_tags[2]], title='searchvalue') self.setup_bookmark(tags=[visible_tags[2]], title='searchvalue')
self.setup_bookmark(tags=[invisible_tags[0]]) self.setup_bookmark(tags=[invisible_tags[0]])
@@ -131,6 +145,20 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertVisibleTags(response, visible_tags) self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags) self.assertInvisibleTags(response, invisible_tags)
def test_should_display_selected_tags_from_query(self):
tags = [
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
self.setup_tag(),
]
self.setup_bookmark(tags=tags)
response = self.client.get(reverse('bookmarks:index') + f'?q=%23{tags[0].name}+%23{tags[1].name}')
self.assertSelectedTags(response, [tags[0], tags[1]])
def test_should_open_bookmarks_in_new_page_by_default(self): def test_should_open_bookmarks_in_new_page_by_default(self):
visible_bookmarks = [ visible_bookmarks = [
self.setup_bookmark(), self.setup_bookmark(),
@@ -156,3 +184,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')
@@ -46,6 +47,84 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertCountEqual(data_list, expectations) self.assertCountEqual(data_list, expectations)
def test_list_bookmarks(self):
response = self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1, self.bookmark2, self.bookmark3])
def test_list_bookmarks_should_filter_by_query(self):
response = self.get(reverse('bookmarks:bookmark-list') + '?q=#' + self.tag1.name,
expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1])
def test_list_archived_bookmarks_does_not_return_unarchived_bookmarks(self):
response = self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1, self.archived_bookmark2])
def test_list_archived_bookmarks_should_filter_by_query(self):
response = self.get(reverse('bookmarks:bookmark-archived') + '?q=#' + self.tag1.name,
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): def test_create_bookmark(self):
data = { data = {
'url': 'https://example.com/', 'url': 'https://example.com/',
@@ -53,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)
@@ -62,6 +142,32 @@ 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.filter(name=data['tag_names'][0]).count(), 1)
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
def test_create_bookmark_with_same_url_updates_existing_bookmark(self):
original_bookmark = self.setup_bookmark()
data = {
'url': original_bookmark.url,
'title': 'Updated title',
'description': 'Updated description',
'unread': True,
'shared': True,
'is_archived': True,
'tag_names': ['tag1', 'tag2']
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url'])
self.assertEqual(bookmark.id, original_bookmark.id)
self.assertEqual(bookmark.url, data['url'])
self.assertEqual(bookmark.title, data['title'])
self.assertEqual(bookmark.description, data['description'])
# 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.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)
@@ -82,22 +188,6 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
data = {'url': 'https://example.com/'} data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED) self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
def test_list_bookmarks(self):
response = self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1, self.bookmark2, self.bookmark3])
def test_list_bookmarks_should_filter_by_query(self):
response = self.get(reverse('bookmarks:bookmark-list') + '?q=#' + self.tag1.name, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1])
def test_list_archived_bookmarks_does_not_return_unarchived_bookmarks(self):
response = self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1, self.archived_bookmark2])
def test_list_archived_bookmarks_should_filter_by_query(self):
response = self.get(reverse('bookmarks:bookmark-archived') + '?q=#' + self.tag1.name, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1])
def test_create_archived_bookmark(self): def test_create_archived_bookmark(self):
data = { data = {
'url': 'https://example.com/', 'url': 'https://example.com/',
@@ -117,35 +207,34 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1) self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
def test_create_bookmark_is_not_archived_by_default(self): def test_create_bookmark_is_not_archived_by_default(self):
data = { data = {'url': 'https://example.com/'}
'url': 'https://example.com/',
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED) self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url']) bookmark = Bookmark.objects.get(url=data['url'])
self.assertFalse(bookmark.is_archived) self.assertFalse(bookmark.is_archived)
def test_create_unread_bookmark(self): def test_create_unread_bookmark(self):
data = { data = {'url': 'https://example.com/', 'unread': True}
'url': 'https://example.com/',
'unread': True,
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED) self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url']) bookmark = Bookmark.objects.get(url=data['url'])
self.assertTrue(bookmark.unread) self.assertTrue(bookmark.unread)
def test_create_bookmark_is_not_unread_by_default(self): def test_create_bookmark_is_not_unread_by_default(self):
data = { data = {'url': 'https://example.com/'}
'url': 'https://example.com/',
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED) self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
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_bookmark_minimal_payload_does_not_archive(self): 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/'} data = {'url': 'https://example.com/'}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED) self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
bookmark = Bookmark.objects.get(url=data['url']) bookmark = Bookmark.objects.get(url=data['url'])
self.assertFalse(bookmark.is_archived) 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])
@@ -174,6 +263,20 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(updated_bookmark.description, '') self.assertEqual(updated_bookmark.description, '')
self.assertEqual(updated_bookmark.tag_names, []) self.assertEqual(updated_bookmark.tag_names, [])
def test_update_bookmark_unread_flag(self):
data = {'url': 'https://example.com/', 'unread': 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.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])
@@ -205,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)
@@ -241,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')
@@ -254,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

@@ -18,6 +18,25 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None: def setUp(self) -> None:
self.get_or_create_test_user() 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, 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())
self.assertEqual(Bookmark.objects.count(), 1)
self.assertEqual(updated_bookmark.id, original_bookmark.id)
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)
def test_create_should_create_web_archive_snapshot(self): def test_create_should_create_web_archive_snapshot(self):
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot: with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
bookmark_data = Bookmark(url='https://example.com') bookmark_data = Bookmark(url='https://example.com')

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,180 @@
from typing import List
from django.template import Template, RequestContext
from django.test import TestCase, RequestFactory
from bookmarks.models import Tag
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def render_template(self, tags: List[Tag], selected_tags: List[Tag] = None, url: str = '/test'):
if not selected_tags:
selected_tags = []
rf = RequestFactory()
request = rf.get(url)
context = RequestContext(request, {
'request': request,
'tags': tags,
'selected_tags': selected_tags,
})
template_to_render = Template(
'{% load bookmarks %}'
'{% tag_cloud tags selected_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 assertNumSelectedTags(self, rendered_template: str, count: int):
soup = self.make_soup(rendered_template)
link_elements = soup.select('p.selected-tags a')
self.assertEqual(len(link_elements), count)
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):
tags = [
self.setup_tag(name='shared', user=self.setup_user()),
self.setup_tag(name='shared', user=self.setup_user()),
self.setup_tag(name='shared', user=self.setup_user()),
]
rendered_template = self.render_template(tags)
self.assertTagGroups(rendered_template, [
[
'shared',
],
])
def test_selected_tags(self):
tags = [
self.setup_tag(name='tag1'),
self.setup_tag(name='tag2'),
]
rendered_template = self.render_template(tags, tags, url='/test?q=%23tag1 %23tag2')
self.assertNumSelectedTags(rendered_template, 2)
self.assertInHTML('''
<a href="?q=%23tag2"
class="text-bold mr-2">
<span>-tag1</span>
</a>
''', rendered_template)
self.assertInHTML('''
<a href="?q=%23tag1"
class="text-bold mr-2">
<span>-tag2</span>
</a>
''', rendered_template)
def test_selected_tags_ignore_casing_when_removing_query_part(self):
tags = [
self.setup_tag(name='TEST'),
]
rendered_template = self.render_template(tags, tags, url='/test?q=%23test')
self.assertInHTML('''
<a href="?q="
class="text-bold mr-2">
<span>-TEST</span>
</a>
''', rendered_template)
def test_no_duplicate_selected_tags(self):
tags = [
self.setup_tag(name='shared', user=self.setup_user()),
self.setup_tag(name='shared', user=self.setup_user()),
self.setup_tag(name='shared', user=self.setup_user()),
]
rendered_template = self.render_template(tags, tags, url='/test?q=%23shared')
self.assertInHTML('''
<a href="?q="
class="text-bold mr-2">
<span>-shared</span>
</a>
''', rendered_template, count=1)
def test_selected_tag_url_keeps_other_search_terms(self):
tag = self.setup_tag(name='tag1')
rendered_template = self.render_template([tag], [tag], url='/test?q=term1 %23tag1 term2 %21untagged')
self.assertInHTML('''
<a href="?q=term1+term2+%21untagged"
class="text-bold mr-2">
<span>-tag1</span>
</a>
''', rendered_template)
def test_selected_tags_are_excluded_from_groups(self):
tags = [
self.setup_tag(name='tag1'),
self.setup_tag(name='tag2'),
self.setup_tag(name='tag3'),
self.setup_tag(name='tag4'),
self.setup_tag(name='tag5'),
]
selected_tags = [
tags[0],
tags[1],
]
rendered_template = self.render_template(tags, selected_tags)
self.assertTagGroups(rendered_template, [
['tag3', 'tag4', 'tag5']
])

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, Q, 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,63 @@ _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_selected_tags(tags: QuerySet[Tag], query_string: str):
parsed_query = queries.parse_query_string(query_string)
tag_names = parsed_query['tag_names']
if len(tag_names) == 0:
return []
condition = Q()
for tag_name in parsed_query['tag_names']:
condition = condition | Q(name__iexact=tag_name)
return list(tags.filter(condition))
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) selected_tags = _get_selected_tags(tags, filters.query)
# 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 +86,20 @@ 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 '', 'selected_tags': selected_tags,
'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)

View File

@@ -1,6 +1,6 @@
{ {
"name": "linkding", "name": "linkding",
"version": "1.12.0", "version": "1.13.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@@ -1 +1 @@
1.12.0 1.13.0