mirror of
https://github.com/sissbruecker/linkding
synced 2024-11-22 03:13:02 +00:00
Add option for showing bookmark favicons (#390)
* Implement favicon loader * Implement load favicon task * Show favicons in bookmark list * Add missing migration * Load missing favicons on import * Automatically refresh favicons * Add enable favicon setting * Update uwsgi config to host favicons * Improve settings wording * Fix favicon loader test setup * Document LD_FAVICON_PROVIDER setting * Add refresh favicons button
This commit is contained in:
parent
4cb39fae99
commit
814401be2e
22 changed files with 786 additions and 47 deletions
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_description = models.TextField(blank=True, null=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)
|
||||
is_archived = 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,
|
||||
default=WEB_ARCHIVE_INTEGRATION_DISABLED)
|
||||
enable_sharing = models.BooleanField(default=False, null=False)
|
||||
enable_favicons = models.BooleanField(default=False, null=False)
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'enable_sharing']
|
||||
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'enable_sharing', 'enable_favicons']
|
||||
|
||||
|
||||
@receiver(post_save, sender=get_user_model())
|
||||
|
|
|
@ -30,6 +30,8 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
|||
bookmark.save()
|
||||
# Create snapshot on web archive
|
||||
tasks.create_web_archive_snapshot(current_user, bookmark, False)
|
||||
# Load favicon
|
||||
tasks.load_favicon(current_user, bookmark)
|
||||
|
||||
return bookmark
|
||||
|
||||
|
@ -43,6 +45,9 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
|||
# Update dates
|
||||
bookmark.date_modified = timezone.now()
|
||||
bookmark.save()
|
||||
# Update favicon
|
||||
tasks.load_favicon(current_user, bookmark)
|
||||
|
||||
if has_url_changed:
|
||||
# Update web archive snapshot, if URL changed
|
||||
tasks.create_web_archive_snapshot(current_user, bookmark, True)
|
||||
|
|
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
|
||||
tasks.schedule_bookmarks_without_snapshots(user)
|
||||
# Load favicons for newly imported bookmarks
|
||||
tasks.schedule_bookmarks_without_favicons(user)
|
||||
|
||||
end = timezone.now()
|
||||
logger.debug(f'Import duration: {end - import_start}')
|
||||
|
|
|
@ -2,6 +2,7 @@ import logging
|
|||
|
||||
import waybackpy
|
||||
from background_task import background
|
||||
from background_task.models import Task
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
|
@ -10,6 +11,7 @@ from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecord
|
|||
import bookmarks.services.wayback
|
||||
from bookmarks.models import Bookmark, UserProfile
|
||||
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
|
||||
from bookmarks.services import favicon_loader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -72,7 +74,8 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
|
|||
logger.error(
|
||||
f'Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}')
|
||||
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_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
|
||||
# new ones when processing bookmarks in bulk
|
||||
_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()
|
||||
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)
|
||||
|
|
|
@ -58,6 +58,12 @@ ul.bookmark-list {
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.title img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: $gray-color-dark;
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
{% load static %}
|
||||
{% load shared %}
|
||||
{% load pagination %}
|
||||
{% htmlmin %}
|
||||
|
@ -11,6 +12,9 @@
|
|||
<div class="title">
|
||||
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
|
||||
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 }}
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -36,6 +36,30 @@
|
|||
Whether to open bookmarks a new page or in the same page.
|
||||
</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">
|
||||
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
|
||||
integration</label>
|
||||
|
@ -61,7 +85,14 @@
|
|||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</form>
|
||||
</section>
|
||||
|
|
|
@ -33,6 +33,7 @@ class BookmarkFactoryMixin:
|
|||
website_title: str = '',
|
||||
website_description: str = '',
|
||||
web_archive_snapshot_url: str = '',
|
||||
favicon_file: str = '',
|
||||
):
|
||||
if not title:
|
||||
title = get_random_string(length=32)
|
||||
|
@ -56,6 +57,7 @@ class BookmarkFactoryMixin:
|
|||
unread=unread,
|
||||
shared=shared,
|
||||
web_archive_snapshot_url=web_archive_snapshot_url,
|
||||
favicon_file=favicon_file,
|
||||
)
|
||||
bookmark.save()
|
||||
for tag in tags:
|
||||
|
|
|
@ -79,6 +79,17 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
|||
</span>
|
||||
''', 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:
|
||||
rf = RequestFactory()
|
||||
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>
|
||||
</span>
|
||||
''', 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)
|
||||
|
|
|
@ -67,6 +67,13 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||
|
||||
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):
|
||||
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
|
||||
bookmark = self.setup_bookmark()
|
||||
|
@ -109,6 +116,14 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
|||
|
||||
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):
|
||||
bookmark = Bookmark(
|
||||
url='https://example.com',
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import datetime
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import patch
|
||||
from unittest import mock
|
||||
|
||||
import waybackpy
|
||||
from background_task.models import Task
|
||||
|
@ -8,6 +8,7 @@ from django.contrib.auth.models import User
|
|||
from django.test import TestCase, override_settings
|
||||
from waybackpy.exceptions import WaybackError
|
||||
|
||||
import bookmarks.services.favicon_loader
|
||||
import bookmarks.services.wayback
|
||||
from bookmarks.models import UserProfile
|
||||
from bookmarks.services import tasks
|
||||
|
@ -53,12 +54,14 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||
def setUp(self):
|
||||
user = self.get_or_create_test_user()
|
||||
user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
|
||||
user.profile.enable_favicons = True
|
||||
user.profile.save()
|
||||
|
||||
@disable_logging
|
||||
def run_pending_task(self, task_function):
|
||||
func = getattr(task_function, 'task_function', None)
|
||||
task = Task.objects.all()[0]
|
||||
self.assertEqual(task_function.name, task.task_name)
|
||||
args, kwargs = task.params()
|
||||
func(*args, **kwargs)
|
||||
task.delete()
|
||||
|
@ -69,6 +72,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||
tasks = Task.objects.all()
|
||||
|
||||
for task in tasks:
|
||||
self.assertEqual(task_function.name, task.task_name)
|
||||
args, kwargs = task.params()
|
||||
func(*args, **kwargs)
|
||||
task.delete()
|
||||
|
@ -76,7 +80,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||
def test_create_web_archive_snapshot_should_update_snapshot_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI()):
|
||||
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI()):
|
||||
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()
|
||||
|
@ -84,7 +88,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com/created_snapshot')
|
||||
|
||||
def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self):
|
||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
||||
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
||||
return_value=MockWaybackMachineSaveAPI()) as mock_save_api:
|
||||
tasks._create_web_archive_snapshot_task(123, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
|
@ -94,7 +98,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||
def test_create_web_archive_snapshot_should_skip_if_snapshot_exists(self):
|
||||
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
|
||||
|
||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
||||
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
||||
return_value=MockWaybackMachineSaveAPI()) as 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)
|
||||
|
@ -104,7 +108,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||
def test_create_web_archive_snapshot_should_force_update_snapshot(self):
|
||||
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
|
||||
|
||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
||||
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
||||
return_value=MockWaybackMachineSaveAPI('https://other.com')):
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, True)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
|
@ -115,9 +119,9 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||
def test_create_web_archive_snapshot_should_use_newest_snapshot_as_fallback(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
||||
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
||||
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
|
||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=MockWaybackMachineCDXServerAPI()):
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
|
@ -128,9 +132,9 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||
def test_create_web_archive_snapshot_should_ignore_missing_newest_snapshot(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
||||
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
||||
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
|
||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
|
@ -141,9 +145,9 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||
def test_create_web_archive_snapshot_should_ignore_newest_snapshot_errors(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
||||
with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
|
||||
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
|
||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
|
||||
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
|
||||
self.run_pending_task(tasks._create_web_archive_snapshot_task)
|
||||
|
@ -154,7 +158,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||
def test_load_web_archive_snapshot_should_update_snapshot_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=MockWaybackMachineCDXServerAPI()):
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||
|
@ -163,7 +167,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||
self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url)
|
||||
|
||||
def test_load_web_archive_snapshot_should_handle_missing_bookmark_id(self):
|
||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api:
|
||||
tasks._load_web_archive_snapshot_task(123)
|
||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||
|
@ -173,7 +177,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||
def test_load_web_archive_snapshot_should_skip_if_snapshot_exists(self):
|
||||
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
|
||||
|
||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api:
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||
|
@ -183,7 +187,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||
def test_load_web_archive_snapshot_should_handle_missing_snapshot(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||
|
@ -193,7 +197,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||
def test_load_web_archive_snapshot_should_handle_wayback_errors(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
|
||||
return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
|
||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
||||
self.run_pending_task(tasks._load_web_archive_snapshot_task)
|
||||
|
@ -262,3 +266,157 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||
tasks.schedule_bookmarks_without_snapshots(self.user)
|
||||
|
||||
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()
|
||||
|
||||
@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'))
|
|
@ -262,3 +262,12 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||
import_netscape_html(test_html, 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
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
import requests
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from requests import RequestException
|
||||
|
||||
from bookmarks.models import UserProfile
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
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()
|
||||
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):
|
||||
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'))
|
||||
|
||||
def test_should_save_profile(self):
|
||||
def test_update_profile(self):
|
||||
form_data = {
|
||||
'update_profile': '',
|
||||
'theme': UserProfile.THEME_DARK,
|
||||
'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,
|
||||
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF,
|
||||
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
|
||||
'enable_sharing': True,
|
||||
'enable_favicons': True,
|
||||
}
|
||||
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
|
||||
html = response.content.decode()
|
||||
|
||||
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.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_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):
|
||||
response = self.client.get(reverse('bookmarks:settings.general'))
|
||||
|
|
|
@ -3,6 +3,7 @@ import time
|
|||
from functools import lru_cache
|
||||
|
||||
import requests
|
||||
from django.conf import settings as django_settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
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.queries import query_bookmarks
|
||||
from bookmarks.services import exporter
|
||||
from bookmarks.services import exporter, tasks
|
||||
from bookmarks.services import importer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -28,24 +29,48 @@ except Exception as exc:
|
|||
|
||||
@login_required
|
||||
def general(request):
|
||||
if request.method == 'POST':
|
||||
form = UserProfileForm(request.POST, instance=request.user.profile)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
else:
|
||||
form = UserProfileForm(instance=request.user.profile)
|
||||
|
||||
profile_form = None
|
||||
enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
|
||||
update_profile_success_message = None
|
||||
refresh_favicons_success_message = None
|
||||
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')
|
||||
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', {
|
||||
'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_errors_message': import_errors_message,
|
||||
'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
|
||||
@lru_cache(maxsize=1)
|
||||
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
|
||||
mkdir -p data
|
||||
# Create favicon folder if it does not exist
|
||||
mkdir -p data/favicons
|
||||
|
||||
# Run database migration
|
||||
python manage.py migrate
|
||||
|
|
|
@ -149,3 +149,15 @@ Values: `Integer` | Default = None
|
|||
|
||||
The port of the database server.
|
||||
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.
|
||||
|
|
|
@ -140,6 +140,7 @@ STATICFILES_FINDERS = [
|
|||
# Enable SASS processor to find custom folder for SCSS sources through static file finders
|
||||
STATICFILES_DIRS = [
|
||||
os.path.join(BASE_DIR, 'bookmarks', 'styles'),
|
||||
os.path.join(BASE_DIR, 'data', 'favicons'),
|
||||
]
|
||||
|
||||
# REST framework
|
||||
|
@ -222,3 +223,9 @@ else:
|
|||
DATABASES = {
|
||||
'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')
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
[uwsgi]
|
||||
chdir = /etc/linkding
|
||||
module = siteroot.wsgi:application
|
||||
env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod
|
||||
static-map = /static=static
|
||||
static-map = /static=data/favicons
|
||||
processes = 2
|
||||
threads = 2
|
||||
pidfile = /tmp/linkding.pid
|
||||
|
@ -15,6 +15,7 @@ die-on-term = true
|
|||
|
||||
if-env = LD_CONTEXT_PATH
|
||||
static-map = /%(_)static=static
|
||||
static-map = /%(_)static=data/favicons
|
||||
endif =
|
||||
|
||||
if-env = LD_REQUEST_TIMEOUT
|
||||
|
|
Loading…
Reference in a new issue