Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6484ba8e9 | ||
|
|
4c26d66177 | ||
|
|
c51dcafa40 | ||
|
|
262dd2b28f | ||
|
|
01ad7f4d9e | ||
|
|
d0d5c15345 | ||
|
|
afb752765d | ||
|
|
ce213775b6 | ||
|
|
fd1bbadcf3 | ||
|
|
83c2530df4 | ||
|
|
39782e75e7 | ||
|
|
4bee104b62 | ||
|
|
f4ecffbb7f | ||
|
|
6f52bafda8 | ||
|
|
2deecc5c91 | ||
|
|
54cfa13861 | ||
|
|
ee4f99261f | ||
|
|
d2fa0a8f5a | ||
|
|
02a15c9460 | ||
|
|
7a6428c037 | ||
|
|
c6001aa7b8 | ||
|
|
eefbefd714 | ||
|
|
683cf529d7 | ||
|
|
38204c87cf | ||
|
|
96ee4746ad | ||
|
|
d7c1afa2a5 | ||
|
|
16ed6ef200 | ||
|
|
98b9a9c1a0 |
@@ -14,7 +14,7 @@
|
||||
"forwardPorts": [8000],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "pip3 install --user -r requirements.txt && npm install && mkdir -p data && python3 manage.py migrate",
|
||||
"postCreateCommand": "pip3 install --user -r requirements.txt -r requirements.dev.txt && npm install && mkdir -p data && python3 manage.py migrate",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
!/manage.py
|
||||
!/package.json
|
||||
!/package-lock.json
|
||||
!/requirements.prod.txt
|
||||
!/requirements.dev.txt
|
||||
!/requirements.txt
|
||||
!/rollup.config.js
|
||||
!/rollup.config.mjs
|
||||
!/supervisord.conf
|
||||
!/uwsgi.ini
|
||||
!/version.txt
|
||||
|
||||
32
.github/workflows/main.yaml
vendored
@@ -1,45 +1,51 @@
|
||||
name: linkding CI
|
||||
|
||||
on: [push]
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
unit_tests:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
- name: Install Node dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
- name: Setup Python environment
|
||||
run: pip install -r requirements.txt
|
||||
run: pip install -r requirements.txt -r requirements.dev.txt
|
||||
- name: Run tests
|
||||
run: python manage.py test bookmarks.tests
|
||||
e2e_tests:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
- name: Install Node dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
- name: Setup Python environment
|
||||
run: |
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements.txt -r requirements.dev.txt
|
||||
playwright install chromium
|
||||
- name: Run build
|
||||
run: |
|
||||
|
||||
51
CHANGELOG.md
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## v1.24.1 (16/03/2024)
|
||||
|
||||
### What's Changed
|
||||
* Bump dependencies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/618
|
||||
* Persist secret key in data folder by @sissbruecker in https://github.com/sissbruecker/linkding/pull/620
|
||||
* Group ideographic characters in tag cloud by @jonathan-s in https://github.com/sissbruecker/linkding/pull/613
|
||||
* Bump django from 5.0.1 to 5.0.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/625
|
||||
* Add k8s setup to community section by @jzck in https://github.com/sissbruecker/linkding/pull/633
|
||||
* Added a new Linkding client to community section by @JGeek00 in https://github.com/sissbruecker/linkding/pull/638
|
||||
|
||||
### New Contributors
|
||||
* @jzck made their first contribution in https://github.com/sissbruecker/linkding/pull/633
|
||||
* @JGeek00 made their first contribution in https://github.com/sissbruecker/linkding/pull/638
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.24.0...v1.24.1
|
||||
|
||||
---
|
||||
|
||||
## v1.24.0 (27/01/2024)
|
||||
|
||||
### What's Changed
|
||||
* Support Open Graph description by @jonathan-s in https://github.com/sissbruecker/linkding/pull/602
|
||||
* Add tooltip to truncated bookmark titles by @jonathan-s in https://github.com/sissbruecker/linkding/pull/607
|
||||
* Improve bulk tag performance by @sissbruecker in https://github.com/sissbruecker/linkding/pull/612
|
||||
* Increase tag limit in tag autocomplete by @hypebeast in https://github.com/sissbruecker/linkding/pull/581
|
||||
* Add CapRover as managed hosting option by @adamshand in https://github.com/sissbruecker/linkding/pull/585
|
||||
* Bump playwright dependencies by @jonathan-s in https://github.com/sissbruecker/linkding/pull/601
|
||||
* Adjust archive.org donation link in general.html by @JnsDornbusch in https://github.com/sissbruecker/linkding/pull/603
|
||||
|
||||
### New Contributors
|
||||
* @hypebeast made their first contribution in https://github.com/sissbruecker/linkding/pull/581
|
||||
* @adamshand made their first contribution in https://github.com/sissbruecker/linkding/pull/585
|
||||
* @jonathan-s made their first contribution in https://github.com/sissbruecker/linkding/pull/601
|
||||
* @JnsDornbusch made their first contribution in https://github.com/sissbruecker/linkding/pull/603
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.23.1...v1.24.0
|
||||
|
||||
---
|
||||
|
||||
## v1.23.1 (08/12/2023)
|
||||
|
||||
### What's Changed
|
||||
* Properly encode search query param by @sissbruecker in https://github.com/sissbruecker/linkding/pull/587
|
||||
|
||||
> [!WARNING]
|
||||
> *This resolves a security vulnerability in linkding. Everyone is encouraged to upgrade to the latest version as soon as possible.*
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.23.0...v1.23.1
|
||||
|
||||
---
|
||||
|
||||
## v1.23.0 (24/11/2023)
|
||||
|
||||
### What's Changed
|
||||
|
||||
15
Makefile
Normal file
@@ -0,0 +1,15 @@
|
||||
.PHONY: serve
|
||||
|
||||
serve:
|
||||
python manage.py runserver
|
||||
|
||||
tasks:
|
||||
python manage.py process_tasks
|
||||
|
||||
test:
|
||||
pytest
|
||||
|
||||
format:
|
||||
black bookmarks
|
||||
black siteroot
|
||||
npx prettier bookmarks/frontend --write
|
||||
26
README.md
@@ -40,11 +40,12 @@ The name comes from:
|
||||
- Automatically provides titles, descriptions and icons of bookmarked websites
|
||||
- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/)
|
||||
- Import and export bookmarks in Netscape HTML format
|
||||
- Installable as a Progressive Web App (PWA)
|
||||
- Extensions for [Firefox](https://addons.mozilla.org/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet
|
||||
- Light and dark themes
|
||||
- SSO support via OIDC or authentication proxies
|
||||
- REST API for developing 3rd party apps
|
||||
- Admin panel for user self-service and raw data access
|
||||
- Easy setup using Docker and a SQLite database, with PostgreSQL as an option
|
||||
|
||||
|
||||
**Demo:** https://demo.linkding.link/ ([see here](https://github.com/sissbruecker/linkding/issues/408) if you have trouble accessing it)
|
||||
@@ -212,10 +213,12 @@ This section lists community projects around using linkding, in alphabetical ord
|
||||
- [feed2linkding](https://codeberg.org/strubbl/feed2linkding) A commandline utility to add all web feed item links to linkding via API call. By [Strubbl](https://github.com/Strubbl)
|
||||
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
|
||||
- [iOS Shortcut using API and Tagging](https://gist.github.com/andrewdolphin/a7dff49505e588d940bec55132fab8ad) An iOS shortcut using the Linkding API (no extra logins required) that pulls previously used tags and allows tagging at the time of link creation.
|
||||
- [k8s + s3](https://github.com/jzck/linkding-k8s-s3) - Setup for hosting stateless linkding on k8s with sqlite replicated to s3. By [jzck](https://github.com/jzck)
|
||||
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
|
||||
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
|
||||
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
|
||||
- [linkding-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
|
||||
- [Linkdy](https://github.com/JGeek00/linkdy): An open source mobile and desktop (not yet) client created with Flutter. Available at the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy). By [JGeek00](https://github.com/JGeek00).
|
||||
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
|
||||
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
|
||||
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
|
||||
@@ -256,7 +259,7 @@ source ~/environments/linkding/bin/activate[.csh|.fish]
|
||||
```
|
||||
Within the active environment install the application dependencies from the application folder:
|
||||
```
|
||||
pip3 install -Ur requirements.txt
|
||||
pip3 install -r requirements.txt -r requirements.dev.txt
|
||||
```
|
||||
Install frontend dependencies:
|
||||
```
|
||||
@@ -281,6 +284,20 @@ python3 manage.py runserver
|
||||
```
|
||||
The frontend is now available under http://localhost:8000
|
||||
|
||||
### Tests
|
||||
|
||||
Run all tests with pytest:
|
||||
```
|
||||
pytest
|
||||
```
|
||||
|
||||
### Formatting
|
||||
|
||||
Format Python code with black, and JavaScript code with prettier:
|
||||
```
|
||||
make format
|
||||
```
|
||||
|
||||
### DevContainers
|
||||
|
||||
This repository also supports DevContainers: [](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=git@github.com:sissbruecker/linkding.git)
|
||||
@@ -300,8 +317,3 @@ Start the Django development server with:
|
||||
python3 manage.py runserver
|
||||
```
|
||||
The frontend is now available under http://localhost:8000
|
||||
|
||||
Run all tests with pytest
|
||||
```
|
||||
pytest
|
||||
```
|
||||
|
||||
@@ -14,80 +14,123 @@ from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||
|
||||
|
||||
class LinkdingAdminSite(AdminSite):
|
||||
site_header = 'linkding administration'
|
||||
site_title = 'linkding Admin'
|
||||
site_header = "linkding administration"
|
||||
site_title = "linkding Admin"
|
||||
|
||||
|
||||
class AdminBookmark(admin.ModelAdmin):
|
||||
list_display = ('resolved_title', 'url', 'is_archived', 'owner', 'date_added')
|
||||
search_fields = ('title', 'description', 'website_title', 'website_description', 'url', 'tags__name')
|
||||
list_filter = ('owner__username', 'is_archived', 'unread', 'tags',)
|
||||
ordering = ('-date_added',)
|
||||
actions = ['delete_selected_bookmarks', 'archive_selected_bookmarks', 'unarchive_selected_bookmarks', 'mark_as_read', 'mark_as_unread']
|
||||
list_display = ("resolved_title", "url", "is_archived", "owner", "date_added")
|
||||
search_fields = (
|
||||
"title",
|
||||
"description",
|
||||
"website_title",
|
||||
"website_description",
|
||||
"url",
|
||||
"tags__name",
|
||||
)
|
||||
list_filter = (
|
||||
"owner__username",
|
||||
"is_archived",
|
||||
"unread",
|
||||
"tags",
|
||||
)
|
||||
ordering = ("-date_added",)
|
||||
actions = [
|
||||
"delete_selected_bookmarks",
|
||||
"archive_selected_bookmarks",
|
||||
"unarchive_selected_bookmarks",
|
||||
"mark_as_read",
|
||||
"mark_as_unread",
|
||||
]
|
||||
|
||||
def get_actions(self, request):
|
||||
actions = super().get_actions(request)
|
||||
# Remove default delete action, which gets replaced by delete_selected_bookmarks below
|
||||
# The default action shows a confirmation page which can fail in production when selecting all bookmarks and the
|
||||
# number of objects to delete exceeds the value in DATA_UPLOAD_MAX_NUMBER_FIELDS (1000 by default)
|
||||
del actions['delete_selected']
|
||||
del actions["delete_selected"]
|
||||
return actions
|
||||
|
||||
def delete_selected_bookmarks(self, request, queryset: QuerySet):
|
||||
bookmarks_count = queryset.count()
|
||||
for bookmark in queryset:
|
||||
bookmark.delete()
|
||||
self.message_user(request, ngettext(
|
||||
'%d bookmark was successfully deleted.',
|
||||
'%d bookmarks were successfully deleted.',
|
||||
bookmarks_count,
|
||||
) % bookmarks_count, messages.SUCCESS)
|
||||
self.message_user(
|
||||
request,
|
||||
ngettext(
|
||||
"%d bookmark was successfully deleted.",
|
||||
"%d bookmarks were successfully deleted.",
|
||||
bookmarks_count,
|
||||
)
|
||||
% bookmarks_count,
|
||||
messages.SUCCESS,
|
||||
)
|
||||
|
||||
def archive_selected_bookmarks(self, request, queryset: QuerySet):
|
||||
for bookmark in queryset:
|
||||
archive_bookmark(bookmark)
|
||||
bookmarks_count = queryset.count()
|
||||
self.message_user(request, ngettext(
|
||||
'%d bookmark was successfully archived.',
|
||||
'%d bookmarks were successfully archived.',
|
||||
bookmarks_count,
|
||||
) % bookmarks_count, messages.SUCCESS)
|
||||
self.message_user(
|
||||
request,
|
||||
ngettext(
|
||||
"%d bookmark was successfully archived.",
|
||||
"%d bookmarks were successfully archived.",
|
||||
bookmarks_count,
|
||||
)
|
||||
% bookmarks_count,
|
||||
messages.SUCCESS,
|
||||
)
|
||||
|
||||
def unarchive_selected_bookmarks(self, request, queryset: QuerySet):
|
||||
for bookmark in queryset:
|
||||
unarchive_bookmark(bookmark)
|
||||
bookmarks_count = queryset.count()
|
||||
self.message_user(request, ngettext(
|
||||
'%d bookmark was successfully unarchived.',
|
||||
'%d bookmarks were successfully unarchived.',
|
||||
bookmarks_count,
|
||||
) % bookmarks_count, messages.SUCCESS)
|
||||
self.message_user(
|
||||
request,
|
||||
ngettext(
|
||||
"%d bookmark was successfully unarchived.",
|
||||
"%d bookmarks were successfully unarchived.",
|
||||
bookmarks_count,
|
||||
)
|
||||
% bookmarks_count,
|
||||
messages.SUCCESS,
|
||||
)
|
||||
|
||||
def mark_as_read(self, request, queryset: QuerySet):
|
||||
bookmarks_count = queryset.count()
|
||||
queryset.update(unread=False)
|
||||
self.message_user(request, ngettext(
|
||||
'%d bookmark marked as read.',
|
||||
'%d bookmarks marked as read.',
|
||||
bookmarks_count,
|
||||
) % bookmarks_count, messages.SUCCESS)
|
||||
self.message_user(
|
||||
request,
|
||||
ngettext(
|
||||
"%d bookmark marked as read.",
|
||||
"%d bookmarks marked as read.",
|
||||
bookmarks_count,
|
||||
)
|
||||
% bookmarks_count,
|
||||
messages.SUCCESS,
|
||||
)
|
||||
|
||||
def mark_as_unread(self, request, queryset: QuerySet):
|
||||
bookmarks_count = queryset.count()
|
||||
queryset.update(unread=True)
|
||||
self.message_user(request, ngettext(
|
||||
'%d bookmark marked as unread.',
|
||||
'%d bookmarks marked as unread.',
|
||||
bookmarks_count,
|
||||
) % bookmarks_count, messages.SUCCESS)
|
||||
self.message_user(
|
||||
request,
|
||||
ngettext(
|
||||
"%d bookmark marked as unread.",
|
||||
"%d bookmarks marked as unread.",
|
||||
bookmarks_count,
|
||||
)
|
||||
% bookmarks_count,
|
||||
messages.SUCCESS,
|
||||
)
|
||||
|
||||
|
||||
class AdminTag(admin.ModelAdmin):
|
||||
list_display = ('name', 'bookmarks_count', 'owner', 'date_added')
|
||||
search_fields = ('name', 'owner__username')
|
||||
list_filter = ('owner__username',)
|
||||
ordering = ('-date_added',)
|
||||
actions = ['delete_unused_tags']
|
||||
list_display = ("name", "bookmarks_count", "owner", "date_added")
|
||||
search_fields = ("name", "owner__username")
|
||||
list_filter = ("owner__username",)
|
||||
ordering = ("-date_added",)
|
||||
actions = ["delete_unused_tags"]
|
||||
|
||||
def get_queryset(self, request):
|
||||
queryset = super().get_queryset(request)
|
||||
@@ -97,7 +140,7 @@ class AdminTag(admin.ModelAdmin):
|
||||
def bookmarks_count(self, obj):
|
||||
return obj.bookmarks_count
|
||||
|
||||
bookmarks_count.admin_order_field = 'bookmarks_count'
|
||||
bookmarks_count.admin_order_field = "bookmarks_count"
|
||||
|
||||
def delete_unused_tags(self, request, queryset: QuerySet):
|
||||
unused_tags = queryset.filter(bookmark__isnull=True)
|
||||
@@ -106,23 +149,33 @@ class AdminTag(admin.ModelAdmin):
|
||||
tag.delete()
|
||||
|
||||
if unused_tags_count > 0:
|
||||
self.message_user(request, ngettext(
|
||||
'%d unused tag was successfully deleted.',
|
||||
'%d unused tags were successfully deleted.',
|
||||
unused_tags_count,
|
||||
) % unused_tags_count, messages.SUCCESS)
|
||||
self.message_user(
|
||||
request,
|
||||
ngettext(
|
||||
"%d unused tag was successfully deleted.",
|
||||
"%d unused tags were successfully deleted.",
|
||||
unused_tags_count,
|
||||
)
|
||||
% unused_tags_count,
|
||||
messages.SUCCESS,
|
||||
)
|
||||
else:
|
||||
self.message_user(request, gettext(
|
||||
'There were no unused tags in the selection',
|
||||
), messages.SUCCESS)
|
||||
self.message_user(
|
||||
request,
|
||||
gettext(
|
||||
"There were no unused tags in the selection",
|
||||
),
|
||||
messages.SUCCESS,
|
||||
)
|
||||
|
||||
|
||||
class AdminUserProfileInline(admin.StackedInline):
|
||||
model = UserProfile
|
||||
can_delete = False
|
||||
verbose_name_plural = 'Profile'
|
||||
fk_name = 'user'
|
||||
readonly_fields = ('search_preferences', )
|
||||
verbose_name_plural = "Profile"
|
||||
fk_name = "user"
|
||||
readonly_fields = ("search_preferences",)
|
||||
|
||||
|
||||
class AdminCustomUser(UserAdmin):
|
||||
inlines = (AdminUserProfileInline,)
|
||||
@@ -134,15 +187,15 @@ class AdminCustomUser(UserAdmin):
|
||||
|
||||
|
||||
class AdminToast(admin.ModelAdmin):
|
||||
list_display = ('key', 'message', 'owner', 'acknowledged')
|
||||
search_fields = ('key', 'message')
|
||||
list_filter = ('owner__username',)
|
||||
list_display = ("key", "message", "owner", "acknowledged")
|
||||
search_fields = ("key", "message")
|
||||
list_filter = ("owner__username",)
|
||||
|
||||
|
||||
class AdminFeedToken(admin.ModelAdmin):
|
||||
list_display = ('key', 'user')
|
||||
search_fields = ['key']
|
||||
list_filter = ('user__username',)
|
||||
list_display = ("key", "user")
|
||||
search_fields = ["key"]
|
||||
list_filter = ("user__username",)
|
||||
|
||||
|
||||
linkding_admin_site = LinkdingAdminSite()
|
||||
|
||||
@@ -5,18 +5,28 @@ from rest_framework.response import Response
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer, UserProfileSerializer
|
||||
from bookmarks.api.serializers import (
|
||||
BookmarkSerializer,
|
||||
TagSerializer,
|
||||
UserProfileSerializer,
|
||||
)
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark, website_loader
|
||||
from bookmarks.services.bookmarks import (
|
||||
archive_bookmark,
|
||||
unarchive_bookmark,
|
||||
website_loader,
|
||||
)
|
||||
from bookmarks.services.website_loader import WebsiteMetadata
|
||||
|
||||
|
||||
class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin):
|
||||
class BookmarkViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
):
|
||||
serializer_class = BookmarkSerializer
|
||||
|
||||
def get_permissions(self):
|
||||
@@ -24,7 +34,7 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
# The shared action should still filter bookmarks so that
|
||||
# unauthenticated users only see bookmarks from users that have public
|
||||
# sharing explicitly enabled
|
||||
if self.action == 'shared':
|
||||
if self.action == "shared":
|
||||
return [AllowAny()]
|
||||
|
||||
# Otherwise use default permissions which should require authentication
|
||||
@@ -33,7 +43,7 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
# For list action, use query set that applies search and tag projections
|
||||
if self.action == 'list':
|
||||
if self.action == "list":
|
||||
search = BookmarkSearch.from_request(self.request.GET)
|
||||
return queries.query_bookmarks(user, user.profile, search)
|
||||
|
||||
@@ -41,9 +51,9 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
return Bookmark.objects.all().filter(owner=user)
|
||||
|
||||
def get_serializer_context(self):
|
||||
return {'user': self.request.user}
|
||||
return {"user": self.request.user}
|
||||
|
||||
@action(methods=['get'], detail=False)
|
||||
@action(methods=["get"], detail=False)
|
||||
def archived(self, request):
|
||||
user = request.user
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
@@ -53,51 +63,59 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
data = serializer(page, many=True).data
|
||||
return self.get_paginated_response(data)
|
||||
|
||||
@action(methods=['get'], detail=False)
|
||||
@action(methods=["get"], detail=False)
|
||||
def shared(self, request):
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
user = User.objects.filter(username=search.user).first()
|
||||
public_only = not request.user.is_authenticated
|
||||
query_set = queries.query_shared_bookmarks(user, request.user_profile, search, public_only)
|
||||
query_set = queries.query_shared_bookmarks(
|
||||
user, request.user_profile, search, public_only
|
||||
)
|
||||
page = self.paginate_queryset(query_set)
|
||||
serializer = self.get_serializer_class()
|
||||
data = serializer(page, many=True).data
|
||||
return self.get_paginated_response(data)
|
||||
|
||||
@action(methods=['post'], detail=True)
|
||||
@action(methods=["post"], detail=True)
|
||||
def archive(self, request, pk):
|
||||
bookmark = self.get_object()
|
||||
archive_bookmark(bookmark)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(methods=['post'], detail=True)
|
||||
@action(methods=["post"], detail=True)
|
||||
def unarchive(self, request, pk):
|
||||
bookmark = self.get_object()
|
||||
unarchive_bookmark(bookmark)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(methods=['get'], detail=False)
|
||||
@action(methods=["get"], detail=False)
|
||||
def check(self, request):
|
||||
url = request.GET.get('url')
|
||||
url = request.GET.get("url")
|
||||
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
||||
existing_bookmark_data = self.get_serializer(bookmark).data if bookmark else None
|
||||
existing_bookmark_data = (
|
||||
self.get_serializer(bookmark).data if bookmark else None
|
||||
)
|
||||
|
||||
# Either return metadata from existing bookmark, or scrape from URL
|
||||
if bookmark:
|
||||
metadata = WebsiteMetadata(url, bookmark.website_title, bookmark.website_description)
|
||||
metadata = WebsiteMetadata(
|
||||
url, bookmark.website_title, bookmark.website_description
|
||||
)
|
||||
else:
|
||||
metadata = website_loader.load_website_metadata(url)
|
||||
|
||||
return Response({
|
||||
'bookmark': existing_bookmark_data,
|
||||
'metadata': metadata.to_dict()
|
||||
}, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
{"bookmark": existing_bookmark_data, "metadata": metadata.to_dict()},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class TagViewSet(viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin):
|
||||
class TagViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
):
|
||||
serializer_class = TagSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -105,16 +123,16 @@ class TagViewSet(viewsets.GenericViewSet,
|
||||
return Tag.objects.all().filter(owner=user)
|
||||
|
||||
def get_serializer_context(self):
|
||||
return {'user': self.request.user}
|
||||
return {"user": self.request.user}
|
||||
|
||||
|
||||
class UserViewSet(viewsets.GenericViewSet):
|
||||
@action(methods=['get'], detail=False)
|
||||
@action(methods=["get"], detail=False)
|
||||
def profile(self, request):
|
||||
return Response(UserProfileSerializer(request.user.profile).data)
|
||||
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'bookmarks', BookmarkViewSet, basename='bookmark')
|
||||
router.register(r'tags', TagViewSet, basename='tag')
|
||||
router.register(r'user', UserViewSet, basename='user')
|
||||
router.register(r"bookmarks", BookmarkViewSet, basename="bookmark")
|
||||
router.register(r"tags", TagViewSet, basename="tag")
|
||||
router.register(r"user", UserViewSet, basename="user")
|
||||
|
||||
@@ -14,7 +14,7 @@ class TagListField(serializers.ListField):
|
||||
class BookmarkListSerializer(ListSerializer):
|
||||
def to_representation(self, data):
|
||||
# Prefetch nested relations to avoid n+1 queries
|
||||
prefetch_related_objects(data, 'tags')
|
||||
prefetch_related_objects(data, "tags")
|
||||
|
||||
return super().to_representation(data)
|
||||
|
||||
@@ -23,32 +23,34 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = [
|
||||
'id',
|
||||
'url',
|
||||
'title',
|
||||
'description',
|
||||
'notes',
|
||||
'website_title',
|
||||
'website_description',
|
||||
'is_archived',
|
||||
'unread',
|
||||
'shared',
|
||||
'tag_names',
|
||||
'date_added',
|
||||
'date_modified'
|
||||
"id",
|
||||
"url",
|
||||
"title",
|
||||
"description",
|
||||
"notes",
|
||||
"website_title",
|
||||
"website_description",
|
||||
"web_archive_snapshot_url",
|
||||
"is_archived",
|
||||
"unread",
|
||||
"shared",
|
||||
"tag_names",
|
||||
"date_added",
|
||||
"date_modified",
|
||||
]
|
||||
read_only_fields = [
|
||||
'website_title',
|
||||
'website_description',
|
||||
'date_added',
|
||||
'date_modified'
|
||||
"website_title",
|
||||
"website_description",
|
||||
"web_archive_snapshot_url",
|
||||
"date_added",
|
||||
"date_modified",
|
||||
]
|
||||
list_serializer_class = BookmarkListSerializer
|
||||
|
||||
# Override optional char fields to provide default value
|
||||
title = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
description = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
notes = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
title = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
description = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
notes = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
is_archived = serializers.BooleanField(required=False, default=False)
|
||||
unread = serializers.BooleanField(required=False, default=False)
|
||||
shared = serializers.BooleanField(required=False, default=False)
|
||||
@@ -57,38 +59,38 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
bookmark = Bookmark()
|
||||
bookmark.url = validated_data['url']
|
||||
bookmark.title = validated_data['title']
|
||||
bookmark.description = validated_data['description']
|
||||
bookmark.notes = validated_data['notes']
|
||||
bookmark.is_archived = validated_data['is_archived']
|
||||
bookmark.unread = validated_data['unread']
|
||||
bookmark.shared = validated_data['shared']
|
||||
tag_string = build_tag_string(validated_data['tag_names'])
|
||||
return create_bookmark(bookmark, tag_string, self.context['user'])
|
||||
bookmark.url = validated_data["url"]
|
||||
bookmark.title = validated_data["title"]
|
||||
bookmark.description = validated_data["description"]
|
||||
bookmark.notes = validated_data["notes"]
|
||||
bookmark.is_archived = validated_data["is_archived"]
|
||||
bookmark.unread = validated_data["unread"]
|
||||
bookmark.shared = validated_data["shared"]
|
||||
tag_string = build_tag_string(validated_data["tag_names"])
|
||||
return create_bookmark(bookmark, tag_string, self.context["user"])
|
||||
|
||||
def update(self, instance: Bookmark, validated_data):
|
||||
# Update fields if they were provided in the payload
|
||||
for key in ['url', 'title', 'description', 'notes', 'unread', 'shared']:
|
||||
for key in ["url", "title", "description", "notes", "unread", "shared"]:
|
||||
if key in validated_data:
|
||||
setattr(instance, key, validated_data[key])
|
||||
|
||||
# Use tag string from payload, or use bookmark's current tags as fallback
|
||||
tag_string = build_tag_string(instance.tag_names)
|
||||
if 'tag_names' in validated_data:
|
||||
tag_string = build_tag_string(validated_data['tag_names'])
|
||||
if "tag_names" in validated_data:
|
||||
tag_string = build_tag_string(validated_data["tag_names"])
|
||||
|
||||
return update_bookmark(instance, tag_string, self.context['user'])
|
||||
return update_bookmark(instance, tag_string, self.context["user"])
|
||||
|
||||
|
||||
class TagSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = ['id', 'name', 'date_added']
|
||||
read_only_fields = ['date_added']
|
||||
fields = ["id", "name", "date_added"]
|
||||
read_only_fields = ["date_added"]
|
||||
|
||||
def create(self, validated_data):
|
||||
return get_or_create_tag(validated_data['name'], self.context['user'])
|
||||
return get_or_create_tag(validated_data["name"], self.context["user"])
|
||||
|
||||
|
||||
class UserProfileSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -2,7 +2,7 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class BookmarksConfig(AppConfig):
|
||||
name = 'bookmarks'
|
||||
name = "bookmarks"
|
||||
|
||||
def ready(self):
|
||||
# Register signal handlers
|
||||
|
||||
@@ -5,28 +5,32 @@ from bookmarks import utils
|
||||
|
||||
def toasts(request):
|
||||
user = request.user
|
||||
toast_messages = Toast.objects.filter(owner=user, acknowledged=False) if user.is_authenticated else []
|
||||
toast_messages = (
|
||||
Toast.objects.filter(owner=user, acknowledged=False)
|
||||
if user.is_authenticated
|
||||
else []
|
||||
)
|
||||
has_toasts = len(toast_messages) > 0
|
||||
|
||||
return {
|
||||
'has_toasts': has_toasts,
|
||||
'toast_messages': toast_messages,
|
||||
"has_toasts": has_toasts,
|
||||
"toast_messages": toast_messages,
|
||||
}
|
||||
|
||||
|
||||
def public_shares(request):
|
||||
# Only check for public shares for anonymous users
|
||||
if not request.user.is_authenticated:
|
||||
query_set = queries.query_shared_bookmarks(None, request.user_profile, BookmarkSearch(), True)
|
||||
query_set = queries.query_shared_bookmarks(
|
||||
None, request.user_profile, BookmarkSearch(), True
|
||||
)
|
||||
has_public_shares = query_set.count() > 0
|
||||
return {
|
||||
'has_public_shares': has_public_shares,
|
||||
"has_public_shares": has_public_shares,
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def app_version(request):
|
||||
return {
|
||||
'app_version': utils.app_version
|
||||
}
|
||||
return {"app_version": utils.app_version}
|
||||
|
||||
@@ -6,38 +6,54 @@ 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',
|
||||
notes='Existing notes',
|
||||
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)
|
||||
existing_bookmark = self.setup_bookmark(
|
||||
title="Existing title",
|
||||
description="Existing description",
|
||||
notes="Existing notes",
|
||||
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'))
|
||||
page.goto(self.live_server_url + reverse("bookmarks:new"))
|
||||
|
||||
# Enter bookmarked URL
|
||||
page.get_by_label('URL').fill(existing_bookmark.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)
|
||||
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.notes, page.get_by_label('Notes').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())
|
||||
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.notes, page.get_by_label("Notes").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')
|
||||
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)
|
||||
page.get_by_text("This URL is already bookmarked.").wait_for(
|
||||
state="hidden", timeout=2000
|
||||
)
|
||||
|
||||
browser.close()
|
||||
|
||||
@@ -47,21 +63,25 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||
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.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')
|
||||
page.get_by_text("This URL is already bookmarked.").wait_for(state="hidden")
|
||||
|
||||
def test_enter_url_of_existing_bookmark_should_show_notes(self):
|
||||
bookmark = self.setup_bookmark(notes='Existing notes', description='Existing description')
|
||||
bookmark = self.setup_bookmark(
|
||||
notes="Existing notes", description="Existing description"
|
||||
)
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse('bookmarks:new'))
|
||||
page.goto(self.live_server_url + reverse("bookmarks:new"))
|
||||
|
||||
details = page.locator('details.notes')
|
||||
expect(details).not_to_have_attribute('open', value='')
|
||||
details = page.locator("details.notes")
|
||||
expect(details).not_to_have_attribute("open", value="")
|
||||
|
||||
page.get_by_label('URL').fill(bookmark.url)
|
||||
expect(details).to_have_attribute('open', value='')
|
||||
page.get_by_label("URL").fill(bookmark.url)
|
||||
expect(details).to_have_attribute("open", value="")
|
||||
|
||||
@@ -9,15 +9,15 @@ from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
class BookmarkItemE2ETestCase(LinkdingE2ETestCase):
|
||||
@skip("Fails in CI, needs investigation")
|
||||
def test_toggle_notes_should_show_hide_notes(self):
|
||||
bookmark = self.setup_bookmark(notes='Test notes')
|
||||
bookmark = self.setup_bookmark(notes="Test notes")
|
||||
|
||||
with sync_playwright() as p:
|
||||
page = self.open(reverse('bookmarks:index'), p)
|
||||
page = self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
notes = self.locate_bookmark(bookmark.title).locator('.notes')
|
||||
notes = self.locate_bookmark(bookmark.title).locator(".notes")
|
||||
expect(notes).to_be_hidden()
|
||||
|
||||
toggle_notes = page.locator('li button.toggle-notes')
|
||||
toggle_notes = page.locator("li button.toggle-notes")
|
||||
toggle_notes.click()
|
||||
expect(notes).to_be_visible()
|
||||
|
||||
|
||||
@@ -9,100 +9,180 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
def setup_test_data(self):
|
||||
self.setup_numbered_bookmarks(50)
|
||||
self.setup_numbered_bookmarks(50, archived=True)
|
||||
self.setup_numbered_bookmarks(50, prefix='foo')
|
||||
self.setup_numbered_bookmarks(50, archived=True, prefix='foo')
|
||||
self.setup_numbered_bookmarks(50, prefix="foo")
|
||||
self.setup_numbered_bookmarks(50, archived=True, prefix="foo")
|
||||
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=False, title__startswith="Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=True, title__startswith="Archived Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
|
||||
)
|
||||
|
||||
def test_active_bookmarks_bulk_select_across(self):
|
||||
self.setup_test_data()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:index'), p)
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
|
||||
self.assertEqual(0, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
|
||||
self.assertEqual(0, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
|
||||
self.assertEqual(
|
||||
0,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=False, title__startswith="Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=True, title__startswith="Archived Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
0,
|
||||
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
|
||||
)
|
||||
|
||||
def test_archived_bookmarks_bulk_select_across(self):
|
||||
self.setup_test_data()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:archived'), p)
|
||||
self.open(reverse("bookmarks:archived"), p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
|
||||
self.assertEqual(0, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
|
||||
self.assertEqual(0, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=False, title__startswith="Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
0,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=True, title__startswith="Archived Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
0,
|
||||
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
|
||||
)
|
||||
|
||||
def test_active_bookmarks_bulk_select_across_respects_query(self):
|
||||
self.setup_test_data()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:index') + '?q=foo', p)
|
||||
self.open(reverse("bookmarks:index") + "?q=foo", p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
|
||||
self.assertEqual(0, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=False, title__startswith="Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=True, title__startswith="Archived Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
0,
|
||||
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
|
||||
)
|
||||
|
||||
def test_archived_bookmarks_bulk_select_across_respects_query(self):
|
||||
self.setup_test_data()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:archived') + '?q=foo', p)
|
||||
self.open(reverse("bookmarks:archived") + "?q=foo", p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=True, title__startswith='Archived Bookmark').count())
|
||||
self.assertEqual(50, Bookmark.objects.filter(is_archived=False, title__startswith='foo').count())
|
||||
self.assertEqual(0, Bookmark.objects.filter(is_archived=True, title__startswith='foo').count())
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=False, title__startswith="Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(
|
||||
is_archived=True, title__startswith="Archived Bookmark"
|
||||
).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
50,
|
||||
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
0,
|
||||
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
|
||||
)
|
||||
|
||||
def test_select_all_toggles_all_checkboxes(self):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
url = reverse("bookmarks:index")
|
||||
page = self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
|
||||
checkboxes = page.locator('label[ld-bulk-edit-checkbox] input')
|
||||
checkboxes = page.locator("label[ld-bulk-edit-checkbox] input")
|
||||
self.assertEqual(6, checkboxes.count())
|
||||
for i in range(checkboxes.count()):
|
||||
expect(checkboxes.nth(i)).not_to_be_checked()
|
||||
@@ -121,7 +201,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
url = reverse("bookmarks:index")
|
||||
self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
@@ -138,7 +218,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
url = reverse("bookmarks:index")
|
||||
self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
@@ -160,7 +240,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
url = reverse("bookmarks:index")
|
||||
self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
@@ -171,18 +251,22 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
expect(self.locate_bulk_edit_select_across()).to_be_checked()
|
||||
|
||||
# Hide select across by toggling a single bookmark
|
||||
self.locate_bookmark('Bookmark 1').locator('label[ld-bulk-edit-checkbox]').click()
|
||||
self.locate_bookmark("Bookmark 1").locator(
|
||||
"label[ld-bulk-edit-checkbox]"
|
||||
).click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
|
||||
|
||||
# Show select across again, verify it is unchecked
|
||||
self.locate_bookmark('Bookmark 1').locator('label[ld-bulk-edit-checkbox]').click()
|
||||
self.locate_bookmark("Bookmark 1").locator(
|
||||
"label[ld-bulk-edit-checkbox]"
|
||||
).click()
|
||||
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
|
||||
|
||||
def test_execute_resets_all_checkboxes(self):
|
||||
self.setup_numbered_bookmarks(100)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
url = reverse("bookmarks:index")
|
||||
page = self.open(url, p)
|
||||
|
||||
# Select all bookmarks, enable select across
|
||||
@@ -191,18 +275,18 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.locate_bulk_edit_select_across().click()
|
||||
|
||||
# Get reference for bookmark list
|
||||
bookmark_list = page.locator('ul[ld-bookmark-list]')
|
||||
bookmark_list = page.locator("ul[ld-bookmark-list]")
|
||||
|
||||
# Execute bulk action
|
||||
self.select_bulk_action('Mark as unread')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
self.select_bulk_action("Mark as unread")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
|
||||
# Wait until bookmark list is updated (old reference becomes invisible)
|
||||
expect(bookmark_list).not_to_be_visible()
|
||||
|
||||
# Verify bulk edit checkboxes are reset
|
||||
checkboxes = page.locator('label[ld-bulk-edit-checkbox] input')
|
||||
checkboxes = page.locator("label[ld-bulk-edit-checkbox] input")
|
||||
self.assertEqual(31, checkboxes.count())
|
||||
for i in range(checkboxes.count()):
|
||||
expect(checkboxes.nth(i)).not_to_be_checked()
|
||||
@@ -215,18 +299,23 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.setup_numbered_bookmarks(100)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
url = reverse("bookmarks:index")
|
||||
self.open(url, p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
expect(self.locate_bulk_edit_bar().get_by_text('All pages (100 bookmarks)')).to_be_visible()
|
||||
expect(
|
||||
self.locate_bulk_edit_bar().get_by_text("All pages (100 bookmarks)")
|
||||
).to_be_visible()
|
||||
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
|
||||
expect(self.locate_bulk_edit_select_all()).not_to_be_checked()
|
||||
self.locate_bulk_edit_select_all().click()
|
||||
|
||||
expect(self.locate_bulk_edit_bar().get_by_text('All pages (70 bookmarks)')).to_be_visible()
|
||||
expect(
|
||||
self.locate_bulk_edit_bar().get_by_text("All pages (70 bookmarks)")
|
||||
).to_be_visible()
|
||||
|
||||
@@ -16,13 +16,15 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
# verify correct data is loaded on update
|
||||
self.setup_numbered_bookmarks(3, with_tags=True)
|
||||
self.setup_numbered_bookmarks(3, with_tags=True, archived=True)
|
||||
self.setup_numbered_bookmarks(3,
|
||||
shared=True,
|
||||
prefix="Joe's Bookmark",
|
||||
user=self.setup_user(enable_sharing=True))
|
||||
self.setup_numbered_bookmarks(
|
||||
3,
|
||||
shared=True,
|
||||
prefix="Joe's Bookmark",
|
||||
user=self.setup_user(enable_sharing=True),
|
||||
)
|
||||
|
||||
def assertVisibleBookmarks(self, titles: List[str]):
|
||||
bookmark_tags = self.page.locator('li[ld-bookmark-item]')
|
||||
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
||||
expect(bookmark_tags).to_have_count(len(titles))
|
||||
|
||||
for title in titles:
|
||||
@@ -30,7 +32,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
expect(matching_tag).to_be_visible()
|
||||
|
||||
def assertVisibleTags(self, titles: List[str]):
|
||||
tag_tags = self.page.locator('.tag-cloud .unselected-tags a')
|
||||
tag_tags = self.page.locator(".tag-cloud .unselected-tags a")
|
||||
expect(tag_tags).to_have_count(len(titles))
|
||||
|
||||
for title in titles:
|
||||
@@ -38,65 +40,67 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
expect(matching_tag).to_be_visible()
|
||||
|
||||
def test_partial_update_respects_query(self):
|
||||
self.setup_numbered_bookmarks(5, prefix='foo')
|
||||
self.setup_numbered_bookmarks(5, prefix='bar')
|
||||
self.setup_numbered_bookmarks(5, prefix="foo")
|
||||
self.setup_numbered_bookmarks(5, prefix="bar")
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index') + '?q=foo'
|
||||
url = reverse("bookmarks:index") + "?q=foo"
|
||||
self.open(url, p)
|
||||
|
||||
self.assertVisibleBookmarks(['foo 1', 'foo 2', 'foo 3', 'foo 4', 'foo 5'])
|
||||
self.assertVisibleBookmarks(["foo 1", "foo 2", "foo 3", "foo 4", "foo 5"])
|
||||
|
||||
self.locate_bookmark('foo 2').get_by_text('Archive').click()
|
||||
self.assertVisibleBookmarks(['foo 1', 'foo 3', 'foo 4', 'foo 5'])
|
||||
self.locate_bookmark("foo 2").get_by_text("Archive").click()
|
||||
self.assertVisibleBookmarks(["foo 1", "foo 3", "foo 4", "foo 5"])
|
||||
|
||||
def test_partial_update_respects_sort(self):
|
||||
self.setup_numbered_bookmarks(5, prefix='foo')
|
||||
self.setup_numbered_bookmarks(5, prefix="foo")
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index') + '?sort=title_asc'
|
||||
url = reverse("bookmarks:index") + "?sort=title_asc"
|
||||
page = self.open(url, p)
|
||||
|
||||
first_item = page.locator('li[ld-bookmark-item]').first
|
||||
expect(first_item).to_contain_text('foo 1')
|
||||
first_item = page.locator("li[ld-bookmark-item]").first
|
||||
expect(first_item).to_contain_text("foo 1")
|
||||
|
||||
first_item.get_by_text('Archive').click()
|
||||
first_item.get_by_text("Archive").click()
|
||||
|
||||
first_item = page.locator('li[ld-bookmark-item]').first
|
||||
expect(first_item).to_contain_text('foo 2')
|
||||
first_item = page.locator("li[ld-bookmark-item]").first
|
||||
expect(first_item).to_contain_text("foo 2")
|
||||
|
||||
def test_partial_update_respects_page(self):
|
||||
# add a suffix, otherwise 'foo 1' also matches 'foo 10'
|
||||
self.setup_numbered_bookmarks(50, prefix='foo', suffix='-')
|
||||
self.setup_numbered_bookmarks(50, prefix="foo", suffix="-")
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index') + '?q=foo&page=2'
|
||||
url = reverse("bookmarks:index") + "?q=foo&page=2"
|
||||
self.open(url, p)
|
||||
|
||||
# with descending sort, page two has 'foo 1' to 'foo 20'
|
||||
expected_titles = [f'foo {i}-' for i in range(1, 21)]
|
||||
expected_titles = [f"foo {i}-" for i in range(1, 21)]
|
||||
self.assertVisibleBookmarks(expected_titles)
|
||||
|
||||
self.locate_bookmark('foo 20-').get_by_text('Archive').click()
|
||||
self.locate_bookmark("foo 20-").get_by_text("Archive").click()
|
||||
|
||||
expected_titles = [f'foo {i}-' for i in range(1, 20)]
|
||||
expected_titles = [f"foo {i}-" for i in range(1, 20)]
|
||||
self.assertVisibleBookmarks(expected_titles)
|
||||
|
||||
def test_multiple_partial_updates(self):
|
||||
self.setup_numbered_bookmarks(5)
|
||||
|
||||
with sync_playwright() as p:
|
||||
url = reverse('bookmarks:index')
|
||||
url = reverse("bookmarks:index")
|
||||
self.open(url, p)
|
||||
|
||||
self.locate_bookmark('Bookmark 1').get_by_text('Archive').click()
|
||||
self.assertVisibleBookmarks(['Bookmark 2', 'Bookmark 3', 'Bookmark 4', 'Bookmark 5'])
|
||||
self.locate_bookmark("Bookmark 1").get_by_text("Archive").click()
|
||||
self.assertVisibleBookmarks(
|
||||
["Bookmark 2", "Bookmark 3", "Bookmark 4", "Bookmark 5"]
|
||||
)
|
||||
|
||||
self.locate_bookmark('Bookmark 2').get_by_text('Archive').click()
|
||||
self.assertVisibleBookmarks(['Bookmark 3', 'Bookmark 4', 'Bookmark 5'])
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Archive").click()
|
||||
self.assertVisibleBookmarks(["Bookmark 3", "Bookmark 4", "Bookmark 5"])
|
||||
|
||||
self.locate_bookmark('Bookmark 3').get_by_text('Archive').click()
|
||||
self.assertVisibleBookmarks(['Bookmark 4', 'Bookmark 5'])
|
||||
self.locate_bookmark("Bookmark 3").get_by_text("Archive").click()
|
||||
self.assertVisibleBookmarks(["Bookmark 4", "Bookmark 5"])
|
||||
|
||||
self.assertReloads(0)
|
||||
|
||||
@@ -104,185 +108,201 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
|
||||
self.setup_fixture()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:index'), p)
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
self.locate_bookmark('Bookmark 2').get_by_text('Archive').click()
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Archive").click()
|
||||
|
||||
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
|
||||
self.assertVisibleTags(['Tag 1', 'Tag 3'])
|
||||
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
|
||||
self.assertVisibleTags(["Tag 1", "Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_active_bookmarks_partial_update_on_delete(self):
|
||||
self.setup_fixture()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:index'), p)
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
self.locate_bookmark('Bookmark 2').get_by_text('Remove').click()
|
||||
self.locate_bookmark('Bookmark 2').get_by_text('Confirm').click()
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Remove").click()
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
|
||||
self.assertVisibleTags(['Tag 1', 'Tag 3'])
|
||||
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
|
||||
self.assertVisibleTags(["Tag 1", "Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_active_bookmarks_partial_update_on_mark_as_read(self):
|
||||
self.setup_fixture()
|
||||
bookmark2 = self.get_numbered_bookmark('Bookmark 2')
|
||||
bookmark2 = self.get_numbered_bookmark("Bookmark 2")
|
||||
bookmark2.unread = True
|
||||
bookmark2.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:index'), p)
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
expect(self.locate_bookmark('Bookmark 2')).to_have_class('unread')
|
||||
self.locate_bookmark('Bookmark 2').get_by_text('Unread').click()
|
||||
self.locate_bookmark('Bookmark 2').get_by_text('Yes').click()
|
||||
expect(self.locate_bookmark("Bookmark 2")).to_have_class("unread")
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Unread").click()
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Yes").click()
|
||||
|
||||
expect(self.locate_bookmark('Bookmark 2')).not_to_have_class('unread')
|
||||
expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("unread")
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_active_bookmarks_partial_update_on_unshare(self):
|
||||
self.setup_fixture()
|
||||
bookmark2 = self.get_numbered_bookmark('Bookmark 2')
|
||||
bookmark2 = self.get_numbered_bookmark("Bookmark 2")
|
||||
bookmark2.shared = True
|
||||
bookmark2.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:index'), p)
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
expect(self.locate_bookmark('Bookmark 2')).to_have_class('shared')
|
||||
self.locate_bookmark('Bookmark 2').get_by_text('Shared').click()
|
||||
self.locate_bookmark('Bookmark 2').get_by_text('Yes').click()
|
||||
expect(self.locate_bookmark("Bookmark 2")).to_have_class("shared")
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Shared").click()
|
||||
self.locate_bookmark("Bookmark 2").get_by_text("Yes").click()
|
||||
|
||||
expect(self.locate_bookmark('Bookmark 2')).not_to_have_class('shared')
|
||||
expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("shared")
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_active_bookmarks_partial_update_on_bulk_archive(self):
|
||||
self.setup_fixture()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:index'), p)
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
|
||||
self.select_bulk_action('Archive')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
self.locate_bookmark("Bookmark 2").locator(
|
||||
"label[ld-bulk-edit-checkbox]"
|
||||
).click()
|
||||
self.select_bulk_action("Archive")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
|
||||
self.assertVisibleTags(['Tag 1', 'Tag 3'])
|
||||
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
|
||||
self.assertVisibleTags(["Tag 1", "Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_active_bookmarks_partial_update_on_bulk_delete(self):
|
||||
self.setup_fixture()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:index'), p)
|
||||
self.open(reverse("bookmarks:index"), p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark('Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
self.locate_bookmark("Bookmark 2").locator(
|
||||
"label[ld-bulk-edit-checkbox]"
|
||||
).click()
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(['Bookmark 1', 'Bookmark 3'])
|
||||
self.assertVisibleTags(['Tag 1', 'Tag 3'])
|
||||
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
|
||||
self.assertVisibleTags(["Tag 1", "Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_archived_bookmarks_partial_update_on_unarchive(self):
|
||||
self.setup_fixture()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:archived'), p)
|
||||
self.open(reverse("bookmarks:archived"), p)
|
||||
|
||||
self.locate_bookmark('Archived Bookmark 2').get_by_text('Unarchive').click()
|
||||
self.locate_bookmark("Archived Bookmark 2").get_by_text("Unarchive").click()
|
||||
|
||||
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])
|
||||
self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3'])
|
||||
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
|
||||
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_archived_bookmarks_partial_update_on_delete(self):
|
||||
self.setup_fixture()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:archived'), p)
|
||||
self.open(reverse("bookmarks:archived"), p)
|
||||
|
||||
self.locate_bookmark('Archived Bookmark 2').get_by_text('Remove').click()
|
||||
self.locate_bookmark('Archived Bookmark 2').get_by_text('Confirm').click()
|
||||
self.locate_bookmark("Archived Bookmark 2").get_by_text("Remove").click()
|
||||
self.locate_bookmark("Archived Bookmark 2").get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])
|
||||
self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3'])
|
||||
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
|
||||
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_archived_bookmarks_partial_update_on_bulk_unarchive(self):
|
||||
self.setup_fixture()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:archived'), p)
|
||||
self.open(reverse("bookmarks:archived"), p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark('Archived Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
|
||||
self.select_bulk_action('Unarchive')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
self.locate_bookmark("Archived Bookmark 2").locator(
|
||||
"label[ld-bulk-edit-checkbox]"
|
||||
).click()
|
||||
self.select_bulk_action("Unarchive")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])
|
||||
self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3'])
|
||||
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
|
||||
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_archived_bookmarks_partial_update_on_bulk_delete(self):
|
||||
self.setup_fixture()
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:archived'), p)
|
||||
self.open(reverse("bookmarks:archived"), p)
|
||||
|
||||
self.locate_bulk_edit_toggle().click()
|
||||
self.locate_bookmark('Archived Bookmark 2').locator('label[ld-bulk-edit-checkbox]').click()
|
||||
self.select_bulk_action('Delete')
|
||||
self.locate_bulk_edit_bar().get_by_text('Execute').click()
|
||||
self.locate_bulk_edit_bar().get_by_text('Confirm').click()
|
||||
self.locate_bookmark("Archived Bookmark 2").locator(
|
||||
"label[ld-bulk-edit-checkbox]"
|
||||
).click()
|
||||
self.select_bulk_action("Delete")
|
||||
self.locate_bulk_edit_bar().get_by_text("Execute").click()
|
||||
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks(['Archived Bookmark 1', 'Archived Bookmark 3'])
|
||||
self.assertVisibleTags(['Archived Tag 1', 'Archived Tag 3'])
|
||||
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
|
||||
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_shared_bookmarks_partial_update_on_unarchive(self):
|
||||
self.setup_fixture()
|
||||
self.setup_numbered_bookmarks(3, shared=True, prefix="My Bookmark", with_tags=True)
|
||||
self.setup_numbered_bookmarks(
|
||||
3, shared=True, prefix="My Bookmark", with_tags=True
|
||||
)
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:shared'), p)
|
||||
self.open(reverse("bookmarks:shared"), p)
|
||||
|
||||
self.locate_bookmark('My Bookmark 2').get_by_text('Archive').click()
|
||||
self.locate_bookmark("My Bookmark 2").get_by_text("Archive").click()
|
||||
|
||||
# Shared bookmarks page also shows archived bookmarks, though it probably shouldn't
|
||||
self.assertVisibleBookmarks([
|
||||
'My Bookmark 1',
|
||||
'My Bookmark 2',
|
||||
'My Bookmark 3',
|
||||
"Joe's Bookmark 1",
|
||||
"Joe's Bookmark 2",
|
||||
"Joe's Bookmark 3",
|
||||
])
|
||||
self.assertVisibleTags(['Shared Tag 1', 'Shared Tag 2', 'Shared Tag 3'])
|
||||
self.assertVisibleBookmarks(
|
||||
[
|
||||
"My Bookmark 1",
|
||||
"My Bookmark 2",
|
||||
"My Bookmark 3",
|
||||
"Joe's Bookmark 1",
|
||||
"Joe's Bookmark 2",
|
||||
"Joe's Bookmark 3",
|
||||
]
|
||||
)
|
||||
self.assertVisibleTags(["Shared Tag 1", "Shared Tag 2", "Shared Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
def test_shared_bookmarks_partial_update_on_delete(self):
|
||||
self.setup_fixture()
|
||||
self.setup_numbered_bookmarks(3, shared=True, prefix="My Bookmark", with_tags=True)
|
||||
self.setup_numbered_bookmarks(
|
||||
3, shared=True, prefix="My Bookmark", with_tags=True
|
||||
)
|
||||
|
||||
with sync_playwright() as p:
|
||||
self.open(reverse('bookmarks:shared'), p)
|
||||
self.open(reverse("bookmarks:shared"), p)
|
||||
|
||||
self.locate_bookmark('My Bookmark 2').get_by_text('Remove').click()
|
||||
self.locate_bookmark('My Bookmark 2').get_by_text('Confirm').click()
|
||||
self.locate_bookmark("My Bookmark 2").get_by_text("Remove").click()
|
||||
self.locate_bookmark("My Bookmark 2").get_by_text("Confirm").click()
|
||||
|
||||
self.assertVisibleBookmarks([
|
||||
'My Bookmark 1',
|
||||
'My Bookmark 3',
|
||||
"Joe's Bookmark 1",
|
||||
"Joe's Bookmark 2",
|
||||
"Joe's Bookmark 3",
|
||||
])
|
||||
self.assertVisibleTags(['Shared Tag 1', 'Shared Tag 3'])
|
||||
self.assertVisibleBookmarks(
|
||||
[
|
||||
"My Bookmark 1",
|
||||
"My Bookmark 3",
|
||||
"Joe's Bookmark 1",
|
||||
"Joe's Bookmark 2",
|
||||
"Joe's Bookmark 3",
|
||||
]
|
||||
)
|
||||
self.assertVisibleTags(["Shared Tag 1", "Shared Tag 3"])
|
||||
self.assertReloads(0)
|
||||
|
||||
@@ -9,11 +9,11 @@ class GlobalShortcutsE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse('bookmarks:index'))
|
||||
page.goto(self.live_server_url + reverse("bookmarks:index"))
|
||||
|
||||
page.press('body', 's')
|
||||
page.press("body", "s")
|
||||
|
||||
expect(page.get_by_placeholder('Search for words or #tags')).to_be_focused()
|
||||
expect(page.get_by_placeholder("Search for words or #tags")).to_be_focused()
|
||||
|
||||
browser.close()
|
||||
|
||||
@@ -21,10 +21,10 @@ class GlobalShortcutsE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse('bookmarks:index'))
|
||||
page.goto(self.live_server_url + reverse("bookmarks:index"))
|
||||
|
||||
page.press('body', 'n')
|
||||
page.press("body", "n")
|
||||
|
||||
expect(page).to_have_url(self.live_server_url + reverse('bookmarks:new'))
|
||||
expect(page).to_have_url(self.live_server_url + reverse("bookmarks:new"))
|
||||
|
||||
browser.close()
|
||||
|
||||
@@ -9,12 +9,14 @@ class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse('bookmarks:settings.general'))
|
||||
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
|
||||
|
||||
enable_sharing = page.get_by_label('Enable bookmark sharing')
|
||||
enable_sharing_label = page.get_by_text('Enable bookmark sharing')
|
||||
enable_public_sharing = page.get_by_label('Enable public bookmark sharing')
|
||||
enable_public_sharing_label = page.get_by_text('Enable public bookmark sharing')
|
||||
enable_sharing = page.get_by_label("Enable bookmark sharing")
|
||||
enable_sharing_label = page.get_by_text("Enable bookmark sharing")
|
||||
enable_public_sharing = page.get_by_label("Enable public bookmark sharing")
|
||||
enable_public_sharing_label = page.get_by_text(
|
||||
"Enable public bookmark sharing"
|
||||
)
|
||||
|
||||
# Public sharing is disabled by default
|
||||
expect(enable_sharing).not_to_be_checked()
|
||||
|
||||
@@ -7,24 +7,28 @@ 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']
|
||||
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': '/'
|
||||
}])
|
||||
context.add_cookies(
|
||||
[
|
||||
{
|
||||
"name": "sessionid",
|
||||
"value": self.cookie.value,
|
||||
"domain": self.live_server_url.replace("http:", ""),
|
||||
"path": "/",
|
||||
}
|
||||
]
|
||||
)
|
||||
return context
|
||||
|
||||
def open(self, url: str, playwright: Playwright) -> Page:
|
||||
browser = self.setup_browser(playwright)
|
||||
self.page = browser.new_page()
|
||||
self.page.goto(self.live_server_url + url)
|
||||
self.page.on('load', self.on_load)
|
||||
self.page.on("load", self.on_load)
|
||||
self.num_loads = 0
|
||||
return self.page
|
||||
|
||||
@@ -35,20 +39,24 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(self.num_loads, count)
|
||||
|
||||
def locate_bookmark(self, title: str):
|
||||
bookmark_tags = self.page.locator('li[ld-bookmark-item]')
|
||||
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
|
||||
return bookmark_tags.filter(has_text=title)
|
||||
|
||||
def locate_bulk_edit_bar(self):
|
||||
return self.page.locator('.bulk-edit-bar')
|
||||
return self.page.locator(".bulk-edit-bar")
|
||||
|
||||
def locate_bulk_edit_select_all(self):
|
||||
return self.locate_bulk_edit_bar().locator('label[ld-bulk-edit-checkbox][all]')
|
||||
return self.locate_bulk_edit_bar().locator("label[ld-bulk-edit-checkbox][all]")
|
||||
|
||||
def locate_bulk_edit_select_across(self):
|
||||
return self.locate_bulk_edit_bar().locator('label.select-across')
|
||||
return self.locate_bulk_edit_bar().locator("label.select-across")
|
||||
|
||||
def locate_bulk_edit_toggle(self):
|
||||
return self.page.get_by_title('Bulk edit')
|
||||
return self.page.get_by_title("Bulk edit")
|
||||
|
||||
def select_bulk_action(self, value: str):
|
||||
return self.locate_bulk_edit_bar().locator('select[name="bulk_action"]').select_option(value)
|
||||
return (
|
||||
self.locate_bulk_edit_bar()
|
||||
.locator('select[name="bulk_action"]')
|
||||
.select_option(value)
|
||||
)
|
||||
|
||||
@@ -6,28 +6,32 @@ from django.db.models import QuerySet
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeedContext:
|
||||
feed_token: FeedToken
|
||||
feed_token: FeedToken | None
|
||||
query_set: QuerySet[Bookmark]
|
||||
|
||||
|
||||
def sanitize(text: str):
|
||||
if not text:
|
||||
return ''
|
||||
return ""
|
||||
# remove control characters
|
||||
valid_chars = ['\n', '\r', '\t']
|
||||
return ''.join(ch for ch in text if ch in valid_chars or unicodedata.category(ch)[0] != 'C')
|
||||
valid_chars = ["\n", "\r", "\t"]
|
||||
return "".join(
|
||||
ch for ch in text if ch in valid_chars or unicodedata.category(ch)[0] != "C"
|
||||
)
|
||||
|
||||
|
||||
class BaseBookmarksFeed(Feed):
|
||||
def get_object(self, request, feed_key: str):
|
||||
feed_token = FeedToken.objects.get(key__exact=feed_key)
|
||||
search = BookmarkSearch(q=request.GET.get('q', ''))
|
||||
query_set = queries.query_bookmarks(feed_token.user, feed_token.user.profile, search)
|
||||
search = BookmarkSearch(q=request.GET.get("q", ""))
|
||||
query_set = queries.query_bookmarks(
|
||||
feed_token.user, feed_token.user.profile, search
|
||||
)
|
||||
return FeedContext(feed_token, query_set)
|
||||
|
||||
def item_title(self, item: Bookmark):
|
||||
@@ -44,22 +48,58 @@ class BaseBookmarksFeed(Feed):
|
||||
|
||||
|
||||
class AllBookmarksFeed(BaseBookmarksFeed):
|
||||
title = 'All bookmarks'
|
||||
description = 'All bookmarks'
|
||||
title = "All bookmarks"
|
||||
description = "All bookmarks"
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse('bookmarks:feeds.all', args=[context.feed_token.key])
|
||||
return reverse("bookmarks:feeds.all", args=[context.feed_token.key])
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
return context.query_set
|
||||
|
||||
|
||||
class UnreadBookmarksFeed(BaseBookmarksFeed):
|
||||
title = 'Unread bookmarks'
|
||||
description = 'All unread bookmarks'
|
||||
title = "Unread bookmarks"
|
||||
description = "All unread bookmarks"
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse('bookmarks:feeds.unread', args=[context.feed_token.key])
|
||||
return reverse("bookmarks:feeds.unread", args=[context.feed_token.key])
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
return context.query_set.filter(unread=True)
|
||||
|
||||
|
||||
class SharedBookmarksFeed(BaseBookmarksFeed):
|
||||
title = "Shared bookmarks"
|
||||
description = "All shared bookmarks"
|
||||
|
||||
def get_object(self, request, feed_key: str):
|
||||
feed_token = FeedToken.objects.get(key__exact=feed_key)
|
||||
search = BookmarkSearch(q=request.GET.get("q", ""))
|
||||
query_set = queries.query_shared_bookmarks(
|
||||
None, feed_token.user.profile, search, False
|
||||
)
|
||||
return FeedContext(feed_token, query_set)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
return context.query_set
|
||||
|
||||
|
||||
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
|
||||
title = "Public shared bookmarks"
|
||||
description = "All public shared bookmarks"
|
||||
|
||||
def get_object(self, request):
|
||||
search = BookmarkSearch(q=request.GET.get("q", ""))
|
||||
default_profile = UserProfile()
|
||||
query_set = queries.query_shared_bookmarks(None, default_profile, search, True)
|
||||
return FeedContext(None, query_set)
|
||||
|
||||
def link(self, context: FeedContext):
|
||||
return reverse("bookmarks:feeds.public_shared")
|
||||
|
||||
def items(self, context: FeedContext):
|
||||
return context.query_set
|
||||
|
||||
@@ -258,4 +258,4 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import TagAutoComplete from "./components/TagAutocomplete.svelte";
|
||||
import SearchAutoComplete from "./components/SearchAutoComplete.svelte";
|
||||
import { ApiClient } from "./api";
|
||||
import "./behaviors/bookmark-page";
|
||||
import "./behaviors/bulk-edit";
|
||||
import "./behaviors/confirm-button";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/modal";
|
||||
import "./behaviors/global-shortcuts";
|
||||
import "./behaviors/tag-autocomplete";
|
||||
|
||||
export default {
|
||||
ApiClient,
|
||||
TagAutoComplete,
|
||||
SearchAutoComplete,
|
||||
};
|
||||
import './behaviors/bookmark-page';
|
||||
import './behaviors/bulk-edit';
|
||||
import './behaviors/confirm-button';
|
||||
import './behaviors/dropdown';
|
||||
import './behaviors/modal';
|
||||
import './behaviors/global-shortcuts';
|
||||
import './behaviors/tag-autocomplete';
|
||||
export { default as TagAutoComplete } from './components/TagAutocomplete.svelte';
|
||||
export { default as SearchAutoComplete } from './components/SearchAutoComplete.svelte';
|
||||
export { ApiClient } from './api';
|
||||
|
||||
@@ -8,19 +8,19 @@ class Command(BaseCommand):
|
||||
help = "Creates a backup of the linkding database"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('destination', type=str, help='Backup file destination')
|
||||
parser.add_argument("destination", type=str, help="Backup file destination")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
destination = options['destination']
|
||||
destination = options["destination"]
|
||||
|
||||
def progress(status, remaining, total):
|
||||
self.stdout.write(f'Copied {total-remaining} of {total} pages...')
|
||||
self.stdout.write(f"Copied {total-remaining} of {total} pages...")
|
||||
|
||||
source_db = sqlite3.connect(os.path.join('data', 'db.sqlite3'))
|
||||
source_db = sqlite3.connect(os.path.join("data", "db.sqlite3"))
|
||||
backup_db = sqlite3.connect(destination)
|
||||
with backup_db:
|
||||
source_db.backup(backup_db, pages=50, progress=progress)
|
||||
backup_db.close()
|
||||
source_db.close()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'Backup created at {destination}'))
|
||||
self.stdout.write(self.style.SUCCESS(f"Backup created at {destination}"))
|
||||
|
||||
@@ -12,18 +12,20 @@ class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
User = get_user_model()
|
||||
superuser_name = os.getenv('LD_SUPERUSER_NAME', None)
|
||||
superuser_password = os.getenv('LD_SUPERUSER_PASSWORD', None)
|
||||
superuser_name = os.getenv("LD_SUPERUSER_NAME", None)
|
||||
superuser_password = os.getenv("LD_SUPERUSER_PASSWORD", None)
|
||||
|
||||
# Skip if option is undefined
|
||||
if not superuser_name:
|
||||
logger.info('Skip creating initial superuser, LD_SUPERUSER_NAME option is not defined')
|
||||
logger.info(
|
||||
"Skip creating initial superuser, LD_SUPERUSER_NAME option is not defined"
|
||||
)
|
||||
return
|
||||
|
||||
# Skip if user already exists
|
||||
user_exists = User.objects.filter(username=superuser_name).exists()
|
||||
if user_exists:
|
||||
logger.info('Skip creating initial superuser, user already exists')
|
||||
logger.info("Skip creating initial superuser, user already exists")
|
||||
return
|
||||
|
||||
user = User(username=superuser_name, is_superuser=True, is_staff=True)
|
||||
@@ -34,4 +36,4 @@ class Command(BaseCommand):
|
||||
user.set_unusable_password()
|
||||
|
||||
user.save()
|
||||
logger.info('Created initial superuser')
|
||||
logger.info("Created initial superuser")
|
||||
|
||||
@@ -14,11 +14,11 @@ class Command(BaseCommand):
|
||||
if not settings.USE_SQLITE:
|
||||
return
|
||||
|
||||
connection = connections['default']
|
||||
connection = connections["default"]
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("PRAGMA journal_mode")
|
||||
current_mode = cursor.fetchone()[0]
|
||||
logger.info(f'Current journal mode: {current_mode}')
|
||||
if current_mode != 'wal':
|
||||
logger.info(f"Current journal mode: {current_mode}")
|
||||
if current_mode != "wal":
|
||||
cursor.execute("PRAGMA journal_mode=wal;")
|
||||
logger.info('Switched to WAL journal mode')
|
||||
logger.info("Switched to WAL journal mode")
|
||||
|
||||
@@ -6,13 +6,15 @@ class Command(BaseCommand):
|
||||
help = "Creates an admin user non-interactively if it doesn't exist"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--username', help="Admin's username")
|
||||
parser.add_argument('--email', help="Admin's email")
|
||||
parser.add_argument('--password', help="Admin's password")
|
||||
parser.add_argument("--username", help="Admin's username")
|
||||
parser.add_argument("--email", help="Admin's email")
|
||||
parser.add_argument("--password", help="Admin's password")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
User = get_user_model()
|
||||
if not User.objects.filter(username=options['username']).exists():
|
||||
User.objects.create_superuser(username=options['username'],
|
||||
email=options['email'],
|
||||
password=options['password'])
|
||||
if not User.objects.filter(username=options["username"]).exists():
|
||||
User.objects.create_superuser(
|
||||
username=options["username"],
|
||||
email=options["email"],
|
||||
password=options["password"],
|
||||
)
|
||||
|
||||
24
bookmarks/management/commands/generate_secret_key.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.management.utils import get_random_secret_key
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generate secret key file if it does not exist"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
secret_key_file = os.path.join("data", "secretkey.txt")
|
||||
|
||||
if os.path.exists(secret_key_file):
|
||||
logger.info(f"Secret key file already exists")
|
||||
return
|
||||
|
||||
secret_key = get_random_secret_key()
|
||||
with open(secret_key_file, "w") as f:
|
||||
f.write(secret_key)
|
||||
logger.info(f"Generated secret key file")
|
||||
@@ -5,15 +5,17 @@ from bookmarks.services.importer import import_netscape_html
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Import Netscape HTML bookmark file'
|
||||
help = "Import Netscape HTML bookmark file"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('file', type=str, help='Path to file')
|
||||
parser.add_argument('user', type=str, help='Name of the user for which to import')
|
||||
parser.add_argument("file", type=str, help="Path to file")
|
||||
parser.add_argument(
|
||||
"user", type=str, help="Name of the user for which to import"
|
||||
)
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
filepath = kwargs['file']
|
||||
username = kwargs['user']
|
||||
filepath = kwargs["file"]
|
||||
username = kwargs["user"]
|
||||
with open(filepath) as html_file:
|
||||
html = html_file.read()
|
||||
user = User.objects.get(username=username)
|
||||
|
||||
@@ -15,19 +15,36 @@ class Migration(migrations.Migration):
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Bookmark',
|
||||
name="Bookmark",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('url', models.URLField()),
|
||||
('title', models.CharField(max_length=512)),
|
||||
('description', models.TextField()),
|
||||
('website_title', models.CharField(blank=True, max_length=512, null=True)),
|
||||
('website_description', models.TextField(blank=True, null=True)),
|
||||
('unread', models.BooleanField(default=True)),
|
||||
('date_added', models.DateTimeField()),
|
||||
('date_modified', models.DateTimeField()),
|
||||
('date_accessed', models.DateTimeField(blank=True, null=True)),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("url", models.URLField()),
|
||||
("title", models.CharField(max_length=512)),
|
||||
("description", models.TextField()),
|
||||
(
|
||||
"website_title",
|
||||
models.CharField(blank=True, max_length=512, null=True),
|
||||
),
|
||||
("website_description", models.TextField(blank=True, null=True)),
|
||||
("unread", models.BooleanField(default=True)),
|
||||
("date_added", models.DateTimeField()),
|
||||
("date_modified", models.DateTimeField()),
|
||||
("date_accessed", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -9,22 +9,36 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('bookmarks', '0001_initial'),
|
||||
("bookmarks", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Tag',
|
||||
name="Tag",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=64)),
|
||||
('date_added', models.DateTimeField()),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=64)),
|
||||
("date_added", models.DateTimeField()),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='bookmark',
|
||||
name='tags',
|
||||
field=models.ManyToManyField(to='bookmarks.Tag'),
|
||||
model_name="bookmark",
|
||||
name="tags",
|
||||
field=models.ManyToManyField(to="bookmarks.Tag"),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0002_auto_20190629_2303'),
|
||||
("bookmarks", "0002_auto_20190629_2303"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='bookmark',
|
||||
name='url',
|
||||
model_name="bookmark",
|
||||
name="url",
|
||||
field=models.URLField(max_length=2048),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,18 +6,18 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0003_auto_20200913_0656'),
|
||||
("bookmarks", "0003_auto_20200913_0656"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='bookmark',
|
||||
name='description',
|
||||
model_name="bookmark",
|
||||
name="description",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='bookmark',
|
||||
name='title',
|
||||
model_name="bookmark",
|
||||
name="title",
|
||||
field=models.CharField(blank=True, max_length=512),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -7,13 +7,16 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0004_auto_20200926_1028'),
|
||||
("bookmarks", "0004_auto_20200926_1028"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='bookmark',
|
||||
name='url',
|
||||
field=models.CharField(max_length=2048, validators=[bookmarks.validators.BookmarkURLValidator()]),
|
||||
model_name="bookmark",
|
||||
name="url",
|
||||
field=models.CharField(
|
||||
max_length=2048,
|
||||
validators=[bookmarks.validators.BookmarkURLValidator()],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0005_auto_20210103_1212'),
|
||||
("bookmarks", "0005_auto_20210103_1212"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bookmark',
|
||||
name='is_archived',
|
||||
model_name="bookmark",
|
||||
name="is_archived",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,8 +6,8 @@ import django.db.models.deletion
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
User = apps.get_model('auth', 'User')
|
||||
UserProfile = apps.get_model('bookmarks', 'UserProfile')
|
||||
User = apps.get_model("auth", "User")
|
||||
UserProfile = apps.get_model("bookmarks", "UserProfile")
|
||||
for user in User.objects.all():
|
||||
try:
|
||||
if user.profile:
|
||||
@@ -24,19 +24,42 @@ def reverse(apps, schema_editor):
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('bookmarks', '0006_bookmark_is_archived'),
|
||||
("bookmarks", "0006_bookmark_is_archived"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserProfile',
|
||||
name="UserProfile",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('theme',
|
||||
models.CharField(choices=[('auto', 'Auto'), ('light', 'Light'), ('dark', 'Dark')], default='auto',
|
||||
max_length=10)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile',
|
||||
to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"theme",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("auto", "Auto"),
|
||||
("light", "Light"),
|
||||
("dark", "Dark"),
|
||||
],
|
||||
default="auto",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="profile",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(forwards, reverse),
|
||||
|
||||
@@ -6,13 +6,21 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0007_userprofile'),
|
||||
("bookmarks", "0007_userprofile"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='bookmark_date_display',
|
||||
field=models.CharField(choices=[('relative', 'Relative'), ('absolute', 'Absolute'), ('hidden', 'Hidden')], default='relative', max_length=10),
|
||||
model_name="userprofile",
|
||||
name="bookmark_date_display",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("relative", "Relative"),
|
||||
("absolute", "Absolute"),
|
||||
("hidden", "Hidden"),
|
||||
],
|
||||
default="relative",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0008_userprofile_bookmark_date_display'),
|
||||
("bookmarks", "0008_userprofile_bookmark_date_display"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bookmark',
|
||||
name='web_archive_snapshot_url',
|
||||
model_name="bookmark",
|
||||
name="web_archive_snapshot_url",
|
||||
field=models.CharField(blank=True, max_length=2048),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,13 +6,17 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0009_bookmark_web_archive_snapshot_url'),
|
||||
("bookmarks", "0009_bookmark_web_archive_snapshot_url"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='bookmark_link_target',
|
||||
field=models.CharField(choices=[('_blank', 'New page'), ('_self', 'Same page')], default='_blank', max_length=10),
|
||||
model_name="userprofile",
|
||||
name="bookmark_link_target",
|
||||
field=models.CharField(
|
||||
choices=[("_blank", "New page"), ("_self", "Same page")],
|
||||
default="_blank",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,13 +6,17 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0010_userprofile_bookmark_link_target'),
|
||||
("bookmarks", "0010_userprofile_bookmark_link_target"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='web_archive_integration',
|
||||
field=models.CharField(choices=[('disabled', 'Disabled'), ('enabled', 'Enabled')], default='disabled', max_length=10),
|
||||
model_name="userprofile",
|
||||
name="web_archive_integration",
|
||||
field=models.CharField(
|
||||
choices=[("disabled", "Disabled"), ("enabled", "Enabled")],
|
||||
default="disabled",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -9,18 +9,32 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('bookmarks', '0011_userprofile_web_archive_integration'),
|
||||
("bookmarks", "0011_userprofile_web_archive_integration"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Toast',
|
||||
name="Toast",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.CharField(max_length=50)),
|
||||
('message', models.TextField()),
|
||||
('acknowledged', models.BooleanField(default=False)),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("key", models.CharField(max_length=50)),
|
||||
("message", models.TextField()),
|
||||
("acknowledged", models.BooleanField(default=False)),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -10,19 +10,21 @@ User = get_user_model()
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
for user in User.objects.all():
|
||||
toast = Toast(key='web_archive_opt_in_hint',
|
||||
message='The Internet Archive Wayback Machine integration has been disabled by default. Check the Settings to re-enable it.',
|
||||
owner=user)
|
||||
toast = Toast(
|
||||
key="web_archive_opt_in_hint",
|
||||
message="The Internet Archive Wayback Machine integration has been disabled by default. Check the Settings to re-enable it.",
|
||||
owner=user,
|
||||
)
|
||||
toast.save()
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
Toast.objects.filter(key='web_archive_opt_in_hint').delete()
|
||||
Toast.objects.filter(key="web_archive_opt_in_hint").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('bookmarks', '0012_toast'),
|
||||
("bookmarks", "0012_toast"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Bookmark = apps.get_model('bookmarks', 'Bookmark')
|
||||
Bookmark = apps.get_model("bookmarks", "Bookmark")
|
||||
Bookmark.objects.update(unread=False)
|
||||
|
||||
|
||||
@@ -14,13 +14,13 @@ def reverse(apps, schema_editor):
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('bookmarks', '0013_web_archive_optin_toast'),
|
||||
("bookmarks", "0013_web_archive_optin_toast"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='bookmark',
|
||||
name='unread',
|
||||
model_name="bookmark",
|
||||
name="unread",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.RunPython(forwards, reverse),
|
||||
|
||||
@@ -9,16 +9,26 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('bookmarks', '0014_alter_bookmark_unread'),
|
||||
("bookmarks", "0014_alter_bookmark_unread"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='FeedToken',
|
||||
name="FeedToken",
|
||||
fields=[
|
||||
('key', models.CharField(max_length=40, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='feed_token', to=settings.AUTH_USER_MODEL)),
|
||||
(
|
||||
"key",
|
||||
models.CharField(max_length=40, primary_key=True, serialize=False),
|
||||
),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="feed_token",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0015_feedtoken'),
|
||||
("bookmarks", "0015_feedtoken"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bookmark',
|
||||
name='shared',
|
||||
model_name="bookmark",
|
||||
name="shared",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0016_bookmark_shared'),
|
||||
("bookmarks", "0016_bookmark_shared"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='enable_sharing',
|
||||
model_name="userprofile",
|
||||
name="enable_sharing",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0017_userprofile_enable_sharing'),
|
||||
("bookmarks", "0017_userprofile_enable_sharing"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bookmark',
|
||||
name='favicon_file',
|
||||
model_name="bookmark",
|
||||
name="favicon_file",
|
||||
field=models.CharField(blank=True, max_length=512),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0018_bookmark_favicon_file'),
|
||||
("bookmarks", "0018_bookmark_favicon_file"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='enable_favicons',
|
||||
model_name="userprofile",
|
||||
name="enable_favicons",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,13 +6,17 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0019_userprofile_enable_favicons'),
|
||||
("bookmarks", "0019_userprofile_enable_favicons"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='tag_search',
|
||||
field=models.CharField(choices=[('strict', 'Strict'), ('lax', 'Lax')], default='strict', max_length=10),
|
||||
model_name="userprofile",
|
||||
name="tag_search",
|
||||
field=models.CharField(
|
||||
choices=[("strict", "Strict"), ("lax", "Lax")],
|
||||
default="strict",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0020_userprofile_tag_search'),
|
||||
("bookmarks", "0020_userprofile_tag_search"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='display_url',
|
||||
model_name="userprofile",
|
||||
name="display_url",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0021_userprofile_display_url'),
|
||||
("bookmarks", "0021_userprofile_display_url"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bookmark',
|
||||
name='notes',
|
||||
model_name="bookmark",
|
||||
name="notes",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0022_bookmark_notes'),
|
||||
("bookmarks", "0022_bookmark_notes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='permanent_notes',
|
||||
model_name="userprofile",
|
||||
name="permanent_notes",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0023_userprofile_permanent_notes'),
|
||||
("bookmarks", "0023_userprofile_permanent_notes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='enable_public_sharing',
|
||||
model_name="userprofile",
|
||||
name="enable_public_sharing",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0024_userprofile_enable_public_sharing'),
|
||||
("bookmarks", "0024_userprofile_enable_public_sharing"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='search_preferences',
|
||||
model_name="userprofile",
|
||||
name="search_preferences",
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
]
|
||||
|
||||
18
bookmarks/migrations/0026_userprofile_custom_css.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.2 on 2024-03-16 23:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0025_userprofile_search_preferences"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="custom_css",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
||||
@@ -26,10 +26,10 @@ class Tag(models.Model):
|
||||
def sanitize_tag_name(tag_name: str):
|
||||
# strip leading/trailing spaces
|
||||
# replace inner spaces with replacement char
|
||||
return tag_name.strip().replace(' ', '-')
|
||||
return tag_name.strip().replace(" ", "-")
|
||||
|
||||
|
||||
def parse_tag_string(tag_string: str, delimiter: str = ','):
|
||||
def parse_tag_string(tag_string: str, delimiter: str = ","):
|
||||
if not tag_string:
|
||||
return []
|
||||
names = tag_string.strip().split(delimiter)
|
||||
@@ -42,7 +42,7 @@ def parse_tag_string(tag_string: str, delimiter: str = ','):
|
||||
return names
|
||||
|
||||
|
||||
def build_tag_string(tag_names: List[str], delimiter: str = ','):
|
||||
def build_tag_string(tag_names: List[str], delimiter: str = ","):
|
||||
return delimiter.join(tag_names)
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ class Bookmark(models.Model):
|
||||
return [tag.name for tag in self.tags.all()]
|
||||
|
||||
def __str__(self):
|
||||
return self.resolved_title + ' (' + self.url[:30] + '...)'
|
||||
return self.resolved_title + " (" + self.url[:30] + "...)"
|
||||
|
||||
|
||||
class BookmarkForm(forms.ModelForm):
|
||||
@@ -90,15 +90,13 @@ class BookmarkForm(forms.ModelForm):
|
||||
url = forms.CharField(validators=[BookmarkURLValidator()])
|
||||
tag_string = forms.CharField(required=False)
|
||||
# Do not require title and description in form as we fill these automatically if they are empty
|
||||
title = forms.CharField(max_length=512,
|
||||
required=False)
|
||||
description = forms.CharField(required=False,
|
||||
widget=forms.Textarea())
|
||||
title = forms.CharField(max_length=512, required=False)
|
||||
description = forms.CharField(required=False, widget=forms.Textarea())
|
||||
# Include website title and description as hidden field as they only provide info when editing bookmarks
|
||||
website_title = forms.CharField(max_length=512,
|
||||
required=False, widget=forms.HiddenInput())
|
||||
website_description = forms.CharField(required=False,
|
||||
widget=forms.HiddenInput())
|
||||
website_title = forms.CharField(
|
||||
max_length=512, required=False, widget=forms.HiddenInput()
|
||||
)
|
||||
website_description = forms.CharField(required=False, widget=forms.HiddenInput())
|
||||
unread = forms.BooleanField(required=False)
|
||||
shared = forms.BooleanField(required=False)
|
||||
# Hidden field that determines whether to close window/tab after saving the bookmark
|
||||
@@ -107,16 +105,16 @@ class BookmarkForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Bookmark
|
||||
fields = [
|
||||
'url',
|
||||
'tag_string',
|
||||
'title',
|
||||
'description',
|
||||
'notes',
|
||||
'website_title',
|
||||
'website_description',
|
||||
'unread',
|
||||
'shared',
|
||||
'auto_close',
|
||||
"url",
|
||||
"tag_string",
|
||||
"title",
|
||||
"description",
|
||||
"notes",
|
||||
"website_title",
|
||||
"website_description",
|
||||
"unread",
|
||||
"shared",
|
||||
"auto_close",
|
||||
]
|
||||
|
||||
@property
|
||||
@@ -125,45 +123,47 @@ class BookmarkForm(forms.ModelForm):
|
||||
|
||||
|
||||
class BookmarkSearch:
|
||||
SORT_ADDED_ASC = 'added_asc'
|
||||
SORT_ADDED_DESC = 'added_desc'
|
||||
SORT_TITLE_ASC = 'title_asc'
|
||||
SORT_TITLE_DESC = 'title_desc'
|
||||
SORT_ADDED_ASC = "added_asc"
|
||||
SORT_ADDED_DESC = "added_desc"
|
||||
SORT_TITLE_ASC = "title_asc"
|
||||
SORT_TITLE_DESC = "title_desc"
|
||||
|
||||
FILTER_SHARED_OFF = 'off'
|
||||
FILTER_SHARED_SHARED = 'yes'
|
||||
FILTER_SHARED_UNSHARED = 'no'
|
||||
FILTER_SHARED_OFF = "off"
|
||||
FILTER_SHARED_SHARED = "yes"
|
||||
FILTER_SHARED_UNSHARED = "no"
|
||||
|
||||
FILTER_UNREAD_OFF = 'off'
|
||||
FILTER_UNREAD_YES = 'yes'
|
||||
FILTER_UNREAD_NO = 'no'
|
||||
FILTER_UNREAD_OFF = "off"
|
||||
FILTER_UNREAD_YES = "yes"
|
||||
FILTER_UNREAD_NO = "no"
|
||||
|
||||
params = ['q', 'user', 'sort', 'shared', 'unread']
|
||||
preferences = ['sort', 'shared', 'unread']
|
||||
params = ["q", "user", "sort", "shared", "unread"]
|
||||
preferences = ["sort", "shared", "unread"]
|
||||
defaults = {
|
||||
'q': '',
|
||||
'user': '',
|
||||
'sort': SORT_ADDED_DESC,
|
||||
'shared': FILTER_SHARED_OFF,
|
||||
'unread': FILTER_UNREAD_OFF,
|
||||
"q": "",
|
||||
"user": "",
|
||||
"sort": SORT_ADDED_DESC,
|
||||
"shared": FILTER_SHARED_OFF,
|
||||
"unread": FILTER_UNREAD_OFF,
|
||||
}
|
||||
|
||||
def __init__(self,
|
||||
q: str = None,
|
||||
user: str = None,
|
||||
sort: str = None,
|
||||
shared: str = None,
|
||||
unread: str = None,
|
||||
preferences: dict = None):
|
||||
def __init__(
|
||||
self,
|
||||
q: str = None,
|
||||
user: str = None,
|
||||
sort: str = None,
|
||||
shared: str = None,
|
||||
unread: str = None,
|
||||
preferences: dict = None,
|
||||
):
|
||||
if not preferences:
|
||||
preferences = {}
|
||||
self.defaults = {**BookmarkSearch.defaults, **preferences}
|
||||
|
||||
self.q = q or self.defaults['q']
|
||||
self.user = user or self.defaults['user']
|
||||
self.sort = sort or self.defaults['sort']
|
||||
self.shared = shared or self.defaults['shared']
|
||||
self.unread = unread or self.defaults['unread']
|
||||
self.q = q or self.defaults["q"]
|
||||
self.user = user or self.defaults["user"]
|
||||
self.sort = sort or self.defaults["sort"]
|
||||
self.shared = shared or self.defaults["shared"]
|
||||
self.unread = unread or self.defaults["unread"]
|
||||
|
||||
def is_modified(self, param):
|
||||
value = self.__dict__[param]
|
||||
@@ -175,7 +175,11 @@ class BookmarkSearch:
|
||||
|
||||
@property
|
||||
def modified_preferences(self):
|
||||
return [preference for preference in self.preferences if self.is_modified(preference)]
|
||||
return [
|
||||
preference
|
||||
for preference in self.preferences
|
||||
if self.is_modified(preference)
|
||||
]
|
||||
|
||||
@property
|
||||
def has_modifications(self):
|
||||
@@ -191,7 +195,9 @@ class BookmarkSearch:
|
||||
|
||||
@property
|
||||
def preferences_dict(self):
|
||||
return {preference: self.__dict__[preference] for preference in self.preferences}
|
||||
return {
|
||||
preference: self.__dict__[preference] for preference in self.preferences
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_request(query_dict: QueryDict, preferences: dict = None):
|
||||
@@ -206,20 +212,20 @@ class BookmarkSearch:
|
||||
|
||||
class BookmarkSearchForm(forms.Form):
|
||||
SORT_CHOICES = [
|
||||
(BookmarkSearch.SORT_ADDED_ASC, 'Added ↑'),
|
||||
(BookmarkSearch.SORT_ADDED_DESC, 'Added ↓'),
|
||||
(BookmarkSearch.SORT_TITLE_ASC, 'Title ↑'),
|
||||
(BookmarkSearch.SORT_TITLE_DESC, 'Title ↓'),
|
||||
(BookmarkSearch.SORT_ADDED_ASC, "Added ↑"),
|
||||
(BookmarkSearch.SORT_ADDED_DESC, "Added ↓"),
|
||||
(BookmarkSearch.SORT_TITLE_ASC, "Title ↑"),
|
||||
(BookmarkSearch.SORT_TITLE_DESC, "Title ↓"),
|
||||
]
|
||||
FILTER_SHARED_CHOICES = [
|
||||
(BookmarkSearch.FILTER_SHARED_OFF, 'Off'),
|
||||
(BookmarkSearch.FILTER_SHARED_SHARED, 'Shared'),
|
||||
(BookmarkSearch.FILTER_SHARED_UNSHARED, 'Unshared'),
|
||||
(BookmarkSearch.FILTER_SHARED_OFF, "Off"),
|
||||
(BookmarkSearch.FILTER_SHARED_SHARED, "Shared"),
|
||||
(BookmarkSearch.FILTER_SHARED_UNSHARED, "Unshared"),
|
||||
]
|
||||
FILTER_UNREAD_CHOICES = [
|
||||
(BookmarkSearch.FILTER_UNREAD_OFF, 'Off'),
|
||||
(BookmarkSearch.FILTER_UNREAD_YES, 'Unread'),
|
||||
(BookmarkSearch.FILTER_UNREAD_NO, 'Read'),
|
||||
(BookmarkSearch.FILTER_UNREAD_OFF, "Off"),
|
||||
(BookmarkSearch.FILTER_UNREAD_YES, "Unread"),
|
||||
(BookmarkSearch.FILTER_UNREAD_NO, "Read"),
|
||||
]
|
||||
|
||||
q = forms.CharField()
|
||||
@@ -228,7 +234,12 @@ class BookmarkSearchForm(forms.Form):
|
||||
shared = forms.ChoiceField(choices=FILTER_SHARED_CHOICES, widget=forms.RadioSelect)
|
||||
unread = forms.ChoiceField(choices=FILTER_UNREAD_CHOICES, widget=forms.RadioSelect)
|
||||
|
||||
def __init__(self, search: BookmarkSearch, editable_fields: List[str] = None, users: List[User] = None):
|
||||
def __init__(
|
||||
self,
|
||||
search: BookmarkSearch,
|
||||
editable_fields: List[str] = None,
|
||||
users: List[User] = None,
|
||||
):
|
||||
super().__init__()
|
||||
editable_fields = editable_fields or []
|
||||
self.editable_fields = editable_fields
|
||||
@@ -236,8 +247,8 @@ class BookmarkSearchForm(forms.Form):
|
||||
# set choices for user field if users are provided
|
||||
if users:
|
||||
user_choices = [(user.username, user.username) for user in users]
|
||||
user_choices.insert(0, ('', 'Everyone'))
|
||||
self.fields['user'].choices = user_choices
|
||||
user_choices.insert(0, ("", "Everyone"))
|
||||
self.fields["user"].choices = user_choices
|
||||
|
||||
for param in search.params:
|
||||
# set initial values for modified params
|
||||
@@ -251,63 +262,95 @@ class BookmarkSearchForm(forms.Form):
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
THEME_AUTO = 'auto'
|
||||
THEME_LIGHT = 'light'
|
||||
THEME_DARK = 'dark'
|
||||
THEME_AUTO = "auto"
|
||||
THEME_LIGHT = "light"
|
||||
THEME_DARK = "dark"
|
||||
THEME_CHOICES = [
|
||||
(THEME_AUTO, 'Auto'),
|
||||
(THEME_LIGHT, 'Light'),
|
||||
(THEME_DARK, 'Dark'),
|
||||
(THEME_AUTO, "Auto"),
|
||||
(THEME_LIGHT, "Light"),
|
||||
(THEME_DARK, "Dark"),
|
||||
]
|
||||
BOOKMARK_DATE_DISPLAY_RELATIVE = 'relative'
|
||||
BOOKMARK_DATE_DISPLAY_ABSOLUTE = 'absolute'
|
||||
BOOKMARK_DATE_DISPLAY_HIDDEN = 'hidden'
|
||||
BOOKMARK_DATE_DISPLAY_RELATIVE = "relative"
|
||||
BOOKMARK_DATE_DISPLAY_ABSOLUTE = "absolute"
|
||||
BOOKMARK_DATE_DISPLAY_HIDDEN = "hidden"
|
||||
BOOKMARK_DATE_DISPLAY_CHOICES = [
|
||||
(BOOKMARK_DATE_DISPLAY_RELATIVE, 'Relative'),
|
||||
(BOOKMARK_DATE_DISPLAY_ABSOLUTE, 'Absolute'),
|
||||
(BOOKMARK_DATE_DISPLAY_HIDDEN, 'Hidden'),
|
||||
(BOOKMARK_DATE_DISPLAY_RELATIVE, "Relative"),
|
||||
(BOOKMARK_DATE_DISPLAY_ABSOLUTE, "Absolute"),
|
||||
(BOOKMARK_DATE_DISPLAY_HIDDEN, "Hidden"),
|
||||
]
|
||||
BOOKMARK_LINK_TARGET_BLANK = '_blank'
|
||||
BOOKMARK_LINK_TARGET_SELF = '_self'
|
||||
BOOKMARK_LINK_TARGET_BLANK = "_blank"
|
||||
BOOKMARK_LINK_TARGET_SELF = "_self"
|
||||
BOOKMARK_LINK_TARGET_CHOICES = [
|
||||
(BOOKMARK_LINK_TARGET_BLANK, 'New page'),
|
||||
(BOOKMARK_LINK_TARGET_SELF, 'Same page'),
|
||||
(BOOKMARK_LINK_TARGET_BLANK, "New page"),
|
||||
(BOOKMARK_LINK_TARGET_SELF, "Same page"),
|
||||
]
|
||||
WEB_ARCHIVE_INTEGRATION_DISABLED = 'disabled'
|
||||
WEB_ARCHIVE_INTEGRATION_ENABLED = 'enabled'
|
||||
WEB_ARCHIVE_INTEGRATION_DISABLED = "disabled"
|
||||
WEB_ARCHIVE_INTEGRATION_ENABLED = "enabled"
|
||||
WEB_ARCHIVE_INTEGRATION_CHOICES = [
|
||||
(WEB_ARCHIVE_INTEGRATION_DISABLED, 'Disabled'),
|
||||
(WEB_ARCHIVE_INTEGRATION_ENABLED, 'Enabled'),
|
||||
(WEB_ARCHIVE_INTEGRATION_DISABLED, "Disabled"),
|
||||
(WEB_ARCHIVE_INTEGRATION_ENABLED, "Enabled"),
|
||||
]
|
||||
TAG_SEARCH_STRICT = 'strict'
|
||||
TAG_SEARCH_LAX = 'lax'
|
||||
TAG_SEARCH_STRICT = "strict"
|
||||
TAG_SEARCH_LAX = "lax"
|
||||
TAG_SEARCH_CHOICES = [
|
||||
(TAG_SEARCH_STRICT, 'Strict'),
|
||||
(TAG_SEARCH_LAX, 'Lax'),
|
||||
(TAG_SEARCH_STRICT, "Strict"),
|
||||
(TAG_SEARCH_LAX, "Lax"),
|
||||
]
|
||||
user = models.OneToOneField(get_user_model(), related_name='profile', on_delete=models.CASCADE)
|
||||
theme = models.CharField(max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO)
|
||||
bookmark_date_display = models.CharField(max_length=10, choices=BOOKMARK_DATE_DISPLAY_CHOICES, blank=False,
|
||||
default=BOOKMARK_DATE_DISPLAY_RELATIVE)
|
||||
bookmark_link_target = models.CharField(max_length=10, choices=BOOKMARK_LINK_TARGET_CHOICES, blank=False,
|
||||
default=BOOKMARK_LINK_TARGET_BLANK)
|
||||
web_archive_integration = models.CharField(max_length=10, choices=WEB_ARCHIVE_INTEGRATION_CHOICES, blank=False,
|
||||
default=WEB_ARCHIVE_INTEGRATION_DISABLED)
|
||||
tag_search = models.CharField(max_length=10, choices=TAG_SEARCH_CHOICES, blank=False,
|
||||
default=TAG_SEARCH_STRICT)
|
||||
user = models.OneToOneField(
|
||||
get_user_model(), related_name="profile", on_delete=models.CASCADE
|
||||
)
|
||||
theme = models.CharField(
|
||||
max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO
|
||||
)
|
||||
bookmark_date_display = models.CharField(
|
||||
max_length=10,
|
||||
choices=BOOKMARK_DATE_DISPLAY_CHOICES,
|
||||
blank=False,
|
||||
default=BOOKMARK_DATE_DISPLAY_RELATIVE,
|
||||
)
|
||||
bookmark_link_target = models.CharField(
|
||||
max_length=10,
|
||||
choices=BOOKMARK_LINK_TARGET_CHOICES,
|
||||
blank=False,
|
||||
default=BOOKMARK_LINK_TARGET_BLANK,
|
||||
)
|
||||
web_archive_integration = models.CharField(
|
||||
max_length=10,
|
||||
choices=WEB_ARCHIVE_INTEGRATION_CHOICES,
|
||||
blank=False,
|
||||
default=WEB_ARCHIVE_INTEGRATION_DISABLED,
|
||||
)
|
||||
tag_search = models.CharField(
|
||||
max_length=10,
|
||||
choices=TAG_SEARCH_CHOICES,
|
||||
blank=False,
|
||||
default=TAG_SEARCH_STRICT,
|
||||
)
|
||||
enable_sharing = models.BooleanField(default=False, null=False)
|
||||
enable_public_sharing = models.BooleanField(default=False, null=False)
|
||||
enable_favicons = models.BooleanField(default=False, null=False)
|
||||
display_url = models.BooleanField(default=False, null=False)
|
||||
permanent_notes = models.BooleanField(default=False, null=False)
|
||||
custom_css = models.TextField(blank=True, null=False)
|
||||
search_preferences = models.JSONField(default=dict, null=False)
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'tag_search',
|
||||
'enable_sharing', 'enable_public_sharing', 'enable_favicons', 'display_url', 'permanent_notes']
|
||||
fields = [
|
||||
"theme",
|
||||
"bookmark_date_display",
|
||||
"bookmark_link_target",
|
||||
"web_archive_integration",
|
||||
"tag_search",
|
||||
"enable_sharing",
|
||||
"enable_public_sharing",
|
||||
"enable_favicons",
|
||||
"display_url",
|
||||
"permanent_notes",
|
||||
"custom_css",
|
||||
]
|
||||
|
||||
|
||||
@receiver(post_save, sender=get_user_model())
|
||||
@@ -332,11 +375,13 @@ class FeedToken(models.Model):
|
||||
"""
|
||||
Adapted from authtoken.models.Token
|
||||
"""
|
||||
|
||||
key = models.CharField(max_length=40, primary_key=True)
|
||||
user = models.OneToOneField(get_user_model(),
|
||||
related_name='feed_token',
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
user = models.OneToOneField(
|
||||
get_user_model(),
|
||||
related_name="feed_token",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -10,18 +10,24 @@ from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
||||
from bookmarks.utils import unique
|
||||
|
||||
|
||||
def query_bookmarks(user: User, profile: UserProfile, search: BookmarkSearch) -> QuerySet:
|
||||
return _base_bookmarks_query(user, profile, search) \
|
||||
.filter(is_archived=False)
|
||||
def query_bookmarks(
|
||||
user: User, profile: UserProfile, search: BookmarkSearch
|
||||
) -> QuerySet:
|
||||
return _base_bookmarks_query(user, profile, search).filter(is_archived=False)
|
||||
|
||||
|
||||
def query_archived_bookmarks(user: User, profile: UserProfile, search: BookmarkSearch) -> QuerySet:
|
||||
return _base_bookmarks_query(user, profile, search) \
|
||||
.filter(is_archived=True)
|
||||
def query_archived_bookmarks(
|
||||
user: User, profile: UserProfile, search: BookmarkSearch
|
||||
) -> QuerySet:
|
||||
return _base_bookmarks_query(user, profile, search).filter(is_archived=True)
|
||||
|
||||
|
||||
def query_shared_bookmarks(user: Optional[User], profile: UserProfile, search: BookmarkSearch,
|
||||
public_only: bool) -> QuerySet:
|
||||
def query_shared_bookmarks(
|
||||
user: Optional[User],
|
||||
profile: UserProfile,
|
||||
search: BookmarkSearch,
|
||||
public_only: bool,
|
||||
) -> QuerySet:
|
||||
conditions = Q(shared=True) & Q(owner__profile__enable_sharing=True)
|
||||
if public_only:
|
||||
conditions = conditions & Q(owner__profile__enable_public_sharing=True)
|
||||
@@ -29,7 +35,9 @@ def query_shared_bookmarks(user: Optional[User], profile: UserProfile, search: B
|
||||
return _base_bookmarks_query(user, profile, search).filter(conditions)
|
||||
|
||||
|
||||
def _base_bookmarks_query(user: Optional[User], profile: UserProfile, search: BookmarkSearch) -> QuerySet:
|
||||
def _base_bookmarks_query(
|
||||
user: Optional[User], profile: UserProfile, search: BookmarkSearch
|
||||
) -> QuerySet:
|
||||
query_set = Bookmark.objects
|
||||
|
||||
# Filter for user
|
||||
@@ -40,34 +48,32 @@ def _base_bookmarks_query(user: Optional[User], profile: UserProfile, search: Bo
|
||||
query = parse_query_string(search.q)
|
||||
|
||||
# Filter for search terms and tags
|
||||
for term in query['search_terms']:
|
||||
conditions = Q(title__icontains=term) \
|
||||
| Q(description__icontains=term) \
|
||||
| Q(notes__icontains=term) \
|
||||
| Q(website_title__icontains=term) \
|
||||
| Q(website_description__icontains=term) \
|
||||
| Q(url__icontains=term)
|
||||
for term in query["search_terms"]:
|
||||
conditions = (
|
||||
Q(title__icontains=term)
|
||||
| Q(description__icontains=term)
|
||||
| Q(notes__icontains=term)
|
||||
| Q(website_title__icontains=term)
|
||||
| Q(website_description__icontains=term)
|
||||
| Q(url__icontains=term)
|
||||
)
|
||||
|
||||
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
|
||||
conditions = conditions | Exists(Bookmark.objects.filter(id=OuterRef('id'), tags__name__iexact=term))
|
||||
conditions = conditions | Exists(
|
||||
Bookmark.objects.filter(id=OuterRef("id"), tags__name__iexact=term)
|
||||
)
|
||||
|
||||
query_set = query_set.filter(conditions)
|
||||
|
||||
for tag_name in query['tag_names']:
|
||||
query_set = query_set.filter(
|
||||
tags__name__iexact=tag_name
|
||||
)
|
||||
for tag_name in query["tag_names"]:
|
||||
query_set = query_set.filter(tags__name__iexact=tag_name)
|
||||
|
||||
# Untagged bookmarks
|
||||
if query['untagged']:
|
||||
query_set = query_set.filter(
|
||||
tags=None
|
||||
)
|
||||
if query["untagged"]:
|
||||
query_set = query_set.filter(tags=None)
|
||||
# Legacy unread bookmarks filter from query
|
||||
if query['unread']:
|
||||
query_set = query_set.filter(
|
||||
unread=True
|
||||
)
|
||||
if query["unread"]:
|
||||
query_set = query_set.filter(unread=True)
|
||||
|
||||
# Unread filter from bookmark search
|
||||
if search.unread == BookmarkSearch.FILTER_UNREAD_YES:
|
||||
@@ -83,29 +89,36 @@ def _base_bookmarks_query(user: Optional[User], profile: UserProfile, search: Bo
|
||||
|
||||
# Sort by date added
|
||||
if search.sort == BookmarkSearch.SORT_ADDED_ASC:
|
||||
query_set = query_set.order_by('date_added')
|
||||
query_set = query_set.order_by("date_added")
|
||||
elif search.sort == BookmarkSearch.SORT_ADDED_DESC:
|
||||
query_set = query_set.order_by('-date_added')
|
||||
query_set = query_set.order_by("-date_added")
|
||||
|
||||
# Sort by title
|
||||
if search.sort == BookmarkSearch.SORT_TITLE_ASC or search.sort == BookmarkSearch.SORT_TITLE_DESC:
|
||||
if (
|
||||
search.sort == BookmarkSearch.SORT_TITLE_ASC
|
||||
or search.sort == BookmarkSearch.SORT_TITLE_DESC
|
||||
):
|
||||
# For the title, the resolved_title logic from the Bookmark entity needs
|
||||
# to be replicated as there is no corresponding database field
|
||||
query_set = query_set.annotate(
|
||||
effective_title=Case(
|
||||
When(Q(title__isnull=False) & ~Q(title__exact=''), then=Lower('title')),
|
||||
When(Q(website_title__isnull=False) & ~Q(website_title__exact=''), then=Lower('website_title')),
|
||||
default=Lower('url'),
|
||||
output_field=CharField()
|
||||
))
|
||||
When(Q(title__isnull=False) & ~Q(title__exact=""), then=Lower("title")),
|
||||
When(
|
||||
Q(website_title__isnull=False) & ~Q(website_title__exact=""),
|
||||
then=Lower("website_title"),
|
||||
),
|
||||
default=Lower("url"),
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
|
||||
# For SQLite, if the ICU extension is loaded, use the custom collation
|
||||
# loaded into the connection. This results in an improved sort order for
|
||||
# unicode characters (umlauts, etc.)
|
||||
if settings.USE_SQLITE and settings.USE_SQLITE_ICU_EXTENSION:
|
||||
order_field = RawSQL('effective_title COLLATE ICU', ())
|
||||
order_field = RawSQL("effective_title COLLATE ICU", ())
|
||||
else:
|
||||
order_field = 'effective_title'
|
||||
order_field = "effective_title"
|
||||
|
||||
if search.sort == BookmarkSearch.SORT_TITLE_ASC:
|
||||
query_set = query_set.order_by(order_field)
|
||||
@@ -115,7 +128,9 @@ def _base_bookmarks_query(user: Optional[User], profile: UserProfile, search: Bo
|
||||
return query_set
|
||||
|
||||
|
||||
def query_bookmark_tags(user: User, profile: UserProfile, search: BookmarkSearch) -> QuerySet:
|
||||
def query_bookmark_tags(
|
||||
user: User, profile: UserProfile, search: BookmarkSearch
|
||||
) -> QuerySet:
|
||||
bookmarks_query = query_bookmarks(user, profile, search)
|
||||
|
||||
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
||||
@@ -123,7 +138,9 @@ def query_bookmark_tags(user: User, profile: UserProfile, search: BookmarkSearch
|
||||
return query_set.distinct()
|
||||
|
||||
|
||||
def query_archived_bookmark_tags(user: User, profile: UserProfile, search: BookmarkSearch) -> QuerySet:
|
||||
def query_archived_bookmark_tags(
|
||||
user: User, profile: UserProfile, search: BookmarkSearch
|
||||
) -> QuerySet:
|
||||
bookmarks_query = query_archived_bookmarks(user, profile, search)
|
||||
|
||||
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
||||
@@ -131,8 +148,12 @@ def query_archived_bookmark_tags(user: User, profile: UserProfile, search: Bookm
|
||||
return query_set.distinct()
|
||||
|
||||
|
||||
def query_shared_bookmark_tags(user: Optional[User], profile: UserProfile, search: BookmarkSearch,
|
||||
public_only: bool) -> QuerySet:
|
||||
def query_shared_bookmark_tags(
|
||||
user: Optional[User],
|
||||
profile: UserProfile,
|
||||
search: BookmarkSearch,
|
||||
public_only: bool,
|
||||
) -> QuerySet:
|
||||
bookmarks_query = query_shared_bookmarks(user, profile, search, public_only)
|
||||
|
||||
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
||||
@@ -140,7 +161,9 @@ def query_shared_bookmark_tags(user: Optional[User], profile: UserProfile, searc
|
||||
return query_set.distinct()
|
||||
|
||||
|
||||
def query_shared_bookmark_users(profile: UserProfile, search: BookmarkSearch, public_only: bool) -> QuerySet:
|
||||
def query_shared_bookmark_users(
|
||||
profile: UserProfile, search: BookmarkSearch, public_only: bool
|
||||
) -> QuerySet:
|
||||
bookmarks_query = query_shared_bookmarks(None, profile, search, public_only)
|
||||
|
||||
query_set = User.objects.filter(bookmark__in=bookmarks_query)
|
||||
@@ -155,23 +178,23 @@ def get_user_tags(user: User):
|
||||
def parse_query_string(query_string):
|
||||
# Sanitize query params
|
||||
if not query_string:
|
||||
query_string = ''
|
||||
query_string = ""
|
||||
|
||||
# Split query into search terms and tags
|
||||
keywords = query_string.strip().split(' ')
|
||||
keywords = query_string.strip().split(" ")
|
||||
keywords = [word for word in keywords if word]
|
||||
|
||||
search_terms = [word for word in keywords if word[0] != '#' and word[0] != '!']
|
||||
tag_names = [word[1:] for word in keywords if word[0] == '#']
|
||||
search_terms = [word for word in keywords if word[0] != "#" and word[0] != "!"]
|
||||
tag_names = [word[1:] for word in keywords if word[0] == "#"]
|
||||
tag_names = unique(tag_names, str.lower)
|
||||
|
||||
# Special search commands
|
||||
untagged = '!untagged' in keywords
|
||||
unread = '!unread' in keywords
|
||||
untagged = "!untagged" in keywords
|
||||
unread = "!unread" in keywords
|
||||
|
||||
return {
|
||||
'search_terms': search_terms,
|
||||
'tag_names': tag_names,
|
||||
'untagged': untagged,
|
||||
'unread': unread,
|
||||
"search_terms": search_terms,
|
||||
"tag_names": tag_names,
|
||||
"untagged": untagged,
|
||||
"unread": unread,
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ from bookmarks.services import tasks
|
||||
|
||||
def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
||||
# If URL is already bookmarked, then update it
|
||||
existing_bookmark: Bookmark = Bookmark.objects.filter(owner=current_user, url=bookmark.url).first()
|
||||
existing_bookmark: Bookmark = Bookmark.objects.filter(
|
||||
owner=current_user, url=bookmark.url
|
||||
).first()
|
||||
|
||||
if existing_bookmark is not None:
|
||||
_merge_bookmark_data(bookmark, existing_bookmark)
|
||||
@@ -68,8 +70,9 @@ def archive_bookmark(bookmark: Bookmark):
|
||||
def archive_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(is_archived=True,
|
||||
date_modified=timezone.now())
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(
|
||||
is_archived=True, date_modified=timezone.now()
|
||||
)
|
||||
|
||||
|
||||
def unarchive_bookmark(bookmark: Bookmark):
|
||||
@@ -82,8 +85,9 @@ def unarchive_bookmark(bookmark: Bookmark):
|
||||
def unarchive_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(is_archived=False,
|
||||
date_modified=timezone.now())
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(
|
||||
is_archived=False, date_modified=timezone.now()
|
||||
)
|
||||
|
||||
|
||||
def delete_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
@@ -94,8 +98,9 @@ def delete_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
|
||||
def tag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
owned_bookmark_ids = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).values_list('id',
|
||||
flat=True)
|
||||
owned_bookmark_ids = Bookmark.objects.filter(
|
||||
owner=current_user, id__in=sanitized_bookmark_ids
|
||||
).values_list("id", flat=True)
|
||||
tag_names = parse_tag_string(tag_string)
|
||||
tags = get_or_create_tags(tag_names, current_user)
|
||||
|
||||
@@ -103,54 +108,69 @@ def tag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user
|
||||
relationships = []
|
||||
for tag in tags:
|
||||
for bookmark_id in owned_bookmark_ids:
|
||||
relationships.append(BookmarkToTagRelationShip(bookmark_id=bookmark_id, tag=tag))
|
||||
relationships.append(
|
||||
BookmarkToTagRelationShip(bookmark_id=bookmark_id, tag=tag)
|
||||
)
|
||||
|
||||
# Insert all bookmark -> tag associations at once, should ignore errors if association already exists
|
||||
BookmarkToTagRelationShip.objects.bulk_create(relationships, ignore_conflicts=True)
|
||||
Bookmark.objects.filter(id__in=owned_bookmark_ids).update(date_modified=timezone.now())
|
||||
Bookmark.objects.filter(id__in=owned_bookmark_ids).update(
|
||||
date_modified=timezone.now()
|
||||
)
|
||||
|
||||
|
||||
def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
|
||||
def untag_bookmarks(
|
||||
bookmark_ids: [Union[int, str]], tag_string: str, current_user: User
|
||||
):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
owned_bookmark_ids = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).values_list('id',
|
||||
flat=True)
|
||||
owned_bookmark_ids = Bookmark.objects.filter(
|
||||
owner=current_user, id__in=sanitized_bookmark_ids
|
||||
).values_list("id", flat=True)
|
||||
tag_names = parse_tag_string(tag_string)
|
||||
tags = get_or_create_tags(tag_names, current_user)
|
||||
|
||||
BookmarkToTagRelationShip = Bookmark.tags.through
|
||||
for tag in tags:
|
||||
# Remove all bookmark -> tag associations for the owned bookmarks and the current tag
|
||||
BookmarkToTagRelationShip.objects.filter(bookmark_id__in=owned_bookmark_ids, tag=tag).delete()
|
||||
BookmarkToTagRelationShip.objects.filter(
|
||||
bookmark_id__in=owned_bookmark_ids, tag=tag
|
||||
).delete()
|
||||
|
||||
Bookmark.objects.filter(id__in=owned_bookmark_ids).update(date_modified=timezone.now())
|
||||
Bookmark.objects.filter(id__in=owned_bookmark_ids).update(
|
||||
date_modified=timezone.now()
|
||||
)
|
||||
|
||||
|
||||
def mark_bookmarks_as_read(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(unread=False,
|
||||
date_modified=timezone.now())
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(
|
||||
unread=False, date_modified=timezone.now()
|
||||
)
|
||||
|
||||
|
||||
def mark_bookmarks_as_unread(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(unread=True,
|
||||
date_modified=timezone.now())
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(
|
||||
unread=True, date_modified=timezone.now()
|
||||
)
|
||||
|
||||
|
||||
def share_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(shared=True,
|
||||
date_modified=timezone.now())
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(
|
||||
shared=True, date_modified=timezone.now()
|
||||
)
|
||||
|
||||
|
||||
def unshare_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
|
||||
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
|
||||
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(shared=False,
|
||||
date_modified=timezone.now())
|
||||
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).update(
|
||||
shared=False, date_modified=timezone.now()
|
||||
)
|
||||
|
||||
|
||||
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
||||
|
||||
@@ -13,40 +13,41 @@ def export_netscape_html(bookmarks: List[Bookmark]):
|
||||
[append_bookmark(doc, bookmark) for bookmark in bookmarks]
|
||||
append_list_end(doc)
|
||||
|
||||
return '\n\r'.join(doc)
|
||||
return "\n\r".join(doc)
|
||||
|
||||
|
||||
def append_header(doc: BookmarkDocument):
|
||||
doc.append('<!DOCTYPE NETSCAPE-Bookmark-file-1>')
|
||||
doc.append("<!DOCTYPE NETSCAPE-Bookmark-file-1>")
|
||||
doc.append('<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">')
|
||||
doc.append('<TITLE>Bookmarks</TITLE>')
|
||||
doc.append('<H1>Bookmarks</H1>')
|
||||
doc.append("<TITLE>Bookmarks</TITLE>")
|
||||
doc.append("<H1>Bookmarks</H1>")
|
||||
|
||||
|
||||
def append_list_start(doc: BookmarkDocument):
|
||||
doc.append('<DL><p>')
|
||||
doc.append("<DL><p>")
|
||||
|
||||
|
||||
def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
|
||||
url = bookmark.url
|
||||
title = html.escape(bookmark.resolved_title or '')
|
||||
desc = html.escape(bookmark.resolved_description or '')
|
||||
title = html.escape(bookmark.resolved_title or "")
|
||||
desc = html.escape(bookmark.resolved_description or "")
|
||||
if bookmark.notes:
|
||||
desc += f'[linkding-notes]{html.escape(bookmark.notes)}[/linkding-notes]'
|
||||
desc += f"[linkding-notes]{html.escape(bookmark.notes)}[/linkding-notes]"
|
||||
tag_names = bookmark.tag_names
|
||||
if bookmark.is_archived:
|
||||
tag_names.append('linkding:archived')
|
||||
tags = ','.join(tag_names)
|
||||
toread = '1' if bookmark.unread else '0'
|
||||
private = '0' if bookmark.shared else '1'
|
||||
tag_names.append("linkding:archived")
|
||||
tags = ",".join(tag_names)
|
||||
toread = "1" if bookmark.unread else "0"
|
||||
private = "0" if bookmark.shared else "1"
|
||||
added = int(bookmark.date_added.timestamp())
|
||||
|
||||
doc.append(
|
||||
f'<DT><A HREF="{url}" ADD_DATE="{added}" PRIVATE="{private}" TOREAD="{toread}" TAGS="{tags}">{title}</A>')
|
||||
f'<DT><A HREF="{url}" ADD_DATE="{added}" PRIVATE="{private}" TOREAD="{toread}" TAGS="{tags}">{title}</A>'
|
||||
)
|
||||
|
||||
if desc:
|
||||
doc.append(f'<DD>{desc}')
|
||||
doc.append(f"<DD>{desc}")
|
||||
|
||||
|
||||
def append_list_end(doc: BookmarkDocument):
|
||||
doc.append('</DL><p>')
|
||||
doc.append("</DL><p>")
|
||||
|
||||
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# register mime type for .ico files, which is not included in the default
|
||||
# mimetypes of the Docker image
|
||||
mimetypes.add_type('image/x-icon', '.ico')
|
||||
mimetypes.add_type("image/x-icon", ".ico")
|
||||
|
||||
|
||||
def _ensure_favicon_folder():
|
||||
@@ -23,16 +23,16 @@ def _ensure_favicon_folder():
|
||||
|
||||
|
||||
def _url_to_filename(url: str) -> str:
|
||||
return re.sub(r'\W+', '_', url)
|
||||
return re.sub(r"\W+", "_", url)
|
||||
|
||||
|
||||
def _get_url_parameters(url: str) -> dict:
|
||||
parsed_uri = urlparse(url)
|
||||
return {
|
||||
# https://example.com/foo?bar -> https://example.com
|
||||
'url': f'{parsed_uri.scheme}://{parsed_uri.hostname}',
|
||||
"url": f"{parsed_uri.scheme}://{parsed_uri.hostname}",
|
||||
# https://example.com/foo?bar -> example.com
|
||||
'domain': parsed_uri.hostname,
|
||||
"domain": parsed_uri.hostname,
|
||||
}
|
||||
|
||||
|
||||
@@ -63,21 +63,21 @@ def load_favicon(url: str) -> str:
|
||||
# Create favicon folder if not exists
|
||||
_ensure_favicon_folder()
|
||||
# Use scheme+hostname as favicon filename to reuse icon for all pages on the same domain
|
||||
favicon_name = _url_to_filename(url_parameters['url'])
|
||||
favicon_name = _url_to_filename(url_parameters["url"])
|
||||
favicon_file = _check_existing_favicon(favicon_name)
|
||||
|
||||
if not favicon_file:
|
||||
# Load favicon from provider, save to file
|
||||
favicon_url = settings.LD_FAVICON_PROVIDER.format(**url_parameters)
|
||||
logger.debug(f'Loading favicon from: {favicon_url}')
|
||||
logger.debug(f"Loading favicon from: {favicon_url}")
|
||||
with requests.get(favicon_url, stream=True) as response:
|
||||
content_type = response.headers['Content-Type']
|
||||
content_type = response.headers["Content-Type"]
|
||||
file_extension = mimetypes.guess_extension(content_type)
|
||||
favicon_file = f'{favicon_name}{file_extension}'
|
||||
favicon_file = f"{favicon_name}{file_extension}"
|
||||
favicon_path = _get_favicon_path(favicon_file)
|
||||
with open(favicon_path, 'wb') as file:
|
||||
with open(favicon_path, "wb") as file:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
file.write(chunk)
|
||||
logger.debug(f'Saved favicon as: {favicon_path}')
|
||||
logger.debug(f"Saved favicon as: {favicon_path}")
|
||||
|
||||
return favicon_file
|
||||
|
||||
@@ -55,18 +55,20 @@ class TagCache:
|
||||
self.cache[tag.name.lower()] = tag
|
||||
|
||||
|
||||
def import_netscape_html(html: str, user: User, options: ImportOptions = ImportOptions()) -> ImportResult:
|
||||
def import_netscape_html(
|
||||
html: str, user: User, options: ImportOptions = ImportOptions()
|
||||
) -> ImportResult:
|
||||
result = ImportResult()
|
||||
import_start = timezone.now()
|
||||
|
||||
try:
|
||||
netscape_bookmarks = parse(html)
|
||||
except:
|
||||
logging.exception('Could not read bookmarks file.')
|
||||
logging.exception("Could not read bookmarks file.")
|
||||
raise
|
||||
|
||||
parse_end = timezone.now()
|
||||
logger.debug(f'Parse duration: {parse_end - import_start}')
|
||||
logger.debug(f"Parse duration: {parse_end - import_start}")
|
||||
|
||||
# Create and cache all tags beforehand
|
||||
_create_missing_tags(netscape_bookmarks, user)
|
||||
@@ -83,7 +85,7 @@ def import_netscape_html(html: str, user: User, options: ImportOptions = ImportO
|
||||
tasks.schedule_bookmarks_without_favicons(user)
|
||||
|
||||
end = timezone.now()
|
||||
logger.debug(f'Import duration: {end - import_start}')
|
||||
logger.debug(f"Import duration: {end - import_start}")
|
||||
|
||||
return result
|
||||
|
||||
@@ -110,7 +112,7 @@ def _get_batches(items: List, batch_size: int):
|
||||
num_items = len(items)
|
||||
|
||||
while offset < num_items:
|
||||
batch = items[offset:min(offset + batch_size, num_items)]
|
||||
batch = items[offset : min(offset + batch_size, num_items)]
|
||||
if len(batch) > 0:
|
||||
batches.append(batch)
|
||||
offset = offset + batch_size
|
||||
@@ -118,11 +120,13 @@ def _get_batches(items: List, batch_size: int):
|
||||
return batches
|
||||
|
||||
|
||||
def _import_batch(netscape_bookmarks: List[NetscapeBookmark],
|
||||
user: User,
|
||||
options: ImportOptions,
|
||||
tag_cache: TagCache,
|
||||
result: ImportResult):
|
||||
def _import_batch(
|
||||
netscape_bookmarks: List[NetscapeBookmark],
|
||||
user: User,
|
||||
options: ImportOptions,
|
||||
tag_cache: TagCache,
|
||||
result: ImportResult,
|
||||
):
|
||||
# Query existing bookmarks
|
||||
batch_urls = [bookmark.href for bookmark in netscape_bookmarks]
|
||||
existing_bookmarks = Bookmark.objects.filter(owner=user, url__in=batch_urls)
|
||||
@@ -136,7 +140,13 @@ def _import_batch(netscape_bookmarks: List[NetscapeBookmark],
|
||||
try:
|
||||
# Lookup existing bookmark by URL, or create new bookmark if there is no bookmark for that URL yet
|
||||
bookmark = next(
|
||||
(bookmark for bookmark in existing_bookmarks if bookmark.url == netscape_bookmark.href), None)
|
||||
(
|
||||
bookmark
|
||||
for bookmark in existing_bookmarks
|
||||
if bookmark.url == netscape_bookmark.href
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not bookmark:
|
||||
bookmark = Bookmark(owner=user)
|
||||
is_update = False
|
||||
@@ -146,7 +156,7 @@ def _import_batch(netscape_bookmarks: List[NetscapeBookmark],
|
||||
_copy_bookmark_data(netscape_bookmark, bookmark, options)
|
||||
# Validate bookmark fields, exclude owner to prevent n+1 database query,
|
||||
# also there is no specific validation on owner
|
||||
bookmark.clean_fields(exclude=['owner'])
|
||||
bookmark.clean_fields(exclude=["owner"])
|
||||
# Schedule for update or insert
|
||||
if is_update:
|
||||
bookmarks_to_update.append(bookmark)
|
||||
@@ -155,20 +165,25 @@ def _import_batch(netscape_bookmarks: List[NetscapeBookmark],
|
||||
|
||||
result.success = result.success + 1
|
||||
except:
|
||||
shortened_bookmark_tag_str = str(netscape_bookmark)[:100] + '...'
|
||||
logging.exception('Error importing bookmark: ' + shortened_bookmark_tag_str)
|
||||
shortened_bookmark_tag_str = str(netscape_bookmark)[:100] + "..."
|
||||
logging.exception("Error importing bookmark: " + shortened_bookmark_tag_str)
|
||||
result.failed = result.failed + 1
|
||||
|
||||
# Bulk update bookmarks in DB
|
||||
Bookmark.objects.bulk_update(bookmarks_to_update, ['url',
|
||||
'date_added',
|
||||
'date_modified',
|
||||
'unread',
|
||||
'shared',
|
||||
'title',
|
||||
'description',
|
||||
'notes',
|
||||
'owner'])
|
||||
Bookmark.objects.bulk_update(
|
||||
bookmarks_to_update,
|
||||
[
|
||||
"url",
|
||||
"date_added",
|
||||
"date_modified",
|
||||
"unread",
|
||||
"shared",
|
||||
"title",
|
||||
"description",
|
||||
"notes",
|
||||
"owner",
|
||||
],
|
||||
)
|
||||
# Bulk insert new bookmarks into DB
|
||||
Bookmark.objects.bulk_create(bookmarks_to_create)
|
||||
|
||||
@@ -183,13 +198,20 @@ def _import_batch(netscape_bookmarks: List[NetscapeBookmark],
|
||||
for netscape_bookmark in netscape_bookmarks:
|
||||
# Lookup bookmark by URL again
|
||||
bookmark = next(
|
||||
(bookmark for bookmark in existing_bookmarks if bookmark.url == netscape_bookmark.href), None)
|
||||
(
|
||||
bookmark
|
||||
for bookmark in existing_bookmarks
|
||||
if bookmark.url == netscape_bookmark.href
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if not bookmark:
|
||||
# Something is wrong, we should have just created this bookmark
|
||||
shortened_bookmark_tag_str = str(netscape_bookmark)[:100] + '...'
|
||||
shortened_bookmark_tag_str = str(netscape_bookmark)[:100] + "..."
|
||||
logging.warning(
|
||||
f'Failed to assign tags to the bookmark: {shortened_bookmark_tag_str}. Could not find bookmark by URL.')
|
||||
f"Failed to assign tags to the bookmark: {shortened_bookmark_tag_str}. Could not find bookmark by URL."
|
||||
)
|
||||
continue
|
||||
|
||||
# Get tag models by string, schedule inserts for bookmark -> tag associations
|
||||
@@ -201,7 +223,9 @@ def _import_batch(netscape_bookmarks: List[NetscapeBookmark],
|
||||
BookmarkToTagRelationShip.objects.bulk_create(relationships, ignore_conflicts=True)
|
||||
|
||||
|
||||
def _copy_bookmark_data(netscape_bookmark: NetscapeBookmark, bookmark: Bookmark, options: ImportOptions):
|
||||
def _copy_bookmark_data(
|
||||
netscape_bookmark: NetscapeBookmark, bookmark: Bookmark, options: ImportOptions
|
||||
):
|
||||
bookmark.url = netscape_bookmark.href
|
||||
if netscape_bookmark.date_added:
|
||||
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
|
||||
|
||||
@@ -25,29 +25,29 @@ class BookmarkParser(HTMLParser):
|
||||
|
||||
self.current_tag = None
|
||||
self.bookmark = None
|
||||
self.href = ''
|
||||
self.add_date = ''
|
||||
self.tags = ''
|
||||
self.title = ''
|
||||
self.description = ''
|
||||
self.notes = ''
|
||||
self.toread = ''
|
||||
self.private = ''
|
||||
self.href = ""
|
||||
self.add_date = ""
|
||||
self.tags = ""
|
||||
self.title = ""
|
||||
self.description = ""
|
||||
self.notes = ""
|
||||
self.toread = ""
|
||||
self.private = ""
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list):
|
||||
name = 'handle_start_' + tag.lower()
|
||||
name = "handle_start_" + tag.lower()
|
||||
if name in dir(self):
|
||||
getattr(self, name)({k.lower(): v for k, v in attrs})
|
||||
self.current_tag = tag
|
||||
|
||||
def handle_endtag(self, tag: str):
|
||||
name = 'handle_end_' + tag.lower()
|
||||
name = "handle_end_" + tag.lower()
|
||||
if name in dir(self):
|
||||
getattr(self, name)()
|
||||
self.current_tag = None
|
||||
|
||||
def handle_data(self, data):
|
||||
name = f'handle_{self.current_tag}_data'
|
||||
name = f"handle_{self.current_tag}_data"
|
||||
if name in dir(self):
|
||||
getattr(self, name)(data)
|
||||
|
||||
@@ -60,22 +60,22 @@ class BookmarkParser(HTMLParser):
|
||||
def handle_start_a(self, attrs: Dict[str, str]):
|
||||
vars(self).update(attrs)
|
||||
tag_names = parse_tag_string(self.tags)
|
||||
archived = 'linkding:archived' in self.tags
|
||||
archived = "linkding:archived" in self.tags
|
||||
try:
|
||||
tag_names.remove('linkding:archived')
|
||||
tag_names.remove("linkding:archived")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
self.bookmark = NetscapeBookmark(
|
||||
href=self.href,
|
||||
title='',
|
||||
description='',
|
||||
notes='',
|
||||
title="",
|
||||
description="",
|
||||
notes="",
|
||||
date_added=self.add_date,
|
||||
tag_names=tag_names,
|
||||
to_read=self.toread == '1',
|
||||
to_read=self.toread == "1",
|
||||
# Mark as private by default, also when attribute is not specified
|
||||
private=self.private != '0',
|
||||
private=self.private != "0",
|
||||
archived=archived,
|
||||
)
|
||||
|
||||
@@ -84,9 +84,9 @@ class BookmarkParser(HTMLParser):
|
||||
|
||||
def handle_dd_data(self, data):
|
||||
desc = data.strip()
|
||||
if '[linkding-notes]' in desc:
|
||||
self.notes = desc.split('[linkding-notes]')[1].split('[/linkding-notes]')[0]
|
||||
self.description = desc.split('[linkding-notes]')[0]
|
||||
if "[linkding-notes]" in desc:
|
||||
self.notes = desc.split("[linkding-notes]")[1].split("[/linkding-notes]")[0]
|
||||
self.description = desc.split("[linkding-notes]")[0]
|
||||
|
||||
def add_bookmark(self):
|
||||
if self.bookmark:
|
||||
@@ -95,14 +95,14 @@ class BookmarkParser(HTMLParser):
|
||||
self.bookmark.notes = self.notes
|
||||
self.bookmarks.append(self.bookmark)
|
||||
self.bookmark = None
|
||||
self.href = ''
|
||||
self.add_date = ''
|
||||
self.tags = ''
|
||||
self.title = ''
|
||||
self.description = ''
|
||||
self.notes = ''
|
||||
self.toread = ''
|
||||
self.private = ''
|
||||
self.href = ""
|
||||
self.add_date = ""
|
||||
self.tags = ""
|
||||
self.title = ""
|
||||
self.description = ""
|
||||
self.notes = ""
|
||||
self.toread = ""
|
||||
self.private = ""
|
||||
|
||||
|
||||
def parse(html: str) -> List[NetscapeBookmark]:
|
||||
|
||||
@@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def get_or_create_tags(tag_names: List[str], user: User):
|
||||
tags = [get_or_create_tag(tag_name, user) for tag_name in tag_names]
|
||||
return unique(tags, operator.attrgetter('id'))
|
||||
return unique(tags, operator.attrgetter("id"))
|
||||
|
||||
|
||||
def get_or_create_tag(name: str, user: User):
|
||||
|
||||
@@ -18,8 +18,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def is_web_archive_integration_active(user: User) -> bool:
|
||||
background_tasks_enabled = not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||
web_archive_integration_enabled = \
|
||||
user.profile.web_archive_integration == UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
|
||||
web_archive_integration_enabled = (
|
||||
user.profile.web_archive_integration
|
||||
== UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
|
||||
)
|
||||
|
||||
return background_tasks_enabled and web_archive_integration_enabled
|
||||
|
||||
@@ -31,28 +33,36 @@ def create_web_archive_snapshot(user: User, bookmark: Bookmark, force_update: bo
|
||||
|
||||
def _load_newest_snapshot(bookmark: Bookmark):
|
||||
try:
|
||||
logger.info(f'Load existing snapshot for bookmark. url={bookmark.url}')
|
||||
cdx_api = bookmarks.services.wayback.CustomWaybackMachineCDXServerAPI(bookmark.url)
|
||||
logger.info(f"Load existing snapshot for bookmark. url={bookmark.url}")
|
||||
cdx_api = bookmarks.services.wayback.CustomWaybackMachineCDXServerAPI(
|
||||
bookmark.url
|
||||
)
|
||||
existing_snapshot = cdx_api.newest()
|
||||
|
||||
if existing_snapshot:
|
||||
bookmark.web_archive_snapshot_url = existing_snapshot.archive_url
|
||||
bookmark.save(update_fields=['web_archive_snapshot_url'])
|
||||
logger.info(f'Using newest snapshot. url={bookmark.url} from={existing_snapshot.datetime_timestamp}')
|
||||
bookmark.save(update_fields=["web_archive_snapshot_url"])
|
||||
logger.info(
|
||||
f"Using newest snapshot. url={bookmark.url} from={existing_snapshot.datetime_timestamp}"
|
||||
)
|
||||
|
||||
except NoCDXRecordFound:
|
||||
logger.info(f'Could not find any snapshots for bookmark. url={bookmark.url}')
|
||||
logger.info(f"Could not find any snapshots for bookmark. url={bookmark.url}")
|
||||
except WaybackError as error:
|
||||
logger.error(f'Failed to load existing snapshot. url={bookmark.url}', exc_info=error)
|
||||
logger.error(
|
||||
f"Failed to load existing snapshot. url={bookmark.url}", exc_info=error
|
||||
)
|
||||
|
||||
|
||||
def _create_snapshot(bookmark: Bookmark):
|
||||
logger.info(f'Create new snapshot for bookmark. url={bookmark.url}...')
|
||||
archive = waybackpy.WaybackMachineSaveAPI(bookmark.url, DEFAULT_USER_AGENT, max_tries=1)
|
||||
logger.info(f"Create new snapshot for bookmark. url={bookmark.url}...")
|
||||
archive = waybackpy.WaybackMachineSaveAPI(
|
||||
bookmark.url, DEFAULT_USER_AGENT, max_tries=1
|
||||
)
|
||||
archive.save()
|
||||
bookmark.web_archive_snapshot_url = archive.archive_url
|
||||
bookmark.save(update_fields=['web_archive_snapshot_url'])
|
||||
logger.info(f'Successfully created new snapshot for bookmark:. url={bookmark.url}')
|
||||
bookmark.save(update_fields=["web_archive_snapshot_url"])
|
||||
logger.info(f"Successfully created new snapshot for bookmark:. url={bookmark.url}")
|
||||
|
||||
|
||||
@background()
|
||||
@@ -72,10 +82,13 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
|
||||
return
|
||||
except TooManyRequestsError:
|
||||
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:
|
||||
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)
|
||||
@@ -102,7 +115,9 @@ def schedule_bookmarks_without_snapshots(user: User):
|
||||
@background()
|
||||
def _schedule_bookmarks_without_snapshots_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
bookmarks_without_snapshots = Bookmark.objects.filter(web_archive_snapshot_url__exact='', owner=user)
|
||||
bookmarks_without_snapshots = Bookmark.objects.filter(
|
||||
web_archive_snapshot_url__exact="", owner=user
|
||||
)
|
||||
|
||||
for bookmark in bookmarks_without_snapshots:
|
||||
# To prevent rate limit errors from the Wayback API only try to load the latest snapshots instead of creating
|
||||
@@ -128,14 +143,16 @@ def _load_favicon_task(bookmark_id: int):
|
||||
except Bookmark.DoesNotExist:
|
||||
return
|
||||
|
||||
logger.info(f'Load favicon for bookmark. url={bookmark.url}')
|
||||
logger.info(f"Load favicon for bookmark. url={bookmark.url}")
|
||||
|
||||
new_favicon_file = favicon_loader.load_favicon(bookmark.url)
|
||||
|
||||
if new_favicon_file != bookmark.favicon_file:
|
||||
bookmark.favicon_file = new_favicon_file
|
||||
bookmark.save(update_fields=['favicon_file'])
|
||||
logger.info(f'Successfully updated favicon for bookmark. url={bookmark.url} icon={new_favicon_file}')
|
||||
bookmark.save(update_fields=["favicon_file"])
|
||||
logger.info(
|
||||
f"Successfully updated favicon for bookmark. url={bookmark.url} icon={new_favicon_file}"
|
||||
)
|
||||
|
||||
|
||||
def schedule_bookmarks_without_favicons(user: User):
|
||||
@@ -146,11 +163,13 @@ def schedule_bookmarks_without_favicons(user: User):
|
||||
@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)
|
||||
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,))
|
||||
task = Task.objects.new_task(
|
||||
task_name="bookmarks.services.tasks._load_favicon_task", args=(bookmark.id,)
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
Task.objects.bulk_create(tasks)
|
||||
@@ -168,7 +187,9 @@ def _schedule_refresh_favicons_task(user_id: int):
|
||||
tasks = []
|
||||
|
||||
for bookmark in bookmarks:
|
||||
task = Task.objects.new_task(task_name='bookmarks.services.tasks._load_favicon_task', args=(bookmark.id,))
|
||||
task = Task.objects.new_task(
|
||||
task_name="bookmarks.services.tasks._load_favicon_task", args=(bookmark.id,)
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
Task.objects.bulk_create(tasks)
|
||||
|
||||
@@ -14,8 +14,10 @@ class CustomWaybackMachineCDXServerAPI(waybackpy.WaybackMachineCDXServerAPI):
|
||||
|
||||
def newest(self):
|
||||
unix_timestamp = int(time.time())
|
||||
self.closest = waybackpy.utils.unix_timestamp_to_wayback_timestamp(unix_timestamp)
|
||||
self.sort = 'closest'
|
||||
self.closest = waybackpy.utils.unix_timestamp_to_wayback_timestamp(
|
||||
unix_timestamp
|
||||
)
|
||||
self.sort = "closest"
|
||||
self.limit = -5
|
||||
|
||||
newest_snapshot = None
|
||||
@@ -37,4 +39,4 @@ class CustomWaybackMachineCDXServerAPI(waybackpy.WaybackMachineCDXServerAPI):
|
||||
super().add_payload(payload)
|
||||
# Set fastLatest query param, as we are only using this API to get the latest snapshot and using fastLatest
|
||||
# makes searching for latest snapshots faster
|
||||
payload['fastLatest'] = 'true'
|
||||
payload["fastLatest"] = "true"
|
||||
|
||||
@@ -18,9 +18,9 @@ class WebsiteMetadata:
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'url': self.url,
|
||||
'title': self.title,
|
||||
'description': self.description,
|
||||
"url": self.url,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
}
|
||||
|
||||
|
||||
@@ -34,22 +34,29 @@ def load_website_metadata(url: str):
|
||||
start = timezone.now()
|
||||
page_text = load_page(url)
|
||||
end = timezone.now()
|
||||
logger.debug(f'Load duration: {end - start}')
|
||||
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
|
||||
description_tag = soup.find('meta', attrs={'name': 'description'})
|
||||
description = description_tag['content'].strip() if description_tag and description_tag[
|
||||
'content'] else None
|
||||
description_tag = soup.find("meta", attrs={"name": "description"})
|
||||
description = (
|
||||
description_tag["content"].strip()
|
||||
if description_tag and description_tag["content"]
|
||||
else None
|
||||
)
|
||||
|
||||
if not description:
|
||||
description_tag = soup.find('meta', attrs={'property': 'og:description'})
|
||||
description = description_tag['content'].strip() if description_tag and description_tag['content'] else None
|
||||
description_tag = soup.find("meta", attrs={"property": "og: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}')
|
||||
logger.debug(f"Parsing duration: {end - start}")
|
||||
finally:
|
||||
return WebsiteMetadata(url=url, title=title, description=description)
|
||||
|
||||
@@ -73,30 +80,30 @@ def load_page(url: str):
|
||||
else:
|
||||
content = content + chunk
|
||||
|
||||
logger.debug(f'Loaded chunk (iteration={iteration}, total={size / 1024})')
|
||||
logger.debug(f"Loaded chunk (iteration={iteration}, total={size / 1024})")
|
||||
|
||||
# Stop reading if we have parsed end of head tag
|
||||
end_of_head = '</head>'.encode('utf-8')
|
||||
end_of_head = "</head>".encode("utf-8")
|
||||
if end_of_head in content:
|
||||
logger.debug(f'Found closing head tag after {size} bytes')
|
||||
logger.debug(f"Found closing head tag after {size} bytes")
|
||||
content = content.split(end_of_head)[0] + end_of_head
|
||||
break
|
||||
# Stop reading if we exceed 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
|
||||
if hasattr(r, '_content_consumed'):
|
||||
logger.debug(f'Request consumed: {r._content_consumed}')
|
||||
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
|
||||
# Several sites seem to specify the response encoding incorrectly, so we ignore it and use custom logic instead
|
||||
# This is different from Response.text which does respect the encoding specified in the response first,
|
||||
# before trying to determine one
|
||||
results = from_bytes(content or '')
|
||||
results = from_bytes(content or "")
|
||||
return str(results.best())
|
||||
|
||||
|
||||
DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36'
|
||||
DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36"
|
||||
|
||||
|
||||
def fake_request_headers():
|
||||
|
||||
@@ -15,9 +15,11 @@ def user_logged_in(sender, request, user, **kwargs):
|
||||
def extend_sqlite(connection=None, **kwargs):
|
||||
# Load ICU extension into Sqlite connection to support case-insensitive
|
||||
# comparisons with unicode characters
|
||||
if connection.vendor == 'sqlite' and settings.USE_SQLITE_ICU_EXTENSION:
|
||||
if connection.vendor == "sqlite" and settings.USE_SQLITE_ICU_EXTENSION:
|
||||
connection.connection.enable_load_extension(True)
|
||||
connection.connection.load_extension(settings.SQLITE_ICU_EXTENSION_PATH.rstrip('.so'))
|
||||
connection.connection.load_extension(
|
||||
settings.SQLITE_ICU_EXTENSION_PATH.rstrip(".so")
|
||||
)
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
# Load an ICU collation for case-insensitive ordering.
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 2.5 KiB |
BIN
bookmarks/static/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 12 KiB |
1
bookmarks/static/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.5" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><circle cx="255.0164" cy="254.9236" fill="#5856e0" r="224.78528" stroke-width="1.18"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(.70710678 .70710678 -.70710678 .70710678 284.139117 -1684.198509)"/><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(-.70957074 -.70463421 .70463421 -.70957074 235.113139 2195.434643)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 663 B |
BIN
bookmarks/static/linkding-screenshot.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
bookmarks/static/logo-192.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
bookmarks/static/logo-512.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.6 KiB |
1
bookmarks/static/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg clip-rule="evenodd" fill-rule="evenodd" height="512" stroke-linejoin="round" stroke-miterlimit="1.5" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><circle cx="255.0164" cy="254.9236" fill="#5856e0" r="224.78528" stroke-width="1.18"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(.70710678 .70710678 -.70710678 .70710678 284.139117 -1684.198509)"/><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(-.70957074 -.70463421 .70463421 -.70957074 235.113139 2195.434643)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 688 B |
BIN
bookmarks/static/maskable-logo-192.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
bookmarks/static/maskable-logo-512.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
1
bookmarks/static/maskable-logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg clip-rule="evenodd" fill-rule="evenodd" height="512" stroke-linejoin="round" stroke-miterlimit="1.5" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m512 512h-512v-512h512" fill="#5856e0" fill-rule="nonzero" stroke-width=".293"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m249.095 110.679-141.167 141.167s-48.578 47.432 4.257 101.668c53.026 54.426 101.654 4.242 101.654 4.242l141.166-141.166"/><path d="m263.892 400.446 140.673-141.659s48.412-47.602-4.612-101.652c-53.215-54.24-101.667-3.888-101.667-3.888l-140.674 141.659"/></g></svg>
|
||||
|
After Width: | Height: | Size: 564 B |
1
bookmarks/static/safari-pinned-tab.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="700pt" preserveAspectRatio="xMidYMid meet" viewBox="0 0 700 700" width="700pt" xmlns="http://www.w3.org/2000/svg"><path d="m3210 6573c-780-79-1463-417-1985-983-444-481-716-1082-791-1750-18-160-18-490 0-650 80-713 380-1341 880-1842 492-494 1125-801 1822-883 150-18 512-21 654-5 407 44 737 142 1094 323 775 394 1350 1108 1571 1952 71 271 98 487 98 785-1 311-35 562-117 847-54 188-99 302-201 508-216 439-510 795-900 1090-441 335-992 550-1544 605-95 9-499 12-581 3zm-639-2228c-543-544-1003-1011-1020-1038-91-134-135-274-134-422 1-167 61-314 200-485 135-165 308-291 467-338 110-32 264-27 376 12 185 65 130 15 1233 1115l1007 1006 150-150c83-82 150-154 150-160s-442-452-982-992c-658-656-1010-1000-1064-1040-304-223-643-298-965-214-271 72-548 272-751 543-115 153-179 283-226 457-22 85-26 115-25 256 0 140 3 172 26 257 34 130 95 266 169 380 54 83 172 205 1067 1101l1006 1007 152-152 153-153zm2359 1051c242-44 461-167 675-381 282-281 415-567 415-890 0-119-17-230-51-336-32-101-123-277-188-363-54-71-2003-2046-2020-2046-11 0-301 281-301 292 0 6 435 449 967 985 533 536 986 998 1007 1027 53 70 121 216 140 303 30 136 14 277-48 416-65 147-245 351-396 449-211 137-403 161-617 79-162-63-173-73-1009-913-428-431-871-876-984-991-112-114-207-207-211-207s-75 67-160 148l-153 149 785 789c431 434 865 872 964 973 266 271 397 369 612 455 176 70 396 94 573 62z" transform="matrix(.1 0 0 -.1 0 700)"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -7,6 +7,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
textarea.custom-css {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.input-group > input[type=submit] {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,4 @@
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -115,8 +115,6 @@
|
||||
{% endif %}
|
||||
<a href="{{ cancel_url }}" class="btn">Nevermind</a>
|
||||
</div>
|
||||
|
||||
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
||||
<script type="application/javascript">
|
||||
/**
|
||||
* - Pre-fill title and description placeholders with metadata from website as soon as URL changes
|
||||
@@ -129,7 +127,6 @@
|
||||
const descriptionInput = document.getElementById('{{ form.description.id_for_label }}');
|
||||
const notesDetails = document.querySelector('form details.notes');
|
||||
const notesInput = document.getElementById('{{ form.notes.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 }}');
|
||||
@@ -185,6 +182,10 @@
|
||||
const bookmarkExistsHint = document.querySelector('.form-input-hint.bookmark-exists');
|
||||
|
||||
if (existingBookmark && !editedBookmarkId) {
|
||||
// Workaround: tag input will be replaced by tag autocomplete, so
|
||||
// defer getting the input until we need it
|
||||
const tagsInput = document.getElementById('{{ form.tag_string.id_for_label }}');
|
||||
|
||||
bookmarkExistsHint.style['display'] = 'block';
|
||||
notesDetails.open = !!existingBookmark.notes;
|
||||
updateInput(titleInput, existingBookmark.title);
|
||||
|
||||
@@ -43,6 +43,4 @@
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="{% static 'favicon.png' %}"/>
|
||||
<link rel="apple-touch-icon" href="{% static 'apple-touch-icon.png' %}">
|
||||
<link rel="icon" href="{% static 'favicon.ico' %}" sizes="48x48">
|
||||
<link rel="icon" href="{% static 'favicon.svg' %}" sizes="any" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon.png' %}">
|
||||
<link rel="mask-icon" href="{% static 'safari-pinned-tab.svg' %}" color="#5856e0">
|
||||
<link rel="manifest" href="{% url 'bookmarks:manifest' %}">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
|
||||
@@ -19,14 +21,21 @@
|
||||
{# Include specific theme variant based on user profile setting #}
|
||||
{% if request.user_profile.theme == 'light' %}
|
||||
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||
<meta name="theme-color" content="#5856e0">
|
||||
{% elif request.user_profile.theme == 'dark' %}
|
||||
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
|
||||
<meta name="theme-color" content="#161822">
|
||||
{% else %}
|
||||
{# Use auto theme as fallback #}
|
||||
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||
media="(prefers-color-scheme: dark)"/>
|
||||
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
|
||||
media="(prefers-color-scheme: light)"/>
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
|
||||
{% endif %}
|
||||
{% if request.user_profile.custom_css %}
|
||||
<style>{{ request.user_profile.custom_css }}</style>
|
||||
{% endif %}
|
||||
</head>
|
||||
<body ld-global-shortcuts>
|
||||
@@ -122,5 +131,6 @@
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -34,7 +34,10 @@
|
||||
</ul>
|
||||
</div>
|
||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
||||
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
|
||||
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-link">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
{# Menu drop-down for smaller devices #}
|
||||
<div class="show-md">
|
||||
@@ -74,7 +77,10 @@
|
||||
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'logout' %}" class="btn btn-link">Logout</a>
|
||||
<form class="d-inline" action="{% url 'logout' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-link">Logout</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,4 @@
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -17,22 +17,24 @@
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
|
||||
{{ form.username|add_class:'form-input'|attr:"placeholder: " }}
|
||||
{{ form.username|add_class:'form-input'|attr:'placeholder: ' }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="{{ form.password.id_for_label }}">Password</label>
|
||||
{{ form.password|add_class:'form-input'|attr:"placeholder: " }}
|
||||
{{ form.password|add_class:'form-input'|attr:'placeholder: ' }}
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<div class="d-flex justify-between">
|
||||
<input type="submit" value="Login" class="btn btn-primary btn-wide">
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<input type="submit" value="Login" class="btn btn-primary btn-wide"/>
|
||||
<input type="hidden" name="next" value="{{ next }}"/>
|
||||
{% if enable_oidc %}
|
||||
<a class="btn btn-link" href="{% url 'oidc_authentication_init' %}">Login with OIDC</a>
|
||||
{% endif %}
|
||||
{% if allow_registration %}
|
||||
<a href="{% url 'django_registration_register' %}" class="btn btn-link">Register</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -124,6 +124,18 @@
|
||||
href="{% url 'bookmarks:shared' %}">shared bookmarks page</a>.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<details {% if form.custom_css.value %}open{% endif %}>
|
||||
<summary>Custom CSS</summary>
|
||||
<label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
|
||||
<div class="mt-2">
|
||||
{{ form.custom_css|add_class:"form-input custom-css"|attr:"rows:6" }}
|
||||
</div>
|
||||
</details>
|
||||
<div class="form-input-hint">
|
||||
Allows to add custom CSS to the page.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
|
||||
{% if update_profile_success_message %}
|
||||
@@ -150,7 +162,9 @@
|
||||
<i class="form-icon"></i> Import public bookmarks as shared
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
When importing bookmarks from a service that supports marking bookmarks as public or private (using the <code>PRIVATE</code> attribute), enabling this option will import all bookmarks that are marked as not private as shared bookmarks.
|
||||
When importing bookmarks from a service that supports marking bookmarks as public or private (using the
|
||||
<code>PRIVATE</code> attribute), enabling this option will import all bookmarks that are marked as not
|
||||
private as shared bookmarks.
|
||||
Otherwise, all bookmarks will be imported as private bookmarks.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,9 +49,11 @@
|
||||
<section class="content-area">
|
||||
<h2>RSS Feeds</h2>
|
||||
<p>The following URLs provide RSS feeds for your bookmarks:</p>
|
||||
<ul>
|
||||
<ul style="list-style-position: outside;">
|
||||
<li><a href="{{ all_feed_url }}">All bookmarks</a></li>
|
||||
<li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li>
|
||||
<li><a href="{{ shared_feed_url }}">Shared bookmarks</a></li>
|
||||
<li><a href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span class="text-small text-gray">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span></li>
|
||||
</ul>
|
||||
<p>
|
||||
All URLs support appending a <code>q</code> URL parameter for specifying a search query.
|
||||
|
||||
@@ -2,48 +2,67 @@ from typing import List
|
||||
|
||||
from django import template
|
||||
|
||||
from bookmarks.models import BookmarkForm, BookmarkSearch, BookmarkSearchForm, Tag, build_tag_string, User
|
||||
from bookmarks.models import (
|
||||
BookmarkForm,
|
||||
BookmarkSearch,
|
||||
BookmarkSearchForm,
|
||||
Tag,
|
||||
build_tag_string,
|
||||
User,
|
||||
)
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.inclusion_tag('bookmarks/form.html', name='bookmark_form', takes_context=True)
|
||||
def bookmark_form(context, form: BookmarkForm, cancel_url: str, bookmark_id: int = 0, auto_close: bool = False):
|
||||
@register.inclusion_tag("bookmarks/form.html", name="bookmark_form", takes_context=True)
|
||||
def bookmark_form(
|
||||
context,
|
||||
form: BookmarkForm,
|
||||
cancel_url: str,
|
||||
bookmark_id: int = 0,
|
||||
auto_close: bool = False,
|
||||
):
|
||||
return {
|
||||
'request': context['request'],
|
||||
'form': form,
|
||||
'auto_close': auto_close,
|
||||
'bookmark_id': bookmark_id,
|
||||
'cancel_url': cancel_url
|
||||
"request": context["request"],
|
||||
"form": form,
|
||||
"auto_close": auto_close,
|
||||
"bookmark_id": bookmark_id,
|
||||
"cancel_url": cancel_url,
|
||||
}
|
||||
|
||||
|
||||
@register.inclusion_tag('bookmarks/search.html', name='bookmark_search', takes_context=True)
|
||||
def bookmark_search(context, search: BookmarkSearch, tags: [Tag], mode: str = ''):
|
||||
@register.inclusion_tag(
|
||||
"bookmarks/search.html", name="bookmark_search", takes_context=True
|
||||
)
|
||||
def bookmark_search(context, search: BookmarkSearch, tags: [Tag], mode: str = ""):
|
||||
tag_names = [tag.name for tag in tags]
|
||||
tags_string = build_tag_string(tag_names, ' ')
|
||||
search_form = BookmarkSearchForm(search, editable_fields=['q'])
|
||||
tags_string = build_tag_string(tag_names, " ")
|
||||
search_form = BookmarkSearchForm(search, editable_fields=["q"])
|
||||
|
||||
if mode == 'shared':
|
||||
preferences_form = BookmarkSearchForm(search, editable_fields=['sort'])
|
||||
if mode == "shared":
|
||||
preferences_form = BookmarkSearchForm(search, editable_fields=["sort"])
|
||||
else:
|
||||
preferences_form = BookmarkSearchForm(search, editable_fields=['sort', 'shared', 'unread'])
|
||||
preferences_form = BookmarkSearchForm(
|
||||
search, editable_fields=["sort", "shared", "unread"]
|
||||
)
|
||||
return {
|
||||
'request': context['request'],
|
||||
'search': search,
|
||||
'search_form': search_form,
|
||||
'preferences_form': preferences_form,
|
||||
'tags_string': tags_string,
|
||||
'mode': mode,
|
||||
"request": context["request"],
|
||||
"search": search,
|
||||
"search_form": search_form,
|
||||
"preferences_form": preferences_form,
|
||||
"tags_string": tags_string,
|
||||
"mode": mode,
|
||||
}
|
||||
|
||||
|
||||
@register.inclusion_tag('bookmarks/user_select.html', name='user_select', takes_context=True)
|
||||
@register.inclusion_tag(
|
||||
"bookmarks/user_select.html", name="user_select", takes_context=True
|
||||
)
|
||||
def user_select(context, search: BookmarkSearch, users: List[User]):
|
||||
sorted_users = sorted(users, key=lambda x: str.lower(x.username))
|
||||
form = BookmarkSearchForm(search, editable_fields=['user'], users=sorted_users)
|
||||
form = BookmarkSearchForm(search, editable_fields=["user"], users=sorted_users)
|
||||
return {
|
||||
'search': search,
|
||||
'users': sorted_users,
|
||||
'form': form,
|
||||
"search": search,
|
||||
"users": sorted_users,
|
||||
"form": form,
|
||||
}
|
||||
|
||||
@@ -8,14 +8,15 @@ NUM_ADJACENT_PAGES = 2
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.inclusion_tag('bookmarks/pagination.html', name='pagination', takes_context=True)
|
||||
@register.inclusion_tag(
|
||||
"bookmarks/pagination.html", name="pagination", takes_context=True
|
||||
)
|
||||
def pagination(context, page: Page):
|
||||
visible_page_numbers = get_visible_page_numbers(page.number, page.paginator.num_pages)
|
||||
visible_page_numbers = get_visible_page_numbers(
|
||||
page.number, page.paginator.num_pages
|
||||
)
|
||||
|
||||
return {
|
||||
'page': page,
|
||||
'visible_page_numbers': visible_page_numbers
|
||||
}
|
||||
return {"page": page, "visible_page_numbers": visible_page_numbers}
|
||||
|
||||
|
||||
def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
|
||||
@@ -29,10 +30,12 @@ def get_visible_page_numbers(current_page_number: int, num_pages: int) -> [int]:
|
||||
visible_pages = set()
|
||||
|
||||
# Add adjacent pages around current page
|
||||
visible_pages |= set(range(
|
||||
max(1, current_page_number - NUM_ADJACENT_PAGES),
|
||||
min(num_pages, current_page_number + NUM_ADJACENT_PAGES) + 1
|
||||
))
|
||||
visible_pages |= set(
|
||||
range(
|
||||
max(1, current_page_number - NUM_ADJACENT_PAGES),
|
||||
min(num_pages, current_page_number + NUM_ADJACENT_PAGES) + 1,
|
||||
)
|
||||
)
|
||||
|
||||
# Add first page
|
||||
visible_pages.add(1)
|
||||
|
||||
@@ -28,12 +28,12 @@ def add_tag_to_query(context, tag_name: str):
|
||||
params = context.request.GET.copy()
|
||||
|
||||
# Append to or create query string
|
||||
if params.__contains__('q'):
|
||||
query_string = params.__getitem__('q') + ' '
|
||||
if params.__contains__("q"):
|
||||
query_string = params.__getitem__("q") + " "
|
||||
else:
|
||||
query_string = ''
|
||||
query_string = query_string + '#' + tag_name
|
||||
params.__setitem__('q', query_string)
|
||||
query_string = ""
|
||||
query_string = query_string + "#" + tag_name
|
||||
params.__setitem__("q", query_string)
|
||||
|
||||
return params.urlencode()
|
||||
|
||||
@@ -41,20 +41,26 @@ def add_tag_to_query(context, tag_name: str):
|
||||
@register.simple_tag(takes_context=True)
|
||||
def remove_tag_from_query(context, tag_name: str):
|
||||
params = context.request.GET.copy()
|
||||
if params.__contains__('q'):
|
||||
if params.__contains__("q"):
|
||||
# Split query string into parts
|
||||
query_string = params.__getitem__('q')
|
||||
query_string = params.__getitem__("q")
|
||||
query_parts = query_string.split()
|
||||
# Remove tag with hash
|
||||
tag_name_with_hash = '#' + tag_name
|
||||
query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name_with_hash)]
|
||||
tag_name_with_hash = "#" + tag_name
|
||||
query_parts = [
|
||||
part
|
||||
for part in query_parts
|
||||
if str.lower(part) != str.lower(tag_name_with_hash)
|
||||
]
|
||||
# When using lax tag search, also remove tag without hash
|
||||
profile = context.request.user_profile
|
||||
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
|
||||
query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name)]
|
||||
query_parts = [
|
||||
part for part in query_parts if str.lower(part) != str.lower(tag_name)
|
||||
]
|
||||
# Rebuild query string
|
||||
query_string = ' '.join(query_parts)
|
||||
params.__setitem__('q', query_string)
|
||||
query_string = " ".join(query_parts)
|
||||
params.__setitem__("q", query_string)
|
||||
|
||||
return params.urlencode()
|
||||
|
||||
@@ -71,38 +77,38 @@ def replace_query_param(context, **kwargs):
|
||||
return query.urlencode()
|
||||
|
||||
|
||||
@register.filter(name='hash_tag')
|
||||
@register.filter(name="hash_tag")
|
||||
def hash_tag(tag_name):
|
||||
return '#' + tag_name
|
||||
return "#" + tag_name
|
||||
|
||||
|
||||
@register.filter(name='first_char')
|
||||
@register.filter(name="first_char")
|
||||
def first_char(text):
|
||||
return text[0]
|
||||
|
||||
|
||||
@register.filter(name='remaining_chars')
|
||||
@register.filter(name="remaining_chars")
|
||||
def remaining_chars(text, index):
|
||||
return text[index:]
|
||||
|
||||
|
||||
@register.filter(name='humanize_absolute_date')
|
||||
@register.filter(name="humanize_absolute_date")
|
||||
def humanize_absolute_date(value):
|
||||
if value in (None, ''):
|
||||
return ''
|
||||
if value in (None, ""):
|
||||
return ""
|
||||
return utils.humanize_absolute_date(value)
|
||||
|
||||
|
||||
@register.filter(name='humanize_relative_date')
|
||||
@register.filter(name="humanize_relative_date")
|
||||
def humanize_relative_date(value):
|
||||
if value in (None, ''):
|
||||
return ''
|
||||
if value in (None, ""):
|
||||
return ""
|
||||
return utils.humanize_relative_date(value)
|
||||
|
||||
|
||||
@register.tag
|
||||
def htmlmin(parser, token):
|
||||
nodelist = parser.parse(('endhtmlmin',))
|
||||
nodelist = parser.parse(("endhtmlmin",))
|
||||
parser.delete_first_token()
|
||||
return HtmlMinNode(nodelist)
|
||||
|
||||
@@ -114,7 +120,7 @@ class HtmlMinNode(template.Node):
|
||||
def render(self, context):
|
||||
output = self.nodelist.render(context)
|
||||
|
||||
output = re.sub(r'\s+', ' ', output)
|
||||
output = re.sub(r"\s+", " ", output)
|
||||
|
||||
return output
|
||||
|
||||
@@ -123,11 +129,11 @@ class HtmlMinNode(template.Node):
|
||||
def render_markdown(context, markdown_text):
|
||||
# naive approach to reusing the renderer for a single request
|
||||
# works for bookmark list for now
|
||||
if not ('markdown_renderer' in context):
|
||||
renderer = markdown.Markdown(extensions=['fenced_code', 'nl2br'])
|
||||
context['markdown_renderer'] = renderer
|
||||
if not ("markdown_renderer" in context):
|
||||
renderer = markdown.Markdown(extensions=["fenced_code", "nl2br"])
|
||||
context["markdown_renderer"] = renderer
|
||||
else:
|
||||
renderer = context['markdown_renderer']
|
||||
renderer = context["markdown_renderer"]
|
||||
|
||||
as_html = renderer.convert(markdown_text)
|
||||
sanitized_html = bleach.clean(as_html, markdown_tags, markdown_attrs)
|
||||
|
||||
@@ -18,26 +18,29 @@ class BookmarkFactoryMixin:
|
||||
|
||||
def get_or_create_test_user(self):
|
||||
if self.user is None:
|
||||
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
|
||||
self.user = User.objects.create_user(
|
||||
"testuser", "test@example.com", "password123"
|
||||
)
|
||||
|
||||
return self.user
|
||||
|
||||
def setup_bookmark(self,
|
||||
is_archived: bool = False,
|
||||
unread: bool = False,
|
||||
shared: bool = False,
|
||||
tags=None,
|
||||
user: User = None,
|
||||
url: str = '',
|
||||
title: str = None,
|
||||
description: str = '',
|
||||
notes: str = '',
|
||||
website_title: str = '',
|
||||
website_description: str = '',
|
||||
web_archive_snapshot_url: str = '',
|
||||
favicon_file: str = '',
|
||||
added: datetime = None,
|
||||
):
|
||||
def setup_bookmark(
|
||||
self,
|
||||
is_archived: bool = False,
|
||||
unread: bool = False,
|
||||
shared: bool = False,
|
||||
tags=None,
|
||||
user: User = None,
|
||||
url: str = "",
|
||||
title: str = None,
|
||||
description: str = "",
|
||||
notes: str = "",
|
||||
website_title: str = "",
|
||||
website_description: str = "",
|
||||
web_archive_snapshot_url: str = "",
|
||||
favicon_file: str = "",
|
||||
added: datetime = None,
|
||||
):
|
||||
if title is None:
|
||||
title = get_random_string(length=32)
|
||||
if tags is None:
|
||||
@@ -46,7 +49,7 @@ class BookmarkFactoryMixin:
|
||||
user = self.get_or_create_test_user()
|
||||
if not url:
|
||||
unique_id = get_random_string(length=32)
|
||||
url = 'https://example.com/' + unique_id
|
||||
url = "https://example.com/" + unique_id
|
||||
if added is None:
|
||||
added = timezone.now()
|
||||
bookmark = Bookmark(
|
||||
@@ -71,49 +74,58 @@ class BookmarkFactoryMixin:
|
||||
bookmark.save()
|
||||
return bookmark
|
||||
|
||||
def setup_numbered_bookmarks(self,
|
||||
count: int,
|
||||
prefix: str = '',
|
||||
suffix: str = '',
|
||||
tag_prefix: str = '',
|
||||
archived: bool = False,
|
||||
unread: bool = False,
|
||||
shared: bool = False,
|
||||
with_tags: bool = False,
|
||||
user: User = None):
|
||||
def setup_numbered_bookmarks(
|
||||
self,
|
||||
count: int,
|
||||
prefix: str = "",
|
||||
suffix: str = "",
|
||||
tag_prefix: str = "",
|
||||
archived: bool = False,
|
||||
unread: bool = False,
|
||||
shared: bool = False,
|
||||
with_tags: bool = False,
|
||||
with_web_archive_snapshot_url: bool = False,
|
||||
user: User = None,
|
||||
):
|
||||
user = user or self.get_or_create_test_user()
|
||||
bookmarks = []
|
||||
|
||||
if not prefix:
|
||||
if archived:
|
||||
prefix = 'Archived Bookmark'
|
||||
prefix = "Archived Bookmark"
|
||||
elif shared:
|
||||
prefix = 'Shared Bookmark'
|
||||
prefix = "Shared Bookmark"
|
||||
else:
|
||||
prefix = 'Bookmark'
|
||||
prefix = "Bookmark"
|
||||
|
||||
if not tag_prefix:
|
||||
if archived:
|
||||
tag_prefix = 'Archived Tag'
|
||||
tag_prefix = "Archived Tag"
|
||||
elif shared:
|
||||
tag_prefix = 'Shared Tag'
|
||||
tag_prefix = "Shared Tag"
|
||||
else:
|
||||
tag_prefix = 'Tag'
|
||||
tag_prefix = "Tag"
|
||||
|
||||
for i in range(1, count + 1):
|
||||
title = f'{prefix} {i}{suffix}'
|
||||
url = f'https://example.com/{prefix}/{i}'
|
||||
title = f"{prefix} {i}{suffix}"
|
||||
url = f"https://example.com/{prefix}/{i}"
|
||||
tags = []
|
||||
if with_tags:
|
||||
tag_name = f'{tag_prefix} {i}{suffix}'
|
||||
tag_name = f"{tag_prefix} {i}{suffix}"
|
||||
tags = [self.setup_tag(name=tag_name, user=user)]
|
||||
bookmark = self.setup_bookmark(url=url,
|
||||
title=title,
|
||||
is_archived=archived,
|
||||
unread=unread,
|
||||
shared=shared,
|
||||
tags=tags,
|
||||
user=user)
|
||||
web_archive_snapshot_url = ""
|
||||
if with_web_archive_snapshot_url:
|
||||
web_archive_snapshot_url = f"https://web.archive.org/web/{i}"
|
||||
bookmark = self.setup_bookmark(
|
||||
url=url,
|
||||
title=title,
|
||||
is_archived=archived,
|
||||
unread=unread,
|
||||
shared=shared,
|
||||
tags=tags,
|
||||
web_archive_snapshot_url=web_archive_snapshot_url,
|
||||
user=user,
|
||||
)
|
||||
bookmarks.append(bookmark)
|
||||
|
||||
return bookmarks
|
||||
@@ -121,7 +133,7 @@ class BookmarkFactoryMixin:
|
||||
def get_numbered_bookmark(self, title: str):
|
||||
return Bookmark.objects.get(title=title)
|
||||
|
||||
def setup_tag(self, user: User = None, name: str = ''):
|
||||
def setup_tag(self, user: User = None, name: str = ""):
|
||||
if user is None:
|
||||
user = self.get_or_create_test_user()
|
||||
if not name:
|
||||
@@ -130,10 +142,15 @@ class BookmarkFactoryMixin:
|
||||
tag.save()
|
||||
return tag
|
||||
|
||||
def setup_user(self, name: str = None, enable_sharing: bool = False, enable_public_sharing: bool = False):
|
||||
def setup_user(
|
||||
self,
|
||||
name: str = None,
|
||||
enable_sharing: bool = False,
|
||||
enable_public_sharing: bool = False,
|
||||
):
|
||||
if not name:
|
||||
name = get_random_string(length=32)
|
||||
user = User.objects.create_user(name, 'user@example.com', 'password123')
|
||||
user = User.objects.create_user(name, "user@example.com", "password123")
|
||||
user.profile.enable_sharing = enable_sharing
|
||||
user.profile.enable_public_sharing = enable_public_sharing
|
||||
user.profile.save()
|
||||
@@ -161,17 +178,17 @@ class LinkdingApiTestCase(APITestCase):
|
||||
return response
|
||||
|
||||
def post(self, url, data=None, expected_status_code=status.HTTP_200_OK):
|
||||
response = self.client.post(url, data, format='json')
|
||||
response = self.client.post(url, data, format="json")
|
||||
self.assertEqual(response.status_code, expected_status_code)
|
||||
return response
|
||||
|
||||
def put(self, url, data=None, expected_status_code=status.HTTP_200_OK):
|
||||
response = self.client.put(url, data, format='json')
|
||||
response = self.client.put(url, data, format="json")
|
||||
self.assertEqual(response.status_code, expected_status_code)
|
||||
return response
|
||||
|
||||
def patch(self, url, data=None, expected_status_code=status.HTTP_200_OK):
|
||||
response = self.client.patch(url, data, format='json')
|
||||
response = self.client.patch(url, data, format="json")
|
||||
self.assertEqual(response.status_code, expected_status_code)
|
||||
return response
|
||||
|
||||
@@ -182,14 +199,16 @@ class LinkdingApiTestCase(APITestCase):
|
||||
|
||||
|
||||
class BookmarkHtmlTag:
|
||||
def __init__(self,
|
||||
href: str = '',
|
||||
title: str = '',
|
||||
description: str = '',
|
||||
add_date: str = '',
|
||||
tags: str = '',
|
||||
to_read: bool = False,
|
||||
private: bool = True):
|
||||
def __init__(
|
||||
self,
|
||||
href: str = "",
|
||||
title: str = "",
|
||||
description: str = "",
|
||||
add_date: str = "",
|
||||
tags: str = "",
|
||||
to_read: bool = False,
|
||||
private: bool = True,
|
||||
):
|
||||
self.href = href
|
||||
self.title = title
|
||||
self.description = description
|
||||
@@ -201,7 +220,7 @@ class BookmarkHtmlTag:
|
||||
|
||||
class ImportTestMixin:
|
||||
def render_tag(self, tag: BookmarkHtmlTag):
|
||||
return f'''
|
||||
return f"""
|
||||
<DT>
|
||||
<A {f'HREF="{tag.href}"' if tag.href else ''}
|
||||
{f'ADD_DATE="{tag.add_date}"' if tag.add_date else ''}
|
||||
@@ -211,13 +230,13 @@ class ImportTestMixin:
|
||||
{tag.title if tag.title else ''}
|
||||
</A>
|
||||
{f'<DD>{tag.description}' if tag.description else ''}
|
||||
'''
|
||||
"""
|
||||
|
||||
def render_html(self, tags: List[BookmarkHtmlTag] = None, tags_html: str = ''):
|
||||
def render_html(self, tags: List[BookmarkHtmlTag] = None, tags_html: str = ""):
|
||||
if tags:
|
||||
rendered_tags = [self.render_tag(tag) for tag in tags]
|
||||
tags_html = '\n'.join(rendered_tags)
|
||||
return f'''
|
||||
tags_html = "\n".join(rendered_tags)
|
||||
return f"""
|
||||
<!DOCTYPE NETSCAPE-Bookmark-file-1>
|
||||
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
|
||||
<TITLE>Bookmarks</TITLE>
|
||||
@@ -225,34 +244,34 @@ class ImportTestMixin:
|
||||
<DL><p>
|
||||
{tags_html}
|
||||
</DL><p>
|
||||
'''
|
||||
"""
|
||||
|
||||
|
||||
_words = [
|
||||
'quasi',
|
||||
'consequatur',
|
||||
'necessitatibus',
|
||||
'debitis',
|
||||
'quod',
|
||||
'vero',
|
||||
'qui',
|
||||
'commodi',
|
||||
'quod',
|
||||
'odio',
|
||||
'aliquam',
|
||||
'veniam',
|
||||
'architecto',
|
||||
'consequatur',
|
||||
'autem',
|
||||
'qui',
|
||||
'iste',
|
||||
'asperiores',
|
||||
'soluta',
|
||||
'et',
|
||||
"quasi",
|
||||
"consequatur",
|
||||
"necessitatibus",
|
||||
"debitis",
|
||||
"quod",
|
||||
"vero",
|
||||
"qui",
|
||||
"commodi",
|
||||
"quod",
|
||||
"odio",
|
||||
"aliquam",
|
||||
"veniam",
|
||||
"architecto",
|
||||
"consequatur",
|
||||
"autem",
|
||||
"qui",
|
||||
"iste",
|
||||
"asperiores",
|
||||
"soluta",
|
||||
"et",
|
||||
]
|
||||
|
||||
|
||||
def random_sentence(num_words: int = None, including_word: str = ''):
|
||||
def random_sentence(num_words: int = None, including_word: str = ""):
|
||||
if num_words is None:
|
||||
num_words = random.randint(5, 10)
|
||||
selected_words = random.choices(_words, k=num_words)
|
||||
@@ -260,7 +279,7 @@ def random_sentence(num_words: int = None, including_word: str = ''):
|
||||
selected_words.append(including_word)
|
||||
random.shuffle(selected_words)
|
||||
|
||||
return ' '.join(selected_words)
|
||||
return " ".join(selected_words)
|
||||
|
||||
|
||||
def disable_logging(f):
|
||||
@@ -275,5 +294,5 @@ def disable_logging(f):
|
||||
|
||||
|
||||
def collapse_whitespace(text: str):
|
||||
text = text.replace('\n', '').replace('\r', '')
|
||||
return ' '.join(text.split())
|
||||
text = text.replace("\n", "").replace("\r", "")
|
||||
return " ".join(text.split())
|
||||
|
||||
@@ -6,21 +6,24 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
class AnonymousViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def assertSharedBookmarksLinkCount(self, response, count):
|
||||
url = reverse('bookmarks:shared')
|
||||
self.assertContains(response, f'<a href="{url}" class="btn btn-link">Shared bookmarks</a>',
|
||||
count=count)
|
||||
url = reverse("bookmarks:shared")
|
||||
self.assertContains(
|
||||
response,
|
||||
f'<a href="{url}" class="btn btn-link">Shared bookmarks</a>',
|
||||
count=count,
|
||||
)
|
||||
|
||||
def test_publicly_shared_bookmarks_link(self):
|
||||
# should not render link if no public shares exist
|
||||
user = self.setup_user(enable_sharing=True)
|
||||
self.setup_bookmark(user=user, shared=True)
|
||||
|
||||
response = self.client.get(reverse('login'))
|
||||
response = self.client.get(reverse("login"))
|
||||
self.assertSharedBookmarksLinkCount(response, 0)
|
||||
|
||||
# should render link if public shares exist
|
||||
user.profile.enable_public_sharing = True
|
||||
user.profile.save()
|
||||
|
||||
response = self.client.get(reverse('login'))
|
||||
response = self.client.get(reverse("login"))
|
||||
self.assertSharedBookmarksLinkCount(response, 1)
|
||||
|
||||
@@ -7,23 +7,35 @@ from django.test import TestCase
|
||||
|
||||
class AppOptionsTestCase(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.settings_module = importlib.import_module('siteroot.settings.base')
|
||||
self.settings_module = importlib.import_module("siteroot.settings.base")
|
||||
|
||||
def test_empty_csrf_trusted_origins(self):
|
||||
module = importlib.reload(self.settings_module)
|
||||
|
||||
self.assertFalse(hasattr(module, 'CSRF_TRUSTED_ORIGINS'))
|
||||
self.assertFalse(hasattr(module, "CSRF_TRUSTED_ORIGINS"))
|
||||
|
||||
@mock.patch.dict(os.environ, {'LD_CSRF_TRUSTED_ORIGINS': 'https://linkding.example.com'})
|
||||
@mock.patch.dict(
|
||||
os.environ, {"LD_CSRF_TRUSTED_ORIGINS": "https://linkding.example.com"}
|
||||
)
|
||||
def test_single_csrf_trusted_origin(self):
|
||||
module = importlib.reload(self.settings_module)
|
||||
|
||||
self.assertTrue(hasattr(module, 'CSRF_TRUSTED_ORIGINS'))
|
||||
self.assertCountEqual(module.CSRF_TRUSTED_ORIGINS, ['https://linkding.example.com'])
|
||||
self.assertTrue(hasattr(module, "CSRF_TRUSTED_ORIGINS"))
|
||||
self.assertCountEqual(
|
||||
module.CSRF_TRUSTED_ORIGINS, ["https://linkding.example.com"]
|
||||
)
|
||||
|
||||
@mock.patch.dict(os.environ, {'LD_CSRF_TRUSTED_ORIGINS': 'https://linkding.example.com,http://linkding.example.com'})
|
||||
@mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"LD_CSRF_TRUSTED_ORIGINS": "https://linkding.example.com,http://linkding.example.com"
|
||||
},
|
||||
)
|
||||
def test_multiple_csrf_trusted_origin(self):
|
||||
module = importlib.reload(self.settings_module)
|
||||
|
||||
self.assertTrue(hasattr(module, 'CSRF_TRUSTED_ORIGINS'))
|
||||
self.assertCountEqual(module.CSRF_TRUSTED_ORIGINS, ['https://linkding.example.com', 'http://linkding.example.com'])
|
||||
self.assertTrue(hasattr(module, "CSRF_TRUSTED_ORIGINS"))
|
||||
self.assertCountEqual(
|
||||
module.CSRF_TRUSTED_ORIGINS,
|
||||
["https://linkding.example.com", "http://linkding.example.com"],
|
||||
)
|
||||
|
||||
@@ -10,37 +10,49 @@ class AuthProxySupportTest(TestCase):
|
||||
# Reproducing configuration from the settings logic here
|
||||
# ideally this test would just override the respective options
|
||||
@modify_settings(
|
||||
MIDDLEWARE={'append': 'bookmarks.middlewares.CustomRemoteUserMiddleware'},
|
||||
AUTHENTICATION_BACKENDS={'prepend': 'django.contrib.auth.backends.RemoteUserBackend'}
|
||||
MIDDLEWARE={"append": "bookmarks.middlewares.CustomRemoteUserMiddleware"},
|
||||
AUTHENTICATION_BACKENDS={
|
||||
"prepend": "django.contrib.auth.backends.RemoteUserBackend"
|
||||
},
|
||||
)
|
||||
def test_auth_proxy_authentication(self):
|
||||
user = User.objects.create_user('auth_proxy_user', 'user@example.com', 'password123')
|
||||
user = User.objects.create_user(
|
||||
"auth_proxy_user", "user@example.com", "password123"
|
||||
)
|
||||
|
||||
headers = {'REMOTE_USER': user.username}
|
||||
response = self.client.get(reverse('bookmarks:index'), **headers)
|
||||
headers = {"REMOTE_USER": user.username}
|
||||
response = self.client.get(reverse("bookmarks:index"), **headers)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Reproducing configuration from the settings logic here
|
||||
# ideally this test would just override the respective options
|
||||
@modify_settings(
|
||||
MIDDLEWARE={'append': 'bookmarks.middlewares.CustomRemoteUserMiddleware'},
|
||||
AUTHENTICATION_BACKENDS={'prepend': 'django.contrib.auth.backends.RemoteUserBackend'}
|
||||
MIDDLEWARE={"append": "bookmarks.middlewares.CustomRemoteUserMiddleware"},
|
||||
AUTHENTICATION_BACKENDS={
|
||||
"prepend": "django.contrib.auth.backends.RemoteUserBackend"
|
||||
},
|
||||
)
|
||||
def test_auth_proxy_with_custom_header(self):
|
||||
with patch.object(CustomRemoteUserMiddleware, 'header', new_callable=PropertyMock) as mock:
|
||||
mock.return_value = 'Custom-User'
|
||||
user = User.objects.create_user('auth_proxy_user', 'user@example.com', 'password123')
|
||||
with patch.object(
|
||||
CustomRemoteUserMiddleware, "header", new_callable=PropertyMock
|
||||
) as mock:
|
||||
mock.return_value = "Custom-User"
|
||||
user = User.objects.create_user(
|
||||
"auth_proxy_user", "user@example.com", "password123"
|
||||
)
|
||||
|
||||
headers = {'Custom-User': user.username}
|
||||
response = self.client.get(reverse('bookmarks:index'), **headers)
|
||||
headers = {"Custom-User": user.username}
|
||||
response = self.client.get(reverse("bookmarks:index"), **headers)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_auth_proxy_is_disabled_by_default(self):
|
||||
user = User.objects.create_user('auth_proxy_user', 'user@example.com', 'password123')
|
||||
user = User.objects.create_user(
|
||||
"auth_proxy_user", "user@example.com", "password123"
|
||||
)
|
||||
|
||||
headers = {'REMOTE_USER': user.username}
|
||||
response = self.client.get(reverse('bookmarks:index'), **headers, follow=True)
|
||||
headers = {"REMOTE_USER": user.username}
|
||||
response = self.client.get(reverse("bookmarks:index"), **headers, follow=True)
|
||||
|
||||
self.assertRedirects(response, '/login/?next=%2Fbookmarks')
|
||||
self.assertRedirects(response, "/login/?next=%2Fbookmarks")
|
||||
|
||||
@@ -17,26 +17,37 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(len(bookmarks), Bookmark.objects.count())
|
||||
|
||||
for bookmark in bookmarks:
|
||||
self.assertEqual(model_to_dict(bookmark), model_to_dict(Bookmark.objects.get(id=bookmark.id)))
|
||||
self.assertEqual(
|
||||
model_to_dict(bookmark),
|
||||
model_to_dict(Bookmark.objects.get(id=bookmark.id)),
|
||||
)
|
||||
|
||||
def test_archive_should_archive_bookmark(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'archive': [bookmark.id],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"archive": [bookmark.id],
|
||||
},
|
||||
)
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertTrue(bookmark.is_archived)
|
||||
|
||||
def test_can_only_archive_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"
|
||||
)
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:index.action'), {
|
||||
'archive': [bookmark.id],
|
||||
})
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"archive": [bookmark.id],
|
||||
},
|
||||
)
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
@@ -46,20 +57,28 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_unarchive_should_unarchive_bookmark(self):
|
||||
bookmark = self.setup_bookmark(is_archived=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'unarchive': [bookmark.id],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"unarchive": [bookmark.id],
|
||||
},
|
||||
)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertFalse(bookmark.is_archived)
|
||||
|
||||
def test_unarchive_can_only_archive_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"
|
||||
)
|
||||
bookmark = self.setup_bookmark(is_archived=True, user=other_user)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:index.action'), {
|
||||
'unarchive': [bookmark.id],
|
||||
})
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"unarchive": [bookmark.id],
|
||||
},
|
||||
)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
@@ -68,28 +87,39 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_delete_should_delete_bookmark(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'remove': [bookmark.id],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"remove": [bookmark.id],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(Bookmark.objects.count(), 0)
|
||||
|
||||
def test_delete_can_only_delete_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"
|
||||
)
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:index.action'), {
|
||||
'remove': [bookmark.id],
|
||||
})
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"remove": [bookmark.id],
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertTrue(Bookmark.objects.filter(id=bookmark.id).exists())
|
||||
|
||||
def test_mark_as_read(self):
|
||||
bookmark = self.setup_bookmark(unread=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'mark_as_read': [bookmark.id],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"mark_as_read": [bookmark.id],
|
||||
},
|
||||
)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertFalse(bookmark.unread)
|
||||
@@ -97,21 +127,29 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_unshare_should_unshare_bookmark(self):
|
||||
bookmark = self.setup_bookmark(shared=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'unshare': [bookmark.id],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"unshare": [bookmark.id],
|
||||
},
|
||||
)
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertFalse(bookmark.shared)
|
||||
|
||||
def test_can_only_unshare_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"
|
||||
)
|
||||
bookmark = self.setup_bookmark(user=other_user, shared=True)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:index.action'), {
|
||||
'unshare': [bookmark.id],
|
||||
})
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"unshare": [bookmark.id],
|
||||
},
|
||||
)
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
@@ -123,27 +161,43 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_archive"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||
|
||||
def test_can_only_bulk_archive_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"
|
||||
)
|
||||
bookmark1 = self.setup_bookmark(user=other_user)
|
||||
bookmark2 = self.setup_bookmark(user=other_user)
|
||||
bookmark3 = self.setup_bookmark(user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_archive"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
@@ -154,27 +208,43 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(is_archived=True)
|
||||
bookmark3 = self.setup_bookmark(is_archived=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:archived.action'), {
|
||||
'bulk_action': ['bulk_unarchive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:archived.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_unarchive"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
|
||||
|
||||
def test_can_only_bulk_unarchive_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"
|
||||
)
|
||||
bookmark1 = self.setup_bookmark(is_archived=True, user=other_user)
|
||||
bookmark2 = self.setup_bookmark(is_archived=True, user=other_user)
|
||||
bookmark3 = self.setup_bookmark(is_archived=True, user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:archived.action'), {
|
||||
'bulk_action': ['bulk_unarchive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:archived.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_unarchive"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
@@ -185,27 +255,43 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
|
||||
self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())
|
||||
self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first())
|
||||
|
||||
def test_can_only_bulk_delete_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"
|
||||
)
|
||||
bookmark1 = self.setup_bookmark(user=other_user)
|
||||
bookmark2 = self.setup_bookmark(user=other_user)
|
||||
bookmark3 = self.setup_bookmark(user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIsNotNone(Bookmark.objects.filter(id=bookmark1.id).first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(id=bookmark2.id).first())
|
||||
@@ -218,12 +304,19 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_tag'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_tag"],
|
||||
"bulk_execute": [""],
|
||||
"bulk_tag_string": [f"{tag1.name} {tag2.name}"],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
@@ -234,19 +327,28 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_can_only_bulk_tag_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"
|
||||
)
|
||||
bookmark1 = self.setup_bookmark(user=other_user)
|
||||
bookmark2 = self.setup_bookmark(user=other_user)
|
||||
bookmark3 = self.setup_bookmark(user=other_user)
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_tag'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_tag"],
|
||||
"bulk_execute": [""],
|
||||
"bulk_tag_string": [f"{tag1.name} {tag2.name}"],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
@@ -263,12 +365,19 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_untag'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_untag"],
|
||||
"bulk_execute": [""],
|
||||
"bulk_tag_string": [f"{tag1.name} {tag2.name}"],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
@@ -279,19 +388,28 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertCountEqual(bookmark3.tags.all(), [])
|
||||
|
||||
def test_can_only_bulk_untag_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"
|
||||
)
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
bookmark1 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
|
||||
bookmark2 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
|
||||
bookmark3 = self.setup_bookmark(tags=[tag1, tag2], user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_untag'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_tag_string': [f'{tag1.name} {tag2.name}'],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_untag"],
|
||||
"bulk_execute": [""],
|
||||
"bulk_tag_string": [f"{tag1.name} {tag2.name}"],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
bookmark1.refresh_from_db()
|
||||
bookmark2.refresh_from_db()
|
||||
@@ -306,27 +424,43 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(unread=True)
|
||||
bookmark3 = self.setup_bookmark(unread=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_read'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_read"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_can_only_bulk_mark_as_read_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"
|
||||
)
|
||||
bookmark1 = self.setup_bookmark(unread=True, user=other_user)
|
||||
bookmark2 = self.setup_bookmark(unread=True, user=other_user)
|
||||
bookmark3 = self.setup_bookmark(unread=True, user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_read'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_read"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
@@ -337,27 +471,43 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(unread=False)
|
||||
bookmark3 = self.setup_bookmark(unread=False)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_unread'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_unread"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
|
||||
|
||||
def test_can_only_bulk_mark_as_unread_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"
|
||||
)
|
||||
bookmark1 = self.setup_bookmark(unread=False, user=other_user)
|
||||
bookmark2 = self.setup_bookmark(unread=False, user=other_user)
|
||||
bookmark3 = self.setup_bookmark(unread=False, user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_unread'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_unread"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
|
||||
@@ -368,27 +518,43 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(shared=False)
|
||||
bookmark3 = self.setup_bookmark(shared=False)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_share'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_share"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_can_only_bulk_share_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"
|
||||
)
|
||||
bookmark1 = self.setup_bookmark(shared=False, user=other_user)
|
||||
bookmark2 = self.setup_bookmark(shared=False, user=other_user)
|
||||
bookmark3 = self.setup_bookmark(shared=False, user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_share'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_share"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
@@ -399,27 +565,43 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark(shared=True)
|
||||
bookmark3 = self.setup_bookmark(shared=True)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_unshare'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_unshare"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
|
||||
|
||||
def test_can_only_bulk_unshare_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"
|
||||
)
|
||||
bookmark1 = self.setup_bookmark(shared=True, user=other_user)
|
||||
bookmark2 = self.setup_bookmark(shared=True, user=other_user)
|
||||
bookmark3 = self.setup_bookmark(shared=True, user=other_user)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_unshare'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_unshare"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
|
||||
@@ -430,11 +612,14 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_archive"],
|
||||
"bulk_execute": [""],
|
||||
"bulk_select_across": ["on"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
|
||||
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
|
||||
@@ -443,11 +628,14 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_bulk_select_across_ignores_page(self):
|
||||
self.setup_numbered_bookmarks(100)
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action') + '?page=2', {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action") + "?page=2",
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
"bulk_select_across": ["on"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(0, Bookmark.objects.count())
|
||||
|
||||
@@ -455,85 +643,108 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
# create a number of bookmarks with different states / visibility
|
||||
self.setup_numbered_bookmarks(3, with_tags=True)
|
||||
self.setup_numbered_bookmarks(3, with_tags=True, archived=True)
|
||||
self.setup_numbered_bookmarks(3,
|
||||
shared=True,
|
||||
prefix="Joe's Bookmark",
|
||||
user=self.setup_user(enable_sharing=True))
|
||||
self.setup_numbered_bookmarks(
|
||||
3,
|
||||
shared=True,
|
||||
prefix="Joe's Bookmark",
|
||||
user=self.setup_user(enable_sharing=True),
|
||||
)
|
||||
|
||||
def test_index_action_bulk_select_across_only_affects_active_bookmarks(self):
|
||||
self.setup_bulk_edit_scope_test_data()
|
||||
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title='Bookmark 1').first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title='Bookmark 2').first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title='Bookmark 3').first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title="Bookmark 1").first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title="Bookmark 2").first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title="Bookmark 3").first())
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
"bulk_select_across": ["on"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(6, Bookmark.objects.count())
|
||||
self.assertIsNone(Bookmark.objects.filter(title='Bookmark 1').first())
|
||||
self.assertIsNone(Bookmark.objects.filter(title='Bookmark 2').first())
|
||||
self.assertIsNone(Bookmark.objects.filter(title='Bookmark 3').first())
|
||||
self.assertIsNone(Bookmark.objects.filter(title="Bookmark 1").first())
|
||||
self.assertIsNone(Bookmark.objects.filter(title="Bookmark 2").first())
|
||||
self.assertIsNone(Bookmark.objects.filter(title="Bookmark 3").first())
|
||||
|
||||
def test_index_action_bulk_select_across_respects_query(self):
|
||||
self.setup_numbered_bookmarks(3, prefix='foo')
|
||||
self.setup_numbered_bookmarks(3, prefix='bar')
|
||||
self.setup_numbered_bookmarks(3, prefix="foo")
|
||||
self.setup_numbered_bookmarks(3, prefix="bar")
|
||||
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith='foo').count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action') + '?q=foo', {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action") + "?q=foo",
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
"bulk_select_across": ["on"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith='foo').count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith='bar').count())
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
||||
|
||||
def test_archived_action_bulk_select_across_only_affects_archived_bookmarks(self):
|
||||
self.setup_bulk_edit_scope_test_data()
|
||||
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title='Archived Bookmark 1').first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title='Archived Bookmark 2').first())
|
||||
self.assertIsNotNone(Bookmark.objects.filter(title='Archived Bookmark 3').first())
|
||||
self.assertIsNotNone(
|
||||
Bookmark.objects.filter(title="Archived Bookmark 1").first()
|
||||
)
|
||||
self.assertIsNotNone(
|
||||
Bookmark.objects.filter(title="Archived Bookmark 2").first()
|
||||
)
|
||||
self.assertIsNotNone(
|
||||
Bookmark.objects.filter(title="Archived Bookmark 3").first()
|
||||
)
|
||||
|
||||
self.client.post(reverse('bookmarks:archived.action'), {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:archived.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
"bulk_select_across": ["on"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(6, Bookmark.objects.count())
|
||||
self.assertIsNone(Bookmark.objects.filter(title='Archived Bookmark 1').first())
|
||||
self.assertIsNone(Bookmark.objects.filter(title='Archived Bookmark 2').first())
|
||||
self.assertIsNone(Bookmark.objects.filter(title='Archived Bookmark 3').first())
|
||||
self.assertIsNone(Bookmark.objects.filter(title="Archived Bookmark 1").first())
|
||||
self.assertIsNone(Bookmark.objects.filter(title="Archived Bookmark 2").first())
|
||||
self.assertIsNone(Bookmark.objects.filter(title="Archived Bookmark 3").first())
|
||||
|
||||
def test_archived_action_bulk_select_across_respects_query(self):
|
||||
self.setup_numbered_bookmarks(3, prefix='foo', archived=True)
|
||||
self.setup_numbered_bookmarks(3, prefix='bar', archived=True)
|
||||
self.setup_numbered_bookmarks(3, prefix="foo", archived=True)
|
||||
self.setup_numbered_bookmarks(3, prefix="bar", archived=True)
|
||||
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith='foo').count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
|
||||
self.client.post(reverse('bookmarks:archived.action') + '?q=foo', {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:archived.action") + "?q=foo",
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
"bulk_select_across": ["on"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith='foo').count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith='bar').count())
|
||||
self.assertEqual(0, Bookmark.objects.filter(title__startswith="foo").count())
|
||||
self.assertEqual(3, Bookmark.objects.filter(title__startswith="bar").count())
|
||||
|
||||
def test_shared_action_bulk_select_across_not_supported(self):
|
||||
self.setup_bulk_edit_scope_test_data()
|
||||
|
||||
response = self.client.post(reverse('bookmarks:shared.action'), {
|
||||
'bulk_action': ['bulk_delete'],
|
||||
'bulk_execute': [''],
|
||||
'bulk_select_across': ['on'],
|
||||
})
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:shared.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_delete"],
|
||||
"bulk_execute": [""],
|
||||
"bulk_select_across": ["on"],
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_handles_empty_bookmark_id(self):
|
||||
@@ -541,17 +752,23 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
response = self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
})
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_archive"],
|
||||
"bulk_execute": [""],
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
response = self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [],
|
||||
})
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bulk_action": ["bulk_archive"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [],
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
|
||||
@@ -561,9 +778,16 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
self.client.post(reverse('bookmarks:index.action'), {
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index.action"),
|
||||
{
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertBookmarksAreUnmodified([bookmark1, bookmark2, bookmark3])
|
||||
|
||||
@@ -572,14 +796,25 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark2 = self.setup_bookmark()
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
url = reverse('bookmarks:index.action') + '?return_url=' + reverse('bookmarks:settings.index')
|
||||
response = self.client.post(url, {
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
})
|
||||
url = (
|
||||
reverse("bookmarks:index.action")
|
||||
+ "?return_url="
|
||||
+ reverse("bookmarks:settings.index")
|
||||
)
|
||||
response = self.client.post(
|
||||
url,
|
||||
{
|
||||
"bulk_action": ["bulk_archive"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse('bookmarks:settings.index'))
|
||||
self.assertRedirects(response, reverse("bookmarks:settings.index"))
|
||||
|
||||
def test_should_not_redirect_to_external_url(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
@@ -587,19 +822,27 @@ class BookmarkActionViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark3 = self.setup_bookmark()
|
||||
|
||||
def post_with(return_url, follow=None):
|
||||
url = reverse('bookmarks:index.action') + f'?return_url={return_url}'
|
||||
return self.client.post(url, {
|
||||
'bulk_action': ['bulk_archive'],
|
||||
'bulk_execute': [''],
|
||||
'bookmark_id': [str(bookmark1.id), str(bookmark2.id), str(bookmark3.id)],
|
||||
}, follow=follow)
|
||||
url = reverse("bookmarks:index.action") + f"?return_url={return_url}"
|
||||
return self.client.post(
|
||||
url,
|
||||
{
|
||||
"bulk_action": ["bulk_archive"],
|
||||
"bulk_execute": [""],
|
||||
"bookmark_id": [
|
||||
str(bookmark1.id),
|
||||
str(bookmark2.id),
|
||||
str(bookmark3.id),
|
||||
],
|
||||
},
|
||||
follow=follow,
|
||||
)
|
||||
|
||||
response = post_with('https://example.com')
|
||||
self.assertRedirects(response, reverse('bookmarks:index'))
|
||||
response = post_with('//example.com')
|
||||
self.assertRedirects(response, reverse('bookmarks:index'))
|
||||
response = post_with('://example.com')
|
||||
self.assertRedirects(response, reverse('bookmarks:index'))
|
||||
response = post_with("https://example.com")
|
||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||
response = post_with("//example.com")
|
||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||
response = post_with("://example.com")
|
||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||
|
||||
response = post_with('/foo//example.com', follow=True)
|
||||
response = post_with("/foo//example.com", follow=True)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
@@ -6,7 +6,11 @@ from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin, collapse_whitespace
|
||||
from bookmarks.tests.helpers import (
|
||||
BookmarkFactoryMixin,
|
||||
HtmlTestMixin,
|
||||
collapse_whitespace,
|
||||
)
|
||||
|
||||
|
||||
class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
@@ -15,33 +19,41 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
|
||||
def assertVisibleBookmarks(
|
||||
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
|
||||
):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
bookmark_list = soup.select_one(f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]')
|
||||
bookmark_list = soup.select_one(
|
||||
f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]'
|
||||
)
|
||||
self.assertIsNotNone(bookmark_list)
|
||||
|
||||
bookmark_items = bookmark_list.select('li[ld-bookmark-item]')
|
||||
bookmark_items = bookmark_list.select("li[ld-bookmark-item]")
|
||||
self.assertEqual(len(bookmark_items), len(bookmarks))
|
||||
|
||||
for bookmark in bookmarks:
|
||||
bookmark_item = bookmark_list.select_one(
|
||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]')
|
||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
|
||||
)
|
||||
self.assertIsNotNone(bookmark_item)
|
||||
|
||||
def assertInvisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
|
||||
def assertInvisibleBookmarks(
|
||||
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
|
||||
):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
|
||||
for bookmark in bookmarks:
|
||||
bookmark_item = soup.select_one(
|
||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]')
|
||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
|
||||
)
|
||||
self.assertIsNone(bookmark_item)
|
||||
|
||||
def assertVisibleTags(self, response, tags: List[Tag]):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
tag_cloud = soup.select_one('div.tag-cloud')
|
||||
tag_cloud = soup.select_one("div.tag-cloud")
|
||||
self.assertIsNotNone(tag_cloud)
|
||||
|
||||
tag_items = tag_cloud.select('a[data-is-tag-item]')
|
||||
tag_items = tag_cloud.select("a[data-is-tag-item]")
|
||||
self.assertEqual(len(tag_items), len(tags))
|
||||
|
||||
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
|
||||
@@ -51,7 +63,7 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
def assertInvisibleTags(self, response, tags: List[Tag]):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
tag_items = soup.select('a[data-is-tag-item]')
|
||||
tag_items = soup.select("a[data-is-tag-item]")
|
||||
|
||||
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
|
||||
|
||||
@@ -60,78 +72,103 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
def assertSelectedTags(self, response, tags: List[Tag]):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
selected_tags = soup.select_one('p.selected-tags')
|
||||
selected_tags = soup.select_one("p.selected-tags")
|
||||
self.assertIsNotNone(selected_tags)
|
||||
|
||||
tag_list = selected_tags.select('a')
|
||||
tag_list = selected_tags.select("a")
|
||||
self.assertEqual(len(tag_list), len(tags))
|
||||
|
||||
for tag in tags:
|
||||
self.assertTrue(tag.name in selected_tags.text, msg=f'Selected tags do not contain: {tag.name}')
|
||||
self.assertTrue(
|
||||
tag.name in selected_tags.text,
|
||||
msg=f"Selected tags do not contain: {tag.name}",
|
||||
)
|
||||
|
||||
def assertEditLink(self, response, url):
|
||||
html = response.content.decode()
|
||||
self.assertInHTML(f'''
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<a href="{url}">Edit</a>
|
||||
''', html)
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
def assertBulkActionForm(self, response, url: str):
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
needle = collapse_whitespace(f'''
|
||||
needle = collapse_whitespace(
|
||||
f"""
|
||||
<form class="bookmark-actions"
|
||||
action="{url}"
|
||||
method="post" autocomplete="off">
|
||||
''')
|
||||
"""
|
||||
)
|
||||
self.assertIn(needle, html)
|
||||
|
||||
def test_should_list_archived_and_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
other_user = User.objects.create_user(
|
||||
"otheruser", "otheruser@example.com", "password123"
|
||||
)
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, archived=True)
|
||||
invisible_bookmarks = [
|
||||
self.setup_bookmark(is_archived=False),
|
||||
self.setup_bookmark(is_archived=True, user=other_user),
|
||||
]
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived'))
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_bookmarks_matching_query(self):
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, prefix='foo', archived=True)
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix='bar', archived=True)
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, prefix="foo", archived=True
|
||||
)
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, prefix="bar", archived=True
|
||||
)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived') + '?q=foo')
|
||||
response = self.client.get(reverse("bookmarks:archived") + "?q=foo")
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_tags_for_archived_and_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, archived=True)
|
||||
unarchived_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, archived=False, tag_prefix='unarchived')
|
||||
other_user_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, archived=True, user=other_user,
|
||||
tag_prefix='otheruser')
|
||||
other_user = User.objects.create_user(
|
||||
"otheruser", "otheruser@example.com", "password123"
|
||||
)
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, archived=True
|
||||
)
|
||||
unarchived_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, archived=False, tag_prefix="unarchived"
|
||||
)
|
||||
other_user_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, archived=True, user=other_user, tag_prefix="otheruser"
|
||||
)
|
||||
|
||||
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
|
||||
invisible_tags = self.get_tags_from_bookmarks(unarchived_bookmarks + other_user_bookmarks)
|
||||
invisible_tags = self.get_tags_from_bookmarks(
|
||||
unarchived_bookmarks + other_user_bookmarks
|
||||
)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived'))
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_tags_for_bookmarks_matching_query(self):
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, archived=True, prefix='foo',
|
||||
tag_prefix='foo')
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, archived=True, prefix='bar',
|
||||
tag_prefix='bar')
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, archived=True, prefix="foo", tag_prefix="foo"
|
||||
)
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, archived=True, prefix="bar", tag_prefix="bar"
|
||||
)
|
||||
|
||||
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
|
||||
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived') + '?q=foo')
|
||||
response = self.client.get(reverse("bookmarks:archived") + "?q=foo")
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
@@ -139,19 +176,31 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
|
||||
user_profile = self.user.profile
|
||||
user_profile.search_preferences = {
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_YES,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
}
|
||||
user_profile.save()
|
||||
|
||||
unread_bookmarks = self.setup_numbered_bookmarks(3, archived=True, unread=True, with_tags=True, prefix='unread',
|
||||
tag_prefix='unread')
|
||||
read_bookmarks = self.setup_numbered_bookmarks(3, archived=True, unread=False, with_tags=True, prefix='read',
|
||||
tag_prefix='read')
|
||||
unread_bookmarks = self.setup_numbered_bookmarks(
|
||||
3,
|
||||
archived=True,
|
||||
unread=True,
|
||||
with_tags=True,
|
||||
prefix="unread",
|
||||
tag_prefix="unread",
|
||||
)
|
||||
read_bookmarks = self.setup_numbered_bookmarks(
|
||||
3,
|
||||
archived=True,
|
||||
unread=False,
|
||||
with_tags=True,
|
||||
prefix="read",
|
||||
tag_prefix="read",
|
||||
)
|
||||
|
||||
unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)
|
||||
read_tags = self.get_tags_from_bookmarks(read_bookmarks)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived'))
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
self.assertVisibleBookmarks(response, unread_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, read_bookmarks)
|
||||
self.assertVisibleTags(response, unread_tags)
|
||||
@@ -167,11 +216,15 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
]
|
||||
self.setup_bookmark(is_archived=True, tags=tags)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived') + f'?q=%23{tags[0].name}+%23{tags[1].name}')
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:archived") + f"?q=%23{tags[0].name}+%23{tags[1].name}"
|
||||
)
|
||||
|
||||
self.assertSelectedTags(response, [tags[0], tags[1]])
|
||||
|
||||
def test_should_not_display_search_terms_from_query_as_selected_tags_in_strict_mode(self):
|
||||
def test_should_not_display_search_terms_from_query_as_selected_tags_in_strict_mode(
|
||||
self,
|
||||
):
|
||||
tags = [
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
@@ -181,7 +234,10 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
]
|
||||
self.setup_bookmark(title=tags[0].name, tags=tags, is_archived=True)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:archived")
|
||||
+ f"?q={tags[0].name}+%23{tags[1].name.upper()}"
|
||||
)
|
||||
|
||||
self.assertSelectedTags(response, [tags[1]])
|
||||
|
||||
@@ -198,16 +254,19 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
]
|
||||
self.setup_bookmark(tags=tags, is_archived=True)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:archived")
|
||||
+ f"?q={tags[0].name}+%23{tags[1].name.upper()}"
|
||||
)
|
||||
|
||||
self.assertSelectedTags(response, [tags[0], tags[1]])
|
||||
|
||||
def test_should_open_bookmarks_in_new_page_by_default(self):
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, archived=True)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived'))
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, '_blank')
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, "_blank")
|
||||
|
||||
def test_should_open_bookmarks_in_same_page_if_specified_in_user_profile(self):
|
||||
user = self.get_or_create_test_user()
|
||||
@@ -216,71 +275,72 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, archived=True)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived'))
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, "_self")
|
||||
|
||||
def test_edit_link_return_url_respects_search_options(self):
|
||||
bookmark = self.setup_bookmark(title='foo', is_archived=True)
|
||||
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
|
||||
base_url = reverse('bookmarks:archived')
|
||||
bookmark = self.setup_bookmark(title="foo", is_archived=True)
|
||||
edit_url = reverse("bookmarks:edit", args=[bookmark.id])
|
||||
base_url = reverse("bookmarks:archived")
|
||||
|
||||
# without query params
|
||||
return_url = urllib.parse.quote(base_url)
|
||||
url = f'{edit_url}?return_url={return_url}'
|
||||
url = f"{edit_url}?return_url={return_url}"
|
||||
|
||||
response = self.client.get(base_url)
|
||||
self.assertEditLink(response, url)
|
||||
|
||||
# with query
|
||||
url_params = '?q=foo'
|
||||
url_params = "?q=foo"
|
||||
return_url = urllib.parse.quote(base_url + url_params)
|
||||
url = f'{edit_url}?return_url={return_url}'
|
||||
url = f"{edit_url}?return_url={return_url}"
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertEditLink(response, url)
|
||||
|
||||
# with query and sort and page
|
||||
url_params = '?q=foo&sort=title_asc&page=2'
|
||||
url_params = "?q=foo&sort=title_asc&page=2"
|
||||
return_url = urllib.parse.quote(base_url + url_params)
|
||||
url = f'{edit_url}?return_url={return_url}'
|
||||
url = f"{edit_url}?return_url={return_url}"
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertEditLink(response, url)
|
||||
|
||||
def test_bulk_edit_respects_search_options(self):
|
||||
action_url = reverse('bookmarks:archived.action')
|
||||
base_url = reverse('bookmarks:archived')
|
||||
action_url = reverse("bookmarks:archived.action")
|
||||
base_url = reverse("bookmarks:archived")
|
||||
|
||||
# without params
|
||||
return_url = urllib.parse.quote_plus(base_url)
|
||||
url = f'{action_url}?return_url={return_url}'
|
||||
url = f"{action_url}?return_url={return_url}"
|
||||
|
||||
response = self.client.get(base_url)
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
||||
# with query
|
||||
url_params = '?q=foo'
|
||||
url_params = "?q=foo"
|
||||
return_url = urllib.parse.quote_plus(base_url + url_params)
|
||||
url = f'{action_url}?q=foo&return_url={return_url}'
|
||||
url = f"{action_url}?q=foo&return_url={return_url}"
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
||||
# with query and sort
|
||||
url_params = '?q=foo&sort=title_asc'
|
||||
url_params = "?q=foo&sort=title_asc"
|
||||
return_url = urllib.parse.quote_plus(base_url + url_params)
|
||||
url = f'{action_url}?q=foo&sort=title_asc&return_url={return_url}'
|
||||
url = f"{action_url}?q=foo&sort=title_asc&return_url={return_url}"
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
||||
def test_allowed_bulk_actions(self):
|
||||
url = reverse('bookmarks:archived')
|
||||
url = reverse("bookmarks:archived")
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(f'''
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<select name="bulk_action" class="form-select select-sm">
|
||||
<option value="bulk_unarchive">Unarchive</option>
|
||||
<option value="bulk_delete">Delete</option>
|
||||
@@ -289,18 +349,21 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
<option value="bulk_read">Mark as read</option>
|
||||
<option value="bulk_unread">Mark as unread</option>
|
||||
</select>
|
||||
''', html)
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
def test_allowed_bulk_actions_with_sharing_enabled(self):
|
||||
user_profile = self.user.profile
|
||||
user_profile.enable_sharing = True
|
||||
user_profile.save()
|
||||
|
||||
url = reverse('bookmarks:archived')
|
||||
url = reverse("bookmarks:archived")
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(f'''
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<select name="bulk_action" class="form-select select-sm">
|
||||
<option value="bulk_unarchive">Unarchive</option>
|
||||
<option value="bulk_delete">Delete</option>
|
||||
@@ -311,142 +374,191 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
<option value="bulk_share">Share</option>
|
||||
<option value="bulk_unshare">Unshare</option>
|
||||
</select>
|
||||
''', html)
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
def test_apply_search_preferences(self):
|
||||
# no params
|
||||
response = self.client.post(reverse('bookmarks:archived'))
|
||||
response = self.client.post(reverse("bookmarks:archived"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse('bookmarks:archived'))
|
||||
self.assertEqual(response.url, reverse("bookmarks:archived"))
|
||||
|
||||
# some params
|
||||
response = self.client.post(reverse('bookmarks:archived'), {
|
||||
'q': 'foo',
|
||||
'sort': BookmarkSearch.SORT_TITLE_ASC,
|
||||
})
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:archived"),
|
||||
{
|
||||
"q": "foo",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse('bookmarks:archived') + '?q=foo&sort=title_asc')
|
||||
self.assertEqual(
|
||||
response.url, reverse("bookmarks:archived") + "?q=foo&sort=title_asc"
|
||||
)
|
||||
|
||||
# params with default value are removed
|
||||
response = self.client.post(reverse('bookmarks:archived'), {
|
||||
'q': 'foo',
|
||||
'user': '',
|
||||
'sort': BookmarkSearch.SORT_ADDED_DESC,
|
||||
'shared': BookmarkSearch.FILTER_SHARED_OFF,
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_YES,
|
||||
})
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:archived"),
|
||||
{
|
||||
"q": "foo",
|
||||
"user": "",
|
||||
"sort": BookmarkSearch.SORT_ADDED_DESC,
|
||||
"shared": BookmarkSearch.FILTER_SHARED_OFF,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse('bookmarks:archived') + '?q=foo&unread=yes')
|
||||
self.assertEqual(
|
||||
response.url, reverse("bookmarks:archived") + "?q=foo&unread=yes"
|
||||
)
|
||||
|
||||
# page is removed
|
||||
response = self.client.post(reverse('bookmarks:archived'), {
|
||||
'q': 'foo',
|
||||
'page': '2',
|
||||
'sort': BookmarkSearch.SORT_TITLE_ASC,
|
||||
})
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:archived"),
|
||||
{
|
||||
"q": "foo",
|
||||
"page": "2",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse('bookmarks:archived') + '?q=foo&sort=title_asc')
|
||||
self.assertEqual(
|
||||
response.url, reverse("bookmarks:archived") + "?q=foo&sort=title_asc"
|
||||
)
|
||||
|
||||
def test_save_search_preferences(self):
|
||||
user_profile = self.user.profile
|
||||
|
||||
# no params
|
||||
self.client.post(reverse('bookmarks:archived'), {
|
||||
'save': '',
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:archived"),
|
||||
{
|
||||
"save": "",
|
||||
},
|
||||
)
|
||||
user_profile.refresh_from_db()
|
||||
self.assertEqual(user_profile.search_preferences, {
|
||||
'sort': BookmarkSearch.SORT_ADDED_DESC,
|
||||
'shared': BookmarkSearch.FILTER_SHARED_OFF,
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
|
||||
})
|
||||
self.assertEqual(
|
||||
user_profile.search_preferences,
|
||||
{
|
||||
"sort": BookmarkSearch.SORT_ADDED_DESC,
|
||||
"shared": BookmarkSearch.FILTER_SHARED_OFF,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_OFF,
|
||||
},
|
||||
)
|
||||
|
||||
# with param
|
||||
self.client.post(reverse('bookmarks:archived'), {
|
||||
'save': '',
|
||||
'sort': BookmarkSearch.SORT_TITLE_ASC,
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:archived"),
|
||||
{
|
||||
"save": "",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
},
|
||||
)
|
||||
user_profile.refresh_from_db()
|
||||
self.assertEqual(user_profile.search_preferences, {
|
||||
'sort': BookmarkSearch.SORT_TITLE_ASC,
|
||||
'shared': BookmarkSearch.FILTER_SHARED_OFF,
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
|
||||
})
|
||||
self.assertEqual(
|
||||
user_profile.search_preferences,
|
||||
{
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
"shared": BookmarkSearch.FILTER_SHARED_OFF,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_OFF,
|
||||
},
|
||||
)
|
||||
|
||||
# add a param
|
||||
self.client.post(reverse('bookmarks:archived'), {
|
||||
'save': '',
|
||||
'sort': BookmarkSearch.SORT_TITLE_ASC,
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_YES,
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:archived"),
|
||||
{
|
||||
"save": "",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
},
|
||||
)
|
||||
user_profile.refresh_from_db()
|
||||
self.assertEqual(user_profile.search_preferences, {
|
||||
'sort': BookmarkSearch.SORT_TITLE_ASC,
|
||||
'shared': BookmarkSearch.FILTER_SHARED_OFF,
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_YES,
|
||||
})
|
||||
self.assertEqual(
|
||||
user_profile.search_preferences,
|
||||
{
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
"shared": BookmarkSearch.FILTER_SHARED_OFF,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
},
|
||||
)
|
||||
|
||||
# remove a param
|
||||
self.client.post(reverse('bookmarks:archived'), {
|
||||
'save': '',
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_YES,
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:archived"),
|
||||
{
|
||||
"save": "",
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
},
|
||||
)
|
||||
user_profile.refresh_from_db()
|
||||
self.assertEqual(user_profile.search_preferences, {
|
||||
'sort': BookmarkSearch.SORT_ADDED_DESC,
|
||||
'shared': BookmarkSearch.FILTER_SHARED_OFF,
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_YES,
|
||||
})
|
||||
self.assertEqual(
|
||||
user_profile.search_preferences,
|
||||
{
|
||||
"sort": BookmarkSearch.SORT_ADDED_DESC,
|
||||
"shared": BookmarkSearch.FILTER_SHARED_OFF,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
},
|
||||
)
|
||||
|
||||
# ignores non-preferences
|
||||
self.client.post(reverse('bookmarks:archived'), {
|
||||
'save': '',
|
||||
'q': 'foo',
|
||||
'user': 'john',
|
||||
'page': '3',
|
||||
'sort': BookmarkSearch.SORT_TITLE_ASC,
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:archived"),
|
||||
{
|
||||
"save": "",
|
||||
"q": "foo",
|
||||
"user": "john",
|
||||
"page": "3",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
},
|
||||
)
|
||||
user_profile.refresh_from_db()
|
||||
self.assertEqual(user_profile.search_preferences, {
|
||||
'sort': BookmarkSearch.SORT_TITLE_ASC,
|
||||
'shared': BookmarkSearch.FILTER_SHARED_OFF,
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
|
||||
})
|
||||
self.assertEqual(
|
||||
user_profile.search_preferences,
|
||||
{
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
"shared": BookmarkSearch.FILTER_SHARED_OFF,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_OFF,
|
||||
},
|
||||
)
|
||||
|
||||
def test_url_encode_bookmark_actions_url(self):
|
||||
url = reverse('bookmarks:archived') + '?q=%23foo'
|
||||
url = reverse("bookmarks:archived") + "?q=%23foo"
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
soup = self.make_soup(html)
|
||||
actions_form = soup.select('form.bookmark-actions')[0]
|
||||
actions_form = soup.select("form.bookmark-actions")[0]
|
||||
|
||||
self.assertEqual(actions_form.attrs['action'],
|
||||
'/bookmarks/archived/action?q=%23foo&return_url=%2Fbookmarks%2Farchived%3Fq%3D%2523foo')
|
||||
self.assertEqual(
|
||||
actions_form.attrs["action"],
|
||||
"/bookmarks/archived/action?q=%23foo&return_url=%2Fbookmarks%2Farchived%3Fq%3D%2523foo",
|
||||
)
|
||||
|
||||
def test_encode_search_params(self):
|
||||
bookmark = self.setup_bookmark(description='alert(\'xss\')', is_archived=True)
|
||||
bookmark = self.setup_bookmark(description="alert('xss')", is_archived=True)
|
||||
|
||||
url = reverse('bookmarks:archived') + '?q=alert(%27xss%27)'
|
||||
url = reverse("bookmarks:archived") + "?q=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
self.assertContains(response, bookmark.url)
|
||||
|
||||
url = reverse('bookmarks:archived') + '?sort=alert(%27xss%27)'
|
||||
url = reverse("bookmarks:archived") + "?sort=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse('bookmarks:archived') + '?unread=alert(%27xss%27)'
|
||||
url = reverse("bookmarks:archived") + "?unread=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse('bookmarks:archived') + '?shared=alert(%27xss%27)'
|
||||
url = reverse("bookmarks:archived") + "?shared=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse('bookmarks:archived') + '?user=alert(%27xss%27)'
|
||||
url = reverse("bookmarks:archived") + "?user=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse('bookmarks:archived') + '?page=alert(%27xss%27)'
|
||||
url = reverse("bookmarks:archived") + "?page=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
@@ -8,7 +8,9 @@ from django.db.utils import DEFAULT_DB_ALIAS
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class BookmarkArchivedViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
|
||||
class BookmarkArchivedViewPerformanceTestCase(
|
||||
TransactionTestCase, BookmarkFactoryMixin
|
||||
):
|
||||
|
||||
def setUp(self) -> None:
|
||||
user = self.get_or_create_test_user()
|
||||
@@ -26,8 +28,10 @@ class BookmarkArchivedViewPerformanceTestCase(TransactionTestCase, BookmarkFacto
|
||||
# capture number of queries
|
||||
context = CaptureQueriesContext(self.get_connection())
|
||||
with context:
|
||||
response = self.client.get(reverse('bookmarks:archived'))
|
||||
self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks)
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
self.assertContains(
|
||||
response, "<li ld-bookmark-item>", num_initial_bookmarks
|
||||
)
|
||||
|
||||
number_of_queries = context.final_queries
|
||||
|
||||
@@ -38,5 +42,9 @@ class BookmarkArchivedViewPerformanceTestCase(TransactionTestCase, BookmarkFacto
|
||||
|
||||
# assert num queries doesn't increase
|
||||
with self.assertNumQueries(number_of_queries):
|
||||
response = self.client.get(reverse('bookmarks:archived'))
|
||||
self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks + num_additional_bookmarks)
|
||||
response = self.client.get(reverse("bookmarks:archived"))
|
||||
self.assertContains(
|
||||
response,
|
||||
"<li ld-bookmark-item>",
|
||||
num_initial_bookmarks + num_additional_bookmarks,
|
||||
)
|
||||
|
||||
@@ -16,153 +16,192 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
if overrides is None:
|
||||
overrides = {}
|
||||
form_data = {
|
||||
'url': 'http://example.com/edited',
|
||||
'tag_string': 'editedtag1 editedtag2',
|
||||
'title': 'edited title',
|
||||
'description': 'edited description',
|
||||
'notes': 'edited notes',
|
||||
'unread': False,
|
||||
'shared': False,
|
||||
"url": "http://example.com/edited",
|
||||
"tag_string": "editedtag1 editedtag2",
|
||||
"title": "edited title",
|
||||
"description": "edited description",
|
||||
"notes": "edited notes",
|
||||
"unread": False,
|
||||
"shared": False,
|
||||
}
|
||||
return {**form_data, **overrides}
|
||||
|
||||
def test_should_edit_bookmark(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
form_data = self.create_form_data({'id': bookmark.id})
|
||||
form_data = self.create_form_data({"id": bookmark.id})
|
||||
|
||||
self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
|
||||
self.client.post(reverse("bookmarks:edit", args=[bookmark.id]), form_data)
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.owner, self.user)
|
||||
self.assertEqual(bookmark.url, form_data['url'])
|
||||
self.assertEqual(bookmark.title, form_data['title'])
|
||||
self.assertEqual(bookmark.description, form_data['description'])
|
||||
self.assertEqual(bookmark.notes, form_data['notes'])
|
||||
self.assertEqual(bookmark.unread, form_data['unread'])
|
||||
self.assertEqual(bookmark.shared, form_data['shared'])
|
||||
self.assertEqual(bookmark.url, form_data["url"])
|
||||
self.assertEqual(bookmark.title, form_data["title"])
|
||||
self.assertEqual(bookmark.description, form_data["description"])
|
||||
self.assertEqual(bookmark.notes, form_data["notes"])
|
||||
self.assertEqual(bookmark.unread, form_data["unread"])
|
||||
self.assertEqual(bookmark.shared, form_data["shared"])
|
||||
self.assertEqual(bookmark.tags.count(), 2)
|
||||
tags = bookmark.tags.order_by('name').all()
|
||||
self.assertEqual(tags[0].name, 'editedtag1')
|
||||
self.assertEqual(tags[1].name, 'editedtag2')
|
||||
tags = bookmark.tags.order_by("name").all()
|
||||
self.assertEqual(tags[0].name, "editedtag1")
|
||||
self.assertEqual(tags[1].name, "editedtag2")
|
||||
|
||||
def test_should_edit_unread_state(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
form_data = self.create_form_data({'id': bookmark.id, 'unread': True})
|
||||
self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
|
||||
form_data = self.create_form_data({"id": bookmark.id, "unread": True})
|
||||
self.client.post(reverse("bookmarks:edit", args=[bookmark.id]), form_data)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertTrue(bookmark.unread)
|
||||
|
||||
form_data = self.create_form_data({'id': bookmark.id, 'unread': False})
|
||||
self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
|
||||
form_data = self.create_form_data({"id": bookmark.id, "unread": False})
|
||||
self.client.post(reverse("bookmarks:edit", args=[bookmark.id]), form_data)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertFalse(bookmark.unread)
|
||||
|
||||
def test_should_edit_shared_state(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
form_data = self.create_form_data({'id': bookmark.id, 'shared': True})
|
||||
self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
|
||||
form_data = self.create_form_data({"id": bookmark.id, "shared": True})
|
||||
self.client.post(reverse("bookmarks:edit", args=[bookmark.id]), form_data)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertTrue(bookmark.shared)
|
||||
|
||||
form_data = self.create_form_data({'id': bookmark.id, 'shared': False})
|
||||
self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
|
||||
form_data = self.create_form_data({"id": bookmark.id, "shared": False})
|
||||
self.client.post(reverse("bookmarks:edit", args=[bookmark.id]), form_data)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertFalse(bookmark.shared)
|
||||
|
||||
def test_should_prefill_bookmark_form_fields(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
bookmark = self.setup_bookmark(tags=[tag1, tag2], title='edited title', description='edited description',
|
||||
notes='edited notes', website_title='website title',
|
||||
website_description='website description')
|
||||
bookmark = self.setup_bookmark(
|
||||
tags=[tag1, tag2],
|
||||
title="edited title",
|
||||
description="edited description",
|
||||
notes="edited notes",
|
||||
website_title="website title",
|
||||
website_description="website description",
|
||||
)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
|
||||
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(f'''
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<input type="text" name="url" value="{bookmark.url}" placeholder=" "
|
||||
autofocus class="form-input" required id="id_url">
|
||||
''', html)
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
tag_string = build_tag_string(bookmark.tag_names, ' ')
|
||||
self.assertInHTML(f'''
|
||||
tag_string = build_tag_string(bookmark.tag_names, " ")
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<input ld-tag-autocomplete type="text" name="tag_string" value="{tag_string}"
|
||||
autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string">
|
||||
''', html)
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
self.assertInHTML(f'''
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<input type="text" name="title" value="{bookmark.title}" maxlength="512" autocomplete="off"
|
||||
class="form-input" id="id_title">
|
||||
''', html)
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
self.assertInHTML(f'''
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<textarea name="description" cols="40" rows="2" class="form-input" id="id_description">
|
||||
{bookmark.description}
|
||||
</textarea>
|
||||
''', html)
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
self.assertInHTML(f'''
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes">
|
||||
{bookmark.notes}
|
||||
</textarea>
|
||||
''', html)
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
self.assertInHTML(f'''
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<input type="hidden" name="website_title" id="id_website_title"
|
||||
value="{bookmark.website_title}">
|
||||
''', html)
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
self.assertInHTML(f'''
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<input type="hidden" name="website_description" id="id_website_description"
|
||||
value="{bookmark.website_description}">
|
||||
''', html)
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
def test_should_redirect_to_return_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
form_data = self.create_form_data()
|
||||
|
||||
url = reverse('bookmarks:edit', args=[bookmark.id]) + '?return_url=' + reverse('bookmarks:close')
|
||||
url = (
|
||||
reverse("bookmarks:edit", args=[bookmark.id])
|
||||
+ "?return_url="
|
||||
+ reverse("bookmarks:close")
|
||||
)
|
||||
response = self.client.post(url, form_data)
|
||||
|
||||
self.assertRedirects(response, reverse('bookmarks:close'))
|
||||
self.assertRedirects(response, reverse("bookmarks:close"))
|
||||
|
||||
def test_should_redirect_to_bookmark_index_by_default(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
form_data = self.create_form_data()
|
||||
|
||||
response = self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:edit", args=[bookmark.id]), form_data
|
||||
)
|
||||
|
||||
self.assertRedirects(response, reverse('bookmarks:index'))
|
||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||
|
||||
def test_should_not_redirect_to_external_url(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
def post_with(return_url, follow=None):
|
||||
form_data = self.create_form_data()
|
||||
url = reverse('bookmarks:edit', args=[bookmark.id]) + f'?return_url={return_url}'
|
||||
url = (
|
||||
reverse("bookmarks:edit", args=[bookmark.id])
|
||||
+ f"?return_url={return_url}"
|
||||
)
|
||||
return self.client.post(url, form_data, follow=follow)
|
||||
|
||||
response = post_with('https://example.com')
|
||||
self.assertRedirects(response, reverse('bookmarks:index'))
|
||||
response = post_with('//example.com')
|
||||
self.assertRedirects(response, reverse('bookmarks:index'))
|
||||
response = post_with('://example.com')
|
||||
self.assertRedirects(response, reverse('bookmarks:index'))
|
||||
response = post_with("https://example.com")
|
||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||
response = post_with("//example.com")
|
||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||
response = post_with("://example.com")
|
||||
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||
|
||||
response = post_with('/foo//example.com', follow=True)
|
||||
response = post_with("/foo//example.com", follow=True)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_can_only_edit_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"
|
||||
)
|
||||
bookmark = self.setup_bookmark(user=other_user)
|
||||
form_data = self.create_form_data({'id': bookmark.id})
|
||||
form_data = self.create_form_data({"id": bookmark.id})
|
||||
|
||||
response = self.client.post(reverse('bookmarks:edit', args=[bookmark.id]), form_data)
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:edit", args=[bookmark.id]), form_data
|
||||
)
|
||||
bookmark.refresh_from_db()
|
||||
self.assertNotEqual(bookmark.url, form_data['url'])
|
||||
self.assertNotEqual(bookmark.url, form_data["url"])
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_should_respect_share_profile_setting(self):
|
||||
@@ -170,38 +209,46 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.user.profile.enable_sharing = False
|
||||
self.user.profile.save()
|
||||
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
|
||||
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML('''
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<label for="id_shared" class="form-checkbox">
|
||||
<input type="checkbox" name="shared" id="id_shared">
|
||||
<i class="form-icon"></i>
|
||||
<span>Share</span>
|
||||
</label>
|
||||
''', html, count=0)
|
||||
""",
|
||||
html,
|
||||
count=0,
|
||||
)
|
||||
|
||||
self.user.profile.enable_sharing = True
|
||||
self.user.profile.save()
|
||||
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
|
||||
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML('''
|
||||
self.assertInHTML(
|
||||
"""
|
||||
<label for="id_shared" class="form-checkbox">
|
||||
<input type="checkbox" name="shared" id="id_shared">
|
||||
<i class="form-icon"></i>
|
||||
<span>Share</span>
|
||||
</label>
|
||||
''', html, count=1)
|
||||
""",
|
||||
html,
|
||||
count=1,
|
||||
)
|
||||
|
||||
def test_should_hide_notes_if_there_are_no_notes(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
|
||||
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
|
||||
|
||||
self.assertContains(response, '<details class="notes">', count=1)
|
||||
|
||||
def test_should_show_notes_if_there_are_notes(self):
|
||||
bookmark = self.setup_bookmark(notes='test notes')
|
||||
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
|
||||
bookmark = self.setup_bookmark(notes="test notes")
|
||||
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
|
||||
|
||||
self.assertContains(response, '<details class="notes" open>', count=1)
|
||||
|
||||
@@ -6,7 +6,11 @@ from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin, collapse_whitespace
|
||||
from bookmarks.tests.helpers import (
|
||||
BookmarkFactoryMixin,
|
||||
HtmlTestMixin,
|
||||
collapse_whitespace,
|
||||
)
|
||||
|
||||
|
||||
class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
@@ -15,33 +19,41 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
user = self.get_or_create_test_user()
|
||||
self.client.force_login(user)
|
||||
|
||||
def assertVisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
|
||||
def assertVisibleBookmarks(
|
||||
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
|
||||
):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
bookmark_list = soup.select_one(f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]')
|
||||
bookmark_list = soup.select_one(
|
||||
f'ul.bookmark-list[data-bookmarks-total="{len(bookmarks)}"]'
|
||||
)
|
||||
self.assertIsNotNone(bookmark_list)
|
||||
|
||||
bookmark_items = bookmark_list.select('li[ld-bookmark-item]')
|
||||
bookmark_items = bookmark_list.select("li[ld-bookmark-item]")
|
||||
self.assertEqual(len(bookmark_items), len(bookmarks))
|
||||
|
||||
for bookmark in bookmarks:
|
||||
bookmark_item = bookmark_list.select_one(
|
||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]')
|
||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
|
||||
)
|
||||
self.assertIsNotNone(bookmark_item)
|
||||
|
||||
def assertInvisibleBookmarks(self, response, bookmarks: List[Bookmark], link_target: str = '_blank'):
|
||||
def assertInvisibleBookmarks(
|
||||
self, response, bookmarks: List[Bookmark], link_target: str = "_blank"
|
||||
):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
|
||||
for bookmark in bookmarks:
|
||||
bookmark_item = soup.select_one(
|
||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]')
|
||||
f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]'
|
||||
)
|
||||
self.assertIsNone(bookmark_item)
|
||||
|
||||
def assertVisibleTags(self, response, tags: List[Tag]):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
tag_cloud = soup.select_one('div.tag-cloud')
|
||||
tag_cloud = soup.select_one("div.tag-cloud")
|
||||
self.assertIsNotNone(tag_cloud)
|
||||
|
||||
tag_items = tag_cloud.select('a[data-is-tag-item]')
|
||||
tag_items = tag_cloud.select("a[data-is-tag-item]")
|
||||
self.assertEqual(len(tag_items), len(tags))
|
||||
|
||||
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
|
||||
@@ -51,7 +63,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
def assertInvisibleTags(self, response, tags: List[Tag]):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
tag_items = soup.select('a[data-is-tag-item]')
|
||||
tag_items = soup.select("a[data-is-tag-item]")
|
||||
|
||||
tag_item_names = [tag_item.text.strip() for tag_item in tag_items]
|
||||
|
||||
@@ -60,74 +72,96 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
def assertSelectedTags(self, response, tags: List[Tag]):
|
||||
soup = self.make_soup(response.content.decode())
|
||||
selected_tags = soup.select_one('p.selected-tags')
|
||||
selected_tags = soup.select_one("p.selected-tags")
|
||||
self.assertIsNotNone(selected_tags)
|
||||
|
||||
tag_list = selected_tags.select('a')
|
||||
tag_list = selected_tags.select("a")
|
||||
self.assertEqual(len(tag_list), len(tags))
|
||||
|
||||
for tag in tags:
|
||||
self.assertTrue(tag.name in selected_tags.text, msg=f'Selected tags do not contain: {tag.name}')
|
||||
self.assertTrue(
|
||||
tag.name in selected_tags.text,
|
||||
msg=f"Selected tags do not contain: {tag.name}",
|
||||
)
|
||||
|
||||
def assertEditLink(self, response, url):
|
||||
html = response.content.decode()
|
||||
self.assertInHTML(f'''
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<a href="{url}">Edit</a>
|
||||
''', html)
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
def assertBulkActionForm(self, response, url: str):
|
||||
html = collapse_whitespace(response.content.decode())
|
||||
needle = collapse_whitespace(f'''
|
||||
needle = collapse_whitespace(
|
||||
f"""
|
||||
<form class="bookmark-actions"
|
||||
action="{url}"
|
||||
method="post" autocomplete="off">
|
||||
''')
|
||||
"""
|
||||
)
|
||||
self.assertIn(needle, html)
|
||||
|
||||
def test_should_list_unarchived_and_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
other_user = User.objects.create_user(
|
||||
"otheruser", "otheruser@example.com", "password123"
|
||||
)
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3)
|
||||
invisible_bookmarks = [
|
||||
self.setup_bookmark(is_archived=True),
|
||||
self.setup_bookmark(user=other_user),
|
||||
]
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_bookmarks_matching_query(self):
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, prefix='foo')
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix='bar')
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, prefix="foo")
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(3, prefix="bar")
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index') + '?q=foo')
|
||||
response = self.client.get(reverse("bookmarks:index") + "?q=foo")
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, invisible_bookmarks)
|
||||
|
||||
def test_should_list_tags_for_unarchived_and_user_owned_bookmarks(self):
|
||||
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||
other_user = User.objects.create_user(
|
||||
"otheruser", "otheruser@example.com", "password123"
|
||||
)
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True)
|
||||
archived_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, archived=True, tag_prefix='archived')
|
||||
other_user_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, user=other_user, tag_prefix='otheruser')
|
||||
archived_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, archived=True, tag_prefix="archived"
|
||||
)
|
||||
other_user_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, user=other_user, tag_prefix="otheruser"
|
||||
)
|
||||
|
||||
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
|
||||
invisible_tags = self.get_tags_from_bookmarks(archived_bookmarks + other_user_bookmarks)
|
||||
invisible_tags = self.get_tags_from_bookmarks(
|
||||
archived_bookmarks + other_user_bookmarks
|
||||
)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
|
||||
def test_should_list_tags_for_bookmarks_matching_query(self):
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, prefix='foo', tag_prefix='foo')
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(3, with_tags=True, prefix='bar', tag_prefix='bar')
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, prefix="foo", tag_prefix="foo"
|
||||
)
|
||||
invisible_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, with_tags=True, prefix="bar", tag_prefix="bar"
|
||||
)
|
||||
|
||||
visible_tags = self.get_tags_from_bookmarks(visible_bookmarks)
|
||||
invisible_tags = self.get_tags_from_bookmarks(invisible_bookmarks)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index') + '?q=foo')
|
||||
response = self.client.get(reverse("bookmarks:index") + "?q=foo")
|
||||
|
||||
self.assertVisibleTags(response, visible_tags)
|
||||
self.assertInvisibleTags(response, invisible_tags)
|
||||
@@ -135,19 +169,21 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
|
||||
user_profile = self.user.profile
|
||||
user_profile.search_preferences = {
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_YES,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
}
|
||||
user_profile.save()
|
||||
|
||||
unread_bookmarks = self.setup_numbered_bookmarks(3, unread=True, with_tags=True, prefix='unread',
|
||||
tag_prefix='unread')
|
||||
read_bookmarks = self.setup_numbered_bookmarks(3, unread=False, with_tags=True, prefix='read',
|
||||
tag_prefix='read')
|
||||
unread_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, unread=True, with_tags=True, prefix="unread", tag_prefix="unread"
|
||||
)
|
||||
read_bookmarks = self.setup_numbered_bookmarks(
|
||||
3, unread=False, with_tags=True, prefix="read", tag_prefix="read"
|
||||
)
|
||||
|
||||
unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)
|
||||
read_tags = self.get_tags_from_bookmarks(read_bookmarks)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
self.assertVisibleBookmarks(response, unread_bookmarks)
|
||||
self.assertInvisibleBookmarks(response, read_bookmarks)
|
||||
self.assertVisibleTags(response, unread_tags)
|
||||
@@ -163,11 +199,16 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
]
|
||||
self.setup_bookmark(tags=tags)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index') + f'?q=%23{tags[0].name}+%23{tags[1].name.upper()}')
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:index")
|
||||
+ f"?q=%23{tags[0].name}+%23{tags[1].name.upper()}"
|
||||
)
|
||||
|
||||
self.assertSelectedTags(response, [tags[0], tags[1]])
|
||||
|
||||
def test_should_not_display_search_terms_from_query_as_selected_tags_in_strict_mode(self):
|
||||
def test_should_not_display_search_terms_from_query_as_selected_tags_in_strict_mode(
|
||||
self,
|
||||
):
|
||||
tags = [
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
@@ -177,7 +218,9 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
]
|
||||
self.setup_bookmark(title=tags[0].name, tags=tags)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:index") + f"?q={tags[0].name}+%23{tags[1].name.upper()}"
|
||||
)
|
||||
|
||||
self.assertSelectedTags(response, [tags[1]])
|
||||
|
||||
@@ -194,16 +237,18 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
]
|
||||
self.setup_bookmark(tags=tags)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
|
||||
response = self.client.get(
|
||||
reverse("bookmarks:index") + f"?q={tags[0].name}+%23{tags[1].name.upper()}"
|
||||
)
|
||||
|
||||
self.assertSelectedTags(response, [tags[0], tags[1]])
|
||||
|
||||
def test_should_open_bookmarks_in_new_page_by_default(self):
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, '_blank')
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, "_blank")
|
||||
|
||||
def test_should_open_bookmarks_in_same_page_if_specified_in_user_profile(self):
|
||||
user = self.get_or_create_test_user()
|
||||
@@ -212,71 +257,72 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
visible_bookmarks = self.setup_numbered_bookmarks(3)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index'))
|
||||
response = self.client.get(reverse("bookmarks:index"))
|
||||
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
|
||||
self.assertVisibleBookmarks(response, visible_bookmarks, "_self")
|
||||
|
||||
def test_edit_link_return_url_respects_search_options(self):
|
||||
bookmark = self.setup_bookmark(title='foo')
|
||||
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
|
||||
base_url = reverse('bookmarks:index')
|
||||
bookmark = self.setup_bookmark(title="foo")
|
||||
edit_url = reverse("bookmarks:edit", args=[bookmark.id])
|
||||
base_url = reverse("bookmarks:index")
|
||||
|
||||
# without query params
|
||||
return_url = urllib.parse.quote(base_url)
|
||||
url = f'{edit_url}?return_url={return_url}'
|
||||
url = f"{edit_url}?return_url={return_url}"
|
||||
|
||||
response = self.client.get(base_url)
|
||||
self.assertEditLink(response, url)
|
||||
|
||||
# with query
|
||||
url_params = '?q=foo'
|
||||
url_params = "?q=foo"
|
||||
return_url = urllib.parse.quote(base_url + url_params)
|
||||
url = f'{edit_url}?return_url={return_url}'
|
||||
url = f"{edit_url}?return_url={return_url}"
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertEditLink(response, url)
|
||||
|
||||
# with query and sort and page
|
||||
url_params = '?q=foo&sort=title_asc&page=2'
|
||||
url_params = "?q=foo&sort=title_asc&page=2"
|
||||
return_url = urllib.parse.quote(base_url + url_params)
|
||||
url = f'{edit_url}?return_url={return_url}'
|
||||
url = f"{edit_url}?return_url={return_url}"
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertEditLink(response, url)
|
||||
|
||||
def test_bulk_edit_respects_search_options(self):
|
||||
action_url = reverse('bookmarks:index.action')
|
||||
base_url = reverse('bookmarks:index')
|
||||
action_url = reverse("bookmarks:index.action")
|
||||
base_url = reverse("bookmarks:index")
|
||||
|
||||
# without params
|
||||
return_url = urllib.parse.quote_plus(base_url)
|
||||
url = f'{action_url}?return_url={return_url}'
|
||||
url = f"{action_url}?return_url={return_url}"
|
||||
|
||||
response = self.client.get(base_url)
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
||||
# with query
|
||||
url_params = '?q=foo'
|
||||
url_params = "?q=foo"
|
||||
return_url = urllib.parse.quote_plus(base_url + url_params)
|
||||
url = f'{action_url}?q=foo&return_url={return_url}'
|
||||
url = f"{action_url}?q=foo&return_url={return_url}"
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
||||
# with query and sort
|
||||
url_params = '?q=foo&sort=title_asc'
|
||||
url_params = "?q=foo&sort=title_asc"
|
||||
return_url = urllib.parse.quote_plus(base_url + url_params)
|
||||
url = f'{action_url}?q=foo&sort=title_asc&return_url={return_url}'
|
||||
url = f"{action_url}?q=foo&sort=title_asc&return_url={return_url}"
|
||||
|
||||
response = self.client.get(base_url + url_params)
|
||||
self.assertBulkActionForm(response, url)
|
||||
|
||||
def test_allowed_bulk_actions(self):
|
||||
url = reverse('bookmarks:index')
|
||||
url = reverse("bookmarks:index")
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(f'''
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<select name="bulk_action" class="form-select select-sm">
|
||||
<option value="bulk_archive">Archive</option>
|
||||
<option value="bulk_delete">Delete</option>
|
||||
@@ -285,18 +331,21 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
<option value="bulk_read">Mark as read</option>
|
||||
<option value="bulk_unread">Mark as unread</option>
|
||||
</select>
|
||||
''', html)
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
def test_allowed_bulk_actions_with_sharing_enabled(self):
|
||||
user_profile = self.user.profile
|
||||
user_profile.enable_sharing = True
|
||||
user_profile.save()
|
||||
|
||||
url = reverse('bookmarks:index')
|
||||
url = reverse("bookmarks:index")
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(f'''
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<select name="bulk_action" class="form-select select-sm">
|
||||
<option value="bulk_archive">Archive</option>
|
||||
<option value="bulk_delete">Delete</option>
|
||||
@@ -307,142 +356,189 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
<option value="bulk_share">Share</option>
|
||||
<option value="bulk_unshare">Unshare</option>
|
||||
</select>
|
||||
''', html)
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
def test_apply_search_preferences(self):
|
||||
# no params
|
||||
response = self.client.post(reverse('bookmarks:index'))
|
||||
response = self.client.post(reverse("bookmarks:index"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse('bookmarks:index'))
|
||||
self.assertEqual(response.url, reverse("bookmarks:index"))
|
||||
|
||||
# some params
|
||||
response = self.client.post(reverse('bookmarks:index'), {
|
||||
'q': 'foo',
|
||||
'sort': BookmarkSearch.SORT_TITLE_ASC,
|
||||
})
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index"),
|
||||
{
|
||||
"q": "foo",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse('bookmarks:index') + '?q=foo&sort=title_asc')
|
||||
self.assertEqual(
|
||||
response.url, reverse("bookmarks:index") + "?q=foo&sort=title_asc"
|
||||
)
|
||||
|
||||
# params with default value are removed
|
||||
response = self.client.post(reverse('bookmarks:index'), {
|
||||
'q': 'foo',
|
||||
'user': '',
|
||||
'sort': BookmarkSearch.SORT_ADDED_DESC,
|
||||
'shared': BookmarkSearch.FILTER_SHARED_OFF,
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_YES,
|
||||
})
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index"),
|
||||
{
|
||||
"q": "foo",
|
||||
"user": "",
|
||||
"sort": BookmarkSearch.SORT_ADDED_DESC,
|
||||
"shared": BookmarkSearch.FILTER_SHARED_OFF,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse('bookmarks:index') + '?q=foo&unread=yes')
|
||||
self.assertEqual(response.url, reverse("bookmarks:index") + "?q=foo&unread=yes")
|
||||
|
||||
# page is removed
|
||||
response = self.client.post(reverse('bookmarks:index'), {
|
||||
'q': 'foo',
|
||||
'page': '2',
|
||||
'sort': BookmarkSearch.SORT_TITLE_ASC,
|
||||
})
|
||||
response = self.client.post(
|
||||
reverse("bookmarks:index"),
|
||||
{
|
||||
"q": "foo",
|
||||
"page": "2",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse('bookmarks:index') + '?q=foo&sort=title_asc')
|
||||
self.assertEqual(
|
||||
response.url, reverse("bookmarks:index") + "?q=foo&sort=title_asc"
|
||||
)
|
||||
|
||||
def test_save_search_preferences(self):
|
||||
user_profile = self.user.profile
|
||||
|
||||
# no params
|
||||
self.client.post(reverse('bookmarks:index'), {
|
||||
'save': '',
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index"),
|
||||
{
|
||||
"save": "",
|
||||
},
|
||||
)
|
||||
user_profile.refresh_from_db()
|
||||
self.assertEqual(user_profile.search_preferences, {
|
||||
'sort': BookmarkSearch.SORT_ADDED_DESC,
|
||||
'shared': BookmarkSearch.FILTER_SHARED_OFF,
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
|
||||
})
|
||||
self.assertEqual(
|
||||
user_profile.search_preferences,
|
||||
{
|
||||
"sort": BookmarkSearch.SORT_ADDED_DESC,
|
||||
"shared": BookmarkSearch.FILTER_SHARED_OFF,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_OFF,
|
||||
},
|
||||
)
|
||||
|
||||
# with param
|
||||
self.client.post(reverse('bookmarks:index'), {
|
||||
'save': '',
|
||||
'sort': BookmarkSearch.SORT_TITLE_ASC,
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index"),
|
||||
{
|
||||
"save": "",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
},
|
||||
)
|
||||
user_profile.refresh_from_db()
|
||||
self.assertEqual(user_profile.search_preferences, {
|
||||
'sort': BookmarkSearch.SORT_TITLE_ASC,
|
||||
'shared': BookmarkSearch.FILTER_SHARED_OFF,
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
|
||||
})
|
||||
self.assertEqual(
|
||||
user_profile.search_preferences,
|
||||
{
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
"shared": BookmarkSearch.FILTER_SHARED_OFF,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_OFF,
|
||||
},
|
||||
)
|
||||
|
||||
# add a param
|
||||
self.client.post(reverse('bookmarks:index'), {
|
||||
'save': '',
|
||||
'sort': BookmarkSearch.SORT_TITLE_ASC,
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_YES,
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index"),
|
||||
{
|
||||
"save": "",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
},
|
||||
)
|
||||
user_profile.refresh_from_db()
|
||||
self.assertEqual(user_profile.search_preferences, {
|
||||
'sort': BookmarkSearch.SORT_TITLE_ASC,
|
||||
'shared': BookmarkSearch.FILTER_SHARED_OFF,
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_YES,
|
||||
})
|
||||
self.assertEqual(
|
||||
user_profile.search_preferences,
|
||||
{
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
"shared": BookmarkSearch.FILTER_SHARED_OFF,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
},
|
||||
)
|
||||
|
||||
# remove a param
|
||||
self.client.post(reverse('bookmarks:index'), {
|
||||
'save': '',
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_YES,
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index"),
|
||||
{
|
||||
"save": "",
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
},
|
||||
)
|
||||
user_profile.refresh_from_db()
|
||||
self.assertEqual(user_profile.search_preferences, {
|
||||
'sort': BookmarkSearch.SORT_ADDED_DESC,
|
||||
'shared': BookmarkSearch.FILTER_SHARED_OFF,
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_YES,
|
||||
})
|
||||
self.assertEqual(
|
||||
user_profile.search_preferences,
|
||||
{
|
||||
"sort": BookmarkSearch.SORT_ADDED_DESC,
|
||||
"shared": BookmarkSearch.FILTER_SHARED_OFF,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_YES,
|
||||
},
|
||||
)
|
||||
|
||||
# ignores non-preferences
|
||||
self.client.post(reverse('bookmarks:index'), {
|
||||
'save': '',
|
||||
'q': 'foo',
|
||||
'user': 'john',
|
||||
'page': '3',
|
||||
'sort': BookmarkSearch.SORT_TITLE_ASC,
|
||||
})
|
||||
self.client.post(
|
||||
reverse("bookmarks:index"),
|
||||
{
|
||||
"save": "",
|
||||
"q": "foo",
|
||||
"user": "john",
|
||||
"page": "3",
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
},
|
||||
)
|
||||
user_profile.refresh_from_db()
|
||||
self.assertEqual(user_profile.search_preferences, {
|
||||
'sort': BookmarkSearch.SORT_TITLE_ASC,
|
||||
'shared': BookmarkSearch.FILTER_SHARED_OFF,
|
||||
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
|
||||
})
|
||||
self.assertEqual(
|
||||
user_profile.search_preferences,
|
||||
{
|
||||
"sort": BookmarkSearch.SORT_TITLE_ASC,
|
||||
"shared": BookmarkSearch.FILTER_SHARED_OFF,
|
||||
"unread": BookmarkSearch.FILTER_UNREAD_OFF,
|
||||
},
|
||||
)
|
||||
|
||||
def test_url_encode_bookmark_actions_url(self):
|
||||
url = reverse('bookmarks:index') + '?q=%23foo'
|
||||
url = reverse("bookmarks:index") + "?q=%23foo"
|
||||
response = self.client.get(url)
|
||||
html = response.content.decode()
|
||||
soup = self.make_soup(html)
|
||||
actions_form = soup.select('form.bookmark-actions')[0]
|
||||
actions_form = soup.select("form.bookmark-actions")[0]
|
||||
|
||||
self.assertEqual(actions_form.attrs['action'],
|
||||
'/bookmarks/action?q=%23foo&return_url=%2Fbookmarks%3Fq%3D%2523foo')
|
||||
self.assertEqual(
|
||||
actions_form.attrs["action"],
|
||||
"/bookmarks/action?q=%23foo&return_url=%2Fbookmarks%3Fq%3D%2523foo",
|
||||
)
|
||||
|
||||
def test_encode_search_params(self):
|
||||
bookmark = self.setup_bookmark(description='alert(\'xss\')')
|
||||
bookmark = self.setup_bookmark(description="alert('xss')")
|
||||
|
||||
url = reverse('bookmarks:index') + '?q=alert(%27xss%27)'
|
||||
url = reverse("bookmarks:index") + "?q=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
self.assertContains(response, bookmark.url)
|
||||
|
||||
url = reverse('bookmarks:index') + '?sort=alert(%27xss%27)'
|
||||
url = reverse("bookmarks:index") + "?sort=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse('bookmarks:index') + '?unread=alert(%27xss%27)'
|
||||
url = reverse("bookmarks:index") + "?unread=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse('bookmarks:index') + '?shared=alert(%27xss%27)'
|
||||
url = reverse("bookmarks:index") + "?shared=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse('bookmarks:index') + '?user=alert(%27xss%27)'
|
||||
url = reverse("bookmarks:index") + "?user=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||
url = reverse('bookmarks:index') + '?page=alert(%27xss%27)'
|
||||
url = reverse("bookmarks:index") + "?page=alert(%27xss%27)"
|
||||
response = self.client.get(url)
|
||||
self.assertNotContains(response, 'alert(\'xss\')')
|
||||
self.assertNotContains(response, "alert('xss')")
|
||||
|
||||