mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-27 20:36:49 +02:00
Compare commits
18 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
794b6d8932 | ||
![]() |
6b4664117b | ||
![]() |
621b497dc6 | ||
![]() |
4bb05f811b | ||
![]() |
fb8e6b3b5f | ||
![]() |
814401be2e | ||
![]() |
4cb39fae99 | ||
![]() |
30da1880a5 | ||
![]() |
da99b8b034 | ||
![]() |
894625aa25 | ||
![]() |
62d7fb5f63 | ||
![]() |
fa2633147a | ||
![]() |
ddf97b0a3f | ||
![]() |
d3b4aa7602 | ||
![]() |
021d1cd673 | ||
![]() |
43d52642a6 | ||
![]() |
4f9170c48d | ||
![]() |
313a0ee99f |
41
.github/workflows/main.yaml
vendored
41
.github/workflows/main.yaml
vendored
@@ -3,22 +3,45 @@ name: linkding CI
|
|||||||
on: [push]
|
on: [push]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
run_tests:
|
unit_tests:
|
||||||
name: Run Django Tests
|
name: Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v3
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v1
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.10"
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 14
|
node-version: 18
|
||||||
- name: Install Python dependencies
|
|
||||||
run: pip install -r requirements.txt
|
|
||||||
- name: Install Node dependencies
|
- name: Install Node dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
- name: Setup Python environment
|
||||||
|
run: pip install -r requirements.txt
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: python manage.py test
|
run: python manage.py test bookmarks.tests
|
||||||
|
e2e_tests:
|
||||||
|
name: E2E Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: "3.10"
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
- name: Install Node dependencies
|
||||||
|
run: npm install
|
||||||
|
- name: Setup Python environment
|
||||||
|
run: |
|
||||||
|
pip install -r requirements.txt
|
||||||
|
playwright install chromium
|
||||||
|
python manage.py compilescss
|
||||||
|
python manage.py collectstatic --ignore=*.scss
|
||||||
|
- name: Run tests
|
||||||
|
run: python manage.py test bookmarks.e2e
|
||||||
|
61
CHANGELOG.md
61
CHANGELOG.md
@@ -1,5 +1,66 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.17.0 (21/01/2023)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add Health Check endpoint by @mckennajones in https://github.com/sissbruecker/linkding/pull/392
|
||||||
|
* Cache website metadata to avoid duplicate scraping by @sissbruecker in https://github.com/sissbruecker/linkding/pull/401
|
||||||
|
* Prefill form if URL is already bookmarked by @sissbruecker in https://github.com/sissbruecker/linkding/pull/402
|
||||||
|
* Add option for showing bookmark favicons by @sissbruecker in https://github.com/sissbruecker/linkding/pull/390
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.16.1...v1.17.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.16.1 (20/01/2023)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Fix bookmark website metadata not being updated when URL changes by @sissbruecker in https://github.com/sissbruecker/linkding/pull/400
|
||||||
|
* Bump django from 4.1 to 4.1.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/391
|
||||||
|
* Bump certifi from 2022.6.15 to 2022.12.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/374
|
||||||
|
* Bump minimatch from 3.0.4 to 3.1.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/366
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.16.0...v1.16.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.16.0 (12/01/2023)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add postgres as database engine by @tomamplius in https://github.com/sissbruecker/linkding/pull/388
|
||||||
|
* Gracefully stop docker container when it receives SIGTERM by @mckennajones in https://github.com/sissbruecker/linkding/pull/368
|
||||||
|
* Limit document size for website scraper by @sissbruecker in https://github.com/sissbruecker/linkding/pull/354
|
||||||
|
* Add error handling for checking latest version by @sissbruecker in https://github.com/sissbruecker/linkding/pull/360
|
||||||
|
* Trim website metadata title and description by @luca1197 in https://github.com/sissbruecker/linkding/pull/383
|
||||||
|
* Only show admin link for superusers by @AlexanderS in https://github.com/sissbruecker/linkding/pull/384
|
||||||
|
* Add apache reverse proxy documentation. by @jhauris in https://github.com/sissbruecker/linkding/pull/371
|
||||||
|
* Correct LD_ENABLE_AUTH_PROXY documentation by @jhauris in https://github.com/sissbruecker/linkding/pull/379
|
||||||
|
* Android HTTP shortcuts v3 by @kzshantonu in https://github.com/sissbruecker/linkding/pull/387
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @jhauris made their first contribution in https://github.com/sissbruecker/linkding/pull/371
|
||||||
|
* @AlexanderS made their first contribution in https://github.com/sissbruecker/linkding/pull/384
|
||||||
|
* @mckennajones made their first contribution in https://github.com/sissbruecker/linkding/pull/368
|
||||||
|
* @tomamplius made their first contribution in https://github.com/sissbruecker/linkding/pull/388
|
||||||
|
* @luca1197 made their first contribution in https://github.com/sissbruecker/linkding/pull/383
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.15.1...v1.16.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.15.1 (05/10/2022)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Fix static file dir warning by @sissbruecker in https://github.com/sissbruecker/linkding/pull/350
|
||||||
|
* Add setting and documentation for fixing CSRF errors by @sissbruecker in https://github.com/sissbruecker/linkding/pull/349
|
||||||
|
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.15.0...v1.15.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.15.0 (11/09/2022)
|
## v1.15.0 (11/09/2022)
|
||||||
|
|
||||||
### What's Changed
|
### What's Changed
|
||||||
|
@@ -34,7 +34,7 @@ RUN mkdir /opt/venv && \
|
|||||||
|
|
||||||
|
|
||||||
FROM python:3.10.6-slim-buster as final
|
FROM python:3.10.6-slim-buster as final
|
||||||
RUN apt-get update && apt-get -y install mime-support libpq-dev
|
RUN apt-get update && apt-get -y install mime-support libpq-dev curl
|
||||||
WORKDIR /etc/linkding
|
WORKDIR /etc/linkding
|
||||||
# copy prod dependencies
|
# copy prod dependencies
|
||||||
COPY --from=prod-deps /opt/venv /opt/venv
|
COPY --from=prod-deps /opt/venv /opt/venv
|
||||||
@@ -51,4 +51,8 @@ ENV PATH /opt/venv/bin:$PATH
|
|||||||
RUN ["chmod", "g+w", "."]
|
RUN ["chmod", "g+w", "."]
|
||||||
# Run bootstrap logic
|
# Run bootstrap logic
|
||||||
RUN ["chmod", "+x", "./bootstrap.sh"]
|
RUN ["chmod", "+x", "./bootstrap.sh"]
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
|
||||||
|
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/health || exit 1
|
||||||
|
|
||||||
CMD ["./bootstrap.sh"]
|
CMD ["./bootstrap.sh"]
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
from django.urls import reverse
|
|
||||||
from rest_framework import viewsets, mixins, status
|
from rest_framework import viewsets, mixins, status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@@ -7,8 +6,8 @@ from rest_framework.routers import DefaultRouter
|
|||||||
from bookmarks import queries
|
from bookmarks import queries
|
||||||
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
|
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
|
||||||
from bookmarks.models import Bookmark, BookmarkFilters, Tag, User
|
from bookmarks.models import Bookmark, BookmarkFilters, Tag, User
|
||||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark, website_loader
|
||||||
from bookmarks.services.website_loader import load_website_metadata
|
from bookmarks.services.website_loader import WebsiteMetadata
|
||||||
|
|
||||||
|
|
||||||
class BookmarkViewSet(viewsets.GenericViewSet,
|
class BookmarkViewSet(viewsets.GenericViewSet,
|
||||||
@@ -68,15 +67,13 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
|||||||
def check(self, request):
|
def check(self, request):
|
||||||
url = request.GET.get('url')
|
url = request.GET.get('url')
|
||||||
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
||||||
existing_bookmark_data = None
|
existing_bookmark_data = self.get_serializer(bookmark).data if bookmark else None
|
||||||
|
|
||||||
if bookmark is not None:
|
# Either return metadata from existing bookmark, or scrape from URL
|
||||||
existing_bookmark_data = {
|
if bookmark:
|
||||||
'id': bookmark.id,
|
metadata = WebsiteMetadata(url, bookmark.website_title, bookmark.website_description)
|
||||||
'edit_url': reverse('bookmarks:edit', args=[bookmark.id])
|
else:
|
||||||
}
|
metadata = website_loader.load_website_metadata(url)
|
||||||
|
|
||||||
metadata = load_website_metadata(url)
|
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'bookmark': existing_bookmark_data,
|
'bookmark': existing_bookmark_data,
|
||||||
|
0
bookmarks/e2e/__init__.py
Normal file
0
bookmarks/e2e/__init__.py
Normal file
21
bookmarks/e2e/helpers.py
Normal file
21
bookmarks/e2e/helpers.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from django.contrib.staticfiles.testing import LiveServerTestCase
|
||||||
|
from playwright.sync_api import BrowserContext
|
||||||
|
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
|
class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.client.force_login(self.get_or_create_test_user())
|
||||||
|
self.cookie = self.client.cookies['sessionid']
|
||||||
|
|
||||||
|
def setup_browser(self, playwright) -> BrowserContext:
|
||||||
|
browser = playwright.chromium.launch(headless=True)
|
||||||
|
context = browser.new_context()
|
||||||
|
context.add_cookies([{
|
||||||
|
'name': 'sessionid',
|
||||||
|
'value': self.cookie.value,
|
||||||
|
'domain': self.live_server_url.replace('http:', ''),
|
||||||
|
'path': '/'
|
||||||
|
}])
|
||||||
|
return context
|
51
bookmarks/e2e/test_bookmark_form.py
Normal file
51
bookmarks/e2e/test_bookmark_form.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||||
|
def test_create_should_check_for_existing_bookmark(self):
|
||||||
|
existing_bookmark = self.setup_bookmark(title='Existing title',
|
||||||
|
description='Existing description',
|
||||||
|
tags=[self.setup_tag(name='tag1'), self.setup_tag(name='tag2')],
|
||||||
|
website_title='Existing website title',
|
||||||
|
website_description='Existing website description',
|
||||||
|
unread=True)
|
||||||
|
tag_names = ' '.join(existing_bookmark.tag_names)
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(self.live_server_url + reverse('bookmarks:new'))
|
||||||
|
|
||||||
|
# Enter bookmarked URL
|
||||||
|
page.get_by_label('URL').fill(existing_bookmark.url)
|
||||||
|
# Already bookmarked hint should be visible
|
||||||
|
page.get_by_text('This URL is already bookmarked.').wait_for(timeout=2000)
|
||||||
|
# Form should be pre-filled with data from existing bookmark
|
||||||
|
self.assertEqual(existing_bookmark.title, page.get_by_label('Title').input_value())
|
||||||
|
self.assertEqual(existing_bookmark.description, page.get_by_label('Description').input_value())
|
||||||
|
self.assertEqual(existing_bookmark.website_title, page.get_by_label('Title').get_attribute('placeholder'))
|
||||||
|
self.assertEqual(existing_bookmark.website_description,
|
||||||
|
page.get_by_label('Description').get_attribute('placeholder'))
|
||||||
|
self.assertEqual(tag_names, page.get_by_label('Tags').input_value())
|
||||||
|
self.assertTrue(tag_names, page.get_by_label('Mark as unread').is_checked())
|
||||||
|
|
||||||
|
# Enter non-bookmarked URL
|
||||||
|
page.get_by_label('URL').fill('https://example.com/unknown')
|
||||||
|
# Already bookmarked hint should be hidden
|
||||||
|
page.get_by_text('This URL is already bookmarked.').wait_for(state='hidden', timeout=2000)
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
def test_edit_should_not_check_for_existing_bookmark(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(self.live_server_url + reverse('bookmarks:edit', args=[bookmark.id]))
|
||||||
|
|
||||||
|
page.wait_for_timeout(timeout=1000)
|
||||||
|
page.get_by_text('This URL is already bookmarked.').wait_for(state='hidden')
|
30
bookmarks/e2e/test_global_shortcuts.py
Normal file
30
bookmarks/e2e/test_global_shortcuts.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from playwright.sync_api import sync_playwright, expect
|
||||||
|
|
||||||
|
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalShortcutsE2ETestCase(LinkdingE2ETestCase):
|
||||||
|
def test_focus_search(self):
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(self.live_server_url + reverse('bookmarks:index'))
|
||||||
|
|
||||||
|
page.press('body', 's')
|
||||||
|
|
||||||
|
expect(page.get_by_placeholder('Search for words or #tags')).to_be_focused()
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
def test_add_bookmark(self):
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = self.setup_browser(p)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(self.live_server_url + reverse('bookmarks:index'))
|
||||||
|
|
||||||
|
page.press('body', 'n')
|
||||||
|
|
||||||
|
expect(page).to_have_url(self.live_server_url + reverse('bookmarks:new'))
|
||||||
|
|
||||||
|
browser.close()
|
18
bookmarks/migrations/0018_bookmark_favicon_file.py
Normal file
18
bookmarks/migrations/0018_bookmark_favicon_file.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1 on 2023-01-07 23:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('bookmarks', '0017_userprofile_enable_sharing'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='bookmark',
|
||||||
|
name='favicon_file',
|
||||||
|
field=models.CharField(blank=True, max_length=512),
|
||||||
|
),
|
||||||
|
]
|
18
bookmarks/migrations/0019_userprofile_enable_favicons.py
Normal file
18
bookmarks/migrations/0019_userprofile_enable_favicons.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.1 on 2023-01-09 21:16
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('bookmarks', '0018_bookmark_favicon_file'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userprofile',
|
||||||
|
name='enable_favicons',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
@@ -53,6 +53,7 @@ class Bookmark(models.Model):
|
|||||||
website_title = models.CharField(max_length=512, blank=True, null=True)
|
website_title = models.CharField(max_length=512, blank=True, null=True)
|
||||||
website_description = models.TextField(blank=True, null=True)
|
website_description = models.TextField(blank=True, null=True)
|
||||||
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
|
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
|
||||||
|
favicon_file = models.CharField(max_length=512, 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)
|
shared = models.BooleanField(default=False)
|
||||||
@@ -161,12 +162,13 @@ class UserProfile(models.Model):
|
|||||||
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)
|
enable_sharing = models.BooleanField(default=False, null=False)
|
||||||
|
enable_favicons = 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', 'enable_sharing']
|
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'enable_sharing', 'enable_favicons']
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=get_user_model())
|
@receiver(post_save, sender=get_user_model())
|
||||||
|
@@ -30,6 +30,8 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
|||||||
bookmark.save()
|
bookmark.save()
|
||||||
# Create snapshot on web archive
|
# Create snapshot on web archive
|
||||||
tasks.create_web_archive_snapshot(current_user, bookmark, False)
|
tasks.create_web_archive_snapshot(current_user, bookmark, False)
|
||||||
|
# Load favicon
|
||||||
|
tasks.load_favicon(current_user, bookmark)
|
||||||
|
|
||||||
return bookmark
|
return bookmark
|
||||||
|
|
||||||
@@ -43,11 +45,15 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
|||||||
# Update dates
|
# Update dates
|
||||||
bookmark.date_modified = timezone.now()
|
bookmark.date_modified = timezone.now()
|
||||||
bookmark.save()
|
bookmark.save()
|
||||||
|
# Update favicon
|
||||||
|
tasks.load_favicon(current_user, bookmark)
|
||||||
|
|
||||||
if has_url_changed:
|
if has_url_changed:
|
||||||
# Update web archive snapshot, if URL changed
|
# Update web archive snapshot, if URL changed
|
||||||
tasks.create_web_archive_snapshot(current_user, bookmark, True)
|
tasks.create_web_archive_snapshot(current_user, bookmark, True)
|
||||||
# Only update website metadata if URL changed
|
# Only update website metadata if URL changed
|
||||||
_update_website_metadata(bookmark)
|
_update_website_metadata(bookmark)
|
||||||
|
bookmark.save()
|
||||||
|
|
||||||
return bookmark
|
return bookmark
|
||||||
|
|
||||||
|
57
bookmarks/services/favicon_loader.py
Normal file
57
bookmarks/services/favicon_loader.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import os.path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
max_file_age = 60 * 60 * 24 # 1 day
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_favicon_folder():
|
||||||
|
Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _url_to_filename(url: str) -> str:
|
||||||
|
name = re.sub(r'\W+', '_', url)
|
||||||
|
return f'{name}.png'
|
||||||
|
|
||||||
|
|
||||||
|
def _get_base_url(url: str) -> str:
|
||||||
|
parsed_uri = urlparse(url)
|
||||||
|
return f'{parsed_uri.scheme}://{parsed_uri.hostname}'
|
||||||
|
|
||||||
|
|
||||||
|
def _get_favicon_path(favicon_file: str) -> Path:
|
||||||
|
return Path(os.path.join(settings.LD_FAVICON_FOLDER, favicon_file))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_stale(path: Path) -> bool:
|
||||||
|
stat = path.stat()
|
||||||
|
file_age = time.time() - stat.st_mtime
|
||||||
|
return file_age >= max_file_age
|
||||||
|
|
||||||
|
|
||||||
|
def load_favicon(url: str) -> str:
|
||||||
|
# Get base URL so that we can reuse favicons for multiple bookmarks with the same host
|
||||||
|
base_url = _get_base_url(url)
|
||||||
|
favicon_name = _url_to_filename(base_url)
|
||||||
|
favicon_path = _get_favicon_path(favicon_name)
|
||||||
|
|
||||||
|
# Load icon if it doesn't exist yet or has become stale
|
||||||
|
if not favicon_path.exists() or _is_stale(favicon_path):
|
||||||
|
# Create favicon folder if not exists
|
||||||
|
_ensure_favicon_folder()
|
||||||
|
# Load favicon from provider, save to file
|
||||||
|
favicon_url = settings.LD_FAVICON_PROVIDER.format(url=base_url)
|
||||||
|
response = requests.get(favicon_url, stream=True)
|
||||||
|
|
||||||
|
with open(favicon_path, 'wb') as file:
|
||||||
|
shutil.copyfileobj(response.raw, file)
|
||||||
|
|
||||||
|
del response
|
||||||
|
|
||||||
|
return favicon_name
|
@@ -74,6 +74,8 @@ def import_netscape_html(html: str, user: User):
|
|||||||
|
|
||||||
# Create snapshots for newly imported bookmarks
|
# Create snapshots for newly imported bookmarks
|
||||||
tasks.schedule_bookmarks_without_snapshots(user)
|
tasks.schedule_bookmarks_without_snapshots(user)
|
||||||
|
# Load favicons for newly imported bookmarks
|
||||||
|
tasks.schedule_bookmarks_without_favicons(user)
|
||||||
|
|
||||||
end = timezone.now()
|
end = timezone.now()
|
||||||
logger.debug(f'Import duration: {end - import_start}')
|
logger.debug(f'Import duration: {end - import_start}')
|
||||||
|
@@ -2,6 +2,7 @@ import logging
|
|||||||
|
|
||||||
import waybackpy
|
import waybackpy
|
||||||
from background_task import background
|
from background_task import background
|
||||||
|
from background_task.models import Task
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
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
|
||||||
@@ -9,6 +10,7 @@ from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecord
|
|||||||
|
|
||||||
import bookmarks.services.wayback
|
import bookmarks.services.wayback
|
||||||
from bookmarks.models import Bookmark, UserProfile
|
from bookmarks.models import Bookmark, UserProfile
|
||||||
|
from bookmarks.services import favicon_loader
|
||||||
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
|
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -35,7 +37,7 @@ def _load_newest_snapshot(bookmark: Bookmark):
|
|||||||
|
|
||||||
if existing_snapshot:
|
if existing_snapshot:
|
||||||
bookmark.web_archive_snapshot_url = existing_snapshot.archive_url
|
bookmark.web_archive_snapshot_url = existing_snapshot.archive_url
|
||||||
bookmark.save()
|
bookmark.save(update_fields=['web_archive_snapshot_url'])
|
||||||
logger.info(f'Using newest snapshot. url={bookmark.url} from={existing_snapshot.datetime_timestamp}')
|
logger.info(f'Using newest snapshot. url={bookmark.url} from={existing_snapshot.datetime_timestamp}')
|
||||||
|
|
||||||
except NoCDXRecordFound:
|
except NoCDXRecordFound:
|
||||||
@@ -49,7 +51,7 @@ def _create_snapshot(bookmark: Bookmark):
|
|||||||
archive = waybackpy.WaybackMachineSaveAPI(bookmark.url, DEFAULT_USER_AGENT, max_tries=1)
|
archive = waybackpy.WaybackMachineSaveAPI(bookmark.url, DEFAULT_USER_AGENT, max_tries=1)
|
||||||
archive.save()
|
archive.save()
|
||||||
bookmark.web_archive_snapshot_url = archive.archive_url
|
bookmark.web_archive_snapshot_url = archive.archive_url
|
||||||
bookmark.save()
|
bookmark.save(update_fields=['web_archive_snapshot_url'])
|
||||||
logger.info(f'Successfully created new snapshot for bookmark:. url={bookmark.url}')
|
logger.info(f'Successfully created new snapshot for bookmark:. url={bookmark.url}')
|
||||||
|
|
||||||
|
|
||||||
@@ -72,7 +74,8 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
|
|||||||
logger.error(
|
logger.error(
|
||||||
f'Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}')
|
f'Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}')
|
||||||
except WaybackError as error:
|
except WaybackError as error:
|
||||||
logger.error(f'Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}', exc_info=error)
|
logger.error(f'Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}',
|
||||||
|
exc_info=error)
|
||||||
|
|
||||||
# Load the newest snapshot as fallback
|
# Load the newest snapshot as fallback
|
||||||
_load_newest_snapshot(bookmark)
|
_load_newest_snapshot(bookmark)
|
||||||
@@ -105,3 +108,67 @@ def _schedule_bookmarks_without_snapshots_task(user_id: int):
|
|||||||
# To prevent rate limit errors from the Wayback API only try to load the latest snapshots instead of creating
|
# To prevent rate limit errors from the Wayback API only try to load the latest snapshots instead of creating
|
||||||
# new ones when processing bookmarks in bulk
|
# new ones when processing bookmarks in bulk
|
||||||
_load_web_archive_snapshot_task(bookmark.id)
|
_load_web_archive_snapshot_task(bookmark.id)
|
||||||
|
|
||||||
|
|
||||||
|
def is_favicon_feature_active(user: User) -> bool:
|
||||||
|
background_tasks_enabled = not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||||
|
|
||||||
|
return background_tasks_enabled and user.profile.enable_favicons
|
||||||
|
|
||||||
|
|
||||||
|
def load_favicon(user: User, bookmark: Bookmark):
|
||||||
|
if is_favicon_feature_active(user):
|
||||||
|
_load_favicon_task(bookmark.id)
|
||||||
|
|
||||||
|
|
||||||
|
@background()
|
||||||
|
def _load_favicon_task(bookmark_id: int):
|
||||||
|
try:
|
||||||
|
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||||
|
except Bookmark.DoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f'Load favicon for bookmark. url={bookmark.url}')
|
||||||
|
|
||||||
|
new_favicon = favicon_loader.load_favicon(bookmark.url)
|
||||||
|
|
||||||
|
if new_favicon != bookmark.favicon_file:
|
||||||
|
bookmark.favicon_file = new_favicon
|
||||||
|
bookmark.save(update_fields=['favicon_file'])
|
||||||
|
logger.info(f'Successfully updated favicon for bookmark. url={bookmark.url} icon={new_favicon}')
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_bookmarks_without_favicons(user: User):
|
||||||
|
if is_favicon_feature_active(user):
|
||||||
|
_schedule_bookmarks_without_favicons_task(user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@background()
|
||||||
|
def _schedule_bookmarks_without_favicons_task(user_id: int):
|
||||||
|
user = get_user_model().objects.get(id=user_id)
|
||||||
|
bookmarks = Bookmark.objects.filter(favicon_file__exact='', owner=user)
|
||||||
|
tasks = []
|
||||||
|
|
||||||
|
for bookmark in bookmarks:
|
||||||
|
task = Task.objects.new_task(task_name='bookmarks.services.tasks._load_favicon_task', args=(bookmark.id,))
|
||||||
|
tasks.append(task)
|
||||||
|
|
||||||
|
Task.objects.bulk_create(tasks)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_refresh_favicons(user: User):
|
||||||
|
if is_favicon_feature_active(user) and settings.LD_ENABLE_REFRESH_FAVICONS:
|
||||||
|
_schedule_refresh_favicons_task(user.id)
|
||||||
|
|
||||||
|
|
||||||
|
@background()
|
||||||
|
def _schedule_refresh_favicons_task(user_id: int):
|
||||||
|
user = get_user_model().objects.get(id=user_id)
|
||||||
|
bookmarks = Bookmark.objects.filter(owner=user)
|
||||||
|
tasks = []
|
||||||
|
|
||||||
|
for bookmark in bookmarks:
|
||||||
|
task = Task.objects.new_task(task_name='bookmarks.services.tasks._load_favicon_task', args=(bookmark.id,))
|
||||||
|
tasks.append(task)
|
||||||
|
|
||||||
|
Task.objects.bulk_create(tasks)
|
||||||
|
@@ -1,9 +1,11 @@
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from charset_normalizer import from_bytes
|
from charset_normalizer import from_bytes
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -22,16 +24,27 @@ class WebsiteMetadata:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Caching metadata avoids scraping again when saving bookmarks, in case the
|
||||||
|
# metadata was already scraped to show preview values in the bookmark form
|
||||||
|
@lru_cache(maxsize=10)
|
||||||
def load_website_metadata(url: str):
|
def load_website_metadata(url: str):
|
||||||
title = None
|
title = None
|
||||||
description = None
|
description = None
|
||||||
try:
|
try:
|
||||||
|
start = timezone.now()
|
||||||
page_text = load_page(url)
|
page_text = load_page(url)
|
||||||
|
end = timezone.now()
|
||||||
|
logger.debug(f'Load duration: {end - start}')
|
||||||
|
|
||||||
|
start = timezone.now()
|
||||||
soup = BeautifulSoup(page_text, 'html.parser')
|
soup = BeautifulSoup(page_text, 'html.parser')
|
||||||
|
|
||||||
title = soup.title.string.strip() if soup.title is not None else None
|
title = soup.title.string.strip() if soup.title is not None else None
|
||||||
description_tag = soup.find('meta', attrs={'name': 'description'})
|
description_tag = soup.find('meta', attrs={'name': 'description'})
|
||||||
description = description = description_tag['content'].strip() if description_tag and description_tag['content'] else None
|
description = description = description_tag['content'].strip() if description_tag and description_tag[
|
||||||
|
'content'] else None
|
||||||
|
end = timezone.now()
|
||||||
|
logger.debug(f'Parsing duration: {end - start}')
|
||||||
finally:
|
finally:
|
||||||
return WebsiteMetadata(url=url, title=title, description=description)
|
return WebsiteMetadata(url=url, title=title, description=description)
|
||||||
|
|
||||||
@@ -44,15 +57,19 @@ def load_page(url: str):
|
|||||||
headers = fake_request_headers()
|
headers = fake_request_headers()
|
||||||
size = 0
|
size = 0
|
||||||
content = None
|
content = None
|
||||||
|
iteration = 0
|
||||||
# Use with to ensure request gets closed even if it's only read partially
|
# Use with to ensure request gets closed even if it's only read partially
|
||||||
with requests.get(url, timeout=10, headers=headers, stream=True) as r:
|
with requests.get(url, timeout=10, headers=headers, stream=True) as r:
|
||||||
for chunk in r.iter_content(chunk_size=CHUNK_SIZE):
|
for chunk in r.iter_content(chunk_size=CHUNK_SIZE):
|
||||||
size += len(chunk)
|
size += len(chunk)
|
||||||
|
iteration = iteration + 1
|
||||||
if content is None:
|
if content is None:
|
||||||
content = chunk
|
content = chunk
|
||||||
else:
|
else:
|
||||||
content = content + chunk
|
content = content + chunk
|
||||||
|
|
||||||
|
logger.debug(f'Loaded chunk (iteration={iteration}, total={size / 1024})')
|
||||||
|
|
||||||
# Stop reading if we have parsed end of head tag
|
# Stop reading if we have parsed end of head tag
|
||||||
if '</head>'.encode('utf-8') in content:
|
if '</head>'.encode('utf-8') in content:
|
||||||
logger.debug(f'Found closing head tag after {size} bytes')
|
logger.debug(f'Found closing head tag after {size} bytes')
|
||||||
@@ -61,6 +78,8 @@ def load_page(url: str):
|
|||||||
if size > MAX_CONTENT_LIMIT:
|
if size > MAX_CONTENT_LIMIT:
|
||||||
logger.debug(f'Cancel reading document after {size} bytes')
|
logger.debug(f'Cancel reading document after {size} bytes')
|
||||||
break
|
break
|
||||||
|
if hasattr(r, '_content_consumed'):
|
||||||
|
logger.debug(f'Request consumed: {r._content_consumed}')
|
||||||
|
|
||||||
# Use charset_normalizer to determine encoding that best matches the response content
|
# Use charset_normalizer to determine encoding that best matches the response content
|
||||||
# Several sites seem to specify the response encoding incorrectly, so we ignore it and use custom logic instead
|
# Several sites seem to specify the response encoding incorrectly, so we ignore it and use custom logic instead
|
||||||
|
@@ -58,6 +58,12 @@ ul.bookmark-list {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title img {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
vertical-align: text-top;
|
||||||
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
color: $gray-color-dark;
|
color: $gray-color-dark;
|
||||||
|
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
{% load static %}
|
||||||
{% load shared %}
|
{% load shared %}
|
||||||
{% load pagination %}
|
{% load pagination %}
|
||||||
{% htmlmin %}
|
{% htmlmin %}
|
||||||
@@ -11,6 +12,9 @@
|
|||||||
<div class="title">
|
<div class="title">
|
||||||
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
|
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
|
||||||
class="{% if bookmark.unread %}text-italic{% endif %}">
|
class="{% if bookmark.unread %}text-italic{% endif %}">
|
||||||
|
{% if bookmark.favicon_file and request.user.profile.enable_favicons %}
|
||||||
|
<img src="{% static bookmark.favicon_file %}" alt="">
|
||||||
|
{% endif %}
|
||||||
{{ bookmark.resolved_title }}
|
{{ bookmark.resolved_title }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -15,8 +15,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="form-input-hint bookmark-exists">
|
<div class="form-input-hint bookmark-exists">
|
||||||
This URL is already bookmarked. You can <a href="#">edit</a> it or you can overwrite the existing bookmark
|
This URL is already bookmarked.
|
||||||
by saving this form.
|
The form has been pre-filled with the existing bookmark, and saving the form will update the existing bookmark.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -128,6 +128,9 @@
|
|||||||
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
|
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
|
||||||
const titleInput = document.getElementById('{{ form.title.id_for_label }}');
|
const titleInput = document.getElementById('{{ form.title.id_for_label }}');
|
||||||
const descriptionInput = document.getElementById('{{ form.description.id_for_label }}');
|
const descriptionInput = document.getElementById('{{ form.description.id_for_label }}');
|
||||||
|
const tagsInput = document.getElementById('{{ form.tag_string.id_for_label }}');
|
||||||
|
const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}');
|
||||||
|
const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}');
|
||||||
const websiteTitleInput = document.getElementById('{{ form.website_title.id_for_label }}');
|
const websiteTitleInput = document.getElementById('{{ form.website_title.id_for_label }}');
|
||||||
const websiteDescriptionInput = document.getElementById('{{ form.website_description.id_for_label }}');
|
const websiteDescriptionInput = document.getElementById('{{ form.website_description.id_for_label }}');
|
||||||
const editedBookmarkId = {{ bookmark_id }};
|
const editedBookmarkId = {{ bookmark_id }};
|
||||||
@@ -145,6 +148,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateInput(input, value) {
|
||||||
|
input.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCheckbox(input, value) {
|
||||||
|
input.checked = value;
|
||||||
|
}
|
||||||
|
|
||||||
function checkUrl() {
|
function checkUrl() {
|
||||||
toggleLoadingIcon(titleInput, true);
|
toggleLoadingIcon(titleInput, true);
|
||||||
toggleLoadingIcon(descriptionInput, true);
|
toggleLoadingIcon(descriptionInput, true);
|
||||||
@@ -162,13 +173,17 @@
|
|||||||
toggleLoadingIcon(titleInput, false);
|
toggleLoadingIcon(titleInput, false);
|
||||||
toggleLoadingIcon(descriptionInput, false);
|
toggleLoadingIcon(descriptionInput, false);
|
||||||
|
|
||||||
// Display hint if URL is already bookmarked
|
// Prefill form and display hint if URL is already bookmarked
|
||||||
|
const existingBookmark = data.bookmark;
|
||||||
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
|
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
|
||||||
const editExistingBookmarkLink = bookmarkExistsHint.querySelector('a');
|
|
||||||
|
|
||||||
if (data.bookmark && data.bookmark.id !== editedBookmarkId) {
|
if (existingBookmark && !editedBookmarkId) {
|
||||||
bookmarkExistsHint.style['display'] = 'block';
|
bookmarkExistsHint.style['display'] = 'block';
|
||||||
editExistingBookmarkLink.href = data.bookmark.edit_url;
|
updateInput(titleInput, existingBookmark.title);
|
||||||
|
updateInput(descriptionInput, existingBookmark.description);
|
||||||
|
updateInput(tagsInput, existingBookmark.tag_names.join(" "));
|
||||||
|
updateCheckbox(unreadCheckbox, existingBookmark.unread);
|
||||||
|
updateCheckbox(sharedCheckbox, existingBookmark.shared);
|
||||||
} else {
|
} else {
|
||||||
bookmarkExistsHint.style['display'] = 'none';
|
bookmarkExistsHint.style['display'] = 'none';
|
||||||
}
|
}
|
||||||
|
@@ -36,6 +36,30 @@
|
|||||||
Whether to open bookmarks a new page or in the same page.
|
Whether to open bookmarks a new page or in the same page.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.enable_favicons.id_for_label }}" class="form-checkbox">
|
||||||
|
{{ form.enable_favicons }}
|
||||||
|
<i class="form-icon"></i> Enable Favicons
|
||||||
|
</label>
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Automatically loads favicons for bookmarked websites and displays them next to each bookmark.
|
||||||
|
By default, this feature uses a <b>Google service</b> to download favicons.
|
||||||
|
If you don't want to use this service, check the <a
|
||||||
|
href="https://github.com/sissbruecker/linkding/blob/master/docs/Options.md" target="_blank">options
|
||||||
|
documentation</a> on how to configure a custom favicon provider.
|
||||||
|
Icons are downloaded in the background, and it may take a while for them to show up.
|
||||||
|
</div>
|
||||||
|
{% if request.user.profile.enable_favicons and enable_refresh_favicons %}
|
||||||
|
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if refresh_favicons_success_message %}
|
||||||
|
<div class="has-success">
|
||||||
|
<p class="form-input-hint">
|
||||||
|
{{ refresh_favicons_success_message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
|
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
|
||||||
integration</label>
|
integration</label>
|
||||||
@@ -61,7 +85,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<input type="submit" value="Save" class="btn btn-primary mt-2">
|
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
|
||||||
|
{% if update_profile_success_message %}
|
||||||
|
<div class="has-success">
|
||||||
|
<p class="form-input-hint">
|
||||||
|
{{ update_profile_success_message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
@@ -33,6 +33,7 @@ class BookmarkFactoryMixin:
|
|||||||
website_title: str = '',
|
website_title: str = '',
|
||||||
website_description: str = '',
|
website_description: str = '',
|
||||||
web_archive_snapshot_url: str = '',
|
web_archive_snapshot_url: str = '',
|
||||||
|
favicon_file: str = '',
|
||||||
):
|
):
|
||||||
if not title:
|
if not title:
|
||||||
title = get_random_string(length=32)
|
title = get_random_string(length=32)
|
||||||
@@ -56,6 +57,7 @@ class BookmarkFactoryMixin:
|
|||||||
unread=unread,
|
unread=unread,
|
||||||
shared=shared,
|
shared=shared,
|
||||||
web_archive_snapshot_url=web_archive_snapshot_url,
|
web_archive_snapshot_url=web_archive_snapshot_url,
|
||||||
|
favicon_file=favicon_file,
|
||||||
)
|
)
|
||||||
bookmark.save()
|
bookmark.save()
|
||||||
for tag in tags:
|
for tag in tags:
|
||||||
|
@@ -1,4 +1,6 @@
|
|||||||
|
import urllib.parse
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -6,6 +8,8 @@ from rest_framework import status
|
|||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
from bookmarks.models import Bookmark
|
from bookmarks.models import Bookmark
|
||||||
|
from bookmarks.services import website_loader
|
||||||
|
from bookmarks.services.website_loader import WebsiteMetadata
|
||||||
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
|
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -353,6 +357,66 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
bookmark = Bookmark.objects.get(id=self.archived_bookmark1.id)
|
bookmark = Bookmark.objects.get(id=self.archived_bookmark1.id)
|
||||||
self.assertFalse(bookmark.is_archived)
|
self.assertFalse(bookmark.is_archived)
|
||||||
|
|
||||||
|
def test_check_returns_no_bookmark_if_url_is_not_bookmarked(self):
|
||||||
|
url = reverse('bookmarks:bookmark-check')
|
||||||
|
check_url = urllib.parse.quote_plus('https://example.com')
|
||||||
|
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
|
||||||
|
bookmark_data = response.data['bookmark']
|
||||||
|
|
||||||
|
self.assertIsNone(bookmark_data)
|
||||||
|
|
||||||
|
def test_check_returns_scraped_metadata_if_url_is_not_bookmarked(self):
|
||||||
|
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
|
||||||
|
expected_metadata = WebsiteMetadata(
|
||||||
|
'https://example.com',
|
||||||
|
'Scraped metadata',
|
||||||
|
'Scraped description'
|
||||||
|
)
|
||||||
|
mock_load_website_metadata.return_value = expected_metadata
|
||||||
|
|
||||||
|
url = reverse('bookmarks:bookmark-check')
|
||||||
|
check_url = urllib.parse.quote_plus('https://example.com')
|
||||||
|
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
|
||||||
|
metadata = response.data['metadata']
|
||||||
|
|
||||||
|
self.assertIsNotNone(metadata)
|
||||||
|
self.assertIsNotNone(expected_metadata.url, metadata['url'])
|
||||||
|
self.assertIsNotNone(expected_metadata.title, metadata['title'])
|
||||||
|
self.assertIsNotNone(expected_metadata.description, metadata['description'])
|
||||||
|
|
||||||
|
def test_check_returns_bookmark_if_url_is_bookmarked(self):
|
||||||
|
bookmark = self.setup_bookmark(url='https://example.com',
|
||||||
|
title='Example title',
|
||||||
|
description='Example description')
|
||||||
|
|
||||||
|
url = reverse('bookmarks:bookmark-check')
|
||||||
|
check_url = urllib.parse.quote_plus('https://example.com')
|
||||||
|
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
|
||||||
|
bookmark_data = response.data['bookmark']
|
||||||
|
|
||||||
|
self.assertIsNotNone(bookmark_data)
|
||||||
|
self.assertEqual(bookmark.id, bookmark_data['id'])
|
||||||
|
self.assertEqual(bookmark.url, bookmark_data['url'])
|
||||||
|
self.assertEqual(bookmark.title, bookmark_data['title'])
|
||||||
|
self.assertEqual(bookmark.description, bookmark_data['description'])
|
||||||
|
|
||||||
|
def test_check_returns_existing_metadata_if_url_is_bookmarked(self):
|
||||||
|
bookmark = self.setup_bookmark(url='https://example.com',
|
||||||
|
website_title='Existing title',
|
||||||
|
website_description='Existing description')
|
||||||
|
|
||||||
|
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
|
||||||
|
url = reverse('bookmarks:bookmark-check')
|
||||||
|
check_url = urllib.parse.quote_plus('https://example.com')
|
||||||
|
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
|
||||||
|
metadata = response.data['metadata']
|
||||||
|
|
||||||
|
mock_load_website_metadata.assert_not_called()
|
||||||
|
self.assertIsNotNone(metadata)
|
||||||
|
self.assertIsNotNone(bookmark.url, metadata['url'])
|
||||||
|
self.assertIsNotNone(bookmark.website_title, metadata['title'])
|
||||||
|
self.assertIsNotNone(bookmark.website_description, metadata['description'])
|
||||||
|
|
||||||
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)
|
||||||
@@ -396,3 +460,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
url = reverse('bookmarks:bookmark-unarchive', args=[inaccessible_shared_bookmark.id])
|
url = reverse('bookmarks:bookmark-unarchive', args=[inaccessible_shared_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-check')
|
||||||
|
check_url = urllib.parse.quote_plus(inaccessible_bookmark.url)
|
||||||
|
response = self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
|
||||||
|
self.assertIsNone(response.data['bookmark'])
|
||||||
|
@@ -79,6 +79,17 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
|||||||
</span>
|
</span>
|
||||||
''', html, count=count)
|
''', html, count=count)
|
||||||
|
|
||||||
|
def assertFaviconVisible(self, html: str, bookmark: Bookmark):
|
||||||
|
self.assertFaviconCount(html, bookmark, 1)
|
||||||
|
|
||||||
|
def assertFaviconHidden(self, html: str, bookmark: Bookmark):
|
||||||
|
self.assertFaviconCount(html, bookmark, 0)
|
||||||
|
|
||||||
|
def assertFaviconCount(self, html: str, bookmark: Bookmark, count=1):
|
||||||
|
self.assertInHTML(f'''
|
||||||
|
<img src="/static/{bookmark.favicon_file}" alt="">
|
||||||
|
''', html, count=count)
|
||||||
|
|
||||||
def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test') -> str:
|
def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test') -> str:
|
||||||
rf = RequestFactory()
|
rf = RequestFactory()
|
||||||
request = rf.get(url)
|
request = rf.get(url)
|
||||||
@@ -211,3 +222,33 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
|||||||
<a class="text-gray" href="?q=foo&user={bookmark.owner.username}">{bookmark.owner.username}</a>
|
<a class="text-gray" href="?q=foo&user={bookmark.owner.username}">{bookmark.owner.username}</a>
|
||||||
</span>
|
</span>
|
||||||
''', html)
|
''', html)
|
||||||
|
|
||||||
|
def test_favicon_should_be_visible_when_favicons_enabled(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_favicons = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
|
||||||
|
html = self.render_default_template([bookmark])
|
||||||
|
|
||||||
|
self.assertFaviconVisible(html, bookmark)
|
||||||
|
|
||||||
|
def test_favicon_should_be_hidden_when_there_is_no_icon(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_favicons = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
bookmark = self.setup_bookmark(favicon_file='')
|
||||||
|
html = self.render_default_template([bookmark])
|
||||||
|
|
||||||
|
self.assertFaviconHidden(html, bookmark)
|
||||||
|
|
||||||
|
def test_favicon_should_be_hidden_when_favicons_disabled(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_favicons = False
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
|
||||||
|
html = self.render_default_template([bookmark])
|
||||||
|
|
||||||
|
self.assertFaviconHidden(html, bookmark)
|
||||||
|
@@ -5,11 +5,12 @@ from django.test import TestCase
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, Tag
|
from bookmarks.models import Bookmark, Tag
|
||||||
|
from bookmarks.services import tasks
|
||||||
|
from bookmarks.services import website_loader
|
||||||
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.services import website_loader
|
from bookmarks.services.website_loader import WebsiteMetadata
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
from bookmarks.services import tasks
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -19,6 +20,27 @@ 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_website_metadata(self):
|
||||||
|
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
|
||||||
|
expected_metadata = WebsiteMetadata(
|
||||||
|
'https://example.com',
|
||||||
|
'Website title',
|
||||||
|
'Website description'
|
||||||
|
)
|
||||||
|
mock_load_website_metadata.return_value = expected_metadata
|
||||||
|
|
||||||
|
bookmark_data = Bookmark(url='https://example.com',
|
||||||
|
title='Updated Title',
|
||||||
|
description='Updated description',
|
||||||
|
unread=True,
|
||||||
|
shared=True,
|
||||||
|
is_archived=True)
|
||||||
|
created_bookmark = create_bookmark(bookmark_data, '', self.get_or_create_test_user())
|
||||||
|
|
||||||
|
created_bookmark.refresh_from_db()
|
||||||
|
self.assertEqual(expected_metadata.title, created_bookmark.website_title)
|
||||||
|
self.assertEqual(expected_metadata.description, created_bookmark.website_description)
|
||||||
|
|
||||||
def test_create_should_update_existing_bookmark_with_same_url(self):
|
def test_create_should_update_existing_bookmark_with_same_url(self):
|
||||||
original_bookmark = self.setup_bookmark(url='https://example.com', unread=False, shared=False)
|
original_bookmark = self.setup_bookmark(url='https://example.com', unread=False, shared=False)
|
||||||
bookmark_data = Bookmark(url='https://example.com',
|
bookmark_data = Bookmark(url='https://example.com',
|
||||||
@@ -45,6 +67,13 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
mock_create_web_archive_snapshot.assert_called_once_with(self.user, bookmark, False)
|
mock_create_web_archive_snapshot.assert_called_once_with(self.user, bookmark, False)
|
||||||
|
|
||||||
|
def test_create_should_load_favicon(self):
|
||||||
|
with patch.object(tasks, 'load_favicon') as mock_load_favicon:
|
||||||
|
bookmark_data = Bookmark(url='https://example.com')
|
||||||
|
bookmark = create_bookmark(bookmark_data, 'tag1,tag2', self.user)
|
||||||
|
|
||||||
|
mock_load_favicon.assert_called_once_with(self.user, bookmark)
|
||||||
|
|
||||||
def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
|
def test_update_should_create_web_archive_snapshot_if_url_did_change(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 = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
@@ -63,11 +92,21 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
def test_update_should_update_website_metadata_if_url_did_change(self):
|
def test_update_should_update_website_metadata_if_url_did_change(self):
|
||||||
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
|
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
|
||||||
|
expected_metadata = WebsiteMetadata(
|
||||||
|
'https://example.com/updated',
|
||||||
|
'Updated website title',
|
||||||
|
'Updated website description'
|
||||||
|
)
|
||||||
|
mock_load_website_metadata.return_value = expected_metadata
|
||||||
|
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
bookmark.url = 'https://example.com/updated'
|
bookmark.url = 'https://example.com/updated'
|
||||||
update_bookmark(bookmark, 'tag1,tag2', self.user)
|
update_bookmark(bookmark, 'tag1,tag2', self.user)
|
||||||
|
|
||||||
|
bookmark.refresh_from_db()
|
||||||
mock_load_website_metadata.assert_called_once()
|
mock_load_website_metadata.assert_called_once()
|
||||||
|
self.assertEqual(expected_metadata.title, bookmark.website_title)
|
||||||
|
self.assertEqual(expected_metadata.description, bookmark.website_description)
|
||||||
|
|
||||||
def test_update_should_not_update_website_metadata_if_url_did_not_change(self):
|
def test_update_should_not_update_website_metadata_if_url_did_not_change(self):
|
||||||
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
|
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
|
||||||
@@ -77,6 +116,14 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
mock_load_website_metadata.assert_not_called()
|
mock_load_website_metadata.assert_not_called()
|
||||||
|
|
||||||
|
def test_update_should_update_favicon(self):
|
||||||
|
with patch.object(tasks, 'load_favicon') as mock_load_favicon:
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
bookmark.title = 'updated title'
|
||||||
|
update_bookmark(bookmark, 'tag1,tag2', self.user)
|
||||||
|
|
||||||
|
mock_load_favicon.assert_called_once_with(self.user, bookmark)
|
||||||
|
|
||||||
def test_archive_bookmark(self):
|
def test_archive_bookmark(self):
|
||||||
bookmark = Bookmark(
|
bookmark = Bookmark(
|
||||||
url='https://example.com',
|
url='https://example.com',
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from unittest.mock import patch
|
from typing import Any
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
import waybackpy
|
import waybackpy
|
||||||
from background_task.models import Task
|
from background_task.models import Task
|
||||||
@@ -8,21 +9,21 @@ from django.contrib.auth.models import User
|
|||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from waybackpy.exceptions import WaybackError
|
from waybackpy.exceptions import WaybackError
|
||||||
|
|
||||||
|
import bookmarks.services.favicon_loader
|
||||||
import bookmarks.services.wayback
|
import bookmarks.services.wayback
|
||||||
from bookmarks.models import UserProfile
|
from bookmarks.models import UserProfile
|
||||||
from bookmarks.services import tasks
|
from bookmarks.services import tasks
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
|
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
|
||||||
|
|
||||||
|
|
||||||
class MockWaybackMachineSaveAPI:
|
def create_wayback_machine_save_api_mock(archive_url: str = 'https://example.com/created_snapshot',
|
||||||
def __init__(self, archive_url: str = 'https://example.com/created_snapshot', fail_on_save: bool = False):
|
fail_on_save: bool = False):
|
||||||
self.archive_url = archive_url
|
mock_api = mock.Mock(archive_url=archive_url)
|
||||||
self.fail_on_save = fail_on_save
|
|
||||||
|
|
||||||
def save(self):
|
if fail_on_save:
|
||||||
if self.fail_on_save:
|
mock_api.save.side_effect = WaybackError
|
||||||
raise WaybackError
|
|
||||||
return self
|
return mock_api
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -31,21 +32,18 @@ class MockCdxSnapshot:
|
|||||||
datetime_timestamp: datetime.datetime
|
datetime_timestamp: datetime.datetime
|
||||||
|
|
||||||
|
|
||||||
class MockWaybackMachineCDXServerAPI:
|
def create_cdx_server_api_mock(archive_url: str | None = 'https://example.com/newest_snapshot',
|
||||||
def __init__(self,
|
|
||||||
archive_url: str = 'https://example.com/newest_snapshot',
|
|
||||||
has_no_snapshot=False,
|
|
||||||
fail_loading_snapshot=False):
|
fail_loading_snapshot=False):
|
||||||
self.archive_url = archive_url
|
mock_api = mock.Mock()
|
||||||
self.has_no_snapshot = has_no_snapshot
|
|
||||||
self.fail_loading_snapshot = fail_loading_snapshot
|
|
||||||
|
|
||||||
def newest(self):
|
if fail_loading_snapshot:
|
||||||
if self.has_no_snapshot:
|
mock_api.newest.side_effect = WaybackError
|
||||||
return None
|
elif archive_url:
|
||||||
if self.fail_loading_snapshot:
|
mock_api.newest.return_value = MockCdxSnapshot(archive_url, datetime.datetime.now())
|
||||||
raise WaybackError
|
else:
|
||||||
return MockCdxSnapshot(self.archive_url, datetime.datetime.now())
|
mock_api.newest.return_value = None
|
||||||
|
|
||||||
|
return mock_api
|
||||||
|
|
||||||
|
|
||||||
class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
@@ -53,49 +51,55 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
user = self.get_or_create_test_user()
|
user = self.get_or_create_test_user()
|
||||||
user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
|
user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
|
||||||
|
user.profile.enable_favicons = True
|
||||||
user.profile.save()
|
user.profile.save()
|
||||||
|
|
||||||
@disable_logging
|
@disable_logging
|
||||||
def run_pending_task(self, task_function):
|
def run_pending_task(self, task_function: Any):
|
||||||
func = getattr(task_function, 'task_function', None)
|
func = getattr(task_function, 'task_function', None)
|
||||||
task = Task.objects.all()[0]
|
task = Task.objects.all()[0]
|
||||||
|
self.assertEqual(task_function.name, task.task_name)
|
||||||
args, kwargs = task.params()
|
args, kwargs = task.params()
|
||||||
func(*args, **kwargs)
|
func(*args, **kwargs)
|
||||||
task.delete()
|
task.delete()
|
||||||
|
|
||||||
@disable_logging
|
@disable_logging
|
||||||
def run_all_pending_tasks(self, task_function):
|
def run_all_pending_tasks(self, task_function: Any):
|
||||||
func = getattr(task_function, 'task_function', None)
|
func = getattr(task_function, 'task_function', None)
|
||||||
tasks = Task.objects.all()
|
tasks = Task.objects.all()
|
||||||
|
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
|
self.assertEqual(task_function.name, task.task_name)
|
||||||
args, kwargs = task.params()
|
args, kwargs = task.params()
|
||||||
func(*args, **kwargs)
|
func(*args, **kwargs)
|
||||||
task.delete()
|
task.delete()
|
||||||
|
|
||||||
def test_create_web_archive_snapshot_should_update_snapshot_url(self):
|
def test_create_web_archive_snapshot_should_update_snapshot_url(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
mock_save_api = create_wayback_machine_save_api_mock()
|
||||||
|
|
||||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI()):
|
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
|
||||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||||
bookmark.refresh_from_db()
|
bookmark.refresh_from_db()
|
||||||
|
|
||||||
|
mock_save_api.save.assert_called_once()
|
||||||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com/created_snapshot')
|
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com/created_snapshot')
|
||||||
|
|
||||||
def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self):
|
def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self):
|
||||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
mock_save_api = create_wayback_machine_save_api_mock()
|
||||||
return_value=MockWaybackMachineSaveAPI()) as mock_save_api:
|
|
||||||
|
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
|
||||||
tasks._create_web_archive_snapshot_task(123, False)
|
tasks._create_web_archive_snapshot_task(123, False)
|
||||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||||
|
|
||||||
mock_save_api.assert_not_called()
|
mock_save_api.save.assert_not_called()
|
||||||
|
|
||||||
def test_create_web_archive_snapshot_should_skip_if_snapshot_exists(self):
|
def test_create_web_archive_snapshot_should_skip_if_snapshot_exists(self):
|
||||||
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
|
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
|
||||||
|
mock_save_api = create_wayback_machine_save_api_mock()
|
||||||
|
|
||||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
|
||||||
return_value=MockWaybackMachineSaveAPI()) as mock_save_api:
|
|
||||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||||
|
|
||||||
@@ -103,9 +107,9 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
def test_create_web_archive_snapshot_should_force_update_snapshot(self):
|
def test_create_web_archive_snapshot_should_force_update_snapshot(self):
|
||||||
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
|
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
|
||||||
|
mock_save_api = create_wayback_machine_save_api_mock(archive_url='https://other.com')
|
||||||
|
|
||||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
|
||||||
return_value=MockWaybackMachineSaveAPI('https://other.com')):
|
|
||||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, True)
|
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, True)
|
||||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||||
bookmark.refresh_from_db()
|
bookmark.refresh_from_db()
|
||||||
@@ -114,24 +118,27 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
def test_create_web_archive_snapshot_should_use_newest_snapshot_as_fallback(self):
|
def test_create_web_archive_snapshot_should_use_newest_snapshot_as_fallback(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
mock_save_api = create_wayback_machine_save_api_mock(fail_on_save=True)
|
||||||
|
mock_cdx_api = create_cdx_server_api_mock()
|
||||||
|
|
||||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
|
||||||
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
|
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
return_value=mock_cdx_api):
|
||||||
return_value=MockWaybackMachineCDXServerAPI()):
|
|
||||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||||
|
|
||||||
bookmark.refresh_from_db()
|
bookmark.refresh_from_db()
|
||||||
|
mock_cdx_api.newest.assert_called_once()
|
||||||
self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url)
|
self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url)
|
||||||
|
|
||||||
def test_create_web_archive_snapshot_should_ignore_missing_newest_snapshot(self):
|
def test_create_web_archive_snapshot_should_ignore_missing_newest_snapshot(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
mock_save_api = create_wayback_machine_save_api_mock(fail_on_save=True)
|
||||||
|
mock_cdx_api = create_cdx_server_api_mock(archive_url=None)
|
||||||
|
|
||||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
|
||||||
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
|
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
return_value=mock_cdx_api):
|
||||||
return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
|
|
||||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||||
|
|
||||||
@@ -140,51 +147,78 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
def test_create_web_archive_snapshot_should_ignore_newest_snapshot_errors(self):
|
def test_create_web_archive_snapshot_should_ignore_newest_snapshot_errors(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
mock_save_api = create_wayback_machine_save_api_mock(fail_on_save=True)
|
||||||
|
mock_cdx_api = create_cdx_server_api_mock(fail_loading_snapshot=True)
|
||||||
|
|
||||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
|
||||||
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
|
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
return_value=mock_cdx_api):
|
||||||
return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
|
|
||||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||||
|
|
||||||
bookmark.refresh_from_db()
|
bookmark.refresh_from_db()
|
||||||
self.assertEqual('', bookmark.web_archive_snapshot_url)
|
self.assertEqual('', bookmark.web_archive_snapshot_url)
|
||||||
|
|
||||||
|
def test_create_web_archive_snapshot_should_not_save_stale_bookmark_data(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
mock_save_api = create_wayback_machine_save_api_mock()
|
||||||
|
|
||||||
|
# update bookmark during API call to check that saving
|
||||||
|
# the snapshot does not overwrite updated bookmark data
|
||||||
|
def mock_save_impl():
|
||||||
|
bookmark.title = 'Updated title'
|
||||||
|
bookmark.save()
|
||||||
|
|
||||||
|
mock_save_api.save.side_effect = mock_save_impl
|
||||||
|
|
||||||
|
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=mock_save_api):
|
||||||
|
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||||
|
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||||
|
bookmark.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(bookmark.title, 'Updated title')
|
||||||
|
self.assertEqual('https://example.com/created_snapshot', bookmark.web_archive_snapshot_url)
|
||||||
|
|
||||||
def test_load_web_archive_snapshot_should_update_snapshot_url(self):
|
def test_load_web_archive_snapshot_should_update_snapshot_url(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
mock_cdx_api = create_cdx_server_api_mock()
|
||||||
|
|
||||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||||
return_value=MockWaybackMachineCDXServerAPI()):
|
return_value=mock_cdx_api):
|
||||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||||
|
|
||||||
bookmark.refresh_from_db()
|
bookmark.refresh_from_db()
|
||||||
|
mock_cdx_api.newest.assert_called_once()
|
||||||
self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url)
|
self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url)
|
||||||
|
|
||||||
def test_load_web_archive_snapshot_should_handle_missing_bookmark_id(self):
|
def test_load_web_archive_snapshot_should_handle_missing_bookmark_id(self):
|
||||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
mock_cdx_api = create_cdx_server_api_mock()
|
||||||
return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api:
|
|
||||||
|
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||||
|
return_value=mock_cdx_api):
|
||||||
tasks._load_web_archive_snapshot_task(123)
|
tasks._load_web_archive_snapshot_task(123)
|
||||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||||
|
|
||||||
mock_cdx_api.assert_not_called()
|
mock_cdx_api.newest.assert_not_called()
|
||||||
|
|
||||||
def test_load_web_archive_snapshot_should_skip_if_snapshot_exists(self):
|
def test_load_web_archive_snapshot_should_skip_if_snapshot_exists(self):
|
||||||
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
|
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
|
||||||
|
mock_cdx_api = create_cdx_server_api_mock()
|
||||||
|
|
||||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||||
return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api:
|
return_value=mock_cdx_api):
|
||||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||||
|
|
||||||
mock_cdx_api.assert_not_called()
|
mock_cdx_api.newest.assert_not_called()
|
||||||
|
|
||||||
def test_load_web_archive_snapshot_should_handle_missing_snapshot(self):
|
def test_load_web_archive_snapshot_should_handle_missing_snapshot(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
mock_cdx_api = create_cdx_server_api_mock(archive_url=None)
|
||||||
|
|
||||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||||
return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
|
return_value=mock_cdx_api):
|
||||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||||
|
|
||||||
@@ -192,14 +226,37 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
def test_load_web_archive_snapshot_should_handle_wayback_errors(self):
|
def test_load_web_archive_snapshot_should_handle_wayback_errors(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
mock_cdx_api = create_cdx_server_api_mock(fail_loading_snapshot=True)
|
||||||
|
|
||||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||||
return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
|
return_value=mock_cdx_api):
|
||||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||||
|
|
||||||
self.assertEqual('', bookmark.web_archive_snapshot_url)
|
self.assertEqual('', bookmark.web_archive_snapshot_url)
|
||||||
|
|
||||||
|
def test_load_web_archive_snapshot_should_not_save_stale_bookmark_data(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
mock_cdx_api = create_cdx_server_api_mock()
|
||||||
|
|
||||||
|
# update bookmark during API call to check that saving
|
||||||
|
# the snapshot does not overwrite updated bookmark data
|
||||||
|
def mock_newest_impl():
|
||||||
|
bookmark.title = 'Updated title'
|
||||||
|
bookmark.save()
|
||||||
|
return mock.DEFAULT
|
||||||
|
|
||||||
|
mock_cdx_api.newest.side_effect = mock_newest_impl
|
||||||
|
|
||||||
|
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||||
|
return_value=mock_cdx_api):
|
||||||
|
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||||
|
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||||
|
bookmark.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual('Updated title', bookmark.title)
|
||||||
|
self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url)
|
||||||
|
|
||||||
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
||||||
def test_create_web_archive_snapshot_should_not_run_when_background_tasks_are_disabled(self):
|
def test_create_web_archive_snapshot_should_not_run_when_background_tasks_are_disabled(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
@@ -262,3 +319,177 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
tasks.schedule_bookmarks_without_snapshots(self.user)
|
tasks.schedule_bookmarks_without_snapshots(self.user)
|
||||||
|
|
||||||
self.assertEqual(Task.objects.count(), 0)
|
self.assertEqual(Task.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_load_favicon_should_create_favicon_file(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
|
||||||
|
mock_load_favicon.return_value = 'https_example_com.png'
|
||||||
|
|
||||||
|
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
|
||||||
|
self.run_pending_task(tasks._load_favicon_task)
|
||||||
|
bookmark.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(bookmark.favicon_file, 'https_example_com.png')
|
||||||
|
|
||||||
|
def test_load_favicon_should_update_favicon_file(self):
|
||||||
|
bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
|
||||||
|
|
||||||
|
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
|
||||||
|
mock_load_favicon.return_value = 'https_example_updated_com.png'
|
||||||
|
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
|
||||||
|
self.run_pending_task(tasks._load_favicon_task)
|
||||||
|
|
||||||
|
mock_load_favicon.assert_called()
|
||||||
|
bookmark.refresh_from_db()
|
||||||
|
self.assertEqual(bookmark.favicon_file, 'https_example_updated_com.png')
|
||||||
|
|
||||||
|
def test_load_favicon_should_handle_missing_bookmark(self):
|
||||||
|
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
|
||||||
|
tasks._load_favicon_task(123)
|
||||||
|
self.run_pending_task(tasks._load_favicon_task)
|
||||||
|
|
||||||
|
mock_load_favicon.assert_not_called()
|
||||||
|
|
||||||
|
def test_load_favicon_should_not_save_stale_bookmark_data(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
# update bookmark during API call to check that saving
|
||||||
|
# the favicon does not overwrite updated bookmark data
|
||||||
|
def mock_load_favicon_impl(url):
|
||||||
|
bookmark.title = 'Updated title'
|
||||||
|
bookmark.save()
|
||||||
|
return 'https_example_com.png'
|
||||||
|
|
||||||
|
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
|
||||||
|
mock_load_favicon.side_effect = mock_load_favicon_impl
|
||||||
|
|
||||||
|
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
|
||||||
|
self.run_pending_task(tasks._load_favicon_task)
|
||||||
|
bookmark.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(bookmark.title, 'Updated title')
|
||||||
|
self.assertEqual(bookmark.favicon_file, 'https_example_com.png')
|
||||||
|
|
||||||
|
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
||||||
|
def test_load_favicon_should_not_run_when_background_tasks_are_disabled(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
|
||||||
|
|
||||||
|
self.assertEqual(Task.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_load_favicon_should_not_run_when_favicon_feature_is_disabled(self):
|
||||||
|
self.user.profile.enable_favicons = False
|
||||||
|
self.user.profile.save()
|
||||||
|
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
|
||||||
|
|
||||||
|
self.assertEqual(Task.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_schedule_bookmarks_without_favicons_should_load_favicon_for_all_bookmarks_without_favicon(self):
|
||||||
|
user = self.get_or_create_test_user()
|
||||||
|
self.setup_bookmark()
|
||||||
|
self.setup_bookmark()
|
||||||
|
self.setup_bookmark()
|
||||||
|
self.setup_bookmark(favicon_file='https_example_com.png')
|
||||||
|
self.setup_bookmark(favicon_file='https_example_com.png')
|
||||||
|
self.setup_bookmark(favicon_file='https_example_com.png')
|
||||||
|
|
||||||
|
tasks.schedule_bookmarks_without_favicons(user)
|
||||||
|
self.run_pending_task(tasks._schedule_bookmarks_without_favicons_task)
|
||||||
|
|
||||||
|
task_list = Task.objects.all()
|
||||||
|
self.assertEqual(task_list.count(), 3)
|
||||||
|
|
||||||
|
for task in task_list:
|
||||||
|
self.assertEqual(task.task_name, 'bookmarks.services.tasks._load_favicon_task')
|
||||||
|
|
||||||
|
def test_schedule_bookmarks_without_favicons_should_only_update_user_owned_bookmarks(self):
|
||||||
|
user = self.get_or_create_test_user()
|
||||||
|
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||||
|
self.setup_bookmark()
|
||||||
|
self.setup_bookmark()
|
||||||
|
self.setup_bookmark()
|
||||||
|
self.setup_bookmark(user=other_user)
|
||||||
|
self.setup_bookmark(user=other_user)
|
||||||
|
self.setup_bookmark(user=other_user)
|
||||||
|
|
||||||
|
tasks.schedule_bookmarks_without_favicons(user)
|
||||||
|
self.run_pending_task(tasks._schedule_bookmarks_without_favicons_task)
|
||||||
|
|
||||||
|
task_list = Task.objects.all()
|
||||||
|
self.assertEqual(task_list.count(), 3)
|
||||||
|
|
||||||
|
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
||||||
|
def test_schedule_bookmarks_without_favicons_should_not_run_when_background_tasks_are_disabled(self):
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
tasks.schedule_bookmarks_without_favicons(self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertEqual(Task.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_schedule_bookmarks_without_favicons_should_not_run_when_favicon_feature_is_disabled(self):
|
||||||
|
self.user.profile.enable_favicons = False
|
||||||
|
self.user.profile.save()
|
||||||
|
|
||||||
|
bookmark = self.setup_bookmark()
|
||||||
|
tasks.schedule_bookmarks_without_favicons(self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertEqual(Task.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_schedule_refresh_favicons_should_update_favicon_for_all_bookmarks(self):
|
||||||
|
user = self.get_or_create_test_user()
|
||||||
|
self.setup_bookmark()
|
||||||
|
self.setup_bookmark()
|
||||||
|
self.setup_bookmark()
|
||||||
|
self.setup_bookmark(favicon_file='https_example_com.png')
|
||||||
|
self.setup_bookmark(favicon_file='https_example_com.png')
|
||||||
|
self.setup_bookmark(favicon_file='https_example_com.png')
|
||||||
|
|
||||||
|
tasks.schedule_refresh_favicons(user)
|
||||||
|
self.run_pending_task(tasks._schedule_refresh_favicons_task)
|
||||||
|
|
||||||
|
task_list = Task.objects.all()
|
||||||
|
self.assertEqual(task_list.count(), 6)
|
||||||
|
|
||||||
|
for task in task_list:
|
||||||
|
self.assertEqual(task.task_name, 'bookmarks.services.tasks._load_favicon_task')
|
||||||
|
|
||||||
|
def test_schedule_refresh_favicons_should_only_update_user_owned_bookmarks(self):
|
||||||
|
user = self.get_or_create_test_user()
|
||||||
|
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||||
|
self.setup_bookmark()
|
||||||
|
self.setup_bookmark()
|
||||||
|
self.setup_bookmark()
|
||||||
|
self.setup_bookmark(user=other_user)
|
||||||
|
self.setup_bookmark(user=other_user)
|
||||||
|
self.setup_bookmark(user=other_user)
|
||||||
|
|
||||||
|
tasks.schedule_refresh_favicons(user)
|
||||||
|
self.run_pending_task(tasks._schedule_refresh_favicons_task)
|
||||||
|
|
||||||
|
task_list = Task.objects.all()
|
||||||
|
self.assertEqual(task_list.count(), 3)
|
||||||
|
|
||||||
|
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
||||||
|
def test_schedule_refresh_favicons_should_not_run_when_background_tasks_are_disabled(self):
|
||||||
|
self.setup_bookmark()
|
||||||
|
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertEqual(Task.objects.count(), 0)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_REFRESH_FAVICONS=False)
|
||||||
|
def test_schedule_refresh_favicons_should_not_run_when_refresh_is_disabled(self):
|
||||||
|
self.setup_bookmark()
|
||||||
|
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertEqual(Task.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_schedule_refresh_favicons_should_not_run_when_favicon_feature_is_disabled(self):
|
||||||
|
self.user.profile.enable_favicons = False
|
||||||
|
self.user.profile.save()
|
||||||
|
|
||||||
|
self.setup_bookmark()
|
||||||
|
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
|
||||||
|
|
||||||
|
self.assertEqual(Task.objects.count(), 0)
|
||||||
|
127
bookmarks/tests/test_favicon_loader.py
Normal file
127
bookmarks/tests/test_favicon_loader.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import io
|
||||||
|
import os.path
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from bookmarks.services import favicon_loader
|
||||||
|
|
||||||
|
mock_icon_data = b'mock_icon'
|
||||||
|
|
||||||
|
|
||||||
|
class FaviconLoaderTestCase(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.ensure_favicon_folder()
|
||||||
|
self.clear_favicon_folder()
|
||||||
|
|
||||||
|
def create_mock_response(self, icon_data=mock_icon_data):
|
||||||
|
mock_response = mock.Mock()
|
||||||
|
mock_response.raw = io.BytesIO(icon_data)
|
||||||
|
return mock_response
|
||||||
|
|
||||||
|
def ensure_favicon_folder(self):
|
||||||
|
Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def clear_favicon_folder(self):
|
||||||
|
folder = Path(settings.LD_FAVICON_FOLDER)
|
||||||
|
for file in folder.iterdir():
|
||||||
|
file.unlink()
|
||||||
|
|
||||||
|
def get_icon_path(self, filename):
|
||||||
|
return Path(os.path.join(settings.LD_FAVICON_FOLDER, filename))
|
||||||
|
|
||||||
|
def icon_exists(self, filename):
|
||||||
|
return self.get_icon_path(filename).exists()
|
||||||
|
|
||||||
|
def get_icon_data(self, filename):
|
||||||
|
return self.get_icon_path(filename).read_bytes()
|
||||||
|
|
||||||
|
def count_icons(self):
|
||||||
|
files = os.listdir(settings.LD_FAVICON_FOLDER)
|
||||||
|
return len(files)
|
||||||
|
|
||||||
|
def test_load_favicon(self):
|
||||||
|
with mock.patch('requests.get') as mock_get:
|
||||||
|
mock_get.return_value = self.create_mock_response()
|
||||||
|
favicon_loader.load_favicon('https://example.com')
|
||||||
|
|
||||||
|
# should create icon file
|
||||||
|
self.assertTrue(self.icon_exists('https_example_com.png'))
|
||||||
|
|
||||||
|
# should store image data
|
||||||
|
self.assertEqual(mock_icon_data, self.get_icon_data('https_example_com.png'))
|
||||||
|
|
||||||
|
def test_load_favicon_creates_folder_if_not_exists(self):
|
||||||
|
with mock.patch('requests.get') as mock_get:
|
||||||
|
mock_get.return_value = self.create_mock_response()
|
||||||
|
|
||||||
|
folder = Path(settings.LD_FAVICON_FOLDER)
|
||||||
|
folder.rmdir()
|
||||||
|
|
||||||
|
self.assertFalse(folder.exists())
|
||||||
|
|
||||||
|
favicon_loader.load_favicon('https://example.com')
|
||||||
|
|
||||||
|
self.assertTrue(folder.exists())
|
||||||
|
|
||||||
|
def test_load_favicon_creates_single_icon_for_same_base_url(self):
|
||||||
|
with mock.patch('requests.get') as mock_get:
|
||||||
|
mock_get.return_value = self.create_mock_response()
|
||||||
|
favicon_loader.load_favicon('https://example.com')
|
||||||
|
favicon_loader.load_favicon('https://example.com?foo=bar')
|
||||||
|
favicon_loader.load_favicon('https://example.com/foo')
|
||||||
|
|
||||||
|
self.assertEqual(1, self.count_icons())
|
||||||
|
self.assertTrue(self.icon_exists('https_example_com.png'))
|
||||||
|
|
||||||
|
def test_load_favicon_creates_multiple_icons_for_different_base_url(self):
|
||||||
|
with mock.patch('requests.get') as mock_get:
|
||||||
|
mock_get.return_value = self.create_mock_response()
|
||||||
|
favicon_loader.load_favicon('https://example.com')
|
||||||
|
favicon_loader.load_favicon('https://sub.example.com')
|
||||||
|
favicon_loader.load_favicon('https://other-domain.com')
|
||||||
|
|
||||||
|
self.assertEqual(3, self.count_icons())
|
||||||
|
self.assertTrue(self.icon_exists('https_example_com.png'))
|
||||||
|
self.assertTrue(self.icon_exists('https_sub_example_com.png'))
|
||||||
|
self.assertTrue(self.icon_exists('https_other_domain_com.png'))
|
||||||
|
|
||||||
|
def test_load_favicon_caches_icons(self):
|
||||||
|
with mock.patch('requests.get') as mock_get:
|
||||||
|
mock_get.return_value = self.create_mock_response()
|
||||||
|
|
||||||
|
favicon_loader.load_favicon('https://example.com')
|
||||||
|
mock_get.assert_called()
|
||||||
|
|
||||||
|
mock_get.reset_mock()
|
||||||
|
favicon_loader.load_favicon('https://example.com')
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
|
||||||
|
def test_load_favicon_updates_stale_icon(self):
|
||||||
|
with mock.patch('requests.get') as mock_get:
|
||||||
|
mock_get.return_value = self.create_mock_response()
|
||||||
|
favicon_loader.load_favicon('https://example.com')
|
||||||
|
|
||||||
|
icon_path = self.get_icon_path('https_example_com.png')
|
||||||
|
|
||||||
|
updated_mock_icon_data = b'updated_mock_icon'
|
||||||
|
mock_get.return_value = self.create_mock_response(icon_data=updated_mock_icon_data)
|
||||||
|
mock_get.reset_mock()
|
||||||
|
|
||||||
|
# change icon modification date so it is not stale yet
|
||||||
|
nearly_one_day_ago = time.time() - 60 * 60 * 23
|
||||||
|
os.utime(icon_path.absolute(), (nearly_one_day_ago, nearly_one_day_ago))
|
||||||
|
|
||||||
|
favicon_loader.load_favicon('https://example.com')
|
||||||
|
mock_get.assert_not_called()
|
||||||
|
|
||||||
|
# change icon modification date so it is considered stale
|
||||||
|
one_day_ago = time.time() - 60 * 60 * 24
|
||||||
|
os.utime(icon_path.absolute(), (one_day_ago, one_day_ago))
|
||||||
|
|
||||||
|
favicon_loader.load_favicon('https://example.com')
|
||||||
|
mock_get.assert_called()
|
||||||
|
self.assertEqual(updated_mock_icon_data, self.get_icon_data('https_example_com.png'))
|
36
bookmarks/tests/test_health_view.py
Normal file
36
bookmarks/tests/test_health_view.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.db import connections
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from bookmarks.views.settings import app_version
|
||||||
|
|
||||||
|
|
||||||
|
class HealthViewTestCase(TestCase):
|
||||||
|
|
||||||
|
def test_health_healthy(self):
|
||||||
|
response = self.client.get("/health")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response_body = response.json()
|
||||||
|
expected_body = {
|
||||||
|
'version': app_version,
|
||||||
|
'status': 'healthy'
|
||||||
|
}
|
||||||
|
self.assertDictEqual(response_body, expected_body)
|
||||||
|
|
||||||
|
def test_health_unhealhty(self):
|
||||||
|
with patch.object(connections['default'], 'ensure_connection') as mock_ensure_connection:
|
||||||
|
mock_ensure_connection.side_effect = Exception('Connection error')
|
||||||
|
|
||||||
|
response = self.client.get("/health")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 500)
|
||||||
|
|
||||||
|
response_body = response.json()
|
||||||
|
expected_body = {
|
||||||
|
'version': app_version,
|
||||||
|
'status': 'unhealthy'
|
||||||
|
}
|
||||||
|
self.assertDictEqual(response_body, expected_body)
|
@@ -262,3 +262,12 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
import_netscape_html(test_html, user)
|
import_netscape_html(test_html, user)
|
||||||
|
|
||||||
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user)
|
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user)
|
||||||
|
|
||||||
|
def test_schedule_favicon_loading(self):
|
||||||
|
user = self.get_or_create_test_user()
|
||||||
|
test_html = self.render_html(tags_html='')
|
||||||
|
|
||||||
|
with patch.object(tasks, 'schedule_bookmarks_without_favicons') as mock_schedule_bookmarks_without_favicons:
|
||||||
|
import_netscape_html(test_html, user)
|
||||||
|
|
||||||
|
mock_schedule_bookmarks_without_favicons.assert_called_once_with(user)
|
||||||
|
@@ -1,12 +1,13 @@
|
|||||||
import random
|
import random
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.urls import reverse
|
|
||||||
from unittest.mock import patch, Mock
|
from unittest.mock import patch, Mock
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
from django.urls import reverse
|
||||||
from requests import RequestException
|
from requests import RequestException
|
||||||
|
|
||||||
from bookmarks.models import UserProfile
|
from bookmarks.models import UserProfile
|
||||||
|
from bookmarks.services import tasks
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
from bookmarks.views.settings import app_version, get_version_info
|
from bookmarks.views.settings import app_version, get_version_info
|
||||||
|
|
||||||
@@ -17,6 +18,20 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
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 create_profile_form_data(self, overrides=None):
|
||||||
|
if not overrides:
|
||||||
|
overrides = {}
|
||||||
|
form_data = {
|
||||||
|
'theme': UserProfile.THEME_AUTO,
|
||||||
|
'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
|
||||||
|
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_BLANK,
|
||||||
|
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED,
|
||||||
|
'enable_sharing': False,
|
||||||
|
'enable_favicons': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {**form_data, **overrides}
|
||||||
|
|
||||||
def test_should_render_successfully(self):
|
def test_should_render_successfully(self):
|
||||||
response = self.client.get(reverse('bookmarks:settings.general'))
|
response = self.client.get(reverse('bookmarks:settings.general'))
|
||||||
|
|
||||||
@@ -28,15 +43,18 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.general'))
|
self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.general'))
|
||||||
|
|
||||||
def test_should_save_profile(self):
|
def test_update_profile(self):
|
||||||
form_data = {
|
form_data = {
|
||||||
|
'update_profile': '',
|
||||||
'theme': UserProfile.THEME_DARK,
|
'theme': UserProfile.THEME_DARK,
|
||||||
'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,
|
'enable_sharing': True,
|
||||||
|
'enable_favicons': True,
|
||||||
}
|
}
|
||||||
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
|
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
self.user.profile.refresh_from_db()
|
self.user.profile.refresh_from_db()
|
||||||
|
|
||||||
@@ -46,6 +64,118 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
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'])
|
self.assertEqual(self.user.profile.enable_sharing, form_data['enable_sharing'])
|
||||||
|
self.assertEqual(self.user.profile.enable_favicons, form_data['enable_favicons'])
|
||||||
|
self.assertInHTML('''
|
||||||
|
<p class="form-input-hint">Profile updated</p>
|
||||||
|
''', html)
|
||||||
|
|
||||||
|
def test_update_profile_should_not_be_called_without_respective_form_action(self):
|
||||||
|
form_data = {
|
||||||
|
'theme': UserProfile.THEME_DARK,
|
||||||
|
}
|
||||||
|
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.user.profile.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(self.user.profile.theme, UserProfile.THEME_AUTO)
|
||||||
|
self.assertInHTML('''
|
||||||
|
<p class="form-input-hint">Profile updated</p>
|
||||||
|
''', html, count=0)
|
||||||
|
|
||||||
|
def test_enable_favicons_should_schedule_icon_update(self):
|
||||||
|
with patch.object(tasks, 'schedule_bookmarks_without_favicons') as mock_schedule_bookmarks_without_favicons:
|
||||||
|
# Enabling favicons schedules update
|
||||||
|
form_data = self.create_profile_form_data({
|
||||||
|
'update_profile': '',
|
||||||
|
'enable_favicons': True,
|
||||||
|
})
|
||||||
|
self.client.post(reverse('bookmarks:settings.general'), form_data)
|
||||||
|
|
||||||
|
mock_schedule_bookmarks_without_favicons.assert_called_once_with(self.user)
|
||||||
|
|
||||||
|
# No update scheduled if favicons are already enabled
|
||||||
|
mock_schedule_bookmarks_without_favicons.reset_mock()
|
||||||
|
|
||||||
|
self.client.post(reverse('bookmarks:settings.general'), form_data)
|
||||||
|
|
||||||
|
mock_schedule_bookmarks_without_favicons.assert_not_called()
|
||||||
|
|
||||||
|
# No update scheduled when disabling favicons
|
||||||
|
form_data = self.create_profile_form_data({
|
||||||
|
'enable_favicons': False,
|
||||||
|
})
|
||||||
|
|
||||||
|
self.client.post(reverse('bookmarks:settings.general'), form_data)
|
||||||
|
|
||||||
|
mock_schedule_bookmarks_without_favicons.assert_not_called()
|
||||||
|
|
||||||
|
def test_refresh_favicons(self):
|
||||||
|
with patch.object(tasks, 'schedule_refresh_favicons') as mock_schedule_refresh_favicons:
|
||||||
|
form_data = {
|
||||||
|
'refresh_favicons': '',
|
||||||
|
}
|
||||||
|
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
mock_schedule_refresh_favicons.assert_called_once()
|
||||||
|
self.assertInHTML('''
|
||||||
|
<p class="form-input-hint">
|
||||||
|
Scheduled favicon update. This may take a while...
|
||||||
|
</p>
|
||||||
|
''', html)
|
||||||
|
|
||||||
|
def test_refresh_favicons_should_not_be_called_without_respective_form_action(self):
|
||||||
|
with patch.object(tasks, 'schedule_refresh_favicons') as mock_schedule_refresh_favicons:
|
||||||
|
form_data = {
|
||||||
|
}
|
||||||
|
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
mock_schedule_refresh_favicons.assert_not_called()
|
||||||
|
self.assertInHTML('''
|
||||||
|
<p class="form-input-hint">
|
||||||
|
Scheduled favicon update. This may take a while...
|
||||||
|
</p>
|
||||||
|
''', html, count=0)
|
||||||
|
|
||||||
|
def test_refresh_favicons_should_be_visible_when_favicons_enabled_in_profile(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_favicons = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse('bookmarks:settings.general'))
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML('''
|
||||||
|
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
|
||||||
|
''', html, count=1)
|
||||||
|
|
||||||
|
def test_refresh_favicons_should_not_be_visible_when_favicons_disabled_in_profile(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_favicons = False
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse('bookmarks:settings.general'))
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML('''
|
||||||
|
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
|
||||||
|
''', html, count=0)
|
||||||
|
|
||||||
|
@override_settings(LD_ENABLE_REFRESH_FAVICONS=False)
|
||||||
|
def test_refresh_favicons_should_not_be_visible_when_disabled(self):
|
||||||
|
profile = self.get_or_create_test_user().profile
|
||||||
|
profile.enable_favicons = True
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse('bookmarks:settings.general'))
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML('''
|
||||||
|
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
|
||||||
|
''', html, count=0)
|
||||||
|
|
||||||
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'))
|
||||||
|
@@ -25,6 +25,10 @@ class MockStreamingResponse:
|
|||||||
|
|
||||||
|
|
||||||
class WebsiteLoaderTestCase(TestCase):
|
class WebsiteLoaderTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
# clear cached metadata before test run
|
||||||
|
website_loader.load_website_metadata.cache_clear()
|
||||||
|
|
||||||
def render_html_document(self, title, description):
|
def render_html_document(self, title, description):
|
||||||
return f'''
|
return f'''
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
@@ -31,4 +31,6 @@ urlpatterns = [
|
|||||||
# Feeds
|
# Feeds
|
||||||
path('feeds/<str:feed_key>/all', AllBookmarksFeed(), name='feeds.all'),
|
path('feeds/<str:feed_key>/all', AllBookmarksFeed(), name='feeds.all'),
|
||||||
path('feeds/<str:feed_key>/unread', UnreadBookmarksFeed(), name='feeds.unread'),
|
path('feeds/<str:feed_key>/unread', UnreadBookmarksFeed(), name='feeds.unread'),
|
||||||
|
# Health check
|
||||||
|
path('health', views.health, name='health')
|
||||||
]
|
]
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
from .bookmarks import *
|
from .bookmarks import *
|
||||||
from .settings import *
|
from .settings import *
|
||||||
from .toasts import *
|
from .toasts import *
|
||||||
|
from .health import health
|
||||||
|
20
bookmarks/views/health.py
Normal file
20
bookmarks/views/health.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from django.db import connections
|
||||||
|
from django.http import JsonResponse
|
||||||
|
|
||||||
|
from bookmarks.views.settings import app_version
|
||||||
|
|
||||||
|
|
||||||
|
def health(request):
|
||||||
|
code = 200
|
||||||
|
response = {
|
||||||
|
'version': app_version,
|
||||||
|
'status': 'healthy'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
connections['default'].ensure_connection()
|
||||||
|
except Exception:
|
||||||
|
response['status'] = 'unhealthy'
|
||||||
|
code = 500
|
||||||
|
|
||||||
|
return JsonResponse(response, status=code)
|
@@ -3,6 +3,7 @@ import time
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from django.conf import settings as django_settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.db.models import prefetch_related_objects
|
from django.db.models import prefetch_related_objects
|
||||||
@@ -13,7 +14,7 @@ from rest_framework.authtoken.models import Token
|
|||||||
|
|
||||||
from bookmarks.models import UserProfileForm, FeedToken
|
from bookmarks.models import UserProfileForm, FeedToken
|
||||||
from bookmarks.queries import query_bookmarks
|
from bookmarks.queries import query_bookmarks
|
||||||
from bookmarks.services import exporter
|
from bookmarks.services import exporter, tasks
|
||||||
from bookmarks.services import importer
|
from bookmarks.services import importer
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -28,24 +29,48 @@ except Exception as exc:
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def general(request):
|
def general(request):
|
||||||
if request.method == 'POST':
|
profile_form = None
|
||||||
form = UserProfileForm(request.POST, instance=request.user.profile)
|
enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
|
||||||
if form.is_valid():
|
update_profile_success_message = None
|
||||||
form.save()
|
refresh_favicons_success_message = None
|
||||||
else:
|
|
||||||
form = UserProfileForm(instance=request.user.profile)
|
|
||||||
|
|
||||||
import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success')
|
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')
|
import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors')
|
||||||
version_info = get_version_info(get_ttl_hash())
|
version_info = get_version_info(get_ttl_hash())
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
if 'update_profile' in request.POST:
|
||||||
|
profile_form = update_profile(request)
|
||||||
|
update_profile_success_message = 'Profile updated'
|
||||||
|
if 'refresh_favicons' in request.POST:
|
||||||
|
tasks.schedule_refresh_favicons(request.user)
|
||||||
|
refresh_favicons_success_message = 'Scheduled favicon update. This may take a while...'
|
||||||
|
|
||||||
|
if not profile_form:
|
||||||
|
profile_form = UserProfileForm(instance=request.user.profile)
|
||||||
|
|
||||||
return render(request, 'settings/general.html', {
|
return render(request, 'settings/general.html', {
|
||||||
'form': form,
|
'form': profile_form,
|
||||||
|
'enable_refresh_favicons': enable_refresh_favicons,
|
||||||
|
'update_profile_success_message': update_profile_success_message,
|
||||||
|
'refresh_favicons_success_message': refresh_favicons_success_message,
|
||||||
'import_success_message': import_success_message,
|
'import_success_message': import_success_message,
|
||||||
'import_errors_message': import_errors_message,
|
'import_errors_message': import_errors_message,
|
||||||
'version_info': version_info,
|
'version_info': version_info,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def update_profile(request):
|
||||||
|
user = request.user
|
||||||
|
profile = user.profile
|
||||||
|
favicons_were_enabled = profile.enable_favicons
|
||||||
|
form = UserProfileForm(request.POST, instance=profile)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
if profile.enable_favicons and not favicons_were_enabled:
|
||||||
|
tasks.schedule_bookmarks_without_favicons(request.user)
|
||||||
|
return form
|
||||||
|
|
||||||
|
|
||||||
# Cache API call response, for one hour when using get_ttl_hash with default params
|
# Cache API call response, for one hour when using get_ttl_hash with default params
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_version_info(ttl_hash=None):
|
def get_version_info(ttl_hash=None):
|
||||||
|
@@ -5,6 +5,8 @@ LD_SERVER_PORT="${LD_SERVER_PORT:-9090}"
|
|||||||
|
|
||||||
# Create data folder if it does not exist
|
# Create data folder if it does not exist
|
||||||
mkdir -p data
|
mkdir -p data
|
||||||
|
# Create favicon folder if it does not exist
|
||||||
|
mkdir -p data/favicons
|
||||||
|
|
||||||
# Run database migration
|
# Run database migration
|
||||||
python manage.py migrate
|
python manage.py migrate
|
||||||
|
@@ -149,3 +149,15 @@ Values: `Integer` | Default = None
|
|||||||
|
|
||||||
The port of the database server.
|
The port of the database server.
|
||||||
Should use the default port if left empty, for example `5432` for PostgresSQL.
|
Should use the default port if left empty, for example `5432` for PostgresSQL.
|
||||||
|
|
||||||
|
### `LD_FAVICON_PROVIDER`
|
||||||
|
|
||||||
|
Values: `String` | Default = `https://t1.gstatic.com/faviconV2?url={url}&client=SOCIAL&type=FAVICON`
|
||||||
|
|
||||||
|
The favicon provider used for downloading icons if they are enabled in the user profile settings.
|
||||||
|
The default provider is a Google service that automatically detects the correct favicon for a website, and provides icons in consistent image format (PNG) and in a consistent image size.
|
||||||
|
|
||||||
|
This setting allows to configure a custom provider in form of a URL.
|
||||||
|
When calling the provider with the URL of a website, it must return the image data for the favicon of that website.
|
||||||
|
The configured favicon provider URL must contain a `{url}` placeholder that will be replaced with the URL of the website for which to download the favicon.
|
||||||
|
See the default URL for an example.
|
||||||
|
16
package-lock.json
generated
16
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "linkding",
|
"name": "linkding",
|
||||||
"version": "1.11.1",
|
"version": "1.16.0",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "linkding",
|
"name": "linkding",
|
||||||
"version": "1.11.1",
|
"version": "1.16.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rollup/plugin-commonjs": "^21.0.2",
|
"@rollup/plugin-commonjs": "^21.0.2",
|
||||||
@@ -435,9 +435,9 @@
|
|||||||
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
|
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.0.4",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
|
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^1.1.7"
|
"brace-expansion": "^1.1.7"
|
||||||
},
|
},
|
||||||
@@ -995,9 +995,9 @@
|
|||||||
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
|
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="
|
||||||
},
|
},
|
||||||
"minimatch": {
|
"minimatch": {
|
||||||
"version": "3.0.4",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
|
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"brace-expansion": "^1.1.7"
|
"brace-expansion": "^1.1.7"
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "linkding",
|
"name": "linkding",
|
||||||
"version": "1.16.0",
|
"version": "1.17.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
asgiref==3.5.2
|
asgiref==3.5.2
|
||||||
beautifulsoup4==4.11.1
|
beautifulsoup4==4.11.1
|
||||||
certifi==2022.6.15
|
certifi==2022.12.7
|
||||||
charset-normalizer==2.1.1
|
charset-normalizer==2.1.1
|
||||||
click==8.1.3
|
click==8.1.3
|
||||||
confusable-homoglyphs==3.2.0
|
confusable-homoglyphs==3.2.0
|
||||||
Django==4.1
|
Django==4.1.2
|
||||||
django-generate-secret-key==1.0.2
|
django-generate-secret-key==1.0.2
|
||||||
django-registration==3.3
|
django-registration==3.3
|
||||||
django-sass-processor==1.2.1
|
django-sass-processor==1.2.1
|
||||||
|
@@ -1,11 +1,11 @@
|
|||||||
asgiref==3.5.2
|
asgiref==3.5.2
|
||||||
beautifulsoup4==4.11.1
|
beautifulsoup4==4.11.1
|
||||||
certifi==2022.6.15
|
certifi==2022.12.7
|
||||||
charset-normalizer==2.1.1
|
charset-normalizer==2.1.1
|
||||||
click==8.1.3
|
click==8.1.3
|
||||||
confusable-homoglyphs==3.2.0
|
confusable-homoglyphs==3.2.0
|
||||||
coverage==5.5
|
coverage==5.5
|
||||||
Django==4.1
|
Django==4.1.2
|
||||||
django-appconf==1.0.5
|
django-appconf==1.0.5
|
||||||
django-compressor==4.1
|
django-compressor==4.1
|
||||||
django-debug-toolbar==3.6.0
|
django-debug-toolbar==3.6.0
|
||||||
@@ -15,9 +15,12 @@ django-sass-processor==1.2.1
|
|||||||
django-widget-tweaks==1.4.12
|
django-widget-tweaks==1.4.12
|
||||||
django4-background-tasks==1.2.7
|
django4-background-tasks==1.2.7
|
||||||
djangorestframework==3.13.1
|
djangorestframework==3.13.1
|
||||||
|
greenlet==2.0.1
|
||||||
idna==3.3
|
idna==3.3
|
||||||
libsass==0.21.0
|
libsass==0.21.0
|
||||||
|
playwright==1.29.1
|
||||||
psycopg2-binary==2.9.5
|
psycopg2-binary==2.9.5
|
||||||
|
pyee==9.0.4
|
||||||
python-dateutil==2.8.2
|
python-dateutil==2.8.2
|
||||||
pytz==2022.2.1
|
pytz==2022.2.1
|
||||||
rcssmin==1.1.0
|
rcssmin==1.1.0
|
||||||
|
@@ -140,6 +140,7 @@ STATICFILES_FINDERS = [
|
|||||||
# Enable SASS processor to find custom folder for SCSS sources through static file finders
|
# Enable SASS processor to find custom folder for SCSS sources through static file finders
|
||||||
STATICFILES_DIRS = [
|
STATICFILES_DIRS = [
|
||||||
os.path.join(BASE_DIR, 'bookmarks', 'styles'),
|
os.path.join(BASE_DIR, 'bookmarks', 'styles'),
|
||||||
|
os.path.join(BASE_DIR, 'data', 'favicons'),
|
||||||
]
|
]
|
||||||
|
|
||||||
# REST framework
|
# REST framework
|
||||||
@@ -222,3 +223,9 @@ else:
|
|||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': default_database
|
'default': default_database
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Favicons
|
||||||
|
LD_DEFAULT_FAVICON_PROVIDER = 'https://t1.gstatic.com/faviconV2?url={url}&client=SOCIAL&type=FAVICON'
|
||||||
|
LD_FAVICON_PROVIDER = os.getenv('LD_FAVICON_PROVIDER', LD_DEFAULT_FAVICON_PROVIDER)
|
||||||
|
LD_FAVICON_FOLDER = os.path.join(BASE_DIR, 'data', 'favicons')
|
||||||
|
LD_ENABLE_REFRESH_FAVICONS = os.getenv('LD_ENABLE_REFRESH_FAVICONS', True) in (True, 'True', '1')
|
||||||
|
@@ -25,7 +25,7 @@ LOGGING = {
|
|||||||
'disable_existing_loggers': False,
|
'disable_existing_loggers': False,
|
||||||
'formatters': {
|
'formatters': {
|
||||||
'simple': {
|
'simple': {
|
||||||
'format': '{levelname} {message}',
|
'format': '{levelname} {asctime} {module}: {message}',
|
||||||
'style': '{',
|
'style': '{',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
[uwsgi]
|
[uwsgi]
|
||||||
chdir = /etc/linkding
|
|
||||||
module = siteroot.wsgi:application
|
module = siteroot.wsgi:application
|
||||||
env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod
|
env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod
|
||||||
static-map = /static=static
|
static-map = /static=static
|
||||||
|
static-map = /static=data/favicons
|
||||||
processes = 2
|
processes = 2
|
||||||
threads = 2
|
threads = 2
|
||||||
pidfile = /tmp/linkding.pid
|
pidfile = /tmp/linkding.pid
|
||||||
@@ -15,6 +15,7 @@ die-on-term = true
|
|||||||
|
|
||||||
if-env = LD_CONTEXT_PATH
|
if-env = LD_CONTEXT_PATH
|
||||||
static-map = /%(_)static=static
|
static-map = /%(_)static=static
|
||||||
|
static-map = /%(_)static=data/favicons
|
||||||
endif =
|
endif =
|
||||||
|
|
||||||
if-env = LD_REQUEST_TIMEOUT
|
if-env = LD_REQUEST_TIMEOUT
|
||||||
|
@@ -1 +1 @@
|
|||||||
1.16.0
|
1.17.1
|
||||||
|
Reference in New Issue
Block a user