Compare commits

...

163 commits

Author SHA1 Message Date
Mouse Reeve e3471fcc35
Merge pull request #2148 from hughrun/quotes
add page numbers to comment and quote statuses
2022-06-10 17:35:15 -07:00
Mouse Reeve 2993989d27
Merge pull request #2149 from cincodenada/preview-generation-memory
Update preview image generation to only query ids
2022-06-10 17:25:05 -07:00
Joel Bradshaw 7f5d47a36f Use values_list with flat, yay! 2022-06-07 23:15:34 -07:00
Mouse Reeve 3aa159bc89
Merge branch 'main' into preview-generation-memory 2022-06-05 18:39:59 -07:00
Mouse Reeve 8d082bc189
Merge branch 'main' into quotes 2022-06-05 15:42:01 -07:00
Mouse Reeve 08231f52ff
Merge pull request #2150 from cincodenada/fix-pylint
Fix pylint config for pylint 2.14.0
2022-06-05 15:41:32 -07:00
Joel Bradshaw 6584cb6404 Go back to one requirements.txt, simplify workflow
The workflow can now use .pylintrc and the pylint req in
requirements.txt rather than having the options inline and installing it
separately
2022-06-05 14:57:42 -07:00
Joel Bradshaw b3603c04c5 Add pylint to bw-dev
Because pylint requires the app to be fully parseable with all its
dependencies, we run it in the web container, and add pylint as a dev
dependency.
2022-06-05 14:49:21 -07:00
Joel Bradshaw 6d6ab9a531 Add .pylintrc with fixes for new pylint version 2022-06-05 14:38:03 -07:00
Joel Bradshaw b744ff7836 Run black 2022-06-05 13:40:01 -07:00
Joel Bradshaw 482005f304 Update preview image generation to only query ids
Previously we were querying the full book objects just to get a list of
id's, which is much slower and also takes a lot more memory, which can
cause the process to be killed on memory-limited machines with a large
number of books.

Instead, since we're just dispatching jobs here, we can just ask for the
id's, which is faster and much more practical memory-wise.

The map is a little annoying, I didn't see a way to directly get just a
list of the value of one field, so we have to get a list of
dictionairies with one key and then pull that key out. Whatevs.
2022-06-05 13:07:44 -07:00
Hugh Rundle 4de9989d8e add page numbers to comment and quote statuses
This adds the page number for quote and comment statuses where a page number is provided:

- all ActivityPub posts
- Explore cards for comments (quotes already have the page number)

This responds to #2136
2022-06-05 16:02:25 +10:00
Mouse Reeve d5bbb759e0
Merge pull request #2146 from bookwyrm-social/locales
Updates locales for stopped reading strings
2022-05-31 17:09:44 -07:00
Mouse Reeve 077c9bfe46 Updates locales for stopped reading strings 2022-05-31 16:53:46 -07:00
Mouse Reeve 9d5e113b92
Merge pull request #2145 from bookwyrm-social/about-layout
Clip column in about page
2022-05-31 14:05:43 -07:00
Mouse Reeve 4c050d0999
Merge pull request #2141 from bookwyrm-social/ol-search-rank
Use relative list order ranking in OpenLibrary search
2022-05-31 13:11:49 -07:00
Mouse Reeve 20f452ebf4 Clip column in about page
Text in the superlatives section can cause this column to expand outside
the container.
2022-05-31 12:23:59 -07:00
Mouse Reeve 374fdcf467 Use relative list order ranking in openlibrary search
Set OpenLibrary search condifidence based on the provided result order,
just using 1/(list index), so the first has rank 1, the second 0.5, the
third 0.33, et cetera.
2022-05-31 10:22:49 -07:00
Mouse Reeve 355e7039f0
Merge pull request #2139 from bookwyrm-social/search-refactor
Search refactor
2022-05-31 10:22:17 -07:00
Mouse Reeve c3b35760a2 Updates test mocks for remote search 2022-05-31 09:37:54 -07:00
Mouse Reeve 969db13ff2 Safely return None in remote search return_first 2022-05-31 08:49:23 -07:00
Mouse Reeve 05fd30cfcf Pylint fixes in connector tests 2022-05-31 08:37:07 -07:00
Mouse Reeve 5e99002aad Raise priority for external connectors in initdb
By default, OpenLibrary and Inventaire were prioritzed below other
BookWyrm nodes. In practice, people have gotten better search results
from these connectors, hence the change. With the search refactor, this
has much less impact, but it will show these search results higher in
the list.

If the results page shows all the connectors' results integrated, this
field should be removed entirely.
2022-05-31 08:25:02 -07:00
Mouse Reeve a053f20961 Re-implements return first option
Since we get all the results quickly now, this aggregates all the
results that came back and sorts them by confidence, and returns the
highest confidence result. The confidences aren't great on free text
search, but conceptually that's how it should work at least.

It may make sense to aggregate the search results in all contexts, but
I'll propose that in a separate PR.
2022-05-31 08:20:59 -07:00
Mouse Reeve 98ed03b6b4 Python formatting and test update 2022-05-30 17:00:34 -07:00
Mouse Reeve 83ee5a756f Filter intentaire results by confidence 2022-05-30 16:42:37 -07:00
Mouse Reeve af19d728d2 Removes outdated unit tests 2022-05-30 16:16:10 -07:00
Mouse Reeve 87fe984462 Combines search formatter and parser function
The parser was extracting the list of search results from the json
object returned by the search endpoint, and the formatter was converting
an individual json entry into a SearchResult object. This just merged
them into one function, because they are never used separately.
2022-05-30 12:52:31 -07:00
Mouse Reeve 525e2a591d More error handing
Adds logging and error handling for some of the numerous ways a request
could fail (the remote site is down, the url is blocked, etc).

I also have the results boxes open by default, which makes it more
legible imo.
2022-05-30 12:40:13 -07:00
Mouse Reeve 45f2199c71 Gather and wait on async requests
This sends out the request tasks all at once and then aggregates the
results, instead of just running them one after another asynchronously.
2022-05-30 12:05:22 -07:00
Mouse Reeve 5e81ec75fb Set request headers in async search get request
Gotta ask for json
2022-05-30 11:19:16 -07:00
Mouse Reeve 9a9cef7766 Verify url before async search
The database lookup doesn't work during the asyn process, so this change
loops through the connectors and grabs the formatted urls before sending
it to the async handler.
2022-05-30 11:16:05 -07:00
Mouse Reeve 0adda36da7 Remove search endpoints from Connector
Instead of having individual search functions that make individual
requests, the connectors will always be searched asynchronously
together. The process_seach_response combines the parse and format
functions, which could probably be merged into one over-rideable
function.

The current to-do on this is to remove Inventaire search results that
are below the confidence threshhold after search, which used to happen
in the `search` function.
2022-05-30 10:37:24 -07:00
Mouse Reeve 9c03bf782e Make an async request to all search connectors
This is the untest first pass at re-arranging remote search to work in
parallel rather than sequence. It moves a couple functions around
(raise_not_valid_url, for example, needs to be in connector_manager.py
now to avoid circular imports). It adds a function to Connector objects
that generates a search result (either to the isbn endpoint or the free
text endpoint) based on the query, which was previously done as part of
the search.

