Merge branch 'main' into add-shelves-column

This commit is contained in:
Joachim 2023-08-01 15:22:53 +02:00 committed by GitHub
commit c1c449e0df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
306 changed files with 20446 additions and 6711 deletions

View file

@ -5,3 +5,4 @@ __pycache__
.git
.github
.pytest*
.env

View file

@ -8,7 +8,7 @@ USE_HTTPS=true
DOMAIN=your.domain.here
EMAIL=your@email.here
# Instance defualt language (see options at bookwyrm/settings.py "LANGUAGES"
# Instance default language (see options at bookwyrm/settings.py "LANGUAGES"
LANGUAGE_CODE="en-us"
# Used for deciding which editions to prefer
DEFAULT_LANGUAGE="English"
@ -32,6 +32,8 @@ REDIS_ACTIVITY_PORT=6379
REDIS_ACTIVITY_PASSWORD=redispassword345
# Optional, use a different redis database (defaults to 0)
# REDIS_ACTIVITY_DB_INDEX=0
# Alternatively specify the full redis url, i.e. if you need to use a unix:// socket
# REDIS_ACTIVITY_URL=
# Redis as celery broker
REDIS_BROKER_HOST=redis_broker
@ -39,6 +41,8 @@ REDIS_BROKER_PORT=6379
REDIS_BROKER_PASSWORD=redispassword123
# Optional, use a different redis database (defaults to 0)
# REDIS_BROKER_DB_INDEX=0
# Alternatively specify the full redis url, i.e. if you need to use a unix:// socket
# REDIS_BROKER_URL=
# Monitoring for celery
FLOWER_PORT=8888
@ -61,7 +65,7 @@ SEARCH_TIMEOUT=5
QUERY_TIMEOUT=5
# Thumbnails Generation
ENABLE_THUMBNAIL_GENERATION=false
ENABLE_THUMBNAIL_GENERATION=true
# S3 configuration
USE_S3=false
@ -78,6 +82,12 @@ AWS_SECRET_ACCESS_KEY=
# AWS_S3_REGION_NAME=None # "fr-par"
# AWS_S3_ENDPOINT_URL=None # "https://s3.fr-par.scw.cloud"
# Commented are example values if you use Azure Blob Storage
# USE_AZURE=true
# AZURE_ACCOUNT_NAME= # "example-account-name"
# AZURE_ACCOUNT_KEY= # "base64-encoded-access-key"
# AZURE_CONTAINER= # "example-blob-container-name"
# AZURE_CUSTOM_DOMAIN= # "example-account-name.blob.core.windows.net"
# Preview image generation can be computing and storage intensive
ENABLE_PREVIEW_IMAGES=False
@ -116,3 +126,14 @@ OTEL_SERVICE_NAME=
# for your instance:
# https://docs.djangoproject.com/en/3.2/ref/settings/#secure-proxy-ssl-header
HTTP_X_FORWARDED_PROTO=false
# TOTP settings
# TWO_FACTOR_LOGIN_VALIDITY_WINDOW sets the number of codes either side
# which will be accepted.
TWO_FACTOR_LOGIN_VALIDITY_WINDOW=2
TWO_FACTOR_LOGIN_MAX_SECONDS=60
# Additional hosts to allow in the Content-Security-Policy, "self" (should be DOMAIN)
# and AWS_S3_CUSTOM_DOMAIN (if used) are added by default.
# Value should be a comma-separated list of host names.
CSP_ADDITIONAL_HOSTS=

View file

@ -13,3 +13,5 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
- uses: psf/black@22.12.0
with:
version: 22.12.0

50
.github/workflows/mypy.yml vendored Normal file
View file

@ -0,0 +1,50 @@
name: Mypy
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.9
uses: actions/setup-python@v4
with:
python-version: 3.9
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Analysing the code with mypy
env:
SECRET_KEY: beepbeep
DEBUG: false
USE_HTTPS: true
DOMAIN: your.domain.here
BOOKWYRM_DATABASE_BACKEND: postgres
MEDIA_ROOT: images/
POSTGRES_PASSWORD: hunter2
POSTGRES_USER: postgres
POSTGRES_DB: github_actions
POSTGRES_HOST: 127.0.0.1
CELERY_BROKER: ""
REDIS_BROKER_PORT: 6379
REDIS_BROKER_PASSWORD: beep
USE_DUMMY_CACHE: true
FLOWER_PORT: 8888
EMAIL_HOST: "smtp.mailgun.org"
EMAIL_PORT: 587
EMAIL_HOST_USER: ""
EMAIL_HOST_PASSWORD: ""
EMAIL_USE_TLS: true
ENABLE_PREVIEW_IMAGES: false
ENABLE_THUMBNAIL_GENERATION: true
HTTP_X_FORWARDED_PROTO: false
run: |
mypy bookwyrm celerywyrm

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

@ -3,7 +3,7 @@ import inspect
import sys
from .base_activity import ActivityEncoder, Signature, naive_parse
from .base_activity import Link, Mention
from .base_activity import Link, Mention, Hashtag
from .base_activity import ActivitySerializerError, resolve_remote_id
from .image import Document, Image
from .note import Note, GeneratedNote, Article, Comment, Quotation

View file

