Compare commits

...

29 Commits

Author SHA1 Message Date
Sascha Ißbrücker
d2fa0a8f5a Bump version 2024-03-16 09:28:57 +01:00
Juan Gilsanz Polo
02a15c9460 Added a new Linkding client to community section (#638)
* Added linkdy entry to readme

* Fix alphabetic order

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2024-03-16 08:26:21 +01:00
Jack Halford
7a6428c037 Add k8s setup to community section (#633)
* Update README.md

* Move to community section

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2024-03-16 08:24:12 +01:00
dependabot[bot]
c6001aa7b8 Bump django from 5.0.1 to 5.0.2 (#625)
Bumps [django](https://github.com/django/django) from 5.0.1 to 5.0.2.
- [Commits](https://github.com/django/django/compare/5.0.1...5.0.2)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-16 07:21:58 +01:00
Sascha Ißbrücker
eefbefd714 Enable workflow runs from pull requests 2024-03-16 07:19:40 +01:00
Jonathan Sundqvist
683cf529d7 Group ideographic characters in tag cloud (#613)
* Fix #588, Ideographic characters should be grouped together.
Following the suggestion of using regex to find the ideographic
range in this SO answer https://stackoverflow.com/a/2718203/554903

We group the ideographic characters together, while keeping other
chinese, japanese and korean characters apart.

* cleanup

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-03-16 07:09:37 +01:00
Sascha Ißbrücker
38204c87cf Persist secret key in data folder (#620)
* Persist secret key in data folder

* use random secret key by default in prod

* fix e2e test
2024-01-28 23:58:03 +01:00
Sascha Ißbrücker
96ee4746ad Fix JS bundle caching 2024-01-28 23:07:38 +01:00
Sascha Ißbrücker
d7c1afa2a5 Bump dependencies (#618)
* Bump dependencies

* Make it work with Python 3.10

* replace psycopg2-binary with psycopg2 in Docker build
2024-01-28 22:50:51 +01:00
Sascha Ißbrücker
16ed6ef200 Update CHANGELOG.md 2024-01-27 20:01:48 +01:00
Sascha Ißbrücker
98b9a9c1a0 Add black code formatter 2024-01-27 11:29:16 +01:00
Sascha Ißbrücker
6775633be5 Bump version 2024-01-27 10:58:21 +01:00
Jonathan Sundqvist
150dfecc6f Support Open Graph description (#602)
* Support pytest for running tests

* Support extracting description from meta og:description property

* Revert changes to TOC

* Add test

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-01-27 10:28:46 +01:00
Jonathan Sundqvist
81ae55bc1c Add tooltip to truncated bookmark titles (#607)
* Add title to link so you can see the entire title when hover

* Tweak JS, styles

* Fix snapshot tests

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-01-27 10:16:44 +01:00
Sascha Ißbrücker
935189ecc2 Improve bulk tag performance (#612) 2024-01-27 09:13:21 +01:00
JnsDornbusch
7997f20d89 Adjust archive.org donation link in general.html (#603)
Adjust archive.org donation link due to broken link.
2024-01-23 22:57:50 +01:00
Jonathan Sundqvist
ae27500cde Bump playwright dependencies (#601) 2024-01-23 22:45:25 +01:00
Adam Shand
71d853999e Add CapRover as managed hosting option (#585)
Add a note that I've created a one-click app for Linkding in CapRover (Scalable PaaS, automated Docker+nginx).
2024-01-23 22:37:51 +01:00
Sebastian Ruml
70288d6865 Increase tag limit in tag autocomplete (#581)
- increas tag limit to 5000

Co-authored-by: Sebastian Ruml <sebastian@sebastianruml.name>
2024-01-23 22:32:16 +01:00
Sascha Ißbrücker
e83d519cab Bump version 2023-12-08 22:01:24 +01:00
Sascha Ißbrücker
6355d8dff1 Properly encode search query param (#587) 2023-12-08 21:53:54 +01:00
Sascha Ißbrücker
227cfdb063 Update CHANGELOG.md 2023-11-24 10:23:44 +01:00
Sascha Ißbrücker
2d4da099c7 Bump version 2023-11-24 09:33:36 +01:00
Sascha Ißbrücker
a9512b2333 Include archived bookmarks in export (#579) 2023-11-24 09:21:23 +01:00
Oleksandr Perepadia
47e944e6c5 Update README.md (#574)
* Update README.md

Correct Firefox addon links to direct to the English language page

* Update firefox addon links to not presume any language
2023-11-14 17:08:17 +01:00
Sascha Ißbrücker
6c7ce91d53 Add backup CLI command (#571) 2023-11-05 19:27:48 +01:00
Sascha Ißbrücker
87020de917 Add Alpine based Docker image (experimental) (#570)
* use alpine as base image

* try fix missing mime types

* Extract separate Dockerfile

* Restore playwright dev dependency

* Add info to README.md
2023-11-05 14:18:26 +01:00
Sascha Ißbrücker
a130daa0f0 Update backup docs 2023-11-05 13:53:08 +01:00
Sascha Ißbrücker
d7c68c2818 Update CHANGELOG.md 2023-11-04 12:26:30 +01:00
158 changed files with 7954 additions and 4369 deletions

View File

@@ -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": {

View File

@@ -1,34 +1,22 @@
# Remove project files, data, tmp files, build files
/.env
/.idea
/data
/node_modules
/tmp
/docs
/static
/scripts
/build
/out
/.git
/.devcontainer
# Ignore everything
*
/.dockerignore
/.gitignore
/.gitattributes
/Dockerfile
/docker-compose.yml
/*.sh
/*.iml
/*.patch
/*.md
/*.js
/*.log
/*.pid
# Include files required for build or at runtime
!/bookmarks
!/siteroot
# Whitelist files needed in build or prod image
!/rollup.config.js
!/bootstrap.sh
!/background-tasks-wrapper.sh
!/bootstrap.sh
!/LICENSE.txt
!/manage.py
!/package.json
!/package-lock.json
!/requirements.dev.txt
!/requirements.txt
!/rollup.config.js
!/supervisord.conf
!/uwsgi.ini
!/version.txt
# Remove development settings
# Remove dev settings
/siteroot/settings/dev.py

View File

@@ -1,6 +1,6 @@
name: linkding CI
on: [push]
on: [push, pull_request]
jobs:
unit_tests:
@@ -19,7 +19,7 @@ jobs:
- name: Install Node dependencies
run: npm install
- 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:
@@ -39,7 +39,7 @@ jobs:
run: npm install
- 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: |

View File

@@ -1,5 +1,66 @@
# Changelog
## 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
* Add Alpine based Docker image (experimental) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/570
* Add backup CLI command by @sissbruecker in https://github.com/sissbruecker/linkding/pull/571
* Update browser extension links by @OPerepadia in https://github.com/sissbruecker/linkding/pull/574
* Include archived bookmarks in export by @sissbruecker in https://github.com/sissbruecker/linkding/pull/579
### New Contributors
* @OPerepadia made their first contribution in https://github.com/sissbruecker/linkding/pull/574
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.3...v1.23.0
---
## v1.22.3 (04/11/2023)
### What's Changed
* Fix RSS feed not handling None values by @vitormarcal in https://github.com/sissbruecker/linkding/pull/569
* Bump django from 4.1.10 to 4.1.13 by @dependabot in https://github.com/sissbruecker/linkding/pull/567
### New Contributors
* @vitormarcal made their first contribution in https://github.com/sissbruecker/linkding/pull/569
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.2...v1.22.3
---
## v1.22.2 (27/10/2023)
### What's Changed

15
Makefile Normal file
View 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

View File

@@ -9,11 +9,11 @@
## Overview
- [Introduction](#introduction)
- [Installation](#installation)
- [Using Docker](#using-docker)
- [Using Docker Compose](#using-docker-compose)
- [User Setup](#user-setup)
- [Reverse Proxy Setup](#reverse-proxy-setup)
- [Managed Hosting Options](#managed-hosting-options)
- [Using Docker](#using-docker)
- [Using Docker Compose](#using-docker-compose)
- [User Setup](#user-setup)
- [Reverse Proxy Setup](#reverse-proxy-setup)
- [Managed Hosting Options](#managed-hosting-options)
- [Documentation](#documentation)
- [Browser Extension](#browser-extension)
- [Community](#community)
@@ -40,7 +40,7 @@ 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
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet
- 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
- REST API for developing 3rd party apps
- Admin panel for user self-service and raw data access
@@ -58,9 +58,27 @@ The name comes from:
linkding is designed to be run with container solutions like [Docker](https://docs.docker.com/get-started/).
The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.
By default, linkding uses SQLite as a database.
linkding uses an SQLite database by default.
Alternatively linkding supports PostgreSQL, see the [database options](docs/Options.md#LD_DB_ENGINE) for more information.
<details>
<summary>🧪 Alpine-based image</summary>
The default Docker image (`latest` tag) is based on a slim variant of Debian Linux.
Alternatively, there is an image based on Alpine Linux (`latest-alpine` tag) which has a smaller size, resulting in a smaller download and less disk space required.
The Alpine image is currently about 45 MB in compressed size, compared to about 130 MB for the Debian image.
To use it, replace the `latest` tag with `latest-alpine`, either in the CLI command below when using Docker, or in the `docker-compose.yml` file when using docker-compose.
> [!WARNING]
> The image is currently considered experimental in order to gather feedback and iron out any issues.
> Only use it if you are comfortable running experimental software or want to help out with testing.
> While there should be no issues with creating new installations, there might be issues when migrating existing installations.
> If you plan to migrate your existing installation, make sure to create proper [backups](https://github.com/sissbruecker/linkding/blob/master/docs/backup.md) first.
</details>
### Using Docker
To install linkding using Docker you can just run the [latest image](https://hub.docker.com/repository/docker/sissbruecker/linkding) from Docker Hub:
@@ -85,7 +103,7 @@ docker-compose up -d
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
### User setup
### User Setup
For security reasons, the linkding Docker image does not provide an initial user, so you have to create one after setting up an installation. To do so, replace the credentials in the following command and run it:
@@ -101,7 +119,7 @@ docker-compose exec linkding python manage.py createsuperuser --username=joe --e
The command will prompt you for a secure password. After the command has completed you can start using the application by logging into the UI with your credentials.
Alternatively you can automatically create an initial superuser on startup using the [`LD_SUPERUSER_NAME` option](docs/Options.md#LD_SUPERUSER_NAME).
Alternatively you can automatically create an initial superuser on startup using the [`LD_SUPERUSER_NAME` option](docs/Options.md#LD_SUPERUSER_NAME).
### Reverse Proxy Setup
@@ -164,6 +182,7 @@ Self-hosting web applications still requires a lot of technical know-how and com
- [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel)
- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding) ([Disclosure](#pikapods))
- [CapRover](https://caprover.com/) - Linkding is included as a default one-click app
## Documentation
@@ -180,7 +199,7 @@ Self-hosting web applications still requires a lot of technical know-how and com
## Browser Extension
linkding comes with an official browser extension that allows to quickly add bookmarks, and search bookmarks through the browser's address bar. You can get the extension here:
- [Mozilla Addon Store](https://addons.mozilla.org/de/firefox/addon/linkding-extension/)
- [Mozilla Addon Store](https://addons.mozilla.org/firefox/addon/linkding-extension/)
- [Chrome Web Store](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
The extension is open-source as well, and can be found [here](https://github.com/sissbruecker/linkding-extension).
@@ -192,11 +211,13 @@ This section lists community projects around using linkding, in alphabetical ord
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
- [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.
- [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)
@@ -205,7 +226,7 @@ This section lists community projects around using linkding, in alphabetical ord
### PikaPods
[PikaPods](https://www.pikapods.com/) has a revenue sharing agreement with this project, sharing some of their revenue from hosting linkding instances. I do not intend to profit from this project financially, so I am in turn donating that revenue. Big thanks to PikaPods for making this possible.
[PikaPods](https://www.pikapods.com/) has a revenue sharing agreement with this project, sharing some of their revenue from hosting linkding instances. I do not intend to profit from this project financially, so I am in turn donating that revenue. Big thanks to PikaPods for making this possible.
See the table below for a list of donations.
@@ -237,7 +258,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:
```
@@ -262,6 +283,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: [![Open in Remote - Containers](https://img.shields.io/static/v1?label=Remote%20-%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=git@github.com:sissbruecker/linkding.git)

View File

@@ -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()

View File

@@ -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")

View File

@@ -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,32 @@ 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",
"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",
"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 +57,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):

View File

@@ -2,7 +2,7 @@ from django.apps import AppConfig
class BookmarksConfig(AppConfig):
name = 'bookmarks'
name = "bookmarks"
def ready(self):
# Register signal handlers

View File

@@ -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}

View File

@@ -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="")

View File

@@ -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()

View File

@@ -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,22 @@ 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()
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()

View File

@@ -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)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)
)

View File

@@ -17,17 +17,21 @@ class FeedContext:
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,22 @@ 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)

View File

@@ -59,10 +59,18 @@ class BookmarkItem {
constructor(element) {
this.element = element;
// Toggle notes
const notesToggle = element.querySelector(".toggle-notes");
if (notesToggle) {
notesToggle.addEventListener("click", this.onToggleNotes.bind(this));
}
// Add tooltip to title if it is truncated
const titleAnchor = element.querySelector(".title > a");
const titleSpan = titleAnchor.querySelector("span");
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
titleAnchor.dataset.tooltip = titleSpan.textContent;
}
}
onToggleNotes(event) {

View File

@@ -22,7 +22,7 @@
async function init() {
// For now we cache all tags on load as the template did before
try {
tags = await apiClient.getTags({limit: 1000, offset: 0});
tags = await apiClient.getTags({limit: 5000, offset: 0});
tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
} catch (e) {
console.warn('TagAutocomplete: Error loading tag list');

View File

@@ -0,0 +1,26 @@
import sqlite3
import os
from django.core.management.base import BaseCommand
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")
def handle(self, *args, **options):
destination = options["destination"]
def progress(status, remaining, total):
self.stdout.write(f"Copied {total-remaining} of {total} pages...")
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}"))

View File

@@ -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")

View File

@@ -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")

View File

@@ -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"],
)

View 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")

View 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)

View File

@@ -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,
),
),
],
),
]

View File

@@ -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"),
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View File

@@ -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()],
),
),
]

View File

@@ -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),
),
]

View File

@@ -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),

View File

@@ -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,
),
),
]

View File

@@ -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),
),
]

View File

@@ -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,
),
),
]

View File

@@ -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,
),
),
]

View File

@@ -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,
),
),
],
),
]

View File

@@ -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 = [

View File

@@ -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),

View File

@@ -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,
),
),
],
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View File

@@ -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,
),
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View File

@@ -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,50 +262,70 @@ 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)
@@ -306,8 +337,18 @@ class UserProfile(models.Model):
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",
]
@receiver(post_save, sender=get_user_model())
@@ -332,11 +373,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):

View File

@@ -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,
}

View File

@@ -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)
@@ -67,9 +69,10 @@ def archive_bookmark(bookmark: Bookmark):
def archive_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.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):
@@ -81,70 +84,93 @@ def unarchive_bookmark(bookmark: Bookmark):
def unarchive_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.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):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.delete()
Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids).delete()
def tag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_user: User):
sanitized_bookmark_ids = _sanitize_id_list(bookmark_ids)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
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)
for bookmark in bookmarks:
bookmark.tags.add(*tags)
bookmark.date_modified = timezone.now()
BookmarkToTagRelationShip = Bookmark.tags.through
relationships = []
for tag in tags:
for bookmark_id in owned_bookmark_ids:
relationships.append(
BookmarkToTagRelationShip(bookmark_id=bookmark_id, tag=tag)
)
Bookmark.objects.bulk_update(bookmarks, ['date_modified'])
# 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()
)
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)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
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)
for bookmark in bookmarks:
bookmark.tags.remove(*tags)
bookmark.date_modified = timezone.now()
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()
Bookmark.objects.bulk_update(bookmarks, ['date_modified'])
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)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.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)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.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)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.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)
bookmarks = Bookmark.objects.filter(owner=current_user, id__in=sanitized_bookmark_ids)
bookmarks.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):

View File

@@ -13,37 +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]'
tags = ','.join(bookmark.tag_names)
toread = '1' if bookmark.unread else '0'
private = '0' if bookmark.shared else '1'
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"
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>")

View File

@@ -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

View File

@@ -5,7 +5,7 @@ from typing import List
from django.contrib.auth.models import User
from django.utils import timezone
from bookmarks.models import Bookmark, Tag, parse_tag_string
from bookmarks.models import Bookmark, Tag
from bookmarks.services import tasks
from bookmarks.services.parser import parse, NetscapeBookmark
from bookmarks.utils import parse_timestamp
@@ -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
@@ -93,8 +95,7 @@ def _create_missing_tags(netscape_bookmarks: List[NetscapeBookmark], user: User)
tags_to_create = []
for netscape_bookmark in netscape_bookmarks:
tag_names = parse_tag_string(netscape_bookmark.tag_string)
for tag_name in tag_names:
for tag_name in netscape_bookmark.tag_names:
tag = tag_cache.get(tag_name)
if not tag:
tag = Tag(name=tag_name, owner=user)
@@ -111,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
@@ -119,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)
@@ -137,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
@@ -147,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)
@@ -156,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)
@@ -184,18 +198,24 @@ 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
tag_names = parse_tag_string(netscape_bookmark.tag_string)
tags = tag_cache.get_all(tag_names)
tags = tag_cache.get_all(netscape_bookmark.tag_names)
for tag in tags:
relationships.append(BookmarkToTagRelationShip(bookmark=bookmark, tag=tag))
@@ -203,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)
@@ -219,3 +241,5 @@ def _copy_bookmark_data(netscape_bookmark: NetscapeBookmark, bookmark: Bookmark,
bookmark.notes = netscape_bookmark.notes
if options.map_private_flag and not netscape_bookmark.private:
bookmark.shared = True
if netscape_bookmark.archived:
bookmark.is_archived = True

View File

@@ -2,6 +2,8 @@ from dataclasses import dataclass
from html.parser import HTMLParser
from typing import Dict, List
from bookmarks.models import parse_tag_string
@dataclass
class NetscapeBookmark:
@@ -10,9 +12,10 @@ class NetscapeBookmark:
description: str
notes: str
date_added: str
tag_string: str
tag_names: List[str]
to_read: bool
private: bool
archived: bool
class BookmarkParser(HTMLParser):
@@ -22,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)
@@ -56,16 +59,24 @@ 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
try:
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_string=self.tags,
to_read=self.toread == '1',
tag_names=tag_names,
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,
)
def handle_a_data(self, data):
@@ -73,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:
@@ -84,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]:

View File

@@ -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):

View File

@@ -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)

View File

@@ -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"

View File

@@ -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,17 +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 = 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
)
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)
@@ -68,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():

View File

@@ -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.

View File

@@ -107,6 +107,18 @@ ul.bookmark-list {
padding: 0;
}
@keyframes appear {
0% {
opacity: 0;
}
90% {
opacity: 0;
}
100% {
opacity: 1;
}
}
/* Bookmarks */
li[ld-bookmark-item] {
position: relative;
@@ -122,6 +134,27 @@ li[ld-bookmark-item] {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&[data-tooltip]:hover::after, &[data-tooltip]:focus::after {
content: attr(data-tooltip);
position: absolute;
z-index: 10;
top: 20px;
left: 50%;
transform: translateX(-50%);
width: max-content;
max-width: 100%;
height: fit-content;
background-color: #292f62;
color: #fff;
padding: $unit-1;
border-radius: $border-radius;
border: 1px solid #424a8c;
font-size: $font-size-sm;
font-style: normal;
white-space: normal;
animation: 0.3s ease 0s appear;
}
}
&.unread .title a {

View File

@@ -43,6 +43,4 @@
</div>
</section>
</div>
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
{% endblock %}

View File

@@ -14,11 +14,11 @@
<i class="form-icon"></i>
</label>
<div class="title">
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener" >
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
<img src="{% static bookmark_item.favicon_file %}" alt="">
{% endif %}
{{ bookmark_item.title }}
<span>{{ bookmark_item.title }}</span>
</a>
</div>
{% if bookmark_list.show_url %}

View File

@@ -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);

View File

@@ -43,6 +43,4 @@
</div>
</section>
</div>
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
{% endblock %}

View File

@@ -122,5 +122,6 @@
{% block content %}
{% endblock %}
</div>
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
</body>
</html>

View File

@@ -95,7 +95,7 @@
props: {
name: 'q',
placeholder: 'Search for words or #tags',
value: '{{ search.q|safe }}',
value: input.value,
tags: uniqueTags,
mode: '{{ mode }}',
linkTarget: '{{ request.user_profile.bookmark_link_target }}',

View File

@@ -46,6 +46,4 @@
</div>
</section>
</div>
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
{% endblock %}

View File

@@ -99,7 +99,7 @@
Machine</a>.
This allows to preserve, and later access the website as it was at the point in time it was bookmarked, in
case it goes offline or its content is modified.
Please consider donating to the <a href="https://archive.org/donate/index.php" target="_blank"
Please consider donating to the <a href="https://archive.org/donate" target="_blank"
rel="noopener">Internet Archive</a> if you make use of this feature.
</div>
</div>

View File

@@ -9,7 +9,7 @@
<h2>Browser Extension</h2>
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The extension is available in the official extension stores for:</p>
<ul>
<li><a href="https://addons.mozilla.org/de/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
<li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe" target="_blank">Chrome</a></li>
</ul>
<p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a> as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p>

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,53 @@ 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,
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)
bookmark = self.setup_bookmark(
url=url,
title=title,
is_archived=archived,
unread=unread,
shared=shared,
tags=tags,
user=user,
)
bookmarks.append(bookmark)
return bookmarks
@@ -121,7 +128,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 +137,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 +173,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 +194,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 +215,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 +225,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 +239,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 +274,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 +289,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())

View File

@@ -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)

View File

@@ -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"],
)

View File

@@ -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")

View File

@@ -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)

View File

@@ -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,114 +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)
url = reverse("bookmarks:archived") + "?q=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
self.assertContains(response, bookmark.url)
url = reverse("bookmarks:archived") + "?sort=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:archived") + "?unread=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:archived") + "?shared=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:archived") + "?user=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:archived") + "?page=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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,114 +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')")
url = reverse("bookmarks:index") + "?q=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
self.assertContains(response, bookmark.url)
url = reverse("bookmarks:index") + "?sort=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:index") + "?unread=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:index") + "?shared=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:index") + "?user=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:index") + "?page=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")

View File

@@ -26,8 +26,10 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
# capture number of queries
context = CaptureQueriesContext(self.get_connection())
with context:
response = self.client.get(reverse('bookmarks:index'))
self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks)
response = self.client.get(reverse("bookmarks:index"))
self.assertContains(
response, "<li ld-bookmark-item>", num_initial_bookmarks
)
number_of_queries = context.final_queries
@@ -38,5 +40,9 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
# assert num queries doesn't increase
with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse('bookmarks:index'))
self.assertContains(response, '<li ld-bookmark-item>', num_initial_bookmarks + num_additional_bookmarks)
response = self.client.get(reverse("bookmarks:index"))
self.assertContains(
response,
"<li ld-bookmark-item>",
num_initial_bookmarks + num_additional_bookmarks,
)

View File

@@ -15,41 +15,41 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
if overrides is None:
overrides = {}
form_data = {
'url': 'http://example.com',
'tag_string': 'tag1 tag2',
'title': 'test title',
'description': 'test description',
'notes': 'test notes',
'unread': False,
'shared': False,
'auto_close': '',
"url": "http://example.com",
"tag_string": "tag1 tag2",
"title": "test title",
"description": "test description",
"notes": "test notes",
"unread": False,
"shared": False,
"auto_close": "",
}
return {**form_data, **overrides}
def test_should_create_new_bookmark(self):
form_data = self.create_form_data()
self.client.post(reverse('bookmarks:new'), form_data)
self.client.post(reverse("bookmarks:new"), form_data)
self.assertEqual(Bookmark.objects.count(), 1)
bookmark = Bookmark.objects.first()
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, 'tag1')
self.assertEqual(tags[1].name, 'tag2')
tags = bookmark.tags.order_by("name").all()
self.assertEqual(tags[0].name, "tag1")
self.assertEqual(tags[1].name, "tag2")
def test_should_create_new_unread_bookmark(self):
form_data = self.create_form_data({'unread': True})
form_data = self.create_form_data({"unread": True})
self.client.post(reverse('bookmarks:new'), form_data)
self.client.post(reverse("bookmarks:new"), form_data)
self.assertEqual(Bookmark.objects.count(), 1)
@@ -57,9 +57,9 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertTrue(bookmark.unread)
def test_should_create_new_shared_bookmark(self):
form_data = self.create_form_data({'shared': True})
form_data = self.create_form_data({"shared": True})
self.client.post(reverse('bookmarks:new'), form_data)
self.client.post(reverse("bookmarks:new"), form_data)
self.assertEqual(Bookmark.objects.count(), 1)
@@ -67,125 +67,146 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertTrue(bookmark.shared)
def test_should_prefill_url_from_url_parameter(self):
response = self.client.get(reverse('bookmarks:new') + '?url=http://example.com')
response = self.client.get(reverse("bookmarks:new") + "?url=http://example.com")
html = response.content.decode()
self.assertInHTML(
'<input type="text" name="url" value="http://example.com" '
'placeholder=" " autofocus class="form-input" required '
'id="id_url">',
html)
html,
)
def test_should_prefill_title_from_url_parameter(self):
response = self.client.get(reverse('bookmarks:new') + '?title=Example%20Title')
response = self.client.get(reverse("bookmarks:new") + "?title=Example%20Title")
html = response.content.decode()
self.assertInHTML(
'<input type="text" name="title" value="Example Title" '
'class="form-input" maxlength="512" autocomplete="off" '
'id="id_title">',
html)
html,
)
def test_should_prefill_description_from_url_parameter(self):
response = self.client.get(reverse('bookmarks:new') + '?description=Example%20Site%20Description')
response = self.client.get(
reverse("bookmarks:new") + "?description=Example%20Site%20Description"
)
html = response.content.decode()
self.assertInHTML(
'<textarea name="description" class="form-input" cols="40" '
'rows="2" id="id_description">Example Site Description</textarea>',
html)
html,
)
def test_should_enable_auto_close_when_specified_in_url_parameter(self):
response = self.client.get(
reverse('bookmarks:new') + '?auto_close')
response = self.client.get(reverse("bookmarks:new") + "?auto_close")
html = response.content.decode()
self.assertInHTML(
'<input type="hidden" name="auto_close" value="true" '
'id="id_auto_close">',
html)
html,
)
def test_should_not_enable_auto_close_when_not_specified_in_url_parameter(
self):
response = self.client.get(reverse('bookmarks:new'))
def test_should_not_enable_auto_close_when_not_specified_in_url_parameter(self):
response = self.client.get(reverse("bookmarks:new"))
html = response.content.decode()
self.assertInHTML('<input type="hidden" name="auto_close" id="id_auto_close">', html)
self.assertInHTML(
'<input type="hidden" name="auto_close" id="id_auto_close">', html
)
def test_should_redirect_to_index_view(self):
form_data = self.create_form_data()
response = self.client.post(reverse('bookmarks:new'), form_data)
response = self.client.post(reverse("bookmarks:new"), form_data)
self.assertRedirects(response, reverse('bookmarks:index'))
self.assertRedirects(response, reverse("bookmarks:index"))
def test_should_not_redirect_to_external_url(self):
form_data = self.create_form_data()
response = self.client.post(reverse('bookmarks:new') + '?return_url=https://example.com', form_data)
response = self.client.post(
reverse("bookmarks:new") + "?return_url=https://example.com", form_data
)
self.assertRedirects(response, reverse('bookmarks:index'))
self.assertRedirects(response, reverse("bookmarks:index"))
def test_auto_close_should_redirect_to_close_view(self):
form_data = self.create_form_data({'auto_close': 'true'})
form_data = self.create_form_data({"auto_close": "true"})
response = self.client.post(reverse('bookmarks:new'), form_data)
response = self.client.post(reverse("bookmarks:new"), form_data)
self.assertRedirects(response, reverse('bookmarks:close'))
self.assertRedirects(response, reverse("bookmarks:close"))
def test_should_respect_share_profile_setting(self):
self.user.profile.enable_sharing = False
self.user.profile.save()
response = self.client.get(reverse('bookmarks:new'))
response = self.client.get(reverse("bookmarks:new"))
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:new'))
response = self.client.get(reverse("bookmarks:new"))
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_show_respective_share_hint(self):
self.user.profile.enable_sharing = True
self.user.profile.save()
response = self.client.get(reverse('bookmarks:new'))
response = self.client.get(reverse("bookmarks:new"))
html = response.content.decode()
self.assertInHTML('''
self.assertInHTML(
"""
<div class="form-input-hint">
Share this bookmark with other registered users.
</div>
''', html)
""",
html,
)
self.user.profile.enable_public_sharing = True
self.user.profile.save()
response = self.client.get(reverse('bookmarks:new'))
response = self.client.get(reverse("bookmarks:new"))
html = response.content.decode()
self.assertInHTML('''
self.assertInHTML(
"""
<div class="form-input-hint">
Share this bookmark with other registered users and anonymous users.
</div>
''', html)
""",
html,
)
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]))
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]))
self.assertContains(response, '<details class="notes">', count=1)
self.assertContains(response, '<details class="notes">', count=1)

View File

@@ -9,40 +9,45 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
# no params
search = BookmarkSearch()
form = BookmarkSearchForm(search)
self.assertEqual(form['q'].initial, '')
self.assertEqual(form['user'].initial, '')
self.assertEqual(form['sort'].initial, BookmarkSearch.SORT_ADDED_DESC)
self.assertEqual(form['shared'].initial, BookmarkSearch.FILTER_SHARED_OFF)
self.assertEqual(form['unread'].initial, BookmarkSearch.FILTER_UNREAD_OFF)
self.assertEqual(form["q"].initial, "")
self.assertEqual(form["user"].initial, "")
self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_DESC)
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_OFF)
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_OFF)
# with params
search = BookmarkSearch(q='search query',
sort=BookmarkSearch.SORT_ADDED_ASC,
user='user123',
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES)
search = BookmarkSearch(
q="search query",
sort=BookmarkSearch.SORT_ADDED_ASC,
user="user123",
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES,
)
form = BookmarkSearchForm(search)
self.assertEqual(form['q'].initial, 'search query')
self.assertEqual(form['user'].initial, 'user123')
self.assertEqual(form['sort'].initial, BookmarkSearch.SORT_ADDED_ASC)
self.assertEqual(form['shared'].initial, BookmarkSearch.FILTER_SHARED_SHARED)
self.assertEqual(form['unread'].initial, BookmarkSearch.FILTER_UNREAD_YES)
self.assertEqual(form["q"].initial, "search query")
self.assertEqual(form["user"].initial, "user123")
self.assertEqual(form["sort"].initial, BookmarkSearch.SORT_ADDED_ASC)
self.assertEqual(form["shared"].initial, BookmarkSearch.FILTER_SHARED_SHARED)
self.assertEqual(form["unread"].initial, BookmarkSearch.FILTER_UNREAD_YES)
def test_user_options(self):
users = [
self.setup_user('user1'),
self.setup_user('user2'),
self.setup_user('user3'),
self.setup_user("user1"),
self.setup_user("user2"),
self.setup_user("user3"),
]
search = BookmarkSearch()
form = BookmarkSearchForm(search, users=users)
self.assertCountEqual(form['user'].field.choices, [
('', 'Everyone'),
('user1', 'user1'),
('user2', 'user2'),
('user3', 'user3'),
])
self.assertCountEqual(
form["user"].field.choices,
[
("", "Everyone"),
("user1", "user1"),
("user2", "user2"),
("user3", "user3"),
],
)
def test_hidden_fields(self):
# no modified params
@@ -51,24 +56,27 @@ class BookmarkSearchFormTest(TestCase, BookmarkFactoryMixin):
self.assertEqual(len(form.hidden_fields()), 0)
# some modified params
search = BookmarkSearch(q='search query',
sort=BookmarkSearch.SORT_ADDED_ASC)
search = BookmarkSearch(q="search query", sort=BookmarkSearch.SORT_ADDED_ASC)
form = BookmarkSearchForm(search)
self.assertCountEqual(form.hidden_fields(), [form['q'], form['sort']])
self.assertCountEqual(form.hidden_fields(), [form["q"], form["sort"]])
# all modified params
search = BookmarkSearch(q='search query',
sort=BookmarkSearch.SORT_ADDED_ASC,
user='user123',
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES)
search = BookmarkSearch(
q="search query",
sort=BookmarkSearch.SORT_ADDED_ASC,
user="user123",
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES,
)
form = BookmarkSearchForm(search)
self.assertCountEqual(form.hidden_fields(),
[form['q'], form['sort'], form['user'], form['shared'], form['unread']])
self.assertCountEqual(
form.hidden_fields(),
[form["q"], form["sort"], form["user"], form["shared"], form["unread"]],
)
# some modified params are editable fields
search = BookmarkSearch(q='search query',
sort=BookmarkSearch.SORT_ADDED_ASC,
user='user123')
form = BookmarkSearchForm(search, editable_fields=['q', 'user'])
self.assertCountEqual(form.hidden_fields(), [form['sort']])
search = BookmarkSearch(
q="search query", sort=BookmarkSearch.SORT_ADDED_ASC, user="user123"
)
form = BookmarkSearchForm(search, editable_fields=["q", "user"])
self.assertCountEqual(form.hidden_fields(), [form["sort"]])

View File

@@ -10,57 +10,59 @@ class BookmarkSearchModelTest(TestCase):
query_dict = QueryDict()
search = BookmarkSearch.from_request(query_dict)
self.assertEqual(search.q, '')
self.assertEqual(search.user, '')
self.assertEqual(search.q, "")
self.assertEqual(search.user, "")
self.assertEqual(search.sort, BookmarkSearch.SORT_ADDED_DESC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
# some params
query_dict = QueryDict('q=search query&user=user123')
query_dict = QueryDict("q=search query&user=user123")
bookmark_search = BookmarkSearch.from_request(query_dict)
self.assertEqual(bookmark_search.q, 'search query')
self.assertEqual(bookmark_search.user, 'user123')
self.assertEqual(bookmark_search.q, "search query")
self.assertEqual(bookmark_search.user, "user123")
self.assertEqual(bookmark_search.sort, BookmarkSearch.SORT_ADDED_DESC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
# all params
query_dict = QueryDict('q=search query&sort=title_asc&user=user123&shared=yes&unread=yes')
query_dict = QueryDict(
"q=search query&sort=title_asc&user=user123&shared=yes&unread=yes"
)
search = BookmarkSearch.from_request(query_dict)
self.assertEqual(search.q, 'search query')
self.assertEqual(search.user, 'user123')
self.assertEqual(search.q, "search query")
self.assertEqual(search.user, "user123")
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_SHARED)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_YES)
# respects preferences
preferences = {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
"sort": BookmarkSearch.SORT_TITLE_ASC,
"unread": BookmarkSearch.FILTER_UNREAD_YES,
}
query_dict = QueryDict('q=search query')
query_dict = QueryDict("q=search query")
search = BookmarkSearch.from_request(query_dict, preferences)
self.assertEqual(search.q, 'search query')
self.assertEqual(search.user, '')
self.assertEqual(search.q, "search query")
self.assertEqual(search.user, "")
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_ASC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_OFF)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_YES)
# query overrides preferences
preferences = {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'shared': BookmarkSearch.FILTER_SHARED_SHARED,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
"sort": BookmarkSearch.SORT_TITLE_ASC,
"shared": BookmarkSearch.FILTER_SHARED_SHARED,
"unread": BookmarkSearch.FILTER_UNREAD_YES,
}
query_dict = QueryDict('sort=title_desc&shared=no&unread=off')
query_dict = QueryDict("sort=title_desc&shared=no&unread=off")
search = BookmarkSearch.from_request(query_dict, preferences)
self.assertEqual(search.q, '')
self.assertEqual(search.user, '')
self.assertEqual(search.q, "")
self.assertEqual(search.user, "")
self.assertEqual(search.sort, BookmarkSearch.SORT_TITLE_DESC)
self.assertEqual(search.shared, BookmarkSearch.FILTER_SHARED_UNSHARED)
self.assertEqual(search.unread, BookmarkSearch.FILTER_UNREAD_OFF)
@@ -72,28 +74,36 @@ class BookmarkSearchModelTest(TestCase):
self.assertEqual(len(modified_params), 0)
# params are default values
bookmark_search = BookmarkSearch(q='', sort=BookmarkSearch.SORT_ADDED_DESC, user='', shared='')
bookmark_search = BookmarkSearch(
q="", sort=BookmarkSearch.SORT_ADDED_DESC, user="", shared=""
)
modified_params = bookmark_search.modified_params
self.assertEqual(len(modified_params), 0)
# some modified params
bookmark_search = BookmarkSearch(q='search query', sort=BookmarkSearch.SORT_ADDED_ASC)
bookmark_search = BookmarkSearch(
q="search query", sort=BookmarkSearch.SORT_ADDED_ASC
)
modified_params = bookmark_search.modified_params
self.assertCountEqual(modified_params, ['q', 'sort'])
self.assertCountEqual(modified_params, ["q", "sort"])
# all modified params
bookmark_search = BookmarkSearch(q='search query',
sort=BookmarkSearch.SORT_ADDED_ASC,
user='user123',
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES)
bookmark_search = BookmarkSearch(
q="search query",
sort=BookmarkSearch.SORT_ADDED_ASC,
user="user123",
shared=BookmarkSearch.FILTER_SHARED_SHARED,
unread=BookmarkSearch.FILTER_UNREAD_YES,
)
modified_params = bookmark_search.modified_params
self.assertCountEqual(modified_params, ['q', 'sort', 'user', 'shared', 'unread'])
self.assertCountEqual(
modified_params, ["q", "sort", "user", "shared", "unread"]
)
# preferences are not modified params
preferences = {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
"sort": BookmarkSearch.SORT_TITLE_ASC,
"unread": BookmarkSearch.FILTER_UNREAD_YES,
}
bookmark_search = BookmarkSearch(preferences=preferences)
modified_params = bookmark_search.modified_params
@@ -101,27 +111,31 @@ class BookmarkSearchModelTest(TestCase):
# param is not modified if it matches the preference
preferences = {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
"sort": BookmarkSearch.SORT_TITLE_ASC,
"unread": BookmarkSearch.FILTER_UNREAD_YES,
}
bookmark_search = BookmarkSearch(sort=BookmarkSearch.SORT_TITLE_ASC,
unread=BookmarkSearch.FILTER_UNREAD_YES,
preferences=preferences)
bookmark_search = BookmarkSearch(
sort=BookmarkSearch.SORT_TITLE_ASC,
unread=BookmarkSearch.FILTER_UNREAD_YES,
preferences=preferences,
)
modified_params = bookmark_search.modified_params
self.assertEqual(len(modified_params), 0)
# overriding preferences is a modified param
preferences = {
'sort': BookmarkSearch.SORT_TITLE_ASC,
'shared': BookmarkSearch.FILTER_SHARED_SHARED,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
"sort": BookmarkSearch.SORT_TITLE_ASC,
"shared": BookmarkSearch.FILTER_SHARED_SHARED,
"unread": BookmarkSearch.FILTER_UNREAD_YES,
}
bookmark_search = BookmarkSearch(sort=BookmarkSearch.SORT_TITLE_DESC,
shared=BookmarkSearch.FILTER_SHARED_UNSHARED,
unread=BookmarkSearch.FILTER_UNREAD_OFF,
preferences=preferences)
bookmark_search = BookmarkSearch(
sort=BookmarkSearch.SORT_TITLE_DESC,
shared=BookmarkSearch.FILTER_SHARED_UNSHARED,
unread=BookmarkSearch.FILTER_UNREAD_OFF,
preferences=preferences,
)
modified_params = bookmark_search.modified_params
self.assertCountEqual(modified_params, ['sort', 'shared', 'unread'])
self.assertCountEqual(modified_params, ["sort", "shared", "unread"])
def test_has_modifications(self):
# no params
@@ -129,34 +143,49 @@ class BookmarkSearchModelTest(TestCase):
self.assertFalse(bookmark_search.has_modifications)
# params are default values
bookmark_search = BookmarkSearch(q='', sort=BookmarkSearch.SORT_ADDED_DESC, user='', shared='')
bookmark_search = BookmarkSearch(
q="", sort=BookmarkSearch.SORT_ADDED_DESC, user="", shared=""
)
self.assertFalse(bookmark_search.has_modifications)
# modified params
bookmark_search = BookmarkSearch(q='search query', sort=BookmarkSearch.SORT_ADDED_ASC)
bookmark_search = BookmarkSearch(
q="search query", sort=BookmarkSearch.SORT_ADDED_ASC
)
self.assertTrue(bookmark_search.has_modifications)
def test_preferences_dict(self):
# no params
bookmark_search = BookmarkSearch()
self.assertEqual(bookmark_search.preferences_dict, {
'sort': BookmarkSearch.SORT_ADDED_DESC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
})
self.assertEqual(
bookmark_search.preferences_dict,
{
"sort": BookmarkSearch.SORT_ADDED_DESC,
"shared": BookmarkSearch.FILTER_SHARED_OFF,
"unread": BookmarkSearch.FILTER_UNREAD_OFF,
},
)
# with params
bookmark_search = BookmarkSearch(sort=BookmarkSearch.SORT_TITLE_DESC, unread=BookmarkSearch.FILTER_UNREAD_YES)
self.assertEqual(bookmark_search.preferences_dict, {
'sort': BookmarkSearch.SORT_TITLE_DESC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
bookmark_search = BookmarkSearch(
sort=BookmarkSearch.SORT_TITLE_DESC, unread=BookmarkSearch.FILTER_UNREAD_YES
)
self.assertEqual(
bookmark_search.preferences_dict,
{
"sort": BookmarkSearch.SORT_TITLE_DESC,
"shared": BookmarkSearch.FILTER_SHARED_OFF,
"unread": BookmarkSearch.FILTER_UNREAD_YES,
},
)
# only returns preferences
bookmark_search = BookmarkSearch(q='search query', user='user123')
self.assertEqual(bookmark_search.preferences_dict, {
'sort': BookmarkSearch.SORT_ADDED_DESC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_OFF,
})
bookmark_search = BookmarkSearch(q="search query", user="user123")
self.assertEqual(
bookmark_search.preferences_dict,
{
"sort": BookmarkSearch.SORT_ADDED_DESC,
"shared": BookmarkSearch.FILTER_SHARED_OFF,
"unread": BookmarkSearch.FILTER_UNREAD_OFF,
},
)

View File

@@ -8,21 +8,25 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def render_template(self, url: str, tags: QuerySet[Tag] = Tag.objects.all(), mode: str = ''):
def render_template(
self, url: str, tags: QuerySet[Tag] = Tag.objects.all(), mode: str = ""
):
rf = RequestFactory()
request = rf.get(url)
request.user = self.get_or_create_test_user()
request.user_profile = self.get_or_create_test_user().profile
search = BookmarkSearch.from_request(request.GET)
context = RequestContext(request, {
'request': request,
'search': search,
'tags': tags,
'mode': mode,
})
context = RequestContext(
request,
{
"request": request,
"search": search,
"tags": tags,
"mode": mode,
},
)
template_to_render = Template(
'{% load bookmarks %}'
'{% bookmark_search search tags mode %}'
"{% load bookmarks %}" "{% bookmark_search search tags mode %}"
)
return template_to_render.render(context)
@@ -31,7 +35,7 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertIsNotNone(input)
if value is not None:
self.assertEqual(input['value'], value)
self.assertEqual(input["value"], value)
def assertNoHiddenInput(self, form: BeautifulSoup, name: str):
input = form.select_one(f'input[name="{name}"][type="hidden"]')
@@ -42,19 +46,19 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertIsNotNone(input)
if value is not None:
self.assertEqual(input['value'], value)
self.assertEqual(input["value"], value)
def assertSelect(self, form: BeautifulSoup, name: str, value: str = None):
select = form.select_one(f'select[name="{name}"]')
self.assertIsNotNone(select)
if value is not None:
options = select.select('option')
options = select.select("option")
for option in options:
if option['value'] == value:
self.assertTrue(option.has_attr('selected'))
if option["value"] == value:
self.assertTrue(option.has_attr("selected"))
else:
self.assertFalse(option.has_attr('selected'))
self.assertFalse(option.has_attr("selected"))
def assertRadioGroup(self, form: BeautifulSoup, name: str, value: str = None):
radios = form.select(f'input[name="{name}"][type="radio"]')
@@ -62,165 +66,182 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
if value is not None:
for radio in radios:
if radio['value'] == value:
self.assertTrue(radio.has_attr('checked'))
if radio["value"] == value:
self.assertTrue(radio.has_attr("checked"))
else:
self.assertFalse(radio.has_attr('checked'))
self.assertFalse(radio.has_attr("checked"))
def assertNoRadioGroup(self, form: BeautifulSoup, name: str):
radios = form.select(f'input[name="{name}"][type="radio"]')
self.assertTrue(len(radios) == 0)
def assertUnmodifiedLabel(self, html: str, text: str, id: str = ''):
id_attr = f'for="{id}"' if id else ''
tag = 'label' if id else 'div'
def assertUnmodifiedLabel(self, html: str, text: str, id: str = ""):
id_attr = f'for="{id}"' if id else ""
tag = "label" if id else "div"
needle = f'<{tag} class="form-label" {id_attr}>{text}</{tag}>'
self.assertInHTML(needle, html)
def assertModifiedLabel(self, html: str, text: str, id: str = ''):
id_attr = f'for="{id}"' if id else ''
tag = 'label' if id else 'div'
def assertModifiedLabel(self, html: str, text: str, id: str = ""):
id_attr = f'for="{id}"' if id else ""
tag = "label" if id else "div"
needle = f'<{tag} class="form-label text-bold" {id_attr}>{text}</{tag}>'
self.assertInHTML(needle, html)
def test_search_form_inputs(self):
# Without params
url = '/test'
url = "/test"
rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
search_form = soup.select_one('form#search')
search_form = soup.select_one("form#search")
self.assertSearchInput(search_form, 'q')
self.assertNoHiddenInput(search_form, 'user')
self.assertNoHiddenInput(search_form, 'sort')
self.assertNoHiddenInput(search_form, 'shared')
self.assertNoHiddenInput(search_form, 'unread')
self.assertSearchInput(search_form, "q")
self.assertNoHiddenInput(search_form, "user")
self.assertNoHiddenInput(search_form, "sort")
self.assertNoHiddenInput(search_form, "shared")
self.assertNoHiddenInput(search_form, "unread")
# With params
url = '/test?q=foo&user=john&sort=title_asc&shared=yes&unread=yes'
url = "/test?q=foo&user=john&sort=title_asc&shared=yes&unread=yes"
rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
search_form = soup.select_one('form#search')
search_form = soup.select_one("form#search")
self.assertSearchInput(search_form, 'q', 'foo')
self.assertHiddenInput(search_form, 'user', 'john')
self.assertHiddenInput(search_form, 'sort', BookmarkSearch.SORT_TITLE_ASC)
self.assertHiddenInput(search_form, 'shared', BookmarkSearch.FILTER_SHARED_SHARED)
self.assertHiddenInput(search_form, 'unread', BookmarkSearch.FILTER_UNREAD_YES)
self.assertSearchInput(search_form, "q", "foo")
self.assertHiddenInput(search_form, "user", "john")
self.assertHiddenInput(search_form, "sort", BookmarkSearch.SORT_TITLE_ASC)
self.assertHiddenInput(
search_form, "shared", BookmarkSearch.FILTER_SHARED_SHARED
)
self.assertHiddenInput(search_form, "unread", BookmarkSearch.FILTER_UNREAD_YES)
def test_preferences_form_inputs(self):
# Without params
url = '/test'
url = "/test"
rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
preferences_form = soup.select_one('form#search_preferences')
preferences_form = soup.select_one("form#search_preferences")
self.assertNoHiddenInput(preferences_form, 'q')
self.assertNoHiddenInput(preferences_form, 'user')
self.assertNoHiddenInput(preferences_form, 'sort')
self.assertNoHiddenInput(preferences_form, 'shared')
self.assertNoHiddenInput(preferences_form, 'unread')
self.assertNoHiddenInput(preferences_form, "q")
self.assertNoHiddenInput(preferences_form, "user")
self.assertNoHiddenInput(preferences_form, "sort")
self.assertNoHiddenInput(preferences_form, "shared")
self.assertNoHiddenInput(preferences_form, "unread")
self.assertSelect(preferences_form, 'sort', BookmarkSearch.SORT_ADDED_DESC)
self.assertRadioGroup(preferences_form, 'shared', BookmarkSearch.FILTER_SHARED_OFF)
self.assertRadioGroup(preferences_form, 'unread', BookmarkSearch.FILTER_UNREAD_OFF)
self.assertSelect(preferences_form, "sort", BookmarkSearch.SORT_ADDED_DESC)
self.assertRadioGroup(
preferences_form, "shared", BookmarkSearch.FILTER_SHARED_OFF
)
self.assertRadioGroup(
preferences_form, "unread", BookmarkSearch.FILTER_UNREAD_OFF
)
# With params
url = '/test?q=foo&user=john&sort=title_asc&shared=yes&unread=yes'
url = "/test?q=foo&user=john&sort=title_asc&shared=yes&unread=yes"
rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
preferences_form = soup.select_one('form#search_preferences')
preferences_form = soup.select_one("form#search_preferences")
self.assertHiddenInput(preferences_form, 'q', 'foo')
self.assertHiddenInput(preferences_form, 'user', 'john')
self.assertNoHiddenInput(preferences_form, 'sort')
self.assertNoHiddenInput(preferences_form, 'shared')
self.assertNoHiddenInput(preferences_form, 'unread')
self.assertHiddenInput(preferences_form, "q", "foo")
self.assertHiddenInput(preferences_form, "user", "john")
self.assertNoHiddenInput(preferences_form, "sort")
self.assertNoHiddenInput(preferences_form, "shared")
self.assertNoHiddenInput(preferences_form, "unread")
self.assertSelect(preferences_form, 'sort', BookmarkSearch.SORT_TITLE_ASC)
self.assertRadioGroup(preferences_form, 'shared', BookmarkSearch.FILTER_SHARED_SHARED)
self.assertRadioGroup(preferences_form, 'unread', BookmarkSearch.FILTER_UNREAD_YES)
self.assertSelect(preferences_form, "sort", BookmarkSearch.SORT_TITLE_ASC)
self.assertRadioGroup(
preferences_form, "shared", BookmarkSearch.FILTER_SHARED_SHARED
)
self.assertRadioGroup(
preferences_form, "unread", BookmarkSearch.FILTER_UNREAD_YES
)
def test_preferences_form_inputs_shared_mode(self):
# Without params
url = '/test'
rendered_template = self.render_template(url, mode='shared')
url = "/test"
rendered_template = self.render_template(url, mode="shared")
soup = self.make_soup(rendered_template)
preferences_form = soup.select_one('form#search_preferences')
preferences_form = soup.select_one("form#search_preferences")
self.assertNoHiddenInput(preferences_form, 'q')
self.assertNoHiddenInput(preferences_form, 'user')
self.assertNoHiddenInput(preferences_form, 'sort')
self.assertNoHiddenInput(preferences_form, 'shared')
self.assertNoHiddenInput(preferences_form, 'unread')
self.assertNoHiddenInput(preferences_form, "q")
self.assertNoHiddenInput(preferences_form, "user")
self.assertNoHiddenInput(preferences_form, "sort")
self.assertNoHiddenInput(preferences_form, "shared")
self.assertNoHiddenInput(preferences_form, "unread")
self.assertSelect(preferences_form, 'sort', BookmarkSearch.SORT_ADDED_DESC)
self.assertNoRadioGroup(preferences_form, 'shared')
self.assertNoRadioGroup(preferences_form, 'unread')
self.assertSelect(preferences_form, "sort", BookmarkSearch.SORT_ADDED_DESC)
self.assertNoRadioGroup(preferences_form, "shared")
self.assertNoRadioGroup(preferences_form, "unread")
# With params
url = '/test?q=foo&user=john&sort=title_asc'
rendered_template = self.render_template(url, mode='shared')
url = "/test?q=foo&user=john&sort=title_asc"
rendered_template = self.render_template(url, mode="shared")
soup = self.make_soup(rendered_template)
preferences_form = soup.select_one('form#search_preferences')
preferences_form = soup.select_one("form#search_preferences")
self.assertHiddenInput(preferences_form, 'q', 'foo')
self.assertHiddenInput(preferences_form, 'user', 'john')
self.assertNoHiddenInput(preferences_form, 'sort')
self.assertNoHiddenInput(preferences_form, 'shared')
self.assertNoHiddenInput(preferences_form, 'unread')
self.assertHiddenInput(preferences_form, "q", "foo")
self.assertHiddenInput(preferences_form, "user", "john")
self.assertNoHiddenInput(preferences_form, "sort")
self.assertNoHiddenInput(preferences_form, "shared")
self.assertNoHiddenInput(preferences_form, "unread")
self.assertSelect(preferences_form, 'sort', BookmarkSearch.SORT_TITLE_ASC)
self.assertNoRadioGroup(preferences_form, 'shared')
self.assertNoRadioGroup(preferences_form, 'unread')
self.assertSelect(preferences_form, "sort", BookmarkSearch.SORT_TITLE_ASC)
self.assertNoRadioGroup(preferences_form, "shared")
self.assertNoRadioGroup(preferences_form, "unread")
def test_modified_indicator(self):
# Without modifications
url = '/test'
url = "/test"
rendered_template = self.render_template(url)
self.assertIn('<button type="button" class="btn dropdown-toggle">', rendered_template)
self.assertIn(
'<button type="button" class="btn dropdown-toggle">', rendered_template
)
# With modifications
url = '/test?sort=title_asc'
url = "/test?sort=title_asc"
rendered_template = self.render_template(url)
self.assertIn('<button type="button" class="btn dropdown-toggle badge">', rendered_template)
self.assertIn(
'<button type="button" class="btn dropdown-toggle badge">',
rendered_template,
)
# Ignores non-preferences modifications
url = '/test?q=foo&user=john'
url = "/test?q=foo&user=john"
rendered_template = self.render_template(url)
self.assertIn('<button type="button" class="btn dropdown-toggle">', rendered_template)
self.assertIn(
'<button type="button" class="btn dropdown-toggle">', rendered_template
)
def test_modified_labels(self):
# Without modifications
url = '/test'
url = "/test"
rendered_template = self.render_template(url)
self.assertUnmodifiedLabel(rendered_template, 'Sort by', 'id_sort')
self.assertUnmodifiedLabel(rendered_template, 'Shared filter')
self.assertUnmodifiedLabel(rendered_template, 'Unread filter')
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort")
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
# Modified sort
url = '/test?sort=title_asc'
url = "/test?sort=title_asc"
rendered_template = self.render_template(url)
self.assertModifiedLabel(rendered_template, 'Sort by', 'id_sort')
self.assertUnmodifiedLabel(rendered_template, 'Shared filter')
self.assertUnmodifiedLabel(rendered_template, 'Unread filter')
self.assertModifiedLabel(rendered_template, "Sort by", "id_sort")
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
# Modified shared
url = '/test?shared=yes'
url = "/test?shared=yes"
rendered_template = self.render_template(url)
self.assertUnmodifiedLabel(rendered_template, 'Sort by', 'id_sort')
self.assertModifiedLabel(rendered_template, 'Shared filter')
self.assertUnmodifiedLabel(rendered_template, 'Unread filter')
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort")
self.assertModifiedLabel(rendered_template, "Shared filter")
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
# Modified unread
url = '/test?unread=yes'
url = "/test?unread=yes"
rendered_template = self.render_template(url)
self.assertUnmodifiedLabel(rendered_template, 'Sort by', 'id_sort')
self.assertUnmodifiedLabel(rendered_template, 'Shared filter')
self.assertModifiedLabel(rendered_template, 'Unread filter')
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort")
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
self.assertModifiedLabel(rendered_template, "Unread filter")

View File

@@ -15,39 +15,50 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
user = self.get_or_create_test_user()
self.client.force_login(user)
def assertBookmarkCount(self, html: str, bookmark: Bookmark, count: int, link_target: str = '_blank'):
def assertBookmarkCount(
self, html: str, bookmark: Bookmark, count: int, link_target: str = "_blank"
):
self.assertInHTML(
f'<a href="{bookmark.url}" target="{link_target}" rel="noopener">{bookmark.resolved_title}</a>',
html, count=count
html,
count=count,
)
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]
@@ -57,7 +68,7 @@ class BookmarkSharedViewTestCase(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]
@@ -67,26 +78,31 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def assertVisibleUserOptions(self, response, users: List[User]):
html = response.content.decode()
user_options = [
'<option value="" selected="">Everyone</option>'
]
user_options = ['<option value="" selected="">Everyone</option>']
for user in users:
user_options.append(f'<option value="{user.username}">{user.username}</option>')
user_select_html = f'''
user_options.append(
f'<option value="{user.username}">{user.username}</option>'
)
user_select_html = f"""
<select name="user" class="form-select" required="" id="id_user">
{''.join(user_options)}
</select>
'''
"""
self.assertInHTML(user_select_html, html)
def assertEditLink(self, response, url):
html = response.content.decode()
self.assertInHTML(f'''
self.assertInHTML(
f"""
<a href="{url}">Edit</a>
''', html)
""",
html,
)
def test_should_list_shared_bookmarks_from_all_users_that_have_sharing_enabled(self):
def test_should_list_shared_bookmarks_from_all_users_that_have_sharing_enabled(
self,
):
self.authenticate()
user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True)
@@ -105,7 +121,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(shared=True, user=user4),
]
response = self.client.get(reverse('bookmarks:shared'))
response = self.client.get(reverse("bookmarks:shared"))
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -124,7 +140,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(shared=True, user=user3),
]
url = reverse('bookmarks:shared') + '?user=' + user1.username
url = reverse("bookmarks:shared") + "?user=" + user1.username
response = self.client.get(url)
self.assertVisibleBookmarks(response, visible_bookmarks)
@@ -134,10 +150,12 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.authenticate()
user = self.setup_user(enable_sharing=True)
visible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user, prefix='foo')
visible_bookmarks = self.setup_numbered_bookmarks(
3, shared=True, user=user, prefix="foo"
)
invisible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user)
response = self.client.get(reverse('bookmarks:shared') + '?q=foo')
response = self.client.get(reverse("bookmarks:shared") + "?q=foo")
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
@@ -146,15 +164,21 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
user2 = self.setup_user(enable_sharing=True)
visible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user1, prefix='user1')
invisible_bookmarks = self.setup_numbered_bookmarks(3, shared=True, user=user2, prefix='user2')
visible_bookmarks = self.setup_numbered_bookmarks(
3, shared=True, user=user1, prefix="user1"
)
invisible_bookmarks = self.setup_numbered_bookmarks(
3, shared=True, user=user2, prefix="user2"
)
response = self.client.get(reverse('bookmarks:shared'))
response = self.client.get(reverse("bookmarks:shared"))
self.assertVisibleBookmarks(response, visible_bookmarks)
self.assertInvisibleBookmarks(response, invisible_bookmarks)
def test_should_list_tags_for_shared_bookmarks_from_all_users_that_have_sharing_enabled(self):
def test_should_list_tags_for_shared_bookmarks_from_all_users_that_have_sharing_enabled(
self,
):
self.authenticate()
user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=True)
@@ -181,7 +205,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(shared=False, user=user3, tags=[invisible_tags[2]])
self.setup_bookmark(shared=True, user=user4, tags=[invisible_tags[3]])
response = self.client.get(reverse('bookmarks:shared'))
response = self.client.get(reverse("bookmarks:shared"))
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
@@ -203,7 +227,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[0]])
self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[1]])
url = reverse('bookmarks:shared') + '?user=' + user1.username
url = reverse("bookmarks:shared") + "?user=" + user1.username
response = self.client.get(url)
self.assertVisibleTags(response, visible_tags)
@@ -225,15 +249,21 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_tag(user=user3),
]
self.setup_bookmark(shared=True, user=user1, title='searchvalue', tags=[visible_tags[0]])
self.setup_bookmark(shared=True, user=user2, title='searchvalue', tags=[visible_tags[1]])
self.setup_bookmark(shared=True, user=user3, title='searchvalue', tags=[visible_tags[2]])
self.setup_bookmark(
shared=True, user=user1, title="searchvalue", tags=[visible_tags[0]]
)
self.setup_bookmark(
shared=True, user=user2, title="searchvalue", tags=[visible_tags[1]]
)
self.setup_bookmark(
shared=True, user=user3, title="searchvalue", tags=[visible_tags[2]]
)
self.setup_bookmark(shared=True, user=user1, tags=[invisible_tags[0]])
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])
self.setup_bookmark(shared=True, user=user3, tags=[invisible_tags[2]])
response = self.client.get(reverse('bookmarks:shared') + '?q=searchvalue')
response = self.client.get(reverse("bookmarks:shared") + "?q=searchvalue")
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
@@ -257,7 +287,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[0]])
self.setup_bookmark(shared=True, user=user2, tags=[invisible_tags[1]])
response = self.client.get(reverse('bookmarks:shared'))
response = self.client.get(reverse("bookmarks:shared"))
self.assertVisibleTags(response, visible_tags)
self.assertInvisibleTags(response, invisible_tags)
@@ -265,8 +295,8 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def test_should_list_users_with_shared_bookmarks_if_sharing_is_enabled(self):
self.authenticate()
expected_visible_users = [
self.setup_user(name='user_a', enable_sharing=True),
self.setup_user(name='user_b', enable_sharing=True),
self.setup_user(name="user_a", enable_sharing=True),
self.setup_user(name="user_b", enable_sharing=True),
]
self.setup_bookmark(shared=True, user=expected_visible_users[0])
self.setup_bookmark(shared=True, user=expected_visible_users[1])
@@ -274,14 +304,18 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(shared=False, user=self.setup_user(enable_sharing=True))
self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=False))
response = self.client.get(reverse('bookmarks:shared'))
response = self.client.get(reverse("bookmarks:shared"))
self.assertVisibleUserOptions(response, expected_visible_users)
def test_should_list_only_users_with_publicly_shared_bookmarks_without_login(self):
# users with public sharing enabled
expected_visible_users = [
self.setup_user(name='user_a', enable_sharing=True, enable_public_sharing=True),
self.setup_user(name='user_b', enable_sharing=True, enable_public_sharing=True),
self.setup_user(
name="user_a", enable_sharing=True, enable_public_sharing=True
),
self.setup_user(
name="user_b", enable_sharing=True, enable_public_sharing=True
),
]
self.setup_bookmark(shared=True, user=expected_visible_users[0])
self.setup_bookmark(shared=True, user=expected_visible_users[1])
@@ -290,7 +324,7 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=True))
self.setup_bookmark(shared=True, user=self.setup_user(enable_sharing=True))
response = self.client.get(reverse('bookmarks:shared'))
response = self.client.get(reverse("bookmarks:shared"))
self.assertVisibleUserOptions(response, expected_visible_users)
def test_should_list_bookmarks_and_tags_for_search_preferences(self):
@@ -299,19 +333,33 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
user_profile = self.get_or_create_test_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, shared=True, unread=True, with_tags=True, prefix='unread',
tag_prefix='unread', user=other_user)
read_bookmarks = self.setup_numbered_bookmarks(3, shared=True, unread=False, with_tags=True, prefix='read',
tag_prefix='read', user=other_user)
unread_bookmarks = self.setup_numbered_bookmarks(
3,
shared=True,
unread=True,
with_tags=True,
prefix="unread",
tag_prefix="unread",
user=other_user,
)
read_bookmarks = self.setup_numbered_bookmarks(
3,
shared=True,
unread=False,
with_tags=True,
prefix="read",
tag_prefix="read",
user=other_user,
)
unread_tags = self.get_tags_from_bookmarks(unread_bookmarks)
read_tags = self.get_tags_from_bookmarks(read_bookmarks)
response = self.client.get(reverse('bookmarks:shared'))
response = self.client.get(reverse("bookmarks:shared"))
self.assertVisibleBookmarks(response, unread_bookmarks)
self.assertInvisibleBookmarks(response, read_bookmarks)
self.assertVisibleTags(response, unread_tags)
@@ -325,12 +373,12 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
visible_bookmarks = [
self.setup_bookmark(shared=True),
self.setup_bookmark(shared=True),
self.setup_bookmark(shared=True)
self.setup_bookmark(shared=True),
]
response = self.client.get(reverse('bookmarks:shared'))
response = self.client.get(reverse("bookmarks:shared"))
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):
self.authenticate()
@@ -342,12 +390,12 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
visible_bookmarks = [
self.setup_bookmark(shared=True),
self.setup_bookmark(shared=True),
self.setup_bookmark(shared=True)
self.setup_bookmark(shared=True),
]
response = self.client.get(reverse('bookmarks:shared'))
response = self.client.get(reverse("bookmarks:shared"))
self.assertVisibleBookmarks(response, visible_bookmarks, '_self')
self.assertVisibleBookmarks(response, visible_bookmarks, "_self")
def test_edit_link_return_url_respects_search_options(self):
self.authenticate()
@@ -355,148 +403,227 @@ class BookmarkSharedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
user.profile.enable_sharing = True
user.profile.save()
bookmark = self.setup_bookmark(title='foo', shared=True, user=user)
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
base_url = reverse('bookmarks:shared')
bookmark = self.setup_bookmark(title="foo", shared=True, user=user)
edit_url = reverse("bookmarks:edit", args=[bookmark.id])
base_url = reverse("bookmarks:shared")
# 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 user
url_params = f'?q=foo&user={user.username}'
url_params = f"?q=foo&user={user.username}"
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_apply_search_preferences(self):
# no params
response = self.client.post(reverse('bookmarks:shared'))
response = self.client.post(reverse("bookmarks:shared"))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse('bookmarks:shared'))
self.assertEqual(response.url, reverse("bookmarks:shared"))
# some params
response = self.client.post(reverse('bookmarks:shared'), {
'q': 'foo',
'sort': BookmarkSearch.SORT_TITLE_ASC,
})
response = self.client.post(
reverse("bookmarks:shared"),
{
"q": "foo",
"sort": BookmarkSearch.SORT_TITLE_ASC,
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse('bookmarks:shared') + '?q=foo&sort=title_asc')
self.assertEqual(
response.url, reverse("bookmarks:shared") + "?q=foo&sort=title_asc"
)
# params with default value are removed
response = self.client.post(reverse('bookmarks:shared'), {
'q': 'foo',
'user': '',
'sort': BookmarkSearch.SORT_ADDED_DESC,
'shared': BookmarkSearch.FILTER_SHARED_OFF,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
response = self.client.post(
reverse("bookmarks:shared"),
{
"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:shared') + '?q=foo&unread=yes')
self.assertEqual(
response.url, reverse("bookmarks:shared") + "?q=foo&unread=yes"
)
# page is removed
response = self.client.post(reverse('bookmarks:shared'), {
'q': 'foo',
'page': '2',
'sort': BookmarkSearch.SORT_TITLE_ASC,
})
response = self.client.post(
reverse("bookmarks:shared"),
{
"q": "foo",
"page": "2",
"sort": BookmarkSearch.SORT_TITLE_ASC,
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse('bookmarks:shared') + '?q=foo&sort=title_asc')
self.assertEqual(
response.url, reverse("bookmarks:shared") + "?q=foo&sort=title_asc"
)
def test_save_search_preferences(self):
self.authenticate()
user_profile = self.user.profile
# no params
self.client.post(reverse('bookmarks:shared'), {
'save': '',
})
self.client.post(
reverse("bookmarks:shared"),
{
"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:shared'), {
'save': '',
'sort': BookmarkSearch.SORT_TITLE_ASC,
})
self.client.post(
reverse("bookmarks:shared"),
{
"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:shared'), {
'save': '',
'sort': BookmarkSearch.SORT_TITLE_ASC,
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
self.client.post(
reverse("bookmarks:shared"),
{
"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:shared'), {
'save': '',
'unread': BookmarkSearch.FILTER_UNREAD_YES,
})
self.client.post(
reverse("bookmarks:shared"),
{
"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:shared'), {
'save': '',
'q': 'foo',
'user': 'john',
'page': '3',
'sort': BookmarkSearch.SORT_TITLE_ASC,
})
self.client.post(
reverse("bookmarks:shared"),
{
"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:shared') + '?q=%23foo'
url = reverse("bookmarks:shared") + "?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/shared/action?q=%23foo&return_url=%2Fbookmarks%2Fshared%3Fq%3D%2523foo')
self.assertEqual(
actions_form.attrs["action"],
"/bookmarks/shared/action?q=%23foo&return_url=%2Fbookmarks%2Fshared%3Fq%3D%2523foo",
)
def test_encode_search_params(self):
self.authenticate()
user = self.get_or_create_test_user()
user.profile.enable_sharing = True
user.profile.save()
bookmark = self.setup_bookmark(description="alert('xss')", shared=True)
url = reverse("bookmarks:shared") + "?q=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
self.assertContains(response, bookmark.url)
url = reverse("bookmarks:shared") + "?sort=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:shared") + "?unread=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:shared") + "?shared=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:shared") + "?user=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")
url = reverse("bookmarks:shared") + "?page=alert(%27xss%27)"
response = self.client.get(url)
self.assertNotContains(response, "alert('xss')")

View File

@@ -27,8 +27,10 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
# capture number of queries
context = CaptureQueriesContext(self.get_connection())
with context:
response = self.client.get(reverse('bookmarks:shared'))
self.assertContains(response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks)
response = self.client.get(reverse("bookmarks:shared"))
self.assertContains(
response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks
)
number_of_queries = context.final_queries
@@ -40,5 +42,9 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
# assert num queries doesn't increase
with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse('bookmarks:shared'))
self.assertContains(response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks + num_additional_bookmarks)
response = self.client.get(reverse("bookmarks:shared"))
self.assertContains(
response,
'<li ld-bookmark-item class="shared">',
num_initial_bookmarks + num_additional_bookmarks,
)

View File

@@ -9,36 +9,38 @@ from bookmarks.models import BookmarkForm, Bookmark
User = get_user_model()
ENABLED_URL_VALIDATION_TEST_CASES = [
('thisisnotavalidurl', False),
('http://domain', False),
('unknownscheme://domain.com', False),
('http://domain.com', True),
('http://www.domain.com', True),
('https://domain.com', True),
('https://www.domain.com', True),
("thisisnotavalidurl", False),
("http://domain", False),
("unknownscheme://domain.com", False),
("http://domain.com", True),
("http://www.domain.com", True),
("https://domain.com", True),
("https://www.domain.com", True),
]
DISABLED_URL_VALIDATION_TEST_CASES = [
('thisisnotavalidurl', True),
('http://domain', True),
('unknownscheme://domain.com', True),
('http://domain.com', True),
('http://www.domain.com', True),
('https://domain.com', True),
('https://www.domain.com', True),
("thisisnotavalidurl", True),
("http://domain", True),
("unknownscheme://domain.com", True),
("http://domain.com", True),
("http://www.domain.com", True),
("https://domain.com", True),
("https://www.domain.com", True),
]
class BookmarkValidationTestCase(TestCase):
def setUp(self) -> None:
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
self.user = User.objects.create_user(
"testuser", "test@example.com", "password123"
)
def test_bookmark_model_should_not_allow_missing_url(self):
bookmark = Bookmark(
date_added=datetime.datetime.now(),
date_modified=datetime.datetime.now(),
owner=self.user
owner=self.user,
)
with self.assertRaises(ValidationError):
@@ -46,10 +48,10 @@ class BookmarkValidationTestCase(TestCase):
def test_bookmark_model_should_not_allow_empty_url(self):
bookmark = Bookmark(
url='',
url="",
date_added=datetime.datetime.now(),
date_modified=datetime.datetime.now(),
owner=self.user
owner=self.user,
)
with self.assertRaises(ValidationError):
@@ -64,15 +66,15 @@ class BookmarkValidationTestCase(TestCase):
self._run_bookmark_model_url_validity_checks(DISABLED_URL_VALIDATION_TEST_CASES)
def test_bookmark_form_should_validate_required_fields(self):
form = BookmarkForm(data={'url': ''})
form = BookmarkForm(data={"url": ""})
self.assertEqual(len(form.errors), 1)
self.assertIn('required', str(form.errors))
self.assertIn("required", str(form.errors))
form = BookmarkForm(data={'url': None})
form = BookmarkForm(data={"url": None})
self.assertEqual(len(form.errors), 1)
self.assertIn('required', str(form.errors))
self.assertIn("required", str(form.errors))
@override_settings(LD_DISABLE_URL_VALIDATION=False)
def test_bookmark_form_should_validate_url_if_not_disabled_in_settings(self):
@@ -89,23 +91,25 @@ class BookmarkValidationTestCase(TestCase):
url=url,
date_added=datetime.datetime.now(),
date_modified=datetime.datetime.now(),
owner=self.user
owner=self.user,
)
try:
bookmark.full_clean()
self.assertTrue(expectation, 'Did not expect validation error')
self.assertTrue(expectation, "Did not expect validation error")
except ValidationError as e:
self.assertFalse(expectation, 'Expected validation error')
self.assertTrue('url' in e.message_dict, 'Expected URL validation to fail')
self.assertFalse(expectation, "Expected validation error")
self.assertTrue(
"url" in e.message_dict, "Expected URL validation to fail"
)
def _run_bookmark_form_url_validity_checks(self, cases):
for case in cases:
url, expectation = case
form = BookmarkForm(data={'url': url})
form = BookmarkForm(data={"url": url})
if expectation:
self.assertEqual(len(form.errors), 0)
else:
self.assertEqual(len(form.errors), 1)
self.assertIn('Enter a valid URL', str(form.errors))
self.assertIn("Enter a valid URL", str(form.errors))

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,10 @@ from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
class BookmarksApiPerformanceTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.api_token = Token.objects.get_or_create(user=self.get_or_create_test_user())[0]
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.api_token.key)
self.api_token = Token.objects.get_or_create(
user=self.get_or_create_test_user()
)[0]
self.client.credentials(HTTP_AUTHORIZATION="Token " + self.api_token.key)
def get_connection(self):
return connections[DEFAULT_DB_ALIAS]
@@ -26,7 +28,10 @@ class BookmarksApiPerformanceTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
# capture number of queries
context = CaptureQueriesContext(self.get_connection())
with context:
self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
self.get(
reverse("bookmarks:bookmark-list"),
expected_status_code=status.HTTP_200_OK,
)
number_of_queries = context.final_queries
@@ -41,7 +46,10 @@ class BookmarksApiPerformanceTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
# capture number of queries
context = CaptureQueriesContext(self.get_connection())
with context:
self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_200_OK)
self.get(
reverse("bookmarks:bookmark-archived"),
expected_status_code=status.HTTP_200_OK,
)
number_of_queries = context.final_queries
@@ -57,7 +65,10 @@ class BookmarksApiPerformanceTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
# capture number of queries
context = CaptureQueriesContext(self.get_connection())
with context:
self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
self.get(
reverse("bookmarks:bookmark-shared"),
expected_status_code=status.HTTP_200_OK,
)
number_of_queries = context.final_queries

View File

@@ -9,47 +9,68 @@ from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def authenticate(self) -> None:
self.api_token = Token.objects.get_or_create(user=self.get_or_create_test_user())[0]
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.api_token.key)
self.api_token = Token.objects.get_or_create(
user=self.get_or_create_test_user()
)[0]
self.client.credentials(HTTP_AUTHORIZATION="Token " + self.api_token.key)
def test_list_bookmarks_requires_authentication(self):
self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.get(
reverse("bookmarks:bookmark-list"),
expected_status_code=status.HTTP_401_UNAUTHORIZED,
)
self.authenticate()
self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
self.get(
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK
)
def test_list_archived_bookmarks_requires_authentication(self):
self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.get(
reverse("bookmarks:bookmark-archived"),
expected_status_code=status.HTTP_401_UNAUTHORIZED,
)
self.authenticate()
self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_200_OK)
self.get(
reverse("bookmarks:bookmark-archived"),
expected_status_code=status.HTTP_200_OK,
)
def test_list_shared_bookmarks_does_not_require_authentication(self):
self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
self.get(
reverse("bookmarks:bookmark-shared"),
expected_status_code=status.HTTP_200_OK,
)
self.authenticate()
self.get(reverse('bookmarks:bookmark-shared'), expected_status_code=status.HTTP_200_OK)
self.get(
reverse("bookmarks:bookmark-shared"),
expected_status_code=status.HTTP_200_OK,
)
def test_create_bookmark_requires_authentication(self):
data = {
'url': 'https://example.com/',
'title': 'Test title',
'description': 'Test description',
'notes': 'Test notes',
'is_archived': False,
'unread': False,
'shared': False,
'tag_names': ['tag1', 'tag2']
"url": "https://example.com/",
"title": "Test title",
"description": "Test description",
"notes": "Test notes",
"is_archived": False,
"unread": False,
"shared": False,
"tag_names": ["tag1", "tag2"],
}
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_401_UNAUTHORIZED)
self.post(
reverse("bookmarks:bookmark-list"), data, status.HTTP_401_UNAUTHORIZED
)
self.authenticate()
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
def test_get_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark()
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
@@ -58,8 +79,8 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
def test_update_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark()
data = {'url': 'https://example.com/'}
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
data = {"url": "https://example.com/"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
@@ -68,8 +89,8 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
def test_patch_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark()
data = {'url': 'https://example.com'}
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
data = {"url": "https://example.com"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_401_UNAUTHORIZED)
@@ -78,7 +99,7 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
def test_delete_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark()
url = reverse('bookmarks:bookmark-detail', args=[bookmark.id])
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
@@ -87,7 +108,7 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
def test_archive_requires_authentication(self):
bookmark = self.setup_bookmark()
url = reverse('bookmarks:bookmark-archive', args=[bookmark.id])
url = reverse("bookmarks:bookmark-archive", args=[bookmark.id])
self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
@@ -96,7 +117,7 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
def test_unarchive_requires_authentication(self):
bookmark = self.setup_bookmark(is_archived=True)
url = reverse('bookmarks:bookmark-unarchive', args=[bookmark.id])
url = reverse("bookmarks:bookmark-unarchive", args=[bookmark.id])
self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
@@ -104,16 +125,18 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
def test_check_requires_authentication(self):
url = reverse('bookmarks:bookmark-check')
check_url = urllib.parse.quote_plus('https://example.com')
url = reverse("bookmarks:bookmark-check")
check_url = urllib.parse.quote_plus("https://example.com")
self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_401_UNAUTHORIZED)
self.get(
f"{url}?url={check_url}", expected_status_code=status.HTTP_401_UNAUTHORIZED
)
self.authenticate()
self.get(f'{url}?url={check_url}', expected_status_code=status.HTTP_200_OK)
self.get(f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK)
def test_user_profile_requires_authentication(self):
url = reverse('bookmarks:user-profile')
url = reverse("bookmarks:user-profile")
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)

View File

@@ -16,34 +16,48 @@ from bookmarks.views.partials import contexts
class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
def assertBookmarksLink(self, html: str, bookmark: Bookmark, link_target: str = '_blank'):
favicon_img = f'<img src="/static/{bookmark.favicon_file}" alt="">' if bookmark.favicon_file else ''
def assertBookmarksLink(
self, html: str, bookmark: Bookmark, link_target: str = "_blank"
):
favicon_img = (
f'<img src="/static/{bookmark.favicon_file}" alt="">'
if bookmark.favicon_file
else ""
)
self.assertInHTML(
f'''
f"""
<a href="{bookmark.url}"
target="{link_target}"
rel="noopener">
{favicon_img}
{bookmark.resolved_title}
<span>{bookmark.resolved_title}</span>
</a>
''',
html
""",
html,
)
def assertDateLabel(self, html: str, label_content: str):
self.assertInHTML(f'''
self.assertInHTML(
f"""
<span>{label_content}</span>
<span class="separator">|</span>
''', html)
""",
html,
)
def assertWebArchiveLink(self, html: str, label_content: str, url: str, link_target: str = '_blank'):
self.assertInHTML(f'''
def assertWebArchiveLink(
self, html: str, label_content: str, url: str, link_target: str = "_blank"
):
self.assertInHTML(
f"""
<a href="{url}"
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
{label_content}
</a>
<span class="separator">|</span>
''', html)
""",
html,
)
def assertBookmarkActions(self, html: str, bookmark: Bookmark):
self.assertBookmarkActionsCount(html, bookmark, count=1)
@@ -53,20 +67,32 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
def assertBookmarkActionsCount(self, html: str, bookmark: Bookmark, count=1):
# Edit link
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
self.assertInHTML(f'''
edit_url = reverse("bookmarks:edit", args=[bookmark.id])
self.assertInHTML(
f"""
<a href="{edit_url}?return_url=/bookmarks">Edit</a>
''', html, count=count)
""",
html,
count=count,
)
# Archive link
self.assertInHTML(f'''
self.assertInHTML(
f"""
<button type="submit" name="archive" value="{bookmark.id}"
class="btn btn-link btn-sm">Archive</button>
''', html, count=count)
""",
html,
count=count,
)
# Delete link
self.assertInHTML(f'''
self.assertInHTML(
f"""
<button ld-confirm-button type="submit" name="remove" value="{bookmark.id}"
class="btn btn-link btn-sm">Remove</button>
''', html, count=count)
""",
html,
count=count,
)
def assertShareInfo(self, html: str, bookmark: Bookmark):
self.assertShareInfoCount(html, bookmark, 1)
@@ -75,11 +101,15 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
self.assertShareInfoCount(html, bookmark, 0)
def assertShareInfoCount(self, html: str, bookmark: Bookmark, count=1):
self.assertInHTML(f'''
self.assertInHTML(
f"""
<span>Shared by
<a href="?user={bookmark.owner.username}">{bookmark.owner.username}</a>
</span>
''', html, count=count)
""",
html,
count=count,
)
def assertFaviconVisible(self, html: str, bookmark: Bookmark):
self.assertFaviconCount(html, bookmark, 1)
@@ -88,47 +118,68 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
self.assertFaviconCount(html, bookmark, 0)
def assertFaviconCount(self, html: str, bookmark: Bookmark, count=1):
self.assertInHTML(f'''
self.assertInHTML(
f"""
<img src="/static/{bookmark.favicon_file}" alt="">
''', html, count=count)
""",
html,
count=count,
)
def assertBookmarkURLCount(self, html: str, bookmark: Bookmark, link_target: str = '_blank', count=0):
self.assertInHTML(f'''
def assertBookmarkURLCount(
self, html: str, bookmark: Bookmark, link_target: str = "_blank", count=0
):
self.assertInHTML(
f"""
<div class="url-path truncate">
<a href="{bookmark.url}" target="{link_target}" rel="noopener"
class="url-display text-sm">
{bookmark.url}
</a>
</div>
''', html, count)
""",
html,
count,
)
def assertBookmarkURLVisible(self, html: str, bookmark: Bookmark):
self.assertBookmarkURLCount(html, bookmark, count=1)
def assertBookmarkURLHidden(self, html: str, bookmark: Bookmark, link_target: str = '_blank'):
def assertBookmarkURLHidden(
self, html: str, bookmark: Bookmark, link_target: str = "_blank"
):
self.assertBookmarkURLCount(html, bookmark, count=0)
def assertNotes(self, html: str, notes_html: str, count=1):
self.assertInHTML(f'''
self.assertInHTML(
f"""
<div class="notes bg-gray text-gray-dark">
<div class="notes-content">
{notes_html}
</div>
</div>
''', html, count=count)
""",
html,
count=count,
)
def assertNotesToggle(self, html: str, count=1):
self.assertInHTML(f'''
self.assertInHTML(
f"""
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-note"></use>
</svg>
Notes
</button>
''', html, count=count)
""",
html,
count=count,
)
def assertUnshareButton(self, html: str, bookmark: Bookmark, count=1):
self.assertInHTML(f'''
self.assertInHTML(
f"""
<button type="submit" name="unshare" value="{bookmark.id}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button confirm-icon="ld-icon-unshare" confirm-question="Unshare?">
@@ -137,10 +188,14 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
</svg>
Shared
</button>
''', html, count=count)
""",
html,
count=count,
)
def assertMarkAsReadButton(self, html: str, bookmark: Bookmark, count=1):
self.assertInHTML(f'''
self.assertInHTML(
f"""
<button type="submit" name="mark_as_read" value="{bookmark.id}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button confirm-icon="ld-icon-read" confirm-question="Mark as read?">
@@ -149,12 +204,19 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
</svg>
Unread
</button>
''', html, count=count)
""",
html,
count=count,
)
def render_template(self,
url='/bookmarks',
context_type: Type[contexts.BookmarkListContext] = contexts.ActiveBookmarkListContext,
user: User | AnonymousUser = None) -> str:
def render_template(
self,
url="/bookmarks",
context_type: Type[
contexts.BookmarkListContext
] = contexts.ActiveBookmarkListContext,
user: User | AnonymousUser = None,
) -> str:
rf = RequestFactory()
request = rf.get(url)
request.user = user or self.get_or_create_test_user()
@@ -162,14 +224,14 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
middleware(request)
bookmark_list_context = context_type(request)
context = RequestContext(request, {'bookmark_list': bookmark_list_context})
context = RequestContext(request, {"bookmark_list": bookmark_list_context})
template = Template(
"{% include 'bookmarks/bookmark_list.html' %}"
)
template = Template("{% include 'bookmarks/bookmark_list.html' %}")
return template.render(context)
def setup_date_format_test(self, date_display_setting: str, web_archive_url: str = ''):
def setup_date_format_test(
self, date_display_setting: str, web_archive_url: str = ""
):
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = web_archive_url
@@ -180,38 +242,46 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
return bookmark
def test_should_respect_absolute_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE)
bookmark = self.setup_date_format_test(
UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE
)
html = self.render_template()
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
formatted_date = formats.date_format(bookmark.date_added, "SHORT_DATE_FORMAT")
self.assertDateLabel(html, formatted_date)
def test_should_render_web_archive_link_with_absolute_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE,
'https://web.archive.org/web/20210811214511/https://wanikani.com/')
bookmark = self.setup_date_format_test(
UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE,
"https://web.archive.org/web/20210811214511/https://wanikani.com/",
)
html = self.render_template()
formatted_date = formats.date_format(bookmark.date_added, 'SHORT_DATE_FORMAT')
formatted_date = formats.date_format(bookmark.date_added, "SHORT_DATE_FORMAT")
self.assertWebArchiveLink(html, formatted_date, bookmark.web_archive_snapshot_url)
self.assertWebArchiveLink(
html, formatted_date, bookmark.web_archive_snapshot_url
)
def test_should_respect_relative_date_setting(self):
self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE)
html = self.render_template()
self.assertDateLabel(html, '1 week ago')
self.assertDateLabel(html, "1 week ago")
def test_should_render_web_archive_link_with_relative_date_setting(self):
bookmark = self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
'https://web.archive.org/web/20210811214511/https://wanikani.com/')
bookmark = self.setup_date_format_test(
UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
"https://web.archive.org/web/20210811214511/https://wanikani.com/",
)
html = self.render_template()
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url)
self.assertWebArchiveLink(html, "1 week ago", bookmark.web_archive_snapshot_url)
def test_bookmark_link_target_should_be_blank_by_default(self):
bookmark = self.setup_bookmark()
html = self.render_template()
self.assertBookmarksLink(html, bookmark, link_target='_blank')
self.assertBookmarksLink(html, bookmark, link_target="_blank")
def test_bookmark_link_target_should_respect_user_profile(self):
profile = self.get_or_create_test_user().profile
@@ -221,17 +291,19 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark()
html = self.render_template()
self.assertBookmarksLink(html, bookmark, link_target='_self')
self.assertBookmarksLink(html, bookmark, link_target="_self")
def test_web_archive_link_target_should_be_blank_by_default(self):
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = 'https://example.com'
bookmark.web_archive_snapshot_url = "https://example.com"
bookmark.save()
html = self.render_template()
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank')
self.assertWebArchiveLink(
html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_blank"
)
def test_web_archive_link_target_should_respect_user_profile(self):
profile = self.get_or_create_test_user().profile
@@ -240,12 +312,14 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = 'https://example.com'
bookmark.web_archive_snapshot_url = "https://example.com"
bookmark.save()
html = self.render_template()
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_self')
self.assertWebArchiveLink(
html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_self"
)
def test_should_reflect_unread_state_as_css_class(self):
self.setup_bookmark(unread=True)
@@ -281,7 +355,9 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
self.assertNoShareInfo(html, bookmark)
def test_show_share_info_for_non_owned_bookmarks(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
other_user = User.objects.create_user(
"otheruser", "otheruser@example.com", "password123"
)
other_user.profile.enable_sharing = True
other_user.profile.save()
@@ -292,25 +368,32 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
self.assertShareInfo(html, bookmark)
def test_share_info_user_link_keeps_query_params(self):
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
other_user = User.objects.create_user(
"otheruser", "otheruser@example.com", "password123"
)
other_user.profile.enable_sharing = True
other_user.profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=True, title='foo')
html = self.render_template(url='/bookmarks?q=foo', context_type=contexts.SharedBookmarkListContext)
bookmark = self.setup_bookmark(user=other_user, shared=True, title="foo")
html = self.render_template(
url="/bookmarks?q=foo", context_type=contexts.SharedBookmarkListContext
)
self.assertInHTML(f'''
self.assertInHTML(
f"""
<span>Shared by
<a href="?q=foo&user={bookmark.owner.username}">{bookmark.owner.username}</a>
</span>
''', html)
""",
html,
)
def test_favicon_should_be_visible_when_favicons_enabled(self):
profile = self.get_or_create_test_user().profile
profile.enable_favicons = True
profile.save()
bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
bookmark = self.setup_bookmark(favicon_file="https_example_com.png")
html = self.render_template()
self.assertFaviconVisible(html, bookmark)
@@ -320,7 +403,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
profile.enable_favicons = True
profile.save()
bookmark = self.setup_bookmark(favicon_file='')
bookmark = self.setup_bookmark(favicon_file="")
html = self.render_template()
self.assertFaviconHidden(html, bookmark)
@@ -330,7 +413,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
profile.enable_favicons = False
profile.save()
bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
bookmark = self.setup_bookmark(favicon_file="https_example_com.png")
html = self.render_template()
self.assertFaviconHidden(html, bookmark)
@@ -428,21 +511,23 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
self.setup_bookmark()
html = self.render_template()
self.assertNotes(html, '', 0)
self.assertNotes(html, "", 0)
self.assertNotesToggle(html, 0)
def test_with_notes(self):
self.setup_bookmark(notes='Test note')
self.setup_bookmark(notes="Test note")
html = self.render_template()
note_html = '<p>Test note</p>'
note_html = "<p>Test note</p>"
self.assertNotes(html, note_html, 1)
def test_note_renders_markdown(self):
self.setup_bookmark(notes='**Example:** `print("Hello world!")`')
html = self.render_template()
note_html = '<p><strong>Example:</strong> <code>print("Hello world!")</code></p>'
note_html = (
'<p><strong>Example:</strong> <code>print("Hello world!")</code></p>'
)
self.assertNotes(html, note_html, 1)
def test_note_cleans_html(self):
@@ -453,7 +538,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
self.assertNotes(html, note_html, 1)
def test_notes_are_hidden_initially_by_default(self):
self.setup_bookmark(notes='Test note')
self.setup_bookmark(notes="Test note")
html = collapse_whitespace(self.render_template())
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="1">', html)
@@ -463,7 +548,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
profile.permanent_notes = False
profile.save()
self.setup_bookmark(notes='Test note')
self.setup_bookmark(notes="Test note")
html = collapse_whitespace(self.render_template())
self.assertIn('<ul class="bookmark-list" data-bookmarks-total="1">', html)
@@ -473,13 +558,15 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
profile.permanent_notes = True
profile.save()
self.setup_bookmark(notes='Test note')
self.setup_bookmark(notes="Test note")
html = collapse_whitespace(self.render_template())
self.assertIn('<ul class="bookmark-list show-notes" data-bookmarks-total="1">', html)
self.assertIn(
'<ul class="bookmark-list show-notes" data-bookmarks-total="1">', html
)
def test_toggle_notes_is_visible_by_default(self):
self.setup_bookmark(notes='Test note')
self.setup_bookmark(notes="Test note")
html = self.render_template()
self.assertNotesToggle(html, 1)
@@ -489,7 +576,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
profile.permanent_notes = False
profile.save()
self.setup_bookmark(notes='Test note')
self.setup_bookmark(notes="Test note")
html = self.render_template()
self.assertNotesToggle(html, 1)
@@ -499,7 +586,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
profile.permanent_notes = True
profile.save()
self.setup_bookmark(notes='Test note')
self.setup_bookmark(notes="Test note")
html = self.render_template()
self.assertNotesToggle(html, 0)
@@ -512,25 +599,35 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin):
bookmark = self.setup_bookmark()
bookmark.date_added = timezone.now() - relativedelta(days=8)
bookmark.web_archive_snapshot_url = 'https://web.archive.org/web/20230531200136/https://example.com'
bookmark.web_archive_snapshot_url = (
"https://web.archive.org/web/20230531200136/https://example.com"
)
bookmark.notes = '**Example:** `print("Hello world!")`'
bookmark.favicon_file = 'https_example_com.png'
bookmark.favicon_file = "https_example_com.png"
bookmark.shared = True
bookmark.unread = True
bookmark.save()
html = self.render_template(context_type=contexts.SharedBookmarkListContext, user=AnonymousUser())
self.assertBookmarksLink(html, bookmark, link_target='_blank')
self.assertWebArchiveLink(html, '1 week ago', bookmark.web_archive_snapshot_url, link_target='_blank')
html = self.render_template(
context_type=contexts.SharedBookmarkListContext, user=AnonymousUser()
)
self.assertBookmarksLink(html, bookmark, link_target="_blank")
self.assertWebArchiveLink(
html, "1 week ago", bookmark.web_archive_snapshot_url, link_target="_blank"
)
self.assertNoBookmarkActions(html, bookmark)
self.assertShareInfo(html, bookmark)
self.assertMarkAsReadButton(html, bookmark, count=0)
self.assertUnshareButton(html, bookmark, count=0)
note_html = '<p><strong>Example:</strong> <code>print("Hello world!")</code></p>'
note_html = (
'<p><strong>Example:</strong> <code>print("Hello world!")</code></p>'
)
self.assertNotes(html, note_html, 1)
self.assertFaviconVisible(html, bookmark)
def test_empty_state(self):
html = self.render_template()
self.assertInHTML('<p class="empty-title h5">You have no bookmarks yet</p>', html)
self.assertInHTML(
'<p class="empty-title h5">You have no bookmarks yet</p>', html
)