I also lowered the timeout to 8 seconds by default.
2022-05-30 10:15:22 -07:00
Mouse Reeve 7905be7de2
Merge pull request #2138 from bookwyrm-social/ratings-query
Use general ratings rather than privacy filtered
2022-05-30 09:33:05 -07:00
Mouse Reeve fb3c7205af Updates unit tests 2022-05-30 09:17:51 -07:00
Mouse Reeve fc3b609ada Use general ratings rather than privacy filtered
The original system customized how a rating is displayed to every user
based on the privacy settings of the reviews and, relatedly, who the
user follows. This is cool, but the query is too complicated to load in
sessions, and the initial load, which isn't mitigated by caching, is too
much and causes timeouts for many users. Also the cache clearing wasn't
working correctly because I put in a wildcard, which does not work.
2022-05-30 08:42:48 -07:00
Mouse Reeve 4e3c346780
Merge pull request #2134 from bookwyrm-social/stopped-shelf-fixes
Stopped shelf fixes
2022-05-26 13:12:57 -07:00
Mouse Reeve 74925a379a Prettier 2022-05-26 12:54:31 -07:00
Mouse Reeve 4e0e6ed5a4 Tick javascript cache and version number 2022-05-26 12:49:04 -07:00
Mouse Reeve 09db4e48f4 Hide rather than remove current shelve list items 2022-05-26 12:46:34 -07:00
Mouse Reeve c5f5d4d994 Only show "stop" option when a book is in progress 2022-05-26 12:27:44 -07:00
Mouse Reeve 4905652e22 Handle stopped reading special case in javascript
This should be refactored, but maybe not today
2022-05-26 12:23:13 -07:00
Mouse Reeve 4c5d2570ab Save and display stopped date in readthrough 2022-05-26 11:53:33 -07:00
Mouse Reeve dfe0656eb4 Typo fix 2022-05-26 11:38:36 -07:00
Mouse Reeve 375c5a8789 Adds stopped date separate from finish date on readthrough 2022-05-26 11:36:37 -07:00
Mouse Reeve 1f6fbd8d29 Fixes stopped reading button logic
The stopped state is similar to finished
2022-05-26 11:28:54 -07:00
Mouse Reeve 9b4a498661 Don't show a button for the shelf a book is currently on
This will lead to nonsensical modal states
2022-05-26 11:19:49 -07:00
Mouse Reeve 92dbfec5f8 Adds status header for stopped reading statuses 2022-05-26 11:10:14 -07:00
Mouse Reeve 6848616ff1 Fixes reading status field in stop modal
The value of the reading status needs to match one of the database
options for `reading_status` in the `Comment` model
2022-05-26 11:09:11 -07:00
Mouse Reeve 007751c8cb Adds error logging to status views 2022-05-26 10:58:11 -07:00
Mouse Reeve 23c6019340 Adds merge migration 2022-05-26 10:23:32 -07:00
Mouse Reeve 77a7dfa924
Merge pull request #2133 from bookwyrm-social/activitypub-connection-erorr
Don't throw an error when unable to connect to remote data
2022-05-26 10:12:18 -07:00
Mouse Reeve 88b2cffcf2
Merge pull request #2035 from bookwyrm-social/stopped-shelf
Stopped shelf
2022-05-26 10:11:32 -07:00
Mouse Reeve 9d275db322 Updates ignore boost logic that no longer produces errors 2022-05-26 09:57:39 -07:00
Mouse Reeve 3e54a5f4a3 Python formatting 2022-05-26 09:00:45 -07:00
Mouse Reeve 0bfe1e9dfc Don't throw an error when unable to connect to remote data 2022-05-25 13:24:11 -07:00
Mouse Reeve f4226b050f
Merge pull request #2129 from bookwyrm-social/locales
Updates locales (changes to German, Romanian)
2022-05-23 18:02:45 -07:00
Mouse Reeve b8ddafffbe
Merge pull request #2130 from bookwyrm-social/followers-hidden
Make an exception for yourself when followers are hidden
2022-05-23 18:02:34 -07:00
Mouse Reeve 0f7317f8fe Make an exception for yourself when followers are hidden 2022-05-23 15:31:05 -07:00
Mouse Reeve 867981b2a4 Updates locales (changes to German, Romanian) 2022-05-23 15:20:35 -07:00
Mouse Reeve 6d5923bb8f
Merge pull request #2128 from bookwyrm-social/multiple-authors
Multiple authors not added when editing book
2022-05-23 14:07:54 -07:00
Mouse Reeve 3ed685e341
Merge pull request #2126 from bookwyrm-social/black-update
Updates black version
2022-05-23 13:59:19 -07:00
Mouse Reeve 9172d7ff4e
Merge pull request #2127 from bookwyrm-social/add-book
Corrects redirect to confirm mode when adding book
2022-05-23 13:59:12 -07:00
Mouse Reeve 69f192e78c Fixes error in add author code returning too soon 2022-05-23 13:57:14 -07:00
Mouse Reeve b2c587e082 Adds unit test for add author code when editing book 2022-05-23 13:51:58 -07:00
Mouse Reeve efd1fd82a9 Corrects redirect to confirm mode when adding book 2022-05-23 13:02:06 -07:00
Mouse Reeve ae2006c726 Updates black version 2022-05-23 12:46:45 -07:00
Mouse Reeve 1843959d10
Merge pull request #2093 from Ryuno-Ki/calibre-import
Calibre import. Fixes #627
2022-05-23 12:37:50 -07:00
Mouse Reeve 212bd49e6c
Merge pull request #2125 from bookwyrm-social/edit-author
Fixes edit author paths
2022-05-23 12:26:07 -07:00
André Jaenisch d837146b66
Make black happy
Signed-off-by: André Jaenisch <andre.jaenisch@posteo.de>
2022-05-23 20:59:28 +02:00
André Jaenisch b564e514fd
Handle parsed dates that already have a timezone on import.
Signed-off-by: André Jaenisch <andre.jaenisch@posteo.de>
2022-05-23 20:52:57 +02:00
André Jaenisch 12541d5f1c
Map timestamp to date_added to avoid integrity error.
Signed-off-by: André Jaenisch <andre.jaenisch@posteo.de>
2022-05-23 20:52:26 +02:00
Mouse Reeve d8b2ab74d1 Fixes edit author paths 2022-05-23 11:08:04 -07:00
Mouse Reeve 065095776f
Merge pull request #2119 from bookwyrm-social/edit-book
Fixes urls in edit book form
2022-05-19 10:47:10 -07:00
Mouse Reeve 6d7bb33683 Fixes urls in edit book form 2022-05-19 09:32:01 -07:00
Mouse Reeve cbd43c42a9
Merge pull request #2115 from bookwyrm-social/book-langs
Prevent error when a book language has a null value
2022-05-16 11:41:07 -07:00
Mouse Reeve 8d2da587d9 Prevent error when a book language has a null value 2022-05-16 11:06:11 -07:00
Mouse Reeve 39b6364e62
Merge pull request #2114 from bookwyrm-social/sentry-error-article
Fixes exception when receiving Article type activities
2022-05-16 10:38:15 -07:00
Mouse Reeve b2775c5160 Check unsupported types before attempting to serialize 2022-05-16 10:21:54 -07:00
Mouse Reeve fd43b56d31 Fixes celery error encountering Article type activities 2022-05-16 10:17:21 -07:00
Mouse Reeve 17864da8a2
Merge pull request #2113 from bookwyrm-social/status-priority
Fixes how backdated statuses are prioritized
2022-05-16 09:49:39 -07:00
Mouse Reeve fdd4691e00 Adds unit test 2022-05-16 09:41:34 -07:00
Mouse Reeve 876d9c2695 Fixes how backdated statuses are prioritized 2022-05-16 09:24:01 -07:00
Mouse Reeve fd66961ab8
Merge pull request #2104 from maxheadroom/main
add automatic restart of containers
2022-05-16 08:07:49 -07:00
Mouse Reeve ae8edce197
Merge pull request #2111 from maeserichar/fix_broken_links
Fix broken links in README
2022-05-16 08:05:44 -07:00
Mouse Reeve 241169650d
Merge pull request #2007 from viviicat/url-names
Add names of books/lists/authors/etc as slugs, redirect to slugified version of the page
2022-05-16 08:04:58 -07:00
Mouse Reeve 23eb1c1b10
Merge pull request #1942 from willhoh/isbn_search
Isbn check befor search
2022-05-16 08:01:31 -07:00
Ricardo Rodríguez 643a3509dd docs: Fix broken links in README 2022-05-15 13:01:25 +02:00
Mouse Reeve a5f9efc2b5
Merge pull request #2110 from bookwyrm-social/locales
Updates locales
2022-05-14 08:43:35 -07:00
Mouse Reeve 8c0ad7e73d Updates locales 2022-05-14 08:29:25 -07:00
Falko Zurell d0b7474744
add automatic restart of containers
Added ```restart: unless-stopped``` to keep containers up and running after a reboot.
2022-05-09 11:00:28 +02:00
Mouse Reeve 49e6eb8f68
Merge pull request #2092 from bookwyrm-social/locale-updates
Updates locales
2022-05-06 12:50:49 -07:00
Mouse Reeve ba7c39404b
Merge pull request #2103 from denmch/bugfix-profile-link
Replace user|username with request.user.localname
2022-05-06 12:19:34 -07:00
Den McHenry 80b0206e0d Replace user|username with request.user.localname 2022-05-06 10:29:25 -07:00
André Jaenisch 62c7661fb9
Reformat tests using black
Signed-off-by: André Jaenisch <andre.jaenisch@posteo.de>
2022-05-05 21:31:56 +02:00
André Jaenisch 22fcb61fb2
Write tests for Calibre importer
Signed-off-by: André Jaenisch <andre.jaenisch@posteo.de>
2022-05-05 13:08:01 +02:00
André Jaenisch 6bd9b725e2
Refactor hard-coded strings with a reference to a static property
Signed-off-by: André Jaenisch <andre.jaenisch@posteo.de>
2022-05-05 13:07:25 +02:00
André Jaenisch eeb1cc7197
Use a default shelf because Calibre indicates no reading status
Signed-off-by: André Jaenisch <andre.jaenisch@posteo.de>
2022-04-30 19:08:31 +02:00
André Jaenisch 3626db3c1a
Add Calibre importer for CSV exports
Signed-off-by: André Jaenisch <andre.jaenisch@posteo.de>
2022-04-30 15:25:35 +02:00
Mouse Reeve 95c043cc92 Updates locales 2022-04-29 15:44:31 -07:00
Mouse Reeve a4a06fa32c
Merge pull request #2090 from bookwyrm-social/dashboard-warning
Fixes invite request alert count
2022-04-29 15:41:57 -07:00
Mouse Reeve 966bec1d18 Fixes invite request alert count 2022-04-26 08:33:15 -07:00
Mouse Reeve 708dc4d613
Merge pull request #2089 from bookwyrm-social/no-confirmation
Show clearer behavior when no email confirmation is needed after all
2022-04-26 08:24:35 -07:00
Mouse Reeve a6cb46356f Show clearer behavior when no email confirmation is needed after all 2022-04-26 08:14:31 -07:00
Mouse Reeve 34be995125
Merge pull request #2087 from bookwyrm-social/locales
Updates locales
2022-04-26 07:50:16 -07:00
Mouse Reeve 676a51411f Updates locales 2022-04-26 07:41:23 -07:00
Mouse Reeve 93ec53f523
Merge pull request #2085 from bookwyrm-social/dependabot/pip/django-3.2.13
Bump django from 3.2.12 to 3.2.13
2022-04-25 09:20:12 -07:00
dependabot[bot] 3559bb5630
Bump django from 3.2.12 to 3.2.13
Bumps [django](https://github.com/django/django) from 3.2.12 to 3.2.13.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.2.12...3.2.13)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-22 22:47:05 +00:00
Mouse Reeve 358c507839
Merge pull request #2080 from viviicat/dark-read-hist-editions
Further dark theme fixes
2022-04-09 07:53:17 -07:00
Vivianne Langdon 64b623df32 fixes for bulma not having good dark support 2022-04-09 00:06:10 -07:00
Vivianne Langdon d3992802f2 use a new has-text-default instead of has-text-black 2022-04-08 23:14:30 -07:00
Vivianne Langdon b0d3eaeb40 allow empty slugs, for non-url-friendly book names 2022-04-08 22:11:05 -07:00
Vivianne 5a2bf64864
Merge branch 'bookwyrm-social:main' into url-names 2022-04-08 21:45:37 -07:00
Mouse Reeve 71cbe611de Merge migration 2022-03-26 13:07:27 -07:00
Mouse Reeve ec21d20b90 Merge branch 'main' into stopped-shelf 2022-03-26 13:06:06 -07:00
Mouse Reeve 108981a226 Creates fresh migration and removes merges 2022-03-16 16:35:03 -07:00
Mouse Reeve 0cf2c07069
Merge branch 'main' into url-names 2022-03-16 16:32:07 -07:00
Mouse Reeve 159b73d860 Fixes errors in migration 2022-03-16 13:54:25 -07:00
Mouse Reeve 819458e82a Improves error reporting on activitypub parser 2022-03-16 13:53:54 -07:00
Mouse Reeve f2b0b306e9
Merge pull request #1934 from tversteeg/partially-read-shelf
Add 'Stopped Reading' shelf
2022-03-16 13:51:15 -07:00
Thomas Versteeg b3f03164cc Apply black 2022-03-15 09:28:40 +01:00
Thomas Versteeg ee414598bf
Merge branch 'main' into partially-read-shelf 2022-03-15 08:28:02 +00:00
Thomas Versteeg 5d8404f797 Add merge migration 2022-03-12 11:45:09 +01:00
Thomas Versteeg 9e6dfb4706
Merge branch 'main' into partially-read-shelf 2022-03-12 10:38:56 +00:00
Vivianne Langdon a4391f35c1 black 2022-03-11 22:31:40 -08:00
Vivianne Langdon d6767e42fc fix variable clash 2022-03-11 22:28:05 -08:00
Vivianne Langdon cf53134577 disable linting unused-argument 2022-03-11 21:19:20 -08:00
Vivianne Langdon 598a0587cf Fix issue with tabs on bottom of book page 2022-03-11 21:10:22 -08:00
Vivianne Langdon f2d7bdbf27 in progress fixes for pylint 2022-03-11 20:14:45 -08:00
Vivianne Langdon 594fa5d058 Black 2022-03-11 20:00:13 -08:00
Vivianne 9fa8caba45
Merge branch 'bookwyrm-social:main' into url-names 2022-03-11 19:55:06 -08:00
Vivianne Langdon 5d25da93d5 revert previously changed unit tests 2022-03-11 04:25:50 -08:00
Vivianne Langdon d9ac326c29 No more remote id with slug, just add slug in local path. 2022-03-11 04:18:52 -08:00
Mouse Reeve 34a4c18397
Merge branch 'main' into partially-read-shelf 2022-03-05 19:23:35 -08:00
Vivianne Langdon 8838875879 Fix failure to 404 2022-03-02 04:07:13 -08:00
Vivianne Langdon 81594892ef Fix test for unit test requests 2022-03-02 03:42:29 -08:00
Vivianne Langdon 05f11e68c5 Hopefully knocking out many of the unit test fails 2022-03-02 03:11:02 -08:00
Vivianne Langdon 440e2f8806 black 2022-03-02 01:47:08 -08:00
Vivianne Langdon 2b483488aa Remove slugs from shelf as their id has text in it already 2022-03-02 01:37:58 -08:00
Vivianne Langdon 846963ad18 Fix accidental change to post 2022-03-02 01:16:30 -08:00
Vivianne Langdon d8181d6d66 Redirect to correct url with slug 2022-03-02 01:12:32 -08:00
Vivianne Langdon ebf463fc91 Generation of slugs and new urls to handle slugs
- TODO: redirect to correct slug if not found.
2022-03-02 00:21:23 -08:00
Thomas Versteeg 1e3f9246d6 Produce a proper status 2022-02-28 20:56:59 +01:00
Thomas Versteeg 539775f370 Merge remote-tracking branch 'upstream/main' into partially-read-shelf 2022-02-28 20:44:55 +01:00
Mouse Reeve a5571c65bc
Update 0134_alter_stopped_reading.py 2022-02-25 18:25:41 -08:00
Mouse Reeve b511928400
Create 0134_alter_stopped_reading.py 2022-02-25 18:08:30 -08:00
Thomas Versteeg 8deee2220e Fix stopped reading status model in non-javascript environment 2022-02-25 22:39:42 +01:00
Thomas Versteeg 5eb113af6b Create merge migration 2022-02-25 22:03:49 +01:00
Thomas Versteeg e9dfa42e11
Merge branch 'main' into partially-read-shelf 2022-02-25 21:00:29 +00:00
Thomas Versteeg d67dac4519
Merge branch 'main' into partially-read-shelf 2022-02-17 16:34:10 +00:00
Thomas Versteeg d63e5ab2d2 Fix tests 2022-02-14 18:12:08 +01:00
Willi Hohenstein 03ff8c248d Added input control and better char replacement 2022-02-14 17:38:45 +01:00
Willi Hohenstein 0b02287378 add docstring 2022-02-13 20:49:44 +01:00
Willi Hohenstein 526a1c6ef4 removed unnecessary code 2022-02-13 20:31:06 +01:00
Willi Hohenstein 54eeeb5798 fix style to pass tests 2022-02-13 20:30:11 +01:00
Willi Hohenstein 3c05cecb50 function moved 2022-02-13 19:07:25 +01:00
Willi Hohenstein a4b08d7213 add test with valid isbn10 2022-02-13 17:10:32 +01:00
Willi Hohenstein 5801ef011f add isbn check 2022-02-13 09:35:15 +01:00
Willi Hohenstein 27c26b4d16 add test for dashed ISBN 2022-02-13 09:34:28 +01:00
Thomas Versteeg c88b34814f Rename 'Partially Read' to 'Stopped Reading' 2022-02-12 19:49:54 +01:00
Thomas Versteeg bc89dd7041 Change shelf Finished label
When the shelf is read the label is 'Finished', otherwise it's 'Until'.
2022-02-12 11:19:00 +01:00
Thomas Versteeg 2b27889457 Add 'Partially Read' shelf 2022-02-11 14:33:46 +01:00
140 changed files with 2950 additions and 2270 deletions

View file

@ -21,8 +21,7 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pylint
- name: Analysing the code with pylint
run: |
pylint bookwyrm/ --ignore=migrations --disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801
pylint bookwyrm/

6
.pylintrc Normal file
View file

@ -0,0 +1,6 @@
[MAIN]
ignore=migrations
load-plugins=pylint.extensions.no_self_use
[MESSAGES CONTROL]
disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801,C3001

View file

@ -6,6 +6,7 @@ RUN mkdir /app /app/static /app/images
WORKDIR /app
RUN apt-get update && apt-get install -y gettext libgettextpo-dev tidy && apt-get clean
COPY requirements.txt /app/
RUN pip install -r requirements.txt --no-cache-dir
RUN apt-get update && apt-get install -y gettext libgettextpo-dev tidy && apt-get clean

View file

@ -16,7 +16,7 @@ If you'd like to join an instance, you can check out the [instances](https://joi
## Contributing
See [contributing](https://docs.joinbookwyrm.com/how-to-contribute.html) for code, translation or monetary contributions.
See [contributing](https://docs.joinbookwyrm.com/contributing.html) for code, translation or monetary contributions.
## About BookWyrm
### What it is and isn't
@ -76,4 +76,4 @@ Deployment
## Set up BookWyrm
The [documentation website](https://docs.joinbookwyrm.com/) has instruction on how to set up BookWyrm in a [developer environment](https://docs.joinbookwyrm.com/developer-environment.html) or [production](https://docs.joinbookwyrm.com/installing-in-production.html).
The [documentation website](https://docs.joinbookwyrm.com/) has instruction on how to set up BookWyrm in a [developer environment](https://docs.joinbookwyrm.com/install-dev.html) or [production](https://docs.joinbookwyrm.com/install-prod.html).

View file

@ -1,6 +1,7 @@
""" basics for an activitypub serializer """
from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder
import logging
from django.apps import apps
from django.db import IntegrityError, transaction
@ -8,6 +9,8 @@ from django.db import IntegrityError, transaction
from bookwyrm.connectors import ConnectorException, get_data
from bookwyrm.tasks import app
logger = logging.getLogger(__name__)
class ActivitySerializerError(ValueError):
"""routine problems serializing activitypub json"""
@ -39,12 +42,12 @@ def naive_parse(activity_objects, activity_json, serializer=None):
activity_json["type"] = "PublicKey"
activity_type = activity_json.get("type")
if activity_type in ["Question", "Article"]:
return None
try:
serializer = activity_objects[activity_type]
except KeyError as err:
# we know this exists and that we can't handle it
if activity_type in ["Question"]:
return None
raise ActivitySerializerError(err)
return serializer(activity_objects=activity_objects, **activity_json)
@ -65,7 +68,7 @@ class ActivityObject:
try:
value = kwargs[field.name]
if value in (None, MISSING, {}):
raise KeyError()
raise KeyError("Missing required field", field.name)
try:
is_subclass = issubclass(field.type, ActivityObject)
except TypeError:
@ -268,9 +271,9 @@ def resolve_remote_id(
try:
data = get_data(remote_id)
except ConnectorException:
raise ActivitySerializerError(
f"Could not connect to host for remote_id: {remote_id}"
)
logger.exception("Could not connect to host for remote_id: %s", remote_id)
return None
# determine the model implicitly, if not provided
# or if it's a model with subclasses like Status, check again
if not model or hasattr(model.objects, "select_subclasses"):

View file

@ -298,8 +298,9 @@ def add_status_on_create_command(sender, instance, created):
priority = HIGH
# check if this is an old status, de-prioritize if so
# (this will happen if federation is very slow, or, more expectedly, on csv import)
one_day = 60 * 60 * 24
if (instance.created_date - instance.published_date).seconds > one_day:
if instance.published_date < timezone.now() - timedelta(
days=1
) or instance.created_date < instance.published_date - timedelta(days=1):
priority = LOW
add_status_task.apply_async(

View file

@ -148,8 +148,8 @@ class SearchResult:
def __repr__(self):
# pylint: disable=consider-using-f-string
return "<SearchResult key={!r} title={!r} author={!r}>".format(
self.key, self.title, self.author
return "<SearchResult key={!r} title={!r} author={!r} confidence={!r}>".format(
self.key, self.title, self.author, self.confidence
)
def json(self):

View file

@ -1,9 +1,8 @@
""" functionality outline for a book data connector """
from abc import ABC, abstractmethod
import imghdr
import ipaddress
import logging
from urllib.parse import urlparse
import re
from django.core.files.base import ContentFile
from django.db import transaction
@ -11,7 +10,7 @@ import requests
from requests.exceptions import RequestException
from bookwyrm import activitypub, models, settings
from .connector_manager import load_more_data, ConnectorException
from .connector_manager import load_more_data, ConnectorException, raise_not_valid_url
from .format_mappings import format_mappings
@ -39,62 +38,34 @@ class AbstractMinimalConnector(ABC):
for field in self_fields:
setattr(self, field, getattr(info, field))
def search(self, query, min_confidence=None, timeout=settings.QUERY_TIMEOUT):
"""free text search"""
params = {}
if min_confidence:
params["min_confidence"] = min_confidence
def get_search_url(self, query):
"""format the query url"""
# Check if the query resembles an ISBN
if maybe_isbn(query) and self.isbn_search_url and self.isbn_search_url != "":
return f"{self.isbn_search_url}{query}"
data = self.get_search_data(
f"{self.search_url}{query}",
params=params,
timeout=timeout,
)
results = []
# NOTE: previously, we tried searching isbn and if that produces no results,
# searched as free text. This, instead, only searches isbn if it's isbn-y
return f"{self.search_url}{query}"
for doc in self.parse_search_data(data)[:10]:
results.append(self.format_search_result(doc))
return results
def isbn_search(self, query, timeout=settings.QUERY_TIMEOUT):
"""isbn search"""
params = {}
data = self.get_search_data(
f"{self.isbn_search_url}{query}",
params=params,
timeout=timeout,
)
results = []
# this shouldn't be returning mutliple results, but just in case
for doc in self.parse_isbn_search_data(data)[:10]:
results.append(self.format_isbn_search_result(doc))
return results
def get_search_data(self, remote_id, **kwargs): # pylint: disable=no-self-use
"""this allows connectors to override the default behavior"""
return get_data(remote_id, **kwargs)
def process_search_response(self, query, data, min_confidence):
"""Format the search results based on the formt of the query"""
if maybe_isbn(query):
return list(self.parse_isbn_search_data(data))[:10]
return list(self.parse_search_data(data, min_confidence))[:10]
@abstractmethod
def get_or_create_book(self, remote_id):
"""pull up a book record by whatever means possible"""
@abstractmethod
def parse_search_data(self, data):
def parse_search_data(self, data, min_confidence):
"""turn the result json from a search into a list"""
@abstractmethod
def format_search_result(self, search_result):
"""create a SearchResult obj from json"""
@abstractmethod
def parse_isbn_search_data(self, data):
"""turn the result json from a search into a list"""
@abstractmethod
def format_isbn_search_result(self, search_result):
"""create a SearchResult obj from json"""
class AbstractConnector(AbstractMinimalConnector):
"""generic book data connector"""
@ -254,9 +225,6 @@ def get_data(url, params=None, timeout=10):
# check if the url is blocked
raise_not_valid_url(url)
if models.FederatedServer.is_blocked(url):
raise ConnectorException(f"Attempting to load data from blocked url: {url}")
try:
resp = requests.get(
url,
@ -311,20 +279,6 @@ def get_image(url, timeout=10):
return image_content, extension
def raise_not_valid_url(url):
"""do some basic reality checks on the url"""
parsed = urlparse(url)
if not parsed.scheme in ["http", "https"]:
raise ConnectorException("Invalid scheme: ", url)
try:
ipaddress.ip_address(parsed.netloc)
raise ConnectorException("Provided url is an IP address: ", url)
except ValueError:
# it's not an IP address, which is good
pass
class Mapping:
"""associate a local database field with a field in an external dataset"""
@ -366,3 +320,9 @@ def unique_physical_format(format_text):
# try a direct match, so saving this would be redundant
return None
return format_text
def maybe_isbn(query):
"""check if a query looks like an isbn"""
isbn = re.sub(r"[\W_]", "", query) # removes filler characters
return len(isbn) in [10, 13] # ISBN10 or ISBN13

View file

@ -10,15 +10,12 @@ class Connector(AbstractMinimalConnector):
def get_or_create_book(self, remote_id):
return activitypub.resolve_remote_id(remote_id, model=models.Edition)
def parse_search_data(self, data):
return data
def format_search_result(self, search_result):
search_result["connector"] = self
return SearchResult(**search_result)
def parse_search_data(self, data, min_confidence):
for search_result in data:
search_result["connector"] = self
yield SearchResult(**search_result)
def parse_isbn_search_data(self, data):
return data
def format_isbn_search_result(self, search_result):
return self.format_search_result(search_result)
for search_result in data:
search_result["connector"] = self
yield SearchResult(**search_result)

View file

@ -1,17 +1,18 @@
""" interface with whatever connectors the app has """
from datetime import datetime
import asyncio
import importlib
import ipaddress
import logging
import re
from urllib.parse import urlparse
import aiohttp
from django.dispatch import receiver
from django.db.models import signals
from requests import HTTPError
from bookwyrm import book_search, models
from bookwyrm.settings import SEARCH_TIMEOUT
from bookwyrm.settings import SEARCH_TIMEOUT, USER_AGENT
from bookwyrm.tasks import app
logger = logging.getLogger(__name__)
@ -21,53 +22,85 @@ class ConnectorException(HTTPError):
"""when the connector can't do what was asked"""
async def get_results(session, url, min_confidence, query, connector):
"""try this specific connector"""
# pylint: disable=line-too-long
headers = {
"Accept": (
'application/json, application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'
),
"User-Agent": USER_AGENT,
}
params = {"min_confidence": min_confidence}
try:
async with session.get(url, headers=headers, params=params) as response:
if not response.ok:
logger.info("Unable to connect to %s: %s", url, response.reason)
return
try:
raw_data = await response.json()
except aiohttp.client_exceptions.ContentTypeError as err:
logger.exception(err)
return
return {
"connector": connector,
"results": connector.process_search_response(
query, raw_data, min_confidence
),
}
except asyncio.TimeoutError:
logger.info("Connection timed out for url: %s", url)
except aiohttp.ClientError as err:
logger.exception(err)
async def async_connector_search(query, items, min_confidence):
"""Try a number of requests simultaneously"""
timeout = aiohttp.ClientTimeout(total=SEARCH_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
tasks = []
for url, connector in items:
tasks.append(
asyncio.ensure_future(
get_results(session, url, min_confidence, query, connector)
)
)
results = await asyncio.gather(*tasks)
return results
def search(query, min_confidence=0.1, return_first=False):
"""find books based on arbitary keywords"""
if not query:
return []
results = []
# Have we got a ISBN ?
isbn = re.sub(r"[\W_]", "", query)
maybe_isbn = len(isbn) in [10, 13] # ISBN10 or ISBN13
start_time = datetime.now()
items = []
for connector in get_connectors():
result_set = None
if maybe_isbn and connector.isbn_search_url and connector.isbn_search_url != "":
# Search on ISBN
try:
result_set = connector.isbn_search(isbn)
except Exception as err: # pylint: disable=broad-except
logger.info(err)
# if this fails, we can still try regular search
# get the search url from the connector before sending
url = connector.get_search_url(query)
try:
raise_not_valid_url(url)
except ConnectorException:
# if this URL is invalid we should skip it and move on
logger.info("Request denied to blocked domain: %s", url)
continue
items.append((url, connector))
# if no isbn search results, we fallback to generic search
if not result_set:
try:
result_set = connector.search(query, min_confidence=min_confidence)
except Exception as err: # pylint: disable=broad-except
# we don't want *any* error to crash the whole search page
logger.info(err)
continue
if return_first and result_set:
# if we found anything, return it
return result_set[0]
if result_set:
results.append(
{
"connector": connector,
"results": result_set,
}
)
if (datetime.now() - start_time).seconds >= SEARCH_TIMEOUT:
break
# load as many results as we can
results = asyncio.run(async_connector_search(query, items, min_confidence))
results = [r for r in results if r]
if return_first:
return None
# find the best result from all the responses and return that
all_results = [r for con in results for r in con["results"]]
all_results = sorted(all_results, key=lambda r: r.confidence, reverse=True)
return all_results[0] if all_results else None
# failed requests will return None, so filter those out
return results
@ -133,3 +166,20 @@ def create_connector(sender, instance, created, *args, **kwargs):
"""create a connector to an external bookwyrm server"""
if instance.application_type == "bookwyrm":
get_or_create_connector(f"https://{instance.server_name}")
def raise_not_valid_url(url):
"""do some basic reality checks on the url"""
parsed = urlparse(url)
if not parsed.scheme in ["http", "https"]:
raise ConnectorException("Invalid scheme: ", url)
try:
ipaddress.ip_address(parsed.netloc)
raise ConnectorException("Provided url is an IP address: ", url)
except ValueError:
# it's not an IP address, which is good
pass
if models.FederatedServer.is_blocked(url):
raise ConnectorException(f"Attempting to load data from blocked url: {url}")

View file

@ -77,53 +77,42 @@ class Connector(AbstractConnector):
**{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks", "type"]},
}
def search(self, query, min_confidence=None): # pylint: disable=arguments-differ
"""overrides default search function with confidence ranking"""
results = super().search(query)
if min_confidence:
# filter the search results after the fact
return [r for r in results if r.confidence >= min_confidence]
return results
def parse_search_data(self, data):
return data.get("results")
def format_search_result(self, search_result):
images = search_result.get("image")
cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None
# a deeply messy translation of inventaire's scores
confidence = float(search_result.get("_score", 0.1))
confidence = 0.1 if confidence < 150 else 0.999
return SearchResult(
title=search_result.get("label"),
key=self.get_remote_id(search_result.get("uri")),
author=search_result.get("description"),
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
cover=cover,
confidence=confidence,
connector=self,
)
def parse_search_data(self, data, min_confidence):
for search_result in data.get("results", []):
images = search_result.get("image")
cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None
# a deeply messy translation of inventaire's scores
confidence = float(search_result.get("_score", 0.1))
confidence = 0.1 if confidence < 150 else 0.999
if confidence < min_confidence:
continue
yield SearchResult(
title=search_result.get("label"),
key=self.get_remote_id(search_result.get("uri")),
author=search_result.get("description"),
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
cover=cover,
confidence=confidence,
connector=self,
)
def parse_isbn_search_data(self, data):
"""got some daaaata"""
results = data.get("entities")
if not results:
return []
return list(results.values())
def format_isbn_search_result(self, search_result):
"""totally different format than a regular search result"""
title = search_result.get("claims", {}).get("wdt:P1476", [])
if not title:
return None
return SearchResult(
title=title[0],
key=self.get_remote_id(search_result.get("uri")),
author=search_result.get("description"),
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
cover=self.get_cover_url(search_result.get("image")),
connector=self,
)
return
for search_result in list(results.values()):
title = search_result.get("claims", {}).get("wdt:P1476", [])
if not title:
continue
yield SearchResult(
title=title[0],
key=self.get_remote_id(search_result.get("uri")),
author=search_result.get("description"),
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
cover=self.get_cover_url(search_result.get("image")),
connector=self,
)
def is_work_data(self, data):
return data.get("type") == "work"

View file

@ -152,39 +152,41 @@ class Connector(AbstractConnector):
image_name = f"{cover_id}-{size}.jpg"
return f"{self.covers_url}/b/id/{image_name}"
def parse_search_data(self, data):
return data.get("docs")
def parse_search_data(self, data, min_confidence):
for idx, search_result in enumerate(data.get("docs")):
# build the remote id from the openlibrary key
key = self.books_url + search_result["key"]
author = search_result.get("author_name") or ["Unknown"]
cover_blob = search_result.get("cover_i")
cover = self.get_cover_url([cover_blob], size="M") if cover_blob else None
def format_search_result(self, search_result):
# build the remote id from the openlibrary key
key = self.books_url + search_result["key"]
author = search_result.get("author_name") or ["Unknown"]
cover_blob = search_result.get("cover_i")
cover = self.get_cover_url([cover_blob], size="M") if cover_blob else None
return SearchResult(
title=search_result.get("title"),
key=key,
author=", ".join(author),
connector=self,
year=search_result.get("first_publish_year"),
cover=cover,
)
# OL doesn't provide confidence, but it does sort by an internal ranking, so
# this confidence value is relative to the list position
confidence = 1 / (idx + 1)
yield SearchResult(
title=search_result.get("title"),
key=key,
author=", ".join(author),
connector=self,
year=search_result.get("first_publish_year"),
cover=cover,
confidence=confidence,
)
def parse_isbn_search_data(self, data):
return list(data.values())
def format_isbn_search_result(self, search_result):
# build the remote id from the openlibrary key
key = self.books_url + search_result["key"]
authors = search_result.get("authors") or [{"name": "Unknown"}]
author_names = [author.get("name") for author in authors]
return SearchResult(
title=search_result.get("title"),
key=key,
author=", ".join(author_names),
connector=self,
year=search_result.get("publish_date"),
)
for search_result in list(data.values()):
# build the remote id from the openlibrary key
key = self.books_url + search_result["key"]
authors = search_result.get("authors") or [{"name": "Unknown"}]
author_names = [author.get("name") for author in authors]
yield SearchResult(
title=search_result.get("title"),
key=key,
author=", ".join(author_names),
connector=self,
year=search_result.get("publish_date"),
)
def load_edition_data(self, olkey):
"""query openlibrary for editions of a work"""

View file

@ -53,7 +53,12 @@ class ReadThroughForm(CustomForm):
self.add_error(
"finish_date", _("Reading finish date cannot be before start date.")
)
stopped_date = cleaned_data.get("stopped_date")
if start_date and stopped_date and start_date > stopped_date:
self.add_error(
"stopped_date", _("Reading stopped date cannot be before start date.")
)
class Meta:
model = models.ReadThrough
fields = ["user", "book", "start_date", "finish_date"]
fields = ["user", "book", "start_date", "finish_date", "stopped_date"]

View file

@ -1,6 +1,7 @@
""" import classes """
from .importer import Importer
from .calibre_import import CalibreImporter
from .goodreads_import import GoodreadsImporter
from .librarything_import import LibrarythingImporter
from .openlibrary_import import OpenLibraryImporter

View file

@ -0,0 +1,28 @@
""" handle reading a csv from calibre """
from bookwyrm.models import Shelf
from . import Importer
class CalibreImporter(Importer):
"""csv downloads from Calibre"""
service = "Calibre"
def __init__(self, *args, **kwargs):
# Add timestamp to row_mappings_guesses for date_added to avoid
# integrity error
row_mappings_guesses = []
for field, mapping in self.row_mappings_guesses:
if field in ("date_added",):
row_mappings_guesses.append((field, mapping + ["timestamp"]))
else:
row_mappings_guesses.append((field, mapping))
self.row_mappings_guesses = row_mappings_guesses
super().__init__(*args, **kwargs)
def get_shelf(self, normalized_row):
# Calibre export does not indicate which shelf to use. Go with a default one for now
return Shelf.TO_READ

View file

@ -1,5 +1,8 @@
""" handle reading a tsv from librarything """
import re
from bookwyrm.models import Shelf
from . import Importer
@ -21,7 +24,7 @@ class LibrarythingImporter(Importer):
def get_shelf(self, normalized_row):
if normalized_row["date_finished"]:
return "read"
return Shelf.READ_FINISHED
if normalized_row["date_started"]:
return "reading"
return "to-read"
return Shelf.READING
return Shelf.TO_READ

View file

@ -56,12 +56,17 @@ class Command(BaseCommand):
self.stdout.write(" OK 🖼")
# Books
books = models.Book.objects.select_subclasses().filter()
self.stdout.write(
" → Book preview images ({}): ".format(len(books)), ending=""
book_ids = (
models.Book.objects.select_subclasses()
.filter()
.values_list("id", flat=True)
)
for book in books:
preview_images.generate_edition_preview_image_task.delay(book.id)
self.stdout.write(
" → Book preview images ({}): ".format(len(book_ids)), ending=""
)
for book_id in book_ids:
preview_images.generate_edition_preview_image_task.delay(book_id)
self.stdout.write(".", ending="")
self.stdout.write(" OK 🖼")

View file

@ -89,7 +89,7 @@ def init_connectors():
covers_url="https://inventaire.io",
search_url="https://inventaire.io/api/search?types=works&types=works&search=",
isbn_search_url="https://inventaire.io/api/entities?action=by-uris&uris=isbn%3A",
priority=3,
priority=1,
)
models.Connector.objects.create(
@ -101,7 +101,7 @@ def init_connectors():
covers_url="https://covers.openlibrary.org",
search_url="https://openlibrary.org/search?q=",
isbn_search_url="https://openlibrary.org/api/books?jscmd=data&format=json&bibkeys=ISBN:",
priority=3,
priority=1,
)

View file

@ -0,0 +1,80 @@
# Generated by Django 3.2.12 on 2022-03-16 23:20
import bookwyrm.models.fields
from django.db import migrations
from bookwyrm.models import Shelf
def add_shelves(apps, schema_editor):
"""add any superusers to the "admin" group"""
db_alias = schema_editor.connection.alias
shelf_model = apps.get_model("bookwyrm", "Shelf")
users = apps.get_model("bookwyrm", "User")
local_users = users.objects.using(db_alias).filter(local=True)
for user in local_users:
remote_id = f"{user.remote_id}/books/stopped"
shelf_model.objects.using(db_alias).create(
name="Stopped reading",
identifier=Shelf.STOPPED_READING,
user=user,
editable=False,
remote_id=remote_id,
)
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0145_sitesettings_version"),
]
operations = [
migrations.AlterField(
model_name="comment",
name="reading_status",
field=bookwyrm.models.fields.CharField(
blank=True,
choices=[
("to-read", "To-Read"),
("reading", "Reading"),
("read", "Read"),
("stopped-reading", "Stopped-Reading"),
],
max_length=255,
null=True,
),
),
migrations.AlterField(
model_name="quotation",
name="reading_status",
field=bookwyrm.models.fields.CharField(
blank=True,
choices=[
("to-read", "To-Read"),
("reading", "Reading"),
("read", "Read"),
("stopped-reading", "Stopped-Reading"),
],
max_length=255,
null=True,
),
),
migrations.AlterField(
model_name="review",
name="reading_status",
field=bookwyrm.models.fields.CharField(
blank=True,
choices=[
("to-read", "To-Read"),
("reading", "Reading"),
("read", "Read"),
("stopped-reading", "Stopped-Reading"),
],
max_length=255,
null=True,
),
),
migrations.RunPython(add_shelves, reverse_code=migrations.RunPython.noop),
]

View file

@ -0,0 +1,13 @@
# Generated by Django 3.2.12 on 2022-03-26 20:06
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0146_auto_20220316_2320"),
("bookwyrm", "0147_alter_user_preferred_language"),
]
operations = []

View file

@ -0,0 +1,13 @@
# Generated by Django 3.2.13 on 2022-05-26 17:16
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0148_alter_user_preferred_language"),
("bookwyrm", "0148_merge_20220326_2006"),
]
operations = []

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.13 on 2022-05-26 18:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0149_merge_20220526_1716"),
]
operations = [
migrations.AddField(
model_name="readthrough",
name="stopped_date",
field=models.DateTimeField(blank=True, null=True),
),
]

View file

@ -8,6 +8,7 @@ from django.db.models import Q
from django.dispatch import receiver
from django.http import Http404
from django.utils.translation import gettext_lazy as _
from django.utils.text import slugify
from bookwyrm.settings import DOMAIN
from .fields import RemoteIdField
@ -35,10 +36,11 @@ class BookWyrmModel(models.Model):
remote_id = RemoteIdField(null=True, activitypub_field="id")
def get_remote_id(self):
"""generate a url that resolves to the local object"""
"""generate the url that resolves to the local object, without a slug"""
base_path = f"https://{DOMAIN}"
if hasattr(self, "user"):
base_path = f"{base_path}{self.user.local_path}"
model_name = type(self).__name__.lower()
return f"{base_path}/{model_name}/{self.id}"
@ -49,8 +51,20 @@ class BookWyrmModel(models.Model):
@property
def local_path(self):
"""how to link to this object in the local app"""
return self.get_remote_id().replace(f"https://{DOMAIN}", "")
"""how to link to this object in the local app, with a slug"""
local = self.get_remote_id().replace(f"https://{DOMAIN}", "")
name = None
if hasattr(self, "name_field"):
name = getattr(self, self.name_field)
elif hasattr(self, "name"):
name = self.name
if name:
slug = slugify(name)
local = f"{local}/s/{slug}"
return local
def raise_visible_to_user(self, viewer):
"""is a user authorized to view an object?"""

View file

@ -176,8 +176,8 @@ class Book(BookDataModel):
"""properties of this edition, as a string"""
items = [
self.physical_format if hasattr(self, "physical_format") else None,
self.languages[0] + " language"
if self.languages and self.languages[0] != "English"
f"{self.languages[0]} language"
if self.languages and self.languages[0] and self.languages[0] != "English"
else None,
str(self.published_date.year) if self.published_date else None,
", ".join(self.publishers) if hasattr(self, "publishers") else None,

View file

@ -175,9 +175,15 @@ class ImportItem(models.Model):
def date_added(self):
"""when the book was added to this dataset"""
if self.normalized_data.get("date_added"):
return timezone.make_aware(
dateutil.parser.parse(self.normalized_data.get("date_added"))
parsed_date_added = dateutil.parser.parse(
self.normalized_data.get("date_added")
)
if timezone.is_aware(parsed_date_added):
# Keep timezone if import already had one
return parsed_date_added
return timezone.make_aware(parsed_date_added)
return None
@property

View file

@ -27,6 +27,7 @@ class ReadThrough(BookWyrmModel):
)
start_date = models.DateTimeField(blank=True, null=True)
finish_date = models.DateTimeField(blank=True, null=True)
stopped_date = models.DateTimeField(blank=True, null=True)
is_active = models.BooleanField(default=True)
def save(self, *args, **kwargs):
@ -34,7 +35,7 @@ class ReadThrough(BookWyrmModel):
cache.delete(f"latest_read_through-{self.user.id}-{self.book.id}")
self.user.update_active_date()
# an active readthrough must have an unset finish date
if self.finish_date:
if self.finish_date or self.stopped_date:
self.is_active = False
super().save(*args, **kwargs)

View file

@ -6,6 +6,7 @@ from django.db import models
from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
from . import fields
@ -17,8 +18,9 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
TO_READ = "to-read"
READING = "reading"
READ_FINISHED = "read"
STOPPED_READING = "stopped-reading"
READ_STATUS_IDENTIFIERS = (TO_READ, READING, READ_FINISHED)
READ_STATUS_IDENTIFIERS = (TO_READ, READING, READ_FINISHED, STOPPED_READING)
name = fields.CharField(max_length=100)
identifier = models.CharField(max_length=100)
@ -65,6 +67,11 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
identifier = self.identifier or self.get_identifier()
return f"{base_path}/books/{identifier}"
@property
def local_path(self):
"""No slugs"""
return self.get_remote_id().replace(f"https://{DOMAIN}", "")
def raise_not_deletable(self, viewer):
"""don't let anyone delete a default shelf"""
super().raise_not_deletable(viewer)

View file

@ -116,11 +116,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
def ignore_activity(cls, activity): # pylint: disable=too-many-return-statements
"""keep notes if they are replies to existing statuses"""
if activity.type == "Announce":
try:
boosted = activitypub.resolve_remote_id(
activity.object, get_activity=True
)
except activitypub.ActivitySerializerError:
boosted = activitypub.resolve_remote_id(activity.object, get_activity=True)
if not boosted:
# if we can't load the status, definitely ignore it
return True
# keep the boost if we would keep the status
@ -265,7 +262,7 @@ class GeneratedNote(Status):
ReadingStatusChoices = models.TextChoices(
"ReadingStatusChoices", ["to-read", "reading", "read"]
"ReadingStatusChoices", ["to-read", "reading", "read", "stopped-reading"]
)
@ -306,10 +303,17 @@ class Comment(BookStatus):
@property
def pure_content(self):
"""indicate the book in question for mastodon (or w/e) users"""
return (
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a>)</p>'
)
if self.progress_mode == "PG" and self.progress and (self.progress > 0):
return_value = (
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a>, page {self.progress})</p>'
)
else:
return_value = (
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a>)</p>'
)
return return_value
activity_serializer = activitypub.Comment
@ -335,10 +339,17 @@ class Quotation(BookStatus):
"""indicate the book in question for mastodon (or w/e) users"""
quote = re.sub(r"^<p>", '<p>"', self.quote)
quote = re.sub(r"</p>$", '"</p>', quote)
return (
f'{quote} <p>-- <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a></p>{self.content}'
)
if self.position_mode == "PG" and self.position and (self.position > 0):
return_value = (
f'{quote} <p>-- <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a>, page {self.position}</p>{self.content}'
)
else:
return_value = (
f'{quote} <p>-- <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a></p>{self.content}'
)
return return_value
activity_serializer = activitypub.Quotation
@ -377,7 +388,7 @@ class Review(BookStatus):
def save(self, *args, **kwargs):
"""clear rating caches"""
if self.book.parent_work:
cache.delete(f"book-rating-{self.book.parent_work.id}-*")
cache.delete(f"book-rating-{self.book.parent_work.id}")
super().save(*args, **kwargs)

View file

@ -374,6 +374,10 @@ class User(OrderedCollectionPageMixin, AbstractUser):
"name": "Read",
"identifier": "read",
},
{
"name": "Stopped Reading",
"identifier": "stopped-reading",
},
]
for shelf in shelves:

View file

@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
env = Env()
env.read_env()
DOMAIN = env("DOMAIN")
VERSION = "0.3.4"
VERSION = "0.4.0"
RELEASE_API = env(
"RELEASE_API",
@ -21,7 +21,7 @@ RELEASE_API = env(
PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
JS_CACHE = "bc93172a"
JS_CACHE = "e678183b"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
@ -212,7 +212,7 @@ STREAMS = [
# Search configuration
# total time in seconds that the instance will spend searching connectors
SEARCH_TIMEOUT = int(env("SEARCH_TIMEOUT", 15))
SEARCH_TIMEOUT = int(env("SEARCH_TIMEOUT", 8))
# timeout for a query to an individual connector
QUERY_TIMEOUT = int(env("QUERY_TIMEOUT", 5))

View file

@ -23,3 +23,8 @@
.has-background-tertiary {
background-color: $background-tertiary !important;
}
/* Workaround for dark theme as .has-text-black doesn't give desired effect. */
.has-text-default {
color: $text !important;
}

View file

@ -53,6 +53,7 @@ $link-hover: $white-bis;
$link-hover-border: #51595d;
$link-focus: $white-bis;
$link-active: $white-bis;
$link-light: #0d1c26;
/* bulma overrides */
$background: $background-secondary;
@ -83,6 +84,13 @@ $progress-value-background-color: $border-light;
$family-primary: $family-sans-serif;
$family-secondary: $family-sans-serif;
.has-text-muted {
color: $grey-lighter !important;
}
.has-text-more-muted {
color: $grey-light !important;
}
@import "../bookwyrm.scss";
@import "../vendor/icons.css";

View file

@ -57,5 +57,13 @@ $invisible-overlay-background-color: rgba($scheme-invert, 0.66);
$family-primary: $family-sans-serif;
$family-secondary: $family-sans-serif;
.has-text-muted {
color: $grey-dark !important;
}
.has-text-more-muted {
color: $grey !important;
}
@import "../bookwyrm.scss";
@import "../vendor/icons.css";

View file

@ -203,6 +203,8 @@ let StatusCache = new (class {
.forEach((item) => (item.disabled = false));
next_identifier = next_identifier == "complete" ? "read" : next_identifier;
next_identifier =
next_identifier == "stopped-reading-complete" ? "stopped-reading" : next_identifier;
// Disable the current state
button.querySelector(

View file

@ -50,7 +50,7 @@
</ul>
</nav>
<div class="column">
<div class="column is-clipped">
{% block about_content %}{% endblock %}
</div>
</div>

View file

@ -24,7 +24,7 @@
</div>
{% endif %}
<form class="block" name="edit-author" action="{{ author.local_path }}/edit" method="post">
<form class="block" name="edit-author" action="{% url 'edit-author' author.id %}" method="post">
{% csrf_token %}
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}">

View file

@ -284,7 +284,7 @@
{% if user_statuses.review_count or user_statuses.comment_count or user_statuses.quotation_count %}
<nav class="tabs">
<ul>
{% url 'book' book.id as tab_url %}
{% url 'book' book.id book.name|slugify as tab_url %}
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
<a href="{{ tab_url }}#reviews">{% trans "Reviews" %} ({{ review_count }})</a>
</li>

View file

@ -41,10 +41,18 @@
class="block"
{% if book.id %}
name="edit-book"
action="{{ book.local_path }}/{% if confirm_mode %}confirm{% else %}edit{% endif %}"
{% if confirm_mode %}
action="{% url 'edit-book-confirm' book.id %}"
{% else %}
action="{% url 'edit-book' book.id %}"
{% endif %}
{% else %}
name="create-book"
action="/create-book{% if confirm_mode %}/confirm{% endif %}"
{% if confirm_mode %}
action="{% url 'create-book-confirm' %}"
{% else %}
action="{% url 'create-book' %}"
{% endif %}
{% endif %}
method="post"
enctype="multipart/form-data"

View file

@ -21,7 +21,7 @@
<div class="column my-3-mobile ml-3-tablet mr-auto">
<h2 class="title is-5 mb-1">
<a href="{{ book.local_path }}" class="has-text-black">
<a href="{{ book.local_path }}" class="has-text-default">
{{ book|book_title }}
</a>
</h2>

View file

@ -15,7 +15,7 @@
<nav class="breadcrumb subtitle" aria-label="breadcrumbs">
<ul>
<li><a href="{% url 'book' book.id %}">{{ book|book_title }}</a></li>
<li><a href="{% url 'book' book.id book.name|slugify %}">{{ book|book_title }}</a></li>
<li class="is-active">
<a href="#" aria-current="page">
{% trans "Edit links" %}

View file

@ -10,6 +10,7 @@
{% if shelf.identifier == 'to-read' %}{% trans "To Read" %}
{% elif shelf.identifier == 'reading' %}{% trans "Currently Reading" %}
{% elif shelf.identifier == 'read' %}{% trans "Read" %}
{% elif shelf.identifier == 'stopped-reading' %}{% trans "Stopped Reading" %}
{% else %}{{ shelf.name }}{% endif %}
</option>
{% endfor %}

View file

@ -38,7 +38,7 @@
{% for membership in group.memberships.all %}
{% with member=membership.user %}
<div class="box has-text-centered is-shadowless has-background-tertiary my-2 mx-2 member_{{ member.id }}">
<a href="{{ member.local_path }}" class="has-text-black">
<a href="{{ member.local_path }}" class="has-text-default">
{% include 'snippets/avatar.html' with user=member large=True %}
<span title="{{ member.display_name }}" class="is-block is-6 has-text-weight-bold">{{ member.display_name|truncatechars:10 }}</span>
<span title="@{{ member|username }}" class="is-block pb-3">@{{ member|username|truncatechars:8 }}</span>

View file

@ -9,7 +9,7 @@
<div class="column is-flex is-flex-grow-0">
{% for user in suggested_users %}
<div class="box has-text-centered is-shadowless has-background-tertiary m-2">
<a href="{{ user.local_path }}" class="has-text-black">
<a href="{{ user.local_path }}" class="has-text-default">
{% include 'snippets/avatar.html' with user=user large=True %}
<span title="{{ user.display_name }}" class="is-block is-6 has-text-weight-bold">{{ user.display_name|truncatechars:10 }}</span>
<span title="@{{ user|username }}" class="is-block pb-3">@{{ user|username|truncatechars:8 }}</span>

View file

@ -32,6 +32,9 @@
<option value="OpenLibrary" {% if current == 'OpenLibrary' %}selected{% endif %}>
OpenLibrary (CSV)
</option>
<option value="Calibre" {% if current == 'Calibre' %}selected{% endif %}>
Calibre (CSV)
</option>
</select>
</div>

View file

@ -6,7 +6,7 @@
<nav class="breadcrumb subtitle" aria-label="breadcrumbs">
<ul>
<li><a href="{% url 'lists' %}">{% trans "Lists" %}</a></li>
<li><a href="{% url 'list' list.id %}">{{ list.name|truncatechars:30 }}</a></li>
<li><a href="{% url 'list' list_id=list.id slug=list.name|slugify %}">{{ list.name|truncatechars:30 }}</a></li>
<li class="is-active">
<a href="#" aria-current="page">
{% trans "Curate" %}

View file

@ -180,7 +180,7 @@
<h2 class="title is-5">
{% trans "Sort List" %}
</h2>
<form name="sort" action="{% url 'list' list.id %}" method="GET" class="block">
<form name="sort" action="{% url 'list' list_id=list.id slug=list.name|slugify %}" method="GET" class="block">
<div class="field">
<label class="label" for="id_sort_by">{% trans "Sort By" %}</label>
<div class="select is-fullwidth">
@ -207,7 +207,7 @@
{% trans "Suggest Books" %}
{% endif %}
</h2>
<form name="search" action="{% url 'list' list.id %}" method="GET" class="block">
<form name="search" action="{% url 'list' list_id=list.id slug=list.name|slugify %}" method="GET" class="block">
<div class="field has-addons">
<div class="control">
<input aria-label="{% trans 'Search for a book' %}" class="input" type="text" name="q" placeholder="{% trans 'Search for a book' %}" value="{{ query }}">
@ -221,7 +221,7 @@
</div>
</div>
{% if query %}
<p class="help"><a href="{% url 'list' list.id %}">{% trans "Clear search" %}</a></p>
<p class="help"><a href="{% url 'list' list_id=list.id slug=list.name|slugify %}">{% trans "Clear search" %}</a></p>
{% endif %}
</form>
{% if not suggested_books %}

View file

@ -47,12 +47,12 @@
{% block preview %}
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-body has-text-grey-dark{% endif %}">
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-body has-text-muted{% endif %}">
<div class="columns">
<div class="column is-clipped">
{% include 'snippets/status_preview.html' with status=related_status %}
</div>
<div class="column is-narrow has-grey-dark">
<div class="column is-narrow has-text-muted">
{{ related_status.published_date|timesince }}
{% include 'snippets/privacy-icons.html' with item=related_status %}
</div>

View file

@ -47,12 +47,12 @@
{% block preview %}
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-body has-text-grey-dark{% endif %}">
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-body has-text-muted{% endif %}">
<div class="columns">
<div class="column is-clipped">
{% include 'snippets/status_preview.html' with status=related_status %}
</div>
<div class="column is-narrow has-grey-dark">
<div class="column is-narrow has-text-muted">
{{ related_status.published_date|timesince }}
{% include 'snippets/privacy-icons.html' with item=related_status %}
</div>

View file

@ -1,7 +1,7 @@
{% load notification_page_tags %}
{% related_status notification as related_status %}
<div class="notification {% if notification.id in unread %}has-background-primary{% endif %}">
<div class="columns is-mobile {% if notification.id in unread %}has-text-white{% else %}has-text-grey{% endif %}">
<div class="columns is-mobile {% if notification.id in unread %}has-text-white{% else %}has-text-more-muted{% endif %}">
<div class="column is-narrow is-size-3">
<a class="icon" href="{% block primary_link %}{% endblock %}">
{% block icon %}{% endblock %}

View file

@ -48,12 +48,12 @@
{% block preview %}
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-body has-text-black{% endif %}">
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-body has-text-default{% endif %}">
<div class="columns">
<div class="column is-clipped">
{% include 'snippets/status_preview.html' with status=related_status %}
</div>
<div class="column is-narrow has-text-black">
<div class="column is-narrow has-text-default">
{{ related_status.published_date|timesince }}
{% include 'snippets/privacy-icons.html' with item=related_status %}
</div>

View file

@ -51,12 +51,12 @@
{% block preview %}
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-body has-text-black{% endif %}">
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-body has-text-default{% endif %}">
<div class="columns">
<div class="column is-clipped">
{% include 'snippets/status_preview.html' with status=related_status %}
</div>
<div class="column is-narrow has-text-black">
<div class="column is-narrow has-text-default">
{{ related_status.published_date|timesince }}
{% include 'snippets/privacy-icons.html' with item=related_status %}
</div>

View file

@ -0,0 +1,14 @@
{% extends 'layout.html' %}
{% load i18n %}
{% block title %}
{% blocktrans trimmed with book_title=book.title %}
Stop Reading "{{ book_title }}"
{% endblocktrans %}
{% endblock %}
{% block content %}
{% include "snippets/reading_modals/stop_reading_modal.html" with book=book active=True static=True %}
{% endblock %}

View file

@ -19,6 +19,7 @@
</label>
{% include "snippets/progress_field.html" with id=field_id %}
{% endif %}
<div class="field">
<label class="label" for="id_finish_date_{{ readthrough.id }}">
{% trans "Finished reading" %}

View file

@ -8,10 +8,12 @@
<div class="column">
{% trans "Progress Updates:" %}
<ul>
{% if readthrough.finish_date or readthrough.progress %}
{% if readthrough.finish_date or readthrough.stopped_date or readthrough.progress %}
<li>
{% if readthrough.finish_date %}
{{ readthrough.finish_date | localtime | naturalday }}: {% trans "finished" %}
{% elif readthrough.stopped_date %}
{{ readthrough.stopped_date | localtime | naturalday }}: {% trans "stopped" %}
{% else %}
{% if readthrough.progress_mode == 'PG' %}

View file

@ -36,7 +36,7 @@
{% if result_set.results %}
<section class="mb-5">
{% if not result_set.connector.local %}
<details class="details-panel box" {% if forloop.first %}open{% endif %}>
<details class="details-panel box" open>
{% endif %}
{% if not result_set.connector.local %}
<summary class="is-flex is-align-items-center is-flex-wrap-wrap is-gap-2">

View file

@ -86,6 +86,7 @@
{% if shelf.identifier == 'to-read' %}{% trans "To Read" %}
{% elif shelf.identifier == 'reading' %}{% trans "Currently Reading" %}
{% elif shelf.identifier == 'read' %}{% trans "Read" %}
{% elif shelf.identifier == 'stopped-reading' %}{% trans "Stopped Reading" %}
{% else %}{{ shelf.name }}{% endif %}
<span class="subtitle">
{% include 'snippets/privacy-icons.html' with item=shelf %}
@ -150,7 +151,7 @@
{% if is_self %}
<th>{% trans "Shelved" as text %}{% include 'snippets/table-sort-header.html' with field="shelved_date" sort=sort text=text %}</th>
<th>{% trans "Started" as text %}{% include 'snippets/table-sort-header.html' with field="start_date" sort=sort text=text %}</th>
<th>{% trans "Finished" as text %}{% include 'snippets/table-sort-header.html' with field="finish_date" sort=sort text=text %}</th>
<th>{% if shelf.identifier == 'read' %}{% trans "Finished" as text %}{% else %}{% trans "Until" as text %}{% endif %}{% include 'snippets/table-sort-header.html' with field="finish_date" sort=sort text=text %}</th>
{% endif %}
<th>{% trans "Rating" as text %}{% include 'snippets/table-sort-header.html' with field="rating" sort=sort text=text %}</th>
{% endif %}
@ -180,7 +181,7 @@
<td data-title="{% trans "Started" %}">
{{ book.start_date|naturalday|default_if_none:""}}
</td>
<td data-title="{% trans "Finished" %}">
<td data-title="{% if shelf.identifier == 'read' %}{% trans "Finished" as text %}{% else %}{% trans "Until" as text %}{% endif %}">
{{ book.finish_date|naturalday|default_if_none:""}}
</td>
{% endif %}

View file

@ -0,0 +1,42 @@
{% extends 'snippets/reading_modals/layout.html' %}
{% load i18n %}
{% load utilities %}
{% block modal-title %}
{% blocktrans trimmed with book_title=book|book_title %}
Stop Reading "<em>{{ book_title }}</em>"
{% endblocktrans %}
{% endblock %}
{% block modal-form-open %}
<form name="stop-reading-{{ uuid }}" action="{% url 'reading-status' 'stop' book.id %}" method="post" {% if not refresh %}class="submit-status"{% endif %}>
{% csrf_token %}
<input type="hidden" name="id" value="{{ readthrough.id }}">
<input type="hidden" name="reading_status" value="stopped-reading">
<input type="hidden" name="shelf" value="{{ move_from }}">
{% endblock %}
{% block reading-dates %}
<div class="columns">
<div class="column is-half">
<div class="field">
<label class="label" for="stop_id_start_date_{{ uuid }}">
{% trans "Started reading" %}
</label>
<input type="date" name="start_date" class="input" id="stop_id_start_date_{{ uuid }}" value="{{ readthrough.start_date | date:"Y-m-d" }}">
</div>
</div>
<div class="column is-half">
<div class="field">
<label class="label" for="id_read_until_date_{{ uuid }}">
{% trans "Stopped reading" %}
</label>
<input type="date" name="stopped_date" class="input" id="id_read_until_date_{{ uuid }}" value="{% now "Y-m-d" %}">
</div>
</div>
</div>
{% endblock %}
{% block form %}
{% include "snippets/reading_modals/form.html" with optional=True type="stop_modal" %}
{% endblock %}

View file

@ -49,6 +49,13 @@
{% join "finish_reading" uuid as modal_id %}
{% include 'snippets/shelve_button/modal_button.html' with class=button_class fallback_url=fallback_url %}
{% elif shelf.identifier == 'stopped-reading' %}
{% trans "Stopped reading" as button_text %}
{% url 'reading-status' 'stop' book.id as fallback_url %}
{% join "stop_reading" uuid as modal_id %}
{% include 'snippets/shelve_button/modal_button.html' with class=button_class fallback_url=fallback_url %}
{% elif shelf.identifier == 'to-read' %}
{% trans "Want to read" as button_text %}
@ -99,5 +106,8 @@
{% join "finish_reading" uuid as modal_id %}
{% include 'snippets/reading_modals/finish_reading_modal.html' with book=active_shelf.book id=modal_id move_from=current.id readthrough=readthrough refresh=True class="" %}
{% join "stop_reading" uuid as modal_id %}
{% include 'snippets/reading_modals/stop_reading_modal.html' with book=active_shelf.book id=modal_id move_from=current.id readthrough=readthrough refresh=True class="" %}
{% endwith %}
{% endblock %}

View file

@ -29,6 +29,9 @@
{% join "finish_reading" uuid as modal_id %}
{% include 'snippets/reading_modals/finish_reading_modal.html' with book=active_shelf_book id=modal_id readthrough=readthrough class="" %}
{% join "stop_reading" uuid as modal_id %}
{% include 'snippets/reading_modals/stop_reading_modal.html' with book=active_shelf_book id=modal_id readthrough=readthrough class="" %}
{% join "progress_update" uuid as modal_id %}
{% include 'snippets/reading_modals/progress_update_modal.html' with book=active_shelf_book id=modal_id readthrough=readthrough class="" %}

View file

@ -8,7 +8,7 @@
{% comparison_bool shelf.identifier active_shelf.shelf.identifier as is_current %}
<li role="menuitem" class="dropdown-item p-0">
<div
class="{% if next_shelf_identifier == shelf.identifier %}is-hidden{% endif %}"
class="{% if is_current or next_shelf_identifier == shelf.identifier %}is-hidden{% elif shelf.identifier == 'stopped-reading' and active_shelf.shelf.identifier != "reading" %}is-hidden{% endif %}"
data-shelf-dropdown-identifier="{{ shelf.identifier }}"
data-shelf-next="{{ shelf.identifier|next_shelf }}"
>
@ -26,6 +26,13 @@
{% join "finish_reading" button_uuid as modal_id %}
{% include 'snippets/shelve_button/modal_button.html' with class=class fallback_url=fallback_url %}
{% elif shelf.identifier == 'stopped-reading' %}
{% trans "Stop reading" as button_text %}
{% url 'reading-status' 'stop' book.id as fallback_url %}
{% join "stop_reading" button_uuid as modal_id %}
{% include 'snippets/shelve_button/modal_button.html' with class=class fallback_url=fallback_url %}
{% elif shelf.identifier == 'to-read' %}
{% trans "Want to read" as button_text %}

View file

@ -13,6 +13,15 @@
</button>
</div>
<div
class="{% if next_shelf_identifier != 'stopped-reading-complete' %}is-hidden{% endif %}"
data-shelf-identifier="stopped-reading-complete"
>
<button type="button" class="button {{ class }}" disabled>
<span>{% trans "Stopped reading" %}</span>
</button>
</div>
{% for shelf in shelves %}
<div
class="{% if next_shelf_identifier != shelf.identifier %}is-hidden{% endif %}"
@ -33,6 +42,14 @@
{% join "finish_reading" button_uuid as modal_id %}
{% include 'snippets/shelve_button/modal_button.html' with class=class fallback_url=fallback_url %}
{% elif shelf.identifier == 'stopped-reading' %}
{% trans "Stop reading" as button_text %}
{% url 'reading-status' 'finish' book.id as fallback_url %}
{% join "stop_reading" button_uuid as modal_id %}
{% include 'snippets/shelve_button/modal_button.html' with class=class fallback_url=fallback_url %}
{% elif shelf.identifier == 'to-read' %}
{% trans "Want to read" as button_text %}

View file

@ -112,6 +112,9 @@
{% with full=status.content|safe no_trim=status.content_warning itemprop="reviewBody" %}
{% include 'snippets/trimmed_text.html' %}
{% endwith %}
{% if status.progress %}
<div class="is-small is-italic has-text-right mr-3">{% trans "page" %} {{ status.progress }}</div>
{% endif %}
{% endif %}
{% if status.attachments.exists %}

View file

@ -0,0 +1,23 @@
{% spaceless %}
{% load i18n %}
{% load utilities %}
{% load status_display %}
{% load_book status as book %}
{% if book.authors.exists %}
{% with author=book.authors.first %}
{% blocktrans trimmed with book_path=book.local_path book=book|book_title author_name=author.name author_path=author.local_path %}
stopped reading <a href="{{ book_path }}">{{ book }}</a> by <a href="{{ author_path }}">{{ author_name }}</a>
{% endblocktrans %}
{% endwith %}
{% else %}
{% blocktrans trimmed with book_path=book.local_path book=book|book_title %}
stopped reading <a href="{{ book_path }}">{{ book }}</a>
{% endblocktrans %}
{% endif %}
{% endspaceless %}

View file

@ -5,7 +5,7 @@
{% for user in suggested_users %}
<div class="column is-flex is-flex-grow-0">
<div class="box has-text-centered is-shadowless has-background-tertiary m-0">
<a href="{{ user.local_path }}" class="has-text-black">
<a href="{{ user.local_path }}" class="has-text-default">
{% include 'snippets/avatar.html' with user=user large=True %}
<span title="{{ user.display_name }}" class="is-block is-6 has-text-weight-bold">{{ user.display_name|truncatechars:10 }}</span>
<span title="@{{ user|username }}" class="is-block pb-3">@{{ user|username|truncatechars:8 }}</span>

View file

@ -33,8 +33,9 @@
{% if shelf.name == 'To Read' %}{% trans "To Read" %}
{% elif shelf.name == 'Currently Reading' %}{% trans "Currently Reading" %}
{% elif shelf.name == 'Read' %}{% trans "Read" %}
{% elif shelf.name == 'Stopped Reading' %}{% trans "Stopped Reading" %}
{% else %}{{ shelf.name }}{% endif %}
{% if shelf.size > 3 %}<small>(<a href="{{ shelf.local_path }}">{% blocktrans with size=shelf.size %}View all {{ size }}{% endblocktrans %}</a>)</small>{% endif %}
{% if shelf.size > 4 %}<small>(<a href="{{ shelf.local_path }}">{% blocktrans with size=shelf.size %}View all {{ size }}{% endblocktrans %}</a>)</small>{% endif %}
</h3>
<div class="is-mobile field is-grouped">
{% for book in shelf.books %}

View file

@ -21,7 +21,7 @@
role="menu"
>
<li role="menuitem">
<a href="{% url 'user-feed' user|username %}" class="navbar-item">
<a href="{% url 'user-feed' request.user.localname %}" class="navbar-item">
{% trans "Profile" %}
</a>
</li>

View file

@ -13,10 +13,10 @@ register = template.Library()
def get_rating(book, user):
"""get the overall rating of a book"""
return cache.get_or_set(
f"book-rating-{book.parent_work.id}-{user.id}",
lambda u, b: models.Review.privacy_filter(u)
.filter(book__parent_work__editions=b, rating__gt=0)
.aggregate(Avg("rating"))["rating__avg"]
f"book-rating-{book.parent_work.id}",
lambda u, b: models.Review.objects.filter(
book__parent_work__editions=b, rating__gt=0
).aggregate(Avg("rating"))["rating__avg"]
or 0,
user,
book,

View file

@ -30,6 +30,8 @@ def get_next_shelf(current_shelf):
return "read"
if current_shelf == "read":
return "complete"
if current_shelf == "stopped-reading":
return "stopped-reading-complete"
return "to-read"

View file

@ -1,6 +1,10 @@
""" testing activitystreams """
from datetime import datetime, timedelta
from unittest.mock import patch
from django.test import TestCase
from django.utils import timezone
from bookwyrm import activitystreams, models
@ -62,6 +66,39 @@ class ActivitystreamsSignals(TestCase):
self.assertEqual(args["args"][0], status.id)
self.assertEqual(args["queue"], "high_priority")
def test_add_status_on_create_created_low_priority(self, *_):
"""a new statuses has entered"""
# created later than publication
status = models.Status.objects.create(
user=self.remote_user,
content="hi",
privacy="public",
created_date=datetime(2022, 5, 16, tzinfo=timezone.utc),
published_date=datetime(2022, 5, 14, tzinfo=timezone.utc),
)
with patch("bookwyrm.activitystreams.add_status_task.apply_async") as mock:
activitystreams.add_status_on_create_command(models.Status, status, False)
self.assertEqual(mock.call_count, 1)
args = mock.call_args[1]
self.assertEqual(args["args"][0], status.id)
self.assertEqual(args["queue"], "low_priority")
# published later than yesterday
status = models.Status.objects.create(
user=self.remote_user,
content="hi",
privacy="public",
published_date=timezone.now() - timedelta(days=1),
)
with patch("bookwyrm.activitystreams.add_status_task.apply_async") as mock:
activitystreams.add_status_on_create_command(models.Status, status, False)
self.assertEqual(mock.call_count, 1)
args = mock.call_args[1]
self.assertEqual(args["args"][0], status.id)
self.assertEqual(args["queue"], "low_priority")
def test_populate_streams_on_account_create_command(self, *_):
"""create streams for a user"""
with patch("bookwyrm.activitystreams.populate_stream_task.delay") as mock:

View file

@ -42,15 +42,9 @@ class AbstractConnector(TestCase):
generated_remote_link_field = "openlibrary_link"
def format_search_result(self, search_result):
return search_result
def parse_search_data(self, data):
def parse_search_data(self, data, min_confidence):
return data
def format_isbn_search_result(self, search_result):
return search_result
def parse_isbn_search_data(self, data):
return data
@ -101,6 +95,7 @@ class AbstractConnector(TestCase):
result = self.connector.get_or_create_book(
f"https://{DOMAIN}/book/{self.book.id}"
)
self.assertEqual(models.Book.objects.count(), 1)
self.assertEqual(result, self.book)

View file

@ -1,6 +1,5 @@
""" testing book data connectors """
from django.test import TestCase
import responses
from bookwyrm import models
from bookwyrm.connectors import abstract_connector
@ -25,18 +24,12 @@ class AbstractConnector(TestCase):
class TestConnector(abstract_connector.AbstractMinimalConnector):
"""nothing added here"""
def format_search_result(self, search_result):
return search_result
def get_or_create_book(self, remote_id):
pass
def parse_search_data(self, data):
def parse_search_data(self, data, min_confidence):
return data
def format_isbn_search_result(self, search_result):
return search_result
def parse_isbn_search_data(self, data):
return data
@ -54,45 +47,6 @@ class AbstractConnector(TestCase):
self.assertIsNone(connector.name)
self.assertEqual(connector.identifier, "example.com")
@responses.activate
def test_search(self):
"""makes an http request to the outside service"""
responses.add(
responses.GET,
"https://example.com/search?q=a%20book%20title",
json=["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"],
status=200,
)
results = self.test_connector.search("a book title")
self.assertEqual(len(results), 10)
self.assertEqual(results[0], "a")
self.assertEqual(results[1], "b")
self.assertEqual(results[2], "c")
@responses.activate
def test_search_min_confidence(self):
"""makes an http request to the outside service"""
responses.add(
responses.GET,
"https://example.com/search?q=a%20book%20title&min_confidence=1",
json=["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"],
status=200,
)
results = self.test_connector.search("a book title", min_confidence=1)
self.assertEqual(len(results), 10)
@responses.activate
def test_isbn_search(self):
"""makes an http request to the outside service"""
responses.add(
responses.GET,
"https://example.com/isbn?q=123456",
json=["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"],
status=200,
)
results = self.test_connector.isbn_search("123456")
self.assertEqual(len(results), 10)
def test_create_mapping(self):
"""maps remote fields for book data to bookwyrm activitypub fields"""
mapping = Mapping("isbn")

View file

@ -30,14 +30,11 @@ class BookWyrmConnector(TestCase):
result = self.connector.get_or_create_book(book.remote_id)
self.assertEqual(book, result)
def test_format_search_result(self):
def test_parse_search_data(self):
"""create a SearchResult object from search response json"""
datafile = pathlib.Path(__file__).parent.joinpath("../data/bw_search.json")
search_data = json.loads(datafile.read_bytes())
results = self.connector.parse_search_data(search_data)
self.assertIsInstance(results, list)
result = self.connector.format_search_result(results[0])
result = list(self.connector.parse_search_data(search_data, 0))[0]
self.assertIsInstance(result, SearchResult)
self.assertEqual(result.title, "Jonathan Strange and Mr Norrell")
self.assertEqual(result.key, "https://example.com/book/122")
@ -45,10 +42,9 @@ class BookWyrmConnector(TestCase):
self.assertEqual(result.year, 2017)
self.assertEqual(result.connector, self.connector)
def test_format_isbn_search_result(self):
def test_parse_isbn_search_data(self):
"""just gotta attach the connector"""
datafile = pathlib.Path(__file__).parent.joinpath("../data/bw_search.json")
search_data = json.loads(datafile.read_bytes())
results = self.connector.parse_isbn_search_data(search_data)
result = self.connector.format_isbn_search_result(results[0])
result = list(self.connector.parse_isbn_search_data(search_data))[0]
self.assertEqual(result.connector, self.connector)

View file

@ -49,39 +49,11 @@ class ConnectorManager(TestCase):
self.assertEqual(len(connectors), 1)
self.assertIsInstance(connectors[0], BookWyrmConnector)
@responses.activate
def test_search_plaintext(self):
"""search all connectors"""
responses.add(
responses.GET,
"http://fake.ciom/search/Example?min_confidence=0.1",
json=[{"title": "Hello", "key": "https://www.example.com/search/1"}],
)
results = connector_manager.search("Example")
self.assertEqual(len(results), 1)
self.assertEqual(len(results[0]["results"]), 1)
self.assertEqual(results[0]["connector"].identifier, "test_connector_remote")
self.assertEqual(results[0]["results"][0].title, "Hello")
def test_search_empty_query(self):
"""don't panic on empty queries"""
results = connector_manager.search("")
self.assertEqual(results, [])
@responses.activate
def test_search_isbn(self):
"""special handling if a query resembles an isbn"""
responses.add(
responses.GET,
"http://fake.ciom/isbn/0000000000",
json=[{"title": "Hello", "key": "https://www.example.com/search/1"}],
)
results = connector_manager.search("0000000000")
self.assertEqual(len(results), 1)
self.assertEqual(len(results[0]["results"]), 1)
self.assertEqual(results[0]["connector"].identifier, "test_connector_remote")
self.assertEqual(results[0]["results"][0].title, "Hello")
def test_first_search_result(self):
"""only get one search result"""
result = connector_manager.first_search_result("Example")

View file

@ -66,38 +66,14 @@ class Inventaire(TestCase):
with self.assertRaises(ConnectorException):
self.connector.get_book_data("https://test.url/ok")
@responses.activate
def test_search(self):
"""min confidence filtering"""
responses.add(
responses.GET,
"https://inventaire.io/search?q=hi",
json={
"results": [
{
"_score": 200,
"label": "hello",
},
{
"_score": 100,
"label": "hi",
},
],
},
)
results = self.connector.search("hi", min_confidence=0.5)
self.assertEqual(len(results), 1)
self.assertEqual(results[0].title, "hello")
def test_format_search_result(self):
def test_parse_search_data(self):
"""json to search result objs"""
search_file = pathlib.Path(__file__).parent.joinpath(
"../data/inventaire_search.json"
)
search_results = json.loads(search_file.read_bytes())
results = self.connector.parse_search_data(search_results)
formatted = self.connector.format_search_result(results[0])
formatted = list(self.connector.parse_search_data(search_results, 0))[0]
self.assertEqual(formatted.title, "The Stories of Vladimir Nabokov")
self.assertEqual(
@ -178,15 +154,14 @@ class Inventaire(TestCase):
result = self.connector.resolve_keys(keys)
self.assertEqual(result, ["epistolary novel", "crime novel"])
def test_isbn_search(self):
def test_pase_isbn_search_data(self):
"""another search type"""
search_file = pathlib.Path(__file__).parent.joinpath(
"../data/inventaire_isbn_search.json"
)
search_results = json.loads(search_file.read_bytes())
results = self.connector.parse_isbn_search_data(search_results)
formatted = self.connector.format_isbn_search_result(results[0])
formatted = list(self.connector.parse_isbn_search_data(search_results))[0]
self.assertEqual(formatted.title, "L'homme aux cercles bleus")
self.assertEqual(
@ -198,25 +173,12 @@ class Inventaire(TestCase):
"https://covers.inventaire.io/img/entities/12345",
)
def test_isbn_search_empty(self):
def test_parse_isbn_search_data_empty(self):
"""another search type"""
search_results = {}
results = self.connector.parse_isbn_search_data(search_results)
results = list(self.connector.parse_isbn_search_data(search_results))
self.assertEqual(results, [])
def test_isbn_search_no_title(self):
"""another search type"""
search_file = pathlib.Path(__file__).parent.joinpath(
"../data/inventaire_isbn_search.json"
)
search_results = json.loads(search_file.read_bytes())
search_results["entities"]["isbn:9782290349229"]["claims"]["wdt:P1476"] = None
result = self.connector.format_isbn_search_result(
search_results.get("entities")
)
self.assertIsNone(result)
def test_is_work_data(self):
"""is it a work"""
work_file = pathlib.Path(__file__).parent.joinpath(

View file

@ -122,21 +122,11 @@ class Openlibrary(TestCase):
self.assertEqual(result, "https://covers.openlibrary.org/b/id/image-L.jpg")
def test_parse_search_result(self):
"""extract the results from the search json response"""
datafile = pathlib.Path(__file__).parent.joinpath("../data/ol_search.json")
search_data = json.loads(datafile.read_bytes())
result = self.connector.parse_search_data(search_data)
self.assertIsInstance(result, list)
self.assertEqual(len(result), 2)
def test_format_search_result(self):
"""translate json from openlibrary into SearchResult"""
datafile = pathlib.Path(__file__).parent.joinpath("../data/ol_search.json")
search_data = json.loads(datafile.read_bytes())
results = self.connector.parse_search_data(search_data)
self.assertIsInstance(results, list)
result = list(self.connector.parse_search_data(search_data, 0))[0]
result = self.connector.format_search_result(results[0])
self.assertIsInstance(result, SearchResult)
self.assertEqual(result.title, "This Is How You Lose the Time War")
self.assertEqual(result.key, "https://openlibrary.org/works/OL20639540W")
@ -148,18 +138,10 @@ class Openlibrary(TestCase):
"""extract the results from the search json response"""
datafile = pathlib.Path(__file__).parent.joinpath("../data/ol_isbn_search.json")
search_data = json.loads(datafile.read_bytes())
result = self.connector.parse_isbn_search_data(search_data)
self.assertIsInstance(result, list)
result = list(self.connector.parse_isbn_search_data(search_data))
self.assertEqual(len(result), 1)
def test_format_isbn_search_result(self):
"""translate json from openlibrary into SearchResult"""
datafile = pathlib.Path(__file__).parent.joinpath("../data/ol_isbn_search.json")
search_data = json.loads(datafile.read_bytes())
results = self.connector.parse_isbn_search_data(search_data)
self.assertIsInstance(results, list)
result = self.connector.format_isbn_search_result(results[0])
result = result[0]
self.assertIsInstance(result, SearchResult)
self.assertEqual(result.title, "Les ombres errantes")
self.assertEqual(result.key, "https://openlibrary.org/books/OL16262504M")
@ -229,7 +211,7 @@ class Openlibrary(TestCase):
status=200,
)
with patch(
"bookwyrm.connectors.openlibrary.Connector." "get_authors_from_data"
"bookwyrm.connectors.openlibrary.Connector.get_authors_from_data"
) as mock:
mock.return_value = []
result = self.connector.create_edition_from_data(work, self.edition_data)

View file

@ -0,0 +1,2 @@
authors,author_sort,rating,library_name,timestamp,formats,size,isbn,identifiers,comments,tags,series,series_index,languages,title,cover,title_sort,publisher,pubdate,id,uuid
"Seanan McGuire","McGuire, Seanan","5","Bücher","2021-01-19T22:41:16+01:00","epub, original_epub","1433809","9780756411800","goodreads:39077187,isbn:9780756411800","REPLACED COMMENTS (BOOK DESCRIPTION) BECAUSE IT IS REALLY LONG.","Cryptids, Fantasy, Romance, Magic","InCryptid","8.0","eng","That Ain't Witchcraft","/home/tastytea/Bücher/Seanan McGuire/That Ain't Witchcraft (864)/cover.jpg","That Ain't Witchcraft","Daw Books","2019-03-05T01:00:00+01:00","864","3051ed45-8943-4900-a22a-d2704e3583df"
1 authors author_sort rating library_name timestamp formats size isbn identifiers comments tags series series_index languages title cover title_sort publisher pubdate id uuid
2 Seanan McGuire McGuire, Seanan 5 Bücher 2021-01-19T22:41:16+01:00 epub, original_epub 1433809 9780756411800 goodreads:39077187,isbn:9780756411800 REPLACED COMMENTS (BOOK DESCRIPTION) BECAUSE IT IS REALLY LONG. Cryptids, Fantasy, Romance, Magic InCryptid 8.0 eng That Ain't Witchcraft /home/tastytea/Bücher/Seanan McGuire/That Ain't Witchcraft (864)/cover.jpg That Ain't Witchcraft Daw Books 2019-03-05T01:00:00+01:00 864 3051ed45-8943-4900-a22a-d2704e3583df

View file

@ -0,0 +1,71 @@
""" testing import """
import pathlib
from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models
from bookwyrm.importers import CalibreImporter
from bookwyrm.importers.importer import handle_imported_book
# pylint: disable=consider-using-with
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
@patch("bookwyrm.activitystreams.add_book_statuses_task.delay")
class CalibreImport(TestCase):
"""importing from Calibre csv"""
def setUp(self):
"""use a test csv"""
self.importer = CalibreImporter()
datafile = pathlib.Path(__file__).parent.joinpath("../data/calibre.csv")
self.csv = open(datafile, "r", encoding=self.importer.encoding)
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay"
), patch("bookwyrm.lists_stream.populate_lists_task.delay"):
self.local_user = models.User.objects.create_user(
"mouse", "mouse@mouse.mouse", "password", local=True
)
work = models.Work.objects.create(title="Test Work")
self.book = models.Edition.objects.create(
title="Example Edition",
remote_id="https://example.com/book/1",
parent_work=work,
)
def test_create_job(self, *_):
"""creates the import job entry and checks csv"""
import_job = self.importer.create_job(
self.local_user, self.csv, False, "public"
)
import_items = (
models.ImportItem.objects.filter(job=import_job).order_by("index").all()
)
self.assertEqual(len(import_items), 1)
self.assertEqual(import_items[0].index, 0)
self.assertEqual(
import_items[0].normalized_data["title"], "That Ain't Witchcraft"
)
def test_handle_imported_book(self, *_):
"""calibre import added a book, this adds related connections"""
shelf = self.local_user.shelf_set.filter(
identifier=models.Shelf.TO_READ
).first()
self.assertIsNone(shelf.books.first())
import_job = self.importer.create_job(
self.local_user, self.csv, False, "public"
)
import_item = import_job.items.first()
import_item.book = self.book
import_item.save()
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
handle_imported_book(import_item)
shelf.refresh_from_db()
self.assertEqual(shelf.books.first(), self.book)

View file

@ -84,7 +84,9 @@ class GoodreadsImport(TestCase):
def test_handle_imported_book(self, *_):
"""goodreads import added a book, this adds related connections"""
shelf = self.local_user.shelf_set.filter(identifier="read").first()
shelf = self.local_user.shelf_set.filter(
identifier=models.Shelf.READ_FINISHED
).first()
self.assertIsNone(shelf.books.first())
import_job = self.importer.create_job(

View file

@ -174,7 +174,9 @@ class GenericImporter(TestCase):
def test_handle_imported_book(self, *_):
"""import added a book, this adds related connections"""
shelf = self.local_user.shelf_set.filter(identifier="read").first()
shelf = self.local_user.shelf_set.filter(
identifier=models.Shelf.READ_FINISHED
).first()
self.assertIsNone(shelf.books.first())
import_job = self.importer.create_job(
@ -193,7 +195,9 @@ class GenericImporter(TestCase):
def test_handle_imported_book_already_shelved(self, *_):
"""import added a book, this adds related connections"""
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
shelf = self.local_user.shelf_set.filter(identifier="to-read").first()
shelf = self.local_user.shelf_set.filter(
identifier=models.Shelf.TO_READ
).first()
models.ShelfBook.objects.create(
shelf=shelf,
user=self.local_user,
@ -217,12 +221,16 @@ class GenericImporter(TestCase):
shelf.shelfbook_set.first().shelved_date, make_date(2020, 2, 2)
)
self.assertIsNone(
self.local_user.shelf_set.get(identifier="read").books.first()
self.local_user.shelf_set.get(
identifier=models.Shelf.READ_FINISHED
).books.first()
)
def test_handle_import_twice(self, *_):
"""re-importing books"""
shelf = self.local_user.shelf_set.filter(identifier="read").first()
shelf = self.local_user.shelf_set.filter(
identifier=models.Shelf.READ_FINISHED
).first()
import_job = self.importer.create_job(
self.local_user, self.csv, False, "public"
)

View file

@ -93,7 +93,9 @@ class LibrarythingImport(TestCase):
def test_handle_imported_book(self, *_):
"""librarything import added a book, this adds related connections"""
shelf = self.local_user.shelf_set.filter(identifier="read").first()
shelf = self.local_user.shelf_set.filter(
identifier=models.Shelf.READ_FINISHED
).first()
self.assertIsNone(shelf.books.first())
import_job = self.importer.create_job(
@ -117,7 +119,9 @@ class LibrarythingImport(TestCase):
def test_handle_imported_book_already_shelved(self, *_):
"""librarything import added a book, this adds related connections"""
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
shelf = self.local_user.shelf_set.filter(identifier="to-read").first()
shelf = self.local_user.shelf_set.filter(
identifier=models.Shelf.TO_READ
).first()
models.ShelfBook.objects.create(
shelf=shelf, user=self.local_user, book=self.book
)
@ -135,7 +139,9 @@ class LibrarythingImport(TestCase):
shelf.refresh_from_db()
self.assertEqual(shelf.books.first(), self.book)
self.assertIsNone(
self.local_user.shelf_set.get(identifier="read").books.first()
self.local_user.shelf_set.get(
identifier=models.Shelf.READ_FINISHED
).books.first()
)
readthrough = models.ReadThrough.objects.get(user=self.local_user)

View file

@ -70,7 +70,9 @@ class OpenLibraryImport(TestCase):
def test_handle_imported_book(self, *_):
"""openlibrary import added a book, this adds related connections"""
shelf = self.local_user.shelf_set.filter(identifier="reading").first()
shelf = self.local_user.shelf_set.filter(
identifier=models.Shelf.READING
).first()
self.assertIsNone(shelf.books.first())
import_job = self.importer.create_job(

View file

@ -62,7 +62,9 @@ class StorygraphImport(TestCase):
def test_handle_imported_book(self, *_):
"""storygraph import added a book, this adds related connections"""
shelf = self.local_user.shelf_set.filter(identifier="to-read").first()
shelf = self.local_user.shelf_set.filter(
identifier=models.Shelf.TO_READ
).first()
self.assertIsNone(shelf.books.first())
import_job = self.importer.create_job(

View file

@ -195,7 +195,7 @@ class ImportJob(TestCase):
) as search:
search.return_value = result
with patch(
"bookwyrm.connectors.openlibrary.Connector." "get_authors_from_data"
"bookwyrm.connectors.openlibrary.Connector.get_authors_from_data"
):
book = item.get_book_from_identifier()

View file

@ -462,6 +462,8 @@ class Status(TestCase):
@responses.activate
def test_ignore_activity_boost(self, *_):
"""don't bother with most remote statuses"""
responses.add(responses.GET, "http://fish.com/nothing")
activity = activitypub.Announce(
id="http://www.faraway.com/boost/12",
actor=self.remote_user.remote_id,

View file

@ -53,15 +53,17 @@ class User(TestCase):
def test_user_shelves(self):
shelves = models.Shelf.objects.filter(user=self.user).all()
self.assertEqual(len(shelves), 3)
self.assertEqual(len(shelves), 4)
names = [s.name for s in shelves]
self.assertTrue("To Read" in names)
self.assertTrue("Currently Reading" in names)
self.assertTrue("Read" in names)
self.assertTrue("Stopped Reading" in names)
ids = [s.identifier for s in shelves]
self.assertTrue("to-read" in ids)
self.assertTrue("reading" in ids)
self.assertTrue("read" in ids)
self.assertTrue("stopped-reading" in ids)
def test_activitypub_serialize(self):
activity = self.user.to_activity()

View file

@ -40,7 +40,8 @@ class RatingTags(TestCase):
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
def test_get_rating(self, *_):
"""privacy filtered rating"""
"""privacy filtered rating. Commented versions are how it ought to work with
subjective ratings, which are currenly not used for performance reasons."""
# follows-only: not included
models.ReviewRating.objects.create(
user=self.remote_user,
@ -48,7 +49,8 @@ class RatingTags(TestCase):
book=self.book,
privacy="followers",
)
self.assertEqual(rating_tags.get_rating(self.book, self.local_user), 0)
# self.assertEqual(rating_tags.get_rating(self.book, self.local_user), 0)
self.assertEqual(rating_tags.get_rating(self.book, self.local_user), 5)
# public: included
models.ReviewRating.objects.create(

View file

@ -102,18 +102,12 @@ class BookSearch(TestCase):
class TestConnector(AbstractMinimalConnector):
"""nothing added here"""
def format_search_result(self, search_result):
return search_result
def get_or_create_book(self, remote_id):
pass
def parse_search_data(self, data):
def parse_search_data(self, data, min_confidence):
return data
def format_isbn_search_result(self, search_result):
return search_result
def parse_isbn_search_data(self, data):
return data

View file

@ -9,6 +9,7 @@ from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import forms, models, views
from bookwyrm.views.books.edit_book import add_authors
from bookwyrm.tests.validate_html import validate_html
from bookwyrm.tests.views.books.test_book import _setup_cover_url
@ -214,3 +215,22 @@ class EditBookViews(TestCase):
self.book.refresh_from_db()
self.assertTrue(self.book.cover)
def test_add_authors_helper(self):
"""converts form input into author matches"""
form = forms.EditionForm(instance=self.book)
form.data["title"] = "New Title"
form.data["last_edited_by"] = self.local_user.id
form.data["add_author"] = ["Sappho", "Some Guy"]
request = self.factory.post("", form.data)
request.user = self.local_user
with patch("bookwyrm.utils.isni.find_authors_by_name") as mock:
mock.return_value = []
result = add_authors(request, form.data)
self.assertTrue(result["confirm_mode"])
self.assertEqual(result["add_author"], ["Sappho", "Some Guy"])
self.assertEqual(len(result["author_matches"]), 2)
self.assertEqual(result["author_matches"][0]["name"], "Sappho")
self.assertEqual(result["author_matches"][1]["name"], "Some Guy")

View file

@ -208,16 +208,44 @@ class InboxCreate(TestCase):
self.assertEqual(book_list.description, "summary text")
self.assertEqual(book_list.remote_id, "https://example.com/list/22")
def test_create_unsupported_type(self, *_):
def test_create_unsupported_type_question(self, *_):
"""ignore activities we know we can't handle"""
activity = self.create_json
activity["object"] = {
"id": "https://example.com/status/887",
"type": "Question",
}
# just observer how it doesn't throw an error
# just observe how it doesn't throw an error
views.inbox.activity_task(activity)
def test_create_unsupported_type_article(self, *_):
"""special case in unsupported type because we do know what it is"""
activity = self.create_json
activity["object"] = {
"id": "https://example.com/status/887",
"type": "Article",
"name": "hello",
"published": "2021-04-29T21:27:30.014235+00:00",
"attributedTo": "https://example.com/user/mouse",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/mouse/followers"],
"sensitive": False,
"@context": "https://www.w3.org/ns/activitystreams",
}
# just observe how it doesn't throw an error
views.inbox.activity_task(activity)
def test_create_unsupported_type_unknown(self, *_):
"""Something truly unexpected should throw an error"""
activity = self.create_json
activity["object"] = {
"id": "https://example.com/status/887",
"type": "Blaaaah",
}
# error this time
with self.assertRaises(ActivitySerializerError):
views.inbox.activity_task(activity)
def test_create_unknown_type(self, *_):
"""ignore activities we know we've never heard of"""
activity = self.create_json

View file

@ -1,6 +1,5 @@
""" test for app action functionality """
import json
import pathlib
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
@ -8,9 +7,9 @@ from django.http import JsonResponse
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
import responses
from bookwyrm import models, views
from bookwyrm.book_search import SearchResult
from bookwyrm.settings import DOMAIN
from bookwyrm.tests.validate_html import validate_html
@ -65,12 +64,11 @@ class Views(TestCase):
self.assertIsInstance(response, TemplateResponse)
validate_html(response.render())
@responses.activate
def test_search_books(self):
"""searches remote connectors"""
view = views.Search.as_view()
models.Connector.objects.create(
connector = models.Connector.objects.create(
identifier="example.com",
connector_file="openlibrary",
base_url="https://example.com",
@ -78,26 +76,24 @@ class Views(TestCase):
covers_url="https://example.com/covers",
search_url="https://example.com/search?q=",
)
datafile = pathlib.Path(__file__).parent.joinpath("../data/ol_search.json")
search_data = json.loads(datafile.read_bytes())
responses.add(
responses.GET, "https://example.com/search?q=Test%20Book", json=search_data
)
mock_result = SearchResult(title="Mock Book", connector=connector, key="hello")
request = self.factory.get("", {"q": "Test Book", "remote": True})
request.user = self.local_user
with patch("bookwyrm.views.search.is_api_request") as is_api:
is_api.return_value = False
response = view(request)
with patch("bookwyrm.connectors.connector_manager.search") as remote_search:
remote_search.return_value = [
{"results": [mock_result], "connector": connector}
]
response = view(request)
self.assertIsInstance(response, TemplateResponse)
validate_html(response.render())
connector_results = response.context_data["results"]
self.assertEqual(len(connector_results), 2)
self.assertEqual(connector_results[0]["results"][0].title, "Test Book")
self.assertEqual(
connector_results[1]["results"][0].title,
"This Is How You Lose the Time War",
)
self.assertEqual(connector_results[1]["results"][0].title, "Mock Book")
# don't search remote
request = self.factory.get("", {"q": "Test Book", "remote": True})
@ -106,7 +102,11 @@ class Views(TestCase):
request.user = anonymous_user
with patch("bookwyrm.views.search.is_api_request") as is_api:
is_api.return_value = False
response = view(request)
with patch("bookwyrm.connectors.connector_manager.search") as remote_search:
remote_search.return_value = [
{"results": [mock_result], "connector": connector}
]
response = view(request)
self.assertIsInstance(response, TemplateResponse)
validate_html(response.render())
connector_results = response.context_data["results"]

View file

@ -281,7 +281,7 @@ http://www.fish.com/"""
result = views.status.to_markdown(text)
self.assertEqual(
result,
'<p><em>hi</em> and <a href="http://fish.com">fish.com</a> ' "is rad</p>",
'<p><em>hi</em> and <a href="http://fish.com">fish.com</a> is rad</p>',
)
def test_to_markdown_detect_url(self, *_):
@ -297,7 +297,7 @@ http://www.fish.com/"""
"""this is mostly handled in other places, but nonetheless"""
text = "[hi](http://fish.com) is <marquee>rad</marquee>"
result = views.status.to_markdown(text)
self.assertEqual(result, '<p><a href="http://fish.com">hi</a> ' "is rad</p>")
self.assertEqual(result, '<p><a href="http://fish.com">hi</a> is rad</p>')
def test_delete_status(self, mock, *_):
"""marks a status as deleted"""

View file

@ -391,6 +391,9 @@ urlpatterns = [
re_path(
r"^group/(?P<group_id>\d+)(.json)?/?$", views.Group.as_view(), name="group"
),
re_path(
rf"^group/(?P<group_id>\d+){regex.SLUG}/?$", views.Group.as_view(), name="group"
),
re_path(
r"^group/delete/(?P<group_id>\d+)/?$", views.delete_group, name="delete-group"
),
@ -417,7 +420,10 @@ urlpatterns = [
re_path(rf"{USER_PATH}/lists/?$", views.UserLists.as_view(), name="user-lists"),
re_path(r"^list/?$", views.Lists.as_view(), name="lists"),
re_path(r"^list/saved/?$", views.SavedLists.as_view(), name="saved-lists"),
re_path(r"^list/(?P<list_id>\d+)(.json)?/?$", views.List.as_view(), name="list"),
re_path(r"^list/(?P<list_id>\d+)(\.json)?/?$", views.List.as_view(), name="list"),
re_path(
rf"^list/(?P<list_id>\d+){regex.SLUG}/?$", views.List.as_view(), name="list"
),
re_path(
r"^list/(?P<list_id>\d+)/item/(?P<list_item>\d+)/?$",
views.ListItem.as_view(),
@ -487,6 +493,7 @@ urlpatterns = [
re_path(r"^unblock/(?P<user_id>\d+)/?$", views.unblock),
# statuses
re_path(rf"{STATUS_PATH}(.json)?/?$", views.Status.as_view(), name="status"),
re_path(rf"{STATUS_PATH}{regex.SLUG}/?$", views.Status.as_view(), name="status"),
re_path(rf"{STATUS_PATH}/activity/?$", views.Status.as_view(), name="status"),
re_path(
rf"{STATUS_PATH}/replies(.json)?/?$", views.Replies.as_view(), name="replies"
@ -523,18 +530,27 @@ urlpatterns = [
re_path(r"^unboost/(?P<status_id>\d+)/?$", views.Unboost.as_view()),
# books
re_path(rf"{BOOK_PATH}(.json)?/?$", views.Book.as_view(), name="book"),
re_path(rf"{BOOK_PATH}{regex.SLUG}/?$", views.Book.as_view(), name="book"),
re_path(
rf"{BOOK_PATH}/(?P<user_statuses>review|comment|quote)/?$",
views.Book.as_view(),
name="book-user-statuses",
),
re_path(rf"{BOOK_PATH}/edit/?$", views.EditBook.as_view(), name="edit-book"),
re_path(rf"{BOOK_PATH}/confirm/?$", views.ConfirmEditBook.as_view()),
re_path(
rf"{BOOK_PATH}/confirm/?$",
views.ConfirmEditBook.as_view(),
name="edit-book-confirm",
),
re_path(
r"^create-book/data/?$", views.create_book_from_data, name="create-book-data"
),
re_path(r"^create-book/?$", views.CreateBook.as_view(), name="create-book"),
re_path(r"^create-book/confirm/?$", views.ConfirmEditBook.as_view()),
re_path(
r"^create-book/confirm/?$",
views.ConfirmEditBook.as_view(),
name="create-book-confirm",
),
re_path(rf"{BOOK_PATH}/editions(.json)?/?$", views.Editions.as_view()),
re_path(
r"^upload-cover/(?P<book_id>\d+)/?$", views.upload_cover, name="upload-cover"
@ -580,6 +596,11 @@ urlpatterns = [
re_path(
r"^author/(?P<author_id>\d+)(.json)?/?$", views.Author.as_view(), name="author"
),
re_path(
rf"^author/(?P<author_id>\d+){regex.SLUG}/?$",
views.Author.as_view(),
name="author",
),
re_path(
r"^author/(?P<author_id>\d+)/edit/?$",
views.EditAuthor.as_view(),
@ -601,7 +622,7 @@ urlpatterns = [
name="reading-status-update",
),
re_path(
r"^reading-status/(?P<status>want|start|finish)/(?P<book_id>\d+)/?$",
r"^reading-status/(?P<status>want|start|finish|stop)/(?P<book_id>\d+)/?$",
views.ReadingStatus.as_view(),
name="reading-status",
),

View file

@ -6,5 +6,6 @@ STRICT_LOCALNAME = r"@[a-zA-Z_\-\.0-9]+"
USERNAME = rf"{LOCALNAME}(@{DOMAIN})?"
STRICT_USERNAME = rf"\B{STRICT_LOCALNAME}(@{DOMAIN})?\b"
FULL_USERNAME = rf"{LOCALNAME}@{DOMAIN}\b"
SLUG = r"/s/(?P<slug>[-_a-z0-9]*)"
# should match (BookWyrm/1.0.0; or (BookWyrm/99.1.2;
BOOKWYRM_USER_AGENT = r"\(BookWyrm/[0-9]+\.[0-9]+\.[0-9]+;"

View file

@ -103,7 +103,7 @@ class Dashboard(View):
status="pending"
).count(),
"invite_requests": models.InviteRequest.objects.filter(
ignored=False, invite_sent=False
ignored=False, invite__isnull=True
).count(),
"user_stats": user_chart.get_chart(start, end, interval),
"status_stats": status_chart.get_chart(start, end, interval),

View file

@ -11,20 +11,24 @@ from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.connectors import connector_manager
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.views.helpers import is_api_request
from bookwyrm.views.helpers import is_api_request, maybe_redirect_local_path
# pylint: disable= no-self-use
class Author(View):
"""this person wrote a book"""
def get(self, request, author_id):
# pylint: disable=unused-argument
def get(self, request, author_id, slug=None):
"""landing page for an author"""
author = get_object_or_404(models.Author, id=author_id)
if is_api_request(request):
return ActivitypubResponse(author.to_activity())
if redirect_local_path := maybe_redirect_local_path(request, author):
return redirect_local_path
books = (
models.Work.objects.filter(editions__authors=author)
.order_by("created_date")

View file

@ -15,14 +15,14 @@ from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.connectors import connector_manager, ConnectorException
from bookwyrm.connectors.abstract_connector import get_image
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.views.helpers import is_api_request
from bookwyrm.views.helpers import is_api_request, maybe_redirect_local_path
# pylint: disable=no-self-use
class Book(View):
"""a book! this is the stuff"""
def get(self, request, book_id, user_statuses=False, update_error=False):
def get(self, request, book_id, **kwargs):
"""info about a book"""
if is_api_request(request):
book = get_object_or_404(
@ -30,7 +30,11 @@ class Book(View):
)
return ActivitypubResponse(book.to_activity())
user_statuses = user_statuses if request.user.is_authenticated else False
user_statuses = (
kwargs.get("user_statuses", False)
if request.user.is_authenticated
else False
)
# it's safe to use this OR because edition and work and subclasses of the same
# table, so they never have clashing IDs
@ -46,6 +50,11 @@ class Book(View):
if not book or not book.parent_work:
raise Http404()
if redirect_local_path := not user_statuses and maybe_redirect_local_path(
request, book
):
return redirect_local_path
# all reviews for all editions of the book
reviews = models.Review.privacy_filter(request.user).filter(
book__parent_work__editions=book
@ -80,7 +89,7 @@ class Book(View):
else None,
"rating": reviews.aggregate(Avg("rating"))["rating__avg"],
"lists": lists,
"update_error": update_error,
"update_error": kwargs.get("update_error", False),
}
if request.user.is_authenticated:

View file

@ -115,6 +115,7 @@ class CreateBook(View):
# go to confirm mode
if not parent_work_id or data.get("add_author"):
data["confirm_mode"] = True
return TemplateResponse(request, "book/edit/edit_book.html", data)
with transaction.atomic():
@ -189,7 +190,7 @@ def add_authors(request, data):
"existing_isnis": exists,
}
)
return data
return data
@require_POST

View file

@ -15,7 +15,7 @@ from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH, STREAMS
from bookwyrm.suggested_users import suggested_users
from .helpers import filter_stream_by_status_type, get_user_from_username
from .helpers import is_api_request, is_bookwyrm_request
from .helpers import is_api_request, is_bookwyrm_request, maybe_redirect_local_path
from .annual_summary import get_annual_summary_year
@ -113,7 +113,8 @@ class DirectMessage(View):
class Status(View):
"""get posting"""
def get(self, request, username, status_id):
# pylint: disable=unused-argument
def get(self, request, username, status_id, slug=None):
"""display a particular status (and replies, etc)"""
user = get_user_from_username(request.user, username)
status = get_object_or_404(
@ -130,6 +131,9 @@ class Status(View):
status.to_activity(pure=not is_bookwyrm_request(request))
)
if redirect_local_path := maybe_redirect_local_path(request, status):
return redirect_local_path
visible_thread = (
models.Status.privacy_filter(request.user)
.filter(thread_id=status.thread_id)

View file

@ -14,17 +14,22 @@ from django.db.models.functions import Greatest
from bookwyrm import forms, models
from bookwyrm.suggested_users import suggested_users
from .helpers import get_user_from_username
from .helpers import get_user_from_username, maybe_redirect_local_path
# pylint: disable=no-self-use
class Group(View):
"""group page"""
def get(self, request, group_id):
# pylint: disable=unused-argument
def get(self, request, group_id, slug=None):
"""display a group"""
group = get_object_or_404(models.Group, id=group_id)
group.raise_visible_to_user(request.user)
if redirect_local_path := maybe_redirect_local_path(request, group):
return redirect_local_path
lists = (
models.List.privacy_filter(request.user)
.filter(group=group)
@ -80,7 +85,8 @@ class Group(View):
class UserGroups(View):
"""a user's groups page"""
def get(self, request, username):
# pylint: disable=unused-argument
def get(self, request, username, slug=None):
"""display a group"""
user = get_user_from_username(request.user, username)
groups = (

View file

@ -8,6 +8,7 @@ from dateutil.parser import ParserError
from requests import HTTPError
from django.db.models import Q
from django.conf import settings as django_settings
from django.shortcuts import redirect
from django.http import Http404
from django.utils import translation
@ -137,6 +138,7 @@ def handle_reading_status(user, shelf, book, privacy):
"to-read": "wants to read",
"reading": "started reading",
"read": "finished reading",
"stopped-reading": "stopped reading",
}[shelf.identifier]
except KeyError:
# it's a non-standard shelf, don't worry about it
@ -201,3 +203,21 @@ def filter_stream_by_status_type(activities, allowed_types=None):
)
return activities
def maybe_redirect_local_path(request, model):
"""
if the request had an invalid path, return a permanent redirect response to the
correct one, including a slug if any.
if path is valid, returns False.
"""
# don't redirect empty path for unit tests which currently have this
if request.path in ("/", model.local_path):
return False
new_path = model.local_path
if len(request.GET) > 0:
new_path = f"{model.local_path}?{request.GET.urlencode()}"
return redirect(new_path, permanent=True)

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