@ -2,12 +2,17 @@
from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder
import logging
import requests
from django.apps import apps
from django.db import IntegrityError, transaction
from django.utils.http import http_date
from bookwyrm import models
from bookwyrm.connectors import ConnectorException, get_data
from bookwyrm.tasks import app, MEDIUM
from bookwyrm.signatures import make_signature
from bookwyrm.settings import DOMAIN, INSTANCE_ACTOR_USERNAME
from bookwyrm.tasks import app, MISC
logger = logging.getLogger(__name__)
@ -95,16 +100,34 @@ class ActivityObject:
# pylint: disable=too-many-locals,too-many-branches,too-many-arguments
def to_model(
self, model=None, instance=None, allow_create=True, save=True, overwrite=True
self,
model=None,
instance=None,
allow_create=True,
save=True,
overwrite=True,
allow_external_connections=True,
):
"""convert from an activity to a model instance"""
"""convert from an activity to a model instance. Args:
model: the django model that this object is being converted to
(will guess if not known)
instance: an existing database entry that is going to be updated by
this activity
allow_create: whether a new object should be created if there is no
existing object is provided or found matching the remote_id
save: store in the database if true, return an unsaved model obj if false
overwrite: replace fields in the database with this activity if true,
only update blank fields if false
allow_external_connections: look up missing data if true,
throw an exception if false and an external connection is needed
"""
model = model or get_model_from_type(self.type)
# only reject statuses if we're potentially creating them
if (
allow_create
and hasattr(model, "ignore_activity")
and model.ignore_activity(self)
and model.ignore_activity(self, allow_external_connections)
):
return None
@ -122,7 +145,10 @@ class ActivityObject:
for field in instance.simple_fields:
try:
changed = field.set_field_from_activity(
instance, self, overwrite=overwrite
instance,
self,
overwrite=overwrite,
allow_external_connections=allow_external_connections,
)
if changed:
update_fields.append(field.name)
@ -133,7 +159,11 @@ class ActivityObject:
# too early and jank up users
for field in instance.image_fields:
changed = field.set_field_from_activity(
instance, self, save=save, overwrite=overwrite
instance,
self,
save=save,
overwrite=overwrite,
allow_external_connections=allow_external_connections,
)
if changed:
update_fields.append(field.name)
@ -156,8 +186,12 @@ class ActivityObject:
# add many to many fields, which have to be set post-save
for field in instance.many_to_many_fields:
# mention books/users, for example
field.set_field_from_activity(instance, self)
# mention books/users/hashtags, for example
field.set_field_from_activity(
instance,
self,
allow_external_connections=allow_external_connections,
)
# reversed relationships in the models
for (
@ -207,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
@ -246,10 +280,10 @@ def set_related_field(
def get_model_from_type(activity_type):
"""given the activity, what type of model"""
models = apps.get_models()
activity_models = apps.get_models()
model = [
m
for m in models
for m in activity_models
if hasattr(m, "activity_serializer")
and hasattr(m.activity_serializer, "type")
and m.activity_serializer.type == activity_type
@ -261,10 +295,22 @@ def get_model_from_type(activity_type):
return model[0]
# pylint: disable=too-many-arguments
def resolve_remote_id(
remote_id, model=None, refresh=False, save=True, get_activity=False
remote_id,
model=None,
refresh=False,
save=True,
get_activity=False,
allow_external_connections=True,
):
"""take a remote_id and return an instance, creating if necessary"""
"""take a remote_id and return an instance, creating if necessary. Args:
remote_id: the unique url for looking up the object in the db or by http
model: a string or object representing the model that corresponds to the object
save: whether to return an unsaved database entry or a saved one
get_activity: whether to return the activitypub object or the model object
allow_external_connections: whether to make http connections
"""
if model: # a bonus check we can do if we already know the model
if isinstance(model, str):
model = apps.get_model(f"bookwyrm.{model}", require_ready=True)
@ -272,13 +318,26 @@ def resolve_remote_id(
if result and not refresh:
return result if not get_activity else result.to_activity_dataclass()
# The above block will return the object if it already exists in the database.
# If it doesn't, an external connection would be needed, so check if that's cool
if not allow_external_connections:
raise ActivitySerializerError(
"Unable to serialize object without making external HTTP requests"
)
# load the data and create the object
try:
data = get_data(remote_id)
except ConnectorException:
except ConnectionError:
logger.info("Could not connect to host for remote_id: %s", remote_id)
return None
except requests.HTTPError as e:
if (e.response is not None) and e.response.status_code == 401:
# This most likely means it's a mastodon with secure fetch enabled.
data = get_activitypub_data(remote_id)
else:
logger.info("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"):
@ -297,6 +356,52 @@ def resolve_remote_id(
return item.to_model(model=model, instance=result, save=save)
def get_representative():
"""Get or create an actor representing the instance
to sign requests to 'secure mastodon' servers"""
username = f"{INSTANCE_ACTOR_USERNAME}@{DOMAIN}"
email = "bookwyrm@localhost"
try:
user = models.User.objects.get(username=username)
except models.User.DoesNotExist:
user = models.User.objects.create_user(
username=username,
email=email,
local=True,
localname=INSTANCE_ACTOR_USERNAME,
)
return user
def get_activitypub_data(url):
"""wrapper for request.get"""
now = http_date()
sender = get_representative()
if not sender.key_pair.private_key:
# this shouldn't happen. it would be bad if it happened.
raise ValueError("No private key found for sender")
try:
resp = requests.get(
url,
headers={
# pylint: disable=line-too-long
"Accept": 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
"Date": now,
"Signature": make_signature("get", sender, url, now),
},
)
except requests.RequestException:
raise ConnectorException()
if not resp.ok:
resp.raise_for_status()
try:
data = resp.json()
except ValueError:
raise ConnectorException()
return data
@dataclass(init=False)
class Link(ActivityObject):
"""for tagging a book in a status"""
@ -322,3 +427,10 @@ class Mention(Link):
"""a subtype of Link for mentioning an actor"""
type: str = "Mention"
@dataclass(init=False)
class Hashtag(Link):
"""a subtype of Link for mentioning a hashtag"""
type: str = "Hashtag"

View file

@ -92,3 +92,4 @@ class Author(BookData):
bio: str = ""
wikipediaLink: str = ""
type: str = "Author"
website: str = ""

View file

@ -1,9 +1,12 @@
""" note serializer and children thereof """
from dataclasses import dataclass, field
from typing import Dict, List
from django.apps import apps
import re
from .base_activity import ActivityObject, Link
from django.apps import apps
from django.db import IntegrityError, transaction
from .base_activity import ActivityObject, ActivitySerializerError, Link
from .image import Document
@ -38,6 +41,47 @@ class Note(ActivityObject):
updated: str = None
type: str = "Note"
# pylint: disable=too-many-arguments
def to_model(
self,
model=None,
instance=None,
allow_create=True,
save=True,
overwrite=True,
allow_external_connections=True,
):
instance = super().to_model(
model, instance, allow_create, save, overwrite, allow_external_connections
)
if instance is None:
return instance
# Replace links to hashtags in content with local URLs
changed_content = False
for hashtag in instance.mention_hashtags.all():
updated_content = re.sub(
rf'(<a href=")[^"]*(" data-mention="hashtag">{hashtag.name}</a>)',
rf"\1{hashtag.remote_id}\2",
instance.content,
flags=re.IGNORECASE,
)
if instance.content != updated_content:
instance.content = updated_content
changed_content = True
if not save or not changed_content:
return instance
with transaction.atomic():
try:
instance.save(broadcast=False, update_fields=["content"])
except IntegrityError as e:
raise ActivitySerializerError(e)
return instance
@dataclass(init=False)
class Article(Note):

View file

@ -14,12 +14,12 @@ class Verb(ActivityObject):
actor: str
object: ActivityObject
def action(self):
def action(self, allow_external_connections=True):
"""usually we just want to update and save"""
# self.object may return None if the object is invalid in an expected way
# ie, Question type
if self.object:
self.object.to_model()
self.object.to_model(allow_external_connections=allow_external_connections)
# pylint: disable=invalid-name
@ -42,7 +42,7 @@ class Delete(Verb):
cc: List[str] = field(default_factory=lambda: [])
type: str = "Delete"
def action(self):
def action(self, allow_external_connections=True):
"""find and delete the activity object"""
if not self.object:
return
@ -52,7 +52,11 @@ class Delete(Verb):
model = apps.get_model("bookwyrm.User")
obj = model.find_existing_by_remote_id(self.object)
else:
obj = self.object.to_model(save=False, allow_create=False)
obj = self.object.to_model(
save=False,
allow_create=False,
allow_external_connections=allow_external_connections,
)
if obj:
obj.delete()
@ -67,11 +71,13 @@ class Update(Verb):
to: List[str]
type: str = "Update"
def action(self):
def action(self, allow_external_connections=True):
"""update a model instance from the dataclass"""
if not self.object:
return
self.object.to_model(allow_create=False)
self.object.to_model(
allow_create=False, allow_external_connections=allow_external_connections
)
@dataclass(init=False)
@ -80,10 +86,10 @@ class Undo(Verb):
type: str = "Undo"
def action(self):
def action(self, allow_external_connections=True):
"""find and remove the activity object"""
if isinstance(self.object, str):
# it may be that sometihng should be done with these, but idk what
# it may be that something should be done with these, but idk what
# this seems just to be coming from pleroma
return
@ -92,13 +98,28 @@ class Undo(Verb):
model = None
if self.object.type == "Follow":
model = apps.get_model("bookwyrm.UserFollows")
obj = self.object.to_model(model=model, save=False, allow_create=False)
obj = self.object.to_model(
model=model,
save=False,
allow_create=False,
allow_external_connections=allow_external_connections,
)
if not obj:
# this could be a folloq request not a follow proper
# this could be a follow request not a follow proper
model = apps.get_model("bookwyrm.UserFollowRequest")
obj = self.object.to_model(model=model, save=False, allow_create=False)
obj = self.object.to_model(
model=model,
save=False,
allow_create=False,
allow_external_connections=allow_external_connections,
)
else:
obj = self.object.to_model(model=model, save=False, allow_create=False)
obj = self.object.to_model(
model=model,
save=False,
allow_create=False,
allow_external_connections=allow_external_connections,
)
if not obj:
# if we don't have the object, we can't undo it. happens a lot with boosts
return
@ -112,9 +133,9 @@ class Follow(Verb):
object: str
type: str = "Follow"
def action(self):
def action(self, allow_external_connections=True):
"""relationship save"""
self.to_model()
self.to_model(allow_external_connections=allow_external_connections)
@dataclass(init=False)
@ -124,9 +145,9 @@ class Block(Verb):
object: str
type: str = "Block"
def action(self):
def action(self, allow_external_connections=True):
"""relationship save"""
self.to_model()
self.to_model(allow_external_connections=allow_external_connections)
@dataclass(init=False)
@ -136,7 +157,7 @@ class Accept(Verb):
object: Follow
type: str = "Accept"
def action(self):
def action(self, allow_external_connections=True):
"""accept a request"""
obj = self.object.to_model(save=False, allow_create=True)
obj.accept()
@ -149,7 +170,7 @@ class Reject(Verb):
object: Follow
type: str = "Reject"
def action(self):
def action(self, allow_external_connections=True):
"""reject a follow request"""
obj = self.object.to_model(save=False, allow_create=False)
obj.reject()
@ -163,7 +184,7 @@ class Add(Verb):
object: CollectionItem
type: str = "Add"
def action(self):
def action(self, allow_external_connections=True):
"""figure out the target to assign the item to a collection"""
target = resolve_remote_id(self.target)
item = self.object.to_model(save=False)
@ -177,7 +198,7 @@ class Remove(Add):
type: str = "Remove"
def action(self):
def action(self, allow_external_connections=True):
"""find and remove the activity object"""
obj = self.object.to_model(save=False, allow_create=False)
if obj:
@ -191,9 +212,9 @@ class Like(Verb):
object: str
type: str = "Like"
def action(self):
def action(self, allow_external_connections=True):
"""like"""
self.to_model()
self.to_model(allow_external_connections=allow_external_connections)
# pylint: disable=invalid-name
@ -207,6 +228,6 @@ class Announce(Verb):
object: str
type: str = "Announce"
def action(self):
def action(self, allow_external_connections=True):
"""boost"""
self.to_model()
self.to_model(allow_external_connections=allow_external_connections)

View file

@ -4,27 +4,32 @@ from django.dispatch import receiver
from django.db import transaction
from django.db.models import signals, Q
from django.utils import timezone
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
tracer = open_telemetry.tracer()
class ActivityStream(RedisStore):
"""a category of activity stream (like home, local, books)"""
def stream_id(self, user):
def stream_id(self, user_id):
"""the redis key for this user's instance of this stream"""
return f"{user.id}-{self.key}"
return f"{user_id}-{self.key}"
def unread_id(self, user):
def unread_id(self, user_id):
"""the redis key for this user's unread count for this stream"""
stream_id = self.stream_id(user)
stream_id = self.stream_id(user_id)
return f"{stream_id}-unread"
def unread_by_status_type_id(self, user):
def unread_by_status_type_id(self, user_id):
"""the redis key for this user's unread count for this stream"""
stream_id = self.stream_id(user)
stream_id = self.stream_id(user_id)
return f"{stream_id}-unread-by-type"
def get_rank(self, obj): # pylint: disable=no-self-use
@ -33,16 +38,19 @@ class ActivityStream(RedisStore):
def add_status(self, status, increment_unread=False):
"""add a status to users' feeds"""
audience = self.get_audience(status)
# the pipeline contains all the add-to-stream activities
pipeline = self.add_object_to_related_stores(status, execute=False)
pipeline = self.add_object_to_stores(
status, self.get_stores_for_users(audience), execute=False
)
if increment_unread:
for user in self.get_audience(status):
for user_id in audience:
# add to the unread status count
pipeline.incr(self.unread_id(user))
pipeline.incr(self.unread_id(user_id))
# add to the unread status count for status type
pipeline.hincrby(
self.unread_by_status_type_id(user), get_status_type(status), 1
self.unread_by_status_type_id(user_id), get_status_type(status), 1
)
# and go!
@ -52,21 +60,21 @@ class ActivityStream(RedisStore):
"""add a user's statuses to another user's feed"""
# only add the statuses that the viewer should be able to see (ie, not dms)
statuses = models.Status.privacy_filter(viewer).filter(user=user)
self.bulk_add_objects_to_store(statuses, self.stream_id(viewer))
self.bulk_add_objects_to_store(statuses, self.stream_id(viewer.id))
def remove_user_statuses(self, viewer, user):
"""remove a user's status from another user's feed"""
# remove all so that followers only statuses are removed
statuses = user.status_set.all()
self.bulk_remove_objects_from_store(statuses, self.stream_id(viewer))
self.bulk_remove_objects_from_store(statuses, self.stream_id(viewer.id))
def get_activity_stream(self, user):
"""load the statuses to be displayed"""
# clear unreads for this feed
r.set(self.unread_id(user), 0)
r.delete(self.unread_by_status_type_id(user))
r.set(self.unread_id(user.id), 0)
r.delete(self.unread_by_status_type_id(user.id))
statuses = self.get_store(self.stream_id(user))
statuses = self.get_store(self.stream_id(user.id))
return (
models.Status.objects.select_subclasses()
.filter(id__in=statuses)
@ -83,11 +91,11 @@ class ActivityStream(RedisStore):
def get_unread_count(self, user):
"""get the unread status count for this user's feed"""
return int(r.get(self.unread_id(user)) or 0)
return int(r.get(self.unread_id(user.id)) or 0)
def get_unread_count_by_status_type(self, user):
"""get the unread status count for this user's feed's status types"""
status_types = r.hgetall(self.unread_by_status_type_id(user))
status_types = r.hgetall(self.unread_by_status_type_id(user.id))
return {
str(key.decode("utf-8")): int(value) or 0
for key, value in status_types.items()
@ -95,13 +103,20 @@ class ActivityStream(RedisStore):
def populate_streams(self, user):
"""go from zero to a timeline"""
self.populate_store(self.stream_id(user))
self.populate_store(self.stream_id(user.id))
def get_audience(self, status): # pylint: disable=no-self-use
"""given a status, what users should see it"""
# direct messages don't appeard in feeds, direct comments/reviews/etc do
@tracer.start_as_current_span("ActivityStream._get_audience")
def _get_audience(self, status): # pylint: disable=no-self-use
"""given a status, what users should see it, excluding the author"""
trace.get_current_span().set_attribute("status_type", status.status_type)
trace.get_current_span().set_attribute("status_privacy", status.privacy)
trace.get_current_span().set_attribute(
"status_reply_parent_privacy",
status.reply_parent.privacy if status.reply_parent else None,
)
# direct messages don't appear in feeds, direct comments/reviews/etc do
if status.privacy == "direct" and status.status_type == "Note":
return []
return models.User.objects.none()
# everybody who could plausibly see this status
audience = models.User.objects.filter(
@ -114,15 +129,13 @@ class ActivityStream(RedisStore):
# only visible to the poster and mentioned users
if status.privacy == "direct":
audience = audience.filter(
Q(id=status.user.id) # if the user is the post's author
| Q(id__in=status.mention_users.all()) # if the user is mentioned
Q(id__in=status.mention_users.all()) # if the user is mentioned
)
# don't show replies to statuses the user can't see
elif status.reply_parent and status.reply_parent.privacy == "followers":
audience = audience.filter(
Q(id=status.user.id) # if the user is the post's author
| Q(id=status.reply_parent.user.id) # if the user is the OG author
Q(id=status.reply_parent.user.id) # if the user is the OG author
| (
Q(following=status.user) & Q(following=status.reply_parent.user)
) # if the user is following both authors
@ -131,13 +144,23 @@ class ActivityStream(RedisStore):
# only visible to the poster's followers and tagged users
elif status.privacy == "followers":
audience = audience.filter(
Q(id=status.user.id) # if the user is the post's author
| Q(following=status.user) # if the user is following the author
Q(following=status.user) # if the user is following the author
)
return audience.distinct()
def get_stores_for_object(self, obj):
return [self.stream_id(u) for u in self.get_audience(obj)]
@tracer.start_as_current_span("ActivityStream.get_audience")
def get_audience(self, status):
"""given a status, what users should see it"""
trace.get_current_span().set_attribute("stream_id", self.key)
audience = self._get_audience(status).values_list("id", flat=True)
status_author = models.User.objects.filter(
is_active=True, local=True, id=status.user.id
).values_list("id", flat=True)
return list(set(list(audience) + list(status_author)))
def get_stores_for_users(self, user_ids):
"""convert a list of user ids into redis store ids"""
return [self.stream_id(user_id) for user_id in user_ids]
def get_statuses_for_user(self, user): # pylint: disable=no-self-use
"""given a user, what statuses should they see on this stream"""
@ -156,14 +179,19 @@ class HomeStream(ActivityStream):
key = "home"
@tracer.start_as_current_span("HomeStream.get_audience")
def get_audience(self, status):
audience = super().get_audience(status)
trace.get_current_span().set_attribute("stream_id", self.key)
audience = super()._get_audience(status)
if not audience:
return []
return audience.filter(
Q(id=status.user.id) # if the user is the post's author
| Q(following=status.user) # if the user is following the author
).distinct()
# if the user is following the author
audience = audience.filter(following=status.user).values_list("id", flat=True)
# if the user is the post's author
status_author = models.User.objects.filter(
is_active=True, local=True, id=status.user.id
).values_list("id", flat=True)
return list(set(list(audience) + list(status_author)))
def get_statuses_for_user(self, user):
return models.Status.privacy_filter(
@ -202,8 +230,20 @@ class BooksStream(ActivityStream):
key = "books"
def get_audience(self, status):
def _get_audience(self, status):
"""anyone with the mentioned book on their shelves"""
work = (
status.book.parent_work
if hasattr(status, "book")
else status.mention_books.first().parent_work
)
audience = super()._get_audience(status)
if not audience:
return models.User.objects.none()
return audience.filter(shelfbook__book__parent_work=work).distinct()
def get_audience(self, status):
# only show public statuses on the books feed,
# and only statuses that mention books
if status.privacy != "public" or not (
@ -211,16 +251,7 @@ class BooksStream(ActivityStream):
):
return []
work = (
status.book.parent_work
if hasattr(status, "book")
else status.mention_books.first().parent_work
)
audience = super().get_audience(status)
if not audience:
return []
return audience.filter(shelfbook__book__parent_work=work).distinct()
return super().get_audience(status)
def get_statuses_for_user(self, user):
"""any public status that mentions the user's books"""
@ -244,38 +275,38 @@ class BooksStream(ActivityStream):
def add_book_statuses(self, user, book):
"""add statuses about a book to a user's feed"""
work = book.parent_work
statuses = (
models.Status.privacy_filter(
user,
privacy_levels=["public"],
)
.filter(
Q(comment__book__parent_work=work)
| Q(quotation__book__parent_work=work)
| Q(review__book__parent_work=work)
| Q(mention_books__parent_work=work)
)
.distinct()
statuses = models.Status.privacy_filter(
user,
privacy_levels=["public"],
)
self.bulk_add_objects_to_store(statuses, self.stream_id(user))
book_comments = statuses.filter(Q(comment__book__parent_work=work))
book_quotations = statuses.filter(Q(quotation__book__parent_work=work))
book_reviews = statuses.filter(Q(review__book__parent_work=work))
book_mentions = statuses.filter(Q(mention_books__parent_work=work))
self.bulk_add_objects_to_store(book_comments, self.stream_id(user.id))
self.bulk_add_objects_to_store(book_quotations, self.stream_id(user.id))
self.bulk_add_objects_to_store(book_reviews, self.stream_id(user.id))
self.bulk_add_objects_to_store(book_mentions, self.stream_id(user.id))
def remove_book_statuses(self, user, book):
"""add statuses about a book to a user's feed"""
work = book.parent_work
statuses = (
models.Status.privacy_filter(
user,
privacy_levels=["public"],
)
.filter(
Q(comment__book__parent_work=work)
| Q(quotation__book__parent_work=work)
| Q(review__book__parent_work=work)
| Q(mention_books__parent_work=work)
)
.distinct()
statuses = models.Status.privacy_filter(
user,
privacy_levels=["public"],
)
self.bulk_remove_objects_from_store(statuses, self.stream_id(user))
book_comments = statuses.filter(Q(comment__book__parent_work=work))
book_quotations = statuses.filter(Q(quotation__book__parent_work=work))
book_reviews = statuses.filter(Q(review__book__parent_work=work))
book_mentions = statuses.filter(Q(mention_books__parent_work=work))
self.bulk_remove_objects_from_store(book_comments, self.stream_id(user.id))
self.bulk_remove_objects_from_store(book_quotations, self.stream_id(user.id))
self.bulk_remove_objects_from_store(book_reviews, self.stream_id(user.id))
self.bulk_remove_objects_from_store(book_mentions, self.stream_id(user.id))
# determine which streams are enabled in settings.py
@ -312,7 +343,11 @@ 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
# boosts trigger 'saves" twice, so don't bother duplicating the task
if sender == models.Boost and not created:
return
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(
@ -322,7 +357,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,),
@ -466,7 +501,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)
@ -474,7 +509,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)
@ -482,7 +517,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)
@ -490,7 +525,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
@ -500,10 +535,12 @@ def remove_status_task(status_ids):
for stream in streams.values():
for status in statuses:
stream.remove_object_from_related_stores(status)
stream.remove_object_from_stores(
status, stream.get_stores_for_users(stream.get_audience(status))
)
@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)
@ -515,7 +552,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()
@ -525,7 +562,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()
@ -535,7 +572,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)
@ -549,10 +586,10 @@ def handle_boost_task(boost_id):
for stream in streams.values():
# people who should see the boost (not people who see the original status)
audience = stream.get_stores_for_object(instance)
stream.remove_object_from_related_stores(boosted, stores=audience)
audience = stream.get_stores_for_users(stream.get_audience(instance))
stream.remove_object_from_stores(boosted, audience)
for status in old_versions:
stream.remove_object_from_related_stores(status, stores=audience)
stream.remove_object_from_stores(status, audience)
def get_status_type(status):

View file

@ -35,11 +35,12 @@ class BookwyrmConfig(AppConfig):
# pylint: disable=no-self-use
def ready(self):
"""set up OTLP and preview image files, if desired"""
if settings.OTEL_EXPORTER_OTLP_ENDPOINT:
if settings.OTEL_EXPORTER_OTLP_ENDPOINT or settings.OTEL_EXPORTER_CONSOLE:
# pylint: disable=import-outside-toplevel
from bookwyrm.telemetry import open_telemetry
open_telemetry.instrumentDjango()
open_telemetry.instrumentPostgres()
if settings.ENABLE_PREVIEW_IMAGES and settings.FONTS:
# Download any fonts that we don't have yet

View file

@ -20,7 +20,7 @@ def search(query, min_confidence=0, filters=None, return_first=False):
query = query.strip()
results = None
# first, try searching unqiue identifiers
# first, try searching unique identifiers
# unique identifiers never have spaces, title/author usually do
if not " " in query:
results = search_identifiers(query, *filters, return_first=return_first)

View file

@ -1,15 +1,19 @@
""" functionality outline for a book data connector """
from abc import ABC, abstractmethod
from urllib.parse import quote_plus
import imghdr
import logging
import re
import asyncio
import requests
from requests.exceptions import RequestException
import aiohttp
from django.core.files.base import ContentFile
from django.db import transaction
import requests
from requests.exceptions import RequestException
from bookwyrm import activitypub, models, settings
from bookwyrm.settings import USER_AGENT
from .connector_manager import load_more_data, ConnectorException, raise_not_valid_url
from .format_mappings import format_mappings
@ -48,14 +52,47 @@ class AbstractMinimalConnector(ABC):
return f"{self.isbn_search_url}{normalized_query}"
# 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}"
return f"{self.search_url}{quote_plus(query)}"
def process_search_response(self, query, data, min_confidence):
"""Format the search results based on the formt of the query"""
"""Format the search results based on the format 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]
async def get_results(self, session, url, min_confidence, query):
"""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": self,
"results": self.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.info(err)
@abstractmethod
def get_or_create_book(self, remote_id):
"""pull up a book record by whatever means possible"""
@ -244,7 +281,11 @@ def get_data(url, params=None, timeout=settings.QUERY_TIMEOUT):
raise ConnectorException(err)
if not resp.ok:
raise ConnectorException()
if resp.status_code == 401:
# this is probably an AUTHORIZED_FETCH issue
resp.raise_for_status()
else:
raise ConnectorException()
try:
data = resp.json()
except ValueError as err:
@ -316,7 +357,7 @@ def infer_physical_format(format_text):
def unique_physical_format(format_text):
"""only store the format if it isn't diretly in the format mappings"""
"""only store the format if it isn't directly in the format mappings"""
format_text = format_text.lower()
if format_text in format_mappings:
# try a direct match, so saving this would be redundant

View file

@ -12,8 +12,8 @@ from django.db.models import signals
from requests import HTTPError
from bookwyrm import book_search, models
from bookwyrm.settings import SEARCH_TIMEOUT, USER_AGENT
from bookwyrm.tasks import app, LOW
from bookwyrm.settings import SEARCH_TIMEOUT
from bookwyrm.tasks import app, CONNECTORS
logger = logging.getLogger(__name__)
@ -22,40 +22,6 @@ 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.info(err)
async def async_connector_search(query, items, min_confidence):
"""Try a number of requests simultaneously"""
timeout = aiohttp.ClientTimeout(total=SEARCH_TIMEOUT)
@ -64,7 +30,7 @@ async def async_connector_search(query, items, min_confidence):
for url, connector in items:
tasks.append(
asyncio.ensure_future(
get_results(session, url, min_confidence, query, connector)
connector.get_results(session, url, min_confidence, query)
)
)
@ -73,7 +39,7 @@ async def async_connector_search(query, items, min_confidence):
def search(query, min_confidence=0.1, return_first=False):
"""find books based on arbitary keywords"""
"""find books based on arbitrary keywords"""
if not query:
return []
results = []
@ -143,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)
@ -152,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

@ -97,7 +97,7 @@ class Connector(AbstractConnector):
)
def parse_isbn_search_data(self, data):
"""got some daaaata"""
"""got some data"""
results = data.get("entities")
if not results:
return

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

@ -15,7 +15,7 @@ from .custom_form import CustomForm, StyledForm
# pylint: disable=missing-class-docstring
class ExpiryWidget(widgets.Select):
def value_from_datadict(self, data, files, name):
"""human-readable exiration time buckets"""
"""human-readable expiration time buckets"""
selected_string = super().value_from_datadict(data, files, name)
if selected_string == "day":
@ -91,6 +91,7 @@ class RegistrationForm(CustomForm):
"invite_request_question",
"invite_question_text",
"require_confirm_email",
"default_user_auth_group",
]
widgets = {

View file

@ -15,6 +15,7 @@ class AuthorForm(CustomForm):
"aliases",
"bio",
"wikipedia_link",
"website",
"born",
"died",
"openlibrary_key",
@ -31,10 +32,11 @@ class AuthorForm(CustomForm):
"wikipedia_link": forms.TextInput(
attrs={"aria-describedby": "desc_wikipedia_link"}
),
"website": forms.TextInput(attrs={"aria-describedby": "desc_website"}),
"born": forms.SelectDateWidget(attrs={"aria-describedby": "desc_born"}),
"died": forms.SelectDateWidget(attrs={"aria-describedby": "desc_died"}),
"oepnlibrary_key": forms.TextInput(
attrs={"aria-describedby": "desc_oepnlibrary_key"}
"openlibrary_key": forms.TextInput(
attrs={"aria-describedby": "desc_openlibrary_key"}
),
"inventaire_id": forms.TextInput(
attrs={"aria-describedby": "desc_inventaire_id"}

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

@ -8,6 +8,7 @@ import pyotp
from bookwyrm import models
from bookwyrm.settings import DOMAIN
from bookwyrm.settings import TWO_FACTOR_LOGIN_VALIDITY_WINDOW
from .custom_form import CustomForm
@ -108,7 +109,7 @@ class Confirm2FAForm(CustomForm):
otp = self.data.get("otp")
totp = pyotp.TOTP(self.instance.otp_secret)
if not totp.verify(otp):
if not totp.verify(otp, valid_window=TWO_FACTOR_LOGIN_VALIDITY_WINDOW):
if self.instance.hotp_secret:
# maybe it's a backup code?

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

@ -53,6 +53,7 @@ class QuotationForm(CustomForm):
"sensitive",
"privacy",
"position",
"endposition",
"position_mode",
]

View file

@ -1,7 +1,8 @@
""" handle reading a csv from an external service, defaults are from Goodreads """
import csv
from datetime import timedelta
from django.utils import timezone
from bookwyrm.models import ImportJob, ImportItem
from bookwyrm.models import ImportJob, ImportItem, SiteSettings
class Importer:
@ -33,6 +34,7 @@ class Importer:
"reading": ["currently-reading", "reading", "currently reading"],
}
# pylint: disable=too-many-locals
def create_job(self, user, csv_file, include_reviews, privacy):
"""check over a csv and creates a database entry for the job"""
csv_reader = csv.DictReader(csv_file, delimiter=self.delimiter)
@ -49,7 +51,13 @@ class Importer:
source=self.service,
)
enforce_limit, allowed_imports = self.get_import_limit(user)
if enforce_limit and allowed_imports <= 0:
job.complete_job()
return job
for index, entry in rows:
if enforce_limit and index >= allowed_imports:
break
self.create_item(job, index, entry)
return job
@ -99,6 +107,24 @@ class Importer:
"""use the dataclass to create the formatted row of data"""
return {k: entry.get(v) for k, v in mappings.items()}
def get_import_limit(self, user): # pylint: disable=no-self-use
"""check if import limit is set and return how many imports are left"""
site_settings = SiteSettings.objects.get()
import_size_limit = site_settings.import_size_limit
import_limit_reset = site_settings.import_limit_reset
enforce_limit = import_size_limit and import_limit_reset
allowed_imports = 0
if enforce_limit:
time_range = timezone.now() - timedelta(days=import_limit_reset)
import_jobs = ImportJob.objects.filter(
user=user, created_date__gte=time_range
)
# pylint: disable=consider-using-generator
imported_books = sum([job.successful_item_count for job in import_jobs])
allowed_imports = import_size_limit - imported_books
return enforce_limit, allowed_imports
def create_retry_job(self, user, original_job, items):
"""retry items that didn't import"""
job = ImportJob.objects.create(
@ -110,7 +136,13 @@ class Importer:
mappings=original_job.mappings,
retry=True,
)
for item in items:
enforce_limit, allowed_imports = self.get_import_limit(user)
if enforce_limit and allowed_imports <= 0:
job.complete_job()
return job
for index, item in enumerate(items):
if enforce_limit and index >= allowed_imports:
break
# this will re-normalize the raw data
self.create_item(job, item.index, item.data)
return job

View file

@ -19,7 +19,7 @@ class LibrarythingImporter(Importer):
normalized = {k: remove_brackets(entry.get(v)) for k, v in mappings.items()}
isbn_13 = normalized.get("isbn_13")
isbn_13 = isbn_13.split(", ") if isbn_13 else []
normalized["isbn_13"] = isbn_13[1] if len(isbn_13) > 0 else None
normalized["isbn_13"] = isbn_13[1] if len(isbn_13) > 1 else None
return normalized
def get_shelf(self, normalized_row):

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):
@ -24,8 +24,7 @@ class ListsStream(RedisStore):
def add_list(self, book_list):
"""add a list to users' feeds"""
# the pipeline contains all the add-to-stream activities
self.add_object_to_related_stores(book_list)
self.add_object_to_stores(book_list, self.get_stores_for_object(book_list))
def add_user_lists(self, viewer, user):
"""add a user's lists to another user's feed"""
@ -86,18 +85,19 @@ class ListsStream(RedisStore):
if group:
audience = audience.filter(
Q(id=book_list.user.id) # if the user is the list's owner
| Q(following=book_list.user) # if the user is following the pwmer
| Q(following=book_list.user) # if the user is following the owner
# if a user is in the group
| Q(memberships__group__id=book_list.group.id)
)
else:
audience = audience.filter(
Q(id=book_list.user.id) # if the user is the list's owner
| Q(following=book_list.user) # if the user is following the pwmer
| Q(following=book_list.user) # if the user is following the owner
)
return audience.distinct()
def get_stores_for_object(self, obj):
"""the stores that an object belongs in"""
return [self.stream_id(u) for u in self.get_audience(obj)]
def get_lists_for_user(self, user): # pylint: disable=no-self-use
@ -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(
@ -233,20 +233,20 @@ def remove_list_task(list_id, re_add=False):
# delete for every store
stores = [ListsStream().stream_id(idx) for idx in stores]
ListsStream().remove_object_from_related_stores(list_id, stores=stores)
ListsStream().remove_object_from_stores(list_id, stores)
if re_add:
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

@ -3,38 +3,7 @@ merge book data objects """
from django.core.management.base import BaseCommand
from django.db.models import Count
from bookwyrm import models
def update_related(canonical, obj):
"""update all the models with fk to the object being removed"""
# move related models to canonical
related_models = [
(r.remote_field.name, r.related_model) for r in canonical._meta.related_objects
]
for (related_field, related_model) in related_models:
related_objs = related_model.objects.filter(**{related_field: obj})
for related_obj in related_objs:
print("replacing in", related_model.__name__, related_field, related_obj.id)
try:
setattr(related_obj, related_field, canonical)
related_obj.save()
except TypeError:
getattr(related_obj, related_field).add(canonical)
getattr(related_obj, related_field).remove(obj)
def copy_data(canonical, obj):
"""try to get the most data possible"""
for data_field in obj._meta.get_fields():
if not hasattr(data_field, "activitypub_field"):
continue
data_value = getattr(obj, data_field.name)
if not data_value:
continue
if not getattr(canonical, data_field.name):
print("setting data field", data_field.name, data_value)
setattr(canonical, data_field.name, data_value)
canonical.save()
from bookwyrm.management.merge import merge_objects
def dedupe_model(model):
@ -61,19 +30,16 @@ def dedupe_model(model):
print("keeping", canonical.remote_id)
for obj in objs[1:]:
print(obj.remote_id)
copy_data(canonical, obj)
update_related(canonical, obj)
# remove the outdated entry
obj.delete()
merge_objects(canonical, obj)
class Command(BaseCommand):
"""dedplucate allllll the book data models"""
"""deduplicate allllll the book data models"""
help = "merges duplicate book data"
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
"""run deudplications"""
"""run deduplications"""
dedupe_model(models.Edition)
dedupe_model(models.Work)
dedupe_model(models.Author)

View file

@ -4,12 +4,7 @@ import redis
from bookwyrm import settings
r = redis.Redis(
host=settings.REDIS_ACTIVITY_HOST,
port=settings.REDIS_ACTIVITY_PORT,
password=settings.REDIS_ACTIVITY_PASSWORD,
db=settings.REDIS_ACTIVITY_DB_INDEX,
)
r = redis.from_url(settings.REDIS_ACTIVITY_URL)
def erase_streams():

View file

@ -117,10 +117,12 @@ def init_connectors():
def init_settings():
"""info about the instance"""
group_editor = Group.objects.filter(name="editor").first()
models.SiteSettings.objects.create(
support_link="https://www.patreon.com/bookwyrm",
support_title="Patreon",
install_mode=True,
default_user_auth_group=group_editor,
)

View file

@ -0,0 +1,12 @@
""" PROCEED WITH CAUTION: uses deduplication fields to permanently
merge author data objects """
from bookwyrm import models
from bookwyrm.management.merge_command import MergeCommand
class Command(MergeCommand):
"""merges two authors by ID"""
help = "merges specified authors into one"
MODEL = models.Author

View file

@ -0,0 +1,12 @@
""" PROCEED WITH CAUTION: uses deduplication fields to permanently
merge edition data objects """
from bookwyrm import models
from bookwyrm.management.merge_command import MergeCommand
class Command(MergeCommand):
"""merges two editions by ID"""
help = "merges specified editions into one"
MODEL = models.Edition

View file

@ -0,0 +1,12 @@
""" PROCEED WITH CAUTION: uses deduplication fields to permanently
merge work data objects """
from bookwyrm import models
from bookwyrm.management.merge_command import MergeCommand
class Command(MergeCommand):
"""merges two works by ID"""
help = "merges specified works into one"
MODEL = models.Work

View file

@ -33,10 +33,10 @@ def remove_editions():
class Command(BaseCommand):
"""dedplucate allllll the book data models"""
"""deduplicate allllll the book data models"""
help = "merges duplicate book data"
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
"""run deudplications"""
"""run deduplications"""
remove_editions()

View file

@ -9,7 +9,7 @@ class Command(BaseCommand):
# pylint: disable=unused-argument
def handle(self, *args, **options):
"""reveoke nonessential low priority tasks"""
"""revoke nonessential low priority tasks"""
types = [
"bookwyrm.preview_images.generate_edition_preview_image_task",
"bookwyrm.preview_images.generate_user_preview_image_task",

View file

@ -0,0 +1,50 @@
from django.db.models import ManyToManyField
def update_related(canonical, obj):
"""update all the models with fk to the object being removed"""
# move related models to canonical
related_models = [
(r.remote_field.name, r.related_model) for r in canonical._meta.related_objects
]
for (related_field, related_model) in related_models:
# Skip the ManyToMany fields that arent auto-created. These
# should have a corresponding OneToMany field in the model for
# the linking table anyway. If we update it through that model
# instead then we wont lose the extra fields in the linking
# table.
related_field_obj = related_model._meta.get_field(related_field)
if isinstance(related_field_obj, ManyToManyField):
through = related_field_obj.remote_field.through
if not through._meta.auto_created:
continue
related_objs = related_model.objects.filter(**{related_field: obj})
for related_obj in related_objs:
print("replacing in", related_model.__name__, related_field, related_obj.id)
try:
setattr(related_obj, related_field, canonical)
related_obj.save()
except TypeError:
getattr(related_obj, related_field).add(canonical)
getattr(related_obj, related_field).remove(obj)
def copy_data(canonical, obj):
"""try to get the most data possible"""
for data_field in obj._meta.get_fields():
if not hasattr(data_field, "activitypub_field"):
continue
data_value = getattr(obj, data_field.name)
if not data_value:
continue
if not getattr(canonical, data_field.name):
print("setting data field", data_field.name, data_value)
setattr(canonical, data_field.name, data_value)
canonical.save()
def merge_objects(canonical, obj):
copy_data(canonical, obj)
update_related(canonical, obj)
# remove the outdated entry
obj.delete()

View file

@ -0,0 +1,29 @@
from bookwyrm.management.merge import merge_objects
from django.core.management.base import BaseCommand
class MergeCommand(BaseCommand):
"""base class for merge commands"""
def add_arguments(self, parser):
"""add the arguments for this command"""
parser.add_argument("--canonical", type=int, required=True)
parser.add_argument("--other", type=int, required=True)
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
"""merge the two objects"""
model = self.MODEL
try:
canonical = model.objects.get(id=options["canonical"])
except model.DoesNotExist:
print("canonical book doesnt exist!")
return
try:
other = model.objects.get(id=options["other"])
except model.DoesNotExist:
print("other book doesnt exist!")
return
merge_objects(canonical, other)

View file

@ -1467,7 +1467,7 @@ class Migration(migrations.Migration):
(
"expiry",
models.DateTimeField(
default=bookwyrm.models.site.get_passowrd_reset_expiry
default=bookwyrm.models.site.get_password_reset_expiry
),
),
(

View file

@ -6,7 +6,7 @@ from bookwyrm.connectors.abstract_connector import infer_physical_format
def infer_format(app_registry, schema_editor):
"""set the new phsyical format field based on existing format data"""
"""set the new physical format field based on existing format data"""
db_alias = schema_editor.connection.alias
editions = (

View file

@ -5,7 +5,7 @@ from bookwyrm.settings import DOMAIN
def remove_self_connector(app_registry, schema_editor):
"""set the new phsyical format field based on existing format data"""
"""set the new physical format field based on existing format data"""
db_alias = schema_editor.connection.alias
app_registry.get_model("bookwyrm", "Connector").objects.using(db_alias).filter(
connector_file="self_connector"

View file

@ -0,0 +1,23 @@
# Generated by Django 3.2.16 on 2022-12-05 13:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0166_sitesettings_imports_enabled"),
]
operations = [
migrations.AddField(
model_name="sitesettings",
name="import_size_limit",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="sitesettings",
name="import_limit_reset",
field=models.IntegerField(default=0),
),
]

View file

@ -0,0 +1,13 @@
# Generated by Django 3.2.16 on 2022-12-19 20:20
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0167_sitesettings_import_size_limit"),
("bookwyrm", "0170_merge_0168_auto_20221205_2331_0169_auto_20221206_0902"),
]
operations = []

View file

@ -0,0 +1,21 @@
# Generated by Django 3.2.16 on 2023-01-15 08:38
import bookwyrm.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0172_alter_user_preferred_language"),
]
operations = [
migrations.AddField(
model_name="author",
name="website",
field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
),
]

View file

@ -0,0 +1,34 @@
# Generated by Django 3.2.16 on 2022-12-27 21:34
from django.db import migrations, models
import django.db.models.deletion
def backfill_sitesettings(apps, schema_editor):
db_alias = schema_editor.connection.alias
group_model = apps.get_model("auth", "Group")
editor_group = group_model.objects.using(db_alias).filter(name="editor").first()
sitesettings_model = apps.get_model("bookwyrm", "SiteSettings")
sitesettings_model.objects.update(default_user_auth_group=editor_group)
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0175_merge_0173_author_website_0174_merge_20230111_1523"),
]
operations = [
migrations.AddField(
model_name="sitesettings",
name="default_user_auth_group",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.RESTRICT,
to="auth.group",
),
),
migrations.RunPython(backfill_sitesettings, migrations.RunPython.noop),
]

View file

@ -0,0 +1,13 @@
# Generated by Django 3.2.16 on 2023-01-02 14:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0171_merge_20221219_2020"),
("bookwyrm", "0172_alter_user_preferred_language"),
]
operations = []

View file

@ -0,0 +1,35 @@
# Generated by Django 3.2.16 on 2023-01-30 12:40
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("bookwyrm", "0173_default_user_auth_group_setting"),
]
operations = [
migrations.AddField(
model_name="quotation",
name="endposition",
field=models.IntegerField(
blank=True,
null=True,
validators=[django.core.validators.MinValueValidator(0)],
),
),
migrations.AlterField(
model_name="sitesettings",
name="default_user_auth_group",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="auth.group",
),
),
]

View file

@ -0,0 +1,46 @@
# Generated by Django 3.2.18 on 2023-02-22 17:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0174_auto_20230130_1240"),
]
operations = [
migrations.AddField(
model_name="notification",
name="related_link_domains",
field=models.ManyToManyField(to="bookwyrm.LinkDomain"),
),
migrations.AlterField(
model_name="notification",
name="notification_type",
field=models.CharField(
choices=[
("FAVORITE", "Favorite"),
("REPLY", "Reply"),
("MENTION", "Mention"),
("TAG", "Tag"),
("FOLLOW", "Follow"),
("FOLLOW_REQUEST", "Follow Request"),
("BOOST", "Boost"),
("IMPORT", "Import"),
("ADD", "Add"),
("REPORT", "Report"),
("LINK_DOMAIN", "Link Domain"),
("INVITE", "Invite"),
("ACCEPT", "Accept"),
("JOIN", "Join"),
("LEAVE", "Leave"),
("REMOVE", "Remove"),
("GROUP_PRIVACY", "Group Privacy"),
("GROUP_NAME", "Group Name"),
("GROUP_DESCRIPTION", "Group Description"),
],
max_length=255,
),
),
]

View file

@ -0,0 +1,12 @@
# Generated by Django 3.2.16 on 2023-01-11 15:23
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0173_merge_20230102_1444"),
]
operations = []

View file

@ -0,0 +1,13 @@
# Generated by Django 3.2.16 on 2023-01-19 20:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0173_author_website"),
("bookwyrm", "0174_merge_20230111_1523"),
]
operations = []

View file

@ -0,0 +1,53 @@
# Generated by Django 3.2.16 on 2022-12-17 19:28
import bookwyrm.models.fields
import django.contrib.postgres.fields.citext
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0174_auto_20230130_1240"),
]
operations = [
migrations.CreateModel(
name="Hashtag",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
(
"remote_id",
bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
),
(
"name",
django.contrib.postgres.fields.citext.CICharField(max_length=256),
),
],
options={
"abstract": False,
},
),
migrations.AddField(
model_name="status",
name="mention_hashtags",
field=bookwyrm.models.fields.TagField(
related_name="mention_hashtag", to="bookwyrm.Hashtag"
),
),
]

View file

@ -0,0 +1,13 @@
# Generated by Django 3.2.18 on 2023-03-12 23:41
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0174_auto_20230222_1742"),
("bookwyrm", "0176_hashtag_support"),
]
operations = []

View file

@ -0,0 +1,61 @@
# Generated by Django 3.2.18 on 2023-03-28 21:32
import bookwyrm.models.fields
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("bookwyrm", "0177_merge_0174_auto_20230222_1742_0176_hashtag_support"),
]
operations = [
migrations.AlterField(
model_name="hashtag",
name="name",
field=bookwyrm.models.fields.CICharField(max_length=256),
),
migrations.AlterField(
model_name="sitesettings",
name="default_user_auth_group",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.RESTRICT,
to="auth.group",
),
),
migrations.AlterField(
model_name="user",
name="preferred_language",
field=models.CharField(
blank=True,
choices=[
("en-us", "English"),
("ca-es", "Català (Catalan)"),
("de-de", "Deutsch (German)"),
("eo-uy", "Esperanto (Esperanto)"),
("es-es", "Español (Spanish)"),
("eu-es", "Euskara (Basque)"),
("gl-es", "Galego (Galician)"),
("it-it", "Italiano (Italian)"),
("fi-fi", "Suomi (Finnish)"),
("fr-fr", "Français (French)"),
("lt-lt", "Lietuvių (Lithuanian)"),
("no-no", "Norsk (Norwegian)"),
("pl-pl", "Polski (Polish)"),
("pt-br", "Português do Brasil (Brazilian Portuguese)"),
("pt-pt", "Português Europeu (European Portuguese)"),
("ro-ro", "Română (Romanian)"),
("sv-se", "Svenska (Swedish)"),
("zh-hans", "简体中文 (Simplified Chinese)"),
("zh-hant", "繁體中文 (Traditional Chinese)"),
],
max_length=255,
null=True,
),
),
]

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

@ -34,6 +34,8 @@ from .antispam import EmailBlocklist, IPBlocklist, AutoMod, automod_task
from .notification import Notification
from .hashtag import Hashtag
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_models = {
c[1].activity_serializer.__name__: c[1]

View file

@ -21,11 +21,11 @@ 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
from bookwyrm.tasks import app, BROADCAST
from bookwyrm.models.fields import ImageField, ManyToManyField
logger = logging.getLogger(__name__)
# I tried to separate these classes into mutliple files but I kept getting
# I tried to separate these classes into multiple files but I kept getting
# circular import errors so I gave up. I'm sure it could be done though!
PropertyField = namedtuple("PropertyField", ("set_activity_from_field"))
@ -91,7 +91,7 @@ class ActivitypubMixin:
@classmethod
def find_existing(cls, data):
"""compare data to fields that can be used for deduplation.
"""compare data to fields that can be used for deduplication.
This always includes remote_id, but can also be unique identifiers
like an isbn for an edition"""
filters = []
@ -126,7 +126,7 @@ class ActivitypubMixin:
# there OUGHT to be only one match
return match.first()
def broadcast(self, activity, sender, software=None, queue=MEDIUM):
def broadcast(self, activity, sender, software=None, queue=BROADCAST):
"""send out an activity"""
broadcast_task.apply_async(
args=(
@ -198,7 +198,7 @@ class ActivitypubMixin:
class ObjectMixin(ActivitypubMixin):
"""add this mixin for object models that are AP serializable"""
def save(self, *args, created=None, software=None, priority=MEDIUM, **kwargs):
def save(self, *args, created=None, software=None, priority=BROADCAST, **kwargs):
"""broadcast created/updated/deleted objects as appropriate"""
broadcast = kwargs.get("broadcast", True)
# this bonus kwarg would cause an error in the base save method
@ -234,8 +234,8 @@ class ObjectMixin(ActivitypubMixin):
activity = self.to_create_activity(user)
self.broadcast(activity, user, software=software, queue=priority)
except AttributeError:
# janky as heck, this catches the mutliple inheritence chain
# for boosts and ignores this auxilliary broadcast
# janky as heck, this catches the multiple inheritance chain
# for boosts and ignores this auxiliary broadcast
return
return
@ -311,7 +311,7 @@ class OrderedCollectionPageMixin(ObjectMixin):
@property
def collection_remote_id(self):
"""this can be overriden if there's a special remote id, ie outbox"""
"""this can be overridden if there's a special remote id, ie outbox"""
return self.remote_id
def to_ordered_collection(
@ -339,7 +339,7 @@ class OrderedCollectionPageMixin(ObjectMixin):
activity["id"] = remote_id
paginated = Paginator(queryset, PAGE_LENGTH)
# add computed fields specific to orderd collections
# add computed fields specific to ordered collections
activity["totalItems"] = paginated.count
activity["first"] = f"{remote_id}?page=1"
activity["last"] = f"{remote_id}?page={paginated.num_pages}"
@ -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,12 +400,12 @@ 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)
# list items can be updateda, normally you would only broadcast on created
# list items can be updated, normally you would only broadcast on created
if not broadcast or not self.user.local:
return
@ -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
@ -506,7 +506,7 @@ def unfurl_related_field(related_field, sort_field=None):
return related_field.remote_id
@app.task(queue=MEDIUM)
@app.task(queue=BROADCAST)
def broadcast_task(sender_id: int, activity: str, recipients: List[str]):
"""the celery task for broadcast"""
user_model = apps.get_model("bookwyrm.User", require_ready=True)
@ -529,7 +529,7 @@ async def async_broadcast(recipients: List[str], sender, data: str):
async def sign_and_send(
session: aiohttp.ClientSession, sender, data: str, destination: str
session: aiohttp.ClientSession, sender, data: str, destination: str, **kwargs
):
"""Sign the messages and send them in an asynchronous bundle"""
now = http_date()
@ -539,11 +539,19 @@ async def sign_and_send(
raise ValueError("No private key found for sender")
digest = make_digest(data)
signature = make_signature(
"post",
sender,
destination,
now,
digest=digest,
use_legacy_key=kwargs.get("use_legacy_key"),
)
headers = {
"Date": now,
"Digest": digest,
"Signature": make_signature(sender, destination, now, digest),
"Signature": signature,
"Content-Type": "application/activity+json; charset=utf-8",
"User-Agent": USER_AGENT,
}
@ -554,6 +562,14 @@ async def sign_and_send(
logger.exception(
"Failed to send broadcast to %s: %s", destination, response.reason
)
if kwargs.get("use_legacy_key") is not True:
logger.info("Trying again with legacy keyId header value")
asyncio.ensure_future(
sign_and_send(
session, sender, data, destination, use_legacy_key=True
)
)
return response
except asyncio.TimeoutError:
logger.info("Connection timed out for url: %s", destination)
@ -565,7 +581,7 @@ async def sign_and_send(
def to_ordered_collection_page(
queryset, remote_id, id_only=False, page=1, pure=False, **kwargs
):
"""serialize and pagiante a queryset"""
"""serialize and paginate a queryset"""
paginated = Paginator(queryset, PAGE_LENGTH)
activity_page = paginated.get_page(page)

View file

@ -24,7 +24,7 @@ class AnnualGoal(BookWyrmModel):
)
class Meta:
"""unqiueness constraint"""
"""uniqueness constraint"""
unique_together = ("user", "year")
@ -52,7 +52,7 @@ class AnnualGoal(BookWyrmModel):
user=self.user,
book__in=book_ids,
)
return {r.book.id: r.rating for r in reviews}
return {r.book_id: r.rating for r in reviews}
@property
def progress(self):

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

@ -25,6 +25,10 @@ class Author(BookDataModel):
isfdb = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True
)
website = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True
)
# idk probably other keys would be useful here?
born = fields.DateTimeField(blank=True, null=True)
died = fields.DateTimeField(blank=True, null=True)

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,
)
@ -321,7 +323,7 @@ class Edition(Book):
def get_rank(self):
"""calculate how complete the data is on this edition"""
rank = 0
# big ups for havinga cover
# big ups for having a cover
rank += int(bool(self.cover)) * 3
# is it in the instance's preferred language?
rank += int(bool(DEFAULT_LANGUAGE in self.languages))
@ -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

@ -20,8 +20,9 @@ class Favorite(ActivityMixin, BookWyrmModel):
activity_serializer = activitypub.Like
# pylint: disable=unused-argument
@classmethod
def ignore_activity(cls, activity):
def ignore_activity(cls, activity, allow_external_connections=True):
"""don't bother with incoming favs of unknown statuses"""
return not Status.objects.filter(remote_id=activity.object).exists()

View file

@ -7,6 +7,7 @@ from urllib.parse import urljoin
import dateutil.parser
from dateutil.parser import ParserError
from django.contrib.postgres.fields import ArrayField as DjangoArrayField
from django.contrib.postgres.fields import CICharField as DjangoCICharField
from django.core.exceptions import ValidationError
from django.db import models
from django.forms import ClearableFileInput, ImageField as DjangoImageField
@ -67,16 +68,20 @@ class ActivitypubFieldMixin:
self.activitypub_field = activitypub_field
super().__init__(*args, **kwargs)
def set_field_from_activity(self, instance, data, overwrite=True):
"""helper function for assinging a value to the field. Returns if changed"""
def set_field_from_activity(
self, instance, data, overwrite=True, allow_external_connections=True
):
"""helper function for assigning a value to the field. Returns if changed"""
try:
value = getattr(data, self.get_activitypub_field())
except AttributeError:
# masssively hack-y workaround for boosts
# massively hack-y workaround for boosts
if self.get_activitypub_field() != "attributedTo":
raise
value = getattr(data, "actor")
formatted = self.field_from_activity(value)
formatted = self.field_from_activity(
value, allow_external_connections=allow_external_connections
)
if formatted is None or formatted is MISSING or formatted == {}:
return False
@ -116,7 +121,8 @@ class ActivitypubFieldMixin:
return {self.activitypub_wrapper: value}
return value
def field_from_activity(self, value):
# pylint: disable=unused-argument
def field_from_activity(self, value, allow_external_connections=True):
"""formatter to convert activitypub into a model value"""
if value and hasattr(self, "activitypub_wrapper"):
value = value.get(self.activitypub_wrapper)
@ -138,7 +144,7 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
self.load_remote = load_remote
super().__init__(*args, **kwargs)
def field_from_activity(self, value):
def field_from_activity(self, value, allow_external_connections=True):
if not value:
return None
@ -159,7 +165,11 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
if not self.load_remote:
# only look in the local database
return related_model.find_existing_by_remote_id(value)
return activitypub.resolve_remote_id(value, model=related_model)
return activitypub.resolve_remote_id(
value,
model=related_model,
allow_external_connections=allow_external_connections,
)
class RemoteIdField(ActivitypubFieldMixin, models.CharField):
@ -211,7 +221,7 @@ PrivacyLevels = [
class PrivacyField(ActivitypubFieldMixin, models.CharField):
"""this maps to two differente activitypub fields"""
"""this maps to two different activitypub fields"""
public = "https://www.w3.org/ns/activitystreams#Public"
@ -219,7 +229,9 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
super().__init__(*args, max_length=255, choices=PrivacyLevels, default="public")
# pylint: disable=invalid-name
def set_field_from_activity(self, instance, data, overwrite=True):
def set_field_from_activity(
self, instance, data, overwrite=True, allow_external_connections=True
):
if not overwrite:
return False
@ -234,7 +246,11 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
break
if not user_field:
raise ValidationError("No user field found for privacy", data)
user = activitypub.resolve_remote_id(getattr(data, user_field), model="User")
user = activitypub.resolve_remote_id(
getattr(data, user_field),
model="User",
allow_external_connections=allow_external_connections,
)
if to == [self.public]:
setattr(instance, self.name, "public")
@ -295,13 +311,17 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
self.link_only = link_only
super().__init__(*args, **kwargs)
def set_field_from_activity(self, instance, data, overwrite=True):
def set_field_from_activity(
self, instance, data, overwrite=True, allow_external_connections=True
):
"""helper function for assigning a value to the field"""
if not overwrite and getattr(instance, self.name).exists():
return False
value = getattr(data, self.get_activitypub_field())
formatted = self.field_from_activity(value)
formatted = self.field_from_activity(
value, allow_external_connections=allow_external_connections
)
if formatted is None or formatted is MISSING:
return False
getattr(instance, self.name).set(formatted)
@ -313,7 +333,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
return f"{value.instance.remote_id}/{self.name}"
return [i.remote_id for i in value.all()]
def field_from_activity(self, value):
def field_from_activity(self, value, allow_external_connections=True):
if value is None or value is MISSING:
return None
if not isinstance(value, list):
@ -326,7 +346,11 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
except ValidationError:
continue
items.append(
activitypub.resolve_remote_id(remote_id, model=self.related_model)
activitypub.resolve_remote_id(
remote_id,
model=self.related_model,
allow_external_connections=allow_external_connections,
)
)
return items
@ -344,18 +368,29 @@ class TagField(ManyToManyField):
activity_type = item.__class__.__name__
if activity_type == "User":
activity_type = "Mention"
if activity_type == "Hashtag":
name = item.name
else:
name = f"@{getattr(item, item.name_field)}"
tags.append(
activitypub.Link(
href=item.remote_id,
name=getattr(item, item.name_field),
name=name,
type=activity_type,
)
)
return tags
def field_from_activity(self, value):
def field_from_activity(self, value, allow_external_connections=True):
if not isinstance(value, list):
return None
# GoToSocial DMs and single-user mentions are
# sent as objects, not as an array of objects
if isinstance(value, dict):
value = [value]
else:
return None
items = []
for link_json in value:
link = activitypub.Link(**link_json)
@ -365,9 +400,22 @@ class TagField(ManyToManyField):
if tag_type != self.related_model.activity_serializer.type:
# tags can contain multiple types
continue
items.append(
activitypub.resolve_remote_id(link.href, model=self.related_model)
)
if tag_type == "Hashtag":
# we already have all data to create hashtags,
# no need to fetch from remote
item = self.related_model.activity_serializer(**link_json)
hashtag = item.to_model(model=self.related_model, save=True)
items.append(hashtag)
else:
# for other tag types we fetch them remotely
items.append(
activitypub.resolve_remote_id(
link.href,
model=self.related_model,
allow_external_connections=allow_external_connections,
)
)
return items
@ -390,11 +438,15 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
self.alt_field = alt_field
super().__init__(*args, **kwargs)
# pylint: disable=arguments-differ,arguments-renamed
def set_field_from_activity(self, instance, data, save=True, overwrite=True):
"""helper function for assinging a value to the field"""
# pylint: disable=arguments-differ,arguments-renamed,too-many-arguments
def set_field_from_activity(
self, instance, data, save=True, overwrite=True, allow_external_connections=True
):
"""helper function for assigning a value to the field"""
value = getattr(data, self.get_activitypub_field())
formatted = self.field_from_activity(value)
formatted = self.field_from_activity(
value, allow_external_connections=allow_external_connections
)
if formatted is None or formatted is MISSING:
return False
@ -426,7 +478,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
return activitypub.Document(url=url, name=alt)
def field_from_activity(self, value):
def field_from_activity(self, value, allow_external_connections=True):
image_slug = value
# when it's an inline image (User avatar/icon, Book cover), it's a json
# blob, but when it's an attached image, it's just a url
@ -481,7 +533,7 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
return None
return value.isoformat()
def field_from_activity(self, value):
def field_from_activity(self, value, allow_external_connections=True):
try:
date_value = dateutil.parser.parse(value)
try:
@ -495,7 +547,7 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
class HtmlField(ActivitypubFieldMixin, models.TextField):
"""a text field for storing html"""
def field_from_activity(self, value):
def field_from_activity(self, value, allow_external_connections=True):
if not value or value == MISSING:
return None
return clean(value)
@ -515,6 +567,10 @@ class CharField(ActivitypubFieldMixin, models.CharField):
"""activitypub-aware char field"""
class CICharField(ActivitypubFieldMixin, DjangoCICharField):
"""activitypub-aware cichar field"""
class URLField(ActivitypubFieldMixin, models.URLField):
"""activitypub-aware url field"""

View file

@ -0,0 +1,23 @@
""" model for tags """
from bookwyrm import activitypub
from .activitypub_mixin import ActivitypubMixin
from .base_model import BookWyrmModel
from .fields import CICharField
class Hashtag(ActivitypubMixin, BookWyrmModel):
"a hashtag which can be used in statuses"
name = CICharField(
max_length=256,
blank=False,
null=False,
activitypub_field="name",
deduplication_field=True,
)
name_field = "name"
activity_serializer = activitypub.Hashtag
def __repr__(self):
return f"<{self.__class__} id={self.id} name={self.name}>"

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
@ -252,9 +252,12 @@ class ImportItem(models.Model):
@property
def rating(self):
"""x/5 star rating for a book"""
if self.normalized_data.get("rating"):
if not self.normalized_data.get("rating"):
return None
try:
return float(self.normalized_data.get("rating"))
return None
except ValueError:
return None
@property
def date_added(self):
@ -396,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
@ -438,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(
@ -455,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

@ -31,7 +31,7 @@ class Link(ActivitypubMixin, BookWyrmModel):
@property
def name(self):
"""link name via the assocaited domain"""
"""link name via the associated domain"""
return self.domain.name
def save(self, *args, **kwargs):

View file

@ -2,8 +2,8 @@
from django.db import models, transaction
from django.dispatch import receiver
from .base_model import BookWyrmModel
from . import Boost, Favorite, GroupMemberInvitation, ImportJob, ListItem, Report
from . import Status, User, UserFollowRequest
from . import Boost, Favorite, GroupMemberInvitation, ImportJob, LinkDomain
from . import ListItem, Report, Status, User, UserFollowRequest
class Notification(BookWyrmModel):
@ -28,6 +28,7 @@ class Notification(BookWyrmModel):
# Admin
REPORT = "REPORT"
LINK_DOMAIN = "LINK_DOMAIN"
# Groups
INVITE = "INVITE"
@ -43,7 +44,7 @@ class Notification(BookWyrmModel):
NotificationType = models.TextChoices(
# there has got be a better way to do this
"NotificationType",
f"{FAVORITE} {REPLY} {MENTION} {TAG} {FOLLOW} {FOLLOW_REQUEST} {BOOST} {IMPORT} {ADD} {REPORT} {INVITE} {ACCEPT} {JOIN} {LEAVE} {REMOVE} {GROUP_PRIVACY} {GROUP_NAME} {GROUP_DESCRIPTION}",
f"{FAVORITE} {REPLY} {MENTION} {TAG} {FOLLOW} {FOLLOW_REQUEST} {BOOST} {IMPORT} {ADD} {REPORT} {LINK_DOMAIN} {INVITE} {ACCEPT} {JOIN} {LEAVE} {REMOVE} {GROUP_PRIVACY} {GROUP_NAME} {GROUP_DESCRIPTION}",
)
user = models.ForeignKey("User", on_delete=models.CASCADE)
@ -64,6 +65,7 @@ class Notification(BookWyrmModel):
"ListItem", symmetrical=False, related_name="notifications"
)
related_reports = models.ManyToManyField("Report", symmetrical=False)
related_link_domains = models.ManyToManyField("LinkDomain", symmetrical=False)
@classmethod
@transaction.atomic
@ -241,6 +243,26 @@ def notify_admins_on_report(sender, instance, created, *args, **kwargs):
notification.related_reports.add(instance)
@receiver(models.signals.post_save, sender=LinkDomain)
@transaction.atomic
# pylint: disable=unused-argument
def notify_admins_on_link_domain(sender, instance, created, *args, **kwargs):
"""a new link domain needs to be verified"""
if not created:
# otherwise you'll get a notification when you approve a domain
return
# moderators and superusers should be notified
admins = User.admins()
for admin in admins:
notification, _ = Notification.objects.get_or_create(
user=admin,
notification_type=Notification.LINK_DOMAIN,
read=False,
)
notification.related_link_domains.add(instance)
@receiver(models.signals.post_save, sender=GroupMemberInvitation)
# pylint: disable=unused-argument
def notify_user_on_group_invite(sender, instance, *args, **kwargs):
@ -262,7 +284,7 @@ def notify_user_on_list_item_add(sender, instance, created, *args, **kwargs):
return
list_owner = instance.book_list.user
# create a notification if somoene ELSE added to a local user's list
# create a notification if someone ELSE added to a local user's list
if list_owner.local and list_owner != instance.user:
# keep the related_user singular, group the items
Notification.notify_list_item(list_owner, instance)

View file

@ -8,7 +8,7 @@ from .base_model import BookWyrmModel
class ProgressMode(models.TextChoices):
"""types of prgress available"""
"""types of progress available"""
PAGE = "PG", "page"
PERCENT = "PCT", "percent"
@ -32,7 +32,7 @@ class ReadThrough(BookWyrmModel):
def save(self, *args, **kwargs):
"""update user active time"""
cache.delete(f"latest_read_through-{self.user.id}-{self.book.id}")
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 or self.stopped_date:

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
@ -34,7 +33,7 @@ class UserRelationship(BookWyrmModel):
@property
def recipients(self):
"""the remote user needs to recieve direct broadcasts"""
"""the remote user needs to receive direct broadcasts"""
return [u for u in [self.user_subject, self.user_object] if not u.local]
def save(self, *args, **kwargs):
@ -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,12 +165,16 @@ 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
with transaction.atomic():
UserFollows.from_request(self)
try:
UserFollows.from_request(self)
except IntegrityError:
# this just means we already saved this relationship
pass
if self.id:
self.delete()
@ -183,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:
@ -80,7 +80,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
raise PermissionDenied()
class Meta:
"""user/shelf unqiueness"""
"""user/shelf uniqueness"""
unique_together = ("user", "identifier")
@ -100,14 +100,14 @@ 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:
# remove all caches related to all editions of this book
cache.delete_many(
[
f"book-on-shelf-{book.id}-{self.shelf.id}"
f"book-on-shelf-{book.id}-{self.shelf_id}"
for book in self.book.parent_work.editions.all()
]
)
@ -117,7 +117,7 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
if self.id and self.user.local:
cache.delete_many(
[
f"book-on-shelf-{book}-{self.shelf.id}"
f"book-on-shelf-{book}-{self.shelf_id}"
for book in self.book.parent_work.editions.values_list(
"id", flat=True
)

View file

@ -3,6 +3,7 @@ import datetime
from urllib.parse import urljoin
import uuid
import django.contrib.auth.models as auth_models
from django.core.exceptions import PermissionDenied
from django.db import models, IntegrityError
from django.dispatch import receiver
@ -70,6 +71,9 @@ class SiteSettings(SiteModel):
allow_invite_requests = models.BooleanField(default=True)
invite_request_question = models.BooleanField(default=False)
require_confirm_email = models.BooleanField(default=True)
default_user_auth_group = models.ForeignKey(
auth_models.Group, null=True, blank=True, on_delete=models.RESTRICT
)
invite_question_text = models.CharField(
max_length=255, blank=True, default="What is your favourite book?"
@ -90,6 +94,8 @@ class SiteSettings(SiteModel):
# controls
imports_enabled = models.BooleanField(default=True)
import_size_limit = models.IntegerField(default=0)
import_limit_reset = models.IntegerField(default=0)
field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"])
@ -203,7 +209,7 @@ class InviteRequest(BookWyrmModel):
super().save(*args, **kwargs)
def get_passowrd_reset_expiry():
def get_password_reset_expiry():
"""give people a limited time to use the link"""
now = timezone.now()
return now + datetime.timedelta(days=1)
@ -213,7 +219,7 @@ class PasswordReset(models.Model):
"""gives someone access to create an account on the instance"""
code = models.CharField(max_length=32, default=new_access_code)
expiry = models.DateTimeField(default=get_passowrd_reset_expiry)
expiry = models.DateTimeField(default=get_password_reset_expiry)
user = models.OneToOneField(User, on_delete=models.CASCADE)
def valid(self):

View file

@ -34,6 +34,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
raw_content = models.TextField(blank=True, null=True)
mention_users = fields.TagField("User", related_name="mention_user")
mention_books = fields.TagField("Edition", related_name="mention_book")
mention_hashtags = fields.TagField("Hashtag", related_name="mention_hashtag")
local = models.BooleanField(default=True)
content_warning = fields.CharField(
max_length=500, blank=True, null=True, activitypub_field="summary"
@ -80,7 +81,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
def save(self, *args, **kwargs):
"""save and notify"""
if self.reply_parent:
self.thread_id = self.reply_parent.thread_id or self.reply_parent.id
self.thread_id = self.reply_parent.thread_id or self.reply_parent_id
super().save(*args, **kwargs)
@ -115,10 +116,16 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
return list(set(mentions))
@classmethod
def ignore_activity(cls, activity): # pylint: disable=too-many-return-statements
def ignore_activity(
cls, activity, allow_external_connections=True
): # pylint: disable=too-many-return-statements
"""keep notes if they are replies to existing statuses"""
if activity.type == "Announce":
boosted = activitypub.resolve_remote_id(activity.object, get_activity=True)
boosted = activitypub.resolve_remote_id(
activity.object,
get_activity=True,
allow_external_connections=allow_external_connections,
)
if not boosted:
# if we can't load the status, definitely ignore it
return True
@ -135,10 +142,17 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
# keep notes if they mention local users
if activity.tag == MISSING or activity.tag is None:
return True
tags = [l["href"] for l in activity.tag if l["type"] == "Mention"]
# GoToSocial sends single tags as objects
# not wrapped in a list
tags = activity.tag if isinstance(activity.tag, list) else [activity.tag]
user_model = apps.get_model("bookwyrm.User", require_ready=True)
for tag in tags:
if user_model.objects.filter(remote_id=tag, local=True).exists():
if (
tag["type"] == "Mention"
and user_model.objects.filter(
remote_id=tag["href"], local=True
).exists()
):
# we found a mention of a known use boost
return False
return True
@ -329,6 +343,9 @@ class Quotation(BookStatus):
position = models.IntegerField(
validators=[MinValueValidator(0)], null=True, blank=True
)
endposition = models.IntegerField(
validators=[MinValueValidator(0)], null=True, blank=True
)
position_mode = models.CharField(
max_length=3,
choices=ProgressMode.choices,

View file

@ -3,9 +3,9 @@ import re
from urllib.parse import urlparse
from django.apps import apps
from django.contrib.auth.models import AbstractUser, Group
from django.contrib.auth.models import AbstractUser
from django.contrib.postgres.fields import ArrayField, CICharField
from django.core.exceptions import PermissionDenied
from django.core.exceptions import PermissionDenied, ObjectDoesNotExist
from django.dispatch import receiver
from django.db import models, transaction
from django.utils import timezone
@ -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
@ -339,7 +339,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
# this is a new remote user, we need to set their remote server field
if not self.local:
super().save(*args, **kwargs)
transaction.on_commit(lambda: set_remote_server.delay(self.id))
transaction.on_commit(lambda: set_remote_server(self.id))
return
with transaction.atomic():
@ -356,8 +356,14 @@ class User(OrderedCollectionPageMixin, AbstractUser):
# make users editors by default
try:
self.groups.add(Group.objects.get(name="editor"))
except Group.DoesNotExist:
group = (
apps.get_model("bookwyrm.SiteSettings")
.objects.get()
.default_user_auth_group
)
if group:
self.groups.add(group)
except ObjectDoesNotExist:
# this should only happen in tests
pass
@ -388,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
@ -463,18 +471,30 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
return super().save(*args, **kwargs)
@app.task(queue=LOW)
def set_remote_server(user_id):
@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)
actor_parts = urlparse(user.remote_id)
user.federated_server = get_or_create_remote_server(actor_parts.netloc)
federated_server = get_or_create_remote_server(
actor_parts.netloc, allow_external_connections=allow_external_connections
)
# if we were unable to find the server, we need to create a new entry for it
if not federated_server:
# and to do that, we will call this function asynchronously.
if not allow_external_connections:
set_remote_server.delay(user_id, allow_external_connections=True)
return
user.federated_server = federated_server
user.save(broadcast=False, update_fields=["federated_server"])
if user.bookwyrm_user and user.outbox:
get_remote_reviews.delay(user.outbox)
def get_or_create_remote_server(domain, refresh=False):
def get_or_create_remote_server(
domain, allow_external_connections=False, refresh=False
):
"""get info on a remote server"""
server = FederatedServer()
try:
@ -484,6 +504,9 @@ def get_or_create_remote_server(domain, refresh=False):
except FederatedServer.DoesNotExist:
pass
if not allow_external_connections:
return None
try:
data = get_data(f"https://{domain}/.well-known/nodeinfo")
try:
@ -507,7 +530,7 @@ def get_or_create_remote_server(domain, refresh=False):
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

@ -4,12 +4,7 @@ import redis
from bookwyrm import settings
r = redis.Redis(
host=settings.REDIS_ACTIVITY_HOST,
port=settings.REDIS_ACTIVITY_PORT,
password=settings.REDIS_ACTIVITY_PASSWORD,
db=settings.REDIS_ACTIVITY_DB_INDEX,
)
r = redis.from_url(settings.REDIS_ACTIVITY_URL)
class RedisStore(ABC):
@ -21,12 +16,12 @@ class RedisStore(ABC):
"""the object and rank"""
return {obj.id: self.get_rank(obj)}
def add_object_to_related_stores(self, obj, execute=True):
"""add an object to all suitable stores"""
def add_object_to_stores(self, obj, stores, execute=True):
"""add an object to a given set of stores"""
value = self.get_value(obj)
# we want to do this as a bulk operation, hence "pipeline"
pipeline = r.pipeline()
for store in self.get_stores_for_object(obj):
for store in stores:
# add the status to the feed
pipeline.zadd(store, value)
# trim the store
@ -37,14 +32,14 @@ class RedisStore(ABC):
# and go!
return pipeline.execute()
def remove_object_from_related_stores(self, obj, stores=None):
# pylint: disable=no-self-use
def remove_object_from_stores(self, obj, stores):
"""remove an object from all stores"""
# if the stoers are provided, the object can just be an id
# if the stores are provided, the object can just be an id
if stores and isinstance(obj, int):
obj_id = obj
else:
obj_id = obj.id
stores = self.get_stores_for_object(obj) if stores is None else stores
pipeline = r.pipeline()
for store in stores:
pipeline.zrem(store, -1, obj_id)
@ -87,10 +82,6 @@ class RedisStore(ABC):
def get_objects_for_store(self, store):
"""a queryset of what should go in a store, used for populating it"""
@abstractmethod
def get_stores_for_object(self, obj):
"""the stores that an object belongs in"""
@abstractmethod
def get_rank(self, obj):
"""how to rank an object"""

View file

@ -4,6 +4,7 @@ from environs import Env
import requests
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ImproperlyConfigured
# pylint: disable=line-too-long
@ -11,22 +12,22 @@ from django.utils.translation import gettext_lazy as _
env = Env()
env.read_env()
DOMAIN = env("DOMAIN")
VERSION = "0.5.3"
VERSION = "0.6.4"
RELEASE_API = env(
"RELEASE_API",
"https://api.github.com/repos/bookwyrm-social/bookwyrm/releases/latest",
)
PAGE_LENGTH = env("PAGE_LENGTH", 15)
PAGE_LENGTH = env.int("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
JS_CACHE = "ad848b97"
JS_CACHE = "b972a43c"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
EMAIL_HOST = env("EMAIL_HOST")
EMAIL_PORT = env("EMAIL_PORT", 587)
EMAIL_PORT = env.int("EMAIL_PORT", 587)
EMAIL_HOST_USER = env("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", True)
@ -68,13 +69,15 @@ FONT_DIR = os.path.join(STATIC_ROOT, "fonts")
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env("SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env.bool("DEBUG", True)
USE_HTTPS = env.bool("USE_HTTPS", not DEBUG)
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env("SECRET_KEY")
if not DEBUG and SECRET_KEY == "7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr":
raise ImproperlyConfigured("You must change the SECRET_KEY env variable")
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", ["*"])
# Application definition
@ -101,6 +104,7 @@ MIDDLEWARE = [
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"csp.middleware.CSPMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"bookwyrm.middleware.TimezoneMiddleware",
"bookwyrm.middleware.IPBlocklistMiddleware",
@ -204,11 +208,14 @@ WSGI_APPLICATION = "bookwyrm.wsgi.application"
# redis/activity streams settings
REDIS_ACTIVITY_HOST = env("REDIS_ACTIVITY_HOST", "localhost")
REDIS_ACTIVITY_PORT = env("REDIS_ACTIVITY_PORT", 6379)
REDIS_ACTIVITY_PASSWORD = env("REDIS_ACTIVITY_PASSWORD", None)
REDIS_ACTIVITY_DB_INDEX = env("REDIS_ACTIVITY_DB_INDEX", 0)
MAX_STREAM_LENGTH = int(env("MAX_STREAM_LENGTH", 200))
REDIS_ACTIVITY_PORT = env.int("REDIS_ACTIVITY_PORT", 6379)
REDIS_ACTIVITY_PASSWORD = requests.utils.quote(env("REDIS_ACTIVITY_PASSWORD", ""))
REDIS_ACTIVITY_DB_INDEX = env.int("REDIS_ACTIVITY_DB_INDEX", 0)
REDIS_ACTIVITY_URL = env(
"REDIS_ACTIVITY_URL",
f"redis://:{REDIS_ACTIVITY_PASSWORD}@{REDIS_ACTIVITY_HOST}:{REDIS_ACTIVITY_PORT}/{REDIS_ACTIVITY_DB_INDEX}",
)
MAX_STREAM_LENGTH = env.int("MAX_STREAM_LENGTH", 200)
STREAMS = [
{"key": "home", "name": _("Home Timeline"), "shortname": _("Home")},
@ -217,12 +224,12 @@ STREAMS = [
# Search configuration
# total time in seconds that the instance will spend searching connectors
SEARCH_TIMEOUT = int(env("SEARCH_TIMEOUT", 8))
SEARCH_TIMEOUT = env.int("SEARCH_TIMEOUT", 8)
# timeout for a query to an individual connector
QUERY_TIMEOUT = int(env("QUERY_TIMEOUT", 5))
QUERY_TIMEOUT = env.int("INTERACTIVE_QUERY_TIMEOUT", env.int("QUERY_TIMEOUT", 5))
# Redis cache backend
if env("USE_DUMMY_CACHE", False):
if env.bool("USE_DUMMY_CACHE", False):
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.dummy.DummyCache",
@ -232,7 +239,7 @@ else:
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": f"redis://:{REDIS_ACTIVITY_PASSWORD}@{REDIS_ACTIVITY_HOST}:{REDIS_ACTIVITY_PORT}/{REDIS_ACTIVITY_DB_INDEX}",
"LOCATION": REDIS_ACTIVITY_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
@ -252,7 +259,7 @@ DATABASES = {
"USER": env("POSTGRES_USER", "bookwyrm"),
"PASSWORD": env("POSTGRES_PASSWORD", "bookwyrm"),
"HOST": env("POSTGRES_HOST", ""),
"PORT": env("PGPORT", 5432),
"PORT": env.int("PGPORT", 5432),
},
}
@ -287,6 +294,7 @@ LANGUAGES = [
("en-us", _("English")),
("ca-es", _("Català (Catalan)")),
("de-de", _("Deutsch (German)")),
("eo-uy", _("Esperanto (Esperanto)")),
("es-es", _("Español (Spanish)")),
("eu-es", _("Euskara (Basque)")),
("gl-es", _("Galego (Galician)")),
@ -304,6 +312,9 @@ LANGUAGES = [
("zh-hant", _("繁體中文 (Traditional Chinese)")),
]
LANGUAGE_ARTICLES = {
"English": {"the", "a", "an"},
}
TIME_ZONE = "UTC"
@ -326,14 +337,18 @@ IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY = "bookwyrm.thumbnail_generation.Strategy"
# https://docs.djangoproject.com/en/3.2/howto/static-files/
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
CSP_ADDITIONAL_HOSTS = env.list("CSP_ADDITIONAL_HOSTS", [])
# Storage
PROTOCOL = "http"
if USE_HTTPS:
PROTOCOL = "https"
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
USE_S3 = env.bool("USE_S3", False)
USE_AZURE = env.bool("USE_AZURE", False)
if USE_S3:
# AWS settings
@ -355,18 +370,53 @@ if USE_S3:
MEDIA_FULL_URL = MEDIA_URL
STATIC_FULL_URL = STATIC_URL
DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.ImagesStorage"
CSP_DEFAULT_SRC = ["'self'", AWS_S3_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS
CSP_SCRIPT_SRC = ["'self'", AWS_S3_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS
elif USE_AZURE:
AZURE_ACCOUNT_NAME = env("AZURE_ACCOUNT_NAME")
AZURE_ACCOUNT_KEY = env("AZURE_ACCOUNT_KEY")
AZURE_CONTAINER = env("AZURE_CONTAINER")
AZURE_CUSTOM_DOMAIN = env("AZURE_CUSTOM_DOMAIN")
# Azure Static settings
STATIC_LOCATION = "static"
STATIC_URL = (
f"{PROTOCOL}://{AZURE_CUSTOM_DOMAIN}/{AZURE_CONTAINER}/{STATIC_LOCATION}/"
)
STATICFILES_STORAGE = "bookwyrm.storage_backends.AzureStaticStorage"
# Azure Media settings
MEDIA_LOCATION = "images"
MEDIA_URL = (
f"{PROTOCOL}://{AZURE_CUSTOM_DOMAIN}/{AZURE_CONTAINER}/{MEDIA_LOCATION}/"
)
MEDIA_FULL_URL = MEDIA_URL
STATIC_FULL_URL = STATIC_URL
DEFAULT_FILE_STORAGE = "bookwyrm.storage_backends.AzureImagesStorage"
CSP_DEFAULT_SRC = ["'self'", AZURE_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS
CSP_SCRIPT_SRC = ["'self'", AZURE_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS
else:
STATIC_URL = "/static/"
MEDIA_URL = "/images/"
MEDIA_FULL_URL = f"{PROTOCOL}://{DOMAIN}{MEDIA_URL}"
STATIC_FULL_URL = f"{PROTOCOL}://{DOMAIN}{STATIC_URL}"
CSP_DEFAULT_SRC = ["'self'"] + CSP_ADDITIONAL_HOSTS
CSP_SCRIPT_SRC = ["'self'"] + CSP_ADDITIONAL_HOSTS
CSP_INCLUDE_NONCE_IN = ["script-src"]
OTEL_EXPORTER_OTLP_ENDPOINT = env("OTEL_EXPORTER_OTLP_ENDPOINT", None)
OTEL_EXPORTER_OTLP_HEADERS = env("OTEL_EXPORTER_OTLP_HEADERS", None)
OTEL_SERVICE_NAME = env("OTEL_SERVICE_NAME", None)
OTEL_EXPORTER_CONSOLE = env.bool("OTEL_EXPORTER_CONSOLE", False)
TWO_FACTOR_LOGIN_MAX_SECONDS = 60
TWO_FACTOR_LOGIN_MAX_SECONDS = env.int("TWO_FACTOR_LOGIN_MAX_SECONDS", 60)
TWO_FACTOR_LOGIN_VALIDITY_WINDOW = env.int("TWO_FACTOR_LOGIN_VALIDITY_WINDOW", 2)
HTTP_X_FORWARDED_PROTO = env.bool("SECURE_PROXY_SSL_HEADER", False)
if HTTP_X_FORWARDED_PROTO:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Instance Actor for signing GET requests to "secure mode"
# Mastodon servers.
# Do not change this setting unless you already have an existing
# user with the same username - in which case you should change it!
INSTANCE_ACTOR_USERNAME = "bookwyrm.instance.actor"

View file

@ -15,29 +15,40 @@ MAX_SIGNATURE_AGE = 300
def create_key_pair():
"""a new public/private key pair, used for creating new users"""
random_generator = Random.new().read
key = RSA.generate(1024, random_generator)
key = RSA.generate(2048, random_generator)
private_key = key.export_key().decode("utf8")
public_key = key.public_key().export_key().decode("utf8")
return private_key, public_key
def make_signature(sender, destination, date, digest):
def make_signature(method, sender, destination, date, **kwargs):
"""uses a private key to sign an outgoing message"""
inbox_parts = urlparse(destination)
signature_headers = [
f"(request-target): post {inbox_parts.path}",
f"(request-target): {method} {inbox_parts.path}",
f"host: {inbox_parts.netloc}",
f"date: {date}",
f"digest: {digest}",
]
headers = "(request-target) host date"
digest = kwargs.get("digest")
if digest is not None:
signature_headers.append(f"digest: {digest}")
headers = "(request-target) host date digest"
message_to_sign = "\n".join(signature_headers)
signer = pkcs1_15.new(RSA.import_key(sender.key_pair.private_key))
signed_message = signer.sign(SHA256.new(message_to_sign.encode("utf8")))
# For legacy reasons we need to use an incorrect keyId for older Bookwyrm versions
key_id = (
f"{sender.remote_id}#main-key"
if kwargs.get("use_legacy_key")
else f"{sender.remote_id}/#main-key"
)
signature = {
"keyId": f"{sender.remote_id}#main-key",
"keyId": key_id,
"algorithm": "rsa-sha256",
"headers": "(request-target) host date digest",
"headers": headers,
"signature": b64encode(signed_message).decode("utf8"),
}
return ",".join(f'{k}="{v}"' for (k, v) in signature.items())

View file

@ -5,7 +5,7 @@
* - .book-cover is positioned and sized based on its container.
*
* To have the cover within specific dimensions, specify a width or height for
* standard bulmas named breapoints:
* standard bulmas named breakpoints:
*
* `is-(w|h)-(auto|xs|s|m|l|xl|xxl)[-(mobile|tablet|desktop)]`
*
@ -43,7 +43,7 @@
max-height: 100%;
/* Useful when stretching under-sized images. */
image-rendering: optimizequality;
image-rendering: optimizeQuality;
image-rendering: smooth;
}

View file

@ -5,6 +5,10 @@
white-space: nowrap;
}
.stars .no-rating {
font-style: italic;
}
/** Stars in a review form
*
* Specificity makes hovering taking over checked inputs.

View file

@ -44,12 +44,12 @@
.bw-tabs a:hover {
border-bottom-color: transparent;
color: $text;
color: $text
}
.bw-tabs a.is-active {
border-bottom-color: transparent;
color: $link;
color: $link
}
.bw-tabs.is-left {

View file

@ -40,7 +40,7 @@
}
.navbar-item {
// see ../components/_details.scss :: Navbar details
/* see ../components/_details.scss :: Navbar details */
padding-right: 1.75rem;
font-size: 1rem;
}
@ -109,3 +109,9 @@
max-height: 35em;
overflow: hidden;
}
.dropdown-menu .button {
@include mobile {
font-size: $size-6;
}
}

View file

@ -40,6 +40,10 @@
width: 500px !important;
}
.is-h-em {
height: 1em !important;
}
.is-h-xs {
height: 80px !important;
}

View file

@ -98,6 +98,22 @@ $family-secondary: $family-sans-serif;
}
.tabs li:not(.is-active) a {
color: #2e7eb9 !important;
}
.tabs li:not(.is-active) a:hover {
border-bottom-color: #2e7eb9 !important;
}
.tabs li:not(.is-active) a {
color: #2e7eb9 !important;
}
.tabs li.is-active a {
color: #e6e6e6 !important;
border-bottom-color: #e6e6e6 !important ;
}
#qrcode svg {
background-color: #a6a6a6;
}

View file

@ -65,6 +65,22 @@ $family-secondary: $family-sans-serif;
color: $grey !important;
}
.tabs li:not(.is-active) a {
color: #3273dc !important;
}
.tabs li:not(.is-active) a:hover {
border-bottom-color: #3273dc !important;
}
.tabs li:not(.is-active) a {
color: #3273dc !important;
}
.tabs li.is-active a {
color: #4a4a4a !important;
border-bottom-color: #4a4a4a !important ;
}
@import "../bookwyrm.scss";
@import "../vendor/icons.css";
@import "../vendor/shepherd.scss";

View file

@ -6,16 +6,16 @@
@use 'bulma/bulma.sass';
.shepherd-button {
@extend .button.mr-2;
@extend .button, .mr-2;
}
.shepherd-button.shepherd-button-secondary {
@extend .button.is-light;
@extend .button, .is-light;
}
.shepherd-footer {
@extend .message-body;
@extend .is-info.is-light;
@extend .is-info, .is-light;
border-color: $info-light;
border-radius: 0 0 4px 4px;
}
@ -29,7 +29,7 @@
.shepherd-text {
@extend .message-body;
@extend .is-info.is-light;
@extend .is-info, .is-light;
border-radius: 0;
}

View file

@ -5,7 +5,7 @@ let BookWyrm = new (class {
constructor() {
this.MAX_FILE_SIZE_BYTES = 10 * 1000000;
this.initOnDOMLoaded();
this.initReccuringTasks();
this.initRecurringTasks();
this.initEventListeners();
}
@ -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
@ -77,7 +74,7 @@ let BookWyrm = new (class {
/**
* Execute recurring tasks.
*/
initReccuringTasks() {
initRecurringTasks() {
// Polling
document.querySelectorAll("[data-poll]").forEach((liveArea) => this.polling(liveArea));
}
@ -95,7 +92,6 @@ let BookWyrm = new (class {
/**
* Update a counter with recurring requests to the API
* The delay is slightly randomized and increased on each cycle.
*
* @param {Object} counter - DOM node
* @param {int} delay - frequency for polling in ms
@ -104,16 +100,19 @@ let BookWyrm = new (class {
polling(counter, delay) {
const bookwyrm = this;
delay = delay || 10000;
delay += Math.random() * 1000;
delay = delay || 5 * 60 * 1000 + (Math.random() - 0.5) * 30 * 1000;
setTimeout(
function () {
fetch("/api/updates/" + counter.dataset.poll)
.then((response) => response.json())
.then((data) => bookwyrm.updateCountElement(counter, data));
bookwyrm.polling(counter, delay * 1.25);
.then((data) => {
bookwyrm.updateCountElement(counter, data);
bookwyrm.polling(counter);
})
.catch(() => {
bookwyrm.polling(counter, delay * 1.1);
});
},
delay,
counter

View file

@ -2,7 +2,7 @@
"use strict";
/**
* Remoev input field
* Remove input field
*
* @param {event} the button click event
*/

View file

@ -2,6 +2,7 @@
import os
from tempfile import SpooledTemporaryFile
from storages.backends.s3boto3 import S3Boto3Storage
from storages.backends.azure_storage import AzureStorage
class StaticStorage(S3Boto3Storage): # pylint: disable=abstract-method
@ -47,3 +48,16 @@ class ImagesStorage(S3Boto3Storage): # pylint: disable=abstract-method
# Upload the object which will auto close the
# content_autoclose instance
return super()._save(name, content_autoclose)
class AzureStaticStorage(AzureStorage): # pylint: disable=abstract-method
"""Storage class for Static contents"""
location = "static"
class AzureImagesStorage(AzureStorage): # pylint: disable=abstract-method
"""Storage class for Image files"""
location = "images"
overwrite_files = False

View file

@ -4,13 +4,16 @@ import logging
from django.dispatch import receiver
from django.db import transaction
from django.db.models import signals, Count, Q, Case, When, IntegerField
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
logger = logging.getLogger(__name__)
tracer = open_telemetry.tracer()
class SuggestedUsers(RedisStore):
@ -49,30 +52,34 @@ class SuggestedUsers(RedisStore):
)
def get_stores_for_object(self, obj):
"""the stores that an object belongs in"""
return [self.store_id(u) for u in self.get_users_for_object(obj)]
def get_users_for_object(self, obj): # pylint: disable=no-self-use
"""given a user, who might want to follow them"""
return models.User.objects.filter(local=True,).exclude(
return models.User.objects.filter(local=True, is_active=True).exclude(
Q(id=obj.id) | Q(followers=obj) | Q(id__in=obj.blocks.all()) | Q(blocks=obj)
)
@tracer.start_as_current_span("SuggestedUsers.rerank_obj")
def rerank_obj(self, obj, update_only=True):
"""update all the instances of this user with new ranks"""
trace.get_current_span().set_attribute("update_only", update_only)
pipeline = r.pipeline()
for store_user in self.get_users_for_object(obj):
annotated_user = get_annotated_users(
store_user,
id=obj.id,
).first()
if not annotated_user:
continue
with tracer.start_as_current_span("SuggestedUsers.rerank_obj/user") as _:
annotated_user = get_annotated_users(
store_user,
id=obj.id,
).first()
if not annotated_user:
continue
pipeline.zadd(
self.store_id(store_user),
self.get_value(annotated_user),
xx=update_only,
)
pipeline.zadd(
self.store_id(store_user),
self.get_value(annotated_user),
xx=update_only,
)
pipeline.execute()
def rerank_user_suggestions(self, user):
@ -237,41 +244,45 @@ 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)
suggested_users.remove_object_from_related_stores(user)
suggested_users.remove_object_from_stores(
user, suggested_users.get_stores_for_object(user)
)
@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):
suggested_users.remove_object_from_related_stores(user)
suggested_users.remove_object_from_stores(
user, suggested_users.get_stores_for_object(user)
)
@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,9 +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"
IMPORT_TRIGGERED = "import_triggered"
BROADCAST = "broadcast"
MISC = "misc"

View file

@ -1,22 +1,41 @@
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.trace import TracerProvider, Tracer
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
from bookwyrm import settings
trace.set_tracer_provider(TracerProvider())
trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
if settings.OTEL_EXPORTER_CONSOLE:
trace.get_tracer_provider().add_span_processor(
BatchSpanProcessor(ConsoleSpanExporter())
)
elif settings.OTEL_EXPORTER_OTLP_ENDPOINT:
trace.get_tracer_provider().add_span_processor(
BatchSpanProcessor(OTLPSpanExporter())
)
def instrumentDjango():
def instrumentDjango() -> None:
from opentelemetry.instrumentation.django import DjangoInstrumentor
DjangoInstrumentor().instrument()
def instrumentCelery():
def instrumentPostgres() -> None:
from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor
Psycopg2Instrumentor().instrument()
def instrumentCelery() -> None:
from opentelemetry.instrumentation.celery import CeleryInstrumentor
from celery.signals import worker_process_init
@worker_process_init.connect(weak=False)
def init_celery_tracing(*args, **kwargs):
CeleryInstrumentor().instrument()
def tracer() -> Tracer:
return trace.get_tracer(__name__)

View file

@ -28,7 +28,7 @@
<meta itemprop="name" content="{{ author.name }}">
{% firstof author.aliases author.born author.died as details %}
{% firstof author.wikipedia_link author.openlibrary_key author.inventaire_id author.isni author.isfdb as links %}
{% firstof author.wikipedia_link author.website author.openlibrary_key author.inventaire_id author.isni author.isfdb as links %}
{% if details or links %}
<div class="column is-3">
{% if details %}
@ -73,6 +73,14 @@
</div>
{% endif %}
{% if author.website %}
<div>
<a itemprop="sameAs" href="{{ author.website }}" rel="nofollow noopener noreferrer" target="_blank">
{% trans "Website" %}
</a>
</div>
{% endif %}
{% if author.isni %}
<div class="mt-1">
<a itemprop="sameAs" href="{{ author.isni_link }}" rel="nofollow noopener noreferrer" target="_blank">

View file

@ -57,6 +57,10 @@
{% include 'snippets/form_errors.html' with errors_list=form.wikipedia_link.errors id="desc_wikipedia_link" %}
<p class="field"><label class="label" for="id_website">{% trans "Website:" %}</label> {{ form.website }}</p>
{% include 'snippets/form_errors.html' with errors_list=form.website.errors id="desc_website" %}
<div class="field">
<label class="label" for="id_born">{% trans "Birth date:" %}</label>
<input type="date" name="born" value="{{ form.born.value|date:'Y-m-d' }}" class="input" id="id_born">
@ -77,7 +81,7 @@
<label class="label" for="id_openlibrary_key">{% trans "Openlibrary key:" %}</label>
{{ form.openlibrary_key }}
{% include 'snippets/form_errors.html' with errors_list=form.oepnlibrary_key.errors id="desc_oepnlibrary_key" %}
{% include 'snippets/form_errors.html' with errors_list=form.openlibrary_key.errors id="desc_openlibrary_key" %}
</div>
<div class="field">

View file

@ -4,11 +4,12 @@
{% load humanize %}
{% load utilities %}
{% load static %}
{% load shelf_tags %}
{% block title %}{{ book|book_title }}{% endblock %}
{% block opengraph_images %}
{% include 'snippets/opengraph_images.html' with image=book.preview_image %}
{% block opengraph %}
{% include 'snippets/opengraph.html' with title=book.title description=book|book_description image=book.preview_image %}
{% endblock %}
{% block content %}
@ -46,7 +47,13 @@
<meta itemprop="isPartOf" content="{{ book.series | escape }}">
<meta itemprop="volumeNumber" content="{{ book.series_number }}">
({{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %})
{% if book.authors.exists %}
<a href="{% url 'book-series-by' book.authors.first.id %}?series_name={{ book.series }}">
{% endif %}
{{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %}
{% if book.authors.exists %}
</a>
{% endif %}
{% endif %}
</p>
{% endif %}
@ -82,6 +89,8 @@
src="{% static "images/no_cover.jpg" %}"
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
>
<span class="cover-caption">
<span>{{ book.alt_text }}</span>
@ -181,13 +190,15 @@
<meta itemprop="bestRating" content="5">
<meta itemprop="reviewCount" content="{{ review_count }}">
{% include 'snippets/stars.html' with rating=rating %}
<span>
{% include 'snippets/stars.html' with rating=rating %}
{% blocktrans count counter=review_count trimmed %}
({{ review_count }} review)
{% plural %}
({{ review_count }} reviews)
{% endblocktrans %}
{% blocktrans count counter=review_count trimmed %}
({{ review_count }} review)
{% plural %}
({{ review_count }} reviews)
{% endblocktrans %}
</span>
</div>
{% with full=book|book_description itemprop='abstract' %}
@ -215,10 +226,10 @@
{% endif %}
{% with work=book.parent_work %}
{% with work=book.parent_work editions_count=book.parent_work.editions.count %}
<p>
<a href="{{ work.local_path }}/editions" id="tour-other-editions-link">
{% blocktrans trimmed count counter=work.editions.count with count=work.editions.count|intcomma %}
{% blocktrans trimmed count counter=editions_count with count=editions_count|intcomma %}
{{ count }} edition
{% plural %}
{{ count }} editions
@ -237,7 +248,7 @@
<ul>
{% for shelf in user_shelfbooks %}
<li class="box">
<a href="{{ shelf.shelf.local_path }}">{{ shelf.shelf.name }}</a>
<a href="{{ shelf.shelf.local_path }}">{{ shelf.shelf|translate_shelf_name }}</a>
<div class="is-pulled-right">
{% include 'snippets/shelf_selector.html' with shelf=shelf.shelf class="is-small" readthrough=readthrough %}
</div>
@ -247,7 +258,7 @@
{% endif %}
{% for shelf in other_edition_shelves %}
<p>
{% blocktrans with book_path=shelf.book.local_path shelf_path=shelf.shelf.local_path shelf_name=shelf.shelf.name %}A <a href="{{ book_path }}">different edition</a> of this book is on your <a href="{{ shelf_path }}">{{ shelf_name }}</a> shelf.{% endblocktrans %}
{% blocktrans with book_path=shelf.book.local_path shelf_path=shelf.shelf.local_path shelf_name=shelf.shelf|translate_shelf_name %}A <a href="{{ book_path }}">different edition</a> of this book is on your <a href="{{ shelf_path }}">{{ shelf_name }}</a> shelf.{% endblocktrans %}
{% include 'snippets/switch_edition_button.html' with edition=book %}
</p>
{% endfor %}

View file

@ -5,7 +5,7 @@
<div class="modal-background" data-modal-close></div><!-- modal background -->
<div class="modal-card is-align-items-center" role="dialog" aria-modal="true" tabindex="-1" aria-label="{% trans 'Book cover preview' %}">
<div class="cover-container">
<img class="book-cover" src="{% get_media_prefix %}{{ book.cover }}" itemprop="thumbnailUrl" alt="">
<img class="book-cover" src="{% get_media_prefix %}{{ book.cover }}" itemprop="thumbnailUrl" alt="" loading="lazy" decoding="async">
</div>
</div>
<button type="button" data-modal-close class="modal-close is-large" aria-label="{% trans 'Close' %}"></button>

View file

@ -37,6 +37,14 @@
{% endif %}
</header>
{% if form.errors %}
<div class="block">
<p class="notification is-danger is-light">
{% trans "Failed to save book, see errors below for more information." %}
</p>
</div>
{% endif %}
<form
class="block"
{% if book.id %}

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

@ -4,7 +4,7 @@
{% block filter %}
<div class="control">
<label class="label" for="id_search">{% trans "Search editions" %}</label>
<input type="text" class="input" name="q" value="{{ request.GET.q|default:'' }}" id="id_search">
<input type="text" class="input" name="q" value="{{ request.GET.q|default:'' }}" id="id_search" spellcheck="false">
</div>
{% endblock %}

View file

@ -0,0 +1,35 @@
{% extends 'layout.html' %}
{% load i18n %}
{% load book_display_tags %}
{% block title %}{{ series_name }}{% endblock %}
{% block content %}
<div class="block">
<h1 class="title">{{ series_name }}</h1>
<div class="subtitle" dir="auto">
{% trans "Series by" %} <a
href="{{ author.local_path }}"
class="author {{ link_class }}"
itemprop="author"
itemscope
itemtype="https://schema.org/Thing"
><span
itemprop="name"
>{{ author.name }}</span></a>
</div>
<div class="columns is-multiline is-mobile">
{% for book in books %}
{% with book=book %}
<div class="column is-one-fifth-tablet is-half-mobile is-flex is-flex-direction-column">
<div class="is-flex-grow-1 mb-3">
<span class="subtitle">{% if book.series_number %}{% blocktrans with series_number=book.series_number %}Book {{ series_number }}{% endblocktrans %}{% else %}{% trans 'Unsorted Book' %}{% endif %}</span>
{% include 'landing/small-book.html' with book=book %}
</div>
</div>
{% endwith %}
{% endfor %}
</div>
</div>
{% endblock %}

View file

@ -9,12 +9,12 @@
<a href="{{ user_path}}">{{ username }}</a> wants to read <a href="{{ book_path }}">{{ book_title }}</a>
{% endblocktrans %}
{% endif %}
{% if finished reading or status.content == '<p>finished reading</p>' %}
{% if status.content == 'finished reading' or status.content == '<p>finished reading</p>' %}
{% blocktrans trimmed %}
<a href="{{ user_path}}">{{ username }}</a> finished reading <a href="{{ book_path }}">{{ book_title }}</a>
{% endblocktrans %}
{% endif %}
{% if started reading or status.content == '<p>started reading</p>' %}
{% if status.content == 'started reading' or status.content == '<p>started reading</p>' %}
{% blocktrans trimmed %}
<a href="{{ user_path}}">{{ username }}</a> started reading <a href="{{ book_path }}">{{ book_title }}</a>
{% endblocktrans %}

View file

@ -46,7 +46,7 @@
</div>
<div class="notification has-background-body p-2 mb-2 clip-text">
{% include "snippets/status/content_status.html" with hide_book=True trim_length=70 hide_more=True %}
{% include "snippets/status/content_status.html" with hide_book=True trim_length=70 hide_more=True expand=False %}
</div>
<a href="{{ status.remote_id }}">
<span>{% trans "View status" %}</span>

View file

@ -2,7 +2,7 @@
<div style="font-family: BlinkMacSystemFont,-apple-system,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Fira Sans','Droid Sans','Helvetica Neue',Helvetica,Arial,sans-serif; border-radius: 6px; background-color: #efefef; max-width: 632px;">
<div style="padding: 1rem; overflow: auto;">
<div style="float: left; margin-right: 1rem;">
<a style="color: #3273dc;" href="https://{{ domain }}" style="text-decoration: none;"><img src="{{ logo }}" alt="logo"></a>
<a style="color: #3273dc;" href="https://{{ domain }}" style="text-decoration: none;"><img src="{{ logo }}" alt="logo" loading="lazy" decoding="async"></a>
</div>
<div>
<a style="color: black; text-decoration: none" href="https://{{ domain }}" style="text-decoration: none;"><strong>{{ site_name }}</strong><br>

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