View File

@@ -6,11 +6,17 @@ from bookmarks.models import Bookmark
class BookmarkTestCase(TestCase):
def test_bookmark_resolved_title(self):
bookmark = Bookmark(title='Custom title', website_title='Website title', url='https://example.com')
self.assertEqual(bookmark.resolved_title, 'Custom title')
bookmark = Bookmark(
title="Custom title",
website_title="Website title",
url="https://example.com",
)
self.assertEqual(bookmark.resolved_title, "Custom title")
bookmark = Bookmark(title='', website_title='Website title', url='https://example.com')
self.assertEqual(bookmark.resolved_title, 'Website title')
bookmark = Bookmark(
title="", website_title="Website title", url="https://example.com"
)
self.assertEqual(bookmark.resolved_title, "Website title")
bookmark = Bookmark(title='', website_title='', url='https://example.com')
self.assertEqual(bookmark.resolved_title, 'https://example.com')
bookmark = Bookmark(title="", website_title="", url="https://example.com")
self.assertEqual(bookmark.resolved_title, "https://example.com")

View File

@@ -7,9 +7,21 @@ from django.utils import timezone
from bookmarks.models import Bookmark, Tag
from bookmarks.services import tasks
from bookmarks.services import website_loader
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks, mark_bookmarks_as_read, \
mark_bookmarks_as_unread, share_bookmarks, unshare_bookmarks
from bookmarks.services.bookmarks import (
create_bookmark,
update_bookmark,
archive_bookmark,
archive_bookmarks,
unarchive_bookmark,
unarchive_bookmarks,
delete_bookmarks,
tag_bookmarks,
untag_bookmarks,
mark_bookmarks_as_read,
mark_bookmarks_as_unread,
share_bookmarks,
unshare_bookmarks,
)
from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -22,36 +34,48 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.get_or_create_test_user()
def test_create_should_update_website_metadata(self):
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
with patch.object(
website_loader, "load_website_metadata"
) as mock_load_website_metadata:
expected_metadata = WebsiteMetadata(
'https://example.com',
'Website title',
'Website description'
"https://example.com", "Website title", "Website description"
)
mock_load_website_metadata.return_value = expected_metadata
bookmark_data = Bookmark(url='https://example.com',
title='Updated Title',
description='Updated description',
unread=True,
shared=True,
is_archived=True)
created_bookmark = create_bookmark(bookmark_data, '', self.get_or_create_test_user())
bookmark_data = Bookmark(
url="https://example.com",
title="Updated Title",
description="Updated description",
unread=True,
shared=True,
is_archived=True,
)
created_bookmark = create_bookmark(
bookmark_data, "", self.get_or_create_test_user()
)
created_bookmark.refresh_from_db()
self.assertEqual(expected_metadata.title, created_bookmark.website_title)
self.assertEqual(expected_metadata.description, created_bookmark.website_description)
self.assertEqual(
expected_metadata.description, created_bookmark.website_description
)
def test_create_should_update_existing_bookmark_with_same_url(self):
original_bookmark = self.setup_bookmark(url='https://example.com', unread=False, shared=False)
bookmark_data = Bookmark(url='https://example.com',
title='Updated Title',
description='Updated description',
notes='Updated notes',
unread=True,
shared=True,
is_archived=True)
updated_bookmark = create_bookmark(bookmark_data, '', self.get_or_create_test_user())
original_bookmark = self.setup_bookmark(
url="https://example.com", unread=False, shared=False
)
bookmark_data = Bookmark(
url="https://example.com",
title="Updated Title",
description="Updated description",
notes="Updated notes",
unread=True,
shared=True,
is_archived=True,
)
updated_bookmark = create_bookmark(
bookmark_data, "", self.get_or_create_test_user()
)
self.assertEqual(Bookmark.objects.count(), 1)
self.assertEqual(updated_bookmark.id, original_bookmark.id)
@@ -64,75 +88,91 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertFalse(updated_bookmark.is_archived)
def test_create_should_create_web_archive_snapshot(self):
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
bookmark_data = Bookmark(url='https://example.com')
bookmark = create_bookmark(bookmark_data, 'tag1,tag2', self.user)
with patch.object(
tasks, "create_web_archive_snapshot"
) as mock_create_web_archive_snapshot:
bookmark_data = Bookmark(url="https://example.com")
bookmark = create_bookmark(bookmark_data, "tag1,tag2", self.user)
mock_create_web_archive_snapshot.assert_called_once_with(self.user, bookmark, False)
mock_create_web_archive_snapshot.assert_called_once_with(
self.user, bookmark, False
)
def test_create_should_load_favicon(self):
with patch.object(tasks, 'load_favicon') as mock_load_favicon:
bookmark_data = Bookmark(url='https://example.com')
bookmark = create_bookmark(bookmark_data, 'tag1,tag2', self.user)
with patch.object(tasks, "load_favicon") as mock_load_favicon:
bookmark_data = Bookmark(url="https://example.com")
bookmark = create_bookmark(bookmark_data, "tag1,tag2", self.user)
mock_load_favicon.assert_called_once_with(self.user, bookmark)
def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
with patch.object(
tasks, "create_web_archive_snapshot"
) as mock_create_web_archive_snapshot:
bookmark = self.setup_bookmark()
bookmark.url = 'https://example.com/updated'
update_bookmark(bookmark, 'tag1,tag2', self.user)
bookmark.url = "https://example.com/updated"
update_bookmark(bookmark, "tag1,tag2", self.user)
mock_create_web_archive_snapshot.assert_called_once_with(self.user, bookmark, True)
mock_create_web_archive_snapshot.assert_called_once_with(
self.user, bookmark, True
)
def test_update_should_not_create_web_archive_snapshot_if_url_did_not_change(self):
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
with patch.object(
tasks, "create_web_archive_snapshot"
) as mock_create_web_archive_snapshot:
bookmark = self.setup_bookmark()
bookmark.title = 'updated title'
update_bookmark(bookmark, 'tag1,tag2', self.user)
bookmark.title = "updated title"
update_bookmark(bookmark, "tag1,tag2", self.user)
mock_create_web_archive_snapshot.assert_not_called()
def test_update_should_update_website_metadata_if_url_did_change(self):
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
with patch.object(
website_loader, "load_website_metadata"
) as mock_load_website_metadata:
expected_metadata = WebsiteMetadata(
'https://example.com/updated',
'Updated website title',
'Updated website description'
"https://example.com/updated",
"Updated website title",
"Updated website description",
)
mock_load_website_metadata.return_value = expected_metadata
bookmark = self.setup_bookmark()
bookmark.url = 'https://example.com/updated'
update_bookmark(bookmark, 'tag1,tag2', self.user)
bookmark.url = "https://example.com/updated"
update_bookmark(bookmark, "tag1,tag2", self.user)
bookmark.refresh_from_db()
mock_load_website_metadata.assert_called_once()
self.assertEqual(expected_metadata.title, bookmark.website_title)
self.assertEqual(expected_metadata.description, bookmark.website_description)
self.assertEqual(
expected_metadata.description, bookmark.website_description
)
def test_update_should_not_update_website_metadata_if_url_did_not_change(self):
with patch.object(website_loader, 'load_website_metadata') as mock_load_website_metadata:
with patch.object(
website_loader, "load_website_metadata"
) as mock_load_website_metadata:
bookmark = self.setup_bookmark()
bookmark.title = 'updated title'
update_bookmark(bookmark, 'tag1,tag2', self.user)
bookmark.title = "updated title"
update_bookmark(bookmark, "tag1,tag2", self.user)
mock_load_website_metadata.assert_not_called()
def test_update_should_update_favicon(self):
with patch.object(tasks, 'load_favicon') as mock_load_favicon:
with patch.object(tasks, "load_favicon") as mock_load_favicon:
bookmark = self.setup_bookmark()
bookmark.title = 'updated title'
update_bookmark(bookmark, 'tag1,tag2', self.user)
bookmark.title = "updated title"
update_bookmark(bookmark, "tag1,tag2", self.user)
mock_load_favicon.assert_called_once_with(self.user, bookmark)
def test_archive_bookmark(self):
bookmark = Bookmark(
url='https://example.com',
url="https://example.com",
date_added=timezone.now(),
date_modified=timezone.now(),
owner=self.user
owner=self.user,
)
bookmark.save()
@@ -146,7 +186,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
def test_unarchive_bookmark(self):
bookmark = Bookmark(
url='https://example.com',
url="https://example.com",
date_added=timezone.now(),
date_modified=timezone.now(),
owner=self.user,
@@ -165,7 +205,9 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
archive_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
archive_bookmarks(
[bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()
)
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
@@ -183,12 +225,17 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).is_archived)
def test_archive_bookmarks_should_only_archive_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"
)
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
inaccessible_bookmark = self.setup_bookmark(user=other_user)
archive_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
archive_bookmarks(
[bookmark1.id, bookmark2.id, inaccessible_bookmark.id],
self.get_or_create_test_user(),
)
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
@@ -199,7 +246,10 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
archive_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
archive_bookmarks(
[str(bookmark1.id), bookmark2.id, str(bookmark3.id)],
self.get_or_create_test_user(),
)
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
@@ -210,7 +260,9 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(is_archived=True)
bookmark3 = self.setup_bookmark(is_archived=True)
unarchive_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
unarchive_bookmarks(
[bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()
)
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
@@ -221,19 +273,26 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(is_archived=True)
bookmark3 = self.setup_bookmark(is_archived=True)
unarchive_bookmarks([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
unarchive_bookmarks(
[bookmark1.id, bookmark3.id], self.get_or_create_test_user()
)
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).is_archived)
def test_unarchive_bookmarks_should_only_unarchive_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"
)
bookmark1 = self.setup_bookmark(is_archived=True)
bookmark2 = self.setup_bookmark(is_archived=True)
inaccessible_bookmark = self.setup_bookmark(is_archived=True, user=other_user)
unarchive_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
unarchive_bookmarks(
[bookmark1.id, bookmark2.id, inaccessible_bookmark.id],
self.get_or_create_test_user(),
)
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
@@ -244,7 +303,10 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(is_archived=True)
bookmark3 = self.setup_bookmark(is_archived=True)
unarchive_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
unarchive_bookmarks(
[str(bookmark1.id), bookmark2.id, str(bookmark3.id)],
self.get_or_create_test_user(),
)
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).is_archived)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).is_archived)
@@ -255,7 +317,9 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
delete_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
delete_bookmarks(
[bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()
)
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())
@@ -273,23 +337,32 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertIsNone(Bookmark.objects.filter(id=bookmark3.id).first())
def test_delete_bookmarks_should_only_delete_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"
)
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
inaccessible_bookmark = self.setup_bookmark(user=other_user)
delete_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
delete_bookmarks(
[bookmark1.id, bookmark2.id, inaccessible_bookmark.id],
self.get_or_create_test_user(),
)
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())
self.assertIsNotNone(Bookmark.objects.filter(id=inaccessible_bookmark.id).first())
self.assertIsNotNone(
Bookmark.objects.filter(id=inaccessible_bookmark.id).first()
)
def test_delete_bookmarks_should_accept_mix_of_int_and_string_ids(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
delete_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
delete_bookmarks(
[bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()
)
self.assertIsNone(Bookmark.objects.filter(id=bookmark1.id).first())
self.assertIsNone(Bookmark.objects.filter(id=bookmark2.id).first())
@@ -302,8 +375,11 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user())
tag_bookmarks(
[bookmark1.id, bookmark2.id, bookmark3.id],
f"{tag1.name},{tag2.name}",
self.get_or_create_test_user(),
)
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
@@ -318,7 +394,11 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark()
bookmark3 = self.setup_bookmark()
tag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], 'tag1,tag2', self.get_or_create_test_user())
tag_bookmarks(
[bookmark1.id, bookmark2.id, bookmark3.id],
"tag1,tag2",
self.get_or_create_test_user(),
)
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
@@ -326,8 +406,8 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(2, Tag.objects.count())
tag1 = Tag.objects.filter(name='tag1').first()
tag2 = Tag.objects.filter(name='tag2').first()
tag1 = Tag.objects.filter(name="tag1").first()
tag2 = Tag.objects.filter(name="tag2").first()
self.assertIsNotNone(tag1)
self.assertIsNotNone(tag2)
@@ -336,6 +416,31 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
def test_tag_bookmarks_should_handle_existing_relationships(self):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
bookmark1 = self.setup_bookmark(tags=[tag1])
bookmark2 = self.setup_bookmark(tags=[tag1])
bookmark3 = self.setup_bookmark(tags=[tag1])
BookmarkToTagRelationShip = Bookmark.tags.through
self.assertEqual(3, BookmarkToTagRelationShip.objects.count())
tag_bookmarks(
[bookmark1.id, bookmark2.id, bookmark3.id],
f"{tag1.name},{tag2.name}",
self.get_or_create_test_user(),
)
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
bookmark3.refresh_from_db()
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
self.assertEqual(6, BookmarkToTagRelationShip.objects.count())
def test_tag_bookmarks_should_only_tag_specified_bookmarks(self):
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
@@ -343,7 +448,11 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
tag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name},{tag2.name}', self.get_or_create_test_user())
tag_bookmarks(
[bookmark1.id, bookmark3.id],
f"{tag1.name},{tag2.name}",
self.get_or_create_test_user(),
)
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
@@ -354,15 +463,20 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertCountEqual(bookmark3.tags.all(), [tag1, tag2])
def test_tag_bookmarks_should_only_tag_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"
)
bookmark1 = self.setup_bookmark()
bookmark2 = self.setup_bookmark()
inaccessible_bookmark = self.setup_bookmark(user=other_user)
tag1 = self.setup_tag()
tag2 = self.setup_tag()
tag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user())
tag_bookmarks(
[bookmark1.id, bookmark2.id, inaccessible_bookmark.id],
f"{tag1.name},{tag2.name}",
self.get_or_create_test_user(),
)
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
@@ -379,8 +493,11 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
tag1 = self.setup_tag()
tag2 = self.setup_tag()
tag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user())
tag_bookmarks(
[str(bookmark1.id), bookmark2.id, str(bookmark3.id)],
f"{tag1.name},{tag2.name}",
self.get_or_create_test_user(),
)
self.assertCountEqual(bookmark1.tags.all(), [tag1, tag2])
self.assertCountEqual(bookmark2.tags.all(), [tag1, tag2])
@@ -393,8 +510,11 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
untag_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user())
untag_bookmarks(
[bookmark1.id, bookmark2.id, bookmark3.id],
f"{tag1.name},{tag2.name}",
self.get_or_create_test_user(),
)
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
@@ -411,7 +531,11 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
untag_bookmarks([bookmark1.id, bookmark3.id], f'{tag1.name},{tag2.name}', self.get_or_create_test_user())
untag_bookmarks(
[bookmark1.id, bookmark3.id],
f"{tag1.name},{tag2.name}",
self.get_or_create_test_user(),
)
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
@@ -422,15 +546,20 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertCountEqual(bookmark3.tags.all(), [])
def test_untag_bookmarks_should_only_tag_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"
)
tag1 = self.setup_tag()
tag2 = self.setup_tag()
bookmark1 = self.setup_bookmark(tags=[tag1, tag2])
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
inaccessible_bookmark = self.setup_bookmark(user=other_user, tags=[tag1, tag2])
untag_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user())
untag_bookmarks(
[bookmark1.id, bookmark2.id, inaccessible_bookmark.id],
f"{tag1.name},{tag2.name}",
self.get_or_create_test_user(),
)
bookmark1.refresh_from_db()
bookmark2.refresh_from_db()
@@ -447,8 +576,11 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(tags=[tag1, tag2])
bookmark3 = self.setup_bookmark(tags=[tag1, tag2])
untag_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], f'{tag1.name},{tag2.name}',
self.get_or_create_test_user())
untag_bookmarks(
[str(bookmark1.id), bookmark2.id, str(bookmark3.id)],
f"{tag1.name},{tag2.name}",
self.get_or_create_test_user(),
)
self.assertCountEqual(bookmark1.tags.all(), [])
self.assertCountEqual(bookmark2.tags.all(), [])
@@ -459,7 +591,9 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(unread=True)
bookmark3 = self.setup_bookmark(unread=True)
mark_bookmarks_as_read([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
mark_bookmarks_as_read(
[bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()
)
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
@@ -470,19 +604,26 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(unread=True)
bookmark3 = self.setup_bookmark(unread=True)
mark_bookmarks_as_read([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
mark_bookmarks_as_read(
[bookmark1.id, bookmark3.id], self.get_or_create_test_user()
)
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).unread)
def test_mark_bookmarks_as_read_should_only_update_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"
)
bookmark1 = self.setup_bookmark(unread=True)
bookmark2 = self.setup_bookmark(unread=True)
inaccessible_bookmark = self.setup_bookmark(unread=True, user=other_user)
mark_bookmarks_as_read([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
mark_bookmarks_as_read(
[bookmark1.id, bookmark2.id, inaccessible_bookmark.id],
self.get_or_create_test_user(),
)
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
@@ -493,7 +634,10 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(unread=True)
bookmark3 = self.setup_bookmark(unread=True)
mark_bookmarks_as_read([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
mark_bookmarks_as_read(
[str(bookmark1.id), bookmark2.id, str(bookmark3.id)],
self.get_or_create_test_user(),
)
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
@@ -504,7 +648,9 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(unread=False)
bookmark3 = self.setup_bookmark(unread=False)
mark_bookmarks_as_unread([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
mark_bookmarks_as_unread(
[bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()
)
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
@@ -515,19 +661,26 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(unread=False)
bookmark3 = self.setup_bookmark(unread=False)
mark_bookmarks_as_unread([bookmark1.id, bookmark3.id], self.get_or_create_test_user())
mark_bookmarks_as_unread(
[bookmark1.id, bookmark3.id], self.get_or_create_test_user()
)
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).unread)
def test_mark_bookmarks_as_unread_should_only_update_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"
)
bookmark1 = self.setup_bookmark(unread=False)
bookmark2 = self.setup_bookmark(unread=False)
inaccessible_bookmark = self.setup_bookmark(unread=False, user=other_user)
mark_bookmarks_as_unread([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
mark_bookmarks_as_unread(
[bookmark1.id, bookmark2.id, inaccessible_bookmark.id],
self.get_or_create_test_user(),
)
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
@@ -538,7 +691,10 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(unread=False)
bookmark3 = self.setup_bookmark(unread=False)
mark_bookmarks_as_unread([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
mark_bookmarks_as_unread(
[str(bookmark1.id), bookmark2.id, str(bookmark3.id)],
self.get_or_create_test_user(),
)
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).unread)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).unread)
@@ -549,7 +705,9 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(shared=False)
bookmark3 = self.setup_bookmark(shared=False)
share_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
share_bookmarks(
[bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()
)
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
@@ -567,12 +725,17 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertTrue(Bookmark.objects.get(id=bookmark3.id).shared)
def test_share_bookmarks_should_only_update_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"
)
bookmark1 = self.setup_bookmark(shared=False)
bookmark2 = self.setup_bookmark(shared=False)
inaccessible_bookmark = self.setup_bookmark(shared=False, user=other_user)
share_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
share_bookmarks(
[bookmark1.id, bookmark2.id, inaccessible_bookmark.id],
self.get_or_create_test_user(),
)
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
@@ -583,7 +746,10 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(shared=False)
bookmark3 = self.setup_bookmark(shared=False)
share_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
share_bookmarks(
[str(bookmark1.id), bookmark2.id, str(bookmark3.id)],
self.get_or_create_test_user(),
)
self.assertTrue(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertTrue(Bookmark.objects.get(id=bookmark2.id).shared)
@@ -594,7 +760,9 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(shared=True)
bookmark3 = self.setup_bookmark(shared=True)
unshare_bookmarks([bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user())
unshare_bookmarks(
[bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()
)
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
@@ -612,12 +780,17 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
def test_unshare_bookmarks_should_only_update_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"
)
bookmark1 = self.setup_bookmark(shared=True)
bookmark2 = self.setup_bookmark(shared=True)
inaccessible_bookmark = self.setup_bookmark(shared=True, user=other_user)
unshare_bookmarks([bookmark1.id, bookmark2.id, inaccessible_bookmark.id], self.get_or_create_test_user())
unshare_bookmarks(
[bookmark1.id, bookmark2.id, inaccessible_bookmark.id],
self.get_or_create_test_user(),
)
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
@@ -628,7 +801,10 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
bookmark2 = self.setup_bookmark(shared=True)
bookmark3 = self.setup_bookmark(shared=True)
unshare_bookmarks([str(bookmark1.id), bookmark2.id, str(bookmark3.id)], self.get_or_create_test_user())
unshare_bookmarks(
[str(bookmark1.id), bookmark2.id, str(bookmark3.id)],
self.get_or_create_test_user(),
)
self.assertFalse(Bookmark.objects.get(id=bookmark1.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)

Some files were not shown because too many files have changed in this diff Show More