Compare commits

..

9 Commits

Author SHA1 Message Date
Sascha Ißbrücker
a867614461 Bump version 2021-02-14 18:03:40 +01:00
Sascha Ißbrücker
194f5884df Fix bookmarklet (#46) 2021-02-14 17:57:30 +01:00
Sascha Ißbrücker
8e1cda1104 Update modified timestamp on archive/unarchive (#46) 2021-02-14 17:30:51 +01:00
Sascha Ißbrücker
f9659f4342 Move bookmarklet to settings (#46) 2021-02-14 17:14:58 +01:00
Sascha Ißbrücker
6fab248c95 Implement archive mode for search component (#46) 2021-02-14 16:56:12 +01:00
Sascha Ißbrücker
b7676227c0 Implement archived bookmarks endpoint (#46) 2021-02-14 12:14:46 +01:00
Sascha Ißbrücker
0db7610d68 Filter tags for archived/unarchived (#46) 2021-02-14 11:43:09 +01:00
Sascha Ißbrücker
be7b92d608 Implement archive view (#46) 2021-02-14 11:21:05 +01:00
Sascha Ißbrücker
256084f6cb Implement archive function (#46) 2021-02-14 10:51:32 +01:00
27 changed files with 184 additions and 583 deletions

18
API.md
View File

@@ -59,7 +59,7 @@ Example response:
}
```
**List Archived**
**Archived**
```
GET /api/bookmarks/archived/
@@ -121,22 +121,6 @@ Example payload:
}
```
**Archive**
```
POST /api/bookmarks/<id>/archive/
```
Archives a bookmark.
**Unarchive**
```
POST /api/bookmarks/<id>/unarchive/
```
Unarchives a bookmark.
**Delete**
```

View File

@@ -1,32 +1,5 @@
# Changelog
## v1.3.3 (18/02/2021)
- [**closed**] Missing "description" request body parameter in API causes 500 [#78](https://github.com/sissbruecker/linkding/issues/78)
---
## v1.3.2 (18/02/2021)
- [**closed**] /archive and /unarchive API routes return 404 [#77](https://github.com/sissbruecker/linkding/issues/77)
- [**closed**] API - /api/check_url?url= with token authetification [#55](https://github.com/sissbruecker/linkding/issues/55)
---
## v1.3.1 (15/02/2021)
[enhancement] Enhance delete links with inline confirmation
---
## v1.3.0 (14/02/2021)
- [**closed**] Novice help. [#71](https://github.com/sissbruecker/linkding/issues/71)
- [**closed**] Option to create bookmarks public [#70](https://github.com/sissbruecker/linkding/issues/70)
- [**enhancement**] Show URL if title is not available [#64](https://github.com/sissbruecker/linkding/issues/64)
- [**bug**] minor ui nitpicks [#62](https://github.com/sissbruecker/linkding/issues/62)
- [**enhancement**] add an archive function [#46](https://github.com/sissbruecker/linkding/issues/46)
- [**closed**] remove non fqdn check and alert [#36](https://github.com/sissbruecker/linkding/issues/36)
- [**closed**] Add Lotus Notes links [#22](https://github.com/sissbruecker/linkding/issues/22)
---
## v1.2.1 (12/01/2021)
- [**bug**] Bug: Two equal tags with different capitalisation lead to 500 server errors [#65](https://github.com/sissbruecker/linkding/issues/65)
- [**closed**] Enhancement: category and pagination [#11](https://github.com/sissbruecker/linkding/issues/11)

View File

@@ -1,84 +1,17 @@
from django.contrib import admin, messages
from django.contrib.admin import AdminSite
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import User
from django.db.models import Count, QuerySet
from django.utils.translation import ngettext, gettext
from rest_framework.authtoken.admin import TokenAdmin
from rest_framework.authtoken.models import Token
from django.contrib import admin
from bookmarks.models import Bookmark, Tag
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
class LinkdingAdminSite(AdminSite):
site_header = 'linkding administration'
site_title = 'linkding Admin'
@admin.register(Bookmark)
class AdminBookmark(admin.ModelAdmin):
list_display = ('resolved_title', 'url', 'is_archived', 'owner', 'date_added')
search_fields = ('title', 'description', 'website_title', 'website_description', 'url', 'tags__name')
list_filter = ('owner__username', 'is_archived', 'tags',)
ordering = ('-date_added',)
actions = ['archive_selected_bookmarks', 'unarchive_selected_bookmarks']
def archive_selected_bookmarks(self, request, queryset: QuerySet):
for bookmark in queryset:
archive_bookmark(bookmark)
bookmarks_count = queryset.count()
self.message_user(request, ngettext(
'%d bookmark was successfully archived.',
'%d bookmarks were successfully archived.',
bookmarks_count,
) % bookmarks_count, messages.SUCCESS)
def unarchive_selected_bookmarks(self, request, queryset: QuerySet):
for bookmark in queryset:
unarchive_bookmark(bookmark)
bookmarks_count = queryset.count()
self.message_user(request, ngettext(
'%d bookmark was successfully unarchived.',
'%d bookmarks were successfully unarchived.',
bookmarks_count,
) % bookmarks_count, messages.SUCCESS)
list_display = ('title', 'url', 'date_added')
search_fields = ('title', 'url', 'tags__name')
list_filter = ('tags',)
ordering = ('-date_added', )
@admin.register(Tag)
class AdminTag(admin.ModelAdmin):
list_display = ('name', 'bookmarks_count', 'owner', 'date_added')
search_fields = ('name', 'owner__username')
list_filter = ('owner__username',)
ordering = ('-date_added',)
actions = ['delete_unused_tags']
def get_queryset(self, request):
queryset = super().get_queryset(request)
queryset = queryset.annotate(bookmarks_count=Count("bookmark"))
return queryset
def bookmarks_count(self, obj):
return obj.bookmarks_count
def delete_unused_tags(self, request, queryset: QuerySet):
unused_tags = queryset.filter(bookmark__isnull=True)
unused_tags_count = unused_tags.count()
for tag in unused_tags:
tag.delete()
if unused_tags_count > 0:
self.message_user(request, ngettext(
'%d unused tag was successfully deleted.',
'%d unused tags were successfully deleted.',
unused_tags_count,
) % unused_tags_count, messages.SUCCESS)
else:
self.message_user(request, gettext(
'There were no unused tags in the selection',
), messages.SUCCESS)
linkding_admin_site = LinkdingAdminSite()
linkding_admin_site.register(Bookmark, AdminBookmark)
linkding_admin_site.register(Tag, AdminTag)
linkding_admin_site.register(User, UserAdmin)
linkding_admin_site.register(Token, TokenAdmin)
list_display = ('name', 'date_added', 'owner')
search_fields = ('name', 'owner__username')
list_filter = ('owner__username', )
ordering = ('-date_added', )

View File

@@ -1,4 +1,3 @@
from django.urls import reverse
from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action
from rest_framework.response import Response
@@ -7,8 +6,6 @@ from rest_framework.routers import DefaultRouter
from bookmarks import queries
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
from bookmarks.models import Bookmark, Tag
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
from bookmarks.services.website_loader import load_website_metadata
class BookmarkViewSet(viewsets.GenericViewSet,
@@ -42,37 +39,6 @@ class BookmarkViewSet(viewsets.GenericViewSet,
data = serializer(page, many=True).data
return self.get_paginated_response(data)
@action(methods=['post'], detail=True)
def archive(self, request, pk):
bookmark = self.get_object()
archive_bookmark(bookmark)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=['post'], detail=True)
def unarchive(self, request, pk):
bookmark = self.get_object()
unarchive_bookmark(bookmark)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=['get'], detail=False)
def check(self, request):
url = request.GET.get('url')
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
existing_bookmark_data = None
if bookmark is not None:
existing_bookmark_data = {
'id': bookmark.id,
'edit_url': reverse('bookmarks:edit', args=[bookmark.id])
}
metadata = load_website_metadata(url)
return Response({
'bookmark': existing_bookmark_data,
'metadata': metadata.to_dict()
}, status=status.HTTP_200_OK)
class TagViewSet(viewsets.GenericViewSet,
mixins.ListModelMixin,

View File

@@ -30,11 +30,8 @@ class BookmarkSerializer(serializers.ModelSerializer):
'date_modified'
]
# Override optional char fields to provide default value
title = serializers.CharField(required=False, allow_blank=True, default='')
description = serializers.CharField(required=False, allow_blank=True, default='')
# 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()
def create(self, validated_data):
bookmark = Bookmark()

View File

@@ -52,9 +52,4 @@ h2 {
// Remove left padding from first pagination link
.pagination .page-item:first-child a {
padding-left: 0;
}
// Override border color for tab block
.tab-block {
border-bottom: solid 1px $border-color;
}

View File

@@ -56,13 +56,6 @@ ul.bookmark-list {
color: darken($gray-color, 10%);
}
}
.actions .btn-link.bm-remove-confirm {
color: $error-color;
&:hover {
text-decoration: underline;
}
}
}
.bookmark-pagination {

View File

@@ -1,11 +1,6 @@
.settings-page {
section.content-area {
margin-bottom: 2rem;
h2 {
font-size: 1.0rem;
margin-bottom: 0.8rem;
}
}
.input-group > input[type=submit] {

View File

@@ -32,7 +32,8 @@
class="btn btn-link btn-sm">Archive</a>
{% endif %}
<a href="{% url 'bookmarks:remove' bookmark.id %}?return_url={{ return_url }}"
class="btn btn-link btn-sm bm-remove">Remove</a>
class="btn btn-link btn-sm"
onclick="return confirm('Do you really want to delete this bookmark?')">Remove</a>
</div>
</li>
{% endfor %}
@@ -41,38 +42,3 @@
<div class="bookmark-pagination">
{% pagination bookmarks %}
</div>
{# Enhance delete links to show inline confirmation #}
<script type="application/javascript">
window.addEventListener("load", function () {
const linkEls = document.querySelectorAll('.bookmark-list a.bm-remove');
function showConfirmation(linkEl) {
const cancelEl = document.createElement('span');
cancelEl.innerText = 'Cancel';
cancelEl.className = 'btn btn-link btn-sm bm-remove-confirm mr-1';
cancelEl.addEventListener('click', function() {
container.remove();
linkEl.style = '';
});
const confirmEl = document.createElement('a');
confirmEl.innerText = 'Confirm';
confirmEl.className = 'btn btn-link btn-delete btn-sm bm-remove-confirm';
confirmEl.href = linkEl.href;
const container = document.createElement('span');
container.appendChild(cancelEl);
container.appendChild(confirmEl);
linkEl.parentElement.appendChild(container);
linkEl.style = 'display: none';
}
linkEls.forEach(function (linkEl) {
linkEl.addEventListener('click', function (e) {
e.preventDefault();
showConfirmation(linkEl);
});
});
});
</script>

View File

@@ -2,7 +2,7 @@
<p class="empty-title h5">You have no bookmarks yet</p>
<p class="empty-subtitle">
You can get started by <a href="{% url 'bookmarks:new' %}">adding</a> bookmarks,
<a href="{% url 'bookmarks:settings.data' %}">importing</a> your existing bookmarks or configuring the
<a href="{% url 'bookmarks:settings.integrations' %}">bookmarklet</a>.
<a href="{% url 'bookmarks:settings.index' %}">importing</a> your existing bookmarks or configuring the
<a href="{% url 'bookmarks:settings.index' %}#bookmarklet">bookmarklet</a>.
</p>
</div>

View File

@@ -97,7 +97,7 @@
toggleIcon(descriptionInput, true);
const websiteUrl = encodeURIComponent(urlInput.value);
const requestUrl = `{% url 'bookmarks:api-root' %}bookmarks/check?url=${websiteUrl}`;
const requestUrl = `{% url 'bookmarks:api.check_url' %}?url=${websiteUrl}`;
fetch(requestUrl)
.then(response => response.json())
.then(data => {

View File

@@ -1,25 +0,0 @@
{% extends "bookmarks/layout.html" %}
{% block content %}
<div class="settings-page">
{% include 'settings/nav.html' %}
<section class="content-area">
<h2>API Token</h2>
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
<div class="form-group">
<div class="columns">
<div class="column col-6 col-md-12">
<input class="form-input" value="{{ api_token }}" disabled>
</div>
</div>
</div>
<p><strong>Please treat this token as you would any other credential.</strong> Any party with access to this
token can access and manage all your bookmarks.</p>
<p>If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_token_changelist' %}">admin panel</a>. After deleting the token, a new one will be generated when you reload this settings page.</p>
</section>
</div>
{% endblock %}

View File

@@ -1,54 +0,0 @@
{% extends "bookmarks/layout.html" %}
{% block content %}
<div class="settings-page">
{% include 'settings/nav.html' %}
{# Import section #}
<section class="content-area">
<h2>Import</h2>
<p>Import bookmarks and tags in the Netscape HTML format. This will execute a sync where new bookmarks are
added and existing ones are updated.</p>
<form method="post" enctype="multipart/form-data" action="{% url 'bookmarks:settings.import' %}">
{% csrf_token %}
<div class="form-group">
<div class="input-group col-8 col-md-12">
<input class="form-input" type="file" name="import_file">
<input type="submit" class="input-group-btn col-2 btn btn-primary" value="Upload">
</div>
{% if import_success_message %}
<div class="has-success">
<p class="form-input-hint">
{{ import_success_message }}
</p>
</div>
{% endif %}
{% if import_errors_message %}
<div class="has-error">
<p class="form-input-hint">
{{ import_errors_message }}
</p>
</div>
{% endif %}
</div>
</form>
</section>
{# Export section #}
<section class="content-area">
<h2>Export</h2>
<p>Export all bookmarks in Netscape HTML format.</p>
<a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
{% if export_error %}
<div class="has-error">
<p class="form-input-hint">
{{ export_error }}
</p>
</div>
{% endif %}
</section>
</div>
{% endblock %}

View File

@@ -0,0 +1,91 @@
{% extends "bookmarks/layout.html" %}
{% block content %}
<div class="settings-page">
{# Import section #}
<section class="content-area">
<div class="content-area-header">
<h2>Import</h2>
</div>
<p>Import bookmarks and tags in the Netscape HTML format. This will execute a sync where new bookmarks are
added and existing ones are updated.</p>
<form method="post" enctype="multipart/form-data" action="{% url 'bookmarks:settings.import' %}">
{% csrf_token %}
<div class="form-group">
<div class="input-group col-8 col-md-12">
<input class="form-input" type="file" name="import_file">
<input type="submit" class="input-group-btn col-2 btn btn-primary" value="Upload">
</div>
{% if import_success_message %}
<div class="has-success">
<p class="form-input-hint">
{{ import_success_message }}
</p>
</div>
{% endif %}
{% if import_errors_message %}
<div class="has-error">
<p class="form-input-hint">
{{ import_errors_message }}
</p>
</div>
{% endif %}
</div>
</form>
</section>
{# Export section #}
<section class="content-area">
<div class="content-area-header">
<h2>Export</h2>
</div>
<p>Export all bookmarks in Netscape HTML format.</p>
<a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
{% if export_error %}
<div class="has-error">
<p class="form-input-hint">
{{ export_error }}
</p>
</div>
{% endif %}
</section>
{# Integrations section #}
<section class="content-area">
<div class="content-area-header">
<a id="bookmarklet"><h2>Bookmarklet</h2></a>
</div>
<p>The bookmarklet is a quick way to add new bookmarks without opening the linkding application
first. Here's how it works:</p>
<ul>
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
<li>Open the website that you want to bookmark</li>
<li>Click the bookmarklet in your browsers toolbar</li>
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
</ul>
<p>Drag the following bookmarklet to your browsers toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
class="btn btn-primary">📎 Add bookmark</a>
</section>
{# API token section #}
<section class="content-area">
<div class="content-area-header">
<h2>API Token</h2>
</div>
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
<div class="form-group">
<div class="columns">
<div class="column col-6 col-md-12">
<input class="form-input" value="{{ api_token }}" disabled>
</div>
</div>
</div>
<p><strong>Please treat this token as you would any other credential.</strong> Any party with access to this token can access and manage all your bookmarks.</p>
</section>
</div>
{% endblock %}

View File

@@ -1,26 +0,0 @@
{% extends "bookmarks/layout.html" %}
{% block content %}
<div class="settings-page">
{% include 'settings/nav.html' %}
{# Integrations section #}
<section class="content-area">
<h2>Bookmarklet</h2>
<p>The bookmarklet is a quick way to add new bookmarks without opening the linkding application
first. Here's how it works:</p>
<ul>
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
<li>Open the website that you want to bookmark</li>
<li>Click the bookmarklet in your browsers toolbar</li>
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
</ul>
<p>Drag the following bookmarklet to your browsers toolbar:</p>
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
class="btn btn-primary">📎 Add bookmark</a>
</section>
</div>
{% endblock %}

View File

@@ -1,23 +0,0 @@
{% url 'bookmarks:settings.index' as index_url %}
{% url 'bookmarks:settings.data' as data_url %}
{% url 'bookmarks:settings.integrations' as integrations_url %}
{% url 'bookmarks:settings.api' as api_url %}
<ul class="tab tab-block">
<li class="tab-item {% if request.get_full_path == index_url or request.get_full_path == data_url%}active{% endif %}">
<a href="{% url 'bookmarks:settings.data' %}">Data</a>
</li>
<li class="tab-item {% if request.get_full_path == integrations_url %}active{% endif %}">
<a href="{% url 'bookmarks:settings.integrations' %}">Integrations</a>
</li>
<li class="tab-item {% if request.get_full_path == api_url %}active{% endif %}">
<a href="{% url 'bookmarks:settings.api' %}">API</a>
</li>
<li class="tab-item tooltip tooltip-bottom" data-tooltip="The admin panel provides additional features &#010; such as user management and bulk operations.">
<a href="{% url 'admin:index' %}" target="_blank">
<span>Admin</span>
<i class="icon icon-share ml-1" style="font-size: 12px"></i>
</a>
</li>
</ul>
<br>

View File

@@ -1,64 +0,0 @@
from django.contrib.auth.models import User
from django.utils import timezone
from django.utils.crypto import get_random_string
from rest_framework import status
from rest_framework.test import APITestCase
from bookmarks.models import Bookmark, Tag
class BookmarkFactoryMixin:
user = None
def get_or_create_test_user(self):
if self.user is None:
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
return self.user
def setup_bookmark(self, is_archived: bool = False, tags: [Tag] = [], user: User = None):
if user is None:
user = self.get_or_create_test_user()
unique_id = get_random_string(length=32)
bookmark = Bookmark(
url='https://example.com/' + unique_id,
date_added=timezone.now(),
date_modified=timezone.now(),
owner=user,
is_archived=is_archived
)
bookmark.save()
for tag in tags:
bookmark.tags.add(tag)
bookmark.save()
return bookmark
def setup_tag(self, user: User = None):
if user is None:
user = self.get_or_create_test_user()
name = get_random_string(length=32)
tag = Tag(name=name, date_added=timezone.now(), owner=user)
tag.save()
return tag
class LinkdingApiTestCase(APITestCase):
def get(self, url, expected_status_code=status.HTTP_200_OK):
response = self.client.get(url)
self.assertEqual(response.status_code, expected_status_code)
return response
def post(self, url, data=None, expected_status_code=status.HTTP_200_OK):
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, expected_status_code)
return response
def put(self, url, data=None, expected_status_code=status.HTTP_200_OK):
response = self.client.put(url, data, format='json')
self.assertEqual(response.status_code, expected_status_code)
return response
def delete(self, url, expected_status_code=status.HTTP_200_OK):
response = self.client.delete(url)
self.assertEqual(response.status_code, expected_status_code)
return response

View File

@@ -1,138 +0,0 @@
from collections import OrderedDict
from django.contrib.auth.models import User
from django.urls import reverse
from rest_framework import status
from rest_framework.authtoken.models import Token
from bookmarks.models import Bookmark
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.api_token = Token.objects.get_or_create(user=self.get_or_create_test_user())[0]
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.api_token.key)
self.tag1 = self.setup_tag()
self.tag2 = self.setup_tag()
self.bookmark1 = self.setup_bookmark(tags=[self.tag1, self.tag2])
self.bookmark2 = self.setup_bookmark()
self.bookmark3 = self.setup_bookmark(tags=[self.tag2])
self.archived_bookmark1 = self.setup_bookmark(is_archived=True, tags=[self.tag1, self.tag2])
self.archived_bookmark2 = self.setup_bookmark(is_archived=True)
def assertBookmarkListEqual(self, data_list, bookmarks):
expectations = []
for bookmark in bookmarks:
tag_names = [tag.name for tag in bookmark.tags.all()]
tag_names.sort(key=str.lower)
expectation = OrderedDict()
expectation['id'] = bookmark.id
expectation['url'] = bookmark.url
expectation['title'] = bookmark.title
expectation['description'] = bookmark.description
expectation['website_title'] = bookmark.website_title
expectation['website_description'] = bookmark.website_description
expectation['tag_names'] = tag_names
expectation['date_added'] = bookmark.date_added.isoformat().replace('+00:00', 'Z')
expectation['date_modified'] = bookmark.date_modified.isoformat().replace('+00:00', 'Z')
expectations.append(expectation)
for data in data_list:
data['tag_names'].sort(key=str.lower)
self.assertCountEqual(data_list, expectations)
def test_create_bookmark(self):
data = {
'url': 'https://example.com/',
'title': 'Test title',
'description': 'Test description',
'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.url, data['url'])
self.assertEqual(bookmark.title, data['title'])
self.assertEqual(bookmark.description, data['description'])
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_minimal_payload(self):
data = {'url': 'https://example.com/'}
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_get_bookmark(self):
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual([response.data], [self.bookmark1])
def test_update_bookmark(self):
data = {'url': 'https://example.com/'}
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.url, data['url'])
def test_delete_bookmark(self):
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
self.assertEqual(len(Bookmark.objects.filter(id=self.bookmark1.id)), 0)
def test_archive(self):
url = reverse('bookmarks:bookmark-archive', args=[self.bookmark1.id])
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
bookmark = Bookmark.objects.get(id=self.bookmark1.id)
self.assertTrue(bookmark.is_archived)
def test_unarchive(self):
url = reverse('bookmarks:bookmark-unarchive', args=[self.archived_bookmark1.id])
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
bookmark = Bookmark.objects.get(id=self.archived_bookmark1.id)
self.assertFalse(bookmark.is_archived)
def test_can_only_access_own_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
inaccessible_bookmark = self.setup_bookmark(user=other_user)
self.setup_bookmark(user=other_user, is_archived=True)
url = reverse('bookmarks:bookmark-list')
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 3)
url = reverse('bookmarks:bookmark-archived')
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 2)
url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id])
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id])
self.put(url, {url: 'https://example.com/'}, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id])
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse('bookmarks:bookmark-archive', args=[inaccessible_bookmark.id])
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse('bookmarks:bookmark-unarchive', args=[inaccessible_bookmark.id])
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)

View File

@@ -1,13 +1,38 @@
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from django.utils.crypto import get_random_string
from bookmarks.models import Bookmark, Tag
from bookmarks import queries
from bookmarks.tests.helpers import BookmarkFactoryMixin
User = get_user_model()
class QueriesTestCase(TestCase, BookmarkFactoryMixin):
class QueriesTestCase(TestCase):
def setUp(self) -> None:
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
def setup_bookmark(self, is_archived: bool = False, tags: [Tag] = []):
unique_id = get_random_string(length=32)
bookmark = Bookmark(
url='https://example.com/' + unique_id,
date_added=timezone.now(),
date_modified=timezone.now(),
owner=self.user,
is_archived=is_archived
)
bookmark.save()
for tag in tags:
bookmark.tags.add(tag)
bookmark.save()
return bookmark
def setup_tag(self):
name = get_random_string(length=32)
tag = Tag(name=name, date_added=timezone.now(), owner=self.user)
tag.save()
return tag
def test_query_bookmarks_should_not_return_archived_bookmarks(self):
bookmark1 = self.setup_bookmark()
@@ -16,7 +41,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True)
self.setup_bookmark(is_archived=True)
query = queries.query_bookmarks(self.get_or_create_test_user(), '')
query = queries.query_bookmarks(self.user, '')
self.assertCountEqual([bookmark1, bookmark2], list(query))
@@ -27,7 +52,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark()
self.setup_bookmark()
query = queries.query_archived_bookmarks(self.get_or_create_test_user(), '')
query = queries.query_archived_bookmarks(self.user, '')
self.assertCountEqual([bookmark1, bookmark2], list(query))
@@ -38,7 +63,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark()
self.setup_bookmark(is_archived=True, tags=[tag2])
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '')
query = queries.query_bookmark_tags(self.user, '')
self.assertCountEqual([tag1], list(query))
@@ -48,7 +73,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(tags=[tag])
self.setup_bookmark(tags=[tag])
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '')
query = queries.query_bookmark_tags(self.user, '')
self.assertCountEqual([tag], list(query))
@@ -59,7 +84,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark()
self.setup_bookmark(is_archived=True, tags=[tag2])
query = queries.query_archived_bookmark_tags(self.get_or_create_test_user(), '')
query = queries.query_archived_bookmark_tags(self.user, '')
self.assertCountEqual([tag2], list(query))
@@ -69,6 +94,6 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(is_archived=True, tags=[tag])
self.setup_bookmark(is_archived=True, tags=[tag])
query = queries.query_archived_bookmark_tags(self.get_or_create_test_user(), '')
query = queries.query_archived_bookmark_tags(self.user, '')
self.assertCountEqual([tag], list(query))

View File

@@ -19,12 +19,10 @@ urlpatterns = [
path('bookmarks/<int:bookmark_id>/archive', views.bookmarks.archive, name='archive'),
path('bookmarks/<int:bookmark_id>/unarchive', views.bookmarks.unarchive, name='unarchive'),
# Settings
path('settings', views.settings.data, name='settings.index'),
path('settings/data', views.settings.data, name='settings.data'),
path('settings/integrations', views.settings.integrations, name='settings.integrations'),
path('settings/api', views.settings.api, name='settings.api'),
path('settings', views.settings.index, name='settings.index'),
path('settings/import', views.settings.bookmark_import, name='settings.import'),
path('settings/export', views.settings.bookmark_export, name='settings.export'),
# API
path('api/check_url', views.api.check_url, name='api.check_url'),
path('api/', include(router.urls), name='api')
]

View File

@@ -1,2 +1,3 @@
from .api import *
from .bookmarks import *
from .settings import *

27
bookmarks/views/api.py Normal file
View File

@@ -0,0 +1,27 @@
from django.contrib.auth.decorators import login_required
from django.forms import model_to_dict
from django.http import JsonResponse
from django.urls import reverse
from bookmarks.services.website_loader import load_website_metadata
from bookmarks.models import Bookmark
@login_required
def check_url(request):
url = request.GET.get('url')
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
existing_bookmark_data = None
if bookmark is not None:
existing_bookmark_data = {
'id': bookmark.id,
'edit_url': reverse('bookmarks:edit', args=[bookmark.id])
}
metadata = load_website_metadata(url)
return JsonResponse({
'bookmark': existing_bookmark_data,
'metadata': metadata.to_dict()
})

View File

@@ -15,27 +15,15 @@ logger = logging.getLogger(__name__)
@login_required
def data(request):
def index(request):
import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success')
import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors')
return render(request, 'settings/data.html', {
application_url = request.build_absolute_uri("/bookmarks/new")
api_token = Token.objects.get_or_create(user=request.user)[0]
return render(request, 'settings/index.html', {
'import_success_message': import_success_message,
'import_errors_message': import_errors_message,
})
@login_required
def integrations(request):
application_url = request.build_absolute_uri("/bookmarks/new")
return render(request, 'settings/integrations.html', {
'application_url': application_url,
})
@login_required
def api(request):
api_token = Token.objects.get_or_create(user=request.user)[0]
return render(request, 'settings/api.html', {
'api_token': api_token.key
})
@@ -61,7 +49,7 @@ def bookmark_import(request):
messages.error(request, 'An error occurred during bookmark import.', 'bookmark_import_errors')
pass
return HttpResponseRedirect(reverse('bookmarks:settings.data'))
return HttpResponseRedirect(reverse('bookmarks:settings.index'))
@login_required
@@ -77,7 +65,7 @@ def bookmark_export(request):
return response
except:
return render(request, 'settings/data.html', {
return render(request, 'settings/index.html', {
'export_error': 'An error occurred during bookmark export.'
})

View File

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

View File

@@ -41,7 +41,7 @@ INSTALLED_APPS = [
'widget_tweaks',
'django_generate_secret_key',
'rest_framework',
'rest_framework.authtoken',
'rest_framework.authtoken'
]
MIDDLEWARE = [

View File

@@ -13,14 +13,13 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import path, include
from bookmarks.admin import linkding_admin_site
from .settings import ALLOW_REGISTRATION
urlpatterns = [
path('admin/', linkding_admin_site.urls),
path('admin/', admin.site.urls),
path('login/', auth_views.LoginView.as_view(redirect_authenticated_user=True,
extra_context=dict(allow_registration=ALLOW_REGISTRATION)),
name='login'),

View File

@@ -1 +1 @@
1.4.0
1.3.0