Merge branch 'main' into production

This commit is contained in:
Mouse Reeve 2023-07-20 19:09:38 -07:00
commit 21f65cac58
41 changed files with 687 additions and 105 deletions

View file

@ -17,7 +17,7 @@ jobs:
- uses: actions/checkout@v3
- name: Install modules
run: npm install prettier
run: npm install prettier@2.5.1
- name: Run Prettier
run: npx prettier --check bookwyrm/static/js/*.js

333
FEDERATION.md Normal file
View file

@ -0,0 +1,333 @@
# Federation
BookWyrm uses the [ActivityPub](http://activitypub.rocks/) protocol to send and receive user activity between other BookWyrm instances and other services that implement ActivityPub. To handle book data, BookWyrm has a handful of extended Activity types which are not part of the standard, but are legible to other BookWyrm instances.
## Activities and Objects
### Users and relationships
User relationship interactions follow the standard ActivityPub spec.
- `Follow`: request to receive statuses from a user, and view their statuses that have followers-only privacy
- `Accept`: approves a `Follow` and finalizes the relationship
- `Reject`: denies a `Follow`
- `Block`: prevent users from seeing one another's statuses, and prevents the blocked user from viewing the actor's profile
- `Update`: updates a user's profile and settings
- `Delete`: deactivates a user
- `Undo`: reverses a `Follow` or `Block`
### Activities
- `Create/Status`: saves a new status in the database.
- `Delete/Status`: Removes a status
- `Like/Status`: Creates a favorite on the status
- `Announce/Status`: Boosts the status into the actor's timeline
- `Undo/*`,: Reverses a `Like` or `Announce`
### Collections
User's books and lists are represented by [`OrderedCollection`](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection)
### Statuses
BookWyrm is focused on book reading activities - it is not a general-purpose messaging application. For this reason, BookWyrm only accepts status `Create` activities if they are:
- Direct messages (i.e., `Note`s with the privacy level `direct`, which mention a local user),
- Related to a book (of a custom status type that includes the field `inReplyToBook`),
- Replies to existing statuses saved in the database
All other statuses will be received by the instance inbox, but by design **will not be delivered to user inboxes or displayed to users**.
### Custom Object types
With the exception of `Note`, the following object types are used in Bookwyrm but are not currently provided with a custom JSON-LD `@context` extension IRI. This is likely to change in future to make them true deserialisable JSON-LD objects.
##### Note
Within BookWyrm a `Note` is constructed according to [the ActivityStreams vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note), however `Note`s can only be created as direct messages or as replies to other statuses. As mentioned above, this also applies to incoming `Note`s.
##### Review
A `Review` is a status in response to a book (indicated by the `inReplyToBook` field), which has a title, body, and numerical rating between 0 (not rated) and 5.
Example:
```json
{
"id": "https://example.net/user/library_lurker/review/2",
"type": "Review",
"published": "2023-06-30T21:43:46.013132+00:00",
"attributedTo": "https://example.net/user/library_lurker",
"content": "<p>This is an enjoyable book with great characters.</p>",
"to": ["https://example.net/user/library_lurker/followers"],
"cc": [],
"replies": {
"id": "https://example.net/user/library_lurker/review/2/replies",
"type": "OrderedCollection",
"totalItems": 0,
"first": "https://example.net/user/library_lurker/review/2/replies?page=1",
"last": "https://example.net/user/library_lurker/review/2/replies?page=1",
"@context": "https://www.w3.org/ns/activitystreams"
},
"summary": "Spoilers ahead!",
"tag": [],
"attachment": [],
"sensitive": true,
"inReplyToBook": "https://example.net/book/1",
"name": "What a cracking read",
"rating": 4.5,
"@context": "https://www.w3.org/ns/activitystreams"
}
```
##### Comment
A `Comment` on a book mentions a book and has a message body, reading status, and progress indicator.
Example:
```json
{
"id": "https://example.net/user/library_lurker/comment/9",
"type": "Comment",
"published": "2023-06-30T21:43:46.013132+00:00",
"attributedTo": "https://example.net/user/library_lurker",
"content": "<p>This is a very enjoyable book so far.</p>",
"to": ["https://example.net/user/library_lurker/followers"],
"cc": [],
"replies": {
"id": "https://example.net/user/library_lurker/comment/9/replies",
"type": "OrderedCollection",
"totalItems": 0,
"first": "https://example.net/user/library_lurker/comment/9/replies?page=1",
"last": "https://example.net/user/library_lurker/comment/9/replies?page=1",
"@context": "https://www.w3.org/ns/activitystreams"
},
"summary": "Spoilers ahead!",
"tag": [],
"attachment": [],
"sensitive": true,
"inReplyToBook": "https://example.net/book/1",
"readingStatus": "reading",
"progress": 25,
"progressMode": "PG",
"@context": "https://www.w3.org/ns/activitystreams"
}
```
##### Quotation
A quotation (aka "quote") has a message body, an excerpt from a book including position as a page number or percentage indicator, and mentions a book.
Example:
```json
{
"id": "https://example.net/user/mouse/quotation/13",
"url": "https://example.net/user/mouse/quotation/13",
"inReplyTo": null,
"published": "2020-05-10T02:38:31.150343+00:00",
"attributedTo": "https://example.net/user/mouse",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://example.net/user/mouse/followers"
],
"sensitive": false,
"content": "I really like this quote",
"type": "Quotation",
"replies": {
"id": "https://example.net/user/mouse/quotation/13/replies",
"type": "Collection",
"first": {
"type": "CollectionPage",
"next": "https://example.net/user/mouse/quotation/13/replies?only_other_accounts=true&page=true",
"partOf": "https://example.net/user/mouse/quotation/13/replies",
"items": []
}
},
"inReplyToBook": "https://example.net/book/1",
"quote": "To be or not to be, that is the question.",
"position": 50,
"positionMode": "PCT",
"@context": "https://www.w3.org/ns/activitystreams"
}
```
### Custom Objects
##### Work
A particular book, a "work" in the [FRBR](https://en.wikipedia.org/wiki/Functional_Requirements_for_Bibliographic_Records) sense.
Example:
```json
{
"id": "https://bookwyrm.social/book/5988",
"type": "Work",
"authors": [
"https://bookwyrm.social/author/417"
],
"first_published_date": null,
"published_date": null,
"title": "Piranesi",
"sort_title": null,
"subtitle": null,
"description": "**From the *New York Times* bestselling author of *Jonathan Strange & Mr. Norrell*, an intoxicating, hypnotic new novel set in a dreamlike alternative reality.",
"languages": [],
"series": null,
"series_number": null,
"subjects": [
"English literature"
],
"subject_places": [],
"openlibrary_key": "OL20893680W",
"librarything_key": null,
"goodreads_key": null,
"attachment": [
{
"url": "https://bookwyrm.social/images/covers/10226290-M.jpg",
"type": "Image"
}
],
"lccn": null,
"editions": [
"https://bookwyrm.social/book/5989"
],
"@context": "https://www.w3.org/ns/activitystreams"
}
```
##### Edition
A particular _manifestation_ of a Work, in the [FRBR](https://en.wikipedia.org/wiki/Functional_Requirements_for_Bibliographic_Records) sense.
Example:
```json
{
"id": "https://bookwyrm.social/book/5989",
"lastEditedBy": "https://example.net/users/rat",
"type": "Edition",
"authors": [
"https://bookwyrm.social/author/417"
],
"first_published_date": null,
"published_date": "2020-09-15T00:00:00+00:00",
"title": "Piranesi",
"sort_title": null,
"subtitle": null,
"description": "Piranesi's house is no ordinary building; its rooms are infinite, its corridors endless, its walls are lined with thousands upon thousands of statues, each one different from all the others.",
"languages": [
"English"
],
"series": null,
"series_number": null,
"subjects": [],
"subject_places": [],
"openlibrary_key": "OL29486417M",
"librarything_key": null,
"goodreads_key": null,
"isfdb": null,
"attachment": [
{
"url": "https://bookwyrm.social/images/covers/50202953._SX318_.jpg",
"type": "Image"
}
],
"isbn_10": "1526622424",
"isbn_13": "9781526622426",
"oclc_number": null,
"asin": null,
"pages": 272,
"physical_format": null,
"publishers": [
"Bloomsbury Publishing Plc"
],
"work": "https://bookwyrm.social/book/5988",
"@context": "https://www.w3.org/ns/activitystreams"
}
```
#### Shelf
A user's book collection. By default, every user has a `to-read`, `reading`, `read`, and `stopped-reading` shelf which are used to track reading progress. Users may create an unlimited number of additional shelves with their own ids.
Example
```json
{
"id": "https://example.net/user/avid_reader/books/extraspecialbooks-5",
"type": "Shelf",
"totalItems": 0,
"first": "https://example.net/user/avid_reader/books/extraspecialbooks-5?page=1",
"last": "https://example.net/user/avid_reader/books/extraspecialbooks-5?page=1",
"name": "Extra special books",
"owner": "https://example.net/user/avid_reader",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://example.net/user/avid_reader/followers"
],
"@context": "https://www.w3.org/ns/activitystreams"
}
```
#### List
A collection of books that may have items contributed by users other than the one who created the list.
Example:
```json
{
"id": "https://example.net/list/1",
"type": "BookList",
"totalItems": 0,
"first": "https://example.net/list/1?page=1",
"last": "https://example.net/list/1?page=1",
"name": "My cool list",
"owner": "https://example.net/user/avid_reader",
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
"https://example.net/user/avid_reader/followers"
],
"summary": "A list of books I like.",
"curation": "curated",
"@context": "https://www.w3.org/ns/activitystreams"
}
```
#### Activities
- `Create`: Adds a shelf or list to the database.
- `Delete`: Removes a shelf or list.
- `Add`: Adds a book to a shelf or list.
- `Remove`: Removes a book from a shelf or list.
## Alternative Serialization
Because BookWyrm uses custom object types that aren't listed in [the standard ActivityStreams Vocabulary](https://www.w3.org/TR/activitystreams-vocabulary), some statuses are transformed into standard types when sent to or viewed by non-BookWyrm services. `Review`s are converted into `Article`s, and `Comment`s and `Quotation`s are converted into `Note`s, with a link to the book and the cover image attached.
In future this may be done with [JSON-LD type arrays](https://www.w3.org/TR/json-ld/#specifying-the-type) instead.
## Other extensions
### Webfinger
Bookwyrm uses the [Webfinger](https://datatracker.ietf.org/doc/html/rfc7033) standard to identify and disambiguate fediverse actors. The [Webfinger documentation on the Mastodon project](https://docs.joinmastodon.org/spec/webfinger/) provides a good overview of how Webfinger is used.
### HTTP Signatures
Bookwyrm uses and requires HTTP signatures for all `POST` requests. `GET` requests are not signed by default, but if Bookwyrm receives a `403` response to a `GET` it will re-send the request, signed by the default server user. This usually will have a user id of `https://example.net/user/bookwyrm.instance.actor`
#### publicKey id
In older versions of Bookwyrm the `publicKey.id` was incorrectly listed in request headers as `https://example.net/user/username#main-key`. As of v0.6.3 the id is now listed correctly, as `https://example.net/user/username/#main-key`. In most ActivityPub implementations this will make no difference as the URL will usually resolve to the same place.
### NodeInfo
Bookwyrm uses the [NodeInfo](http://nodeinfo.diaspora.software/) standard to provide statistics and version information for each instance.
## Further Documentation
See [docs.joinbookwyrm.com/](https://docs.joinbookwyrm.com/) for more documentation.

View file

@ -12,7 +12,7 @@ from bookwyrm import models
from bookwyrm.connectors import ConnectorException, get_data
from bookwyrm.signatures import make_signature
from bookwyrm.settings import DOMAIN, INSTANCE_ACTOR_USERNAME
from bookwyrm.tasks import app, MEDIUM
from bookwyrm.tasks import app, MISC
logger = logging.getLogger(__name__)
@ -241,7 +241,7 @@ class ActivityObject:
return data
@app.task(queue=MEDIUM)
@app.task(queue=MISC)
@transaction.atomic
def set_related_field(
model_name, origin_model_name, related_field_name, related_remote_id, data

View file

@ -8,7 +8,7 @@ from opentelemetry import trace
from bookwyrm import models
from bookwyrm.redis_store import RedisStore, r
from bookwyrm.tasks import app, LOW, MEDIUM, HIGH
from bookwyrm.tasks import app, STREAMS, IMPORT_TRIGGERED
from bookwyrm.telemetry import open_telemetry
@ -343,7 +343,7 @@ def add_status_on_create(sender, instance, created, *args, **kwargs):
def add_status_on_create_command(sender, instance, created):
"""runs this code only after the database commit completes"""
priority = HIGH
priority = STREAMS
# 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)
if instance.published_date < timezone.now() - timedelta(
@ -353,7 +353,7 @@ def add_status_on_create_command(sender, instance, created):
if instance.user.local:
return
# an out of date remote status is a low priority but should be added
priority = LOW
priority = IMPORT_TRIGGERED
add_status_task.apply_async(
args=(instance.id,),
@ -497,7 +497,7 @@ def remove_statuses_on_unshelve(sender, instance, *args, **kwargs):
# ---- TASKS
@app.task(queue=LOW)
@app.task(queue=STREAMS)
def add_book_statuses_task(user_id, book_id):
"""add statuses related to a book on shelve"""
user = models.User.objects.get(id=user_id)
@ -505,7 +505,7 @@ def add_book_statuses_task(user_id, book_id):
BooksStream().add_book_statuses(user, book)
@app.task(queue=LOW)
@app.task(queue=STREAMS)
def remove_book_statuses_task(user_id, book_id):
"""remove statuses about a book from a user's books feed"""
user = models.User.objects.get(id=user_id)
@ -513,7 +513,7 @@ def remove_book_statuses_task(user_id, book_id):
BooksStream().remove_book_statuses(user, book)
@app.task(queue=MEDIUM)
@app.task(queue=STREAMS)
def populate_stream_task(stream, user_id):
"""background task for populating an empty activitystream"""
user = models.User.objects.get(id=user_id)
@ -521,7 +521,7 @@ def populate_stream_task(stream, user_id):
stream.populate_streams(user)
@app.task(queue=MEDIUM)
@app.task(queue=STREAMS)
def remove_status_task(status_ids):
"""remove a status from any stream it might be in"""
# this can take an id or a list of ids
@ -536,7 +536,7 @@ def remove_status_task(status_ids):
)
@app.task(queue=HIGH)
@app.task(queue=STREAMS)
def add_status_task(status_id, increment_unread=False):
"""add a status to any stream it should be in"""
status = models.Status.objects.select_subclasses().get(id=status_id)
@ -548,7 +548,7 @@ def add_status_task(status_id, increment_unread=False):
stream.add_status(status, increment_unread=increment_unread)
@app.task(queue=MEDIUM)
@app.task(queue=STREAMS)
def remove_user_statuses_task(viewer_id, user_id, stream_list=None):
"""remove all statuses by a user from a viewer's stream"""
stream_list = [streams[s] for s in stream_list] if stream_list else streams.values()
@ -558,7 +558,7 @@ def remove_user_statuses_task(viewer_id, user_id, stream_list=None):
stream.remove_user_statuses(viewer, user)
@app.task(queue=MEDIUM)
@app.task(queue=STREAMS)
def add_user_statuses_task(viewer_id, user_id, stream_list=None):
"""add all statuses by a user to a viewer's stream"""
stream_list = [streams[s] for s in stream_list] if stream_list else streams.values()
@ -568,7 +568,7 @@ def add_user_statuses_task(viewer_id, user_id, stream_list=None):
stream.add_user_statuses(viewer, user)
@app.task(queue=MEDIUM)
@app.task(queue=STREAMS)
def handle_boost_task(boost_id):
"""remove the original post and other, earlier boosts"""
instance = models.Status.objects.get(id=boost_id)

View file

@ -13,7 +13,7 @@ from requests import HTTPError
from bookwyrm import book_search, models
from bookwyrm.settings import SEARCH_TIMEOUT
from bookwyrm.tasks import app, LOW
from bookwyrm.tasks import app, CONNECTORS
logger = logging.getLogger(__name__)
@ -109,7 +109,7 @@ def get_or_create_connector(remote_id):
return load_connector(connector_info)
@app.task(queue=LOW)
@app.task(queue=CONNECTORS)
def load_more_data(connector_id, book_id):
"""background the work of getting all 10,000 editions of LoTR"""
connector_info = models.Connector.objects.get(id=connector_id)
@ -118,7 +118,7 @@ def load_more_data(connector_id, book_id):
connector.expand_book_data(book)
@app.task(queue=LOW)
@app.task(queue=CONNECTORS)
def create_edition_task(connector_id, work_id, data):
"""separate task for each of the 10,000 editions of LoTR"""
connector_info = models.Connector.objects.get(id=connector_id)

View file

@ -3,7 +3,7 @@ from django.core.mail import EmailMultiAlternatives
from django.template.loader import get_template
from bookwyrm import models, settings
from bookwyrm.tasks import app, HIGH
from bookwyrm.tasks import app, EMAIL
from bookwyrm.settings import DOMAIN
@ -75,7 +75,7 @@ def format_email(email_name, data):
return (subject, html_content, text_content)
@app.task(queue=HIGH)
@app.task(queue=EMAIL)
def send_email(recipient, subject, html_content, text_content):
"""use a task to send the email"""
email = EmailMultiAlternatives(

View file

@ -20,6 +20,7 @@ class EditionForm(CustomForm):
model = models.Edition
fields = [
"title",
"sort_title",
"subtitle",
"description",
"series",
@ -45,6 +46,9 @@ class EditionForm(CustomForm):
]
widgets = {
"title": forms.TextInput(attrs={"aria-describedby": "desc_title"}),
"sort_title": forms.TextInput(
attrs={"aria-describedby": "desc_sort_title"}
),
"subtitle": forms.TextInput(attrs={"aria-describedby": "desc_subtitle"}),
"description": forms.Textarea(
attrs={"aria-describedby": "desc_description"}

View file

@ -24,7 +24,7 @@ class SortListForm(forms.Form):
sort_by = ChoiceField(
choices=(
("order", _("List Order")),
("title", _("Book Title")),
("sort_title", _("Book Title")),
("rating", _("Rating")),
),
label=_("Sort By"),

View file

@ -5,7 +5,7 @@ from django.db.models import signals, Count, Q
from bookwyrm import models
from bookwyrm.redis_store import RedisStore
from bookwyrm.tasks import app, MEDIUM, HIGH
from bookwyrm.tasks import app, LISTS
class ListsStream(RedisStore):
@ -217,14 +217,14 @@ def add_list_on_account_create_command(user_id):
# ---- TASKS
@app.task(queue=MEDIUM)
@app.task(queue=LISTS)
def populate_lists_task(user_id):
"""background task for populating an empty list stream"""
user = models.User.objects.get(id=user_id)
ListsStream().populate_lists(user)
@app.task(queue=MEDIUM)
@app.task(queue=LISTS)
def remove_list_task(list_id, re_add=False):
"""remove a list from any stream it might be in"""
stores = models.User.objects.filter(local=True, is_active=True).values_list(
@ -239,14 +239,14 @@ def remove_list_task(list_id, re_add=False):
add_list_task.delay(list_id)
@app.task(queue=HIGH)
@app.task(queue=LISTS)
def add_list_task(list_id):
"""add a list to any stream it should be in"""
book_list = models.List.objects.get(id=list_id)
ListsStream().add_list(book_list)
@app.task(queue=MEDIUM)
@app.task(queue=LISTS)
def remove_user_lists_task(viewer_id, user_id, exclude_privacy=None):
"""remove all lists by a user from a viewer's stream"""
viewer = models.User.objects.get(id=viewer_id)
@ -254,7 +254,7 @@ def remove_user_lists_task(viewer_id, user_id, exclude_privacy=None):
ListsStream().remove_user_lists(viewer, user, exclude_privacy=exclude_privacy)
@app.task(queue=MEDIUM)
@app.task(queue=LISTS)
def add_user_lists_task(viewer_id, user_id):
"""add all lists by a user to a viewer's stream"""
viewer = models.User.objects.get(id=viewer_id)

View file

@ -0,0 +1,49 @@
import re
from itertools import chain
from django.db import migrations, transaction
from django.db.models import Q
from bookwyrm.settings import LANGUAGE_ARTICLES
def set_sort_title(edition):
articles = chain(
*(LANGUAGE_ARTICLES.get(language, ()) for language in tuple(edition.languages))
)
edition.sort_title = re.sub(
f'^{" |^".join(articles)} ', "", str(edition.title).lower()
)
return edition
@transaction.atomic
def populate_sort_title(apps, schema_editor):
Edition = apps.get_model("bookwyrm", "Edition")
db_alias = schema_editor.connection.alias
editions_wo_sort_title = Edition.objects.using(db_alias).filter(
Q(sort_title__isnull=True) | Q(sort_title__exact="")
)
batch_size = 1000
start = 0
end = batch_size
while True:
batch = editions_wo_sort_title[start:end]
if not batch.exists():
break
Edition.objects.bulk_update(
(set_sort_title(edition) for edition in batch), ["sort_title"]
)
start = end
end += batch_size
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0178_auto_20230328_2132"),
]
operations = [
migrations.RunPython(populate_sort_title),
]

View file

@ -21,7 +21,7 @@ from django.utils.http import http_date
from bookwyrm import activitypub
from bookwyrm.settings import USER_AGENT, PAGE_LENGTH
from bookwyrm.signatures import make_signature, make_digest
from bookwyrm.tasks import app, MEDIUM, BROADCAST
from bookwyrm.tasks import app, BROADCAST
from bookwyrm.models.fields import ImageField, ManyToManyField
logger = logging.getLogger(__name__)
@ -379,7 +379,7 @@ class CollectionItemMixin(ActivitypubMixin):
activity_serializer = activitypub.CollectionItem
def broadcast(self, activity, sender, software="bookwyrm", queue=MEDIUM):
def broadcast(self, activity, sender, software="bookwyrm", queue=BROADCAST):
"""only send book collection updates to other bookwyrm instances"""
super().broadcast(activity, sender, software=software, queue=queue)
@ -400,7 +400,7 @@ class CollectionItemMixin(ActivitypubMixin):
return []
return [collection_field.user]
def save(self, *args, broadcast=True, priority=MEDIUM, **kwargs):
def save(self, *args, broadcast=True, priority=BROADCAST, **kwargs):
"""broadcast updated"""
# first off, we want to save normally no matter what
super().save(*args, **kwargs)
@ -444,7 +444,7 @@ class CollectionItemMixin(ActivitypubMixin):
class ActivityMixin(ActivitypubMixin):
"""add this mixin for models that are AP serializable"""
def save(self, *args, broadcast=True, priority=MEDIUM, **kwargs):
def save(self, *args, broadcast=True, priority=BROADCAST, **kwargs):
"""broadcast activity"""
super().save(*args, **kwargs)
user = self.user if hasattr(self, "user") else self.user_subject

View file

@ -8,7 +8,7 @@ from django.db import models, transaction
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from bookwyrm.tasks import app, LOW
from bookwyrm.tasks import app, MISC
from .base_model import BookWyrmModel
from .user import User
@ -65,7 +65,7 @@ class AutoMod(AdminModel):
created_by = models.ForeignKey("User", on_delete=models.PROTECT)
@app.task(queue=LOW)
@app.task(queue=MISC)
def automod_task():
"""Create reports"""
if not AutoMod.objects.exists():

View file

@ -1,4 +1,5 @@
""" database schema for books and shelves """
from itertools import chain
import re
from django.contrib.postgres.search import SearchVectorField
@ -17,6 +18,7 @@ from bookwyrm.preview_images import generate_edition_preview_image_task
from bookwyrm.settings import (
DOMAIN,
DEFAULT_LANGUAGE,
LANGUAGE_ARTICLES,
ENABLE_PREVIEW_IMAGES,
ENABLE_THUMBNAIL_GENERATION,
)
@ -363,6 +365,19 @@ class Edition(Book):
for author_id in self.authors.values_list("id", flat=True):
cache.delete(f"author-books-{author_id}")
# Create sort title by removing articles from title
if self.sort_title in [None, ""]:
if self.sort_title in [None, ""]:
articles = chain(
*(
LANGUAGE_ARTICLES.get(language, ())
for language in tuple(self.languages)
)
)
self.sort_title = re.sub(
f'^{" |^".join(articles)} ', "", str(self.title).lower()
)
return super().save(*args, **kwargs)
@classmethod

View file

@ -19,7 +19,7 @@ from bookwyrm.models import (
Review,
ReviewRating,
)
from bookwyrm.tasks import app, LOW, IMPORTS
from bookwyrm.tasks import app, IMPORT_TRIGGERED, IMPORTS
from .fields import PrivacyLevels
@ -399,7 +399,7 @@ def handle_imported_book(item):
shelved_date = item.date_added or timezone.now()
ShelfBook(
book=item.book, shelf=desired_shelf, user=user, shelved_date=shelved_date
).save(priority=LOW)
).save(priority=IMPORT_TRIGGERED)
for read in item.reads:
# check for an existing readthrough with the same dates
@ -441,7 +441,7 @@ def handle_imported_book(item):
published_date=published_date_guess,
privacy=job.privacy,
)
review.save(software="bookwyrm", priority=LOW)
review.save(software="bookwyrm", priority=IMPORT_TRIGGERED)
else:
# just a rating
review = ReviewRating.objects.filter(
@ -458,7 +458,7 @@ def handle_imported_book(item):
published_date=published_date_guess,
privacy=job.privacy,
)
review.save(software="bookwyrm", priority=LOW)
review.save(software="bookwyrm", priority=IMPORT_TRIGGERED)
# only broadcast this review to other bookwyrm instances
item.linked_review = review

View file

@ -4,7 +4,6 @@ from django.db import models, transaction, IntegrityError
from django.db.models import Q
from bookwyrm import activitypub
from bookwyrm.tasks import HIGH
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
from .activitypub_mixin import generate_activity
from .base_model import BookWyrmModel
@ -142,7 +141,7 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
# a local user is following a remote user
if broadcast and self.user_subject.local and not self.user_object.local:
self.broadcast(self.to_activity(), self.user_subject, queue=HIGH)
self.broadcast(self.to_activity(), self.user_subject)
if self.user_object.local:
manually_approves = self.user_object.manually_approves_followers
@ -166,7 +165,7 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
actor=self.user_object.remote_id,
object=self.to_activity(),
).serialize()
self.broadcast(activity, user, queue=HIGH)
self.broadcast(activity, user)
if broadcast_only:
return
@ -187,7 +186,7 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
actor=self.user_object.remote_id,
object=self.to_activity(),
).serialize()
self.broadcast(activity, self.user_object, queue=HIGH)
self.broadcast(activity, self.user_object)
self.delete()

View file

@ -7,7 +7,7 @@ from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from bookwyrm.tasks import LOW
from bookwyrm.tasks import BROADCAST
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
from . import fields
@ -40,7 +40,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
activity_serializer = activitypub.Shelf
def save(self, *args, priority=LOW, **kwargs):
def save(self, *args, priority=BROADCAST, **kwargs):
"""set the identifier"""
super().save(*args, priority=priority, **kwargs)
if not self.identifier:
@ -100,7 +100,7 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
activity_serializer = activitypub.ShelfItem
collection_field = "shelf"
def save(self, *args, priority=LOW, **kwargs):
def save(self, *args, priority=BROADCAST, **kwargs):
if not self.user:
self.user = self.shelf.user
if self.id and self.user.local:

View file

@ -20,7 +20,7 @@ from bookwyrm.models.status import Status
from bookwyrm.preview_images import generate_user_preview_image_task
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES
from bookwyrm.signatures import create_key_pair
from bookwyrm.tasks import app, LOW
from bookwyrm.tasks import app, MISC
from bookwyrm.utils import regex
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
from .base_model import BookWyrmModel, DeactivationReason, new_access_code
@ -394,6 +394,8 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def reactivate(self):
"""Now you want to come back, huh?"""
# pylint: disable=attribute-defined-outside-init
if not self.allow_reactivation:
return
self.is_active = True
self.deactivation_reason = None
self.allow_reactivation = False
@ -469,7 +471,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
return super().save(*args, **kwargs)
@app.task(queue=LOW)
@app.task(queue=MISC)
def set_remote_server(user_id, allow_external_connections=False):
"""figure out the user's remote server in the background"""
user = User.objects.get(id=user_id)
@ -528,7 +530,7 @@ def get_or_create_remote_server(
return server
@app.task(queue=LOW)
@app.task(queue=MISC)
def get_remote_reviews(outbox):
"""ingest reviews by a new remote bookwyrm user"""
outbox_page = outbox + "?page=true&type=Review"

View file

@ -16,7 +16,7 @@ from django.core.files.storage import default_storage
from django.db.models import Avg
from bookwyrm import models, settings
from bookwyrm.tasks import app, LOW
from bookwyrm.tasks import app, IMAGES
logger = logging.getLogger(__name__)
@ -420,7 +420,7 @@ def save_and_cleanup(image, instance=None):
# pylint: disable=invalid-name
@app.task(queue=LOW)
@app.task(queue=IMAGES)
def generate_site_preview_image_task():
"""generate preview_image for the website"""
if not settings.ENABLE_PREVIEW_IMAGES:
@ -445,7 +445,7 @@ def generate_site_preview_image_task():
# pylint: disable=invalid-name
@app.task(queue=LOW)
@app.task(queue=IMAGES)
def generate_edition_preview_image_task(book_id):
"""generate preview_image for a book"""
if not settings.ENABLE_PREVIEW_IMAGES:
@ -470,7 +470,7 @@ def generate_edition_preview_image_task(book_id):
save_and_cleanup(image, instance=book)
@app.task(queue=LOW)
@app.task(queue=IMAGES)
def generate_user_preview_image_task(user_id):
"""generate preview_image for a user"""
if not settings.ENABLE_PREVIEW_IMAGES:
@ -496,7 +496,7 @@ def generate_user_preview_image_task(user_id):
save_and_cleanup(image, instance=user)
@app.task(queue=LOW)
@app.task(queue=IMAGES)
def remove_user_preview_image_task(user_id):
"""remove preview_image for a user"""
if not settings.ENABLE_PREVIEW_IMAGES:

View file

@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
env = Env()
env.read_env()
DOMAIN = env("DOMAIN")
VERSION = "0.6.3"
VERSION = "0.6.4"
RELEASE_API = env(
"RELEASE_API",
@ -22,7 +22,7 @@ RELEASE_API = env(
PAGE_LENGTH = env.int("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
JS_CACHE = "d993847c"
JS_CACHE = "b972a43c"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
@ -312,6 +312,9 @@ LANGUAGES = [
("zh-hant", _("繁體中文 (Traditional Chinese)")),
]
LANGUAGE_ARTICLES = {
"English": {"the", "a", "an"},
}
TIME_ZONE = "UTC"

View file

@ -40,9 +40,6 @@ let BookWyrm = new (class {
document.querySelectorAll("details.dropdown").forEach((node) => {
node.addEventListener("toggle", this.handleDetailsDropdown.bind(this));
node.querySelectorAll("[data-modal-open]").forEach((modal_node) =>
modal_node.addEventListener("click", () => (node.open = false))
);
});
document

View file

@ -8,7 +8,7 @@ from opentelemetry import trace
from bookwyrm import models
from bookwyrm.redis_store import RedisStore, r
from bookwyrm.tasks import app, LOW, MEDIUM
from bookwyrm.tasks import app, SUGGESTED_USERS
from bookwyrm.telemetry import open_telemetry
@ -244,20 +244,20 @@ def domain_level_update(sender, instance, created, update_fields=None, **kwargs)
# ------------------- TASKS
@app.task(queue=LOW)
@app.task(queue=SUGGESTED_USERS)
def rerank_suggestions_task(user_id):
"""do the hard work in celery"""
suggested_users.rerank_user_suggestions(user_id)
@app.task(queue=LOW)
@app.task(queue=SUGGESTED_USERS)
def rerank_user_task(user_id, update_only=False):
"""do the hard work in celery"""
user = models.User.objects.get(id=user_id)
suggested_users.rerank_obj(user, update_only=update_only)
@app.task(queue=LOW)
@app.task(queue=SUGGESTED_USERS)
def remove_user_task(user_id):
"""do the hard work in celery"""
user = models.User.objects.get(id=user_id)
@ -266,14 +266,14 @@ def remove_user_task(user_id):
)
@app.task(queue=MEDIUM)
@app.task(queue=SUGGESTED_USERS)
def remove_suggestion_task(user_id, suggested_user_id):
"""remove a specific user from a specific user's suggestions"""
suggested_user = models.User.objects.get(id=suggested_user_id)
suggested_users.remove_suggestion(user_id, suggested_user)
@app.task(queue=LOW)
@app.task(queue=SUGGESTED_USERS)
def bulk_remove_instance_task(instance_id):
"""remove a bunch of users from recs"""
for user in models.User.objects.filter(federated_server__id=instance_id):
@ -282,7 +282,7 @@ def bulk_remove_instance_task(instance_id):
)
@app.task(queue=LOW)
@app.task(queue=SUGGESTED_USERS)
def bulk_add_instance_task(instance_id):
"""remove a bunch of users from recs"""
for user in models.User.objects.filter(federated_server__id=instance_id):

View file

@ -10,11 +10,19 @@ app = Celery(
"tasks", broker=settings.CELERY_BROKER_URL, backend=settings.CELERY_RESULT_BACKEND
)
# priorities
# priorities - for backwards compatibility, will be removed next release
LOW = "low_priority"
MEDIUM = "medium_priority"
HIGH = "high_priority"
# import items get their own queue because they're such a pain in the ass
STREAMS = "streams"
IMAGES = "images"
SUGGESTED_USERS = "suggested_users"
EMAIL = "email"
CONNECTORS = "connectors"
LISTS = "lists"
INBOX = "inbox"
IMPORTS = "imports"
# I keep making more queues?? this one broadcasting out
IMPORT_TRIGGERED = "import_triggered"
BROADCAST = "broadcast"
MISC = "misc"

View file

@ -28,6 +28,15 @@
{% include 'snippets/form_errors.html' with errors_list=form.title.errors id="desc_title" %}
</div>
<div class="field">
<label class="label" for="id_sort_title">
{% trans "Sort Title:" %}
</label>
<input type="text" name="sort_title" value="{{ form.sort_title.value|default:'' }}" maxlength="255" class="input" required="" id="id_sort_title" aria-describedby="desc_sort_title">
{% include 'snippets/form_errors.html' with errors_list=form.sort_title.errors id="desc_sort_title" %}
</div>
<div class="field">
<label class="label" for="id_subtitle">
{% trans "Subtitle:" %}

View file

@ -17,8 +17,14 @@
{% if site.imports_enabled %}
{% if import_size_limit and import_limit_reset %}
<div class="notification">
<p>{% blocktrans %}Currently you are allowed to import {{ import_size_limit }} books every {{ import_limit_reset }} days.{% endblocktrans %}</p>
<p>{% blocktrans %}You have {{ allowed_imports }} left.{% endblocktrans %}</p>
<p>
{% blocktrans count days=import_limit_reset with display_size=import_size_limit|intcomma %}
Currently, you are allowed to import {{ display_size }} books every {{ import_limit_reset }} day.
{% plural %}
Currently, you are allowed to import {{ import_size_limit }} books every {{ import_limit_reset }} days.
{% endblocktrans %}
</p>
<p>{% blocktrans with display_left=allowed_imports|intcomma %}You have {{ display_left }} left.{% endblocktrans %}</p>
</div>
{% endif %}
{% if recent_avg_hours or recent_avg_minutes %}

View file

@ -21,6 +21,76 @@
<section class="block content">
<h2>{% trans "Queues" %}</h2>
<div class="columns has-text-centered is-multiline">
<div class="column is-4">
<div class="notification">
<p class="header">{% trans "Streams" %}</p>
<p class="title is-5">{{ queues.streams|intcomma }}</p>
</div>
</div>
<div class="column is-4">
<div class="notification">
<p class="header">{% trans "Broadcasts" %}</p>
<p class="title is-5">{{ queues.broadcast|intcomma }}</p>
</div>
</div>
<div class="column is-4">
<div class="notification">
<p class="header">{% trans "Inbox" %}</p>
<p class="title is-5">{{ queues.inbox|intcomma }}</p>
</div>
</div>
<div class="column is-4">
<div class="notification">
<p class="header">{% trans "Imports" %}</p>
<p class="title is-5">{{ queues.imports|intcomma }}</p>
</div>
</div>
<div class="column is-4">
<div class="notification">
<p class="header">{% trans "Import triggered" %}</p>
<p class="title is-5">{{ queues.import_triggered|intcomma }}</p>
</div>
</div>
<div class="column is-4">
<div class="notification">
<p class="header">{% trans "Connectors" %}</p>
<p class="title is-5">{{ queues.connectors|intcomma }}</p>
</div>
</div>
<div class="column is-6">
<div class="notification">
<p class="header">{% trans "Images" %}</p>
<p class="title is-5">{{ queues.images|intcomma }}</p>
</div>
</div>
<div class="column is-6">
<div class="notification">
<p class="header">{% trans "Suggested Users" %}</p>
<p class="title is-5">{{ queues.suggested_users|intcomma }}</p>
</div>
</div>
<div class="column is-4">
<div class="notification">
<p class="header">{% trans "Lists" %}</p>
<p class="title is-5">{{ queues.lists|intcomma }}</p>
</div>
</div>
<div class="column is-4">
<div class="notification">
<p class="header">{% trans "Email" %}</p>
<p class="title is-5">{{ queues.email|intcomma }}</p>
</div>
</div>
<div class="column is-4">
<div class="notification">
<p class="header">{% trans "Misc" %}</p>
<p class="title is-5">{{ queues.misc|intcomma }}</p>
</div>
</div>
<div class="column is-4">
<div class="notification">
<p class="header">{% trans "Low priority" %}</p>
@ -39,18 +109,6 @@
<p class="title is-5">{{ queues.high_priority|intcomma }}</p>
</div>
</div>
<div class="column is-6">
<div class="notification">
<p class="header">{% trans "Imports" %}</p>
<p class="title is-5">{{ queues.imports|intcomma }}</p>
</div>
</div>
<div class="column is-6">
<div class="notification">
<p class="header">{% trans "Broadcasts" %}</p>
<p class="title is-5">{{ queues.broadcast|intcomma }}</p>
</div>
</div>
</div>
</section>
{% else %}

View file

@ -145,7 +145,7 @@
<thead>
<tr>
<th>{% trans "Cover"%}</th>
<th>{% trans "Title" as text %}{% include 'snippets/table-sort-header.html' with field="title" sort=sort text=text %}</th>
<th>{% trans "Title" as text %}{% include 'snippets/table-sort-header.html' with field="sort_title" sort=sort text=text %}</th>
<th>{% trans "Author" as text %}{% include 'snippets/table-sort-header.html' with field="author" sort=sort text=text %}</th>
{% if request.user.is_authenticated %}
{% if is_self %}

View file

@ -64,7 +64,7 @@ class ActivitystreamsSignals(TestCase):
self.assertEqual(mock.call_count, 1)
args = mock.call_args[1]
self.assertEqual(args["args"][0], status.id)
self.assertEqual(args["queue"], "high_priority")
self.assertEqual(args["queue"], "streams")
def test_add_status_on_create_created_low_priority(self, *_):
"""a new statuses has entered"""
@ -82,7 +82,7 @@ class ActivitystreamsSignals(TestCase):
self.assertEqual(mock.call_count, 1)
args = mock.call_args[1]
self.assertEqual(args["args"][0], status.id)
self.assertEqual(args["queue"], "low_priority")
self.assertEqual(args["queue"], "import_triggered")
# published later than yesterday
status = models.Status.objects.create(
@ -97,7 +97,7 @@ class ActivitystreamsSignals(TestCase):
self.assertEqual(mock.call_count, 1)
args = mock.call_args[1]
self.assertEqual(args["args"][0], status.id)
self.assertEqual(args["queue"], "low_priority")
self.assertEqual(args["queue"], "import_triggered")
def test_populate_streams_on_account_create_command(self, *_):
"""create streams for a user"""

View file

@ -145,7 +145,7 @@ class GenericImporter(TestCase):
) as mock:
import_item_task(import_item.id)
kwargs = mock.call_args.kwargs
self.assertEqual(kwargs["queue"], "low_priority")
self.assertEqual(kwargs["queue"], "import_triggered")
import_item.refresh_from_db()
def test_complete_job(self, *_):

View file

@ -132,3 +132,14 @@ class Book(TestCase):
self.assertIsNotNone(book.cover_bw_book_xlarge_jpg.url)
self.assertIsNotNone(book.cover_bw_book_xxlarge_webp.url)
self.assertIsNotNone(book.cover_bw_book_xxlarge_jpg.url)
def test_populate_sort_title(self):
"""The sort title should remove the initial article on save"""
books = (
models.Edition.objects.create(
title=f"{article} Test Edition", languages=[langauge]
)
for langauge, articles in settings.LANGUAGE_ARTICLES.items()
for article in articles
)
self.assertTrue(all(book.sort_title == "test edition" for book in books))

View file

@ -347,11 +347,17 @@ class RegisterViews(TestCase):
self.settings.save()
self.local_user.is_active = False
self.local_user.allow_reactivation = True
self.local_user.deactivation_reason = "pending"
self.local_user.confirmation_code = "12345"
self.local_user.save(
broadcast=False,
update_fields=["is_active", "deactivation_reason", "confirmation_code"],
update_fields=[
"is_active",
"allow_reactivation",
"deactivation_reason",
"confirmation_code",
],
)
view = views.ConfirmEmailCode.as_view()
request = self.factory.get("")

View file

@ -141,3 +141,24 @@ class DeleteUserViews(TestCase):
self.local_user.refresh_from_db()
self.assertTrue(self.local_user.is_active)
self.assertIsNone(self.local_user.deactivation_reason)
def test_reactivate_user_post_disallowed(self, _):
"""Reactivate action under the wrong circumstances"""
self.local_user.is_active = False
self.local_user.save(broadcast=False)
view = views.ReactivateUser.as_view()
form = forms.LoginForm()
form.data["localname"] = "mouse"
form.data["password"] = "password"
request = self.factory.post("", form.data)
request.user = self.local_user
middleware = SessionMiddleware()
middleware.process_request(request)
request.session.save()
with patch("bookwyrm.views.preferences.delete_user.login"):
view(request)
self.local_user.refresh_from_db()
self.assertFalse(self.local_user.is_active)

View file

@ -4,7 +4,7 @@ from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.http.response import Http404
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test import Client, TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
@ -152,6 +152,30 @@ class UserViews(TestCase):
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_user_page_remote_anonymous(self):
"""when a anonymous user tries to get a remote user"""
with patch("bookwyrm.models.user.set_remote_server"):
models.User.objects.create_user(
"nutria",
"",
"nutriaword",
local=False,
remote_id="https://example.com/users/nutria",
inbox="https://example.com/users/nutria/inbox",
outbox="https://example.com/users/nutria/outbox",
)
view = views.User.as_view()
request = self.factory.get("")
request.user = self.anonymous_user
with patch("bookwyrm.views.user.is_api_request") as is_api:
is_api.return_value = False
result = view(request, "nutria@example.com")
result.client = Client()
self.assertRedirects(
result, "https://example.com/users/nutria", fetch_redirect_response=False
)
@patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
@patch("bookwyrm.activitystreams.populate_stream_task.delay")
def test_followers_page_blocked(self, *_):

View file

@ -11,7 +11,23 @@ from django import forms
import redis
from celerywyrm import settings
from bookwyrm.tasks import app as celery, LOW, MEDIUM, HIGH, IMPORTS, BROADCAST
from bookwyrm.tasks import (
app as celery,
LOW,
MEDIUM,
HIGH,
STREAMS,
IMAGES,
SUGGESTED_USERS,
EMAIL,
CONNECTORS,
LISTS,
INBOX,
IMPORTS,
IMPORT_TRIGGERED,
BROADCAST,
MISC,
)
r = redis.from_url(settings.REDIS_BROKER_URL)
@ -41,8 +57,17 @@ class CeleryStatus(View):
LOW: r.llen(LOW),
MEDIUM: r.llen(MEDIUM),
HIGH: r.llen(HIGH),
STREAMS: r.llen(STREAMS),
IMAGES: r.llen(IMAGES),
SUGGESTED_USERS: r.llen(SUGGESTED_USERS),
EMAIL: r.llen(EMAIL),
CONNECTORS: r.llen(CONNECTORS),
LISTS: r.llen(LISTS),
INBOX: r.llen(INBOX),
IMPORTS: r.llen(IMPORTS),
IMPORT_TRIGGERED: r.llen(IMPORT_TRIGGERED),
BROADCAST: r.llen(BROADCAST),
MISC: r.llen(MISC),
}
# pylint: disable=broad-except
except Exception as err:
@ -88,8 +113,17 @@ class ClearCeleryForm(forms.Form):
(LOW, "Low prioirty"),
(MEDIUM, "Medium priority"),
(HIGH, "High priority"),
(STREAMS, "Streams"),
(IMAGES, "Images"),
(SUGGESTED_USERS, "Suggested users"),
(EMAIL, "Email"),
(CONNECTORS, "Connectors"),
(LISTS, "Lists"),
(INBOX, "Inbox"),
(IMPORTS, "Imports"),
(IMPORT_TRIGGERED, "Import triggered"),
(BROADCAST, "Broadcasts"),
(MISC, "Misc"),
],
widget=forms.CheckboxSelectMultiple,
)

View file

@ -13,7 +13,7 @@ from django.views import View
from django.views.decorators.csrf import csrf_exempt
from bookwyrm import activitypub, models
from bookwyrm.tasks import app, MEDIUM, HIGH
from bookwyrm.tasks import app, INBOX
from bookwyrm.signatures import Signature
from bookwyrm.utils import regex
@ -59,11 +59,7 @@ class Inbox(View):
return HttpResponse()
return HttpResponse(status=401)
# Make activities relating to follow/unfollow a high priority
high = ["Follow", "Accept", "Reject", "Block", "Unblock", "Undo"]
priority = HIGH if activity_json["type"] in high else MEDIUM
sometimes_async_activity_task(activity_json, queue=priority)
sometimes_async_activity_task(activity_json)
return HttpResponse()
@ -101,7 +97,7 @@ def raise_is_blocked_activity(activity_json):
raise PermissionDenied()
def sometimes_async_activity_task(activity_json, queue=MEDIUM):
def sometimes_async_activity_task(activity_json):
"""Sometimes we can effectively respond to a request without queuing a new task,
and whenever that is possible, we should do it."""
activity = activitypub.parse(activity_json)
@ -111,10 +107,10 @@ def sometimes_async_activity_task(activity_json, queue=MEDIUM):
activity.action(allow_external_connections=False)
except activitypub.ActivitySerializerError:
# if that doesn't work, run it asynchronously
activity_task.apply_async(args=(activity_json,), queue=queue)
activity_task.apply_async(args=(activity_json,))
@app.task(queue=MEDIUM)
@app.task(queue=INBOX)
def activity_task(activity_json):
"""do something with this json we think is legit"""
# lets see if the activitypub module can make sense of this json

View file

@ -74,6 +74,7 @@ class Register(View):
password,
localname=localname,
local=True,
allow_reactivation=settings.require_confirm_email,
deactivation_reason="pending" if settings.require_confirm_email else None,
is_active=not settings.require_confirm_email,
preferred_timezone=preferred_timezone,
@ -105,7 +106,9 @@ class ConfirmEmailCode(View):
# look up the user associated with this code
try:
user = models.User.objects.get(confirmation_code=code)
user = models.User.objects.get(
confirmation_code=code, deactivation_reason="pending"
)
except models.User.DoesNotExist:
return TemplateResponse(
request, "confirm_email/confirm_email.html", {"valid": False}

View file

@ -129,7 +129,7 @@ def sort_list(request, items):
"""helper to handle the surprisingly involved sorting"""
# sort_by shall be "order" unless a valid alternative is given
sort_by = request.GET.get("sort_by", "order")
if sort_by not in ("order", "title", "rating"):
if sort_by not in ("order", "sort_title", "rating"):
sort_by = "order"
# direction shall be "ascending" unless a valid alternative is given
@ -139,7 +139,7 @@ def sort_list(request, items):
directional_sort_by = {
"order": "order",
"title": "book__title",
"sort_title": "book__sort_title",
"rating": "average_rating",
}[sort_by]
if direction == "descending":

View file

@ -128,7 +128,7 @@ class Shelf(View):
def sort_books(books, sort):
"""Books in shelf sorting"""
sort_fields = [
"title",
"sort_title",
"author",
"shelved_date",
"start_date",

View file

@ -23,6 +23,9 @@ class User(View):
"""profile page for a user"""
user = get_user_from_username(request.user, username)
if not user.local and not request.user.is_authenticated:
return redirect(user.remote_id)
if is_api_request(request):
# we have a json request
return ActivitypubResponse(user.to_activity())

View file

@ -6,7 +6,7 @@ After=network.target postgresql.service redis.service
User=bookwyrm
Group=bookwyrm
WorkingDirectory=/opt/bookwyrm/
ExecStart=/opt/bookwyrm/venv/bin/celery -A celerywyrm worker -l info -Q high_priority,medium_priority,low_priority,imports,broadcast
ExecStart=/opt/bookwyrm/venv/bin/celery -A celerywyrm worker -l info -Q high_priority,medium_priority,low_priority,streams,images,suggested_users,email,connectors,lists,inbox,imports,import_triggered,broadcast,misc
StandardOutput=journal
StandardError=inherit

View file

@ -88,7 +88,8 @@ services:
build: .
networks:
- main
command: celery -A celerywyrm worker -l info -Q high_priority,medium_priority,low_priority,imports,broadcast
command: celery -A celerywyrm worker -l info -Q high_priority,medium_priority,low_priority,streams,images,suggested_users,email,connectors,lists,inbox,imports,import_triggered,broadcast,misc
logging: *default-logging
volumes:
- .:/app

View file

@ -1,8 +1,8 @@
aiohttp==3.8.3
aiohttp==3.8.5
bleach==5.0.1
celery==5.2.7
colorthief==0.2.1
Django==3.2.19
Django==3.2.20
django-celery-beat==2.4.0
django-compressor==4.3.1
django-imagekit==4.1.0